From 15737e0f37b57a882711c5d2ae28095dd6dbe9ea Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 5 May 2026 02:02:12 +0200 Subject: [PATCH] =?UTF-8?q?Bloque=20F1+F2=20=E2=80=94=20connection=20backo?= =?UTF-8?q?ff=20random=20+=20reauth=20singleflight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 — `ConnectionReconnectOptions.random?: () => number` lets callers inject a deterministic source for backoff jitter, threaded through `computeBackoffDelay`. Default remains `Math.random` so existing apps are unaffected. Test covers maxAttempts, the new random injection (jitter +max and -max clamped to minDelay), the disabled case and the disposed/intentional-close gate. F2 — `runAuth()` singleflight in the connection request runtime: when an auth round is in flight, every concurrent caller awaits the same promise, so only one auth frame goes on the wire. Closes the gap where `session.changed` + `session.external_changed` could land back-to-back and produce two auth frames. Tested at the request-runtime level with fake ack registry + sender (integration-level testing of this through the mock transport is timing-flaky and adds no extra coverage). Co-Authored-By: Claude Opus 4.7 (1M context) --- audit-codex.md | 549 +++++++++++++++++ auditoria- ecosistema-codex.md | 566 +++++++++++++++++ src/arts/connection/connection-requests.ts | 17 +- src/arts/connection/reconnect.ts | 16 +- .../test/connection-requests.test.ts | 173 ++++++ src/arts/connection/test/reconnect.test.ts | 58 ++ src/arts/connection/types.ts | 6 + src/arts/prefs/README.md | 582 ++++++++++++++++++ 8 files changed, 1960 insertions(+), 7 deletions(-) create mode 100644 audit-codex.md create mode 100644 auditoria- ecosistema-codex.md create mode 100644 src/arts/connection/test/connection-requests.test.ts create mode 100644 src/arts/connection/test/reconnect.test.ts create mode 100644 src/arts/prefs/README.md diff --git a/audit-codex.md b/audit-codex.md new file mode 100644 index 0000000..60f52be --- /dev/null +++ b/audit-codex.md @@ -0,0 +1,549 @@ +# Auditoria Codex - ecosistema Active + Orca + +Fecha: 2026-05-04 +Workspace: `G:\dev\svelte\active` + +## Alcance + +Se audito el estado actual del ecosistema con foco en el nuevo modulo +`src/arts/orca` y sus puntos de acoplamiento con: + +- `src/arts/active-app` +- `src/arts/bus` +- `src/arts/session` +- `src/arts/cache` +- `src/arts/perm` +- `src/arts/connection` +- documentacion publica en `src/arts/*/README.md` y pagina activa generada. + +No se modifico codigo existente. El arbol ya tenia cambios previos en: + +- `src/arts/orca/consts.ts` +- `src/arts/orca/result.ts` +- `src/arts/orca/types.ts` + +Esos cambios parecen corregir parte de la deriva de comentarios `v0.1+` respecto a +timeout/fatal/gates/compensation, pero no cierran todos los problemas. + +## Veredicto + +`orca` esta bien orientado arquitectonicamente. La decision fuerte, y la que +realmente eleva el ecosistema, es mover las reacciones inter-modulo a una capa de +orquestacion declarativa situada en `active-app`, no dentro de `cache`, `perm`, +`session` o `connection`. Eso reduce acoplamiento y permite testear flujos +compuestos como objetos de ejecucion (`OrcaRunResult`). + +El modulo, sin embargo, ya ha crecido mas rapido que su contrato. El motor actual +implementa gates, timeouts de accion, compensacion, transacciones, payloads en +tokens, fan-in y politicas de cola. La documentacion mezcla tres versiones a la +vez: boceto conceptual, v0 inicial y estado real. Antes de seguir expandiendo +`orca`, conviene congelar el contrato real y eliminar ambiguedades. + +Estado de pruebas focalizadas: + +```txt +npx vitest run src/arts/orca/test src/arts/active-app/test/presets.test.ts + +Test Files 4 passed +Tests 169 passed +``` + +## Hallazgos P1 + +### 1. Liberacion de trace no es segura con runs paralelos + +Referencia: + +- `src/arts/orca/engine-orca.ts:928` +- `src/arts/orca/engine-orca.ts:931` +- `src/arts/orca/engine-orca.ts:935` + +`maybeReleaseTrace()` borra `traceStates` si no quedan eventos en cola. El propio +comentario reconoce que no sabe si hay runs en vuelo del mismo trace. Eso era +defendible con ejecucion estrictamente secuencial, pero ya existe +`ORCA_QUEUE_PARALLEL`. Si dos eventos derivados del mismo trace corren en +paralelo, el primero que termine puede borrar el estado de trace mientras el otro +sigue vivo. + +Impacto: + +- Los contadores `maxDepth`, `maxEventsPerTrace`, `repeatedEventLimit` y + `dedupeKey` pueden resetearse antes de tiempo. +- Un loop por eventos derivados puede saltarse la proteccion si el trace se borra + mientras quedan runs paralelos activos. +- La trazabilidad de un flujo enterprise deja de ser confiable justo en el modo + mas peligroso: paralelo. + +Recomendacion: + +- Mantener `traceInFlightCount` por `traceId`. +- Incrementarlo en `spawnRun()`. +- Decrementarlo en el `finally` de `executeRun()`. +- Liberar el trace solo cuando `queuedCount(traceId) === 0` e + `inFlightCount(traceId) === 0`. +- Anadir test: dos eventos derivados con `ORCA_QUEUE_PARALLEL` en el mismo trace; + el primero termina, el segundo emite otro evento repetido y el guard sigue + aplicando. + +### 2. `provides` no limita `emits`, asi que la validacion puede mentir + +Referencia: + +- `src/arts/orca/types.ts:421` +- `src/arts/orca/types.ts:424` +- `src/arts/orca/types.ts:425` +- `src/arts/orca/engine-orca.ts:1117` +- `src/arts/orca/engine-orca.ts:1124` + +El contrato dice que `provides` es lo que una accion puede producir, y +`validate()` usa `provides` para detectar tokens imposibles. Pero el runtime +acepta cualquier token que venga en `result.emits`, aunque no este declarado en +`provides`. + +Impacto: + +- `validate()` no es una garantia fuerte; es una estimacion basada en intencion. +- Una accion puede desbloquear otra con un token que el grafo no declaraba. +- Un typo en `emits` puede cambiar el pipeline sin que `validate()` lo vea. +- El futuro `setupOrca()` tipado pierde parte de su sentido si el runtime permite + emisiones fuera de esquema. + +Recomendacion: + +- En modo dev, lanzar o registrar `CONFIGURATION_INVALID` cuando una accion emite + un token no declarado en `provides`. +- En modo prod, al menos registrar diagnostic `orca.configuration.invalid` o + descartar el token no declarado segun opcion. +- Documentar excepcion solo si se decide permitir tokens dinamicos, pero entonces + `validate()` debe llamarse "best effort", no "static graph validation". + +### 3. `ORCA_QUEUE_REPLACE` no es `replace-current`, solo reemplaza queued + +Referencia: + +- `src/arts/orca/consts.ts:73` +- `src/arts/orca/engine-orca.ts:420` +- `src/arts/orca/engine-orca.ts:421` +- `src/arts/orca/engine-orca.ts:422` +- `src/arts/orca/README.md:439` +- `src/arts/orca/README.md:1444` + +Hay dos semanticas distintas usando el mismo nombre mental: + +- Una seccion del README describe `replace` como "aborta el run activo y empieza + uno nuevo", estilo `takeLatest`. +- El motor implementa "reemplaza solo entradas queued; no aborta in-flight". +- Otra seccion posterior del README ya reconoce esta realidad. + +Impacto: + +- Para formularios, busquedas, navegacion o cambio de identidad, un usuario puede + esperar "ultima intencion gana", pero el run anterior seguira ejecutandose. +- Si la accion vieja muta cache/conexiones despues de la nueva, puede pisar estado. +- El nombre `replace` es ambiguo para usuarios que vienen de saga/Rx/RTK. + +Recomendacion: + +- Renombrar la politica actual a algo explicito: `ORCA_QUEUE_REPLACE_QUEUED` o + `ORCA_QUEUE_LATEST_QUEUED`. +- Reservar `ORCA_QUEUE_REPLACE_CURRENT` / `ORCA_QUEUE_TAKE_LATEST` para la + variante fuerte que aborta in-flight. +- Si se mantiene el nombre actual, borrar del README cualquier promesa de abortar + el run activo. + +### 4. IDs de run/event/trace usan `Date.now()` y `Math.random()` fuera de `timr` + +Referencia: + +- `src/arts/orca/engine-orca.ts:1513` +- `src/arts/orca/engine-orca.ts:1514` +- `src/arts/orca/engine-orca.ts:1517` +- `src/arts/orca/engine-orca.ts:1518` +- `src/arts/orca/engine-orca.ts:1521` +- `src/arts/orca/engine-orca.ts:1522` +- `src/arts/orca/README.md:841` + +El README establece que `orca` no debe usar `Date.now()` ni `setTimeout()` +directamente. Los timestamps del envelope ya pasan por `timers.clock.now()`, pero +los IDs de run/event/trace no. + +Impacto: + +- Tests y replay no son plenamente deterministas. +- En SSR/hydration o tests con clocks controlados, los IDs no siguen el tiempo + simulado. +- La disciplina "todo tiempo via timr" queda rota justo en el modulo que quiere + ser trazable. + +Recomendacion: + +- Anadir `idFactory` a `EngineOrcaOptions` o tres factories separadas: + `runIdFactory`, `eventIdFactory`, `traceIdFactory`. +- Default: contador monotono con prefijo, alimentado por `timers.clock.now()` si + se quiere incluir tiempo. +- Tests: factory determinista para snapshots. + +### 5. `setupOrca()` esta documentado como API recomendada pero no existe + +Referencia: + +- `src/arts/orca/README.md:235` +- `src/arts/orca/README.md:241` +- `src/arts/orca/README.md:271` +- `src/arts/orca/README.md:284` +- `src/arts/orca/README.md:1271` +- `src/arts/orca/index.ts` + +El README recomienda `setupOrca()` para apps grandes y muestra ejemplos, pero no +hay export ni implementacion en `src/arts/orca`. + +Impacto: + +- La documentacion invita a usar una API inexistente. +- El usuario no sabe si debe usar `App.Orca.onEvent()` directo, presets o un + setup tipado futuro. +- La promesa de validacion fuerte de tokens/eventos queda sin soporte. + +Recomendacion: + +- O implementar un `setupOrca()` minimo, aunque solo envuelva `onEvent` con + declaracion de eventos/tokens/actions. +- O mover esa seccion a "Futuro / v0.1" y dejar claro que v0 real es + `createEngineOrca()` + `App.Orca.onEvent()`. + +### 6. La integracion con `connection` no esta cerrada y la documentacion promete mas de lo que existe + +Referencia: + +- `src/arts/active-app/presets/standard.ts:28` +- `src/arts/active-app/presets/index.ts:5` +- `src/arts/active-app/service-factories/connections.ts:9` +- `src/arts/connection/README.md:83` +- `src/arts/connection/README.md:93` +- `src/arts/connection/README.md:100` +- `src/arts/connection/session-wiring.ts:27` + +`applyStandardOrca()` solo registra presets de cache y perm. No hay preset para +`connections.reauthenticateAll()` ni para `connections.closeAll()` en cambios de +identidad/revoke/expire. A la vez, la documentacion de `connection` habla de +`autoReauthOn: 'standard'` y de escuchar `SESSION_EVENT_IDENTITY_CHANGED`. + +Ademas, `defineActiveConnections()` ya no inyecta `bus` ni `session`; el comentario +dice que identidad debe venir por orca preset o por una `session` manual, pero ese +preset no existe. + +Impacto: + +- Una app que siga la documentacion puede creer que las conexiones se reautentican + al cambiar de usuario, pero con `applyStandardOrca(App)` no ocurre. +- El caso critico que motivo `orca` ("chat conectado con credenciales del usuario + anterior") sigue sin preset estandar. +- `connection` conserva un mecanismo interno de `session-wiring` que compite + conceptualmente con la nueva regla: los artefactos publican/reciben eventos; la + app orquesta reacciones. + +Recomendacion: + +- Crear preset en `active-app/presets`: + `applyConnectionsReauthOnIdentityChange(App)`. +- Definir si el estandar hace `reauthenticateAll()` o `closeAll()` cuando no hay + credencial nueva. +- Actualizar `applyStandardOrca()` para incluir conexiones cuando `App.connections` + exista. +- Decidir si `connection.session-wiring` queda como modo local/manual o se depreca + a favor del preset de `orca`. + +## Hallazgos P2 + +### 7. `runActionWithTimeout()` aborta de forma cooperativa, pero la accion puede seguir mutando estado + +Referencia: + +- `src/arts/orca/engine-orca.ts:1042` +- `src/arts/orca/engine-orca.ts:1052` +- `src/arts/orca/engine-orca.ts:1056` +- `src/arts/orca/engine-orca.ts:1058` +- `src/arts/orca/engine-orca.ts:1070` + +El timeout hace `actionController.abort()` y resuelve `orcaTimeout(timeoutMs)`, pero +la promesa original de la accion sigue viva si la accion no respeta `ctx.signal`. +Esto es normal en JS, pero debe tratarse como contrato de seguridad. + +Impacto: + +- Una accion timeout puede mutar cache, conexiones o permisos despues de que el run + ya haya tomado otra decision. +- Los tests pueden pasar porque el resultado es `timeout`, pero el side-effect + tardio queda fuera del trace. + +Recomendacion: + +- Documentar que toda accion async debe comprobar `ctx.signal.aborted` antes y + despues de awaits relevantes. +- Anadir helper `ctx.throwIfAborted()` o `orcaAbortIfSignaled(ctx)`. +- Anadir test: accion con timeout que intenta mutar despues; demostrar que el + patron recomendado lo evita. + +### 8. La cola es global para eventos no-parallel, aunque el contrato se lee como per-event + +Referencia: + +- `src/arts/orca/engine-orca.ts:526` +- `src/arts/orca/engine-orca.ts:527` +- `src/arts/orca/engine-orca.ts:529` +- `src/arts/orca/engine-orca.ts:531` + +`canStartRun()` serializa globalmente todos los eventos no-parallel: +`nonParallelInFlight === 0`. Eso significa que un evento lento de `cache` puede +bloquear un evento no relacionado de `perm`, `connection` o cualquier feature. + +Impacto: + +- Semantica mas conservadora y lenta de lo que sugiere "queue policy per event". +- Posible cuello de botella en apps grandes con flujos independientes. +- Si esta decision es intencional, falta nombrarla como "single lane". + +Recomendacion: + +- Decidir explicitamente: + - Modelo A: serializacion global por defecto, documentada como garantia simple. + - Modelo B: serializacion por evento, con concurrencia entre eventos distintos. +- Si se mantiene A, renombrar/comentar como `globalSerialLane`. +- Si se pasa a B, usar `inFlightByEvent` para bloquear solo el mismo evento. + +### 9. `commit()` congela el grafo; eso choca con features lazy si no se documenta como modo prod + +Referencia: + +- `src/arts/orca/types.ts:711` +- `src/arts/orca/types.ts:723` +- `src/arts/orca/engine-orca.ts:1275` +- `src/arts/orca/test/engine-orca.test.ts:4013` + +El comportamiento actual esta claro en codigo y tests: despues de `commit()`, +`onEvent()` y `configureEvent()` lanzan `OrcaFrozenError`. Esto no es un bug por +si mismo, pero tensiona el objetivo de registrar acciones desde features +lazy-loaded. + +Impacto: + +- Si una app llama `App.Orca.commit()` durante bootstrap, una ruta lazy ya no puede + registrar sus acciones al cargar. +- HMR/devtools pueden quedar bloqueados si no se separa "validar" de "sellar". + +Recomendacion: + +- Documentar `commit()` como opcion production/seal, no como paso obligatorio. +- Considerar `validate()` para dev y `commit()` solo para builds donde el grafo es + completo al arrancar. +- Si se quiere lazy + freeze, introducir versionado por evento: cada run usa un + snapshot, pero el registro global puede crecer entre runs. + +### 10. `ctx.emit()` es correcto, pero la atribucion via `bus.publish()` depende de AsyncLocalStorage + +Referencia: + +- `src/arts/orca/engine-orca.ts:133` +- `src/arts/orca/engine-orca.ts:204` +- `src/arts/orca/engine-orca.ts:864` +- `src/arts/orca/als.ts` +- `src/arts/orca/README.md:606` +- `src/arts/orca/README.md:621` +- `src/arts/orca/README.md:1470` + +El diseno correcto es claro: modulo -> `bus.publish()`, accion -> `ctx.emit()`. +El motor intenta interceptar `bus.publish()` dentro de acciones mediante +AsyncLocalStorage cuando esta disponible. En navegador puede no estar disponible, +por lo que el mismo `bus.publish()` dentro de una accion puede ser hijo en Node y +root event en browser. + +Impacto: + +- Trazas distintas entre SSR/tests Node y browser. +- Reentry guards no aplican igual si un evento derivado sale como root. + +Recomendacion: + +- Mantener la regla dura: las acciones usan `ctx.emit()` siempre. +- Tratar la interceptacion ALS como bonus diagnostico, no como contrato. +- Anadir lint/documentacion: no usar `App.Bus.publish()` dentro de una accion de + `orca` salvo que se quiera crear root event explicito. + +### 11. `engine-orca.ts` ya es un monolito de responsabilidades + +Referencia: + +- `src/arts/orca/engine-orca.ts` (~58 KB) + +El engine concentra registro, cola, trace state, reentry, ejecucion de waves, +timeouts, compensation, validation, ID generation, diagnostics y public API. + +Impacto: + +- Dificulta revisar invariantes delicadas como "trace cleanup + parallel". +- Hace mas probable que futuras features (`runTimeoutMs`, `setupOrca`, inspector) + entren sin frontera clara. +- Ya contradice la direccion del ecosistema de reducir monolitos (`connection.ts` + se venia refactorizando por el mismo motivo). + +Recomendacion de split: + +- `queue.ts`: enqueue, policies, drain, in-flight counters. +- `trace.ts`: envelope, trace state, reentry guards, release. +- `runner.ts`: stages, waves, action execution, status precedence. +- `timeouts.ts`: action timeout helpers y timer keys. +- `compensation.ts`: LIFO compensation / transaction compensation. +- `validation.ts`: validate/commit helpers. +- `ids.ts`: factories deterministas. +- `engine-orca.ts`: composicion publica. + +### 12. Los presets usan action ids y tokens inline/locales + +Referencia: + +- `src/arts/active-app/presets/cache-clear-on-identity-change.ts:6` +- `src/arts/active-app/presets/cache-clear-on-identity-change.ts:7` +- `src/arts/active-app/presets/cache-clear-on-revoke.ts:6` +- `src/arts/active-app/presets/cache-clear-on-revoke.ts:7` +- `src/arts/active-app/presets/perm-invalidate-on-identity-change.ts:6` +- `src/arts/active-app/presets/perm-invalidate-on-identity-change.ts:7` + +Los eventos del ecosistema ya estan centralizados como constantes, pero los +`ACTION_ID` y `TOKEN_*` de presets quedan como strings locales no exportados. + +Impacto: + +- No se pueden reutilizar en tests de integracion, docs, inspector `/test/orca` + o setup tipado. +- Rompe la regla emergente: "eventos, tokens y action ids como constantes". +- El inspector no puede mostrar nombres canonicos importables. + +Recomendacion: + +- Crear `src/arts/active-app/presets/consts.ts` o exportar desde cada preset: + `APP_ORCA_ACTION_CACHE_CLEAR_ON_IDENTITY_CHANGE`, + `APP_ORCA_TOKEN_CACHE_CLEARED_ON_IDENTITY_CHANGE`, etc. +- Usar nombres de constantes en README y pagina docs. + +## Hallazgos P3 + +### 13. Header de `engine-orca.ts` esta obsoleto + +Referencia: + +- `src/arts/orca/engine-orca.ts:24` +- `src/arts/orca/engine-orca.ts:25` +- `src/arts/orca/engine-orca.ts:26` + +El comentario inicial dice que `after`, `unless`, `abortOn`, +`actionTimeoutMs` y `compensate` se aceptan pero se ignoran. El motor ya los +implementa. + +Impacto: + +- La siguiente persona que lea el archivo empieza con un mapa mental falso. +- Ya se corrigieron comentarios en `types.ts`, `consts.ts` y `result.ts`, pero el + comentario mas importante del motor sigue atrasado. + +Recomendacion: + +- Actualizar el bloque de cabecera para describir el estado real. +- Evitar version tags contradictorios dentro del codigo; mover roadmap al README. + +### 14. El README de `orca` mezcla contrato actual, boceto y roadmap en un solo flujo + +Referencia: + +- `src/arts/orca/README.md:511` +- `src/arts/orca/README.md:1411` +- `src/arts/orca/README.md:1506` +- `src/arts/orca/README.md:1522` + +Ejemplo claro: una seccion dice que en v0 los tokens son flags sin payload; otra +seccion posterior dice que los tokens con payload ya estan implementados. Esto no +es solo estetico: afecta al modo en que un desarrollador modela acciones. + +Impacto: + +- La documentacion no sirve como contrato normativo. +- Los lectores no saben si estan viendo la version deseada o la version real. + +Recomendacion: + +- Reestructurar README en tres bloques cerrados: + - "Contrato actual implementado" + - "Patrones recomendados" + - "Roadmap / no implementado" +- Eliminar del contrato actual cualquier API no exportada (`setupOrca`) o moverla + a roadmap. +- Mantener una tabla "Feature -> estado -> archivo/test". + +### 15. Falta una prueba compuesta del caso que motivo `orca`: cambio de usuario con cache/perm/connection + +Referencias: + +- `src/arts/active-app/test/presets.test.ts` +- `src/arts/orca/test/engine-orca.test.ts` + +Hay buena cobertura unitaria de `orca` y presets basicos. Falta el escenario +ecosistema que debe probar el valor real: + +1. Usuario A abre sesion. +2. Chat/conexion usa credencial A. +3. Cache actor-scoped guarda datos de A. +4. Perm calcula snapshot de A. +5. Cambia a usuario B. +6. Orca ejecuta clear cache, invalidate perm y reauth/close connection. +7. Ningun dato/credencial de A queda observable. + +Impacto: + +- El sistema puede estar correcto por modulo pero fallar justo en composicion. +- El caso "chat con credenciales antiguas" sigue sin prueba de regresion. + +Recomendacion: + +- Crear test de integracion en `src/arts/active-app/test/ecosystem-orca.test.ts` + o equivalente. +- Usar fakes de cache/perm/connection con counters y credenciales capturadas. +- Verificar orden por `OrcaRunResult`: cache -> perm -> connection si se decide + dependencia via tokens. + +## Observaciones positivas + +- `App.Bus`, `App.Timers` y `App.Orca` ya son core siempre presentes en + `createActiveApp()`, y `Orca` es inerte hasta registrar acciones. Esa decision + encaja con el diseno enterprise sin obligar a cada app a crear singletons + manuales. +- Los presets viven en `active-app/presets`, no dentro de los artefactos. Esto es + correcto: `cache` no debe conocer `session`; `perm` no debe conocer `auth`; + `orca` no debe conocer modulos. +- `ActiveOrca` es un wrapper fino y razonable: estado reactivo, sin meter logica + del engine en Svelte. +- La suite focalizada de `orca` ya es grande: 169 tests entre engine/active/result + y presets. Para un modulo recien nacido, eso es muy buena base. +- `ctx.signal` existe en `OrcaActionContext`; eso habilita cancelacion cooperativa + y es la direccion correcta. + +## Recomendacion de orden de trabajo + +1. Corregir P1.1: trace cleanup con runs paralelos. +2. Cerrar P1.2: `provides` debe ser contrato real o `validate()` debe declararse + best-effort. +3. Resolver P1.3: renombrar/split de `replace` para evitar semantica ambigua. +4. Sustituir `Date.now()`/`Math.random()` por factories deterministas. +5. Decidir `setupOrca()`: implementarlo minimo o moverlo fuera de v0. +6. Crear preset de `connection` y test compuesto usuario A -> usuario B. +7. Reordenar README para separar implementado/recomendado/roadmap. +8. Refactorizar `engine-orca.ts` por responsabilidades antes de anadir run/stage + timeout, inspector visual o setup tipado. + +## Conclusion + +`orca` merece seguir. No es "otro bus": es el sitio correcto para convertir +flujos inter-modulo en artefactos trazables y testeables. Pero precisamente por +eso no puede permitirse ambiguedad en nombres, timeouts, tokens y concurrencia. + +El ecosistema esta cruzando una frontera importante: de librerias coherentes a +runtime de aplicacion. La prioridad ahora no es meter mas features, sino hacer +que el contrato de `orca` sea tan fiable como su idea. diff --git a/auditoria- ecosistema-codex.md b/auditoria- ecosistema-codex.md new file mode 100644 index 0000000..76431be --- /dev/null +++ b/auditoria- ecosistema-codex.md @@ -0,0 +1,566 @@ +# Auditoria Codex del ecosistema hacia 1.0 + +Fecha: 2026-05-04 +Alcance: `lang`, `cache`, `session`, `perm`, `auth`, `http`. +Excluido: `orca`, ya auditado por separado. + +## Veredicto ejecutivo + +El ecosistema esta bastante por encima de una 0.x normal: hay contratos tipados, separacion real entre `libs`, `arts` y `svrs`, factories declarativas en `createActiveApp()`, y una base de tests que hoy pasa para los modulos auditados. + +La brecha hacia 1.0 no esta tanto en "reescribir" modulos, sino en cerrar tres frentes: + +1. **Contratos publicos exactos**: hay documentacion y ejemplos que todavia prometen aliases, nombres o inyecciones que el codigo ya no hace. +2. **Determinismo operativo**: `timr`/`Timers` ya existe, pero `http`, `session`, `cache` y `perm` aun conservan defaults con `Date.now()`, `Math.random()` o timers nativos cuando se usan fuera del wiring ideal. +3. **Pruebas compuestas**: los tests unitarios estan verdes, pero faltan historias de ecosistema donde `auth`, `session`, `perm`, `cache` y `http` fallan o se invalidan juntos. + +Verificacion ejecutada: + +```txt +npx vitest run src/arts/lang/test src/arts/cache/test src/libs/cache/test src/svrs/cache/test src/arts/session/test src/arts/perm/test src/svrs/perm/test src/arts/auth/test src/svrs/auth/test src/arts/http/test + +35 test files passed +426 tests passed +``` + +## Hallazgos transversales + +### P1 - La documentacion debe dejar de prometer APIs antiguas + +Hay restos de la etapa de aliases de 4 letras y de nombres capitalizados: `$cach`, `$sess`, `App.Cache`, `App.Sess`, ejemplos sin `services: { ... }`, y textos que dicen que `cache` esta siempre presente. El codigo actual va por `createActiveApp({ services })` y expone servicios lazy en minuscula (`App.cache`, `App.session`, `App.perm`, `App.auth`, `App.http`). + +Impacto: un desarrollador nuevo no distingue que es API real y que es historia del framework. Para 1.0, esto no puede quedar en "se entiende mirando codigo"; la documentacion es parte del contrato. + +Accion recomendada: + +- Hacer una pasada de docs con una regla mecanica: ningun README ni pagina `src/web/routes/active/docs/**` puede usar alias o propiedades que no existan en `svelte.config.js` y en `src/arts/active-app/service-factories/**`. +- Crear tests de snippets o al menos un script que busque `$cach`, `$sess`, `App.Cache`, `App.Sess`, `App.Http`, `App.Perm`, etc. + +### P1 - La inyeccion entre servicios no esta igual de clara que el discurso + +Las factories actuales inyectan principalmente `logger`, y solo `session` recibe tambien `bus`: + +- `defineActiveAuth(...)` recibe `logger`; el `http` debe venir en `options`. +- `defineActivePerm(...)` recibe `logger`; aunque `PermClientOptions` acepta `http?: EngineHttp`, la factory no declara dependencia de `http`. +- `defineActiveCache(...)` recibe `logger`; no recibe `timers` por defecto. +- `defineActiveSession(...)` recibe `logger` y `bus`; no recibe `timers` para auto-refresh. +- `defineEngineHttp(...)` recibe `logger`; no recibe `timers`. + +Esto es coherente si el principio es "pasivo por defecto, opt-in explicito". Pero varias docs ya hablan como si `Http`, `Cache`, `Bus` y `Timers` se cablearan automaticamente entre servicios. + +Accion recomendada: + +- Decidir para 1.0 una regla unica: o las factories solo reciben core deps minimas, o pueden declarar `serviceDependencies`. +- Si se mantiene el modo minimalista, documentarlo sin ambiguedad: `auth` necesita `http` explicito, `perm` necesita `endpoint` o `http` explicito, `cache` no se invalida sola, `session` no refresca con Timers salvo que se le pase. +- Si se quiere ergonomia enterprise, evolucionar factories para dependencias opcionales: `defineActivePerm` puede consumir `http` si existe; `defineActiveCache` y `defineActiveSession` pueden consumir `timers`. + +### P1 - Falta una suite compuesta de identidad, permisos y cache + +La base verde actual no prueba suficientemente los casos que mas preocupan en una aplicacion real: + +- Usuario A abre sesion, cachea datos privados, cambia a usuario B, y B no ve cache ni permisos de A. +- Backend comunica cambio de permisos y el cliente invalida decision cacheada antes de permitir acciones. +- `http` recibe 401/403, dispara refresh/auth state, `session` adopta o revoca, `perm` y `cache` reaccionan. +- Logout global revoca sesion, limpia cache actor-scoped y deja `perm` sin actor. +- Password reset revoca sesiones y el cliente queda en `anonymous` sin datos privados residuales. + +Accion recomendada: + +- Crear `src/routes/test/ecosystem` como harness de estas historias o moverlas a tests headless en `src/arts/active-app/test/ecosystem-*.test.ts`. +- Estos tests deben usar `App.Bus`/presets/orquestacion cuando toque, pero el criterio es observable: snapshots finales y ausencia de datos cruzados. + +### P2 - Tiempo y aleatoriedad aun no son uniformes + +Puntos concretos: + +- `src/arts/http/retry.ts:48`, `:52`, `:64` usa `Date.now()`, `Math.random()` y `setTimeout`. +- `src/arts/http/timeout.ts:43`, `:63` usa `setTimeout`. +- `src/arts/cache/active-cache.svelte.ts:195` usa `Date.now()` si no se pasa clock. +- `src/arts/session/auto-refresh.ts:44`, `:45`, `:72` usa `Date.now()`, `Math.random()` y `setInterval` si no se pasa `timers`. +- `src/arts/perm/client.ts:291` usa `Date.now()` si no se pasa clock. + +No es necesariamente un bug para uso aislado, pero para 1.0 la composicion `createActiveApp()` deberia poder inyectar `Timers.clock` y scheduler por defecto en los servicios que lo aceptan. Esa es una de las diferencias entre "librerias utiles" y "framework determinista". + +## Modulo `lang` + +### Estado + +`lang` esta entre los modulos mas maduros. Tiene engine puro, wrapper active, resolucion de referencias `#?path|fallback`, extension de schema, pluralizacion, JSON helpers y una suite de tests amplia. + +### Refactorizaciones recomendadas + +1. **Cambiar `SvelteSet` por `Set` en listeners.** En `src/arts/lang/active-lang.svelte.ts:34` `localeListeners` no alimenta templates ni estado derivado; solo se itera manualmente. Igual que se hizo en otros modulos, `Set` plano reduce reactividad innecesaria. +2. **Separar mutacion y composicion de schemas con nombres mas explicitos.** Hoy `extend(namespace, module)` muta el engine activo y `register(namespace, module)` devuelve un engine hijo. Es potente, pero el naming puede confundir. Para 1.0 documentaria una tabla estricta: `extend` muta, `register` compone hijo. +3. **Hacer `SupportedLocale` menos cerrado.** `src/libs/lang/types.ts` limita locales a una lista base. Para producto 1.0, conviene permitir cualquier BCP47 tipado como branded string o una registry generica por app. +4. **Modo estricto de interpolacion.** `interpolateTemplate()` resuelve placeholders, pero no hay modo que falle si falta un parametro. Para 1.0 deberia existir `strictInterpolation` con diagnostico/log. +5. **Unificar docs de inyeccion.** La factory `defineActiveLang` si inyecta logger via `setLogger(core.logger)`. La documentacion debe mostrar claramente `services: { lang: defineActiveLang({ schema }) }` y no dar a entender que `createActiveApp()` siempre trae un schema real. + +### Ampliaciones 1.0 + +- Loader asincrono de packs de idioma: `loadLocale(locale)` con cache y fallback. +- `Lang.tCode(code)` si se adopta una capa comun de errores/codigos. +- Integracion con formatos: resolver `{{price | currency}}`, `{{date | datetime}}` usando `format`. +- Dev inspector: namespace registrados, fallback usado, claves faltantes, referencias circulares. +- Script de validacion de schema: claves faltantes por locale, claves muertas y paths duplicados. + +## Modulo `cache` + +### Estado + +`cache` tiene un runtime solido en `libs/cache`, wrapper `svrs/cache` y `arts/cache` reactivo. Hay politicas, scopes, tags, epochs, singleflight, stale-if-error y adapters memory/storage. La arquitectura esta bien orientada. + +### Refactorizaciones recomendadas + +1. **Actualizar docs antiguas.** Hay ejemplos que aun usan `$cach` o `App.Cache`; el alias real es `$cache` y la app expone `App.cache` solo si se declara `services.cache`. +2. **Inyectar clock desde App.Timers cuando se usa como servicio.** `createActiveCache()` acepta `clock`, pero `defineActiveCache()` solo inyecta `logger`. Para 1.0, el default app-wired deberia usar `core.timers.clock`. +3. **Evitar `console.warn` directo en memory adapter.** `src/libs/cache/adapters/memory.ts` usa `console.warn` si se crea en produccion sin `onProductionWarning`. En 1.0, la advertencia deberia pasar por diagnostics/logger o exigir handler explicito. +4. **Revisar clonacion de valores.** `memoryCacheAdapter` usa `structuredClone` si existe y fallback JSON. El fallback rompe `Date`, `Map`, `Set`, `BigInt`, clases y valores no serializables. Para 1.0: o se documenta "valores serializables" o se expone `clone?: (value) => value`. +5. **Purgado escalable.** La memoria purga expirados escaneando entradas. Es razonable para v0, pero para 1.0 conviene un sweeper opcional con `Timers` o un indice por expiracion si se esperan caches grandes. + +### Ampliaciones 1.0 + +- Adapter L2 remoto opcional: Redis/HTTP/IndexedDB, manteniendo L1 memory. +- Invalidacion por evento app: identity changed, tenant switched, permission changed. +- `cache.queryHttp()` o helper de integracion con `http` para cachear respuestas con schema. +- Metricas: hit rate, stale served, refresh failures, singleflight joins, evictions. +- Politicas de privacy: impedir persistir scopes actor/session en adapters no seguros salvo opt-in. +- Tests de no fuga cross-actor y cross-tenant. + +## Modulo `session` + +### Estado + +`session` es de los modulos mejor testeados. Tiene engine puro, wrapper active, generacion/versionado para evitar carreras, broadcast/storage sync, eventos de bus y auto-refresh opcional. La direccion es buena. + +### Refactorizaciones recomendadas + +1. **Actualizar naming en docs.** Debe desaparecer `$sess` y `App.Sess`; el alias real es `$session` y el servicio es `App.session`. +2. **Cablear auto-refresh con Timers desde App.** `auto-refresh.ts` ya acepta `timers`, pero `defineActiveSession()` no los inyecta. Para 1.0, si una app usa `createActiveApp()`, el refresh deberia poder ser determinista sin boilerplate manual. +3. **Limitar defaults nativos en modo app-wired.** `Date.now`, `Math.random` y `setInterval` son aceptables como fallback aislado, pero no como camino principal del ecosistema. +4. **Documentar ownership frente a auth.** `session` no debe saber de credenciales ni permisos; solo continuidad, refresh/revoke, snapshot y bus events. `auth` prueba identidad; `perm` decide permisos. +5. **Opciones explicitas para sync multi-tab.** Si ya existen, deben documentarse mejor; si no, conviene `broadcast: false | { channel }` para entornos con privacidad estricta o tests. + +### Ampliaciones 1.0 + +- Preset `defineActiveSession({ autoRefresh: { standard: true } })` que use `Timers`. +- Integracion `http` para refresh por 401 sin acoplar `http` a `session`: hook reusable de aplicacion. +- Eventos canonicos para identity changed, credential refreshed, revoked, expired. +- Tests compuestos con auth/cache/perm. +- Modo SSR documentado: adoptar snapshot server sin doble refresh ni flicker. + +## Modulo `perm` + +### Estado + +`perm` tiene mas base de servidor de la que parecia al inicio: `libs/perm` incluye DSL/evaluator/compiler, `svrs/perm` aporta engine, repository y SQL de referencia, y `arts/perm` aporta cliente activo. Es una buena base para 1.0, pero hay dos puntos que conviene cerrar pronto. + +### Refactorizaciones recomendadas + +1. **Corregir compilador SQL para paths anidados.** El runtime usa `getPath`, pero `src/libs/perm/compilers/sql.ts:105` y `:110` leen `input.actor[expr.path]` y `input.context?.[expr.path]`. Un path como `risk.mfa` o `profile.department` se evaluara distinto en runtime y en SQL. Para 1.0 esto debe ser P1: usar `getPath()` tambien en actor/context o declarar que SQL solo soporta paths planos. +2. **Preordenar policies una vez.** `src/libs/perm/runtime.ts` ordena por prioridad en cada decision. Para volumen real, ordenar al construir runtime y mantener indices por action/resource reduce coste. +3. **Memoizar providers por decision.** `DefaultPermEvaluator` puede llamar varias veces a relation/attribute providers con la misma key dentro de una decision. Un cache por request reduce latencia y evita multiples consultas a DB. +4. **Eliminar IDs auto-generados no estables en produccion.** `definePolicies`/builders pueden producir IDs por contador. Para 1.0, los policies persistidos deberian exigir `id` estable o generar checksum determinista. +5. **Alinear factory y docs.** `PermClientOptions` acepta `http?: EngineHttp`, pero `defineActivePerm()` no inyecta `App.http`. O se documenta que `fetcher`/`http` son manuales, o se declara dependencia opcional de `http`. + +### Ampliaciones 1.0 + +- Persistencia oficial: migraciones SQL versionadas, repository contract tests y ejemplos Kysely/Drizzle. +- Webhook/evento de cambio de permisos: invalidar cliente y cache de decisiones por actor/tenant. +- `what()` y `explain()` cacheados con invalidacion por version de policies. +- Obligations/advice con enforcement helpers, no solo datos. +- Auditoria de decisiones: escribir `permission_decision_audit` desde server engine con redaccion. +- Tests de cross-actor race: login A -> decision allow -> login B -> misma accion no reutiliza decision. + +## Modulo `auth` + +### Estado + +`auth` ha avanzado mucho: hay `libs/auth` como lenguaje comun, `svrs/auth` con engine server-authoritative, SQL de referencia, password signup/signin, CSRF, recovery, device records, refresh rotation y OAuth base. `arts/auth` es un cliente seguro que refleja `AuthCurrentView` y no intenta ser autoridad. + +### Refactorizaciones recomendadas + +1. **Completar handlers para rutas ya publicadas.** `src/libs/auth/consts.ts:21-30` declara rutas OAuth, MFA, devices y WebAuthn. `src/arts/auth/active-auth.svelte.ts:154-166` ya llama `DEVICES` y `DEVICE_REVOKE`. Pero `src/svrs/auth/handlers.ts` solo enruta current, csrf, password, logout, email verification y password reset. Resultado: `ActiveAuth.listDevices()` y `revokeDevice()` apuntan a endpoints que el handler generico no sirve. Para 1.0, o se agregan handlers, o se retiran del cliente hasta estar soportados. +2. **No guardar secretos OAuth en metadata de flow.** `oauth-flow.ts` guarda `state` y `verifier` en `metadata` ademas de hashes. Aunque la store sea server-side, el contrato ideal es persistir solo hash/verifier cifrado o recuperar verifier por canal seguro. Para 1.0 debe revisarse porque el documento original era estricto con secretos. +3. **PKCE challenge no debe usar hash token raw si no es base64url SHA-256 estandar.** Si `hashAuthToken()` no produce exactamente `base64url(SHA256(verifier))`, el flujo OAuth no sera interoperable. El test `oauth-pkce.test.ts` existe, pero conviene comprobarlo contra el RFC shape. +4. **Rate-limit esta bien cableado en password/recovery/oauth, pero falta matriz.** `enforceAuthRateLimit()` existe y se usa en flujos principales; para 1.0 hace falta tabla por metodo, key usada y politica recomendada. +5. **`createDbAuthAdapter` es demasiado generic para produccion.** El adapter `db.ts` acepta repositorios con `where` generico basado en records TS, mientras el SQL aplana `actorRef` a `tenant_id/actor_id`. Es valido como referencia, pero 1.0 necesita un adapter/repository probado contra el SQL real, no solo un contrato abstracto. + +### Ampliaciones 1.0 + +- Handlers completos para devices y OAuth; MFA/WebAuthn marcados como experimental si no se implementan. +- Contract tests del SQL auth: credentials, flows, linked accounts, session bindings, refresh families. +- Anti-enumeration tests para recovery/email verification. +- Refresh rotation integrada end-to-end con sesion real, no solo helper unitario. +- Device/session management completo: listar, revocar actual, revocar otro, global logout. +- Security events hacia logger/audit con redaccion obligatoria. +- Integracion con `session`, `cache` y `perm`: signin/logout/password reset invalidan lo necesario sin acoplar modulos directamente. + +## Modulo `http` + +### Estado + +`http` esta muy bien codificado: engine puro, resultados tipados, hooks, retry, timeout, schema de request/response, fetch inyectable y tests amplios. Es una pieza importante para que el ecosistema no dependa de `fetch` crudo. + +### Refactorizaciones recomendadas + +1. **Port de timers/retry.** `retry.ts` y `timeout.ts` usan timers nativos. Para 1.0 deberia existir `HttpTimerPort` o integracion directa con `Timers`, al menos cuando se crea via `defineEngineHttp()`. +2. **Jitter inyectable.** `computeRetryDelay()` usa `Math.random()` si `policy.jitter` esta activo. Para tests deterministas y produccion controlada, aceptar `random?: () => number`. +3. **Cerrar lifecycle de timeouts.** `attemptTimeoutSignal()` y `totalTimeoutSignal()` crean `setTimeout`; si la request termina antes, el timeout queda pendiente hasta disparar. No siempre es grave, pero en alto volumen deberia poder cancelarse. +4. **Helpers de autenticacion sin acoplar a auth.** La pieza deberia ofrecer patrones genericos para `beforeRequest`/`beforeRetry`/`beforeError` que permitan refresh por 401, pero sin conocer `session` ni `auth`. +5. **Observabilidad de hooks.** Hoy se emiten diagnosticos de retry y errores; para 1.0 conviene medir tiempo por intento, delay real, abort reason y hooks que rescatan respuestas. + +### Ampliaciones 1.0 + +- `http.with({ fetch: event.fetch })` documentado con snippets SSR reales. +- Preset de retry empresarial: idempotentes por defecto, 429/503 con Retry-After, jitter determinista opcional. +- Adapter/cache bridge: convertir `Response` en envelope cacheable con schema. +- Circuit breaker opcional o al menos hooks para implementarlo con `cache/session`. +- Tests de 401 -> refresh -> replay request; 403 -> perm invalidation; offline -> stale cache. + +## Roadmap recomendado hacia 1.0 + +### Sprint 1 - Contrato publico y docs reales + +- Corregir aliases y nombres de servicios en READMEs y paginas `active/docs`. +- Documentar factories reales: que inyecta cada una y que debe pasar el desarrollador. +- Crear un test/script de docs que detecte aliases muertos y propiedades antiguas. +- Publicar tabla "core siempre presente vs services declarados". + +### Sprint 2 - Determinismo e inyeccion + +- Hacer que `defineActiveCache`, `defineActiveSession`, `defineActivePerm` y `defineEngineHttp` puedan consumir `core.timers` cuando aplique. +- Inyectar `random` en retry/session auto-refresh. +- Mantener fallbacks nativos para uso aislado, pero no para el camino App. + +### Sprint 3 - Bugs de contrato + +- Completar handlers de `auth` para devices/OAuth o retirar esas APIs del cliente hasta estar soportadas. +- Corregir path anidado en compilador SQL de `perm`. +- Revisar OAuth PKCE y persistencia de verifier/state. +- Alinear `defineActivePerm` con `http` real. + +### Sprint 4 - Tests compuestos + +Escenarios minimos: + +1. Login A -> cache privado -> logout -> login B -> B no ve cache/perm de A. +2. Permission webhook -> `perm.invalidate()` -> decision antigua no se reutiliza. +3. HTTP 401 -> refresh sesion -> replay -> cache conserva solo datos validos. +4. Password reset -> revoca sesiones -> active auth anonimo -> cache actor-scope limpia. +5. Logout global -> session revoked -> perm sin actor -> cache limpia -> http protegido falla seguro. +6. Tenant switch -> cache/perm invalidados por tenant. + +### Sprint 5 - Server readiness + +- `auth` y `perm` ya tienen SQL de referencia; convertirlo en migraciones versionadas o al menos en contract tests ejecutables. +- `cache` necesita historia clara de adapter server: memory solo test/dev, storage/browser, y adapter remoto recomendado. +- `http` no necesita `svrs/http`, pero si necesita ejemplos SSR y edge/runtime. + +## Prioridad resumida + +| Prioridad | Tema | Modulos | Motivo | +|---|---|---|---| +| P1 | Handlers `auth` incompletos para APIs publicas | auth | Cliente llama endpoints que el handler generico no enruta. | +| P1 | SQL compiler no resuelve paths anidados igual que runtime | perm | Riesgo de decisiones distintas entre filtrado DB y evaluacion memory. | +| P1 | Docs/API antiguas tras rename y service schema | todos | Bloquea adopcion y genera mal uso del framework. | +| P1 | Tests compuestos cross-actor/cross-tenant | auth/session/perm/cache/http | Es donde aparecen fugas reales. | +| P2 | Timers/random no unificados | cache/session/perm/http | Rompe determinismo en tests y trazabilidad. | +| P2 | Persistencia/adapters DB contract-tested | auth/perm/cache | Necesario para apps reales. | +| P2 | Metrics/diagnostics de runtime | cache/http/perm/auth | Necesario para operacion 1.0. | +| P3 | Limpieza micro-reactiva (`SvelteSet` listeners) | lang | Pulido, bajo riesgo. | + +## Criterio de cierre para 1.0 + +Yo no marcaria estos modulos como 1.0 hasta que se cumplan estas condiciones: + +- La documentacion publica compila mentalmente y con snippets: ningun alias muerto, ningun servicio inventado. +- El camino `createActiveApp({ services })` inyecta logger, bus, timers y servicios dependientes de forma explicita o documenta que no lo hace. +- `auth`, `session`, `perm`, `cache` y `http` tienen al menos una suite compuesta de identidad completa. +- `auth` no publica rutas/cliente que el server handler no soporte. +- `perm` produce la misma decision en runtime y SQL compiler para paths soportados. +- `http` y auto-refresh son testeables sin timers nativos. +- Los adapters server de `auth` y `perm` tienen contract tests contra el modelo SQL de referencia. + +Conclusion: la arquitectura es buena y la base esta verde. Lo que falta para 1.0 es menos glamour y mas cierre contractual: documentacion verdadera, wiring determinista y pruebas de historias completas. Esa es la parte que convierte el ecosistema en plataforma. + +--- + +# Segunda tanda: sium, storage, timer, logger, frontend, format, connection + +Fecha: 2026-05-04 +Alcance adicional: `sium`, `storage`, `timer`, `logger`, `frontend`, `format`, `connection`. +Finalidad: misma que la primera tanda, buscar refactorizaciones, ampliaciones y criterios de cierre hacia version 1.0. + +Verificacion ejecutada: + +```txt +npx vitest run src/arts/sium/test src/arts/storage/test src/arts/timer/test src/arts/logger/test src/arts/logger/adapters src/arts/frontend/test src/arts/format/test src/arts/format/currency/test src/arts/format/dates/test src/arts/format/numbers/test src/arts/format/units/test src/arts/connection/test + +52 test files passed +686 tests passed +``` + +## Hallazgos transversales de la segunda tanda + +### P1 - Documentacion con aliases y nombres de App obsoletos + +La misma deuda aparece con fuerza en esta tanda. El codigo actual usa aliases semanticos: + +```txt +$storage, $timer, $logger, $format, $connection +``` + +Pero varios README siguen usando: + +```txt +$stor, $timr, $logr, $fmts, $conn +``` + +Tambien aparecen ejemplos con `App.Timers`, `App.Format`, `App.Frontend`, `App.Storage`, `App.Sess`, `App.Lang`, `App.createActiveConnections()` y `App.setLocale(...)`. El contrato actual de `createActiveApp({ services })` expone servicios en minuscula (`App.format`, `App.frontend`, `App.storage`, `App.session`, `App.lang`, `App.connections`) y las factories viven en `$active-app/services`. + +Esto es P1 de documentacion contractual. Aunque el runtime este verde, una API 1.0 no puede tener docs que ensenan a importar desde aliases que ya no existen. + +Accion recomendada: + +- Pasada mecanica por README y paginas docs para reemplazar aliases viejos. +- Script de CI que falle si aparecen `$stor`, `$timr`, `$logr`, `$fmts`, `$conn`, `App.Sess`, `App.Storage`, `App.Format`, `App.Frontend`, `App.Timers` en docs publicas salvo en secciones de migracion. +- Tabla unica por modulo: alias real, factory real, propiedad real de `App`. + +### P1 - Los servicios no comparten todavia un contrato de lifecycle uniforme + +Algunos modulos siguen el contrato `ActiveEngine` o equivalente (`snapshot`, `lastError`, `disposed`, `dispose` idempotente). Otros son utilitarios activos sin `disposed` ni guardas post-dispose. + +Casos relevantes: + +- `storage` no impide `entry()` ni `clear()` despues de `dispose()`. +- `frontend` mantiene setters operativos tras `dispose()`. +- `format` root llama dispose de submodulos sin flag propio idempotente. + +Para 1.0, todo servicio declarado en `createActiveApp({ services })` deberia tener una semantica uniforme: + +- `dispose()` idempotente. +- Metodos mutadores despues de dispose: o no-op documentado, o error tipado. +- Snapshot/introspection si el servicio expone estado. + +### P2 - El patron `CodeError` esta bien adoptado, pero quedan restos de nomenclatura antigua + +`sium`, `storage`, `timer`, `logger` y `connection` ya extienden `CodeError` desde `$libs/errs`, que era una buena direccion. Pero hay comentarios y mensajes que todavia hablan de `stor`, `timr`, `logr` o `conn`. No rompe runtime, pero ensucia la identidad del ecosistema justo ahora que se abandono la regla de 4 letras. + +Accion recomendada: + +- Mantener `CodeError` como raiz. +- Renombrar comentarios, mensajes y catalogos internos que digan `stor/timr/logr/conn` si el modulo ya se llama `storage/timer/logger/connection`. +- Si `ErrCode` usa seeds de 4 letras por decision historica, documentarlo. Si no, migrarlo antes de 1.0. + +## Modulo `sium` + +### Estado + +`sium` es probablemente el modulo mas completo de esta segunda tanda. Tiene core DSL, Standard Schema, introspection, codecs, lazy, domain types de color/date/time, resolucion de issues, integracion con `lang`, `CodeError` y una suite de tests amplia. + +### Refactorizaciones recomendadas + +1. **Resolver locale activo, no solo default inicial.** `createEngineSium()` captura `defaultLocale = options.locale ?? lang?.getDefaultLocale() ?? 'es'`. Si se inyecta `ActiveLang` mediante `defineEngineSium` y luego cambia el locale de `App.lang`, `resolveIssue()` sin locale explicito seguira usando el default capturado. Para 1.0, si el `lang` inyectado tiene `getLocale()`, Sium deberia usar el locale actual, o no pasar locale a `lang.t()` para dejar que Lang resuelva su estado actual. +2. **Cerrar la migracion de errores legacy.** `src/arts/sium/errors.ts` mantiene `SIUM_ERRORS` como catalogo legacy para strings que aun no son `CodeError`. La propia nota dice que quedan sitios por migrar. Para 1.0, todos los errores de construccion/encode/decode deberian tener `ErrCode`. +3. **Reducir fragilidad de facade manual.** `EngineSium` lista manualmente decenas de funciones. Hay tests de barrel, pero para 1.0 conviene un snapshot de surface o generacion controlada para evitar que `core` gane funciones que el engine no expone. +4. **Separar issues de errores de programador en docs.** La distincion existe en codigo: `validate` devuelve `Result`, `decode` lanza. La documentacion debe insistir en cuando usar cada una. + +### Ampliaciones 1.0 + +- `setupSium()` o presets de dominio para formularios complejos. +- Bridge oficial con `lang`: `sium.resolveIssue(issue)` siguiendo locale activo. +- Emision opcional de diagnostics por schema path para errores frecuentes. +- Serializacion estable de schema para devtools y documentacion automatica. +- Contract tests con `standard-schema` frente a Zod/Valibot/ArkType adapters. + +## Modulo `storage` + +### Estado + +`storage` esta muy bien planteado: engine sync, active wrapper con `$state`, adapters memory/local/session/cookie/broadcast, envelopes con version/TTL/migration, serializers y tests suficientes. Es una pieza clave para preferencias no secretas y persistencia local. + +### Hallazgos y refactorizaciones + +1. **P1 - `dispose()` no cierra realmente la superficie publica.** `createEngineStorage()` marca `disposed = true`, pero `entry()`, `clear()` y `entries()` no verifican ese estado. Despues de `dispose()` se puede crear un entry nuevo sobre un bus ya limpiado y registries ya dispuestos. `ActiveStorage.dispose()` hereda el mismo problema porque delega al engine y no guarda flag propio. Para 1.0 debe haber `StorageDisposedError` o no-op documentado. +2. **P2 - TTL usa `Date.now()` sin clock inyectable.** `decodeEnvelope()` y `encodeEnvelope()` aceptan `now`, pero `entry-runtime.ts` llama sin pasar reloj. `EngineStorageOptions` no tiene `clock`. Para tests deterministas y App wiring, conviene `clock?: { now(): number }`, inyectado desde `App.Timers.clock`. +3. **P2 - `dynamicEntry()` depende de `$effect`, pero no hay defensa si se usa fuera de scope.** El comentario lo advierte, pero para 1.0 conviene test y error claro si Svelte lanza fuera de componente/effect root. +4. **P2 - Top-level serializer auto-selection puede sorprender.** Esta documentado: objetos con `Date` anidada caen a JSON y no restauran Date. Para 1.0, anadir recipes de serializer por schema o integracion con Sium. +5. **P2 - Cookies cliente no endurecidas por defecto.** `cookieAdapter()` default `secure: false`, `sameSite: 'lax'`. Es razonable para preferencias no secretas, pero docs deben repetir que no es para secretos y que auth/session cookies no pasan por `storage`. + +### Ampliaciones 1.0 + +- `StorageDisposedError` y guardas post-dispose. +- Clock inyectable desde App. +- Adapter IndexedDB async separado o modulo nuevo, porque el contrato actual es sync. +- Encryption/redaction adapter opt-in para preferencias sensibles, sin prometer seguridad para secretos. +- Schema serializer: `entry('profile', defaults, { schema, serializer: siumSerializer(schema) })`. +- Devtools: entradas vivas, namespace, adapter, defaults conflict, TTL restante. + +## Modulo `timer` + +### Estado + +`timer` esta en buen estado. Es engine puro, clock inyectable, race-safe con version/id/key, abort signal por tarea, active wrapper ligero y tests robustos. Es de las piezas mas solidas del framework. + +### Refactorizaciones recomendadas + +1. **Actualizar docs antiguas.** README sigue usando `$timr` y `App.Timers`; alias real `$timer`, propiedad real `App.Timers` solo para core si se mantiene capitalizado. En codigo actual `createActiveApp()` si expone core `Timers`, asi aqui la capitalizacion es real, pero el alias no. +2. **Consolidar exports de backoff.** `src/arts/timer/backoff.ts` re-exporta desde `$libs/timer`, y `index.ts` tambien lo expone. No es grave, pero para 1.0 conviene una unica historia: backoff vive en `libs/timer`, `arts/timer` lo reexporta en index por conveniencia. +3. **Exponer random en helpers consumidores.** `computeBackoffDelay()` ya acepta `random`, pero `connection` no lo expone en sus reconnect options. Para 1.0, los consumidores deben poder hacer backoff determinista. +4. **Nombrar mejor `timer` como clock/scheduler del ecosistema.** En docs debe quedar claro que no es una utilidad de UI, sino la fuente temporal para `http`, `session`, `connection`, `cache`, `orca`. + +### Ampliaciones 1.0 + +- Fake clock oficial exportado para tests de ecosistema. +- Metrics: drift, scheduled count, cancelled count, failed count por scope. +- `cancelAll(scope)` documentado como primitive de teardown por modulo. +- Helpers para deadline/timeout con `AbortSignal` para que `http`/`orca` no usen timers nativos. +- Devtools de timers activos por scope. + +## Modulo `logger` + +### Estado + +`logger` es potente: `Logger` comun en `$libs/logger`, `EngineLogger` extiende ese contrato, transports, buffers, batching, failure routing con `deniedFor`, adapters Sentry/Datadog/Logtail/Loki/OTel, vitals y tests grandes. Es un pilar enterprise real. + +### Refactorizaciones recomendadas + +1. **P1/P2 - Alinear filtro global con la regla de niveles habilitados.** Los transports usan `levels?: LevelConfig`, que permite `{ [LogLevel.WARN]: { enabled: true }, ... }`. Pero el engine global aun usa `state.level` como threshold (`if (lvl < state.level) return`). Si la regla final del framework es "habilitacion por nivel, no threshold tradicional", `LoggerOptions` deberia aceptar `levels` tambien a nivel global, y `level` quedarse como shorthand o deprecated antes de 1.0. +2. **P2 - Reducir dependencia directa de `console` dentro del engine.** `handleFailure()` emite `console.error` ademas de crear synthetic failure entry. En un entorno enterprise puede duplicar salida o saltarse transports. Para 1.0 conviene `onInternalError`, `internalTransport`, o `consoleFallback?: boolean`. +3. **P2 - Inyectar clock/id factory opcional.** IDs fallback usan `Date.now()`/`Math.random()`, failure throttle usa `Date.now()`, timers de buffer usan `setTimeout`. Como Logger se crea antes de `Timers`, no puede depender de `App.Timers`, pero si puede aceptar `clock`, `idFactory` y `setTimeout` opcionales para tests y runtimes especiales. +4. **P2 - `dispose()` de child logger solo advierte en DEV con `console.warn`.** Es correcto como defensa, pero debe estar documentado en API: solo el root owns lifecycle. + +### Ampliaciones 1.0 + +- Global `levels` con shorthand `levelsAtLeast`. +- Redaction pipeline: campos `token`, `password`, `secret`, `authorization`, JWT-like values. +- Correlation helpers: `logger.withTrace(traceId)`, `logger.withActor(actorRef)`. +- Error bridge: si `error instanceof CodeError`, derivar category/module/code automaticamente. +- Async flush result: `flush(): Promise` para transports remotos. +- Backpressure policy para buffers grandes: drop, block, sample. + +## Modulo `frontend` + +### Estado + +`frontend` es pequeno y util: locale, dir, theme, mode, reduced motion/sound, density y aplicacion DOM via `ActiveDom` o helper de `$libs/dom`. La factory ya integra `dom` y `lang` si existen. Pero esta menos maduro que los demas modulos. + +### Refactorizaciones recomendadas + +1. **P1 - Docs antiguas tras service schema.** README afirma que `createActiveApp()` construye `Frontend` y usa `App.Lang`/`App.Dom`; ahora `frontend` se declara en `services` y se expone como `App.frontend`. +2. **P2 - Lifecycle incompleto.** Tras `dispose()`, los setters (`setLocale`, `setTheme`, etc.) siguen funcionando y pueden aplicar DOM. Para 1.0 debe haber flag `disposed` y semantica uniforme. +3. **P2 - Persistencia de preferencias quedo fuera.** La factory dice que storage persistence es responsabilidad de la app. Es una buena separacion, pero para 1.0 conviene un preset/helper oficial, porque tema/densidad/dir son caso principal de `storage`. +4. **P2 - Falta snapshot unico.** Hay getters individuales, pero no `snapshot()` con `{ locale, dir, theme, mode, reducedMotion, reducedSound, density }`. Para UI/debug/tests es mucho mas comodo. +5. **P3 - Validacion de valores.** `setDensity`, `setMode`, `setDir` aceptan strings tipados en TS, pero runtime JS podria pasar valores invalidos. Si es API publica, conviene validar o documentar TypeScript-only. + +### Ampliaciones 1.0 + +- `snapshot()` y `onChange(snapshot)`. +- Preset `persistFrontendPreferences(storage)`. +- Eventos de bus opcionales: frontend.preference.changed. +- Media query injector para tests SSR/browser. +- Documentar CSS contract: atributos `data-theme`, `data-mode`, `dir`, density, reduced motion. + +## Modulo `format` + +### Estado + +`format` esta bien organizado: numbers, currency, dates y units como submodulos, engines y active wrappers, locale source comun e integracion con `lang` desde factory. Tests cubren cada subdominio. Es funcional y extensible. + +### Refactorizaciones recomendadas + +1. **P1 - Docs con `$fmts` y `App.Format`.** Alias real `$format`, servicio real `App.format`. Ademas hay un typo documental: `import { createRates } from '$formats/currency'` cuando el alias real es `$format`. +2. **P2 - `createActiveFormat().dispose()` no tiene guard idempotente propio.** Los submodulos tienen runtime dispose, pero el root deberia seguir la regla general del ecosistema. +3. **P2 - Rates usa `Date.now()` por defecto.** `createRates({ now })` acepta inyeccion, pero `defineActiveFormat` no ofrece wiring con `Timers.clock`. Para 1.0, usar clock de App si se declaran rates con expiracion. +4. **P2 - Cache global de Intl.NumberFormat sin limite.** `engine-currency.ts` mantiene `formatCache` module-global. En apps multi-locale/multi-currency/larga sesion puede crecer indefinidamente. Conviene LRU pequeno o cache por engine con `dispose()`. +5. **P2 - Locale source doble puede duplicar notificaciones.** `createActiveFormat.setLocale()` actualiza localeState y cada submodulo manualmente. Funciona, pero para 1.0 conviene una sola fuente reactiva que notifique y submodulos se sincronicen una vez. + +### Ampliaciones 1.0 + +- Integracion con `lang` interpolation: formatters nombrados para `{{price | currency}}`. +- Formatter registry: `format.register('filesize', fn)`. +- Ranges: date range, number range, relative time, list format, display names. +- Rates provider HTTP/cache bridge con stale-if-error. +- Unit catalog ampliado y aliases por dominio de negocio. +- Tests por locale de alto riesgo: `ar`, `en-US`, `es-AR`, `fr-FR`, `de-DE`. + +## Modulo `connection` + +### Estado + +`connection` mejoro mucho desde el primer audit: ya no es un monolito puro, ahora tiene piezas separadas para acks, heartbeat, reconnect, session wiring, channel registry, transport runtime, sender, serializer y active wrapper. Tambien exige `TimerScheduler`, que es correcto. Aun asi, es el modulo con mas riesgo operacional de esta tanda. + +### Hallazgos y refactorizaciones + +1. **P1 - `autoReauthOn` existe en tipos pero no se usa.** `EngineConnectionsOptions` declara `autoReauthOn?: ConnectionAutoReauthOn`, pero no aparece en `engine-connections.ts`, `connection.ts` ni presets. Es una opcion publica sin efecto. Para 1.0 hay que implementarla o eliminarla hasta que `orca`/presets la usen. +2. **P1 - README desactualizado.** Usa `$conn`, `App.createActiveConnections()`, `App.Sess`, `App.Bus`, `App.Timers`. El codigo real usa `$connection`, `defineActiveConnections`, `App.connections`, `core.timers`, y el bus no se consume directamente salvo wiring/presets. +3. **P2 - WebSocket coverage es casi inexistente.** `websocket.test.ts` solo valida error cuando WebSocket no existe. Faltan tests de open/message/close/error, binaryType, protocols, URL factory, bufferedAmount y cleanup de listeners. +4. **P2 - `connection.ts` sigue concentrando demasiado wiring.** Aunque bajo de tamano frente al monolito anterior, sigue siendo el composition point de 10 KB con mucho cierre mutable (`disposed`, `intentionalClose`, lifecycle, detachSession, detachBrowserReconnect). Para 1.0 conviene dividir construction runtime en una factory interna que devuelva partes o un `ConnectionRuntimeContext`. +5. **P2 - Reauth concurrente no esta serializada.** `wireConnectionSession()` puede disparar `reauthenticate()` en cambios de sesion sucesivos sin singleflight/cancelacion. Si llega refresh + external changed, pueden salir dos auth frames. Para 1.0, `reauthenticate()` deberia ser singleflight o tener politica. +6. **P2 - Reconnect backoff no expone random determinista.** Usa `computeBackoffDelay()` sin pasar `random`; aunque el helper lo soporta, connection no lo deja configurar. +7. **P2 - Payloads de channels no validan schema.** Tipado TS ayuda en compile-time, pero los frames de red son `unknown`. Para 1.0, una opcion por channel con Sium/StandardSchema reduciria bugs de mensajes malformados. + +### Ampliaciones 1.0 + +- Implementar o retirar `autoReauthOn`. +- `ConnectionContext` para acciones internas: publish diagnostics/events, timers, logger, abort signal. +- Singleflight para connect/reconnect/reauthenticate. +- WebSocket test suite real con mock constructor. +- Channel schemas: `channel('chat', { incoming: { message: schema }, outgoing: { send: schema } })`. +- Backpressure policies mas completas: buffer por topic, drop-oldest/drop-newest, metrics. +- Reconnect policies documentadas: online/visible, queue/drop/replace si hay intento en vuelo. +- Integracion con `orca`: reauth/disconnect como accion opt-in ante identity change, no acoplamiento directo a session/cache/perm. + +## Roadmap recomendado para esta segunda tanda + +### Sprint A - Docs y naming + +- Corregir aliases obsoletos en `sium`, `storage`, `timer`, `logger`, `frontend`, `format`, `connection`. +- Reemplazar ejemplos `App.X` capitalizados por `App.x` servicios, excepto core reales (`App.Logger`, `App.Bus`, `App.Timers`, `App.Orca`) si se mantienen asi. +- Actualizar README de `connection`, `frontend`, `format` y `storage` antes de tocarlos mas: ahora mismo son los que mas pueden confundir. + +### Sprint B - Lifecycle uniforme + +- `storage`: error/no-op post-dispose. +- `frontend`: flag disposed y snapshot. +- `format`: root dispose idempotente. +- Tests de lifecycle para todos los servicios declarables. + +### Sprint C - Determinismo temporal + +- `storage`: clock en `EngineStorageOptions`. +- `format`: rates con clock de App. +- `logger`: opciones `clock`, `idFactory`, `timer` o documentar excepcion por ser core bootstrap. +- `connection`: random inyectable para reconnect. + +### Sprint D - Riesgos operacionales + +- `connection`: implementar/eliminar `autoReauthOn`, singleflight de reauth, WebSocket tests. +- `logger`: global levels por habilitacion si esa es la regla final. +- `sium`: locale activo con Lang. +- `storage`: no operar despues de dispose. + +## Prioridad resumida de la segunda tanda + +| Prioridad | Tema | Modulos | Motivo | +|---|---|---|---| +| P1 | Aliases/docs obsoletos | sium/storage/timer/logger/frontend/format/connection | API 1.0 no puede ensenar imports inexistentes. | +| P1 | `storage.dispose()` no cierra superficie | storage | Permite crear entradas tras teardown. | +| P1 | `autoReauthOn` sin efecto | connection | Opcion publica enganosa en un modulo critico. | +| P1/P2 | Filtro global por threshold vs habilitacion por nivel | logger | Debe alinearse con la regla final del ecosistema. | +| P2 | Locale activo no seguido por Sium | sium/lang | Validaciones pueden resolver mensajes en locale inicial. | +| P2 | Determinismo de tiempo incompleto | storage/logger/format/connection | Falta clock/random/timer injection en caminos 1.0. | +| P2 | Lifecycle incompleto | frontend/format/storage | Consistencia de servicios declarables. | +| P2 | WebSocket tests escasos | connection | Superficie critica con cobertura baja. | + +## Criterio de cierre 1.0 para esta tanda + +- Ningun README usa alias viejo ni propiedad App antigua. +- Todo servicio declarable tiene lifecycle post-dispose definido y probado. +- `storage`, `format.rates`, `connection.reconnect` y `logger` tienen historia determinista o excepcion documentada. +- `sium` resuelve issues con el locale activo cuando se integra con `lang`. +- `connection` no expone opciones muertas y tiene tests reales de WebSocket. +- `logger` deja cerrada la decision global: threshold o per-level enable, pero no una mezcla confusa. +- `frontend` tiene snapshot y persistencia oficial opt-in con `storage`. + +Conclusion de la segunda tanda: `timer`, `sium`, `logger` y `storage` tienen una base muy fuerte; `format` esta sano pero necesita pulido de cache/locales; `frontend` necesita madurar contrato; `connection` es potente, pero debe cerrar opciones muertas, concurrencia de reauth y cobertura WebSocket antes de poder llamarse 1.0. diff --git a/src/arts/connection/connection-requests.ts b/src/arts/connection/connection-requests.ts index ae2f3e9..0d588c4 100644 --- a/src/arts/connection/connection-requests.ts +++ b/src/arts/connection/connection-requests.ts @@ -40,11 +40,17 @@ export interface ConnectionRequestRuntime { export function createConnectionRequestRuntime( runtime: ConnectionRequestRuntimeOptions ): ConnectionRequestRuntime { + // Singleflight gate: if an auth round is in flight, every concurrent + // caller awaits the same promise. Prevents duplicate auth frames when + // `session.changed` and `session.external_changed` land back-to-back + // (or any other reauth source double-fires). + let inFlightAuth: Promise | null = null; + function hasAuthProvider(): boolean { return resolveConnectionAuth(runtime.auth).provider !== null; } - async function runAuth(): Promise { + async function runAuthOnce(): Promise { if (!runtime.isConnected()) return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED }; const auth = resolveConnectionAuth(runtime.auth); if (auth.provider === null) return missingConnectionAuthProvider(); @@ -58,6 +64,15 @@ export function createConnectionRequestRuntime( } } + function runAuth(): Promise { + if (inFlightAuth !== null) return inFlightAuth; + const pending = runAuthOnce().finally(() => { + if (inFlightAuth === pending) inFlightAuth = null; + }); + inFlightAuth = pending; + return pending; + } + async function requestFrame( type: string, payload: TPayload, diff --git a/src/arts/connection/reconnect.ts b/src/arts/connection/reconnect.ts index f862c12..feb8487 100644 --- a/src/arts/connection/reconnect.ts +++ b/src/arts/connection/reconnect.ts @@ -50,12 +50,16 @@ export function createConnectionReconnectPolicy( return { ok: true, attempt, - delayMs: computeBackoffDelay(attempt - 1, { - minDelayMs: options?.minDelayMs ?? CONNECTION_DEFAULT_RECONNECT_MIN_DELAY_MS, - maxDelayMs: options?.maxDelayMs ?? CONNECTION_DEFAULT_RECONNECT_MAX_DELAY_MS, - factor: options?.factor ?? CONNECTION_DEFAULT_RECONNECT_FACTOR, - jitterMs: options?.jitterMs ?? CONNECTION_DEFAULT_RECONNECT_JITTER_MS - }) + delayMs: computeBackoffDelay( + attempt - 1, + { + minDelayMs: options?.minDelayMs ?? CONNECTION_DEFAULT_RECONNECT_MIN_DELAY_MS, + maxDelayMs: options?.maxDelayMs ?? CONNECTION_DEFAULT_RECONNECT_MAX_DELAY_MS, + factor: options?.factor ?? CONNECTION_DEFAULT_RECONNECT_FACTOR, + jitterMs: options?.jitterMs ?? CONNECTION_DEFAULT_RECONNECT_JITTER_MS + }, + options?.random + ) }; } }; diff --git a/src/arts/connection/test/connection-requests.test.ts b/src/arts/connection/test/connection-requests.test.ts new file mode 100644 index 0000000..704d5e3 --- /dev/null +++ b/src/arts/connection/test/connection-requests.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createConnectionRequestRuntime } from '../connection-requests.ts'; +import type { ConnectionAckRegistry } from '../acks.ts'; +import type { ConnectionSender } from '../sender.ts'; +import type { + ConnectionAckResult, + ConnectionFrame, + ConnectionSendResult +} from '../types.ts'; + +interface FakeAckController { + readonly registry: ConnectionAckRegistry; + resolveLatest(result: ConnectionAckResult): void; +} + +function fakeAckRegistry(): FakeAckController { + const waiters = new Map) => void>(); + const order: string[] = []; + + const registry: ConnectionAckRegistry = { + wait(id: string): Promise> { + return new Promise>((resolve) => { + waiters.set(id, resolve as (result: ConnectionAckResult) => void); + order.push(id); + }); + }, + resolve(id, result) { + const waiter = waiters.get(id); + if (waiter === undefined) return; + waiters.delete(id); + const idx = order.indexOf(id); + if (idx !== -1) order.splice(idx, 1); + waiter(result); + }, + resolveAll(result) { + for (const id of [...waiters.keys()]) { + registry.resolve(id, result); + } + }, + resolveFromFrame(frame: ConnectionFrame) { + if (frame.replyTo === undefined) return; + registry.resolve(frame.replyTo, { ok: true, payload: frame.payload }); + }, + mapSendFailure(_: ConnectionSendResult): ConnectionAckResult { + return { ok: true, payload: undefined }; + } + }; + + return { + registry, + resolveLatest(result) { + const id = order[0]; + if (id !== undefined) registry.resolve(id, result); + } + }; +} + +function fakeSender(): { sender: ConnectionSender; sent: ConnectionFrame[] } { + const sent: ConnectionFrame[] = []; + const sender: ConnectionSender = { + sendFrame: vi.fn(async (frame: ConnectionFrame): Promise => { + sent.push(frame); + return { ok: true }; + }), + flushBuffer: vi.fn(async () => {}), + clearBuffer: vi.fn(() => {}) + }; + return { sender, sent }; +} + +describe('connection request runtime — runAuth singleflight', () => { + it('coalesces concurrent runAuth() calls into a single auth round', async () => { + const acks = fakeAckRegistry(); + const { sender, sent } = fakeSender(); + let getAuthCalls = 0; + let nextIdCalls = 0; + + const runtime = createConnectionRequestRuntime({ + auth: { + getAuth: () => { + getAuthCalls++; + return { token: `t-${getAuthCalls}` }; + } + }, + isConnected: () => true, + nextId: () => `id-${++nextIdCalls}`, + acks: acks.registry, + sender + }); + + const a = runtime.runAuth(); + const b = runtime.runAuth(); + const c = runtime.runAuth(); + + // Microtask hop so getAuth() resolves and the auth frame is sent. + await Promise.resolve(); + await Promise.resolve(); + + expect(getAuthCalls).toBe(1); + expect(sent).toHaveLength(1); + expect(sent[0].type).toBe('connection.auth'); + + acks.resolveLatest({ ok: true, payload: undefined }); + + await expect(a).resolves.toEqual({ ok: true }); + await expect(b).resolves.toEqual({ ok: true }); + await expect(c).resolves.toEqual({ ok: true }); + }); + + it('starts a fresh auth round after the previous one settles', async () => { + const acks = fakeAckRegistry(); + const { sender, sent } = fakeSender(); + let getAuthCalls = 0; + let nextIdCalls = 0; + + const runtime = createConnectionRequestRuntime({ + auth: { + getAuth: () => { + getAuthCalls++; + return { token: `t-${getAuthCalls}` }; + } + }, + isConnected: () => true, + nextId: () => `id-${++nextIdCalls}`, + acks: acks.registry, + sender + }); + + const first = runtime.runAuth(); + await Promise.resolve(); + await Promise.resolve(); + acks.resolveLatest({ ok: true, payload: undefined }); + await expect(first).resolves.toEqual({ ok: true }); + + const second = runtime.runAuth(); + await Promise.resolve(); + await Promise.resolve(); + + expect(getAuthCalls).toBe(2); + expect(sent).toHaveLength(2); + + acks.resolveLatest({ ok: true, payload: undefined }); + await expect(second).resolves.toEqual({ ok: true }); + }); + + it('shares a rejected outcome with all in-flight callers', async () => { + const acks = fakeAckRegistry(); + const { sender } = fakeSender(); + const runtime = createConnectionRequestRuntime({ + auth: { + getAuth: () => ({}) + }, + isConnected: () => true, + nextId: () => 'id-1', + acks: acks.registry, + sender + }); + + const a = runtime.runAuth(); + const b = runtime.runAuth(); + + await Promise.resolve(); + await Promise.resolve(); + + acks.resolveLatest({ ok: false, reason: 'connection.ack.rejected', error: 'bad token' }); + + const resA = await a; + const resB = await b; + expect(resA.ok).toBe(false); + expect(resB.ok).toBe(false); + expect(resA).toEqual(resB); + }); +}); diff --git a/src/arts/connection/test/reconnect.test.ts b/src/arts/connection/test/reconnect.test.ts new file mode 100644 index 0000000..24b6749 --- /dev/null +++ b/src/arts/connection/test/reconnect.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { createConnectionReconnectPolicy } from '../reconnect.ts'; + +describe('connection reconnect policy', () => { + it('honors maxAttempts and rejects further attempts', () => { + const policy = createConnectionReconnectPolicy({ maxAttempts: 2, jitterMs: 0 }); + const first = policy.next(0); + expect(first.ok).toBe(true); + + const second = policy.next(1); + expect(second.ok).toBe(true); + + const third = policy.next(2); + expect(third).toEqual({ ok: false, reconnectAttempt: 2, maxAttempts: 2 }); + }); + + it('threads the injected `random` source through computeBackoffDelay', () => { + // random() = 1 → jitter = +jitterMs (max positive offset) + const positivePolicy = createConnectionReconnectPolicy({ + minDelayMs: 100, + maxDelayMs: 1000, + factor: 1, // no exponential growth so we read the jitter cleanly + jitterMs: 50, + random: () => 1 + }); + const positive = positivePolicy.next(0); + expect(positive.ok).toBe(true); + if (!positive.ok) return; + expect(positive.delayMs).toBe(150); + + // random() = 0 → jitter = -jitterMs (max negative offset, clamped to min) + const negativePolicy = createConnectionReconnectPolicy({ + minDelayMs: 100, + maxDelayMs: 1000, + factor: 1, + jitterMs: 50, + random: () => 0 + }); + const negative = negativePolicy.next(0); + expect(negative.ok).toBe(true); + if (!negative.ok) return; + // 100 - 50 = 50, but the helper clamps to minDelayMs. + expect(negative.delayMs).toBe(50); + }); + + it('disabled reconnect is detected via isEnabled()', () => { + const policy = createConnectionReconnectPolicy(false); + expect(policy.disabled).toBe(true); + expect(policy.isEnabled({ disposed: false, intentionalClose: false })).toBe(false); + }); + + it('refuses reconnection when disposed or after intentional close', () => { + const policy = createConnectionReconnectPolicy({ enabled: true }); + expect(policy.isEnabled({ disposed: true, intentionalClose: false })).toBe(false); + expect(policy.isEnabled({ disposed: false, intentionalClose: true })).toBe(false); + expect(policy.isEnabled({ disposed: false, intentionalClose: false })).toBe(true); + }); +}); diff --git a/src/arts/connection/types.ts b/src/arts/connection/types.ts index 52fd70a..c41b17c 100644 --- a/src/arts/connection/types.ts +++ b/src/arts/connection/types.ts @@ -156,6 +156,12 @@ export interface ConnectionReconnectOptions { readonly maxAttempts?: number; readonly reconnectOnVisible?: boolean; readonly reconnectOnOnline?: boolean; + /** + * Random source used by the backoff jitter. Defaults to `Math.random`. + * Inject a deterministic generator for tests or for reproducible + * staggering across many clients. + */ + readonly random?: () => number; } export interface ConnectionHeartbeatOptions { diff --git a/src/arts/prefs/README.md b/src/arts/prefs/README.md new file mode 100644 index 0000000..2292a4c --- /dev/null +++ b/src/arts/prefs/README.md @@ -0,0 +1,582 @@ +# Prefs + +`prefs` defines the application preference layer for Active. + +It is intentionally isolated for now. It is not wired into `active-app` yet, and +no existing artifact should depend on it until the surrounding modules are ready +to consume narrow preference ports. + +## Purpose + +`prefs` owns user/application preferences that influence how other artifacts +behave, render or format data. + +It answers questions like: + +- What locale does the user prefer? +- What locale should formatting use? +- What currency should money defaults use? +- Which unit system should measurements use? +- Which timezone should date/time formatting use? +- Which UI density, theme or motion preference should the frontend apply? + +It does not translate, format, persist by itself, apply DOM changes directly, or +own technical runtime settings. + +The intended boundary is: + +```txt +Prefs = preference state +Lang = translation resolution +Format = number/date/currency/unit formatting +Frontend = DOM/UI application +Storage = persistence +Bus = event publication +Orca = optional app-level orchestration +``` + +## Why This Exists + +Until now, `locale` has naturally drifted toward `lang` because translation is +the most visible locale consumer. That coupling becomes limiting: + +- Applications may need locale-aware formatting without translations. +- `format` needs locale, currency, timezone and unit preferences. +- `frontend` may need direction, theme, density and motion preferences. +- `lang` should not become the authority for every user preference. + +`prefs` separates preference ownership from preference consumption. + +## Non-Goals + +`prefs` is not: + +- A translation engine. +- A formatting engine. +- A storage adapter. +- A feature flag system. +- A tenant configuration system. +- A security policy system. +- A server settings registry. +- A generic key/value bag. + +If a value configures framework behavior, infrastructure, security, routes, +providers, permissions, transport, logging or deployment, it does not belong in +`prefs`. + +## Future Location + +Planned structure: + +```txt +src/ + libs/ + prefs/ + index.ts + consts.ts + types.ts + ports.ts + guards.ts + + arts/ + prefs/ + index.ts + README.md + engine-prefs.ts + active-prefs.svelte.ts + events.ts + persistence.ts + test/ +``` + +`libs/prefs` will contain contracts and shared types. + +`arts/prefs` will contain the runtime implementation. + +`active-app` will eventually instantiate it and expose it as `App.Prefs`, but +that integration is deliberately postponed. + +## Public Shape + +The final public API should feel simple from application code: + +```ts +const Prefs = createActivePrefs({ + initial: { + locale: { preferredLocale: 'es-MX', contentLocale: 'es-ES', fallbackContentLocale: 'en-US' }, + format: { currency: 'EUR', unitSystem: 'metric', timezone: 'Europe/Madrid' }, + ui: { theme: 'system', density: 'comfortable' } + } +}); + +Prefs.setLocale('en-US'); +Prefs.setCurrency('USD'); +Prefs.setUnitSystem('imperial'); +Prefs.setTheme('dark'); +``` + +After `active-app` integration: + +```ts +App.Prefs.setLocale('es-ES'); +App.Prefs.setCurrency('EUR'); +App.Prefs.setUnitSystem('metric'); + +App.Lang.t('#?checkout.title'); +App.Format.currency(19.95); +``` + +## Snapshot Model + +The snapshot should be explicit and serializable. + +```ts +export interface PrefsSnapshot { + readonly locale: PrefsLocaleSnapshot; + readonly format: PrefsFormatSnapshot; + readonly ui: PrefsUiSnapshot; + readonly version: number; + readonly updatedAt?: number; +} + +export interface PrefsLocaleSnapshot { + /** User preference, e.g. from profile, storage, browser or explicit UI choice. */ + readonly preferredLocale?: string; + /** Locale detected from `Accept-Language`, `navigator.languages` or another source. */ + readonly detectedLocale?: string; + /** Locale used by translated content/catalogs. It must exist in `lang`. */ + readonly contentLocale: string; + readonly fallbackContentLocale?: string; + readonly source?: PrefsLocaleSource; + readonly contentResolution?: PrefsLocaleResolution; + readonly direction?: 'ltr' | 'rtl' | 'auto'; +} + +export type PrefsLocaleSource = + | 'explicit' + | 'persisted' + | 'server' + | 'browser' + | 'tenant' + | 'route' + | 'default'; + +export type PrefsLocaleResolution = + | 'exact' + | 'language_match' + | 'fallback' + | 'default' + | 'unsupported'; + +export interface PrefsFormatSnapshot { + readonly timezone?: string; + readonly currency?: string; + readonly unitSystem?: 'metric' | 'imperial' | 'auto'; + readonly numberingSystem?: string; + readonly hourCycle?: 'h11' | 'h12' | 'h23' | 'h24' | 'auto'; +} + +export interface PrefsUiSnapshot { + readonly theme?: 'light' | 'dark' | 'system'; + readonly colorScheme?: string; + readonly density?: 'compact' | 'comfortable' | 'spacious'; + readonly reducedMotion?: boolean | 'system'; + readonly reducedTransparency?: boolean | 'system'; +} +``` + +The snapshot is not a dumping ground. New groups should only be added when more +than one artifact can reasonably consume them or when the preference represents +an application-level user choice. + +## Engine Contract + +`EnginePrefs` is the non-Svelte core. It should be usable in unit tests, +server-side setup and non-reactive consumers. + +```ts +export interface EnginePrefs { + readonly kind: 'prefs'; + + getSnapshot(): PrefsSnapshot; + + patch(patch: PrefsPatch): PrefsSnapshot; + reset(next?: Partial): PrefsSnapshot; + + setLocale(locale: string): PrefsSnapshot; + setPreferredLocale(locale: string | undefined): PrefsSnapshot; + adoptDetectedLocale(locale: string | undefined, source: PrefsLocaleSource): PrefsSnapshot; + resolveContentLocale(input: PrefsContentLocaleResolveInput): PrefsSnapshot; + setFallbackContentLocale(locale: string | undefined): PrefsSnapshot; + setDirection(direction: PrefsLocaleSnapshot['direction']): PrefsSnapshot; + + setTimezone(timezone: string | undefined): PrefsSnapshot; + setCurrency(currency: string | undefined): PrefsSnapshot; + setUnitSystem(unitSystem: PrefsFormatSnapshot['unitSystem']): PrefsSnapshot; + setNumberingSystem(numberingSystem: string | undefined): PrefsSnapshot; + setHourCycle(hourCycle: PrefsFormatSnapshot['hourCycle']): PrefsSnapshot; + + setTheme(theme: PrefsUiSnapshot['theme']): PrefsSnapshot; + setColorScheme(colorScheme: string | undefined): PrefsSnapshot; + setDensity(density: PrefsUiSnapshot['density']): PrefsSnapshot; + setReducedMotion(value: PrefsUiSnapshot['reducedMotion']): PrefsSnapshot; + setReducedTransparency(value: PrefsUiSnapshot['reducedTransparency']): PrefsSnapshot; + + onChange(handler: PrefsChangeHandler): PrefsUnsubscribe; + dispose(): void; +} +``` + +## Active Contract + +`ActivePrefs` wraps the engine with Svelte runes and exposes a reactive snapshot. + +```ts +export interface ActivePrefs extends EnginePrefs { + readonly state: { + readonly snapshot: PrefsSnapshot; + readonly pending: boolean; + readonly lastError: unknown; + }; +} +``` + +The active layer must not invent separate semantics. If `EnginePrefs.setLocale` +updates the snapshot and emits a change event, `ActivePrefs.setLocale` must do +the same. + +## Narrow Ports + +Consumers should not depend on the full `Prefs` object. + +Each artifact should consume the smallest interface it needs: + +```ts +export interface LocalePrefsPort { + getPreferredLocale(): string | undefined; + getDetectedLocale(): string | undefined; + getContentLocale(): string; + onLocaleChange(handler: (locale: string) => void): PrefsUnsubscribe; +} + +export interface FormatPrefsPort { + getCurrency(): string | undefined; + getUnitSystem(): 'metric' | 'imperial' | 'auto' | undefined; + getTimezone(): string | undefined; + onFormatPrefsChange(handler: (snapshot: PrefsFormatSnapshot) => void): PrefsUnsubscribe; +} + +export interface UiPrefsPort { + getTheme(): PrefsUiSnapshot['theme']; + getDensity(): PrefsUiSnapshot['density']; + getReducedMotion(): PrefsUiSnapshot['reducedMotion']; + onUiPrefsChange(handler: (snapshot: PrefsUiSnapshot) => void): PrefsUnsubscribe; +} +``` + +Expected consumers: + +```txt +Lang consumes LocalePrefsPort and uses contentLocale +Format consumes LocalePrefsPort + FormatPrefsPort +Frontend consumes LocalePrefsPort + UiPrefsPort +Storage persists PrefsSnapshot through an adapter/bridge +Bus receives preference change events +Orca may orchestrate app-level reactions to preference changes +``` + +This keeps module dependencies pointed at contracts, not at the concrete +`ActivePrefs` instance. + +## Events + +All event names must be constants. No module should publish ad-hoc strings. + +Planned constants: + +```ts +export const PREFS_EVENT_CHANGED = 'prefs.changed'; +export const PREFS_EVENT_LOCALE_CHANGED = 'prefs.locale.changed'; +export const PREFS_EVENT_FORMAT_CHANGED = 'prefs.format.changed'; +export const PREFS_EVENT_UI_CHANGED = 'prefs.ui.changed'; +export const PREFS_EVENT_RESET = 'prefs.reset'; +``` + +Events should contain safe, serializable payloads only: + +```ts +export interface PrefsChangedPayload { + readonly previous: PrefsSnapshot; + readonly next: PrefsSnapshot; + readonly changed: readonly PrefsChangedPath[]; + readonly cause?: 'user' | 'system' | 'storage' | 'route' | 'tenant' | 'test'; +} +``` + +Events must not contain secrets, tokens, cookies or credentials. + +## Locale Negotiation + +`prefs` must distinguish locale domains. A user locale can be perfectly valid for +`Intl` formatting while not having any translation catalog in `lang`. + +Core rule: + +```txt +preferredLocale = what the user/browser/server says the user wants +contentLocale = supported locale used by translated content/catalogs +``` + +`contentLocale` and the locale used by `Format` are allowed to diverge. `Format` +does not need a separate stored locale; it derives from the locale preference +chain. + +Example: + +```txt +User/browser locale: es-MX +Application locales: en-US, es-ES, fr-FR +Content locale: es-ES +Format uses: es-MX +Content resolution: language_match +``` + +Another example: + +```txt +User/browser locale: ca-ES +Application locales: en-US, es-ES, fr-FR +Content locale: en-US +Format uses: ca-ES +Content resolution: fallback +``` + +This distinction matters because different artifacts consume different pieces: + +```txt +Lang should use contentLocale because catalogs may not exist for preferredLocale. +Format may use preferredLocale, then detectedLocale, then contentLocale. +Frontend may use direction resolved from preferredLocale, detectedLocale or contentLocale. +Storage should persist the user's preference, not only the fallback result. +``` + +`prefs` should expose enough information for UI to explain what happened: + +```ts +export interface PrefsContentLocaleResolveInput { + readonly preferredLocale?: string; + readonly detectedLocale?: string; + readonly supportedContentLocales: readonly string[]; + readonly fallbackContentLocale: string; + readonly source?: PrefsLocaleSource; +} +``` + +The content locale resolver should be deterministic: + +- Exact match wins. +- Language-only match may be used when explicitly enabled by policy. +- Fallback content locale is used when no supported content locale matches. +- The original preferred/detected locale is preserved for future use. +- Unsupported user preferences are not discarded silently. + +The format locale resolver is different. It should prefer the most specific user +locale that the runtime can format: + +```txt +preferredLocale +detectedLocale +contentLocale +framework default +``` + +This lets an application display translated UI in `es-ES` while still formatting +numbers, dates, currencies and units using `es-MX` conventions. + +## Detection + +Locale detection may happen on the server or in the browser. + +Server-side detection can use: + +- User profile preference. +- Session/account preference. +- Cookie preference. +- `Accept-Language`. +- Tenant/application default. + +Browser-side detection can use: + +- `navigator.languages`. +- `navigator.language`. +- Browser color scheme and motion preferences for UI prefs. +- Browser timezone from `Intl.DateTimeFormat().resolvedOptions().timeZone`. + +Hydration must remain deterministic. The recommended rule is: + +```txt +Server snapshot initializes Prefs. +Browser detection may refine missing values after hydration. +Browser detection must not overwrite an explicit or persisted user preference. +``` + +If both server and browser detect different locales, the source priority rules +decide which value wins. The losing value can remain recorded as `detectedLocale` +for diagnostics and UI messaging. + +## Source Priority + +Preference resolution often has several possible sources. The module should +support this without hiding the rules. + +Recommended priority: + +```txt +explicit runtime override +user persisted preference +server user/session preference +tenant/application default +route/layout override +browser/system preference +framework default +``` + +For v0, this can be represented simply through `initial` plus explicit setter +calls. A future version can add a resolver if needed. + +## Persistence + +Persistence should be optional and external. + +`Prefs` should not import `storage` directly. Instead, a bridge can connect both: + +```ts +createPrefsStorageBridge({ + prefs, + storage, + key: 'active:prefs' +}); +``` + +Rules: + +- Persist only safe preferences. +- Do not persist secrets. +- Do not persist temporary route overrides unless explicitly requested. +- Persist the user's preferred values, not only resolved fallback values. +- Persisted values must include a schema version for migrations. +- Persistence should support scope: anonymous device, authenticated user, + tenant, or test. +- Storage failures must not corrupt in-memory preferences. +- Hydration must be deterministic: initial server snapshot wins until the client + intentionally adopts persisted preferences. +- If persisted preferences disagree with server-authoritative user preferences, + the server-authoritative preference wins unless the app explicitly chooses a + client-first policy. + +## Validation + +The module should validate preference values at the boundary: + +- Locale strings should be syntactically valid BCP 47 tags when possible. +- Currency should be ISO 4217-like uppercase codes when possible. +- Timezone should be accepted only if the runtime recognizes it or if validation + is explicitly disabled. +- Unit system, density, theme and hour cycle should be closed unions. + +Invalid preferences should fail clearly in development and return structured +errors in production-oriented APIs. + +## Logging + +`prefs` may accept a narrow logger port, but it must not depend on the concrete +logger implementation. + +Use cases: + +- Invalid persisted snapshot rejected. +- Storage bridge failed to read/write. +- Unknown preference key ignored during migration. +- Browser/system preference could not be detected. + +Normal preference changes should not spam logs. They are application events, not +errors. + +## Lifecycle + +`dispose()` must be idempotent. + +After dispose: + +- Listeners are removed. +- Storage bridges are detached. +- Bus subscriptions are detached. +- Further writes either no-op or throw a documented `prefs` error. The preferred + behavior is to throw in dev/test and no-op in production only if explicitly + configured. + +## Integration Plan + +The integration into `active-app` should happen only after the current ecosystem +docs and contracts are aligned. + +Planned sequence: + +1. Implement isolated `libs/prefs` contracts and `arts/prefs` engine. +2. Add unit tests for snapshot updates, events, ports and dispose behavior. +3. Add `active-prefs.svelte.ts` and rune-state tests. +4. Add optional storage bridge tests using the existing storage artifact. +5. Update `lang` to accept `LocalePrefsPort` without requiring `Prefs`. +6. Update `format` to accept `LocalePrefsPort` and `FormatPrefsPort`. +7. Update `frontend` to accept `UiPrefsPort`. +8. Add `App.Prefs` to `active-app` as an always-present, low-cost service. +9. Add composed tests: changing locale updates `lang`, `format` and `frontend` + consumers without direct coupling. +10. Update the Active documentation page with the new preference model. + +## Test Agenda + +Minimum tests before integration: + +```txt +src/arts/prefs/test/engine-prefs.test.ts +src/arts/prefs/test/active-prefs.test.ts +src/arts/prefs/test/ports.test.ts +src/arts/prefs/test/persistence.test.ts +src/arts/prefs/test/dispose.test.ts +``` + +Required scenarios: + +- Default snapshot is stable and serializable. +- `setLocale` emits locale and global change events. +- Locale negotiation preserves `preferredLocale` when the system falls back. +- Browser detection fills missing locale values but does not overwrite explicit + or persisted preferences. +- Server initial locale wins during hydration unless the adoption policy says + otherwise. +- `setCurrency` emits format and global change events. +- `setTheme` emits UI and global change events. +- `patch` reports changed paths accurately. +- `reset` restores defaults and emits reset. +- Listeners can unsubscribe. +- `dispose` is idempotent. +- Writes after dispose follow the documented policy. +- Storage bridge hydrates from a valid persisted snapshot. +- Storage bridge rejects invalid persisted snapshots without corrupting memory. +- Consumers can use narrow ports without importing `ActivePrefs`. + +## IA Agents + +When modifying or integrating `prefs`, keep these invariants: + +- Do not wire `prefs` into `active-app` until explicitly requested. +- Do not make `prefs` import `lang`, `format`, `frontend`, `storage`, `orca` or + `active-app`. +- Consumers must depend on narrow ports, not on the full `ActivePrefs` object. +- Do not add generic arbitrary settings. +- Do not store secrets. +- Do not format or translate inside `prefs`. +- Do not publish event strings inline; add constants first. +- Keep server, active and shared contracts separated. +- Add tests before using `prefs` from another artifact.