25 KiB
Sium
Sium es la capa de contratos, validacion e introspeccion de Active.
Su objetivo no es competir con Zod, Valibot o ArkType como libreria generalista. Sium existe para que Active tenga un contrato nativo entre datos, validacion, errores traducibles, tipos de dominio y UI auto-generada.
Este archivo es la unica documentacion Markdown del modulo. Si cambia el comportamiento de Sium, actualiza este README en lugar de crear documentos paralelos.
Sium no forma parte de active-app core
createActiveApp(...) deliberadamente no construye Sium. La validacion es
page-scoped: solo las paginas con formularios la necesitan. Cargar todo
el core de schemas/types/issues en paginas sin formularios serìa coste
innecesario.
El patron estandar es una linea al inicio del modulo de la pagina:
import { createEngineSium } from '$sium';
const sium = createEngineSium({ langs: App.langs, logger: App.logger });
Asi cada formulario obtiene un engine afinado a sus necesidades (logger category, contexto de validacion, etc.) y comparte la traduccion de issues + el bus de logs con la app.
Estado
Core, tipos de dominio, lenguajes, Standard Schema e introspeccion estan activos.
Sium no tiene adapter Svelte propio, por diseno: la integracion con UI es agnostica al validador. Los formularios (createForm / Form.Provider) consumen cualquier schema via ~standard, Form.AutoFields auto-renderiza via la introspeccion ~sium, y langs/logger se inyectan por createEngineSium({ langs, logger }). El core sigue siendo TypeScript puro.
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()(rechazaInfinity/-Infinity/NaN),positive()(> 0),nonnegative()(>= 0),multipleOf(n). - Coercion:
coerceNumber(),coerceBoolean(),coerceString()— hermanos generales decoerceDate()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 elResultdirecto en vez dePromise). SS v1 permiteResult | Promise<Result>; esto elimina el branch async + el workarounduntracken los consumidores (form-core,storage) para schemas sincronos. - Transforms de string:
trim(),toLowerCase(),toUpperCase()para normalizar formularios. - Modificador
nullish(): aceptaundefinedynull(=optional+nullable). - Combinadores
tuple()yrecord(): tupla heterogenea de longitud fija + diccionario de claves arbitrarias. Amplian el vocabularioSchemaKind('tuple'/'record');Form.AutoFieldslos degrada con un aviso "not supported yet". - Utilidades de object:
pick/omit/partial/extend/merge— derivan unobject()reusando los campos del original, con tipos precisos viaPick/Omit/Partial. - API depurada:
dateValue()/timeValue()ya no aceptanopts(eran ignorados). Para validar granularidad/calendario, compon unrefinesobre 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.
import { createEngineSium } from '$sium';
const sium = createEngineSium();
Si quieres traducciones activas, inyecta el engine de traducciones:
import { createEngineSium, siumLangs } from '$sium';
import { createEngineLangs } from '$langs';
const langs = createEngineLangs({ sium: siumLangs }, 'es');
const sium = createEngineSium({ langs });
import { object, pipe, string, email, min } from '$sium/core';
import { colorValue, dateValue, timeValue } from '$sium/types';
import { siumLangs, ISSUE_CODES, resolve } from '$sium/langs';
createSchema() es interno. La API publica debe entrar por createEngineSium() o por los barrels core, types y langs.
El directorio _examples/ contiene esquemas de referencia (formularios de
login, signup, settings, etc.) usados por /test/sium y citados en este
README. No es API publica — el guion bajo inicial es la convencion que
señala material interno/dev. No esta re-exportado por sium/index.ts y
ningun consumidor lo importa. Si encuentras un caso real para uno de esos
esquemas, copialo al consumidor; promoverlo a sium publico significaria
soporte indefinido para una decision de UX que no pertenece al runtime.
Idea Central
Un schema Sium es un codec bidireccional con validacion, introspeccion y compatibilidad Standard Schema.
interface Schema<I = unknown, O = I> {
readonly '~standard': StandardSchemaV1.Props<I, O>;
readonly '~sium': SiumIntrospection;
decode(input: I, ctx?: Partial<Ctx>): Promise<O>;
decodeSync(input: I, ctx?: Partial<Ctx>): O;
encode(value: O): I;
validate(input: I, ctx?: Partial<Ctx>): Promise<Result<O>>;
validateSync(input: I, ctx?: Partial<Ctx>): Result<O>;
}
decode valida y transforma. Si falla, lanza SiumValidationError.
validate envuelve decode y devuelve { ok: true, value } o { ok: false, 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. 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.
Por Que Existe
Sium cubre cuatro huecos que las librerias externas no resuelven de forma nativa dentro de Active.
- Tipos de dominio:
ColorValue,DateValue,TimeValuey sus segmentos ya existen en Active. - UI metadata:
meta.widget,meta.channel, labels y opciones viajan dentro del schema. - Errores traducibles: los issues llevan
code,messageidlangref,paramsypath. - Interop: cada schema tambien habla Standard Schema v1.
Arquitectura
src/arts/sium/
index.ts fachada publica
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:
core/no importa Svelte, UI,days,colornilangs.types/puede importarcore/,$libs/colory$libs/days.langs/define el catalogosiumLangsy un fallback minimo de interpolacion.engine-sium.tscompone la fachada publica.
Guia Rapida
Usar La Fachada
import { createEngineSium } from '$sium';
const s = createEngineSium();
export const User = s.object({
name: s.pipe(s.string(), s.min(2), s.max(40)),
email: s.pipe(s.string(), s.email()),
age: s.optional(s.pipe(s.number(), s.integer(), s.min(0)))
});
const result = await User.validate({
name: 'Ada',
email: 'ada@example.com',
age: 37
});
Con integracion:
const s = createEngineSium({ langs, logger });
s.resolveIssue({
path: ['email'],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: { expected: 'string', actual: 'number' }
});
Si langs existe, resolveIssue() delega en EngineLangs. Si no existe, usa el fallback local de $sium/langs.resolve.
En desarrollo tambien puedes validar a traves del engine para obtener logs debug cuando hay issues:
const result = await s.validate(User, input);
const syncResult = s.validateSync(User, input);
Estos wrappers no cambian el resultado de schema.validate(). Solo emiten un log debug en la categoria exportada LOGGER_CATEGORY cuando la validacion falla y has inyectado logger.
Usar Imports Granulares
import { object, optional, pipe, string, number, integer, min, email } from '$sium/core';
export const User = object({
name: pipe(string(), min(2)),
email: pipe(string(), email()),
age: optional(pipe(number(), integer(), min(0)))
});
Validar
const result = await User.validate(input);
if (result.ok) {
result.value;
} else {
result.issues;
}
Decodificar Con Excepcion
import { SiumValidationError } from '$sium/core';
try {
const value = await User.decode(input);
} catch (error) {
if (error instanceof SiumValidationError) {
console.log(error.issues);
}
}
Codificar
const QueryNumber = s.pipe(
s.string(),
s.codec(
(value) => Number(value),
(value) => String(value)
)
);
await QueryNumber.decode('42'); // 42
QueryNumber.encode(42); // '42'
API De Core
Primitivos
string();
number();
boolean();
literal('admin');
enumOf(['admin', 'user', 'guest'] as const);
Modificadores
optional(string());
nullable(string());
nullish(string());
defaulted(number(), () => 18);
optional permite undefined.
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:
optional(asyncSchema).decodeSync(undefined), nullable(asyncSchema).decodeSync(null) y
defaulted(asyncSchema).decodeSync(undefined) no entran al schema interno. Cualquier valor que
si necesite validar el inner async lanza SiumAsyncSchemaError en APIs sync.
Combinadores
object({ name: string() });
array(string());
tuple(string(), number(), boolean());
record(number());
union(string(), number());
discriminated('kind', [
object({ kind: literal('text'), value: string() }),
object({ kind: literal('count'), value: number() })
]);
object() usa unknownKeys: 'strip' por defecto.
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:
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
pipe(string(), min(3), max(40), meta({ label: 'Name', widget: 'text-field' }));
Steps disponibles:
refine(check, fail, opts?)transform(fn, opts?)codec(decode, encode, opts?)meta(annotations)
Si una funcion normal devuelve Promise, declara el step como async.
const UniqueEmail = pipe(
string(),
refine(
(value) => api.emailIsFree(value),
{
code: 'custom',
message: '#?sium.errors.custom|Email is already in use'
},
{ async: true }
)
);
Refines Incluidos
min(2);
max(40);
length(8);
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:
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():
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.
Color
import {
colorValue,
red,
green,
blue,
hue,
saturation,
lightness,
brightness,
alpha
} from '$sium/types';
const Color = colorValue();
const Red = red();
colorValue() valida un ColorValue normalizado con hex, rgb, hsl y hsv.
Los segmentos RGB son enteros 0..255.
Los segmentos HSL/HSV son numeros en sus rangos naturales.
alpha() valida 0..1.
Fecha
import { dateValue, dateRange, coerceDate, year, month, day } from '$sium/types';
const DateOnly = dateValue();
const Range = dateRange();
const FromNativeDate = coerceDate();
dateValue() acepta DateValue de $libs/days.
coerceDate() acepta Date | DateValue y devuelve DateValue.
dateRange() valida { start, end } y rechaza rangos donde start > end.
Hora
import { timeValue, timeRange, hour, minute, second, dayPeriod } from '$sium/types';
const TimeOnly = timeValue();
const Range = timeRange();
timeRange() valida { start, end } y rechaza rangos donde start > end.
hour({ cycle: 12 }) usa rango 1..12.
hour() usa rango 0..23.
Introspeccion
Cada schema expone schema['~sium'].
const schema = pipe(string(), email(), meta({ label: 'Email', widget: 'email-field' }));
schema['~sium'];
Forma simplificada:
{
kind: 'string',
wrappers: [],
effects: ['refine'],
async: false,
meta: { label: 'Email', widget: 'email-field' },
shape: undefined
}
Herramientas incluidas:
serializeSchema(schema);
countLeafFields(schema);
walkSchema(schema, (node, path) => {});
walkSchema() recorre objects, arrays y wrappers. Las variantes de union/discriminated no se expanden automaticamente para evitar UI ambigua.
Errores E i18n
Un issue tiene esta forma:
type Issue = {
path: ReadonlyArray<string | number>;
code: string;
message: string;
params?: Record<string, unknown>;
};
message usa idlangref:
#?sium.errors.type|Expected {{expected}} but received {{actual}}
Los mensajes base viven en siumLangs.
import { siumLangs, resolve } from '$sium/langs';
resolve('#?sium.errors.type|Invalid', {
expected: 'string',
actual: 'number'
});
resolve() de $sium/langs no traduce por locale. Es solo el fallback local de interpolacion cuando no hay EngineLangs.
Para traduccion real:
const sium = createEngineSium({ langs });
const text = sium.resolveIssue(issue, 'es');
El engine expone langSchema como nombre historico para acceder al catalogo
estatico si lo necesitas:
sium.langSchema === siumLangs;
Codigos estables actuales:
type
literal
enum
min_value
max_value
min_length
max_length
length
regex
email
url
integer
custom
range_order
unknown_keys
union_no_match
discriminated_missing_key
discriminated_unknown_value
Contexto De Validacion
Los callbacks de refine, transform y codec reciben ctx.
type Ctx = {
path: ReadonlyArray<string | number>;
rootInput: unknown;
rootValue?: unknown;
locale?: string;
meta?: Record<string, unknown>;
};
ctx.path es absoluto. En object({ user: object({ name }) }), un refine de name recibe ['user', 'name'].
ctx.rootInput es el input original.
ctx.rootValue es passthrough opcional. Sium lo conserva si el caller o un adapter lo
pasan en el contexto, pero el core no lo sintetiza automaticamente.
Para validaciones cross-field sobre input de usuario, usa ctx.rootInput. Para validaciones
cross-field sobre datos ya decodificados, coloca un refine() sobre el schema object() y usa
el objeto decodificado que recibe ese refine.
Ejemplos
Login
import { object, pipe, string, email, min, meta } from '$sium/core';
export const Login = object({
email: pipe(string(), email(), meta({ label: 'Email' })),
password: pipe(string(), min(8), meta({ label: 'Password' }))
});
Signup Con Confirmacion
import { boolean, object, optional, pipe, refine, string, meta } from '$sium/core';
export const Signup = pipe(
object({
email: pipe(string(), meta({ label: 'Email' })),
password: pipe(string(), meta({ label: 'Password' })),
confirmPassword: pipe(string(), meta({ label: 'Confirm password' })),
acceptTerms: pipe(
boolean(),
refine((value) => value === true, {
code: 'custom',
message: '#?sium.errors.custom|You must accept the terms'
}),
meta({ label: 'Accept terms' })
),
newsletter: pipe(optional(boolean()), meta({ label: 'Newsletter' }))
}),
refine((value) => value.password === value.confirmPassword, {
code: 'custom',
message: '#?sium.errors.custom|Passwords do not match'
})
);
Perfil Con Fecha Y Color
import { object, pipe, string, email, min, max, meta } from '$sium/core';
import { colorValue, dateValue } from '$sium/types';
export const Profile = object({
name: pipe(string(), min(2), max(40), meta({ label: 'Name' })),
email: pipe(string(), email(), meta({ label: 'Email' })),
birthDate: pipe(dateValue(), meta({ label: 'Birth date', widget: 'date-field' })),
favoriteColor: pipe(colorValue(), meta({ label: 'Favorite color', widget: 'color-picker' }))
});
Reserva Con Rango
import { integer, max, meta, min, number, object, optional, pipe, string } from '$sium/core';
import { dateRange, timeValue } from '$sium/types';
export const Booking = 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' }))
});
Discriminated Union
import { discriminated, literal, number, object, string } from '$sium/core';
export const Event = discriminated('kind', [
object({
kind: literal('text'),
message: string()
}),
object({
kind: literal('score'),
value: number()
})
]);
Recursion Con Lazy
import { array, lazy, object, optional, string } from '$sium/core';
import type { Schema } from '$sium/core';
type TreeNode = {
label: string;
children?: TreeNode[];
};
export const Tree: Schema<TreeNode> = lazy(() =>
object({
label: string(),
children: optional(array(Tree))
})
);
lazy() protege errores de construccion del schema: si el thunk intenta usar el propio proxy
mientras resuelve, o devuelve ese mismo proxy como schema final, lanza SiumLazyResolvingError.
Esto no es deteccion de ciclos en datos de usuario. Si pasas un objeto runtime autociclico a un
schema recursivo, el decode/encode puede seguir desbordando la pila; valida esos datos antes o
anade una capa de normalizacion especifica del dominio.
Transform Y Codec
import { codec, pipe, string, transform } from '$sium/core';
export const TrimmedLower = pipe(
string(),
transform((value) => value.trim().toLowerCase())
);
export const NumberFromString = pipe(
string(),
codec(
(value) => Number(value),
(value) => String(value)
)
);
Validacion Async
import { pipe, refine, string } from '$sium/core';
export const UniqueUsername = pipe(
string(),
refine(
(value) => fetch(`/api/users/${value}`).then((res) => res.status === 404),
{
code: 'custom',
message: '#?sium.errors.custom|Username is already taken'
},
{ async: true }
)
);
Integracion Svelte
Sium no tiene adapter Svelte propio: la integracion es agnostica al validador y vive en la capa de formularios, no en sium.
- El core es TypeScript puro y se usa sin Svelte.
- Los schemas exponen
~standard(Standard Schema v1), asi quecreateForm/Form.Providerlos validan reactivamente igual que a Zod/Valibot/ArkType. Los schemas sincronos resuelven de forma sincrona. Form.AutoFieldsauto-renderiza los campos leyendo la introspeccion~sium(kind,wrappers,meta.widget, …).- Los issues idlangref (
#?sium.errors.*) los traduce el serviciolangscompartido, reactivo al locale activo. - langs/logger se inyectan en sium por
createEngineSium({ langs, logger }). sium es page-scoped: lo declara la app, no el runtime UIX.
Recetas Rapidas
UUID v4
const UUID = pipe(
string(),
regex(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
);
Slug
const Slug = pipe(string(), min(1), max(80), regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/));
Password Fuerte
const StrongPassword = pipe(
string(),
min(8),
refine((value) => /[a-z]/.test(value), {
code: 'custom',
message: '#?sium.errors.custom|Password needs a lowercase letter'
}),
refine((value) => /[A-Z]/.test(value), {
code: 'custom',
message: '#?sium.errors.custom|Password needs an uppercase letter'
}),
refine((value) => /\d/.test(value), {
code: 'custom',
message: '#?sium.errors.custom|Password needs a number'
})
);
HTTPS URL
const HttpsUrl = pipe(
string(),
url(),
refine((value) => new URL(value).protocol === 'https:', {
code: 'custom',
message: '#?sium.errors.custom|URL must use HTTPS'
})
);
File Upload
const ImageFile = refine<File>((file) => file.size <= 2_000_000 && file.type.startsWith('image/'), {
code: 'custom',
message: '#?sium.errors.custom|File must be an image under 2 MB'
});
Troubleshooting
SiumValidationError
Lanzado por decode cuando hay errores de validacion.
if (error instanceof SiumValidationError) {
error.issues;
}
SiumAsyncSchemaError
Lanzado por decodeSync o validateSync cuando la ruta de validacion necesita steps async.
Los wrappers de sentinel pueden devolver sincronamente si no consultan el inner async
(optional(undefined), nullable(null), defaulted(undefined)).
Los combinadores siguen la misma regla por ruta: por ejemplo, object({ a: optional(asyncSchema) }).decodeSync({ a: undefined }) puede completarse; { a: 'x' }
lanza porque ya necesita validar el inner async.
Solucion: usa await schema.decode(...) o await schema.validate(...).
Un callback devuelve Promise pero ~sium.async es false
JavaScript no permite detectar una funcion normal que devuelve Promise sin ejecutarla. Declara el step con { async: true }.
refine((value) => Promise.resolve(Boolean(value)), { code: 'custom' }, { async: true });
El path de un error no coincide
Los errores emitidos dentro de schemas hijos deben usar ctx.path. Sium pasa paths absolutos a hijos de object y array.
Verificacion Recomendada
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
npx vitest run src/libs/days/test/parse.test.ts src/libs/days/test/queries.test.ts src/libs/color/test/guards.test.ts
Criterio Para Cambios Futuros
Antes de ampliar Sium, comprueba que el cambio cumple estas reglas.
- El core sigue siendo TypeScript puro.
- La API publica no expone
createSchema(). - Cada schema mantiene
decode,encode,validate,~standardy~sium. - Los errores son datos estructurados, no strings finales.
- Los tipos de dominio reutilizan
$libs/colory$libs/days. - La documentacion se actualiza en este unico README.