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

# 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.