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