# Refactorización de `arts/active-app` — eliminación de eventos parche y modelo de servicios ## Estado de este documento **ARCHIVADO — refactor completado 2026-05-04.** El documento captura el análisis y plan de las fases 1-4 (eliminación de `APP_EVENT_*`, modelo de servicios declarativo, integración con `orca`). Todas las fases se ejecutaron; el contrato vivo es `src/arts/active-app/README.md` y el código mismo. Esta página se mantiene como registro histórico del razonamiento que guió el big-bang, no como guía operativa. `orca` arrancó como v0-kernel durante este refactor y se completó al 100% en sesiones posteriores; ver `src/arts/orca/README.md` para el estado actual. --- ## Resumen ejecutivo Tres problemas convergentes: 1. `arts/cache`, `arts/perm`, `arts/connection` se **suscriben internamente al bus** para reaccionar a `APP_EVENT_*`. Eso filtra vocabulario de App (tenant, refresh, identity) a piezas que deberían ser runtime puro. 2. Los `APP_EVENT_*` son **deuda técnica disfrazada**: la mayoría son *republicaciones* de eventos cuyo dueño real es otro módulo (sesión publica identidad, conexión publica connectivity), o son **comandos** disfrazados de eventos (`PERMISSIONS_REFRESH_REQUESTED`, `CACHE_INVALIDATE_REQUESTED`). 3. `arts/active-app` esconde *side-effects* de orquestación —violando una invariante explícita de `arts/orca`— porque hoy no existe `orca` como pieza de orquestación dedicada. **Decisión arquitectónica:** - Eliminar todos los `APP_EVENT_*` salvo los que `aapp` dueña realmente (`DISPOSE_STARTING`). - Eliminar el `session-translator` y las suscripciones internas en `cache`/`perm`/`connection`. - Exponer **API imperativa pública** en cada artefacto (`invalidate`, `refresh`, `cancelPrivateRequests`, `reauthenticateAll`). - Reescribir `aapp` como **compositor + factory + lifecycle** sobre un esquema declarativo de servicios (`AppServiceSchema`). - Toda orquestación inter-modular se mueve a `orca` (cuando exista) o a *bridge code* explícito en `arts/active-app` mientras tanto. --- ## 1. Problema raíz ### 1.1 Acoplamiento concreto observado | Artefacto | Línea | Suscripciones internas | |---|---|---| | `arts/cache/active-cache.svelte.ts` | 211–219 | `USER_IDENTITY_CHANGED`, `TENANT_SWITCHED` | | `arts/perm/active-permissions.svelte.ts` | 151–165 | `USER_IDENTITY_CHANGED`, `PERMISSIONS_REFRESH_REQUESTED`, `TENANT_SWITCHED` | | `arts/connection/bus-session-source.ts` | 10 | `USER_IDENTITY_CHANGED` (vía `createBusSessionSource`) | Cada uno hace `bus.on(APP_EVENT_X, () => método-interno())` para auto-reaccionar. La consecuencia: - `arts/cache` **conoce el concepto "tenant"**. - `arts/perm` **conoce el concepto "refresh"**. - `arts/connection` **conoce el concepto "identity"**. Si alguien intentara usar `arts/cache` fuera de Active framework, está obligado a entender qué es un tenant o a inyectar un bus que falsifique eventos `app.*`. Eso rompe la promesa "los arts son piezas runtime reusables". ### 1.2 Por qué los `APP_EVENT_*` son un parche | Evento | Dueño real | Naturaleza | Veredicto | |---|---|---|---| | `USER_IDENTITY_CHANGED` | `session` | Republicación de `SESSION_EVENT_LIFECYCLE_*` | **Eliminar.** Consumidores escuchan `SESSION_EVENT_*`. | | `PERMISSIONS_REFRESH_REQUESTED` | nadie | **Comando**, no hecho | **Eliminar.** Reemplazar por llamada imperativa `App.perms.refresh()`. | | `CACHE_INVALIDATE_REQUESTED` | nadie | **Comando**, no hecho | **Eliminar.** Reemplazar por `App.cache.invalidate(...)`. | | `TENANT_SWITCHED` | App-state | Hecho de App-level | **Mantener provisional.** Si tenant pasa a un módulo dueño en el futuro, eliminar. | | `CONNECTIVITY_CHANGED` | navegador / `connection` | Estado del navegador | **Mover a `connection`** como `CONNECTION_EVENT_ONLINE/OFFLINE`. | | `DISPOSE_STARTING` | App | Lifecycle de aapp | **Mantener.** Único evento donde App es realmente dueña. | La regla: > Un evento existe en App **solo si App es el dueño del hecho**. Comandos no son > eventos. Las republicaciones no son eventos: son indirección. --- ## 2. Restricción arquitectónica: `orca` `arts/orca/README.md` define el motor de orquestación que va a reemplazar las suscripciones internas. Documento extenso ya cerrado en su contrato. ### 2.1 Invariantes de orca relevantes para este refactor Citas literales (ver `arts/orca/README.md`): > - `orca` no importa artefactos concretos salvo contratos comunes. > - **los artefactos no consumen `orca`**; solo publican eventos en `buss`. > - **la aplicacion registra acciones en `orca`**. > - `sess`, `cach`, `perm`, `connection`, `auth` o `http` **no deben depender > de `orca`** para sus flujos internos. > - `aapp` puede crear `Bus`, `Timers`, `Logger` y `Orchestration`, pero **no > debe esconder la politica de orquestacion**. Y de "Que problema resuelve": > Sin `orca`, las reacciones inter-modulo tienden a acabar repartidas: > > ``` > sess conoce cach > cach conoce perm > connection conoce sess > aapp conoce todo > ``` > > Eso escala mal. ### 2.2 Implicación: este refactor es pre-requisito de orca v0 Si `orca` registra una action en `SESSION_EVENT_LIFECYCLE_REVOKED` que invalida cache, y `arts/cache` **también** se suscribe internamente al mismo evento, hay **doble reacción**: race condition, double-invalidation, estado corrupto. Por tanto el refactor de eliminar suscripciones internas en `cache`/`perm`/ `connection` **no es opcional** para que `orca` exista. O se hace ahora, o se hace como primer paso del proyecto orca. Conviene hacerlo ahora porque: - El acoplamiento conceptual es ruido en el código actual incluso sin orca. - Permite simplificar `libs/active-app/events.ts` drásticamente. - Permite mover `libs/active-app/` → `arts/active-app/` (la única razón de vivir en libs eran los consumidores externos cache/perm/conn que dejarán de existir). --- ## 3. Diseño emergente: `aapp` como compositor + servicios `aapp` deja de tener lógica reaccional propia. Pasa a ser **compositor explícito** con dos secciones: **núcleo** (siempre presente, parte de su runtime) y **servicios** (declarados explícitamente por el desarrollador en un `AppServiceSchema`). ### 3.1 Distinción núcleo vs servicios **Núcleo** (siempre presente, sin opt-in): | Servicio | Responsabilidad | |---|---| | `logger` | logger compartido | | `lang` | i18n | | `storage` | almacenamiento sync (con adapters) | | `format` | formateadores localizados | | `dom` | reactividad DOM | | `frontend` | preferencias de UI | | `bus` | event bus | | `timers` | scheduler de timers | | `orchestration` | `orca` (siempre presente, inerte hasta que se registren acciones) | Configurables vía las opciones que ya existen hoy en `createActiveApp()`. La configuración del núcleo no entra en `services:` — entra en la raíz del options object. Mantenemos los contratos ya definidos. **Servicios opcionales** (opt-in): | Servicio | Hoy es | |---|---| | `sium` | factory lazy `App.createSiumEngine()` | | `session` | factory lazy `App.createActiveSession<...>()` | | `cache` | factory lazy `App.createActiveCache(...)` | | `perm` | factory lazy `App.createActivePerms(...)` | | `http` | factory lazy `App.createEngineHttp(...)` | | `auth` | factory lazy `App.createActiveAuth(...)` | | `connection` | factory lazy `App.createActiveConnections(...)` | Pasan a declararse en el schema. Si no se declaran, no existen en la app y el acceso (`App.cache`) es **error de tipos**. ### 3.2 Esquema base ```ts const App = createActiveApp({ // Núcleo (configurable; siempre presente) lang: { schema: appLang, defaultLocale: 'es' }, storage: { adapter: localStorageAdapter() }, frontend: { theme: 'system' }, // Servicios (opt-in declarativos) services: { cache: defineActiveCache({ adapter: 'memory' }), session: defineActiveSession({ refresh: refreshFn }), http: defineEngineHttp({ baseUrl: '/api' }), } }); App.bus // núcleo: tipo EngineBus App.cache // OK: declarado en services App.session // OK: declarado en services App.perm // ❌ TS error: no está en services App.connections // ❌ TS error: no está en services ``` ### 3.3 Contrato base de un servicio ```ts type ServiceState = | 'pending' // declarado, aún no construido | 'initializing' // construyéndose en este momento | 'running' // operativo | 'failed' // crash al iniciar | 'disposing' // dispose en curso | 'disposed'; // ya destruido type ServiceInitMode = | 'immediate' // se construye en commit() de la app | 'lazy'; // se construye en el primer acceso (App.cache → trigger init) interface AppService { readonly serviceName: TName; readonly initMode: ServiceInitMode; readonly dependencies: readonly string[]; // claves de núcleo o de otros servicios readonly state: ServiceState; // observable público readonly instance: TInstance; // la instancia construida readonly dispose: () => void | Promise; } // Cada artefacto exporta un define*() que produce una factory tipada interface AppServiceFactory { readonly name: TName; readonly initMode: ServiceInitMode; readonly dependencies: readonly (keyof TDeps & string)[]; create(deps: TDeps): TInstance; dispose?(instance: TInstance): void | Promise; } ``` Cada art expone su factory: ```ts // arts/cache/index.ts export function defineActiveCache(options: ActiveCacheOptions) { return { name: 'cache' as const, initMode: 'lazy' as const, dependencies: ['bus', 'logger', 'timers'] as const, create(deps: { bus: EngineBus; logger: EngineLogger; timers: TimerScheduler }) { return createActiveCache({ ...options, ...deps }); }, dispose(instance: ActiveCache) { instance.dispose(); } }; } ``` ### 3.4 Inicialización: `lazy` vs `immediate` | Modo | Cuándo se construye | Caso de uso | |---|---|---| | `immediate` | Al hacer `commit()` (después de `createActiveApp(...)`) | Servicios que la app necesita de salida (sesión inicial, http base) | | `lazy` | En el primer acceso `App.cache` | Servicios que pueden no usarse en algunos flujos (cache cuando solo hay rutas estáticas) | Default por servicio: lo decide el `define*()` del art. La app puede sobrescribirlo: ```ts services: { cache: defineActiveCache(options).withInitMode('immediate') } ``` ### 3.5 Validación del schema Estática (al construir): - **Servicios declarados que no existen como factory** → error. - **Dependencias declaradas que apuntan a un servicio no presente en el schema ni en el núcleo** → error en `createActiveApp()` (compile-time vía tipos cuando sea posible; runtime al `commit()` si los tipos no llegan). - **Ciclos de dependencias** → error en `commit()`. Runtime: - Servicios `immediate` se construyen en orden topológico al `commit()`. - Si la construcción de uno falla, su `state` queda en `'failed'` y se aborta el `commit()` con error agregado. - Servicios `lazy` se construyen en el primer acceso; el error queda en su `state`. ### 3.6 Type-safety `ActiveApp` se vuelve genérico sobre el schema: ```ts type ActiveApp>> = CoreApp & { [K in keyof TServices]: TServices[K]['instance']; } & { // Acceso a metadata services: { [K in keyof TServices]: AppService; }; }; ``` Resultado: si declaras `cache` y `session`, `App.cache` y `App.session` existen con su tipo correcto y `App.perm` falla en compile-time. ### 3.7 Orquestación La orquestación **no entra en el schema de servicios**. Va por `orca`: ```ts // El developer registra acciones en App.orchestration, no en el schema App.orchestration.onEvent(SESSION_EVENT_LIFECYCLE_REVOKED, { id: ORCA_ACTION_INVALIDATE_CACHE, stage: ORCA_STAGE_MAIN, action: async () => { await App.cache.invalidate({ on: 'userIdentityChange' }); return orcaSuccess(); } }); ``` Razón: orca tiene su propio sistema (stages, tokens, policies) que no pertenece al schema declarativo de servicios. Mezclarlos hace que el schema crezca a un DSL paralelo de orca, redundante. **Mientras `orca v0` no exista**, `aapp` puede aceptar un slot opcional `bridges:` con suscripciones provisionales del estilo `bus.on(EVENT, () => App.X.method())`. Comentado claramente como código transitorio. Cuando `orca` llegue, esas líneas se reemplazan por `Orca.onEvent()`. --- ## 4. Decisiones cerradas 1. **Modelo:** núcleo (siempre presente) + servicios (opt-in declarado). 2. **Naming:** `createActiveApp(options)` (no `new ActiveApp(...)`); helper de servicio `defineActive*` / `defineEngine*`. La interfaz base se llama `AppService` (sin "Active" — el "Active" del framework significa `$state`-reactivo y no aplica a todos los servicios). 3. **Tipo del schema:** `services: { [K in TName]: AppServiceFactory<...> }` tipado, con `K` literal para inferencia. 4. **Servicios declarados que no existen como factory:** error. 5. **Dependencias faltantes:** error en compile-time (cuando los tipos alcanzan) y en `commit()` runtime como respaldo. 6. **Init mode por servicio:** `lazy` por defecto en factories de servicios opcionales; `immediate` solo si el `define*()` lo declara así. Override en la declaración del schema permitido. 7. **Núcleo configurable** vía opciones existentes (`lang`, `storage`, `frontend`, …) en la raíz del options object. 8. **Orquestación**: vive en `orca`, no en el schema de servicios. 9. **`AppEventBus`, `APP_EVENT_*`**: sobreviven solo `DISPOSE_STARTING`. El resto se elimina. `libs/active-app/events.ts` se reduce a este único evento (o desaparece, ver §6). 10. **`session-translator`**: se elimina. `orca` (o el bridge provisional) escucha `SESSION_EVENT_LIFECYCLE_*` directamente. --- ## 5. Decisiones abiertas 1. **Ubicación final de `libs/active-app/`:** una vez vaciado, ¿se mueve todo a `arts/active-app/` (sin libs) o se mantiene `libs/active-app/` con solo `consts.ts` y `errors.ts`? **Recomendación**: mover todo a `arts/active-app/`. Solo el código del núcleo y los servicios es runtime; no hay contrato puro reusable que justifique una capa abstracta. 2. **Tenant**: ¿quién dueña el cambio de tenant? Si se confirma que es App, `APP_EVENT_TENANT_SWITCHED` sobrevive. Si pasa a un módulo (futuro `tenant`), se elimina. **Acción**: investigar consumidores reales y decidir. 3. **Connectivity**: ¿`arts/connection` ya publica `CONNECTION_EVENT_ONLINE/ OFFLINE`? Si sí, `APP_EVENT_CONNECTIVITY_CHANGED` se elimina y los listeners migran. **Acción**: verificar antes de Fase 1. 4. **`bridges:` provisional vs forzar `orca` desde día uno**: ¿conviene meter las suscripciones provisionales en `aapp` con un slot dedicado o esperar a `orca`? 5. **Eager construction en `commit()`**: ¿el orden topológico se calcula automáticamente o se exige al desarrollador declararlo? **Recomendación**: automático con detección de ciclos. --- ## 6. Plan de refactor por fases ### Fase 1 — Eliminar eventos parche (ejecutable inmediatamente) **Objetivo**: dejar `arts/cache`, `arts/perm`, `arts/connection` sin suscripciones internas a eventos `APP_EVENT_*`. Eliminar el `session- translator`. Reducir `libs/active-app/events.ts` a `DISPOSE_STARTING` (y posiblemente `TENANT_SWITCHED` si decidimos mantenerlo). **Riesgo**: alto. Cambia el comportamiento "auto-invalidate" que hoy hacen los arts. Tests que asumen ese comportamiento se rompen. **Subpasos**: 1. **1A — Verificar dueños reales**: - Confirmar que `connection` publica `CONNECTION_EVENT_ONLINE/OFFLINE`. Si no, considerar que `connection` lo añada antes de eliminar `APP_EVENT_CONNECTIVITY_CHANGED`. - Confirmar que `tenant` no tiene un módulo dueño y que mantenerlo en App es la opción correcta. 2. **1B — Exponer API imperativa pública** en `arts/cache`, `arts/perm`, `arts/connection` para los efectos hoy automáticos: - `cache.invalidate({ on: 'userIdentityChange' | 'tenantSwitched' | ... })` - `perm.invalidate()`, `perm.refresh({ cause? })` - `connections.adoptIdentity(id)`, `connections.reauthenticateAll()` 3. **1C — Eliminar suscripciones internas** en los `active-*.svelte.ts` de los tres arts. Conservar (de momento) el option `bus?: AppEventBus` para no romper firmas de creación. 4. **1D — Eliminar `session-translator.ts`** y sus tests. 5. **1E — Mover suscripciones provisionales a `aapp`**: - `arts/active-app/active-app.svelte.ts` registra `bus.on(SESSION_EVENT_*, () => app.cache?.invalidate(...))` como código transitorio. - Comentado: `// PROVISIONAL: when orca v0 lands, replace with App.orchestration.onEvent(...)` 6. **1F — Eliminar publishers/payloads/helpers de `libs/active-app/events.ts`** salvo `DISPOSE_STARTING`. Tests que publicaban `publishAppUserIdentityChanged(...)` migran a publicar `SESSION_EVENT_LIFECYCLE_*` directamente o a llamar la API imperativa. 7. **1G — Limpiar imports** en `arts/cache`, `arts/perm`, `arts/connection` que ya no apunten a `$libs/active-app/events`. 8. **1H — Actualizar `artifact-docs.ts`** para reflejar la realidad. 9. **1I — Tests + commit**. ### Fase 2 — Exposición sólida de API imperativa Si Fase 1 deja la API imperativa "como mejor se pudo", Fase 2 audita y estabiliza: - Firma uniforme `cancelable(): { dispose(): void }` para suscripciones externas si las hay. - Documentar contractualmente qué métodos cada art expone para ser invocados desde `orca`. - Consolidar nombres (`invalidate` vs `clear`, `refresh` vs `reload`). ### Fase 3 — Modelo de servicios Implementar `AppServiceSchema`, `AppService`, `AppServiceFactory`, `createActiveApp({ services: { … } })` con type-safety, init modes, validación. Subpasos: 1. Definir tipos en `arts/active-app/services.ts`. 2. Cada art expone su `defineActive*()` / `defineEngine*()` factory. 3. Reescribir `createActiveApp()` para construirse desde el schema. 4. Migrar las apps cliente (web/routes) a la nueva API. 5. Eliminar las firmas legacy `App.createActive*()`. ### Fase 4 — Integración con orca v0 Cuando `arts/orca/` esté implementado: 1. `App.orchestration` (orca) reemplaza al *bridge code* provisional de Fase 1E. 2. Los registros provisionales `bus.on(SESSION_EVENT_*, ...)` se reescriben como `App.orchestration.onEvent(SESSION_EVENT_*, { id, stage, action })`. 3. `aapp` deja de tener cualquier `bus.on()` directo. Solo compose + factory + lifecycle. --- ## 7. Notas para evaluadores ### 7.1 Qué validar antes de mergear este diseño - ¿La distinción núcleo/servicios cubre todos los casos? Por ejemplo, `dom` está en núcleo pero solo tiene sentido en cliente. Quizás `dom` y `frontend` deberían ser servicios opcionales con default `present` en cliente y `absent` en server. - ¿`orchestration` (orca) en el núcleo es lo correcto, o debería ser un servicio opcional para apps que no orquestan nada? - ¿La separación `libs/X` (contrato) vs `arts/X` (runtime) sigue justificándose para todos los módulos, o solo cuando hay consumidores en capa abstracta? ### 7.2 Riesgos no resueltos - **API imperativa actualmente parcial**: `cache.invalidate({ on: ... })` hoy sí existe; `perm.refresh()` también. Pero se pasaba el flag `on:` a un option que el art interpretaba; con la nueva API, debe pasarse explícitamente desde la action de orca o desde el bridge provisional. - **Tests de ecosistema** asumen comportamiento auto-reactivo. Migrar test por test es trabajo manual no automatizable. - **`bus-session-source`** en `arts/connection` es un caso de adapter de App a Connection. Considerar moverlo a `arts/active-app/integrations/` para no contaminar `connection` con vocabulario de App. ### 7.3 Coherencia con memoria del usuario Este diseño respeta: - **"arts/* boundaries — runtime only"**: arts dejan de filtrar vocabulario de App. - **"Define dependencies in the API, not via silent fallbacks"**: el schema hace explícitas las dependencias entre servicios. - **"Minimal API surface"**: factories `define*()` no añaden helpers redundantes; reusan los `createActive*()` ya existentes. - **"professional-grade design preferences"**: sin acoplamientos ocultos, sin runtime arg-shape magic, type-safety completa. --- ## Apéndice A — Mapa de eventos `APP_EVENT_*` ``` APP_EVENT_USER_IDENTITY_CHANGED → eliminar (republicación de SESSION_EVENT_LIFECYCLE_*) APP_EVENT_PERMISSIONS_REFRESH_REQUESTED → eliminar (comando → App.perm.refresh()) APP_EVENT_CACHE_INVALIDATE_REQUESTED → eliminar (comando → App.cache.invalidate(...)) APP_EVENT_CONNECTIVITY_CHANGED → mover a arts/connection (CONNECTION_EVENT_*) APP_EVENT_TENANT_SWITCHED → mantener provisional (App es dueña actualmente) APP_EVENT_DISPOSE_STARTING → mantener (App es dueña real del lifecycle) ``` ## Apéndice B — Tipos preliminares ```ts // arts/active-app/services.ts (a crear en Fase 3) export type ServiceState = | 'pending' | 'initializing' | 'running' | 'failed' | 'disposing' | 'disposed'; export type ServiceInitMode = 'immediate' | 'lazy'; export interface AppServiceFactory< TName extends string, TDeps extends Record, TInstance > { readonly name: TName; readonly initMode: ServiceInitMode; readonly dependencies: readonly (keyof TDeps & string)[]; create(deps: TDeps): TInstance; dispose?(instance: TInstance): void | Promise; } export interface AppService { readonly serviceName: TName; readonly initMode: ServiceInitMode; readonly dependencies: readonly string[]; readonly state: ServiceState; readonly instance: TInstance; readonly dispose: () => void | Promise; } export type AppServiceSchema = Record>; export type ResolveAppInstances = { [K in keyof S]: S[K] extends AppServiceFactory ? I : never; }; ``` ## Apéndice C — Ejemplo de uso final ```ts import { createActiveApp } from '$active-app'; import { defineActiveCache } from '$cache'; import { defineActiveSession } from '$session'; import { defineEngineHttp } from '$http'; import { SESSION_EVENT_LIFECYCLE_REVOKED } from '$session'; import { ORCA_STAGE_MAIN, orcaSuccess } from '$orca'; const App = createActiveApp({ // Núcleo configurable lang: { schema: appLang, defaultLocale: 'es' }, storage: { adapter: localStorageAdapter() }, frontend: { theme: 'system' }, // Servicios opt-in (opcionales, declarados) services: { cache: defineActiveCache({ adapter: 'memory' }), session: defineActiveSession({ refresh: refreshFn }).withInitMode('immediate'), http: defineEngineHttp({ baseUrl: '/api' }), } }); // Orquestación explícita vía orca (no escondida en services:) App.orchestration.onEvent(SESSION_EVENT_LIFECYCLE_REVOKED, { id: 'app.invalidate-on-revoke', stage: ORCA_STAGE_MAIN, action: async () => { await App.cache.invalidate({ on: 'userIdentityChange' }); return orcaSuccess(); } }); // Type-safety: App.cache // ✅ ActiveCache App.session // ✅ ActiveSession App.http // ✅ EngineHttp App.perm // ❌ TS error: 'perm' is not in services schema App.connections // ❌ TS error ``` --- --- ## 8. Revisión arquitectónica recibida — round 1 Llegó un análisis externo del código actual con tres observaciones que el diseño inicial no había nombrado. ### 8.1 Tres mecanismos de orquestación compitiendo `active-app.svelte.ts` no tiene un solo problema de acoplamiento, tiene tres mecanismos paralelos, cada uno reinventando orquestación con menos rigor que el anterior: 1. **Suscripciones internas en los servicios** (`Cache`, `Perms`, `Connection`) — el problema diagnosticado en §1. 2. **`wireSessionTranslator`** — republica `SESSION_EVENT_LIFECYCLE_*` como `APP_EVENT_USER_IDENTITY_CHANGED`. Combinado con los presets `APP_ORCHESTRATION_TRANSLATOR_*` declarados en `consts.ts`, esto es **un orca embrionario sin tokens, sin stages, sin políticas, enterrado en aapp**. 3. **`createAuthCacheInvalidator`** — orquestación implícita auth ↔ cache ↔ perms con 0 trazabilidad. Conclusión arquitectónica: ya estás haciendo orca, solo que sin formalizar. Cuando llegue, no añade complejidad: la **reemplaza**. ### 8.2 Asimetría singleton vs multi-create Hoy: - `createActiveSession`, `createActivePerms`, `createActiveAuth` lanzan `*AlreadyCreatedError` si se llaman dos veces. - `createActiveConnections` mantiene un `Set` y permite múltiples instancias. El modelo de servicios borra esta asimetría: **una instancia por servicio declarado**. `connections` pasa a singleton. El caso raro de "varios registries" se resuelve en código de aplicación, no en infraestructura. ### 8.3 La distinción "siempre presente" vs "factory" es ruido `Cache` se construye siempre que se construye App. `Sess`, `Perms`, `Auth` son factories invocadas a demanda. La razón histórica es bundle (cache es "casi siempre necesario") no principio de diseño. Con servicios declarativos, la distinción desaparece: **todos los servicios opcionales se declaran o no se declaran**. Cero asimetría. ### 8.4 Código propuesto (round 1) #### `arts/active-app/services.ts` ```ts import type { EngineBus } from '$bus'; import type { ActiveTimers } from '$timer'; import type { EngineLogger } from '$logger'; /** * El núcleo siempre presente. Todos los servicios pueden depender de * cualquier subset de estas piezas. */ export interface CoreServices { readonly logger: EngineLogger; readonly bus: EngineBus; readonly timers: ActiveTimers; // readonly orca: EngineOrca; // cuando v0.0 aterrice } export type CoreServiceKey = keyof CoreServices; export type ServiceInitMode = 'immediate' | 'lazy'; export type ServiceStatus = 'absent' | 'present' | 'failed'; export interface AppServiceFactory< TName extends string = string, TCoreDeps extends readonly CoreServiceKey[] = readonly CoreServiceKey[], TServiceDeps extends readonly string[] = readonly string[], TInstance = unknown > { readonly name: TName; readonly coreDependencies: TCoreDeps; readonly serviceDependencies?: TServiceDeps; readonly initMode?: ServiceInitMode; create(deps: { core: Pick; services: Partial>; }): TInstance; dispose?(instance: TInstance): void | Promise; } export type AppServiceSchema = Record; export type ResolveServiceInstances = { [K in keyof TSchema]: TSchema[K] extends AppServiceFactory< string, readonly CoreServiceKey[], readonly string[], infer I > ? I : never; }; ``` #### `arts/active-app/active-app.svelte.ts` (refactor) Más corto que el actual. Toda la lógica "factory por artefacto" desaparece; la responsabilidad pasa al schema. ```ts import { createActiveTimers } from '$timer/active-timers.svelte'; import { createSvelteEngineBus } from '$bus'; import { createEngineLogger } from '$logger/engine-logger'; // import { createEngineOrca } from '$orca'; // cuando v0.0 aterrice import type { ActiveApp, ActiveAppOptions, ActiveAppBusEvents } from './types.ts'; import type { AppServiceSchema, CoreServices, ServiceStatus } from './services.ts'; export function createActiveApp( options: ActiveAppOptions = {} as ActiveAppOptions ): ActiveApp { // Núcleo const Logger = createEngineLogger(options.logger); const Timers = createActiveTimers({ ...options.timers, logger: Logger }); const Bus = createSvelteEngineBus({ ...options.bus, logger: Logger, clock: Timers.clock }); // const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger }); const core: CoreServices = { logger: Logger, bus: Bus, timers: Timers }; // Servicios declarados const schema = options.services ?? ({} as TSchema); const builders = buildServiceBuilders(schema, core); let disposed = false; return { Logger, Bus, Timers, // Orca, ...builders.proxies, get services() { return builders.statusMap(); }, dispose() { if (disposed) return; disposed = true; builders.disposeAll(); // servicios primero Bus.dispose(); // núcleo después Timers.dispose(); Logger.dispose(); } } as ActiveApp; } interface ServiceBuilders { readonly proxies: Record; readonly statusMap: () => Record; readonly disposeAll: () => void; } function buildServiceBuilders( schema: AppServiceSchema, core: CoreServices ): ServiceBuilders { validateSchema(schema); const order = topologicalOrder(schema); const instances = new Map(); const status = new Map(); const failures = new Map(); for (const name of order) { status.set(name, 'absent'); const factory = schema[name]; if (factory.initMode === 'immediate') construct(name); } function construct(name: string): unknown { if (instances.has(name)) return instances.get(name); const factory = schema[name]; const coreSubset = pick(core, factory.coreDependencies); const serviceSubset: Record = {}; for (const dep of factory.serviceDependencies ?? []) { if (schema[dep]) serviceSubset[dep] = construct(dep); } try { const instance = factory.create({ core: coreSubset, services: serviceSubset }); instances.set(name, instance); status.set(name, 'present'); return instance; } catch (error) { status.set(name, 'failed'); failures.set(name, error); throw error; } } const proxies: Record = {}; for (const name of Object.keys(schema)) { Object.defineProperty(proxies, name, { get: () => construct(name), enumerable: true }); } function disposeAll() { const built = order.filter((n) => instances.has(n)).reverse(); for (const name of built) { try { schema[name].dispose?.(instances.get(name)); } catch { /* dispose errors do not propagate */ } } instances.clear(); } return { proxies, statusMap: () => Object.fromEntries(status), disposeAll }; } function pick(obj: T, keys: readonly K[]): Pick { const result = {} as Pick; for (const k of keys) result[k] = obj[k]; return result; } function validateSchema(schema: AppServiceSchema): void { for (const [key, factory] of Object.entries(schema)) { if (factory.name !== key) { throw new Error( `[active-app] service factory name "${factory.name}" must match schema key "${key}"` ); } } } function topologicalOrder(schema: AppServiceSchema): string[] { const visited = new Set(); const visiting = new Set(); const order: string[] = []; function visit(name: string) { if (visited.has(name)) return; if (visiting.has(name)) { throw new Error(`[active-app] dependency cycle detected at "${name}"`); } visiting.add(name); const factory = schema[name]; if (factory) { for (const dep of factory.serviceDependencies ?? []) { if (schema[dep]) visit(dep); } } visiting.delete(name); visited.add(name); order.push(name); } for (const name of Object.keys(schema)) visit(name); return order; } ``` #### `arts/active-app/types.ts` (refactor) ```ts import type { EngineBus, EngineBusOptions } from '$bus'; import type { EngineLogger, LoggerOptions } from '$logger'; import type { ActiveTimers, EngineTimersOptions } from '$timer'; // import type { EngineOrca } from '$orca'; // cuando v0.0 aterrice import type { ActiveAppBusEvents } from './bus-events'; import type { AppServiceSchema, ResolveServiceInstances, ServiceStatus } from './services.ts'; export interface ActiveAppOptions { logger?: LoggerOptions; timers?: Omit; bus?: Omit; services?: TSchema; } export interface ActiveAppCore { readonly Logger: EngineLogger; readonly Bus: EngineBus; readonly Timers: ActiveTimers; // readonly Orca: EngineOrca; } export type ActiveApp = ActiveAppCore & ResolveServiceInstances & { readonly services: Record; dispose(): void; }; ``` ### 8.5 Decisiones a las preguntas abiertas (round 1) | Pregunta | Decisión | |---|---| | ¿Bus/Timers/Logger siempre construidos? | **SÍ.** Coste despreciable. SSR mínimo paga ~50ns y gana coherencia. | | ¿Big-bang vs coexistencia de APIs? | **Big-bang en rama dedicada.** Coexistencia genera dos APIs vivas que confunden. | | ¿`services: TSchema` opcional? | **SÍ, default `{}`.** `createActiveApp()` zero-arg sigue siendo válido para tests y SSR mínimo. | ### 8.6 Lo que desaparece del código actual con el refactor (round 1) - `wireSessionTranslator` — pasa a preset. - `loadPersistedFrontendPreferences` y `bindFrontendStorage` — se mueven al factory `defineActiveFrontend`. Viven con el artefacto, no en aapp. - `createAuthCacheInvalidator` — pasa a preset opt-in. - Flags `Sess !== undefined` y errores `APP_ERROR_ALREADY_CREATED_*` — el modelo declarativo garantiza singleton por construcción. - Sistema de presets `APP_ORCHESTRATION_STANDARD/SILENT/TRANSLATORS` — se vuelven funciones explícitas opt-in (§9). --- ## 9. Revisión arquitectónica recibida — round 2 Después del round 1 emergen dos refinamientos críticos. El primero cambia **cuándo** se hace el refactor; el segundo cambia **dónde** vive cada pieza. ### 9.1 Construir orca v0.0 ANTES del refactor de aapp Argumento decisivo: si refactorizas aapp sin orca, los presets se escriben con `Bus.on()` directo. Cuando llegue orca, hay que reescribirlos como acciones registradas con stages, tokens, políticas. Esa segunda iteración **no es migración trivial** — es repensar cada flujo crítico (qué stage, qué tokens, qué política de fallo). Se toman las mismas decisiones de diseño dos veces, divergen, y los presets `Bus.on` quedan como código heredado que "funciona" hasta que alguien tenga tiempo de migrarlos. Spoiler: ese tiempo no llega. **Construir orca v0.0 ahora —aunque con motor mínimo— fija el modelo mental antes de escribir un solo preset.** Cuando avances a v0.1 con tokens activos, los presets ya tienen la forma correcta y solo ganan capacidades. ### 9.2 Orca v0.0: superficie completa, motor mínimo La regla operativa: > Toda la API pública del orca futuro existe en v0.0. Internamente, las > características avanzadas son no-ops o se reducen al caso simple. **Entra en v0.0:** - `createEngineOrca({ bus, timers, logger })` — constructor real. - `onEvent(event, action)` — registro funcional con detach. - Action interface completo (`id`, `stage`, `action`, `onError`, `after`, `unless`, `abortOn`, `provides`, `actionTimeoutMs`, `compensate`). - Result types: `orcaSuccess`, `orcaSkipped`, `orcaError` (los tres mínimos producidos; `orcaTimeout` y `orcaFatal` definidos pero no producidos). - `OrcaRunResult` con trace básico. - `dispose()` idempotente. - Diagnostics catalogados via `logr` (RUN_STARTED, RUN_COMPLETED, ACTION_STARTED, ACTION_FAILED, ACTION_COMPLETED, ACTION_SKIPPED, RUN_ABORTED, CONFIGURATION_INVALID). **Reducido a versión simple en v0.0:** - **Stages**: existen como concepto, motor ejecuta en orden canónico, y dentro de cada stage en orden de registro. Sin paralelismo, sin priority. - **Tokens (`after`/`provides`/`unless`/`abortOn`)**: aceptados en el action interface pero **ignorados por el motor**. Los presets pueden declararlos correctamente para forward-compat. - **Políticas de error**: solo `CONTINUE` y `ABORT_RUN` distinguen. Las demás se aceptan en el tipo y se tratan como `CONTINUE`. - **Timeouts**: aceptados en el action interface, ignorados por el motor. Acciones que cuelgan, cuelgan. Documentado como limitación temporal. - **Concurrencia entre runs**: solo modo `QUEUE` implícito. Eventos recibidos durante un run se encolan FIFO. - **Compensación**: campo `compensate` aceptado, motor no lo invoca. - **Validación estática del grafo**: solo IDs duplicados. Ciclos de tokens, deadlocks, etc., para v0.1. **No entra en v0.0:** - `ActiveOrca` (capa reactiva). - `/test/orca` con visualización del DAG. - Replay determinista. - Modos `REPLACE` / `DROP` / `PARALLEL`. - Helper `setupOrca({ tokens, events, actions })` con tipado estricto. Resultado: motor de ~400-600 líneas con **contrato exterior indistinguible del orca completo**. Las acciones que se escriban hoy funcionan el día que orca esté completo, sin cambios. ### 9.3 Cambio crítico: presets y factories viven en `arts/active-app/` **Esto corrige una recomendación inicial errónea de §3.** La idea inicial fue "los presets viven cerca del owner semántico" — por ejemplo `arts/cache/presets/invalidate-on-identity-change.ts`. Eso **es incorrecto** porque reintroduce el acoplamiento que estamos eliminando: si el preset vive en `arts/cache/`, entonces `arts/cache/` importa `SESSION_EVENT_LIFECYCLE_*` desde `$session` y constantes de orca desde `$orca`. **Acoplamiento de art a art** vuelve por la puerta de atrás justo cuando lo estamos sacando por la principal. La regla correcta: > **Las artes no conocen a otras artes.** No importan tipos, ni eventos, ni > constantes de otras artes. Solo dependen del núcleo (`logger`, `bus`, > `timers`, `orca`) y de utilidades en `libs/`. > > **Los presets de orquestación viven en `arts/active-app/presets/`** porque > por definición conocen múltiples artes. Son código de composición, no de > artefacto. > > **Los `define*()` factories viven en `arts/active-app/service-factories/`** > por la misma razón: importan de las artes (`createActiveCache` desde > `$cache`) y del active-app (`AppServiceFactory` desde `./services`). Si > vivieran en `arts/cache/`, ese módulo importaría `AppServiceFactory` desde > `$active-app` y se rompería la inversión de dependencias. Consecuencia: los arts (`arts/cache/index.ts`, `arts/session/index.ts`, etc.) quedan **puros**. Solo exportan motor + Active reactivo + tipos propios. Cero conocimiento de composición. Cero conocimiento de App. ### 9.4 `App.Orca` (no `App.Orchestration`) Coherente con: - El namespace del núcleo: `Logger`, `Bus`, `Timers`, `Orca`. Cadencia visual mantenida. - El alias del import: `$orca`. Que el campo de App se llame distinto al artefacto sería ruido innecesario. - El propio `arts/orca/README.md` que documenta el artefacto como "Orca". La objeción "Orca no es autoexplicativo para alguien que no conoce el sistema" no aplica: nadie sabe qué es `Bus` o `Format` sin leer la documentación. La consistencia interna sí es un objetivo realista; la autoexplicación al primer vistazo no. ### 9.5 La matización sobre paquetes externos Si en el futuro algún art se distribuye como paquete npm reusable, querría exportar sus propios presets. Para v0 esto **no aplica**: todos los presets en `arts/active-app/presets/`. Si llega ese caso, decidiremos entonces. No premature factoring. --- ## 10. Plan revisado de implementación | Paso | Trabajo | Tiempo estimado | |---|---|---| | **1** | Implementar `arts/orca/` v0.0 (motor + tests). | ~1 semana | | **2** | Refactor `aapp` con modelo de servicios + `App.Orca` siempre presente. | ~3 días | | **3** | Reescribir traductores actuales como presets en `arts/active-app/presets/`. Eliminar `wireSessionTranslator`, `createAuthCacheInvalidator`, `APP_ORCHESTRATION_*`. | ~1 semana | | **4** | Migrar páginas (`web/routes/*`) al schema declarativo. Iterativo. | variable | El paso 1 es lo que destraba todo. Sin él, el paso 3 no tiene un sitio donde aterrizar (los presets no pueden escribirse sobre la API final). --- ## 11. Decisiones cerradas (consolidado) 1. **Modelo:** núcleo (siempre presente: Logger/Bus/Timers/Orca) + servicios (opt-in declarado). 2. **Naming:** `createActiveApp(options)`. `defineActiveX` / `defineEngineX` como helpers. `App.Orca` como campo del núcleo. 3. **Tipo del schema:** `services: { [K in TName]: AppServiceFactory<...> }` con K literal para inferencia. 4. **Servicios declarados que no existen como factory:** error. 5. **Dependencias faltantes:** error en compile-time (cuando los tipos alcanzan) y en `commit()` runtime como respaldo. 6. **Init mode por servicio:** `lazy` por defecto. `immediate` solo si el `define*()` lo declara así o el schema lo override. 7. **Núcleo configurable** vía opciones existentes (`lang`, `storage`, `frontend`, …) en la raíz del options object. 8. **Orquestación**: vive en orca. Los presets reusables en `arts/active-app/presets/`. 9. **Eventos APP_***: sobreviven solo `DISPOSE_STARTING`. El resto se elimina. 10. **`session-translator`**: se elimina. Pasa a preset `applySessionRepublishIdentity`. 11. **Presets viven en `arts/active-app/presets/`**, no en cada art. 12. **`define*()` factories viven en `arts/active-app/service-factories/`**. 13. **Bus, Timers, Logger, Orca siempre construidos** — núcleo, no opcional. 14. **Big-bang en rama dedicada**, no coexistencia de APIs. 15. **`services: TSchema` opcional con default `{}`**. 16. **Orca v0.0 antes del refactor de aapp**. Pre-requisito. 17. **Singleton uniforme** para todos los servicios. `connections` pasa a singleton (rompiendo la asimetría actual con `Set`). --- ## Apéndice D — Estructura final del directorio ### `arts/` ``` src/arts/ ├── orca/ ← motor de orquestación (artefacto puro) │ ├── README.md │ ├── consts.ts │ ├── errors.ts │ ├── types.ts │ ├── result.ts │ ├── engine-orca.ts │ ├── diagnostics.ts │ ├── index.ts │ └── test/ ├── cache/ ← motor puro, no conoce a nadie ├── session/ ← motor puro ├── auth/ ← motor puro ├── perm/ ← motor puro ├── connection/ ← motor puro ├── lang/, logger/, timer/, format/, frontend/, dom/, sium/, storage/, http/, bus/ └── active-app/ ← composición (conoce a todos) ├── README.md ├── refactorizacion.md ├── consts.ts ├── errors.ts ├── types.ts ├── services.ts ← AppServiceFactory, CoreServices ├── service-builder.ts ← topología, lazy proxies, dispose ├── active-app.svelte.ts ← createActiveApp() ├── bus-context.svelte.ts ├── integrations/ ← frontend-storage, etc. ├── service-factories/ ← define*() para cada art │ ├── index.ts │ ├── lang.ts │ ├── storage.ts │ ├── format.ts │ ├── frontend.ts │ ├── dom.ts │ ├── http.ts │ ├── cache.ts │ ├── session.ts │ ├── perm.ts │ ├── auth.ts │ └── connections.ts └── presets/ ← orquestación reusable opt-in ├── index.ts ← applyStandardOrca + named exports ├── session-republish-identity.ts ├── cache-invalidate-on-identity-change.ts ├── perm-invalidate-on-identity-change.ts ├── auth-invalidate-cache-on-revoke.ts ├── connection-republish-connectivity.ts └── _shared/ └── tokens.ts ``` ### Cómo queda un art puro (ejemplo `arts/cache/index.ts`) Después del refactor, **cero conocimiento de App**: ```ts export { createEngineCache, createActiveCache } from './active-cache.svelte'; export type { EngineCache, ActiveCache, ActiveCacheOptions, CacheSnapshot, CacheEntry, CacheError } from './types'; export { CACHE_DIAGNOSTIC_EVENTS } from './consts'; export { CacheDisposedError, CacheInvalidScopeError, isCacheDisposedError } from './errors'; ``` ### Cómo queda un factory (ejemplo `arts/active-app/service-factories/cache.ts`) ```ts import { createActiveCache, type ActiveCache, type ActiveCacheOptions } from '$cache'; import type { AppServiceFactory } from '../services'; export function defineActiveCache( options: Omit = {} ): AppServiceFactory<'cache', ['logger', 'bus'], [], ActiveCache> { return { name: 'cache', coreDependencies: ['logger', 'bus'], initMode: 'lazy', create({ core }) { return createActiveCache({ ...options, logger: core.logger, bus: core.bus }); }, dispose(instance) { instance.dispose(); } }; } ``` ### Cómo queda un preset (ejemplo `arts/active-app/presets/cache-invalidate-on-identity-change.ts`) ```ts import { SESSION_EVENT_LIFECYCLE_REVOKED, SESSION_EVENT_LIFECYCLE_ADOPTED } from '$session'; import { ORCA_STAGE_MAIN, ORCA_ON_ERROR_CONTINUE, orcaSuccess, orcaError } from '$orca'; import type { ActiveApp } from '../types'; const ACTION_ID = 'cache.invalidate-on-identity-change'; const TOKEN_INVALIDATED = 'cache:invalidated-on-identity'; export function applyCacheInvalidateOnIdentityChange( App: ActiveApp & { cache: { invalidate(opts: { scope?: string }): Promise } } ): () => void { const detach1 = App.Orca.onEvent(SESSION_EVENT_LIFECYCLE_REVOKED, { id: `${ACTION_ID}.revoked`, stage: ORCA_STAGE_MAIN, provides: [TOKEN_INVALIDATED], onError: ORCA_ON_ERROR_CONTINUE, action: async (payload) => { try { await App.cache.invalidate({ scope: payload.previousActorId }); return orcaSuccess({ emits: [TOKEN_INVALIDATED] }); } catch (error) { return orcaError(error); } } }); const detach2 = App.Orca.onEvent(SESSION_EVENT_LIFECYCLE_ADOPTED, { id: `${ACTION_ID}.adopted`, stage: ORCA_STAGE_MAIN, provides: [TOKEN_INVALIDATED], onError: ORCA_ON_ERROR_CONTINUE, action: async (payload) => { if (!payload.previousActorId) return orcaSuccess(); try { await App.cache.invalidate({ scope: payload.previousActorId }); return orcaSuccess({ emits: [TOKEN_INVALIDATED] }); } catch (error) { return orcaError(error); } } }); return () => { detach1(); detach2(); }; } ``` ### Aggregator estándar (ejemplo `arts/active-app/presets/index.ts`) ```ts import type { ActiveApp } from '../types'; import { applySessionRepublishIdentity } from './session-republish-identity'; import { applyCacheInvalidateOnIdentityChange } from './cache-invalidate-on-identity-change'; import { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identity-change'; import { applyAuthInvalidateCacheOnRevoke } from './auth-invalidate-cache-on-revoke'; import { applyConnectionRepublishConnectivity } from './connection-republish-connectivity'; export { applySessionRepublishIdentity, applyCacheInvalidateOnIdentityChange, applyPermInvalidateOnIdentityChange, applyAuthInvalidateCacheOnRevoke, applyConnectionRepublishConnectivity }; export function applyStandardOrca(App: ActiveApp): () => void { const detachers: Array<() => void> = []; if ('session' in App) detachers.push(applySessionRepublishIdentity(App as never)); if ('cache' in App) detachers.push(applyCacheInvalidateOnIdentityChange(App as never)); if ('perm' in App) detachers.push(applyPermInvalidateOnIdentityChange(App as never)); if ('auth' in App && 'cache' in App) detachers.push(applyAuthInvalidateCacheOnRevoke(App as never)); if ('connections' in App) detachers.push(applyConnectionRepublishConnectivity(App as never)); return () => { for (const detach of detachers.reverse()) detach(); }; } ``` ### Uso final desde la app ```ts import { createActiveApp } from '$active-app'; import { defineActiveLang, defineActiveCache, defineActiveSession, defineEngineHttp } from '$active-app/service-factories'; import { applyStandardOrca } from '$active-app/presets'; export const App = createActiveApp({ logger: { level: LogLevel.INFO }, services: { lang: defineActiveLang({ schema, defaultLocale: 'es' }), cache: defineActiveCache({ adapter: 'memory' }), session: defineActiveSession({ onRefresh, onRevoke }), http: defineEngineHttp({ baseUrl: '/api' }) } }); applyStandardOrca(App); ``` La aplicación importa todo de `$active-app/*`. Los arts no aparecen en sus imports. --- ## Apéndice E — Contrato de `EngineOrca` v0.0 Versión completa del contrato y la implementación mínima del motor. La disciplina aplicada es **superficie completa, motor mínimo**: campos `@v0.0` se honran, campos `@v0.1+` se aceptan en los tipos pero el motor no actúa sobre ellos. ### E.1 `arts/orca/consts.ts` ```ts export const ORCA_MODULE = 'orca' as const; // ── Stages ──────────────────────────────────────────────────────── export const ORCA_STAGE_GUARD = 'guard' as const; export const ORCA_STAGE_PRE = 'pre' as const; export const ORCA_STAGE_MAIN = 'main' as const; export const ORCA_STAGE_POST = 'post' as const; export const ORCA_STAGE_CLEANUP = 'cleanup' as const; export const ORCA_STAGE_FINALLY = 'finally' as const; export const ORCA_STAGES_CANONICAL_ORDER = [ ORCA_STAGE_GUARD, ORCA_STAGE_PRE, ORCA_STAGE_MAIN, ORCA_STAGE_POST, ORCA_STAGE_CLEANUP, ORCA_STAGE_FINALLY ] as const; // ── Result statuses ─────────────────────────────────────────────── export const ORCA_RESULT_SUCCESS = 'success' as const; export const ORCA_RESULT_SKIPPED = 'skipped' as const; export const ORCA_RESULT_ERROR = 'error' as const; export const ORCA_RESULT_TIMEOUT = 'timeout' as const; // aceptado, no producido en v0.0 export const ORCA_RESULT_FATAL = 'fatal' as const; // aceptado, no producido en v0.0 // ── Action statuses (en el run trace) ───────────────────────────── export const ORCA_ACTION_STATUS_SUCCESS = 'success' as const; export const ORCA_ACTION_STATUS_SKIPPED = 'skipped' as const; export const ORCA_ACTION_STATUS_BLOCKED = 'blocked' as const; export const ORCA_ACTION_STATUS_ERROR = 'error' as const; export const ORCA_ACTION_STATUS_TIMEOUT = 'timeout' as const; export const ORCA_ACTION_STATUS_FATAL = 'fatal' as const; // ── Run statuses ────────────────────────────────────────────────── export const ORCA_RUN_SUCCESS = 'success' as const; export const ORCA_RUN_PARTIAL = 'partial' as const; export const ORCA_RUN_ABORTED = 'aborted' as const; export const ORCA_RUN_FATAL = 'fatal' as const; export const ORCA_RUN_TIMEOUT = 'timeout' as const; // ── Error policies ──────────────────────────────────────────────── // v0.0 implementa CONTINUE y ABORT_RUN. El resto se acepta y se trata // como CONTINUE. export const ORCA_ON_ERROR_CONTINUE = 'continue' as const; export const ORCA_ON_ERROR_ABORT_ACTION = 'abort-action' as const; export const ORCA_ON_ERROR_ABORT_STAGE = 'abort-stage' as const; export const ORCA_ON_ERROR_ABORT_RUN = 'abort-run' as const; // ── Diagnostic event names ──────────────────────────────────────── export const ORCA_DIAGNOSTIC_EVENTS = { RUN_STARTED: 'orca.run.started', RUN_COMPLETED: 'orca.run.completed', RUN_ABORTED: 'orca.run.aborted', ACTION_STARTED: 'orca.action.started', ACTION_COMPLETED: 'orca.action.completed', ACTION_FAILED: 'orca.action.failed', ACTION_SKIPPED: 'orca.action.skipped', CONFIGURATION_INVALID: 'orca.configuration.invalid' } as const; export const LOGGER_CATEGORY = 'orca' as const; ``` ### E.2 `arts/orca/errors.ts` ```ts import { CodeError, errCode, type ErrCode, type ErrorMessages } from '$libs/errs'; const ORCA_ERR = 'orca' as const; export const ORCA_ERR_DISPOSED: ErrCode = errCode(ORCA_ERR, 'disposed'); export const ORCA_ERR_DUPLICATE_ACTION_ID: ErrCode = errCode(ORCA_ERR, 'duplicate_action_id'); export const ORCA_ERR_INVALID_STAGE: ErrCode = errCode(ORCA_ERR, 'invalid_stage'); export const ORCA_ERR_INVALID_ACTION: ErrCode = errCode(ORCA_ERR, 'invalid_action'); export const ORCA_ERROR_MESSAGES: ErrorMessages = { [ORCA_ERR_DISPOSED]: 'Orca engine has been disposed.', [ORCA_ERR_DUPLICATE_ACTION_ID]: 'Action id already registered for this event.', [ORCA_ERR_INVALID_STAGE]: 'Action declared with unknown stage.', [ORCA_ERR_INVALID_ACTION]: 'Action definition is missing required fields.' }; export class OrcaDisposedError extends CodeError { constructor(message?: string) { super(ORCA_ERR_DISPOSED, { message: message ?? ORCA_ERROR_MESSAGES[ORCA_ERR_DISPOSED] }); } } export class OrcaDuplicateActionIdError extends CodeError { constructor(event: string, actionId: string) { super(ORCA_ERR_DUPLICATE_ACTION_ID, { message: `Action "${actionId}" already registered for event "${event}".` }); } } export class OrcaInvalidStageError extends CodeError { constructor(stage: string) { super(ORCA_ERR_INVALID_STAGE, { message: `Unknown stage "${stage}". Valid stages: guard, pre, main, post, cleanup, finally.` }); } } export class OrcaInvalidActionError extends CodeError { constructor(reason: string) { super(ORCA_ERR_INVALID_ACTION, { message: `Invalid action: ${reason}` }); } } export function isOrcaDisposedError(error: unknown): error is OrcaDisposedError { return error instanceof OrcaDisposedError; } ``` ### E.3 `arts/orca/types.ts` Contrato exterior completo. **Marcas `@v0.0` y `@v0.1+`** indican qué se honra y qué se acepta-pero-ignora. ```ts import type { EngineBus } from '$bus'; import type { ActiveTimers } from '$timer'; import type { Logger } from '$libs/logr'; import type { ORCA_STAGE_GUARD, ORCA_STAGE_PRE, ORCA_STAGE_MAIN, ORCA_STAGE_POST, ORCA_STAGE_CLEANUP, ORCA_STAGE_FINALLY, ORCA_RESULT_SUCCESS, ORCA_RESULT_SKIPPED, ORCA_RESULT_ERROR, ORCA_RESULT_TIMEOUT, ORCA_RESULT_FATAL, ORCA_ACTION_STATUS_SUCCESS, ORCA_ACTION_STATUS_SKIPPED, ORCA_ACTION_STATUS_BLOCKED, ORCA_ACTION_STATUS_ERROR, ORCA_ACTION_STATUS_TIMEOUT, ORCA_ACTION_STATUS_FATAL, ORCA_RUN_SUCCESS, ORCA_RUN_PARTIAL, ORCA_RUN_ABORTED, ORCA_RUN_FATAL, ORCA_RUN_TIMEOUT, ORCA_ON_ERROR_CONTINUE, ORCA_ON_ERROR_ABORT_ACTION, ORCA_ON_ERROR_ABORT_STAGE, ORCA_ON_ERROR_ABORT_RUN } from './consts'; // ── Identifiers ─────────────────────────────────────────────────── export type OrcaStage = | typeof ORCA_STAGE_GUARD | typeof ORCA_STAGE_PRE | typeof ORCA_STAGE_MAIN | typeof ORCA_STAGE_POST | typeof ORCA_STAGE_CLEANUP | typeof ORCA_STAGE_FINALLY; export type OrcaToken = string; export type OrcaActionId = string; export type OrcaRunId = string; export type OrcaErrorPolicy = | typeof ORCA_ON_ERROR_CONTINUE | typeof ORCA_ON_ERROR_ABORT_ACTION | typeof ORCA_ON_ERROR_ABORT_STAGE | typeof ORCA_ON_ERROR_ABORT_RUN; // ── Result types ────────────────────────────────────────────────── export interface OrcaSuccess { readonly ok: true; readonly status: typeof ORCA_RESULT_SUCCESS; readonly value?: TValue; readonly emits?: readonly OrcaToken[]; } export interface OrcaSkipped { readonly ok: true; readonly status: typeof ORCA_RESULT_SKIPPED; readonly reason?: string; readonly emits?: readonly OrcaToken[]; } export interface OrcaError { readonly ok: false; readonly status: typeof ORCA_RESULT_ERROR; readonly error: unknown; readonly recoverable?: boolean; readonly emits?: readonly OrcaToken[]; } /** * @v0.1+ Returned by the engine when an action exceeds `actionTimeoutMs`. * @v0.0 The shape exists so action authors can type results, but the engine * never produces one (timeouts are not enforced). */ export interface OrcaTimeout { readonly ok: false; readonly status: typeof ORCA_RESULT_TIMEOUT; readonly timeoutMs: number; readonly emits?: readonly OrcaToken[]; } /** * @v0.1+ Distinguished from `OrcaError` for run-aborting failures. * @v0.0 Engine treats fatal as error. */ export interface OrcaFatal { readonly ok: false; readonly status: typeof ORCA_RESULT_FATAL; readonly error: unknown; readonly emits?: readonly OrcaToken[]; } export type OrcaResult = | OrcaSuccess | OrcaSkipped | OrcaError | OrcaTimeout | OrcaFatal; // ── Action context ──────────────────────────────────────────────── export interface OrcaActionContext { readonly runId: OrcaRunId; readonly event: string; readonly stage: OrcaStage; /** * Tokens already emitted in the current run by previous actions. * @v0.0 The set is populated correctly, but not consumed by the engine * (after/unless/abortOn are ignored). Action authors MAY read it. */ readonly tokens: ReadonlySet; /** * Abort signal for the current action. */ readonly signal: AbortSignal; /** * Logger for ad-hoc diagnostics inside the action. */ readonly logger: Logger; } // ── Action definition ───────────────────────────────────────────── export type OrcaActionFn = ( payload: TPayload, context: OrcaActionContext ) => OrcaResult | Promise>; export interface OrcaAction { readonly id: OrcaActionId; readonly stage: OrcaStage; /** @v0.1+ accepted, ignored in v0.0 */ readonly after?: readonly OrcaToken[]; /** @v0.1+ accepted, ignored in v0.0 */ readonly unless?: readonly OrcaToken[]; /** @v0.1+ accepted, ignored in v0.0 */ readonly abortOn?: readonly OrcaToken[]; /** Documented for consumers; engine uses emits in run context regardless. */ readonly provides?: readonly OrcaToken[]; /** @v0.1+ accepted, ignored. Long actions hang in v0.0. */ readonly actionTimeoutMs?: number; /** @v0.0 Honored. Only CONTINUE and ABORT_RUN distinct. */ readonly onError?: OrcaErrorPolicy; /** @v1+ accepted, never invoked in v0.0. */ readonly compensate?: OrcaActionFn; readonly action: OrcaActionFn; } // ── Run trace ───────────────────────────────────────────────────── export interface OrcaActionRun { readonly id: OrcaActionId; readonly stage: OrcaStage; readonly status: | typeof ORCA_ACTION_STATUS_SUCCESS | typeof ORCA_ACTION_STATUS_SKIPPED | typeof ORCA_ACTION_STATUS_BLOCKED | typeof ORCA_ACTION_STATUS_ERROR | typeof ORCA_ACTION_STATUS_TIMEOUT | typeof ORCA_ACTION_STATUS_FATAL; readonly startedAt?: number; readonly endedAt?: number; readonly durationMs?: number; readonly emitted: readonly OrcaToken[]; readonly error?: unknown; } export interface OrcaRunResult { readonly id: OrcaRunId; readonly event: string; readonly status: | typeof ORCA_RUN_SUCCESS | typeof ORCA_RUN_PARTIAL | typeof ORCA_RUN_ABORTED | typeof ORCA_RUN_FATAL | typeof ORCA_RUN_TIMEOUT; readonly startedAt: number; readonly endedAt: number; readonly durationMs: number; readonly tokens: readonly OrcaToken[]; readonly actions: readonly OrcaActionRun[]; } // ── Engine ──────────────────────────────────────────────────────── export interface EngineOrcaOptions { readonly bus: EngineBus; readonly timers: ActiveTimers; readonly logger?: Logger; /** @default 256 */ readonly maxRuns?: number; } export interface EngineOrca { onEvent( event: string, action: OrcaAction ): () => void; actionCount(event: string): number; recentRuns(): readonly OrcaRunResult[]; readonly running: boolean; readonly disposed: boolean; dispose(): void; } ``` ### E.4 `arts/orca/result.ts` Helpers para construir results. Todo el ecosistema los usa en lugar de literales. ```ts import { ORCA_RESULT_SUCCESS, ORCA_RESULT_SKIPPED, ORCA_RESULT_ERROR, ORCA_RESULT_TIMEOUT, ORCA_RESULT_FATAL } from './consts'; import type { OrcaSuccess, OrcaSkipped, OrcaError, OrcaTimeout, OrcaFatal, OrcaToken } from './types'; export function orcaSuccess( options: { value?: TValue; emits?: readonly OrcaToken[] } = {} ): OrcaSuccess { return { ok: true, status: ORCA_RESULT_SUCCESS, value: options.value, emits: options.emits }; } export function orcaSkipped( reason?: string, options: { emits?: readonly OrcaToken[] } = {} ): OrcaSkipped { return { ok: true, status: ORCA_RESULT_SKIPPED, reason, emits: options.emits }; } export function orcaError( error: unknown, options: { emits?: readonly OrcaToken[]; recoverable?: boolean } = {} ): OrcaError { return { ok: false, status: ORCA_RESULT_ERROR, error, recoverable: options.recoverable, emits: options.emits }; } /** @v0.1+ */ export function orcaTimeout( timeoutMs: number, options: { emits?: readonly OrcaToken[] } = {} ): OrcaTimeout { return { ok: false, status: ORCA_RESULT_TIMEOUT, timeoutMs, emits: options.emits }; } /** @v0.1+ */ export function orcaFatal( error: unknown, options: { emits?: readonly OrcaToken[] } = {} ): OrcaFatal { return { ok: false, status: ORCA_RESULT_FATAL, error, emits: options.emits }; } ``` ### E.5 `arts/orca/engine-orca.ts` — motor v0.0 ```ts import { ORCA_STAGES_CANONICAL_ORDER, ORCA_STAGE_FINALLY, ORCA_ON_ERROR_CONTINUE, ORCA_ON_ERROR_ABORT_RUN, ORCA_RESULT_SUCCESS, ORCA_RESULT_SKIPPED, ORCA_RESULT_ERROR, ORCA_RESULT_FATAL, ORCA_ACTION_STATUS_SUCCESS, ORCA_ACTION_STATUS_SKIPPED, ORCA_ACTION_STATUS_ERROR, ORCA_ACTION_STATUS_BLOCKED, ORCA_RUN_SUCCESS, ORCA_RUN_PARTIAL, ORCA_RUN_ABORTED, ORCA_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts'; import { OrcaDisposedError, OrcaDuplicateActionIdError, OrcaInvalidActionError, OrcaInvalidStageError } from './errors'; import type { EngineOrca, EngineOrcaOptions, OrcaAction, OrcaActionContext, OrcaActionRun, OrcaResult, OrcaRunId, OrcaRunResult, OrcaStage, OrcaToken } from './types'; const DEFAULT_MAX_RUNS = 256; export function createEngineOrca(options: EngineOrcaOptions): EngineOrca { const { bus, timers, logger } = options; const maxRuns = options.maxRuns ?? DEFAULT_MAX_RUNS; const actionsByEvent = new Map(); const busDetachers = new Map void>(); const recentRuns: OrcaRunResult[] = []; const runQueue: Array<() => Promise> = []; let disposed = false; let running = false; let activeRunController: AbortController | null = null; function ensureNotDisposed() { if (disposed) throw new OrcaDisposedError(); } function validateAction(action: OrcaAction): void { if (!action || typeof action !== 'object') { throw new OrcaInvalidActionError('action must be an object'); } if (typeof action.id !== 'string' || action.id.length === 0) { throw new OrcaInvalidActionError('action.id must be a non-empty string'); } if (typeof action.action !== 'function') { throw new OrcaInvalidActionError('action.action must be a function'); } if (!ORCA_STAGES_CANONICAL_ORDER.includes(action.stage)) { throw new OrcaInvalidStageError(action.stage); } } function onEvent( event: string, action: OrcaAction ): () => void { ensureNotDisposed(); validateAction(action); const list = actionsByEvent.get(event) ?? []; if (list.some((a) => a.id === action.id)) { throw new OrcaDuplicateActionIdError(event, action.id); } list.push(action as OrcaAction); actionsByEvent.set(event, list); // Suscripción lazy al bus: solo cuando se registra la primera acción. if (!busDetachers.has(event)) { const detach = bus.on(event, (payload: unknown) => { enqueueRun(event, payload); }); busDetachers.set(event, detach); } return () => { const current = actionsByEvent.get(event); if (!current) return; const filtered = current.filter((a) => a.id !== action.id); if (filtered.length === 0) { actionsByEvent.delete(event); busDetachers.get(event)?.(); busDetachers.delete(event); } else { actionsByEvent.set(event, filtered); } }; } function enqueueRun(event: string, payload: unknown): void { if (disposed) return; const actions = actionsByEvent.get(event); if (!actions || actions.length === 0) return; // Snapshot: acciones registradas durante un run no participan en él. const snapshot = actions.slice(); runQueue.push(() => executeRun(event, payload, snapshot)); drainQueue(); } async function drainQueue(): Promise { if (running || disposed) return; const next = runQueue.shift(); if (!next) return; running = true; try { await next(); } finally { running = false; if (!disposed && runQueue.length > 0) { queueMicrotask(() => drainQueue()); } } } async function executeRun( event: string, payload: unknown, actions: OrcaAction[] ): Promise { const runId = generateRunId(); const startedAt = timers.clock.now(); const tokens = new Set(); const actionRuns: OrcaActionRun[] = []; const controller = new AbortController(); activeRunController = controller; emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.RUN_STARTED, { runId, event }); let aborted = false; const byStage = groupByStage(actions); for (const stage of ORCA_STAGES_CANONICAL_ORDER) { const stageActions = byStage.get(stage); if (!stageActions || stageActions.length === 0) continue; // FINALLY siempre se ejecuta, incluso tras abort. if (aborted && stage !== ORCA_STAGE_FINALLY) continue; for (const action of stageActions) { if (controller.signal.aborted) break; const actionRun = await runAction({ action, payload, context: { runId, event, stage, tokens, signal: controller.signal, logger: logger ?? noopLogger() }, tokens }); actionRuns.push(actionRun); if (actionRun.status === ORCA_ACTION_STATUS_ERROR) { const policy = action.onError ?? ORCA_ON_ERROR_CONTINUE; if (policy === ORCA_ON_ERROR_ABORT_RUN) { aborted = true; controller.abort(); emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.RUN_ABORTED, { runId, event, cause: actionRun.error }); break; } } } } const endedAt = timers.clock.now(); const status = computeRunStatus(aborted, actionRuns); const runResult: OrcaRunResult = { id: runId, event, status, startedAt, endedAt, durationMs: endedAt - startedAt, tokens: Array.from(tokens), actions: actionRuns }; recordRun(runResult); activeRunController = null; emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.RUN_COMPLETED, { runId, event, status, durationMs: runResult.durationMs, actionCount: actionRuns.length }); } async function runAction(opts: { action: OrcaAction; payload: unknown; context: OrcaActionContext; tokens: Set; }): Promise { const { action, payload, context, tokens } = opts; const startedAt = timers.clock.now(); emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_STARTED, { runId: context.runId, actionId: action.id, stage: action.stage }); if (context.signal.aborted) { return { id: action.id, stage: action.stage, status: ORCA_ACTION_STATUS_BLOCKED, startedAt, endedAt: startedAt, durationMs: 0, emitted: [] }; } try { const result = await action.action(payload, context); const endedAt = timers.clock.now(); const emitted = result.emits ?? []; for (const token of emitted) tokens.add(token); const status = mapResultToActionStatus(result); if (status === ORCA_ACTION_STATUS_ERROR) { emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_FAILED, { runId: context.runId, actionId: action.id, error: (result as { error?: unknown }).error }); } else if (status === ORCA_ACTION_STATUS_SKIPPED) { emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_SKIPPED, { runId: context.runId, actionId: action.id, reason: (result as { reason?: string }).reason }); } else { emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_COMPLETED, { runId: context.runId, actionId: action.id, durationMs: endedAt - startedAt }); } return { id: action.id, stage: action.stage, status, startedAt, endedAt, durationMs: endedAt - startedAt, emitted: Array.from(emitted), error: status === ORCA_ACTION_STATUS_ERROR ? (result as { error?: unknown }).error : undefined }; } catch (thrown) { // Excepciones no capturadas se convierten en error. const endedAt = timers.clock.now(); emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_FAILED, { runId: context.runId, actionId: action.id, error: thrown, thrown: true }); return { id: action.id, stage: action.stage, status: ORCA_ACTION_STATUS_ERROR, startedAt, endedAt, durationMs: endedAt - startedAt, emitted: [], error: thrown }; } } function recordRun(run: OrcaRunResult): void { recentRuns.push(run); while (recentRuns.length > maxRuns) recentRuns.shift(); } function emitDiagnostic(event: string, data: Record): void { if (!logger) return; logger.debug?.({ category: LOGGER_CATEGORY, message: event, data }); } function dispose(): void { if (disposed) return; disposed = true; activeRunController?.abort(); for (const detach of busDetachers.values()) detach(); busDetachers.clear(); actionsByEvent.clear(); runQueue.length = 0; } return { onEvent, actionCount(event: string) { return actionsByEvent.get(event)?.length ?? 0; }, recentRuns() { return recentRuns.slice(); }, get running() { return running; }, get disposed() { return disposed; }, dispose }; } // ── Helpers ─────────────────────────────────────────────────────── function groupByStage(actions: OrcaAction[]): Map { const result = new Map(); for (const action of actions) { const list = result.get(action.stage) ?? []; list.push(action); result.set(action.stage, list); } return result; } function mapResultToActionStatus(result: OrcaResult) { switch (result.status) { case ORCA_RESULT_SUCCESS: return ORCA_ACTION_STATUS_SUCCESS; case ORCA_RESULT_SKIPPED: return ORCA_ACTION_STATUS_SKIPPED; case ORCA_RESULT_ERROR: return ORCA_ACTION_STATUS_ERROR; case ORCA_RESULT_FATAL: return ORCA_ACTION_STATUS_ERROR; // v0.0 trata fatal como error default: return ORCA_ACTION_STATUS_ERROR; // timeout no se produce en v0.0 } } function computeRunStatus(aborted: boolean, actions: OrcaActionRun[]) { if (aborted) return ORCA_RUN_ABORTED; const anyError = actions.some( (a) => a.status === 'error' || a.status === 'fatal' || a.status === 'timeout' ); if (anyError) return ORCA_RUN_PARTIAL; return ORCA_RUN_SUCCESS; } function generateRunId(): OrcaRunId { return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } function noopLogger() { const noop = () => {}; return { trace: noop, debug: noop, info: noop, warn: noop, error: noop, fatal: noop }; } ``` ### E.6 `arts/orca/index.ts` (barrel) ```ts export { createEngineOrca } from './engine-orca'; export { orcaSuccess, orcaSkipped, orcaError, orcaTimeout, orcaFatal } from './result'; export { ORCA_MODULE, ORCA_STAGE_GUARD, ORCA_STAGE_PRE, ORCA_STAGE_MAIN, ORCA_STAGE_POST, ORCA_STAGE_CLEANUP, ORCA_STAGE_FINALLY, ORCA_STAGES_CANONICAL_ORDER, ORCA_RESULT_SUCCESS, ORCA_RESULT_SKIPPED, ORCA_RESULT_ERROR, ORCA_RESULT_TIMEOUT, ORCA_RESULT_FATAL, ORCA_RUN_SUCCESS, ORCA_RUN_PARTIAL, ORCA_RUN_ABORTED, ORCA_RUN_FATAL, ORCA_RUN_TIMEOUT, ORCA_ON_ERROR_CONTINUE, ORCA_ON_ERROR_ABORT_ACTION, ORCA_ON_ERROR_ABORT_STAGE, ORCA_ON_ERROR_ABORT_RUN, ORCA_DIAGNOSTIC_EVENTS, LOGGER_CATEGORY } from './consts'; export { OrcaDisposedError, OrcaDuplicateActionIdError, OrcaInvalidStageError, OrcaInvalidActionError, isOrcaDisposedError } from './errors'; export type { EngineOrca, EngineOrcaOptions, OrcaAction, OrcaActionFn, OrcaActionContext, OrcaActionId, OrcaActionRun, OrcaError, OrcaErrorPolicy, OrcaFatal, OrcaResult, OrcaRunId, OrcaRunResult, OrcaSkipped, OrcaStage, OrcaSuccess, OrcaTimeout, OrcaToken } from './types'; ``` ### E.7 Matriz de tests para v0.0 ```ts describe('EngineOrca v0.0', () => { describe('registration', () => { it('registra y elimina acciones por evento'); it('lanza OrcaDuplicateActionIdError si se registra la misma id dos veces'); it('lanza OrcaInvalidStageError si el stage es inválido'); it('lanza OrcaInvalidActionError si falta id o action'); it('se suscribe al bus al registrar la primera acción del evento'); it('se desuscribe del bus al eliminar la última acción del evento'); }); describe('execution', () => { it('ejecuta acciones en orden canónico de stages'); it('ejecuta acciones del mismo stage en orden de registro'); it('captura excepciones de acciones como error'); it('agrega tokens emitidos al run context (aunque no los consume)'); it('ejecuta finally aunque el run haya sido abortado'); it('no ejecuta acciones registradas durante un run en ese mismo run'); }); describe('error policies', () => { it('continúa con onError: CONTINUE'); it('aborta el run con onError: ABORT_RUN'); it('trata ABORT_ACTION y ABORT_STAGE como CONTINUE en v0.0'); }); describe('concurrency', () => { it('encola eventos del mismo tipo durante un run en vuelo'); it('procesa eventos encolados en orden FIFO'); }); describe('run trace', () => { it('produce OrcaRunResult con startedAt/endedAt/durationMs'); it('lista todas las acciones ejecutadas con su status'); it('respeta maxRuns en recentRuns()'); }); describe('disposal', () => { it('dispose() es idempotente'); it('aborta el run en vuelo al disponer'); it('lanza OrcaDisposedError al registrar tras dispose'); it('eventos del bus tras dispose no ejecutan acciones'); }); describe('v0.1+ accepted-but-ignored fields', () => { it('acepta after sin esperar tokens'); it('acepta unless sin saltar acciones'); it('acepta abortOn sin bloquear'); it('acepta actionTimeoutMs sin enforcer timeout'); it('acepta compensate sin invocarlo'); }); }); ``` ### E.8 Lo que el motor v0.0 NO hace (resumen claro) Para que el README de orca pueda referenciarlo: - No espera tokens (`after` ignorado). - No salta acciones por tokens presentes (`unless` ignorado). - No bloquea acciones por tokens (`abortOn` ignorado). - No respeta timeouts (`actionTimeoutMs` ignorado). - No invoca compensaciones (`compensate` ignorado). - No detecta deadlocks de tokens. - No expone `ActiveOrca` (la capa reactiva). - No tiene modos de concurrencia configurables — siempre QUEUE. - No distingue ABORT_ACTION ni ABORT_STAGE — todos son CONTINUE excepto ABORT_RUN. Lo que sí garantiza: **un preset bien escrito en v0.0 sigue funcionando correctamente en v0.1+, ganando capacidades sin reescritura**. Esa es la propiedad de diseño que justifica este enfoque. --- ## Cambios aplicados durante esta sesión A medida que se ejecutan las fases, esta sección se actualiza: - **2026-05-02 (sesión de diseño)**: - Documento creado (§1–§7): análisis inicial, modelo de servicios, decisiones, plan. - §8: revisión round 1 con código concreto. - §9: revisión round 2 — orca v0.0 primero, presets y factories en `arts/active-app/`. - §10–§11: plan revisado, decisiones consolidadas (17 items). - Apéndices D y E: estructura final + contrato completo de `EngineOrca` v0.0. - **2026-05-02 (sesión larga de implementación)**: - **Paso 1 (commit `0eddd2a`)** — `arts/orca/` v0.0 implementado. 9 archivos, +37 tests. - **Paso 2A (commit `d528652`)** — Contratos `services.ts` + `service-builder.ts` + 8 factories puros (lang, storage, format, dom, frontend, http, sium, auth). +23 tests. - **Paso 2B (commit `a04fa67`)** — 4 factories restantes (cache, perm, session, connections) + 4 presets de orca + agregador `applyStandardOrca`. +8 tests. - **Paso 3A (commit `fb2e3ac`)** — `App.Orca` añadido al núcleo de `createActiveApp()`. - **Paso 3B (commit `7148a5d`)** — `services: TSchema` aceptado en `createActiveApp()`. `App.cache`, `App.session`, etc. (lowercase) accesibles. +6 tests. - **Paso 3C (commit `60e130b`)** — APIs legacy marcadas `@deprecated` con guía de migración (cache/perm options, `App.createActiveX()`, `App.Sess/Perms/Auth`, `ActiveAppOptions.{connections, permissions, auth, orchestration}`, publishers de APP_EVENT_*). - **Suite total: 1408 tests pasan.** Sin regresiones. ## Estado actual: BIG-BANG COMPLETADO (commits `01a85ad` + `64ab1f0`) El refactor está cerrado. La API legacy ha sido eliminada completamente. **Modelo único soportado en `master`:** ```ts const App = createActiveApp({ logger: { ... }, services: { cache: defineActiveCache(), session: defineActiveSession({ onRefresh, onRevoke }), http: defineEngineHttp({ baseUrl: '/api' }) } }); applyStandardOrca(App); ``` `App.Orca` siempre presente. Servicios construyen lazy. Acceso vía propiedades lowercase (`App.cache`, `App.session`). ## Eliminaciones aplicadas en el big-bang ### Tests legacy (commit `01a85ad`) - `ecosystem.integration.test.ts` (9 monolithic, ~1900 líneas). - `session-translator.test.ts`. - `active-app.test.ts` reescrito de 1072 → ~200 líneas con tests enfocados en composición del núcleo. ### Código legacy (commit `64ab1f0`) - `arts/active-app/integrations/session-translator.ts` y `auth-cache.ts`. - `arts/connection/bus-session-source.ts`. - `libs/active-app/` directorio entero. Su contenido se consolidó en `arts/active-app/{consts,errors,events}.ts`. - `arts/cache`: `bus` y `autoInvalidateOn` options, `wireAutoInvalidation`, `CACHE_AUTO_INVALIDATE_*` constantes y types. - `arts/perm`: `bus` y `autoInvalidateOn`, `wireAutoInvalidation`, `PERM_AUTO_INVALIDATE_*`. - `arts/connection`: `bus` option en `EngineConnectionsOptions`, `shouldWireBusSessionSource` helper. - `arts/active-app/active-app.svelte.ts`: - `createSiumEngine()`, `createActiveSession()`, `createActiveConnections()`, `createActivePerms()`, `createActiveAuth()` factory methods. - `App.Sess`/`App.Perms`/`App.Auth` getters y singleton guards. - Sistema `APP_ORCHESTRATION_*` entero (presets, translators, `resolveActiveAppOrchestration`, `STANDARD_ORCHESTRATION_TRANSLATORS`). - `APP_ERROR_ALREADY_CREATED_*` y `APP_ERROR_CREATE_PERM_ENDPOINT_REQUIRED` constantes. - `APP_EVENT_USER_IDENTITY_CHANGED`, `TENANT_SWITCHED`, `PERMISSIONS_REFRESH_REQUESTED`, `CACHE_INVALIDATE_REQUESTED`, `CONNECTIVITY_CHANGED`. Solo sobrevive `APP_EVENT_DISPOSE_STARTING`. - Sus payloads y `APP_USER_IDENTITY_CAUSE_*`. - `publishApp{UserIdentityChanged, PermsRefreshRequested, CacheInvalidateRequested, TenantSwitched, ConnectivityChanged}`. Sobreviven `publishAppDisposeStarting` y el nuevo `onAppDisposeStarting`. ### Forma final de `arts/active-app/` ``` arts/active-app/ ├── README.md ├── refactorizacion.md (este documento) ├── active-app.svelte.ts — createActiveApp() compositor + schema ├── bus-context.svelte.ts — getBus / setBus para Svelte component context ├── consts.ts — APP_MODULE, APP_BUS_CONTEXT_KEY, runtime gates ├── errors.ts — todo el infra de errores (ya no en libs/) ├── events.ts — APP_EVENT_DISPOSE_STARTING + helpers ├── types.ts — ActiveApp, ActiveAppOptions, etc. ├── services.ts — AppServiceFactory, CoreServices, schema types ├── service-builder.ts — topología, lazy proxies, dispose ├── service-factories/ — define*() para cada art (12 archivos) ├── presets/ — orca actions opt-in + applyStandardOrca ├── integrations/ — frontend-storage (lo único que queda) ├── testing/ — createTestApp helper └── test/ — composition + schema + presets + builder + factories ``` `libs/active-app/` ya no existe. ## Suite de tests **1374 tests pasan.** La diferencia respecto al pico de 1408 son los tests legacy eliminados; la cobertura del modelo nuevo es comprehensiva (`schema-declarative.test.ts`, `service-builder.test.ts`, `service-factories.test.ts`, `presets.test.ts`, `active-app.test.ts` core, más las suites por art).