Vista generada de dec-0116: Contratos contract-first en TypeBox 1.x y salto a Elysia 2 beta, Arktype fuera
docs/decisions/dec-0116-contratos-typebox-fuera-arktype.mdVista generada desde
docs/decisions/dec-0116-contratos-typebox-fuera-arktype.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | LOCKED |
| Fecha | 2026-09-28 |
| Fichero | docs/decisions/dec-0116-contratos-typebox-fuera-arktype.md |
Enmienda a: r18, r20, r54, r60
Por qué importa (del frontmatter del ADR):
Fija la librería de los wire contracts contract-first de
@styx/api-contractsy@styx/domain(r20 §2.3) y la major de Elysia de todos los svcs HTTP. Los contratos los validan en runtime los svcs NATS (parseEnvelope+ 12 sitios) y las rutas HTTP, y la librería se carga en cada contenedor (r17). Sustituye a la propuesta dec-0115.
Nodos del roadmap que lo citan en refs: track/identity/sec, track/elysia2-typebox, track/spire, track/docs
Páginas de la documentación que lo citan: Empezar a contribuir (implementado), Generadores de la documentación (especificado), Catálogo federado (implementado), Bus y contratos (implementado), Planos y servicios (implementado), Entrar con el CLI (implementado)
Leído de docs/decisions/dec-0116-contratos-typebox-fuera-arktype.md, el fichero canónico.
elysia@2.0.0-beta.19 con typebox 1.x (pin en §Decisión 2), sin esperar a Elysia 2 estable y
sin puente por @sinclair/typebox 0.34.01c751c, nunca integrada ni
publicada) fijaba 0.34 hasta Elysia 2 estable y descartaba la beta. La decisión de waxin la
invierte y este texto la reemplaza. No hace falta ADR de enmienda porque la versión anterior no
llegó a ninguna rama compartida.@styx/api-contracts y no se derivan de Elysia.
r52 (Eden App-type) no cambia.spikes/typebox-vs-arktype/RESULTS.md + data/*.json (rama bench/typebox-vs-arktype,
commit 036fc7c, base bb1d967). Dos revisiones independientes del mismo día: una reprodujo
memoria, cold start y throughput, la otra auditó cobertura y semántica.mig/elysia2-spike (base 03ea232), commits 7acd203 (contratos a
TypeBox 1.3.34), 103d7eb (catalog-svc en Elysia 2 beta.19 + @elysia/* + Eden 2) y
64c8768 (problem+json en @styx/clients). Los escépticos no lo refutaron. Auditoría de
breaking changes y plan de lotes: artefactos de sesión elysia2/BREAKING.md y PLAN.md,
resumidos en §Hallazgos de Elysia 2.Versiones: arktype 2.2.5, @sinclair/typebox 0.34.52 (la línea que usa Elysia 1.4) y typebox
1.3.34 (línea vigente en npm), con Bun 1.4.3 y Node 22. Se portaron 17 schemas reales de unos 55.
Throughput y cold start son de la run 2 (loadavg ~2), que es la que reprodujo la revisión
independiente; la run 1 (loadavg 16) queda en data/run1-host-loaded/ sólo como referencia.
Memoria, bundle y typecheck son de la run 1 (la memoria reprodujo dentro de ±2%).
| eje (p50) | Arktype | TypeBox 0.34 compilado | TypeBox 1.x compilado (elegido) |
|---|---|---|---|
| heap tras import + 17 schemas + 1ª validación, bun / node | 17.95 / 19.62 MB | 2.16 / 2.82 MB | 3.17 / 6.59 MB |
| de ello, sólo importar la librería, bun / node | 10.50 / 12.52 MB | 0.39 / 1.72 MB | 0.71 / 3.25 MB |
| cold start de la lib (import + build + 1ª val), bun / node, run 2 | ~350 / ~460 ms | ~68 / ~100 ms | ~155 / ~242 ms |
| bundle web de la suite, min + gzip | 47 KB | 23 KB | 34 KB |
| tsgo: check / memoria | 151 ms / 43.2 MB | 60 ms / 21.4 MB | 68.5 ms / 29.3 MB |
| mensajes válidos, ops/s, bun / node | 4.25M / 3.69M (.allows) · 3.68M / 2.86M (schema(v), lo que usa producción) | 4.54M / 4.82M | 6.30M / 3.50M |
| 90% válidos / 10% inválidos con errores detallados, ops/s, bun / node | 520k / 500k (schema(v)) · 435k / 616k (.allows) | 794k / 720k | 156k / 153k |
| Elysia 1.4.30 | sí (Standard Schema) | sí (t.*) | NO: 500, validator.body.Validate is not a function |
| Elysia 2.0.0-beta.19 | sí (Standard Schema) | NO: "body" is not a schema | sí |
Cómo leer la tabla sin sobredimensionarla:
.allows gana en dos sitios. Uno es MetadataPatch (records):
6.69M / 4.64M frente a 4.31M / 1.93M de 1.x en mensajes válidos. El otro es el rechazo booleano de
unions grandes como PlaybackPlan: 9.60M frente a 1.68M. Producción no usa .allows y
PlaybackPlan no tiene schema runtime, así que ninguno de los dos pesa en el tráfico real hoy.Ponderación para styx. Se pondera por dónde paga styx cada eje:
arktype y elysia (catalog, playback, realtime, sources
y workers; identity cuando se integre) pagan unos 15 MB de heap y unos 200 ms de arranque (bun)
más con Arktype que con TypeBox 1.x sólo por la validación. Además, Elysia 2 ya carga typebox
1.x como peer (>= 1.3.0), así que el coste marginal de la librería de contratos pasa a ser
aproximadamente cero.schema(v), 3.68M / 2.86M), TypeBox 1.x compilado gana en bun y empata en node. Un payload
inválido sólo aparece si el emisor está roto o es malicioso; ahí 1.x es 2.8–4.9× más lento, y la
fachada (§Decisión 3) lo saca del camino caliente.apps/web no valida en runtime; sólo usa Eden para tipos (r52). Lo que paga es bundle:
hoy arrastra Arktype entero (47 KB gzip) por createRequestId a través del barrel. Eso se
arregla con un subpath del módulo hoja, que pesa 0 KB y no depende de esta decisión.Veredicto de la regla: TypeBox 1.x compilado consume menos que Arktype en heap, cold start, bundle y typecheck, gana en CPU de mensajes válidos en bun y empata en node. No gana en todos los ejes: pierde en el camino de error (2.8–4.9×) y en calidad de mensajes. La regla de waxin es de consumo, y en consumo la condición se cumple sin ambigüedad, así que Arktype sale.
Arktype sale de @styx/api-contracts, @styx/domain y
apps/catalog-svc/src/core/ports/ffprobe-types.ts, y de los package.json que lo declaran. Los
contratos se escriben con el builder Type.* de typebox 1.x. Value.Check sin compilar es
10–270× más lento y queda prohibido fuera de tests.
Pins exactos (sin rangos; cada bump es un cambio revisado con smoke):
elysia@2.0.0-beta.19 (tag next a 2026-09-24) y typebox@1.3.23. Corrección de
implementación (2026-09-28, lane w3/elysia2): el spike fijó 1.3.34, pero typebox >= 1.3.24
rompe la compilación eager de beta.19 (precompile tumba listen() y un schema con error
custom da 500 en todas las peticiones, también las válidas; bisección en ADOPT F1). El pin baja
a 1.3.23 y lo protege un test contra Bun real en @styx/service-http (rojo con 1.3.34, verde
con 1.3.23). Cada bump de typebox o de elysia repite ese test.@elysia/*: @elysia/opentelemetry@2.0.0-beta.1,
@elysia/cors@2.0.0-beta.1, @elysia/eden@2.0.0-beta.5, y @elysia/openapi@2.0.0-beta.4
donde se publique OpenAPI. Los @elysiajs/* no tienen versión compatible con Elysia 2.
dec-0062 R-D2.1 ("pin de eden") se aplica ahora a @elysia/eden.exact-mirror@1.2.6 y
openapi-types@12.1.3. Bun enlaza si no el exact-mirror@0.2.7 que ya está en el grafo por
Elysia 1.4 y sólo avisa con warn: incorrect peer dependency.Dos formas del mismo contrato:
Type.Object(...) exportado por api-contracts).
Elysia lo compila y lo cachea. Un validador ya compilado con Compile(schema) no falla al
registrar la ruta: da 500 en la primera petición (Compiled schema detected).compileContract(schema) de @styx/api-contracts/src/validation.ts: compila con
typebox/compile de forma perezosa, check() booleano en el camino caliente e issues() /
validate() sólo tras un check() fallido. IContractIssue = { path, message } es propio de
styx, así que el formato de logs no depende de la librería.JSON Schema pasa a ser la forma canónica serializada del contrato, porque un schema de
TypeBox ya es JSON Schema. Eso desbloquea OpenAPI (el spike lo publica en GET /openapi/json
con toOpenAPISchema(app) y el body de /scan sale igual que el contrato) y los clientes no-TS
(Swift, SDK de spire) sin pasar por .toJsonSchema(), que hoy falla con el .narrow() de
SourceBinding.preference.
Semántica: se preserva la actual y no se endurece en silencio. La paridad del bench (17 fixtures + 16 edge cases) no cubría estas divergencias. Esta es la regla para cada una:
| construcción | Arktype hoy | TypeBox 1.x por defecto | regla del port |
|---|---|---|---|
'object' (details, causedBy, ResolvePlanReply.plan, ResolvePlanPayload.client/preferences, RepresentationGeneration.metrics) | acepta arrays | los rechaza | Type.Union([Type.Object({}), Type.Array(Type.Unknown())]). Llega por el cable (JSON transporta arrays). Endurecerlo es otra decisión |
clave opcional presente con valor undefined | rechaza | acepta | se acepta la divergencia: JSON.parse no puede producir undefined |
±Infinity en number | acepta | 0.34 rechaza; en 1.x no medido | se acepta: JSON no transporta Infinity |
array disperso en string[] | rechaza | acepta | se acepta: JSON no produce huecos |
Además: .narrow() de SourceBinding.preference → Number({ minimum: 0, maximum: 1 }), y
number.integer > 0 → exclusiveMinimum. Único endurecimiento deliberado del spike:
ScanAssetCommandSchema.path lleva minLength: 1, que la ruta HTTP ya exigía; al consumir el
contrato, la restricción sube a él. No se tocan las políticas globales de TypeBox, porque
cambiarían también la validación t.* de Elysia en el mismo proceso. Si algún día se valida en
proceso algo que no venga de JSON.parse, esta tabla se reabre.
Eden (r52): no cambia de modelo. Eden 2 infiere de typeof createHttpApp también con el
schema crudo del contrato como body; el spike lo comprobó con un probe @ts-expect-error (si
Eden infiriera any, el probe se rompería).
Elysia t.*: se deja de duplicar contratos. Las rutas HTTP que hoy declaran su body con
t.Object inline pasan a usar el schema de @styx/api-contracts directamente:
catalog-svc/.../scan.ts (hecho en el spike), sources-svc/.../resolve.ts y
playback-svc/.../sessions.ts, que además revalida a mano. t.* se sigue usando sólo para
params/query locales de una ruta que no son contrato (works.ts).
spire: es otro repo, y en este hay 0 referencias. Este ADR no crea una dependencia de código
con spire. Lo que comparten es el formato de intercambio (JSON Schema) y la fuente de verdad
(@styx/api-contracts, r18).
La imagen sirve el bundle AOT (implementación, 2026-09-28, lane w3/elysia2). Elysia 2 desde
fuente arranca más lento que 1.4 (ADOPT F5), así que cada docker/<svc>-svc.Dockerfile corre
bun run build (= styx-build-service de @styx/service-http): el plugin AOT de Elysia captura
src/app.aot.ts (la misma createHttpApp del bootstrap con los puertos sin implementar) y el
bundle dist/bootstrap.js va solo al runner, sin node_modules, con bun --no-install. Build
con strip: true y compileServiceRoutes(app) antes de listen(): si la app de captura y la del
bootstrap divergen, el servicio no arranca (sin eso, 500 en esas rutas con /health en 200).
Medido en los 6 servicios (3 rondas intercaladas): arranque −42 a −55 %, RSS al estar listo
−26 a −29 % y tras carga −11 a −21 %, req/s dentro del ruido (−5 a +9 %), y las 26 respuestas
del smoke idénticas a las de fuente (docs/outcome/foundation/evidence/elysia2-aot-2026-09-28/). El manifiesto AOT es ABI de la
beta: el build de la imagen lo regenera siempre.
Auditoría de breaking changes de 1.4 → 2.0.0-beta.19 (codemod oficial @elysia/codemod
2.0.0-beta.1 más smokes ejecutables) y spike real sobre catalog-svc. Lo que el codemod cubre de
forma mecánica: orden (path, hook, handler), lifecycle sin prefijo on, scopes
'local' | 'plugin' | 'global' y scope de plugins @elysiajs/* → @elysia/*. Lo que no cubre
y se hace a mano:
422 application/problem+json con
code: 'validation'; ruta inexistente → 404 code: 'not-found'; JSON malformado → 400
code: 'parse'. Compila igual y los tests que sólo miran el status siguen verdes, pero cambia lo
que ve cualquier cliente que parsee cuerpos de error. unwrapEdenResult de @styx/clients los
trata como error de framework (EDEN_BAD_REQUEST / EDEN_NOT_FOUND), no como código de dominio
(hecho en el spike, con tests). En desarrollo el 422 reenvía el body entero (found); con
NODE_ENV=production Elysia lo omite. Los svcs con bodies sensibles (identity) no deben depender
de NODE_ENV: error handler propio, en la tanda AppSec.Compile() a rutas Elysia (§Decisión 3): 500 en la primera petición, no al registrar.HEAD pasa a opt-in (elysia/auto-head). Una ruta función responde HEAD → 404. Los
HEALTHCHECK de los 5 docker/*-svc.Dockerfile usaban wget --spider, que en GNU wget manda
HEAD: los contenedores quedarían unhealthy y el compose de apps (service_healthy) no
arrancaría en cadena. Ningún test lo detecta porque usan app.handle(GET). Regla: healthcheck con
GET explícito (wget -q -O /dev/null http://localhost:<port>/health), que vale también para
Elysia 1.4, con un test que falla si vuelve --spider.@elysia/opentelemetry@2.0.0-beta.1: su .wrap() descarta el segundo argumento, así
que context.server es null en todas las rutas de una app que monte el plugin OTel, incluso con
listen() real. En identity eso deja el rate-limit por IP con un único bucket auth:unknown
compartido (P1 de seguridad y verde falso: sus tests no montan OTel). Receta: resolver la IP desde
la instancia (app.server?.requestIP(req)) o bun patch del plugin y reportarlo upstream, con un
test de integración con OTel + listen() y dos IPs.app.stop() hace drain, ejecuta los cleanup y devuelve promesa: siempre await.
app.server es undefined sin listen (no null).afterResponse/error de un plugin son local por defecto: routeSpan de
@styx/observability (0 callers en producción) dejaría spans sin cerrar. En el spike sale del
barrel porque arrastraba elysia 1.4 a un proceso en Elysia 2 (dos majors cargados sin error
visible); su borrado espera el OK de waxin.@elysia/openapi con provider: null no publica nada, y con UI carga Scalar @latest desde CDN.
Se usa toOpenAPISchema(app) en una ruta propia con new Elysia({ introspect: true }).realtime-svc debe pasar de app.handle(req) a app.fetch(req) en su Bun.serve propio.cors({ origin: true, credentials }) refleja cualquier origen, igual que en 1.x: allowlist en la
tanda AppSec, no es breaking.Medida en un svc real (catalog-svc con Postgres, NATS y OTel reales, 5 rondas intercaladas, host
compartido): RSS −26 MB por contenedor (125.3 → 99.4 MB al estar listo, −21%), con dispersión
menor de 2 MB; la fila intermedia (TypeBox 1.x sobre Elysia 1.4) muestra que todo ese ahorro viene de
sacar Arktype y que Elysia 2 es neutro en RSS. Arranque −209 ms (−25%) con loadavg ~7, que baja a
unos 60 ms con loadavg 12: es ruidoso y no se puede repartir entre Elysia 2 y Arktype. Bundle de
apps/web: −44 KB gzip.
Cada lote es un commit o PR verificable, con typecheck, lint, tests, smoke real y
rm -rf node_modules && bun install limpio. La verificación de cierre la hace alguien distinto de
quien implementa (testing-evidence). El salto de Elysia y el de contratos son dos fases separables:
t.* y Arktype sólo vive
dentro de los handlers, y Elysia 2 acepta Standard Schema, así que se puede subir la major sin
tocar contratos: codemod, pins de §Decisión 2 y los fixes manuales de §Hallazgos.validation.ts, port de los ~55 schemas
(codemod de un solo uso, con las reglas de §Decisión 5), los 13 call sites (parseEnvelope + 12
en apps) y las rutas HTTP de §Decisión 7.Ambas pueden ir juntas, como hizo el spike (contratos primero sobre Elysia 1.4 en 7acd203,
Elysia 2 en 103d7eb), y es el orden recomendado para no cargar Arktype como puente permanente. Lo
que no se permite es dejar un svc a medias: nunca dos majors de Elysia ni dos líneas de TypeBox en un
proceso. Orden por el grafo packages → apps → web:
w2/identity añade
schemas Arktype a api-contracts y se portan con el mismo codemod. Actualizar a TypeBox/JSON Schema
y al vocabulario de lifecycle 2.0 los textos vivos que dicen "contract-first Arktype" (CLAUDE.md
root, apps/CLAUDE.md, packages/CLAUDE.md, .claude/rules/typescript-elysia.md, agentes
builder-services, README.md, workflows de auditoría). Si la migración se retrasa, subir ya
elysia a 1.4.30 (5 GHSA que el lock actual no tiene).routeSpan o port a hooks
'global'), clients (Eden 2, problem+json), subpath @styx/api-contracts/request-id para web.
Guards: 0 imports de arktype en apps//packages/; un solo typebox y un solo major de
elysia en bun.lock; exact-mirror fijado en cada consumidor de Elysia 2; ningún Dockerfile
con wget --spider.app.fetch), sources, playback (@elysia/cors, sessions.ts con el contrato, await app.stop()), identity (fix de context.server con test, compose.ts, validate() con la
fachada). Gate: docker compose ... up con todos los svcs en healthy.vite build pasa en el spike; falta un smoke Playwright de las
pantallas que llaman a catalog y revisar los consumidores de EdenErrorCode.elysia@1.4 y @elysiajs/* del lock; re-medir RSS y arranque en todos
los contenedores con el método del spike; re-medir el camino de error bajo carga NATS inválida
(fuzz de payloads). Sin esas medidas el ADR no se da por cumplido.t.* para HTTP).
JSON Schema y OpenAPI pasan a ser nativos. Se adopta la línea viva de Elysia y TypeBox (r16).@elysia/{cors,opentelemetry} van en beta.1 (julio)
frente al core beta.19 (septiembre) y ya salió un bug real (context.server). Upstream sigue
rompiendo entre betas. Cada bump exige re-leer el CHANGELOG, repetir los smokes y mantener el
pin exacto.check
primero, pero se re-mide bajo carga antes de cerrar..allows(v) en los hot paths: schema(v) es 50–100× más lento al rechazar.@sinclair/typebox 0.34 como puente hasta Elysia 2 estable (lo que decía la versión anterior
de este ADR): consume menos que 1.x, pero obliga a portar dos veces los contratos y a hacer después
un segundo salto acoplado al de Elysia. waxin eligió no esperar..use(autoHead()) en cada app para los healthchecks: ata el despliegue a un plugin del
framework; el GET explícito no depende de él.Value.Check): consume menos memoria, pero es 10–270× más lento
validando.