diff --git a/src/arts/active-app/refactorizacion.md b/src/arts/active-app/refactorizacion.md index a2154fc..74c2127 100644 --- a/src/arts/active-app/refactorizacion.md +++ b/src/arts/active-app/refactorizacion.md @@ -601,8 +601,1762 @@ 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**: Documento creado. +- **2026-05-02**: + - Documento creado (§1–§7): análisis inicial del problema, propuesta de + modelo de servicios, decisiones cerradas/abiertas, plan de fases. + - §8: revisión arquitectónica round 1 — código concreto de `services.ts`, + `active-app.svelte.ts`, `types.ts`, ejemplos de `define*()` factories, + decisiones a las preguntas abiertas, lista de código que desaparece. + - §9: revisión arquitectónica round 2 — orca v0.0 como pre-requisito + (superficie completa, motor mínimo), corrección crítica sobre la + ubicación de presets y factories (todo en `arts/active-app/`, los arts + quedan puros), `App.Orca` como naming definitivo. + - §10–§11: plan revisado a 4 pasos, decisiones cerradas consolidadas (17 + items). + - Apéndice D: estructura final del directorio + ejemplos de art puro, + factory y preset. + - Apéndice E: contrato completo de `EngineOrca` v0.0 (consts, errors, + types, result, motor, barrel, matriz de tests). +- **Implementación**: no iniciada. Pendiente de luz verde.