You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
active-svelte/docs/active-app-refactorizacion.md

2468 lines
80 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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<MyUser>({ 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<TName extends string, TInstance> {
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<void>;
}
// Cada artefacto exporta un define*() que produce una factory tipada
interface AppServiceFactory<TName extends string, TDeps, TInstance> {
readonly name: TName;
readonly initMode: ServiceInitMode;
readonly dependencies: readonly (keyof TDeps & string)[];
create(deps: TDeps): TInstance;
dispose?(instance: TInstance): void | Promise<void>;
}
```
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<TServices extends Record<string, AppService<string, unknown>>> =
CoreApp & {
[K in keyof TServices]: TServices[K]['instance'];
} & {
// Acceso a metadata
services: {
[K in keyof TServices]: AppService<K & string, TServices[K]['instance']>;
};
};
```
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<string, unknown>,
TInstance
> {
readonly name: TName;
readonly initMode: ServiceInitMode;
readonly dependencies: readonly (keyof TDeps & string)[];
create(deps: TDeps): TInstance;
dispose?(instance: TInstance): void | Promise<void>;
}
export interface AppService<TName extends string, TInstance> {
readonly serviceName: TName;
readonly initMode: ServiceInitMode;
readonly dependencies: readonly string[];
readonly state: ServiceState;
readonly instance: TInstance;
readonly dispose: () => void | Promise<void>;
}
export type AppServiceSchema = Record<string, AppServiceFactory<string, never, unknown>>;
export type ResolveAppInstances<S extends AppServiceSchema> = {
[K in keyof S]: S[K] extends AppServiceFactory<infer _N, infer _D, infer I>
? 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<MyUser>({
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<MyUser>
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<ActiveConnections>` 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<CoreServices, TCoreDeps[number]>;
services: Partial<Record<TServiceDeps[number], unknown>>;
}): TInstance;
dispose?(instance: TInstance): void | Promise<void>;
}
export type AppServiceSchema = Record<string, AppServiceFactory>;
export type ResolveServiceInstances<TSchema extends AppServiceSchema> = {
[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<TSchema extends AppServiceSchema = {}>(
options: ActiveAppOptions<TSchema> = {} as ActiveAppOptions<TSchema>
): ActiveApp<TSchema> {
// Núcleo
const Logger = createEngineLogger(options.logger);
const Timers = createActiveTimers({ ...options.timers, logger: Logger });
const Bus = createSvelteEngineBus<ActiveAppBusEvents>({
...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<TSchema>;
}
interface ServiceBuilders {
readonly proxies: Record<string, unknown>;
readonly statusMap: () => Record<string, ServiceStatus>;
readonly disposeAll: () => void;
}
function buildServiceBuilders(
schema: AppServiceSchema,
core: CoreServices
): ServiceBuilders {
validateSchema(schema);
const order = topologicalOrder(schema);
const instances = new Map<string, unknown>();
const status = new Map<string, ServiceStatus>();
const failures = new Map<string, unknown>();
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<string, unknown> = {};
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<string, unknown> = {};
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<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
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<string>();
const visiting = new Set<string>();
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<TSchema extends AppServiceSchema = {}> {
logger?: LoggerOptions;
timers?: Omit<EngineTimersOptions, 'logger'>;
bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
services?: TSchema;
}
export interface ActiveAppCore {
readonly Logger: EngineLogger;
readonly Bus: EngineBus<ActiveAppBusEvents>;
readonly Timers: ActiveTimers;
// readonly Orca: EngineOrca;
}
export type ActiveApp<TSchema extends AppServiceSchema = {}> = ActiveAppCore &
ResolveServiceInstances<TSchema> & {
readonly services: Record<string, ServiceStatus>;
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<ActiveConnections>`).
---
## 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<ActiveCacheOptions, 'logger' | 'bus'> = {}
): 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> } }
): () => 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<MyUser>({ 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<TValue = unknown> {
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<TValue = unknown> =
| OrcaSuccess<TValue>
| 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<OrcaToken>;
/**
* 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<TPayload = unknown, TValue = unknown> = (
payload: TPayload,
context: OrcaActionContext
) => OrcaResult<TValue> | Promise<OrcaResult<TValue>>;
export interface OrcaAction<TPayload = unknown, TValue = unknown> {
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<TPayload, void>;
readonly action: OrcaActionFn<TPayload, TValue>;
}
// ── 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<TPayload = unknown, TValue = unknown>(
event: string,
action: OrcaAction<TPayload, TValue>
): () => 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<TValue = void>(
options: { value?: TValue; emits?: readonly OrcaToken[] } = {}
): OrcaSuccess<TValue> {
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<string, OrcaAction[]>();
const busDetachers = new Map<string, () => void>();
const recentRuns: OrcaRunResult[] = [];
const runQueue: Array<() => Promise<void>> = [];
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<TPayload, TValue>(
event: string,
action: OrcaAction<TPayload, TValue>
): () => 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<void> {
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<void> {
const runId = generateRunId();
const startedAt = timers.clock.now();
const tokens = new Set<OrcaToken>();
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<OrcaToken>;
}): Promise<OrcaActionRun> {
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<string, unknown>): 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<OrcaStage, OrcaAction[]> {
const result = new Map<OrcaStage, OrcaAction[]>();
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<MyUser>({ 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<S, TSchema>, 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).

Powered by TurnKey Linux.