import { issue } from './issue'; import { SiumAsyncSchemaError, SiumValidationError } from '../errors'; import type { Ctx, MetaAnnotations, RefineFailure, 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, 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( check: (value: T, ctx: Ctx) => boolean | Promise, fail: RefineFailure, opts?: StepOptions ): Step { const async = opts?.async ?? isAsyncFunction(check); return { kind: 'refine', async, meta: {}, decode: async (input, ctx) => { const result = await check(input, ctx); if (result) { return input; } throw new SiumValidationError([createRefineIssue(ctx.path, fail)]); }, decodeSync: (input, ctx) => { if (async) { throw createAsyncStepError(); } const result = check(input, ctx); if (isPromiseLike(result)) { throw createAsyncStepError(); } if (result) { return input; } throw new SiumValidationError([createRefineIssue(ctx.path, fail)]); } }; } /** * 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( fn: (value: I, ctx: Ctx) => O | Promise, opts?: StepOptions ): Step { 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( decode: (value: I, ctx: Ctx) => O | Promise, encode: (value: O) => I, opts?: StepOptions ): Step { 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(annotations: MetaAnnotations): Step { const copiedMeta = { ...annotations }; return { kind: 'meta', async: false, meta: copiedMeta, decode: async (input) => input, decodeSync: (input) => input }; }