parent
4fa2e7e600
commit
164e6fb9db
@ -0,0 +1,182 @@
|
||||
import { issue } from './issue';
|
||||
import {
|
||||
SiumAsyncSchemaError,
|
||||
SiumValidationError,
|
||||
type Ctx,
|
||||
type MetaAnnotations,
|
||||
type RefineFailure,
|
||||
type Step
|
||||
} from './types';
|
||||
import { isPromiseLike } from './internals';
|
||||
|
||||
type StepOptions = {
|
||||
/**
|
||||
* Mark callbacks as async when they are regular functions that return a
|
||||
* Promise. JavaScript cannot detect that shape before executing the callback.
|
||||
*/
|
||||
async?: boolean;
|
||||
};
|
||||
|
||||
function isAsyncFunction(fn: Function): boolean {
|
||||
return fn.constructor.name === 'AsyncFunction';
|
||||
}
|
||||
|
||||
function createAsyncStepError() {
|
||||
return new SiumAsyncSchemaError(
|
||||
undefined,
|
||||
'Step contains async logic — use decode() instead of decodeSync().'
|
||||
);
|
||||
}
|
||||
|
||||
function createRefineIssue(path: ReadonlyArray<string | number>, fail: RefineFailure) {
|
||||
return issue({
|
||||
path,
|
||||
code: fail.code ?? 'custom',
|
||||
message: fail.message ?? '#?sium.errors.custom|Invalid value',
|
||||
...(fail.params === undefined ? {} : { params: fail.params })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a refinement step that validates the current value without changing it.
|
||||
*
|
||||
* Failed checks emit a single `Issue` built from `fail`, defaulting to
|
||||
* `code='custom'` and `message='#?sium.errors.custom|Invalid value'`.
|
||||
*/
|
||||
export function refine<T>(
|
||||
check: (value: T, ctx: Ctx) => boolean | Promise<boolean>,
|
||||
fail: RefineFailure,
|
||||
opts?: StepOptions
|
||||
): Step<T, T> {
|
||||
const async = opts?.async ?? isAsyncFunction(check);
|
||||
|
||||
return {
|
||||
kind: 'refine',
|
||||
async,
|
||||
meta: {},
|
||||
decode: async (input, ctx) => {
|
||||
try {
|
||||
const result = await check(input, ctx);
|
||||
if (result) {
|
||||
return input;
|
||||
}
|
||||
|
||||
throw new SiumValidationError([createRefineIssue(ctx.path, fail)]);
|
||||
} catch (error) {
|
||||
if (error instanceof SiumValidationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
decodeSync: (input, ctx) => {
|
||||
if (async) {
|
||||
throw createAsyncStepError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = check(input, ctx);
|
||||
if (isPromiseLike(result)) {
|
||||
throw createAsyncStepError();
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return input;
|
||||
}
|
||||
|
||||
throw new SiumValidationError([createRefineIssue(ctx.path, fail)]);
|
||||
} catch (error) {
|
||||
if (error instanceof SiumValidationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a one-way transformation step.
|
||||
*
|
||||
* `transform` affects decode only. It intentionally does not expose `encode`,
|
||||
* so `pipe(...).encode(...)` treats it as identity in reverse composition.
|
||||
*/
|
||||
export function transform<I, O>(
|
||||
fn: (value: I, ctx: Ctx) => O | Promise<O>,
|
||||
opts?: StepOptions
|
||||
): Step<I, O> {
|
||||
const async = opts?.async ?? isAsyncFunction(fn);
|
||||
|
||||
return {
|
||||
kind: 'transform',
|
||||
async,
|
||||
meta: {},
|
||||
decode: async (input, ctx) => await fn(input, ctx),
|
||||
decodeSync: (input, ctx) => {
|
||||
if (async) {
|
||||
throw createAsyncStepError();
|
||||
}
|
||||
|
||||
const result = fn(input, ctx);
|
||||
if (isPromiseLike(result)) {
|
||||
throw createAsyncStepError();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bidirectional step with explicit decode and encode sides.
|
||||
*
|
||||
* Unlike `transform`, `codec` participates in reverse composition during
|
||||
* `pipe(...).encode(...)` by applying the provided `encode` function.
|
||||
*/
|
||||
export function codec<I, O>(
|
||||
decode: (value: I, ctx: Ctx) => O | Promise<O>,
|
||||
encode: (value: O) => I,
|
||||
opts?: StepOptions
|
||||
): Step<I, O> {
|
||||
const async = opts?.async ?? isAsyncFunction(decode);
|
||||
|
||||
return {
|
||||
kind: 'codec',
|
||||
async,
|
||||
meta: {},
|
||||
decode: async (input, ctx) => await decode(input, ctx),
|
||||
decodeSync: (input, ctx) => {
|
||||
if (async) {
|
||||
throw createAsyncStepError();
|
||||
}
|
||||
|
||||
const result = decode(input, ctx);
|
||||
if (isPromiseLike(result)) {
|
||||
throw createAsyncStepError();
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
encode
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pure annotation step that leaves the value untouched.
|
||||
*
|
||||
* `meta` never changes runtime data and never participates in encode; it only
|
||||
* contributes shallow-copied annotations to the final schema introspection.
|
||||
*/
|
||||
export function meta<T>(annotations: MetaAnnotations): Step<T, T> {
|
||||
const copiedMeta = { ...annotations };
|
||||
|
||||
return {
|
||||
kind: 'meta',
|
||||
async: false,
|
||||
meta: copiedMeta,
|
||||
decode: async (input) => input,
|
||||
decodeSync: (input) => input
|
||||
};
|
||||
}
|
||||
Loading…
Reference in new issue