diff --git a/src/arts/README.md b/src/arts/README.md index b829b6aa5..5f588ebef 100644 --- a/src/arts/README.md +++ b/src/arts/README.md @@ -56,12 +56,14 @@ import type { DiagnosticEvent, Diagnostics, Logger } from '$libs/logger'; interfaces or narrowed aliases for individual modules. - The root logger implementation is `EngineLogger` from `$logger`; it extends the shared `Logger` contract from `$libs/logger`. -- Artifact code defines `Diagnostics` with - `createDiagnostics(logger?)` and emits catalogued events for - internal diagnostics. +- Artifact code defines `Diagnostics` in a dedicated + `diagnostics.ts` file — the canonical home for the `DiagnosticCatalog`, + `createDiagnostics(logger?)` and `emitDiagnostic(...)`. + Every artifact that emits diagnostics ships this file. - Diagnostic event names live in the artifact `consts.ts` as - `*_DIAGNOSTIC_EVENTS`. Messages live in `errors.ts` or `consts.ts`, never as - inline strings in runtime logic. + `*_DIAGNOSTIC_EVENTS`. Message strings are **named constants** in `consts.ts` + or `errors.ts` (or co-located in `diagnostics.ts` when only the catalog reads + them) — never inline string literals in the catalog or runtime logic. - `Diagnostics` always exposes `{ logger, emit(event) }`. The `logger` property is the common `Logger`, so modules that need an ad-hoc `info` or `error` still have the full logger without inventing a second interface. diff --git a/src/arts/sium/README.md b/src/arts/sium/README.md index c27fe25b5..b0adc0f5d 100644 --- a/src/arts/sium/README.md +++ b/src/arts/sium/README.md @@ -30,6 +30,21 @@ Core, tipos de dominio, lenguajes, Standard Schema e introspeccion estan activos El adapter `svelte/` todavia no esta portado en este arbol. Por eso la suite recomendada para Sium omite la integracion Svelte hasta que esa capa se migre. +## Novedades 2.0 + +Ampliacion alineada a lo que el ecosistema Active necesita (formularios, storage, http). NO busca paridad con Zod/Valibot. + +- **Refines de formato (string):** `uuid()`, `slug()`, `datetime()` (ISO 8601), `ipv4()`. +- **Refines numericos:** `finite()` (rechaza `Infinity`/`-Infinity`/`NaN`), `positive()` (> 0), `nonnegative()` (>= 0), `multipleOf(n)`. +- **Coercion:** `coerceNumber()`, `coerceBoolean()`, `coerceString()` — hermanos generales de `coerceDate()` para entradas sueltas (inputs HTML, query strings). +- **Standard Schema sincrono:** `schema['~standard'].validate(x)` resuelve de forma SINCRONA cuando el schema no tiene pasos async (devuelve el `Result` directo en vez de `Promise`). SS v1 permite `Result | Promise`; esto elimina el branch async + el workaround `untrack` en los consumidores (`form-core`, `storage`) para schemas sincronos. +- **Transforms de string:** `trim()`, `toLowerCase()`, `toUpperCase()` para normalizar formularios. +- **Modificador `nullish()`:** acepta `undefined` y `null` (= `optional` + `nullable`). +- **Combinadores `tuple()` y `record()`:** tupla heterogenea de longitud fija + diccionario de claves arbitrarias. Amplian el vocabulario `SchemaKind` (`'tuple'`/`'record'`); `Form.AutoFields` los degrada con un aviso "not supported yet". +- **Utilidades de object:** `pick` / `omit` / `partial` / `extend` / `merge` — derivan un `object()` reusando los campos del original, con tipos precisos via `Pick`/`Omit`/`Partial`. +- **API depurada:** `dateValue()` / `timeValue()` ya no aceptan `opts` (eran ignorados). Para validar granularidad/calendario, compon un `refine` sobre el valor. +- 8 nuevos issue codes (`uuid`, `slug`, `datetime`, `ipv4`, `finite`, `positive`, `nonnegative`, `multiple_of`) con traduccion es/en. + ## Importaciones Sium expone una fachada publica y barrels tematicos. @@ -88,11 +103,11 @@ interface Schema { `validate` envuelve `decode` y devuelve `{ ok: true, value }` o `{ ok: false, issues }`. -`createEngineSium({ logger }).validate(schema, input)` hace lo mismo y, si falla, registra un evento `debug` usando `LOGGER_CATEGORY` y `SIUM_ERRORS.VALIDATION_FAILED`, con `issueCount` e `issues`. +`createEngineSium({ logger }).validate(schema, input)` hace lo mismo y, si falla, emite un diagnóstico `SIUM_DIAGNOSTIC_EVENTS.VALIDATION_FAILED` a nivel `debug` (categoría `SIUM_MODULE`), con `issueCount` e `issues`. `encode` invierte la salida hacia la forma de entrada. En transforms de una sola direccion actua como identidad para esa parte; usa `codec()` cuando necesites reversibilidad real. -`~standard` permite usar schemas Sium en cualquier consumidor de Standard Schema v1. +`~standard` permite usar schemas Sium en cualquier consumidor de Standard Schema v1. Los schemas sincronos resuelven de forma sincrona (`validate` devuelve el `Result` directo); solo los que tienen pasos async devuelven `Promise`. `~sium` contiene metadata para UI, debug, docs y herramientas. @@ -110,13 +125,16 @@ Sium cubre cuatro huecos que las librerias externas no resuelven de forma nativa ```txt src/arts/sium/ index.ts fachada publica - engine-sium.ts createEngineSium() - core/ kernel puro TypeScript - types/ tipos de dominio sobre $libs/color y $libs/days - langs/ catalogo de mensajes y fallback de interpolacion - svelte/ adapter Svelte pendiente de port, no presente ahora - examples/ schemas de ejemplo ejecutables/importables - test/ suite Vitest + engine-sium.ts createEngineSium() + engine-resolver.ts, engine-validation.ts + resolucion de mensajes idlangref + runtime de validate/validateSync + consts.ts, errors.ts, diagnostics.ts + SIUM_MODULE + errores tipados (CodeError) + catalogo de logs + core/ kernel puro TypeScript (schemas, combinators, pipe) + types/ tipos de dominio sobre $libs/color y $libs/days + langs/ catalogo de mensajes traducibles + fallback de interpolacion + _examples/ schemas de ejemplo (dev-only, fuera del barril publico) + test/ suite Vitest ``` Reglas de dependencia: @@ -243,6 +261,7 @@ enumOf(['admin', 'user', 'guest'] as const); ```ts optional(string()); nullable(string()); +nullish(string()); defaulted(number(), () => 18); ``` @@ -250,6 +269,8 @@ defaulted(number(), () => 18); `nullable` permite `null`. +`nullish` permite `undefined` y `null` (combina `optional` + `nullable`). + `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: @@ -262,6 +283,8 @@ si necesite validar el inner async lanza `SiumAsyncSchemaError` en APIs sync. ```ts object({ name: string() }); array(string()); +tuple(string(), number(), boolean()); +record(number()); union(string(), number()); discriminated('kind', [ @@ -277,6 +300,22 @@ object({ name: string() }, { unknownKeys: 'strict' }); object({ name: string() }, { unknownKeys: 'passthrough' }); ``` +`tuple(...)` valida posicionalmente y exige la longitud exacta (issue `tuple_length`). `record(schema)` valida un objeto plano de claves arbitrarias cuyos valores comparten `schema`. + +### Utilidades de object + +Derivan un nuevo `object()` a partir de otro, reusando sus campos: + +```ts +pick(User, ['id', 'name']); // solo esos campos +omit(User, ['password']); // todos menos esos +partial(User); // todos los campos opcionales +extend(User, { role: string() }); // anade / sobrescribe campos +merge(Base, Overrides); // combina dos object (gana el segundo) +``` + +Los tipos resultantes se derivan con `Pick` / `Omit` / `Partial` sobre el schema fuente, asi que siguen siendo precisos. Pasar un schema que no sea `object()` lanza `SiumObjectUtilError`. + ### Pipe Y Steps ```ts @@ -316,10 +355,49 @@ regex(/^[a-z0-9-]+$/); email(); url(); integer(); + +// formato (string) +uuid(); +slug(); +datetime(); // ISO 8601 +ipv4(); + +// numericos +finite(); // rechaza Infinity / -Infinity / NaN +positive(); // > 0 +nonnegative(); // >= 0 +multipleOf(0.5); ``` `min` y `max` funcionan con numeros, strings y arrays. Emiten codigos distintos para valor numerico y longitud. +`finite()` cubre el hueco de `number()`, que acepta `Infinity` por diseno. `positive()` no implica `finite()`: compon ambos para rechazar tambien `Infinity`. + +### Coercion + +Para entradas sueltas (inputs HTML, query strings, FormData) que llegan como string y necesitan convertirse a un primitivo: + +```ts +coerceNumber(); // string | number -> number (rechaza '' y NaN; Infinity pasa, compon con finite()) +coerceBoolean(); // string | number | boolean -> boolean ('true/1/yes/on' vs 'false/0/no/off') +coerceString(); // string | number | boolean -> string +``` + +Son los hermanos generales de `coerceDate()`. Cada uno reporta el `kind` de SALIDA (`coerceNumber()` -> `number`), asi que `Form.AutoFields` renderiza el widget correcto. + +### Transforms De String + +Normalizadores que cambian el valor en decode (son `transform`, sin `encode`). Aplica despues de `string()`: + +```ts +pipe(string(), trim()); // recorta espacios +pipe(string(), toLowerCase()); // minusculas +pipe(string(), toUpperCase()); // mayusculas +pipe(string(), trim(), toLowerCase()); // email normalizado +``` + +El `kind` sigue siendo `string`, asi que son transparentes para `Form.AutoFields`. + ## Tipos De Dominio Los tipos de dominio viven en `$sium/types` y envuelven las librerias canonicas `$libs/color` y `$libs/days`. @@ -769,7 +847,7 @@ Es esperado mientras el provider UI no este portado. El core de Sium se valida c ## Verificacion Recomendada ```bash -npx vitest run src/arts/sium/test/barrel.test.ts src/arts/sium/test/engine-sium.test.ts src/arts/sium/test/combinators.test.ts src/arts/sium/test/date.test.ts src/arts/sium/test/time.test.ts src/arts/sium/test/color.test.ts src/arts/sium/test/pipe.test.ts src/arts/sium/test/primitives.test.ts src/arts/sium/test/refines.test.ts src/arts/sium/test/schema.test.ts src/arts/sium/test/standard-schema.test.ts src/arts/sium/test/modifiers.test.ts src/arts/sium/test/issue.test.ts src/arts/sium/test/lazy.test.ts src/arts/sium/test/introspect.test.ts src/arts/sium/test/langs.test.ts src/arts/sium/test/resolver.test.ts +npx vitest run src/arts/sium/test/barrel.test.ts src/arts/sium/test/engine-sium.test.ts src/arts/sium/test/combinators.test.ts src/arts/sium/test/date.test.ts src/arts/sium/test/time.test.ts src/arts/sium/test/color.test.ts src/arts/sium/test/pipe.test.ts src/arts/sium/test/primitives.test.ts src/arts/sium/test/refines.test.ts src/arts/sium/test/schema.test.ts src/arts/sium/test/standard-schema.test.ts src/arts/sium/test/modifiers.test.ts src/arts/sium/test/issue.test.ts src/arts/sium/test/lazy.test.ts src/arts/sium/test/introspect.test.ts src/arts/sium/test/langs.test.ts src/arts/sium/test/resolver.test.ts src/arts/sium/test/coerce.test.ts src/arts/sium/test/refines-2.test.ts src/arts/sium/test/transforms.test.ts src/arts/sium/test/tuple.test.ts src/arts/sium/test/record.test.ts src/arts/sium/test/object-utils.test.ts ``` ```bash diff --git a/src/arts/sium/_examples/booking-form.ts b/src/arts/sium/_examples/booking-form.ts index ebaaf3f7f..befee4f49 100644 --- a/src/arts/sium/_examples/booking-form.ts +++ b/src/arts/sium/_examples/booking-form.ts @@ -15,25 +15,10 @@ import type { DateRange } from '$libs/days'; export const bookingFormSchema = pipe( object({ - range: pipe( - dateRange(), - meta({ label: 'Date range' }) - ), - checkInTime: pipe( - timeValue(), - meta({ label: 'Check-in time' }) - ), - guests: pipe( - number(), - integer(), - min(1), - max(10), - meta({ label: 'Guests' }) - ), - notes: pipe( - optional(string()), - meta({ label: 'Notes' }) - ) + range: pipe(dateRange(), meta({ label: 'Date range' })), + checkInTime: pipe(timeValue(), meta({ label: 'Check-in time' })), + guests: pipe(number(), integer(), min(1), max(10), meta({ label: 'Guests' })), + notes: pipe(optional(string()), meta({ label: 'Notes' })) }), refine( (v) => { diff --git a/src/arts/sium/_examples/color-customizer-form.ts b/src/arts/sium/_examples/color-customizer-form.ts index 6ec979715..fc5654a70 100644 --- a/src/arts/sium/_examples/color-customizer-form.ts +++ b/src/arts/sium/_examples/color-customizer-form.ts @@ -2,24 +2,9 @@ import { object, pipe, meta } from '$sium/core'; import { colorValue, alpha, hue } from '$sium/types'; export const colorCustomizerFormSchema = object({ - primary: pipe( - colorValue(), - meta({ label: 'Primary color' }) - ), - secondary: pipe( - colorValue(), - meta({ label: 'Secondary color' }) - ), - accent: pipe( - colorValue(), - meta({ label: 'Accent color' }) - ), - opacity: pipe( - alpha(), - meta({ label: 'Opacity' }) - ), - customHue: pipe( - hue(), - meta({ label: 'Custom hue' }) - ) + primary: pipe(colorValue(), meta({ label: 'Primary color' })), + secondary: pipe(colorValue(), meta({ label: 'Secondary color' })), + accent: pipe(colorValue(), meta({ label: 'Accent color' })), + opacity: pipe(alpha(), meta({ label: 'Opacity' })), + customHue: pipe(hue(), meta({ label: 'Custom hue' })) }); diff --git a/src/arts/sium/_examples/login-form.ts b/src/arts/sium/_examples/login-form.ts index 6860d9bca..9cbed26c8 100644 --- a/src/arts/sium/_examples/login-form.ts +++ b/src/arts/sium/_examples/login-form.ts @@ -1,14 +1,6 @@ import { object, pipe, string, email, min, meta } from '$sium/core'; export const loginFormSchema = object({ - email: pipe( - string(), - email(), - meta({ label: 'Email' }) - ), - password: pipe( - string(), - min(8), - meta({ label: 'Password' }) - ) + email: pipe(string(), email(), meta({ label: 'Email' })), + password: pipe(string(), min(8), meta({ label: 'Password' })) }); diff --git a/src/arts/sium/_examples/settings-form.ts b/src/arts/sium/_examples/settings-form.ts index ff291d595..208a6768b 100644 --- a/src/arts/sium/_examples/settings-form.ts +++ b/src/arts/sium/_examples/settings-form.ts @@ -1,39 +1,11 @@ -import { - object, - boolean, - defaulted, - enumOf, - pipe, - meta -} from '$sium/core'; +import { object, boolean, defaulted, enumOf, pipe, meta } from '$sium/core'; export const settingsFormSchema = object({ - theme: pipe( - enumOf(['light', 'dark', 'auto']), - meta({ label: 'Theme' }) - ), - locale: defaulted( - pipe( - enumOf(['es', 'en']), - meta({ label: 'Locale' }) - ), - () => 'es' - ), + theme: pipe(enumOf(['light', 'dark', 'auto']), meta({ label: 'Theme' })), + locale: defaulted(pipe(enumOf(['es', 'en']), meta({ label: 'Locale' })), () => 'es'), notifications: object({ - email: pipe( - boolean(), - meta({ label: 'Email notifications' }) - ), - push: pipe( - boolean(), - meta({ label: 'Push notifications' }) - ), - sms: defaulted( - pipe( - boolean(), - meta({ label: 'SMS notifications' }) - ), - () => false - ) + email: pipe(boolean(), meta({ label: 'Email notifications' })), + push: pipe(boolean(), meta({ label: 'Push notifications' })), + sms: defaulted(pipe(boolean(), meta({ label: 'SMS notifications' })), () => false) }) }); diff --git a/src/arts/sium/_examples/signup-form.ts b/src/arts/sium/_examples/signup-form.ts index 85e576f68..bf92437b5 100644 --- a/src/arts/sium/_examples/signup-form.ts +++ b/src/arts/sium/_examples/signup-form.ts @@ -1,27 +1,10 @@ -import { - object, - pipe, - string, - boolean, - optional, - refine, - meta -} from '$sium/core'; +import { object, pipe, string, boolean, optional, refine, meta } from '$sium/core'; export const signupFormSchema = pipe( object({ - email: pipe( - string(), - meta({ label: 'Email' }) - ), - password: pipe( - string(), - meta({ label: 'Password' }) - ), - confirmPassword: pipe( - string(), - meta({ label: 'Confirm password' }) - ), + email: pipe(string(), meta({ label: 'Email' })), + password: pipe(string(), meta({ label: 'Password' })), + confirmPassword: pipe(string(), meta({ label: 'Confirm password' })), acceptTerms: pipe( boolean(), refine((v) => v === true, { @@ -30,10 +13,7 @@ export const signupFormSchema = pipe( }), meta({ label: 'Accept terms' }) ), - newsletter: pipe( - optional(boolean()), - meta({ label: 'Subscribe to newsletter' }) - ) + newsletter: pipe(optional(boolean()), meta({ label: 'Subscribe to newsletter' })) }), refine((v) => v.password === v.confirmPassword, { code: 'password_mismatch', diff --git a/src/arts/sium/_examples/survey-form.ts b/src/arts/sium/_examples/survey-form.ts index 0f2277929..c5b3455ec 100644 --- a/src/arts/sium/_examples/survey-form.ts +++ b/src/arts/sium/_examples/survey-form.ts @@ -29,10 +29,7 @@ const multipleQuestion = object({ type: literal('multiple'), question: pipe(string(), meta({ label: 'Question' })), options: pipe(array(string()), meta({ label: 'Options' })), - selected: pipe( - enumOf(['a', 'b', 'c', 'd']), - meta({ label: 'Selected' }) - ) + selected: pipe(enumOf(['a', 'b', 'c', 'd']), meta({ label: 'Selected' })) }); export const surveyFormSchema = object({ diff --git a/src/arts/sium/_examples/tree-form.ts b/src/arts/sium/_examples/tree-form.ts index 25369325c..9ab296eaa 100644 --- a/src/arts/sium/_examples/tree-form.ts +++ b/src/arts/sium/_examples/tree-form.ts @@ -1,24 +1,16 @@ -import { - array, - lazy, - min, - object, - optional, - pipe, - string, - type Schema -} from '$sium/core'; +import { array, lazy, min, object, optional, pipe, string, type Schema } from '$sium/core'; export type TreeNode = { label: string; children?: TreeNode[]; }; -export const treeNodeSchema: Schema = lazy((): Schema => - object({ - label: pipe(string(), min(1)), - children: optional(array(treeNodeSchema)) - }) as Schema +export const treeNodeSchema: Schema = lazy( + (): Schema => + object({ + label: pipe(string(), min(1)), + children: optional(array(treeNodeSchema)) + }) as Schema ); export const treeFormSchema = object({ diff --git a/src/arts/sium/_examples/user-form.ts b/src/arts/sium/_examples/user-form.ts index d3c79995a..c217dba4f 100644 --- a/src/arts/sium/_examples/user-form.ts +++ b/src/arts/sium/_examples/user-form.ts @@ -56,13 +56,16 @@ export const userFormSchema = object({ ), confirmPassword: pipe( string(), - refine((value, ctx) => { - const root = ctx.rootInput as Record | undefined; - return value === root?.password; - }, { - code: 'custom', - message: '#?sium.errors.custom|Confirmation must match the password' - }), + refine( + (value, ctx) => { + const root = ctx.rootInput as Record | undefined; + return value === root?.password; + }, + { + code: 'custom', + message: '#?sium.errors.custom|Confirmation must match the password' + } + ), meta({ label: 'Confirm password' }) diff --git a/src/arts/sium/consts.ts b/src/arts/sium/consts.ts index 352d4a783..81c09db7d 100644 --- a/src/arts/sium/consts.ts +++ b/src/arts/sium/consts.ts @@ -5,3 +5,5 @@ export const SIUM_DIAGNOSTIC_EVENTS = { VALIDATION_FAILED: 'sium.validation_failed', RESOLVE_FALLBACK: 'sium.resolve_fallback' } as const; + +export const SIUM_LOG_MSG_VALIDATION_FAILED = `[${SIUM_MODULE}] validation failed.`; diff --git a/src/arts/sium/core/coerce.ts b/src/arts/sium/core/coerce.ts new file mode 100644 index 000000000..05a435a21 --- /dev/null +++ b/src/arts/sium/core/coerce.ts @@ -0,0 +1,110 @@ +import { issue } from './issue'; +import { createSchema } from './schema'; +import { actualType } from './internals'; +import { SiumValidationError } from '../errors'; +import type { Schema } from './types'; + +/** + * Coercion schemas — accept a loose runtime value (the kind HTML inputs, + * query strings and `FormData` produce) and decode it into a concrete + * primitive. They are the general-purpose siblings of `coerceDate()` in + * `types/date.ts`. + * + * Each is a sync identity-on-output codec: `decode` narrows the loose input, + * `encode` returns the decoded value unchanged (coercion is one-way on the + * input axis — there is no canonical inverse for "which string produced this + * number"). The reported `kind` is the OUTPUT kind, so `Form.AutoFields` + * renders the right widget (a number input for `coerceNumber()`, etc.). + */ + +const TRUTHY_STRINGS = new Set(['true', '1', 'yes', 'on']); +const FALSY_STRINGS = new Set(['false', '0', 'no', 'off']); + +function coerceTypeError(expected: string, input: unknown): SiumValidationError { + return new SiumValidationError([ + issue({ + code: 'type', + message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', + params: { expected, actual: actualType(input) } + }) + ]); +} + +/** + * Coerces a string or number into a number. + * + * Numbers (except `NaN`) pass through; numeric strings are parsed with + * `Number()` after trimming. Empty and non-numeric strings raise a `type` + * issue. `Infinity` is allowed — compose with `finite()` to reject it. + */ +export function coerceNumber(): Schema { + return createSchema({ + kind: 'number', + decodeImpl: (input) => { + if (typeof input === 'number') { + if (Number.isNaN(input)) throw coerceTypeError('number', input); + return input; + } + if (typeof input === 'string') { + const trimmed = input.trim(); + if (trimmed === '') throw coerceTypeError('number', input); + const parsed = Number(trimmed); + if (Number.isNaN(parsed)) throw coerceTypeError('number', input); + return parsed; + } + throw coerceTypeError('number', input); + }, + encode: (value) => value + }); +} + +/** + * Coerces a boolean-ish value into a boolean. + * + * Booleans pass through; `1`/`0` map to `true`/`false`; strings are matched + * case-insensitively against `true/1/yes/on` and `false/0/no/off`. Anything + * else raises a `type` issue. + */ +export function coerceBoolean(): Schema { + return createSchema({ + kind: 'boolean', + decodeImpl: (input) => { + if (typeof input === 'boolean') return input; + if (typeof input === 'number') { + if (input === 1) return true; + if (input === 0) return false; + throw coerceTypeError('boolean', input); + } + if (typeof input === 'string') { + const normalized = input.trim().toLowerCase(); + if (TRUTHY_STRINGS.has(normalized)) return true; + if (FALSY_STRINGS.has(normalized)) return false; + throw coerceTypeError('boolean', input); + } + throw coerceTypeError('boolean', input); + }, + encode: (value) => value + }); +} + +/** + * Coerces a string, number or boolean into a string via `String()`. + * + * Strings pass through; numbers (except `NaN`) and booleans are stringified. + * Anything else raises a `type` issue. + */ +export function coerceString(): Schema { + return createSchema({ + kind: 'string', + decodeImpl: (input) => { + if (typeof input === 'string') return input; + if (typeof input === 'number') { + if (Number.isNaN(input)) throw coerceTypeError('string', input); + return String(input); + } + if (typeof input === 'boolean') return String(input); + throw coerceTypeError('string', input); + }, + encode: (value) => value + }); +} diff --git a/src/arts/sium/core/combinator-helpers.ts b/src/arts/sium/core/combinator-helpers.ts index cbdd04c16..7916a2a8a 100644 --- a/src/arts/sium/core/combinator-helpers.ts +++ b/src/arts/sium/core/combinator-helpers.ts @@ -58,6 +58,20 @@ export function createUnknownKeysIssue(keys: string[], path: ReadonlyArray +) { + return issue({ + path, + code: 'tuple_length', + message: + '#?sium.errors.tuple_length|Expected a tuple of length {{length}} (received {{actual}})', + params: { length: expected, actual } + }); +} + export function createUnionNoMatchIssue(path: ReadonlyArray) { return issue({ path, diff --git a/src/arts/sium/core/combinators.ts b/src/arts/sium/core/combinators.ts index 0b63df58c..f0189ce26 100644 --- a/src/arts/sium/core/combinators.ts +++ b/src/arts/sium/core/combinators.ts @@ -1,3 +1,5 @@ export { array } from './array-combinator'; export { object } from './object-combinator'; export { discriminated, union } from './union-combinators'; +export { tuple } from './tuple-combinator'; +export { record } from './record-combinator'; diff --git a/src/arts/sium/core/index.ts b/src/arts/sium/core/index.ts index 2f65ac1ed..e4c3422db 100644 --- a/src/arts/sium/core/index.ts +++ b/src/arts/sium/core/index.ts @@ -1,12 +1,14 @@ /** Public core barrel; validate/standard remain embedded in the schema factory from CDX-002. */ export { string, number, boolean, literal, enumOf } from './primitives'; -export { optional, nullable, defaulted } from './modifiers'; -export { object, array, union, discriminated } from './combinators'; +export { optional, nullable, defaulted, nullish } from './modifiers'; +export { object, array, union, discriminated, tuple, record } from './combinators'; export { lazy } from './lazy'; +export { pick, omit, partial, extend, merge } from './object-utils'; export { serializeSchema, countLeafFields, walkSchema } from './introspect'; export type { SerializedSchema, SerializedShape } from './introspect'; export { pipe, refine, transform, codec, meta } from './pipe'; +export { trim, toLowerCase, toUpperCase } from './transforms'; export { min, max, @@ -15,16 +17,27 @@ export { email, url, integer, + uuid, + slug, + datetime, + ipv4, + finite, + positive, + nonnegative, + multipleOf, cssLength, isCssLength, CSS_LENGTH_REGEX } from './refines'; +export { coerceNumber, coerceBoolean, coerceString } from './coerce'; export { SiumAsyncSchemaError, SiumDiscriminatedUnionError, SiumValidationError, + SiumObjectUtilError, isSiumAsyncSchemaError, isSiumDiscriminatedUnionError, + isSiumObjectUtilError, isSiumValidationError } from '../errors'; export type { SiumDiscriminatedUnionFault } from '../errors'; diff --git a/src/arts/sium/core/introspect.ts b/src/arts/sium/core/introspect.ts index a2d0d42ca..de68abd07 100644 --- a/src/arts/sium/core/introspect.ts +++ b/src/arts/sium/core/introspect.ts @@ -13,17 +13,15 @@ export type SerializedShape = | { kind: 'truncated'; inner: string } | { kind: 'object'; unknownKeys: string; fields: Record } | { kind: 'array'; item: SerializedSchema } + | { kind: 'tuple'; members: SerializedSchema[] } + | { kind: 'record'; value: SerializedSchema } | { kind: 'union'; members: SerializedSchema[] } | { kind: 'discriminated'; key: string; members: SerializedSchema[] } | { kind: 'optional' | 'nullable' | 'defaulted'; inner: SerializedSchema } | { kind: 'enum'; values: Array } | { kind: 'literal'; value: unknown }; -function serializeMetaValue( - value: unknown, - seen: WeakSet, - depth: number -): unknown { +function serializeMetaValue(value: unknown, seen: WeakSet, depth: number): unknown { if ( value === null || typeof value === 'string' || @@ -126,6 +124,24 @@ function serializeSchemaInner( item: serializeSchemaInner(sium.shape.item, maxDepth, depth + 1, seen) } }; + case 'tuple': + return { + ...base, + shape: { + kind: 'tuple', + members: sium.shape.members.map((member) => + serializeSchemaInner(member, maxDepth, depth + 1, seen) + ) + } + }; + case 'record': + return { + ...base, + shape: { + kind: 'record', + value: serializeSchemaInner(sium.shape.value, maxDepth, depth + 1, seen) + } + }; case 'union': return { ...base, @@ -196,7 +212,10 @@ export function countLeafFields(schema: Schema): number { case 'nullable': case 'defaulted': return countLeafFields(shape.inner); + case 'tuple': + return shape.members.reduce((acc, child) => acc + countLeafFields(child), 0); case 'array': + case 'record': case 'union': case 'discriminated': case 'enum': @@ -228,11 +247,17 @@ export function walkSchema( case 'array': walkSchema(shape.item, visit, [...basePath, 0]); return; + case 'tuple': + shape.members.forEach((member, index) => { + walkSchema(member, visit, [...basePath, index]); + }); + return; case 'optional': case 'nullable': case 'defaulted': walkSchema(shape.inner, visit, basePath); return; + case 'record': case 'union': case 'discriminated': case 'enum': diff --git a/src/arts/sium/core/issue.ts b/src/arts/sium/core/issue.ts index d9ae55a83..05391c32a 100644 --- a/src/arts/sium/core/issue.ts +++ b/src/arts/sium/core/issue.ts @@ -66,9 +66,7 @@ export function prependPath( * - one group -> same reference * - one non-empty group among empties -> same reference */ -export function collect( - ...groups: ReadonlyArray> -): ReadonlyArray { +export function collect(...groups: ReadonlyArray>): ReadonlyArray { if (groups.length === 0) { return []; } diff --git a/src/arts/sium/core/modifiers.ts b/src/arts/sium/core/modifiers.ts index 11a956526..1091cec2f 100644 --- a/src/arts/sium/core/modifiers.ts +++ b/src/arts/sium/core/modifiers.ts @@ -144,3 +144,16 @@ export function defaulted( encode: (value) => schema.encode(value) }); } + +/** + * Wraps a schema so BOTH `undefined` and `null` are accepted on decode and + * preserved on encode — the union of `optional()` and `nullable()`. + * + * Implemented as `nullable(optional(schema))`, so it inherits both sentinel + * short-circuits and reports `wrappers: [...inner, 'optional', 'nullable']`. + */ +export function nullish( + schema: Schema +): Schema { + return nullable(optional(schema)); +} diff --git a/src/arts/sium/core/object-utils.ts b/src/arts/sium/core/object-utils.ts new file mode 100644 index 000000000..bef20a70b --- /dev/null +++ b/src/arts/sium/core/object-utils.ts @@ -0,0 +1,92 @@ +import { object } from './object-combinator'; +import { optional } from './modifiers'; +import { SiumObjectUtilError } from '../errors'; +import type { Schema } from './types'; + +/** + * Object-schema utilities — derive a new `object()` schema from an existing + * one by reshaping its field set. They read the field map from the source + * schema's introspection (`shape.fields`) and rebuild a fresh `object()`. + * + * The result types are expressed with `Pick` / `Omit` / `Partial` over the + * source `I` / `O`, so they stay precise. A non-object schema is a programmer + * error and throws `SiumObjectUtilError`. + */ + +type InferInput = S extends Schema ? I : never; +type InferOutput = S extends Schema ? O : never; + +function requireObjectFields(schema: Schema, util: string): Record { + const shape = schema['~sium'].shape; + if (schema['~sium'].kind !== 'object' || shape?.kind !== 'object') { + throw new SiumObjectUtilError(util); + } + return shape.fields as Record; +} + +/** Keeps only the given keys, dropping the rest. */ +export function pick( + schema: Schema, + keys: readonly K[] +): Schema, Pick> { + const fields = requireObjectFields(schema, 'pick'); + const picked: Record = {}; + for (const key of keys) { + if (key in fields) picked[key] = fields[key]; + } + return object(picked) as unknown as Schema, Pick>; +} + +/** Drops the given keys, keeping the rest. */ +export function omit( + schema: Schema, + keys: readonly K[] +): Schema, Omit> { + const fields = requireObjectFields(schema, 'omit'); + const exclude = new Set(keys); + const kept: Record = {}; + for (const key of Object.keys(fields)) { + if (!exclude.has(key)) kept[key] = fields[key]; + } + return object(kept) as unknown as Schema, Omit>; +} + +/** Makes every field optional (`undefined`-accepting). */ +export function partial(schema: Schema): Schema, Partial> { + const fields = requireObjectFields(schema, 'partial'); + const out: Record = {}; + for (const key of Object.keys(fields)) { + out[key] = fields[key]['~sium'].wrappers.includes('optional') + ? fields[key] + : optional(fields[key]); + } + return object(out) as unknown as Schema, Partial>; +} + +/** Adds or overrides fields, returning a wider object schema. */ +export function extend>( + schema: Schema, + fields: F +): Schema< + Omit & { [K in keyof F]: InferInput }, + Omit & { [K in keyof F]: InferOutput } +> { + const base = requireObjectFields(schema, 'extend'); + return object({ ...base, ...fields }) as unknown as Schema< + Omit & { [K in keyof F]: InferInput }, + Omit & { [K in keyof F]: InferOutput } + >; +} + +/** Merges two object schemas; fields from `b` win on key conflicts. */ +export function merge( + a: Schema, + b: Schema +): Schema & IB, Omit & OB> { + const fieldsA = requireObjectFields(a, 'merge'); + const fieldsB = requireObjectFields(b, 'merge'); + return object({ ...fieldsA, ...fieldsB }) as unknown as Schema< + Omit & IB, + Omit & OB + >; +} diff --git a/src/arts/sium/core/pipe.ts b/src/arts/sium/core/pipe.ts index 7bcab4d81..2dcc14ef3 100644 --- a/src/arts/sium/core/pipe.ts +++ b/src/arts/sium/core/pipe.ts @@ -1,11 +1,5 @@ import { createSchema } from './schema'; -import { - type Ctx, - type MetaAnnotations, - type Schema, - type SchemaEffect, - type Step -} from './types'; +import { type Ctx, type MetaAnnotations, type Schema, type SchemaEffect, type Step } from './types'; type UnknownSchema = Schema; type UnknownStep = Step; diff --git a/src/arts/sium/core/primitives.ts b/src/arts/sium/core/primitives.ts index 6c5614e0d..b3e51c545 100644 --- a/src/arts/sium/core/primitives.ts +++ b/src/arts/sium/core/primitives.ts @@ -7,13 +7,13 @@ import type { Schema } from './types'; type PrimitiveEnumValue = string | number; function throwTypeIssue(expected: string, input: unknown): never { - throw new SiumValidationError([ - issue({ - code: 'type', - message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', - params: { - expected, - actual: actualType(input) + throw new SiumValidationError([ + issue({ + code: 'type', + message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', + params: { + expected, + actual: actualType(input) } }) ]); diff --git a/src/arts/sium/core/record-combinator.ts b/src/arts/sium/core/record-combinator.ts new file mode 100644 index 000000000..300ab3a27 --- /dev/null +++ b/src/arts/sium/core/record-combinator.ts @@ -0,0 +1,98 @@ +import { prependPath } from './issue'; +import { createSchema } from './schema'; +import { SiumEncodeExpectsObjectError, SiumValidationError } from '../errors'; +import type { Ctx, Issue, Schema } from './types'; +import { + createChildCtx, + createTypeIssue, + isPlainObjectRecord, + resolveChildIssues, + throwCollectedIssues +} from './combinator-helpers'; + +/** + * Creates a record schema — a plain object with arbitrary string keys whose + * values are all validated by `value`. Use it for dictionaries / maps where + * the keys are not known ahead of time (`record(number())`). + * + * Encode delegates value-by-value. A non-object passed to `decode` raises a + * `type` issue; to `encode`, throws `SiumEncodeExpectsObjectError`. + */ +export function record(value: Schema): Schema, Record> { + const isAsync = value['~sium'].async; + + const decodeSyncRecord = (input: Record, ctx: Ctx) => { + if (!isPlainObjectRecord(input)) { + throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); + } + + const output: Record = {}; + const issueGroups: ReadonlyArray[] = []; + + for (const key of Object.keys(input)) { + const childPath = [...ctx.path, key]; + try { + output[key] = value.decodeSync(input[key], createChildCtx(ctx, key)); + } catch (error) { + if (!(error instanceof SiumValidationError)) throw error; + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + if (issueGroups.length > 0) throwCollectedIssues(...issueGroups); + return output; + }; + + const decodeAsyncRecord = async (input: Record, ctx: Ctx) => { + if (!isPlainObjectRecord(input)) { + throw new SiumValidationError([createTypeIssue('object', input, ctx.path)]); + } + + const output: Record = {}; + const issueGroups: ReadonlyArray[] = []; + + for (const key of Object.keys(input)) { + const childPath = [...ctx.path, key]; + try { + output[key] = await value.decode(input[key], createChildCtx(ctx, key)); + } catch (error) { + if (!(error instanceof SiumValidationError)) throw error; + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + if (issueGroups.length > 0) throwCollectedIssues(...issueGroups); + return output; + }; + + return createSchema({ + kind: 'record', + async: isAsync, + shape: { + kind: 'record', + value + }, + decodeImpl: isAsync ? decodeAsyncRecord : decodeSyncRecord, + decodeSyncImpl: decodeSyncRecord, + encode: (output) => { + if (!isPlainObjectRecord(output)) { + throw new SiumEncodeExpectsObjectError('record'); + } + + const result: Record = {}; + const issueGroups: ReadonlyArray[] = []; + + for (const key of Object.keys(output)) { + try { + result[key] = value.encode(output[key] as O); + } catch (error) { + if (!(error instanceof SiumValidationError)) throw error; + issueGroups.push(prependPath(error.issues, key)); + } + } + + if (issueGroups.length > 0) throwCollectedIssues(...issueGroups); + return result; + } + }); +} diff --git a/src/arts/sium/core/refines.ts b/src/arts/sium/core/refines.ts index f7a7a2f90..35ba73426 100644 --- a/src/arts/sium/core/refines.ts +++ b/src/arts/sium/core/refines.ts @@ -205,6 +205,10 @@ export function isCssLength(value: unknown): boolean { * Creates a CSS-length string refine (px / rem / em / % / vw / vh / vmin / vmax / * ch, non-negative). Compose as `pipe(string(), cssLength())`. * + * Emits the generic `custom` issue code, not `css_value`: this is the + * length-only sibling of `cssValue()` (`types/css.ts`), which owns the richer + * `css_value` code. Kept distinct so the two validators stay independent. + * * @example * ```ts * const schema = pipe(string(), cssLength()); @@ -237,3 +241,159 @@ export function integer(): Step { }) ); } + +// ── v2 format refines (string) ────────────────────────────────────────────── + +/** UUID — 8-4-4-4-12 hex groups, any version, case-insensitive. */ +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** URL-safe slug — lowercase alphanumeric words joined by single hyphens. */ +const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** ISO 8601 date-time — `YYYY-MM-DDTHH:MM:SS(.fff)?(Z|±HH:MM)?`. */ +const ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/; + +/** Dotted-quad IPv4 with each octet in 0–255. */ +const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/; + +/** + * Creates a UUID-format string refine (8-4-4-4-12 hex, any version). + * + * @example + * ```ts + * const schema = pipe(string(), uuid()); + * ``` + */ +export function uuid(): Step { + return refine((value) => typeof value === 'string' && UUID_REGEX.test(value), { + code: 'uuid', + message: '#?sium.errors.uuid|Must be a valid UUID', + params: {} + }); +} + +/** + * Creates a slug-format string refine (lowercase alphanumeric words separated + * by single hyphens; no leading, trailing or doubled hyphens). + * + * @example + * ```ts + * const schema = pipe(string(), slug()); + * ``` + */ +export function slug(): Step { + return refine((value) => typeof value === 'string' && SLUG_REGEX.test(value), { + code: 'slug', + message: '#?sium.errors.slug|Must be a lowercase hyphen-separated slug', + params: {} + }); +} + +/** + * Creates an ISO 8601 date-time string refine. Validates the lexical shape + * (date + `T` + time, optional fractional seconds and `Z`/offset); use + * `dateValue()` / `timeValue()` for structured calendar values. + * + * @example + * ```ts + * const schema = pipe(string(), datetime()); + * ``` + */ +export function datetime(): Step { + return refine((value) => typeof value === 'string' && ISO_DATETIME_REGEX.test(value), { + code: 'datetime', + message: '#?sium.errors.datetime|Must be an ISO 8601 date-time string', + params: {} + }); +} + +/** + * Creates an IPv4-address string refine (dotted quad, each octet 0–255). + * + * @example + * ```ts + * const schema = pipe(string(), ipv4()); + * ``` + */ +export function ipv4(): Step { + return refine((value) => typeof value === 'string' && IPV4_REGEX.test(value), { + code: 'ipv4', + message: '#?sium.errors.ipv4|Must be a valid IPv4 address', + params: {} + }); +} + +// ── v2 numeric refines ────────────────────────────────────────────────────── + +function isMultipleOf(value: number, step: number): boolean { + if (step === 0) return value === 0; + const ratio = value / step; + return Math.abs(ratio - Math.round(ratio)) < 1e-9; +} + +/** + * Creates a finite-number refine — rejects `Infinity`, `-Infinity` and `NaN`. + * `number()` accepts the infinities by design; compose this to exclude them. + * + * @example + * ```ts + * const schema = pipe(number(), finite()); + * ``` + */ +export function finite(): Step { + return refine((value) => typeof value === 'number' && Number.isFinite(value), { + code: 'finite', + message: '#?sium.errors.finite|Must be a finite number', + params: {} + }); +} + +/** + * Creates a strictly-positive number refine (value > 0). Does not imply + * `finite()` — compose both to also reject `Infinity`. + * + * @example + * ```ts + * const schema = pipe(number(), positive()); + * ``` + */ +export function positive(): Step { + return refine((value) => typeof value === 'number' && value > 0, { + code: 'positive', + message: '#?sium.errors.positive|Must be greater than 0', + params: {} + }); +} + +/** + * Creates a non-negative number refine (value >= 0). + * + * @example + * ```ts + * const schema = pipe(number(), nonnegative()); + * ``` + */ +export function nonnegative(): Step { + return refine((value) => typeof value === 'number' && value >= 0, { + code: 'nonnegative', + message: '#?sium.errors.nonnegative|Must be 0 or greater', + params: {} + }); +} + +/** + * Creates a multiple-of refine (value is an integer multiple of `n`, with a + * small float tolerance so `0.3` passes `multipleOf(0.1)`). + * + * @example + * ```ts + * const schema = pipe(number(), multipleOf(5)); + * ``` + */ +export function multipleOf(n: number): Step { + return refine((value) => typeof value === 'number' && isMultipleOf(value, n), { + code: 'multiple_of', + message: '#?sium.errors.multiple_of|Must be a multiple of {{multiple}}', + params: { multiple: n } + }); +} diff --git a/src/arts/sium/core/schema.ts b/src/arts/sium/core/schema.ts index a920183cc..96bab11cd 100644 --- a/src/arts/sium/core/schema.ts +++ b/src/arts/sium/core/schema.ts @@ -106,18 +106,20 @@ export function createSchema(spec: CreateSchemaSpec): Schema { } }; + const toStandardResult = (result: Result) => + isErr(result) ? { issues: toStandardIssues(result.issues) } : { value: result.value }; + const schema: Schema = { '~standard': { version: 1, vendor: SIUM_STANDARD_VENDOR, - validate: async (value: unknown) => { - const result = await validate(value as I); - if (isErr(result)) { - return { issues: toStandardIssues(result.issues) }; - } - - return { value: result.value }; - } + // Sync schemas resolve synchronously so Standard-Schema consumers + // (forms, storage) stay on their sync path; only schemas with async + // steps return a Promise. SS v1 permits `Result | Promise`. + validate: (value: unknown) => + spec.async === true + ? validate(value as I).then(toStandardResult) + : toStandardResult(validateSync(value as I)) }, '~sium': { kind: spec.kind, diff --git a/src/arts/sium/core/standard-schema.ts b/src/arts/sium/core/standard-schema.ts index 55d802bfb..c465a5b32 100644 --- a/src/arts/sium/core/standard-schema.ts +++ b/src/arts/sium/core/standard-schema.ts @@ -1,2 +1 @@ -export { isPromiseLike, pathKeys } from '$libs/standard-schema'; export type { StandardSchemaV1 } from '$libs/standard-schema'; diff --git a/src/arts/sium/core/steps.ts b/src/arts/sium/core/steps.ts index 93db60596..416853b5c 100644 --- a/src/arts/sium/core/steps.ts +++ b/src/arts/sium/core/steps.ts @@ -49,44 +49,28 @@ export function 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; + const result = await check(input, ctx); + if (result) { + return input; } + + throw new SiumValidationError([createRefineIssue(ctx.path, fail)]); }, 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; - } + const result = check(input, ctx); + if (isPromiseLike(result)) { + throw createAsyncStepError(); + } - throw error; + if (result) { + return input; } + + throw new SiumValidationError([createRefineIssue(ctx.path, fail)]); } }; } diff --git a/src/arts/sium/core/transforms.ts b/src/arts/sium/core/transforms.ts new file mode 100644 index 000000000..6ac73c1b9 --- /dev/null +++ b/src/arts/sium/core/transforms.ts @@ -0,0 +1,24 @@ +import { transform } from './pipe'; +import type { Step } from './types'; + +/** + * String-normalizing transform steps. They change the value (so they expose no + * `encode`, like every `transform`) and are meant to run after `string()` has + * validated the type: `pipe(string(), trim(), toLowerCase())`. The `kind` + * stays `string`, so they are transparent to `Form.AutoFields`. + */ + +/** Trims leading and trailing whitespace. */ +export function trim(): Step { + return transform((value) => value.trim()); +} + +/** Lowercases the string (locale-independent). */ +export function toLowerCase(): Step { + return transform((value) => value.toLowerCase()); +} + +/** Uppercases the string (locale-independent). */ +export function toUpperCase(): Step { + return transform((value) => value.toUpperCase()); +} diff --git a/src/arts/sium/core/tuple-combinator.ts b/src/arts/sium/core/tuple-combinator.ts new file mode 100644 index 000000000..9ece18be1 --- /dev/null +++ b/src/arts/sium/core/tuple-combinator.ts @@ -0,0 +1,110 @@ +import { prependPath } from './issue'; +import { createSchema } from './schema'; +import { SiumEncodeExpectsArrayError, SiumValidationError } from '../errors'; +import type { Ctx, Issue, Schema } from './types'; +import { + createChildCtx, + createTupleLengthIssue, + createTypeIssue, + resolveChildIssues, + throwCollectedIssues +} from './combinator-helpers'; + +type InferInput = S extends Schema ? I : never; +type InferOutput = S extends Schema ? O : never; + +/** + * Creates a fixed-length tuple schema — a heterogeneous array validated + * positionally. `tuple(string(), number())` accepts exactly `[string, number]`; + * the length must match (a `tuple_length` issue is raised otherwise). + * + * Encode delegates element-by-element. Non-array values passed to `encode` + * are programmer errors and throw `SiumEncodeExpectsArrayError`. + */ +export function tuple( + ...members: M +): Schema<{ [K in keyof M]: InferInput }, { [K in keyof M]: InferOutput }> { + const memberList = members as ReadonlyArray; + const isAsync = memberList.some((member) => member['~sium'].async); + + const guard = (input: unknown, ctx: Ctx): unknown[] => { + if (!Array.isArray(input)) { + throw new SiumValidationError([createTypeIssue('array', input, ctx.path)]); + } + if (input.length !== memberList.length) { + throw new SiumValidationError([ + createTupleLengthIssue(memberList.length, input.length, ctx.path) + ]); + } + return input; + }; + + const decodeSyncTuple = (input: { [K in keyof M]: InferInput }, ctx: Ctx) => { + const arr = guard(input, ctx); + const output: unknown[] = []; + const issueGroups: ReadonlyArray[] = []; + + for (let index = 0; index < memberList.length; index += 1) { + const childPath = [...ctx.path, index]; + try { + output[index] = memberList[index].decodeSync(arr[index], createChildCtx(ctx, index)); + } catch (error) { + if (!(error instanceof SiumValidationError)) throw error; + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + if (issueGroups.length > 0) throwCollectedIssues(...issueGroups); + return output as { [K in keyof M]: InferOutput }; + }; + + const decodeAsyncTuple = async (input: { [K in keyof M]: InferInput }, ctx: Ctx) => { + const arr = guard(input, ctx); + const output: unknown[] = []; + const issueGroups: ReadonlyArray[] = []; + + for (let index = 0; index < memberList.length; index += 1) { + const childPath = [...ctx.path, index]; + try { + output[index] = await memberList[index].decode(arr[index], createChildCtx(ctx, index)); + } catch (error) { + if (!(error instanceof SiumValidationError)) throw error; + issueGroups.push(resolveChildIssues(error.issues, childPath)); + } + } + + if (issueGroups.length > 0) throwCollectedIssues(...issueGroups); + return output as { [K in keyof M]: InferOutput }; + }; + + return createSchema({ + kind: 'tuple', + async: isAsync, + shape: { + kind: 'tuple', + members + }, + decodeImpl: isAsync ? decodeAsyncTuple : decodeSyncTuple, + decodeSyncImpl: decodeSyncTuple, + encode: (value) => { + if (!Array.isArray(value)) { + throw new SiumEncodeExpectsArrayError(); + } + + const output: unknown[] = []; + const issueGroups: ReadonlyArray[] = []; + + for (let index = 0; index < memberList.length; index += 1) { + try { + output[index] = memberList[index].encode(value[index]); + } catch (error) { + if (!(error instanceof SiumValidationError)) throw error; + issueGroups.push(prependPath(error.issues, index)); + } + } + + if (issueGroups.length > 0) throwCollectedIssues(...issueGroups); + return output as { [K in keyof M]: InferInput }; + } + }); +} diff --git a/src/arts/sium/core/types.ts b/src/arts/sium/core/types.ts index d83f087ee..f09d45955 100644 --- a/src/arts/sium/core/types.ts +++ b/src/arts/sium/core/types.ts @@ -2,9 +2,9 @@ * # sium — core types * * Pure TypeScript — no Svelte runtime, no runes, no DOM. Runnable in Node, - * Bun, Deno, Cloudflare Workers, or a browser. The Svelte adapter lives at - * `$lib/sium/svelte/` and the UI renderer (`Form.AutoFields`) at - * the framework UI form package. + * Bun, Deno, Cloudflare Workers, or a browser. The Svelte adapter + * (`$sium/svelte/`, pending port) and the UI renderer (`Form.AutoFields`) + * live in the framework UI form package. * * Contract and design rules are frozen in the module README. Key invariants: * @@ -44,6 +44,8 @@ export type SchemaKind = | 'enum' | 'object' | 'array' + | 'tuple' + | 'record' | 'union' | 'discriminated' | 'color' @@ -126,6 +128,8 @@ export type SiumShape = unknownKeys: 'strip' | 'strict' | 'passthrough'; } | { kind: 'array'; item: Schema } + | { kind: 'tuple'; members: ReadonlyArray } + | { kind: 'record'; value: Schema } | { kind: 'union'; members: ReadonlyArray } | { kind: 'discriminated'; key: string; members: ReadonlyArray } | { kind: 'optional' | 'nullable'; inner: Schema } diff --git a/src/arts/sium/diagnostics.ts b/src/arts/sium/diagnostics.ts index 78c1faa65..795905ea7 100644 --- a/src/arts/sium/diagnostics.ts +++ b/src/arts/sium/diagnostics.ts @@ -6,12 +6,10 @@ import { type Diagnostics, type Logger } from '$libs/logger'; -import { SIUM_MODULE, SIUM_DIAGNOSTIC_EVENTS } from './consts'; +import { SIUM_MODULE, SIUM_DIAGNOSTIC_EVENTS, SIUM_LOG_MSG_VALIDATION_FAILED } from './consts'; import { SIUM_LOG_MSG_RESOLVE_FALLBACK } from './errors'; import type { Issue } from './core'; -const SIUM_LOG_MSG_VALIDATION_FAILED = `[${SIUM_MODULE}] validation failed.`; - export type SiumDiagnosticType = (typeof SIUM_DIAGNOSTIC_EVENTS)[keyof typeof SIUM_DIAGNOSTIC_EVENTS]; diff --git a/src/arts/sium/engine-resolver.ts b/src/arts/sium/engine-resolver.ts index db1fb7143..12e01da4f 100644 --- a/src/arts/sium/engine-resolver.ts +++ b/src/arts/sium/engine-resolver.ts @@ -42,8 +42,7 @@ function hasActiveLocaleSource( langs: EngineLangs | undefined ): langs is EngineLangs & ActiveLocaleSource { return ( - langs !== undefined && - typeof (langs as Partial).getLocale === 'function' + langs !== undefined && typeof (langs as Partial).getLocale === 'function' ); } diff --git a/src/arts/sium/engine-sium.ts b/src/arts/sium/engine-sium.ts index 1fed57f61..445622d48 100644 --- a/src/arts/sium/engine-sium.ts +++ b/src/arts/sium/engine-sium.ts @@ -12,16 +12,38 @@ import { cssLength, defaulted, discriminated, + tuple, + record, + pick, + omit, + partial, + extend, + merge, email, enumOf, integer, + uuid, + slug, + datetime, + ipv4, + finite, + positive, + nonnegative, + multipleOf, + coerceNumber, + coerceBoolean, + coerceString, lazy, length, literal, max, meta, + trim, + toLowerCase, + toUpperCase, min, nullable, + nullish, number, object, optional, @@ -82,6 +104,13 @@ export interface EngineSium { readonly array: typeof array; readonly union: typeof union; readonly discriminated: typeof discriminated; + readonly tuple: typeof tuple; + readonly record: typeof record; + readonly pick: typeof pick; + readonly omit: typeof omit; + readonly partial: typeof partial; + readonly extend: typeof extend; + readonly merge: typeof merge; readonly lazy: typeof lazy; readonly serializeSchema: typeof serializeSchema; readonly countLeafFields: typeof countLeafFields; @@ -99,6 +128,21 @@ export interface EngineSium { readonly url: typeof url; readonly cssLength: typeof cssLength; readonly integer: typeof integer; + readonly uuid: typeof uuid; + readonly slug: typeof slug; + readonly datetime: typeof datetime; + readonly ipv4: typeof ipv4; + readonly finite: typeof finite; + readonly positive: typeof positive; + readonly nonnegative: typeof nonnegative; + readonly multipleOf: typeof multipleOf; + readonly coerceNumber: typeof coerceNumber; + readonly coerceBoolean: typeof coerceBoolean; + readonly coerceString: typeof coerceString; + readonly trim: typeof trim; + readonly toLowerCase: typeof toLowerCase; + readonly toUpperCase: typeof toUpperCase; + readonly nullish: typeof nullish; readonly timeValue: typeof timeValue; readonly timeRange: typeof timeRange; readonly hour: typeof hour; @@ -163,6 +207,13 @@ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium { array, union, discriminated, + tuple, + record, + pick, + omit, + partial, + extend, + merge, lazy, serializeSchema, countLeafFields, @@ -180,6 +231,21 @@ export function createEngineSium(options: EngineSiumOptions = {}): EngineSium { url, cssLength, integer, + uuid, + slug, + datetime, + ipv4, + finite, + positive, + nonnegative, + multipleOf, + coerceNumber, + coerceBoolean, + coerceString, + trim, + toLowerCase, + toUpperCase, + nullish, timeValue, timeRange, hour, diff --git a/src/arts/sium/engine-validation.ts b/src/arts/sium/engine-validation.ts index cea418709..7326630de 100644 --- a/src/arts/sium/engine-validation.ts +++ b/src/arts/sium/engine-validation.ts @@ -1,9 +1,5 @@ import { SIUM_DIAGNOSTIC_EVENTS } from './consts'; -import { - emitSiumDiagnostic, - issueDiagnosticMeta, - type SiumDiagnostics -} from './diagnostics'; +import { emitSiumDiagnostic, issueDiagnosticMeta, type SiumDiagnostics } from './diagnostics'; import type { Result, Schema } from './core'; export interface SiumValidationRuntime { @@ -19,9 +15,7 @@ export interface SiumValidationRuntime { ) => Result; } -export function createSiumValidationRuntime( - diagnostics: SiumDiagnostics -): SiumValidationRuntime { +export function createSiumValidationRuntime(diagnostics: SiumDiagnostics): SiumValidationRuntime { const validate: SiumValidationRuntime['validate'] = async (schema, input, ctx) => { const result = await schema.validate(input, ctx); if (!result.ok) { diff --git a/src/arts/sium/errors.ts b/src/arts/sium/errors.ts index f6bdc487f..ad2b40e7c 100644 --- a/src/arts/sium/errors.ts +++ b/src/arts/sium/errors.ts @@ -32,14 +32,14 @@ export const SIUM_ERR_ENCODE_EXPECTS_OBJECT: ErrCode = errCode(SIUM_ERR_ENCODE, export const SIUM_ERR_ENCODE_EXPECTS_ARRAY: ErrCode = errCode(SIUM_ERR_ENCODE, 'expects_array'); export const SIUM_ERR_ENCODE_NO_MATCH: ErrCode = errCode(SIUM_ERR_ENCODE, 'no_match'); export const SIUM_ERR_LAZY_RESOLVING: ErrCode = errCode(SIUM_ERR, 'lazy_resolving'); +export const SIUM_ERR_OBJECT_UTIL: ErrCode = errCode(SIUM_ERR, 'object_util'); // ── Error messages ───────────────────────────────────────────────────── export const SIUM_ERROR_MESSAGES: ErrorMessages = { [SIUM_ERR_VALIDATION]: (issues: ReadonlyArray): string => `[${SIUM_MODULE}] validation failed with ${issues.length} issue(s)`, - [SIUM_ERR_ASYNC_SCHEMA]: - `[${SIUM_MODULE}] schema contains async steps — use decode() / validate() instead of decodeSync / validateSync.`, + [SIUM_ERR_ASYNC_SCHEMA]: `[${SIUM_MODULE}] schema contains async steps — use decode() / validate() instead of decodeSync / validateSync.`, [SIUM_ERR_DISCRIMINATED_UNION_MEMBER_NOT_OBJECT]: (key: string): string => `[${SIUM_MODULE}] discriminated(${key}) members must be object() schemas`, [SIUM_ERR_DISCRIMINATED_UNION_MEMBER_MISSING_KEY]: (key: string): string => @@ -48,12 +48,12 @@ export const SIUM_ERROR_MESSAGES: ErrorMessages = { `[${SIUM_MODULE}] discriminated(${key}) members must use literal() on the discriminator field`, [SIUM_ERR_ENCODE_EXPECTS_OBJECT]: (combinator: string): string => `[${SIUM_MODULE}] ${combinator}().encode expects a plain object value`, - [SIUM_ERR_ENCODE_EXPECTS_ARRAY]: - `[${SIUM_MODULE}] array().encode expects an array value`, + [SIUM_ERR_ENCODE_EXPECTS_ARRAY]: `[${SIUM_MODULE}] array().encode expects an array value`, [SIUM_ERR_ENCODE_NO_MATCH]: (key: string): string => `[${SIUM_MODULE}] discriminated().encode could not match discriminator ${key}`, - [SIUM_ERR_LAZY_RESOLVING]: - `[${SIUM_MODULE}] lazy() cannot decode, encode, or validate while its schema thunk is still resolving` + [SIUM_ERR_LAZY_RESOLVING]: `[${SIUM_MODULE}] lazy() cannot decode, encode, or validate while its schema thunk is still resolving`, + [SIUM_ERR_OBJECT_UTIL]: (util: string): string => + `[${SIUM_MODULE}] ${util}() expects an object() schema` }; // ── Error classes ────────────────────────────────────────────────────── @@ -71,9 +71,7 @@ export class SiumValidationError extends CodeError { readonly issues: ReadonlyArray; constructor(issues: ReadonlyArray, message?: string) { - const fn = SIUM_ERROR_MESSAGES[SIUM_ERR_VALIDATION] as ( - issues: ReadonlyArray - ) => string; + const fn = SIUM_ERROR_MESSAGES[SIUM_ERR_VALIDATION] as (issues: ReadonlyArray) => string; super(SIUM_ERR_VALIDATION, { message: message ?? fn(issues) }); this.issues = issues; } @@ -217,27 +215,29 @@ export function isSiumLazyResolvingError(value: unknown): value is SiumLazyResol return value instanceof SiumLazyResolvingError; } -// ── Legacy string catalogue (non-error log/diagnostic messages) ──────── +/** + * Thrown by the object utilities (`pick` / `omit` / `partial` / `extend` / + * `merge`) when given a schema that is not an `object()`. Always a programmer + * error — the utilities only reshape object schemas. + */ +export class SiumObjectUtilError extends CodeError { + readonly util: string; + + constructor(util: string) { + const fn = SIUM_ERROR_MESSAGES[SIUM_ERR_OBJECT_UTIL] as (util: string) => string; + super(SIUM_ERR_OBJECT_UTIL, { message: fn(util) }); + this.util = util; + } +} + +export function isSiumObjectUtilError(value: unknown): value is SiumObjectUtilError { + return value instanceof SiumObjectUtilError; +} + +// ── Non-error log message ────────────────────────────────────────────── // -// These strings are referenced by sium runtime code that is NOT thrown -// (lazy access guard, encode-time invariants in combinators). They live -// here for now until each of those sites is migrated to a typed error. +// Diagnostic log string used by sium runtime code that is NOT thrown (the +// resolve-fallback warning emitted when a langs lookup fails). Every thrown +// programmer error uses the typed CodeError classes above. export const SIUM_LOG_MSG_RESOLVE_FALLBACK = `[${SIUM_MODULE}] falling back while resolving Sium message.`; - -export const SIUM_ERRORS = { - OBJECT_ENCODE_EXPECTS_PLAIN_OBJECT: 'object().encode expects a plain object value', - ARRAY_ENCODE_EXPECTS_ARRAY: 'array().encode expects an array value', - LAZY_RESOLVING_ACCESS: - 'lazy() cannot decode, encode, or validate while its schema thunk is still resolving', - DISCRIMINATED_MEMBER_NOT_OBJECT: (key: string): string => - `discriminated(${key}) members must be object() schemas`, - DISCRIMINATED_MEMBER_MISSING_KEY: (key: string): string => - `discriminated(${key}) members must declare the discriminator field`, - DISCRIMINATED_MEMBER_NOT_LITERAL: (key: string): string => - `discriminated(${key}) members must use literal() on the discriminator field`, - DISCRIMINATED_ENCODE_EXPECTS_PLAIN_OBJECT: - 'discriminated().encode expects a plain object value', - DISCRIMINATED_ENCODE_NO_MATCH: (key: string): string => - `discriminated().encode could not match discriminator ${key}` -} as const; diff --git a/src/arts/sium/index.ts b/src/arts/sium/index.ts index 45e4b08c3..212f61af9 100644 --- a/src/arts/sium/index.ts +++ b/src/arts/sium/index.ts @@ -21,9 +21,9 @@ export { SIUM_ERR_ENCODE_EXPECTS_OBJECT, SIUM_ERR_ENCODE_NO_MATCH, SIUM_ERR_LAZY_RESOLVING, + SIUM_ERR_OBJECT_UTIL, SIUM_ERR_VALIDATION, SIUM_ERROR_MESSAGES, - SIUM_ERRORS, SIUM_LOG_MSG_RESOLVE_FALLBACK, SiumAsyncSchemaError, SiumDiscriminatedUnionError, @@ -31,6 +31,7 @@ export { SiumEncodeExpectsObjectError, SiumEncodeNoMatchError, SiumLazyResolvingError, + SiumObjectUtilError, SiumValidationError, isSiumAsyncSchemaError, isSiumDiscriminatedUnionError, @@ -38,6 +39,7 @@ export { isSiumEncodeExpectsObjectError, isSiumEncodeNoMatchError, isSiumLazyResolvingError, + isSiumObjectUtilError, isSiumValidationError } from './errors'; export type { SiumDiscriminatedUnionFault } from './errors'; @@ -52,10 +54,18 @@ export { optional, nullable, defaulted, + nullish, object, array, union, discriminated, + tuple, + record, + pick, + omit, + partial, + extend, + merge, lazy, serializeSchema, countLeafFields, @@ -65,6 +75,9 @@ export { transform, codec, meta, + trim, + toLowerCase, + toUpperCase, min, max, length, @@ -129,13 +142,7 @@ export { CSS_FREQUENCY_UNITS, CSS_FLEX_UNITS } from './types'; -export type { - CssUnit, - CssValueParts, - CssDimension, - CssKeyword, - CssValueOptions -} from './types'; +export type { CssUnit, CssValueParts, CssDimension, CssKeyword, CssValueOptions } from './types'; // langs export { ISSUE_CODES, resolve, siumLangs } from './langs'; diff --git a/src/arts/sium/langs/codes.ts b/src/arts/sium/langs/codes.ts index a80c97979..cff23f514 100644 --- a/src/arts/sium/langs/codes.ts +++ b/src/arts/sium/langs/codes.ts @@ -22,11 +22,20 @@ export const ISSUE_CODES = [ 'email', 'url', 'integer', + 'uuid', + 'slug', + 'datetime', + 'ipv4', + 'finite', + 'positive', + 'nonnegative', + 'multiple_of', 'css_value', 'custom', 'range_order', // combinators 'unknown_keys', + 'tuple_length', 'union_no_match', 'discriminated_missing_key', 'discriminated_unknown_value' diff --git a/src/arts/sium/langs/langs.ts b/src/arts/sium/langs/langs.ts index 16d80fcf1..e362eb981 100644 --- a/src/arts/sium/langs/langs.ts +++ b/src/arts/sium/langs/langs.ts @@ -102,6 +102,38 @@ export const siumLangs = { es: 'Debe ser un entero (se recibió {{actual}})', en: 'Must be an integer (received {{actual}})' }, + uuid: { + es: 'Debe ser un UUID válido', + en: 'Must be a valid UUID' + }, + slug: { + es: 'Debe ser un slug en minúsculas separado por guiones', + en: 'Must be a lowercase hyphen-separated slug' + }, + datetime: { + es: 'Debe ser una fecha-hora ISO 8601', + en: 'Must be an ISO 8601 date-time string' + }, + ipv4: { + es: 'Debe ser una dirección IPv4 válida', + en: 'Must be a valid IPv4 address' + }, + finite: { + es: 'Debe ser un número finito', + en: 'Must be a finite number' + }, + positive: { + es: 'Debe ser mayor que 0', + en: 'Must be greater than 0' + }, + nonnegative: { + es: 'Debe ser 0 o mayor', + en: 'Must be 0 or greater' + }, + multiple_of: { + es: 'Debe ser múltiplo de {{multiple}}', + en: 'Must be a multiple of {{multiple}}' + }, css_value: { es: 'Debe ser un valor CSS válido ({{units}})', en: 'Must be a valid CSS value ({{units}})' @@ -118,6 +150,10 @@ export const siumLangs = { es: 'Claves desconocidas: {{keys}}', en: 'Unknown keys: {{keys}}' }, + tuple_length: { + es: 'Esperaba una tupla de longitud {{length}} (se recibió {{actual}})', + en: 'Expected a tuple of length {{length}} (received {{actual}})' + }, union_no_match: { es: 'No coincide con ninguna variante', en: 'Does not match any variant' diff --git a/src/arts/sium/langs/resolver.ts b/src/arts/sium/langs/resolver.ts index 8af1979d3..c06d11640 100644 --- a/src/arts/sium/langs/resolver.ts +++ b/src/arts/sium/langs/resolver.ts @@ -4,7 +4,7 @@ * * @example * ```ts - * resolve('Expected {expected} but received {actual}', { expected: 'string', actual: 'number' }) + * resolve('Expected {{expected}} but received {{actual}}', { expected: 'string', actual: 'number' }) * // → 'Expected string but received number' * ``` */ diff --git a/src/arts/sium/test/barrel.test.ts b/src/arts/sium/test/barrel.test.ts index 2a907e86d..614ec27a1 100644 --- a/src/arts/sium/test/barrel.test.ts +++ b/src/arts/sium/test/barrel.test.ts @@ -99,7 +99,9 @@ describe('core barrel type exports', () => { }; const result: ResultAlias = { ok: false, - issues: [{ path: [], code: 'custom', message: '#?sium.errors.custom|Error' } satisfies IssueAlias] + issues: [ + { path: [], code: 'custom', message: '#?sium.errors.custom|Error' } satisfies IssueAlias + ] }; expectTypeOf().toEqualTypeOf>(); diff --git a/src/arts/sium/test/coerce.test.ts b/src/arts/sium/test/coerce.test.ts new file mode 100644 index 000000000..1d4901619 --- /dev/null +++ b/src/arts/sium/test/coerce.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { coerceBoolean, coerceNumber, coerceString } from '../core'; + +describe('coerceNumber()', () => { + const schema = coerceNumber(); + + it('passes through numbers', () => { + expect(schema.decodeSync(42)).toBe(42); + expect(schema.decodeSync(-3.5)).toBe(-3.5); + }); + + it('parses numeric strings (trimmed)', () => { + expect(schema.decodeSync('42')).toBe(42); + expect(schema.decodeSync(' 3.14 ')).toBe(3.14); + expect(schema.decodeSync('-7')).toBe(-7); + }); + + it('rejects empty / non-numeric strings and NaN with a type issue', () => { + for (const bad of ['', ' ', 'abc', '12px']) { + const result = schema.validateSync(bad); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues[0].code).toBe('type'); + } + expect(schema.validateSync(Number.NaN).ok).toBe(false); + }); + + it('reports the output kind (number) and encodes as identity', () => { + expect(schema['~sium'].kind).toBe('number'); + expect(schema.encode(5)).toBe(5); + }); +}); + +describe('coerceBoolean()', () => { + const schema = coerceBoolean(); + + it('passes through booleans', () => { + expect(schema.decodeSync(true)).toBe(true); + expect(schema.decodeSync(false)).toBe(false); + }); + + it('coerces 1/0 and truthy/falsy strings (case-insensitive, trimmed)', () => { + expect(schema.decodeSync(1)).toBe(true); + expect(schema.decodeSync(0)).toBe(false); + for (const value of ['true', 'TRUE', '1', 'yes', 'on', ' On ']) { + expect(schema.decodeSync(value)).toBe(true); + } + for (const value of ['false', 'FALSE', '0', 'no', 'off']) { + expect(schema.decodeSync(value)).toBe(false); + } + }); + + it('rejects ambiguous values', () => { + for (const bad of ['maybe', '2', 2, '']) { + expect(schema.validateSync(bad).ok).toBe(false); + } + }); + + it('reports the output kind (boolean)', () => { + expect(schema['~sium'].kind).toBe('boolean'); + }); +}); + +describe('coerceString()', () => { + const schema = coerceString(); + + it('passes through strings and stringifies number / boolean', () => { + expect(schema.decodeSync('hi')).toBe('hi'); + expect(schema.decodeSync(42)).toBe('42'); + expect(schema.decodeSync(true)).toBe('true'); + expect(schema.decodeSync(false)).toBe('false'); + }); + + it('rejects NaN', () => { + expect(schema.validateSync(Number.NaN).ok).toBe(false); + }); + + it('reports the output kind (string)', () => { + expect(schema['~sium'].kind).toBe('string'); + }); +}); diff --git a/src/arts/sium/test/color.test.ts b/src/arts/sium/test/color.test.ts index 2ac564516..aa988c614 100644 --- a/src/arts/sium/test/color.test.ts +++ b/src/arts/sium/test/color.test.ts @@ -238,4 +238,3 @@ describe('colorValue()', () => { expect(colorValue().decodeSync(shorthand as any)).toBe(shorthand); }); }); - diff --git a/src/arts/sium/test/css.test.ts b/src/arts/sium/test/css.test.ts index 4cab1901a..17160cf13 100644 --- a/src/arts/sium/test/css.test.ts +++ b/src/arts/sium/test/css.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from 'vitest'; import { pipe, string } from '../core'; -import { - cssValue, - parseCssValue, - formatCssValue, - isCssValue, - CSS_UNITS -} from '../types/css'; +import { cssValue, parseCssValue, formatCssValue, isCssValue, CSS_UNITS } from '../types/css'; describe('parseCssValue()', () => { it('parses a px dimension', () => { diff --git a/src/arts/sium/test/date.test.ts b/src/arts/sium/test/date.test.ts index 574ba18fb..02dac1996 100644 --- a/src/arts/sium/test/date.test.ts +++ b/src/arts/sium/test/date.test.ts @@ -246,4 +246,3 @@ describe('coerceDate()', () => { expect(coerceDate().encode(dv)).toBe(dv); }); }); - diff --git a/src/arts/sium/test/engine-sium.test.ts b/src/arts/sium/test/engine-sium.test.ts index 56432b00e..b5f6288bf 100644 --- a/src/arts/sium/test/engine-sium.test.ts +++ b/src/arts/sium/test/engine-sium.test.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import { createEngineLangs } from '$langs'; import { createEngineLogger, LogLevel } from '$logger'; -import { SIUM_MODULE, SIUM_ERRORS, createEngineSium, type EngineSium } from '..'; +import { SIUM_MODULE, createEngineSium, type EngineSium } from '..'; import type { Issue, Schema } from '../core'; import { siumLangs } from '../langs'; diff --git a/src/arts/sium/test/errors.test.ts b/src/arts/sium/test/errors.test.ts index f3e1c8ee3..eed40d196 100644 --- a/src/arts/sium/test/errors.test.ts +++ b/src/arts/sium/test/errors.test.ts @@ -6,11 +6,7 @@ import { SiumValidationError, type Issue } from '../index.ts'; -import { - SIUM_ERR, - SIUM_ERR_ASYNC_SCHEMA, - SIUM_ERR_VALIDATION -} from '../errors.ts'; +import { SIUM_ERR, SIUM_ERR_ASYNC_SCHEMA, SIUM_ERR_VALIDATION } from '../errors.ts'; import { CodeError, isCodeError, matches } from '$libs/errs'; const ISSUE: Issue = { diff --git a/src/arts/sium/test/introspect.test.ts b/src/arts/sium/test/introspect.test.ts index 810616e67..4c2279731 100644 --- a/src/arts/sium/test/introspect.test.ts +++ b/src/arts/sium/test/introspect.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { array, lazy, number, object, optional, pipe, serializeSchema, string, walkSchema } from '../core'; +import { + array, + lazy, + number, + object, + optional, + pipe, + serializeSchema, + string, + walkSchema +} from '../core'; import type { Schema } from '../core'; describe('serializeSchema()', () => { @@ -42,7 +52,8 @@ describe('serializeSchema()', () => { ); const serialized = serializeSchema(TreeSchema, 1); - const children = serialized.shape?.kind === 'object' ? serialized.shape.fields.children : undefined; + const children = + serialized.shape?.kind === 'object' ? serialized.shape.fields.children : undefined; expect(children?.shape).toEqual({ kind: 'truncated', diff --git a/src/arts/sium/test/issue.test.ts b/src/arts/sium/test/issue.test.ts index 57101246c..3f6478687 100644 --- a/src/arts/sium/test/issue.test.ts +++ b/src/arts/sium/test/issue.test.ts @@ -236,4 +236,3 @@ describe('collect()', () => { expect(collect(empty, groupA, empty)).toBe(groupA); }); }); - diff --git a/src/arts/sium/test/langs.test.ts b/src/arts/sium/test/langs.test.ts index ecd4493b2..515fcb71e 100644 --- a/src/arts/sium/test/langs.test.ts +++ b/src/arts/sium/test/langs.test.ts @@ -18,17 +18,26 @@ describe('ISSUE_CODES', () => { 'email', 'url', 'integer', + 'uuid', + 'slug', + 'datetime', + 'ipv4', + 'finite', + 'positive', + 'nonnegative', + 'multiple_of', 'css_value', 'custom', 'range_order', 'unknown_keys', + 'tuple_length', 'union_no_match', 'discriminated_missing_key', 'discriminated_unknown_value' ] as const; - it('contains exactly the 19 codes in declaration order, no duplicates', () => { - expect(ISSUE_CODES).toHaveLength(19); + it('contains exactly the 28 codes in declaration order, no duplicates', () => { + expect(ISSUE_CODES).toHaveLength(28); expect([...ISSUE_CODES]).toEqual(expectedCodes); expect(new Set(ISSUE_CODES).size).toBe(ISSUE_CODES.length); }); @@ -48,10 +57,19 @@ describe('ISSUE_CODES', () => { | 'email' | 'url' | 'integer' + | 'uuid' + | 'slug' + | 'datetime' + | 'ipv4' + | 'finite' + | 'positive' + | 'nonnegative' + | 'multiple_of' | 'css_value' | 'custom' | 'range_order' | 'unknown_keys' + | 'tuple_length' | 'union_no_match' | 'discriminated_missing_key' | 'discriminated_unknown_value' @@ -63,9 +81,9 @@ describe('ISSUE_CODES', () => { describe('siumLangs schema', () => { const errors = siumLangs.errors; - it('has exactly 19 keys — one per IssueCode', () => { + it('has exactly 28 keys — one per IssueCode', () => { const keys = Object.keys(errors); - expect(keys).toHaveLength(19); + expect(keys).toHaveLength(28); for (const code of ISSUE_CODES) { expect(errors).toHaveProperty(code); } @@ -105,9 +123,11 @@ describe('siumLangs schema', () => { }); describe('siumLangs.errors.length pluralization', () => { - const lengthEntry = siumLangs.errors.length as ( - params: { length: number; actual: number; count: number } - ) => { en?: string; es?: string }; + const lengthEntry = siumLangs.errors.length as (params: { + length: number; + actual: number; + count: number; + }) => { en?: string; es?: string }; it('EN: singular when count=1', () => { const result = lengthEntry({ length: 1, actual: 0, count: 1 }); diff --git a/src/arts/sium/test/lazy.test.ts b/src/arts/sium/test/lazy.test.ts index 827ec98fb..f1790bcec 100644 --- a/src/arts/sium/test/lazy.test.ts +++ b/src/arts/sium/test/lazy.test.ts @@ -160,7 +160,7 @@ describe('lazy()', () => { expect(schema['~standard'].version).toBe(1); expect(schema['~standard'].vendor).toBe('sium'); - await expect(schema['~standard'].validate('ok')).resolves.toEqual({ value: 'ok' }); + expect(await schema['~standard'].validate('ok')).toEqual({ value: 'ok' }); }); it('preserves validation issue paths from recursive objects', async () => { diff --git a/src/arts/sium/test/object-utils.test.ts b/src/arts/sium/test/object-utils.test.ts new file mode 100644 index 000000000..b39df46a2 --- /dev/null +++ b/src/arts/sium/test/object-utils.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + extend, + isSiumObjectUtilError, + merge, + number, + object, + omit, + partial, + pick, + string +} from '../core'; + +const User = object({ + id: string(), + name: string(), + age: number() +}); + +describe('pick()', () => { + const schema = pick(User, ['id', 'name']); + + it('keeps only the listed fields', () => { + expect(schema.decodeSync({ id: 'x', name: 'Ada' })).toEqual({ id: 'x', name: 'Ada' }); + expect(schema['~sium'].kind).toBe('object'); + }); + + it('still validates the kept fields', () => { + expect(schema.validateSync({ id: 'x', name: 5 as never }).ok).toBe(false); + }); +}); + +describe('omit()', () => { + it('drops the listed fields', () => { + const schema = omit(User, ['age']); + expect(schema.decodeSync({ id: 'x', name: 'Ada' })).toEqual({ id: 'x', name: 'Ada' }); + }); +}); + +describe('partial()', () => { + const schema = partial(User); + + it('accepts a fully empty object and a subset', () => { + expect(schema.decodeSync({})).toEqual({}); + expect(schema.decodeSync({ id: 'x' })).toEqual({ id: 'x' }); + }); + + it('marks each field optional in introspection', () => { + const shape = schema['~sium'].shape; + expect(shape?.kind).toBe('object'); + if (shape?.kind === 'object') { + expect(shape.fields.id['~sium'].wrappers).toContain('optional'); + } + }); +}); + +describe('extend()', () => { + it('adds new fields', () => { + const schema = extend(User, { email: string() }); + expect(schema.decodeSync({ id: 'x', name: 'Ada', age: 9, email: 'a@b.com' })).toEqual({ + id: 'x', + name: 'Ada', + age: 9, + email: 'a@b.com' + }); + }); +}); + +describe('merge()', () => { + it('combines two object schemas (b wins on conflict)', () => { + const a = object({ x: string(), shared: string() }); + const b = object({ y: number(), shared: number() }); + const schema = merge(a, b); + expect(schema.decodeSync({ x: 'a', y: 1, shared: 2 })).toEqual({ x: 'a', y: 1, shared: 2 }); + }); +}); + +describe('object-util guard', () => { + it('throws SiumObjectUtilError on a non-object schema', () => { + const callPick = pick as unknown as (schema: unknown, keys: string[]) => unknown; + let caught: unknown; + try { + callPick(string(), ['length']); + } catch (error) { + caught = error; + } + expect(isSiumObjectUtilError(caught)).toBe(true); + }); +}); diff --git a/src/arts/sium/test/pipe.test.ts b/src/arts/sium/test/pipe.test.ts index 4d72cb82f..ad2965439 100644 --- a/src/arts/sium/test/pipe.test.ts +++ b/src/arts/sium/test/pipe.test.ts @@ -399,4 +399,3 @@ describe('async propagation in pipe()', () => { await expect(schema.decode('x')).resolves.toBe('X'); }); }); - diff --git a/src/arts/sium/test/primitives.test.ts b/src/arts/sium/test/primitives.test.ts index 75530e99a..6ce10c19c 100644 --- a/src/arts/sium/test/primitives.test.ts +++ b/src/arts/sium/test/primitives.test.ts @@ -296,10 +296,10 @@ describe('primitive introspection and standard interop', () => { it('bridges string() through Standard Schema v1', async () => { const schema = string(); - await expect(schema['~standard'].validate('hello')).resolves.toEqual({ + expect(await schema['~standard'].validate('hello')).toEqual({ value: 'hello' }); - await expect(schema['~standard'].validate(42)).resolves.toEqual({ + expect(await schema['~standard'].validate(42)).toEqual({ issues: [ { code: 'type', @@ -311,4 +311,3 @@ describe('primitive introspection and standard interop', () => { }); }); }); - diff --git a/src/arts/sium/test/record.test.ts b/src/arts/sium/test/record.test.ts new file mode 100644 index 000000000..038c960c6 --- /dev/null +++ b/src/arts/sium/test/record.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { number, record, string } from '../core'; + +describe('record()', () => { + const schema = record(number()); + + it('decodes an object of uniform values (including empty)', () => { + expect(schema.decodeSync({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }); + expect(schema.decodeSync({})).toEqual({}); + }); + + it('validates each value and reports the key path', () => { + const result = schema.validateSync({ a: 1, b: 'nope' as never }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues[0].path).toEqual(['b']); + expect(result.issues[0].code).toBe('type'); + } + }); + + it('rejects non-objects and arrays with a type issue', () => { + expect(schema.validateSync([] as never).ok).toBe(false); + expect(schema.validateSync(null as never).ok).toBe(false); + }); + + it('exposes record introspection', () => { + expect(schema['~sium'].kind).toBe('record'); + expect(schema['~sium'].shape).toMatchObject({ kind: 'record' }); + }); + + it('encodes value-by-value (identity for primitives)', () => { + const codec = record(string()); + expect(codec.encode({ x: 'a', y: 'b' })).toEqual({ x: 'a', y: 'b' }); + }); +}); diff --git a/src/arts/sium/test/refines-2.test.ts b/src/arts/sium/test/refines-2.test.ts new file mode 100644 index 000000000..32dcd0e1b --- /dev/null +++ b/src/arts/sium/test/refines-2.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + datetime, + finite, + ipv4, + multipleOf, + nonnegative, + number, + pipe, + positive, + slug, + string, + uuid +} from '../core'; +import type { Schema } from '../core'; + +/** Returns the first issue code, or `null` when the value passes. */ +function codeOf(schema: Schema, value: I): string | null { + const result = schema.validateSync(value); + return result.ok ? null : result.issues[0].code; +} + +describe('uuid()', () => { + const schema = pipe(string(), uuid()); + + it('accepts valid UUIDs in any case', () => { + expect(schema.validateSync('123e4567-e89b-12d3-a456-426614174000').ok).toBe(true); + expect(schema.validateSync('123E4567-E89B-12D3-A456-426614174000').ok).toBe(true); + }); + + it('rejects malformed UUIDs with code uuid', () => { + expect(codeOf(schema, 'not-a-uuid')).toBe('uuid'); + expect(codeOf(schema, '123e4567e89b12d3a456426614174000')).toBe('uuid'); + }); +}); + +describe('slug()', () => { + const schema = pipe(string(), slug()); + + it('accepts lowercase hyphen-separated slugs', () => { + expect(schema.validateSync('hello-world').ok).toBe(true); + expect(schema.validateSync('a1-b2-c3').ok).toBe(true); + }); + + it('rejects uppercase, spaces and edge / doubled hyphens', () => { + for (const bad of ['Hello', 'hello world', '-x', 'x-', 'a--b', '']) { + expect(codeOf(schema, bad)).toBe('slug'); + } + }); +}); + +describe('datetime()', () => { + const schema = pipe(string(), datetime()); + + it('accepts ISO 8601 date-times (plain, Z, offset)', () => { + for (const value of [ + '2026-06-14T10:30:00', + '2026-06-14T10:30:00.123Z', + '2026-06-14T10:30:00+02:00' + ]) { + expect(schema.validateSync(value).ok).toBe(true); + } + }); + + it('rejects date-only and malformed strings with code datetime', () => { + for (const bad of ['2026-06-14', '10:30:00', 'not-a-date']) { + expect(codeOf(schema, bad)).toBe('datetime'); + } + }); +}); + +describe('ipv4()', () => { + const schema = pipe(string(), ipv4()); + + it('accepts valid dotted-quad addresses', () => { + for (const value of ['0.0.0.0', '192.168.1.1', '255.255.255.255']) { + expect(schema.validateSync(value).ok).toBe(true); + } + }); + + it('rejects out-of-range and malformed addresses with code ipv4', () => { + for (const bad of ['256.0.0.1', '1.2.3', '1.2.3.4.5', 'abc']) { + expect(codeOf(schema, bad)).toBe('ipv4'); + } + }); +}); + +describe('finite()', () => { + const schema = pipe(number(), finite()); + + it('accepts finite numbers', () => { + expect(schema.validateSync(0).ok).toBe(true); + expect(schema.validateSync(-12.5).ok).toBe(true); + }); + + it('rejects the infinities with code finite', () => { + expect(codeOf(schema, Number.POSITIVE_INFINITY)).toBe('finite'); + expect(codeOf(schema, Number.NEGATIVE_INFINITY)).toBe('finite'); + }); +}); + +describe('positive()', () => { + const schema = pipe(number(), positive()); + + it('accepts > 0 and rejects 0 / negatives with code positive', () => { + expect(schema.validateSync(0.1).ok).toBe(true); + expect(codeOf(schema, 0)).toBe('positive'); + expect(codeOf(schema, -1)).toBe('positive'); + }); +}); + +describe('nonnegative()', () => { + const schema = pipe(number(), nonnegative()); + + it('accepts >= 0 and rejects negatives with code nonnegative', () => { + expect(schema.validateSync(0).ok).toBe(true); + expect(schema.validateSync(5).ok).toBe(true); + expect(codeOf(schema, -0.5)).toBe('nonnegative'); + }); +}); + +describe('multipleOf()', () => { + it('accepts integer multiples, with float tolerance', () => { + expect(pipe(number(), multipleOf(5)).validateSync(15).ok).toBe(true); + expect(pipe(number(), multipleOf(0.1)).validateSync(0.3).ok).toBe(true); + }); + + it('rejects non-multiples with code multiple_of and params.multiple', () => { + const result = pipe(number(), multipleOf(5)).validateSync(7); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues[0].code).toBe('multiple_of'); + expect(result.issues[0].params).toEqual({ multiple: 5 }); + } + }); +}); diff --git a/src/arts/sium/test/resolver.test.ts b/src/arts/sium/test/resolver.test.ts index e88a4d292..0adfd94bf 100644 --- a/src/arts/sium/test/resolver.test.ts +++ b/src/arts/sium/test/resolver.test.ts @@ -87,9 +87,7 @@ describe('resolve()', () => { expected: 'user,guest' } ) - ).toBe( - 'Unknown value "admin" for discriminator "type" (expected one of user,guest)' - ); + ).toBe('Unknown value "admin" for discriminator "type" (expected one of user,guest)'); }); }); }); diff --git a/src/arts/sium/test/schema.test.ts b/src/arts/sium/test/schema.test.ts index 3f4b5124a..fbf126076 100644 --- a/src/arts/sium/test/schema.test.ts +++ b/src/arts/sium/test/schema.test.ts @@ -143,10 +143,10 @@ describe('createSchema()', () => { encode: (value: string) => value }); - await expect(successSchema['~standard'].validate('hello')).resolves.toEqual({ + expect(await successSchema['~standard'].validate('hello')).toEqual({ value: 'HELLO' }); - await expect(failureSchema['~standard'].validate('hello')).resolves.toEqual({ + expect(await failureSchema['~standard'].validate('hello')).toEqual({ issues: [ { code: 'custom', @@ -157,4 +157,3 @@ describe('createSchema()', () => { }); }); }); - diff --git a/src/arts/sium/test/standard-schema.test.ts b/src/arts/sium/test/standard-schema.test.ts index a09fec65d..47c2727bc 100644 --- a/src/arts/sium/test/standard-schema.test.ts +++ b/src/arts/sium/test/standard-schema.test.ts @@ -8,6 +8,7 @@ import { object, optional, pipe, + refine, string } from '../core'; import type { StandardSchemaV1 } from '../core'; @@ -101,13 +102,15 @@ describe('Standard Schema validate() roundtrips', () => { }); it('returns { value } on type schema success', async () => { - await expect(dateValue()['~standard'].validate(sampleDate)).resolves.toEqual({ + // Domain-value schemas are sync, so their SS adapter resolves + // synchronously now — `await` still works (no-op on a plain value). + expect(await dateValue()['~standard'].validate(sampleDate)).toEqual({ value: sampleDate }); - await expect(timeValue()['~standard'].validate(sampleTime)).resolves.toEqual({ + expect(await timeValue()['~standard'].validate(sampleTime)).toEqual({ value: sampleTime }); - await expect(colorValue()['~standard'].validate(sampleColor)).resolves.toEqual({ + expect(await colorValue()['~standard'].validate(sampleColor)).toEqual({ value: sampleColor }); }); @@ -162,3 +165,35 @@ describe('Standard Schema validate() roundtrips', () => { expectTypeOf().toEqualTypeOf(); }); }); + +describe('Standard Schema validate() sync fast-path', () => { + it('resolves synchronously for a sync schema (success)', () => { + const result = string()['~standard'].validate('hi'); + + expect(result).not.toBeInstanceOf(Promise); + expect(result).toEqual({ value: 'hi' }); + }); + + it('resolves synchronously for a sync schema (failure)', () => { + const result = number()['~standard'].validate('nope'); + + expect(result).not.toBeInstanceOf(Promise); + expect(result).toHaveProperty('issues'); + }); + + it('returns a Promise for a schema with async steps', async () => { + const asyncSchema = pipe( + string(), + refine(async (value: string) => value.length > 0, { + code: 'custom', + message: '#?sium.errors.custom|must not be empty' + }) + ); + + expect(asyncSchema['~sium'].async).toBe(true); + + const result = asyncSchema['~standard'].validate('hi'); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toEqual({ value: 'hi' }); + }); +}); diff --git a/src/arts/sium/test/time.test.ts b/src/arts/sium/test/time.test.ts index 46e065686..37e7dde26 100644 --- a/src/arts/sium/test/time.test.ts +++ b/src/arts/sium/test/time.test.ts @@ -22,7 +22,9 @@ describe('hour()', () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.issues[0].code).toBe('max_value'); - expect(result.issues[0].message).toBe('#?sium.errors.max_value|Must be at most {{max}} (received {{actual}})'); + expect(result.issues[0].message).toBe( + '#?sium.errors.max_value|Must be at most {{max}} (received {{actual}})' + ); } }); @@ -32,7 +34,9 @@ describe('hour()', () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.issues[0].code).toBe('min_value'); - expect(result.issues[0].message).toBe('#?sium.errors.min_value|Must be at least {{min}} (received {{actual}})'); + expect(result.issues[0].message).toBe( + '#?sium.errors.min_value|Must be at least {{min}} (received {{actual}})' + ); } }); @@ -270,4 +274,3 @@ describe('timeRange()', () => { expect(schema.encode(r)).toBe(r); }); }); - diff --git a/src/arts/sium/test/transforms.test.ts b/src/arts/sium/test/transforms.test.ts new file mode 100644 index 000000000..15a35e899 --- /dev/null +++ b/src/arts/sium/test/transforms.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { nullish, number, pipe, string, toLowerCase, toUpperCase, trim } from '../core'; + +describe('trim()', () => { + it('trims surrounding whitespace on decode', () => { + const schema = pipe(string(), trim()); + expect(schema.decodeSync(' hello ')).toBe('hello'); + expect(schema.decodeSync('x')).toBe('x'); + }); + + it('is a transform (effect) and keeps kind string', () => { + const schema = pipe(string(), trim()); + expect(schema['~sium'].kind).toBe('string'); + expect(schema['~sium'].effects).toContain('transform'); + }); +}); + +describe('toLowerCase() / toUpperCase()', () => { + it('normalizes case on decode', () => { + expect(pipe(string(), toLowerCase()).decodeSync('HeLLo')).toBe('hello'); + expect(pipe(string(), toUpperCase()).decodeSync('HeLLo')).toBe('HELLO'); + }); + + it('composes left-to-right (trim then lowercase)', () => { + const schema = pipe(string(), trim(), toLowerCase()); + expect(schema.decodeSync(' Ada@Example.COM ')).toBe('ada@example.com'); + }); +}); + +describe('nullish()', () => { + const schema = nullish(number()); + + it('accepts undefined, null and valid values', () => { + expect(schema.decodeSync(undefined)).toBeUndefined(); + expect(schema.decodeSync(null)).toBeNull(); + expect(schema.decodeSync(42)).toBe(42); + }); + + it('rejects invalid non-nullish values', () => { + expect(schema.validateSync('nope' as never).ok).toBe(false); + }); + + it('reports both optional and nullable wrappers, kind preserved', () => { + expect(schema['~sium'].kind).toBe('number'); + expect(schema['~sium'].wrappers).toEqual(['optional', 'nullable']); + }); + + it('encodes undefined / null directly', () => { + expect(schema.encode(undefined)).toBeUndefined(); + expect(schema.encode(null)).toBeNull(); + expect(schema.encode(7)).toBe(7); + }); +}); diff --git a/src/arts/sium/test/tuple.test.ts b/src/arts/sium/test/tuple.test.ts new file mode 100644 index 000000000..498916ee4 --- /dev/null +++ b/src/arts/sium/test/tuple.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { boolean, number, string, tuple } from '../core'; + +describe('tuple()', () => { + const schema = tuple(string(), number(), boolean()); + + it('decodes a matching tuple positionally', () => { + expect(schema.decodeSync(['a', 1, true])).toEqual(['a', 1, true]); + }); + + it('validates each position and reports the index path', () => { + const result = schema.validateSync(['a', 'nope', true] as never); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues[0].path).toEqual([1]); + expect(result.issues[0].code).toBe('type'); + } + }); + + it('rejects a wrong length with tuple_length + params', () => { + const result = schema.validateSync(['a', 1] as never); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues[0].code).toBe('tuple_length'); + expect(result.issues[0].params).toEqual({ length: 3, actual: 2 }); + } + }); + + it('rejects non-arrays with a type issue', () => { + const result = schema.validateSync({} as never); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.issues[0].code).toBe('type'); + }); + + it('exposes tuple introspection', () => { + expect(schema['~sium'].kind).toBe('tuple'); + expect(schema['~sium'].shape).toMatchObject({ kind: 'tuple' }); + }); + + it('encodes element-by-element (identity for primitives)', () => { + expect(schema.encode(['a', 1, true])).toEqual(['a', 1, true]); + }); +}); diff --git a/src/arts/sium/types/_helpers.ts b/src/arts/sium/types/_helpers.ts index 7d24bf1f6..6fc72295d 100644 --- a/src/arts/sium/types/_helpers.ts +++ b/src/arts/sium/types/_helpers.ts @@ -1,6 +1,7 @@ import { SiumValidationError } from '../errors'; import { createSchema } from '../core/schema'; import { issue } from '../core/issue'; +import { actualType } from '../core/internals'; import type { MetaAnnotations, Schema, SchemaKind } from '../core/types'; type DomainSchemaOptions = { @@ -77,7 +78,7 @@ function createDomainTypeError(expected: string, input: unknown): SiumValidation issue({ code: 'type', message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', - params: { expected, actual: String(input) } + params: { expected, actual: actualType(input) } }) ]); } diff --git a/src/arts/sium/types/date.ts b/src/arts/sium/types/date.ts index 6e1cc5d7b..2a8a62f7f 100644 --- a/src/arts/sium/types/date.ts +++ b/src/arts/sium/types/date.ts @@ -6,6 +6,7 @@ import { integer, max, min } from '../core'; import { createSchema } from '../core/schema'; import { SiumValidationError } from '../core'; import { issue } from '../core/issue'; +import { actualType } from '../core/internals'; import { createDomainRangeSchema, createDomainValueSchema } from './_helpers'; function isDateValue(value: unknown): value is DateValue { @@ -35,10 +36,7 @@ function isOrderedDateRange(value: DateRange): boolean { * * Checks that the input is a dias `DateValue`-compatible object. */ -export function dateValue(opts?: { - calendar?: 'gregorian' | 'buddhist' | string; - granularity?: 'day' | 'hour' | 'minute' | 'second'; -}) { +export function dateValue() { return createDomainValueSchema({ kind: 'date', expected: 'DateValue', @@ -77,7 +75,7 @@ export function coerceDate() { issue({ code: 'type', message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}', - params: { expected: 'Date | DateValue', actual: String(input) } + params: { expected: 'Date | DateValue', actual: actualType(input) } }) ]); }, diff --git a/src/arts/sium/types/time.ts b/src/arts/sium/types/time.ts index 8dfddf63d..f18548ec3 100644 --- a/src/arts/sium/types/time.ts +++ b/src/arts/sium/types/time.ts @@ -33,13 +33,9 @@ function isOrderedTimeRange(value: TimeRange): boolean { /** * Validates a composite time value (Time | CalendarDateTime | ZonedDateTime). * - * Checks that the input is a dias `TimeValue`-compatible object and optionally - * enforces a minimum granularity via a refine step. + * Checks that the input is a dias `TimeValue`-compatible object. */ -export function timeValue(opts?: { - granularity?: 'hour' | 'minute' | 'second'; - hourCycle?: 12 | 24; -}) { +export function timeValue() { return createDomainValueSchema({ kind: 'time', expected: 'TimeValue',