From c4eafd02f2a6802bb1e9b758a82c07b1a29bcd89 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 14:03:03 +0200 Subject: [PATCH] Allow sync sentinel short-circuits in Sium --- continue.md | 12 +++--- src/arts/sium/README.md | 9 ++++- src/arts/sium/core/modifiers.ts | 45 ++++++++++++++++------ src/arts/sium/core/schema.ts | 5 +++ src/arts/sium/core/types.ts | 4 +- src/arts/sium/test/modifiers.test.ts | 57 ++++++++++++++++++++++++---- 6 files changed, 107 insertions(+), 25 deletions(-) diff --git a/continue.md b/continue.md index 63fcdce90..1791e6bf7 100644 --- a/continue.md +++ b/continue.md @@ -104,11 +104,13 @@ Actualizacion 2026-05-16: 5. Validado: `npx vitest run src/arts/sium/test` -> 18 archivos, 366 tests OK; `npm run check` -> 0 errores, 0 warnings; `npm run test` -> 233 archivos, 2262 tests OK. - 6. Pendiente deliberado: revisar `optional/nullable/defaulted` con schema interno async. El problema - es real, pero la solucion no es mover un `if`: `createSchema.decodeSync` - lanza por `spec.async === true` antes de entrar en `decodeImpl`. Hace falta - una decision de runtime (`syncShortCircuit`, decodeSync custom por wrapper, - o semantica documentada de "schema async siempre async"). + 6. Cerrado: `optional/nullable/defaulted` con inner async conservan el + short-circuit sync del sentinel (`undefined`, `null` o default). Si el + valor definido necesita entrar al inner async, `decodeSync` / + `validateSync` siguen lanzando `SiumAsyncSchemaError`. + 7. Validado tras wrappers async: `npx vitest run src/arts/sium/test` -> 18 + archivos, 368 tests OK; `npm run check` -> 0 errores, 0 warnings; + `npm run test` -> 233 archivos, 2264 tests OK. - Sium P1 coordinado con consumidores: 1. No cambiar `lazy()` a `kind: 'lazy'` hasta que `src/uix/soma/components/form/components/form-auto-fields.svelte` sepa diff --git a/src/arts/sium/README.md b/src/arts/sium/README.md index ee2e4a402..76c2be483 100644 --- a/src/arts/sium/README.md +++ b/src/arts/sium/README.md @@ -252,6 +252,11 @@ defaulted(number(), () => 18); `defaulted` aplica el valor por defecto en decode cuando el input es `undefined`. +Si el schema interno es async, los wrappers conservan el short-circuit sincronico del sentinel: +`optional(asyncSchema).decodeSync(undefined)`, `nullable(asyncSchema).decodeSync(null)` y +`defaulted(asyncSchema).decodeSync(undefined)` no entran al schema interno. Cualquier valor que +si necesite validar el inner async lanza `SiumAsyncSchemaError` en APIs sync. + ### Combinadores ```ts @@ -730,7 +735,9 @@ if (error instanceof SiumValidationError) { ### `SiumAsyncSchemaError` -Lanzado por `decodeSync` o `validateSync` cuando el schema contiene steps async. +Lanzado por `decodeSync` o `validateSync` cuando la ruta de validacion necesita steps async. +Los wrappers de sentinel pueden devolver sincronamente si no consultan el inner async +(`optional(undefined)`, `nullable(null)`, `defaulted(undefined)`). Solucion: usa `await schema.decode(...)` o `await schema.validate(...)`. diff --git a/src/arts/sium/core/modifiers.ts b/src/arts/sium/core/modifiers.ts index b49edde0d..11a956526 100644 --- a/src/arts/sium/core/modifiers.ts +++ b/src/arts/sium/core/modifiers.ts @@ -8,9 +8,9 @@ import type { Schema } from './types'; * - `encode(undefined)` returns `undefined` directly. * - `encode(value)` delegates to the inner schema. * - * Sync APIs inherit the async contract of the wrapped schema. If the inner - * schema is async, `decodeSync` / `validateSync` throw `SiumAsyncSchemaError` - * even when the input is `undefined` and could be short-circuited. + * Sync APIs preserve the sentinel short-circuit even when the inner schema is + * async. `decodeSync(undefined)` returns `undefined`; defined values delegate + * to the inner schema and throw `SiumAsyncSchemaError` if that path is async. */ export function optional(schema: Schema): Schema { const innerIsAsync = schema['~sium'].async; @@ -32,6 +32,13 @@ export function optional(schema: Schema): Schema { + if (input === undefined) { + return undefined; + } + + return schema.decodeSync(input, ctx); + }, encode: (value) => { if (value === undefined) { return undefined; @@ -49,9 +56,9 @@ export function optional(schema: Schema): Schema(schema: Schema): Schema { const innerIsAsync = schema['~sium'].async; @@ -73,6 +80,13 @@ export function nullable(schema: Schema): Schema return innerIsAsync ? schema.decode(input, ctx) : schema.decodeSync(input, ctx); }, + decodeSyncImpl: (input, ctx) => { + if (input === null) { + return null; + } + + return schema.decodeSync(input, ctx); + }, encode: (value) => { if (value === null) { return null; @@ -92,12 +106,14 @@ export function nullable(schema: Schema): Schema * - `encode(value)` delegates directly to the inner schema and never * re-injects the default. * - * Sync APIs inherit the async contract of the wrapped schema. If the inner - * schema is async, `decodeSync` / `validateSync` throw `SiumAsyncSchemaError` - * even when the input is `undefined` and the default could be returned - * without consulting the inner decoder. + * Sync APIs preserve the default short-circuit even when the inner schema is + * async. `decodeSync(undefined)` returns `getDefault()`; defined values delegate + * to the inner schema and throw `SiumAsyncSchemaError` if that path is async. */ -export function defaulted(schema: Schema, getDefault: () => O): Schema { +export function defaulted( + schema: Schema, + getDefault: () => O +): Schema { const innerIsAsync = schema['~sium'].async; return createSchema({ @@ -118,6 +134,13 @@ export function defaulted(schema: Schema, getDefault: () => O): Sche return innerIsAsync ? schema.decode(input, ctx) : schema.decodeSync(input, ctx); }, + decodeSyncImpl: (input, ctx) => { + if (input === undefined) { + return getDefault(); + } + + return schema.decodeSync(input, ctx); + }, encode: (value) => schema.encode(value) }); } diff --git a/src/arts/sium/core/schema.ts b/src/arts/sium/core/schema.ts index cacb45827..a920183cc 100644 --- a/src/arts/sium/core/schema.ts +++ b/src/arts/sium/core/schema.ts @@ -17,6 +17,7 @@ import type { type CreateSchemaSpec = { kind: SchemaKind; decodeImpl: (input: I, ctx: Ctx) => O | Promise; + decodeSyncImpl?: (input: I, ctx: Ctx) => O; encode: (value: O) => I; wrappers?: ReadonlyArray; effects?: ReadonlyArray; @@ -65,6 +66,10 @@ export function createSchema(spec: CreateSchemaSpec): Schema { }; const decodeSync = (input: I, ctx?: Partial): O => { + if (spec.decodeSyncImpl) { + return spec.decodeSyncImpl(input, normalizeCtx(input, ctx)); + } + if (spec.async === true) { throw new SiumAsyncSchemaError(spec.kind); } diff --git a/src/arts/sium/core/types.ts b/src/arts/sium/core/types.ts index a2e22ed3e..d83f087ee 100644 --- a/src/arts/sium/core/types.ts +++ b/src/arts/sium/core/types.ts @@ -14,7 +14,9 @@ * reports `kind='color'`, `wrappers=['optional']`, `effects=[]` so UI * generators can peel the wrappers and resolve the semantic type. * 3. Async is the default API surface. `decodeSync` / `validateSync` throw - * `SiumAsyncSchemaError` if the schema contains async refinements. + * `SiumAsyncSchemaError` when the requested path needs async work. Wrapper + * sentinels (`optional(undefined)`, `nullable(null)`, `defaulted(undefined)`) + * may short-circuit synchronously even if their inner schema is async. * 4. Errors are structured data: `{ path, code, message, params }`. * `message` uses the framework idlangref format * (`#?sium.errors.X|fallback`) so the adapter can resolve or fall back diff --git a/src/arts/sium/test/modifiers.test.ts b/src/arts/sium/test/modifiers.test.ts index 9fc4b310b..eebceb14e 100644 --- a/src/arts/sium/test/modifiers.test.ts +++ b/src/arts/sium/test/modifiers.test.ts @@ -171,7 +171,7 @@ describe('wrapper stacking and async inheritance', () => { expect(schema['~sium'].wrappers).toEqual(['nullable', 'optional']); }); - it('throws SiumAsyncSchemaError for optional(asyncSchema).decodeSync(undefined)', () => { + it('short-circuits optional(asyncSchema).decodeSync(undefined)', () => { const decodeImpl = vi.fn(async (input: string) => input); const asyncSchema = createSchema({ kind: 'string', @@ -180,11 +180,16 @@ describe('wrapper stacking and async inheritance', () => { encode: (value: string) => value }); - expect(() => optional(asyncSchema).decodeSync(undefined)).toThrow(SiumAsyncSchemaError); + expect(optional(asyncSchema).decodeSync(undefined)).toBeUndefined(); + expect(optional(asyncSchema).validateSync(undefined)).toEqual({ + ok: true, + value: undefined + }); + expect(() => optional(asyncSchema).decodeSync('x')).toThrow(SiumAsyncSchemaError); expect(decodeImpl).not.toHaveBeenCalled(); }); - it('throws SiumAsyncSchemaError for defaulted(asyncSchema).decodeSync(undefined)', () => { + it('short-circuits nullable(asyncSchema).decodeSync(null)', () => { const decodeImpl = vi.fn(async (input: string) => input); const asyncSchema = createSchema({ kind: 'string', @@ -193,9 +198,48 @@ describe('wrapper stacking and async inheritance', () => { encode: (value: string) => value }); - expect(() => defaulted(asyncSchema, () => 'fallback').decodeSync(undefined)).toThrow( - SiumAsyncSchemaError - ); + expect(nullable(asyncSchema).decodeSync(null)).toBeNull(); + expect(nullable(asyncSchema).validateSync(null)).toEqual({ + ok: true, + value: null + }); + expect(() => nullable(asyncSchema).decodeSync('x')).toThrow(SiumAsyncSchemaError); + expect(decodeImpl).not.toHaveBeenCalled(); + }); + + it('short-circuits nested wrapper sentinels before async inner work', () => { + const decodeImpl = vi.fn(async (input: string) => input); + const asyncSchema = createSchema({ + kind: 'string', + async: true, + decodeImpl, + encode: (value: string) => value + }); + const schema = optional(nullable(asyncSchema)); + + expect(schema.decodeSync(undefined)).toBeUndefined(); + expect(schema.decodeSync(null)).toBeNull(); + expect(() => schema.decodeSync('x')).toThrow(SiumAsyncSchemaError); + expect(decodeImpl).not.toHaveBeenCalled(); + }); + + it('short-circuits defaulted(asyncSchema).decodeSync(undefined)', () => { + const decodeImpl = vi.fn(async (input: string) => input); + const asyncSchema = createSchema({ + kind: 'string', + async: true, + decodeImpl, + encode: (value: string) => value + }); + + const schema = defaulted(asyncSchema, () => 'fallback'); + + expect(schema.decodeSync(undefined)).toBe('fallback'); + expect(schema.validateSync(undefined)).toEqual({ + ok: true, + value: 'fallback' + }); + expect(() => schema.decodeSync('x')).toThrow(SiumAsyncSchemaError); expect(decodeImpl).not.toHaveBeenCalled(); }); @@ -218,4 +262,3 @@ describe('wrapper stacking and async inheritance', () => { expect(schema['~sium'].meta.widgetOptions).toBe(inner['~sium'].meta.widgetOptions); }); }); -