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:
arts/cache,arts/perm,arts/connectionse suscriben internamente al bus para reaccionar aAPP_EVENT_*. Eso filtra vocabulario de App (tenant, refresh, identity) a piezas que deberían ser runtime puro.- 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). arts/active-appesconde side-effects de orquestación —violando una invariante explícita dearts/orca— porque hoy no existeorcacomo pieza de orquestación dedicada.
Decisión arquitectónica:
- Eliminar todos los
APP_EVENT_*salvo los queaappdueña realmente (DISPOSE_STARTING). - Eliminar el
session-translatory las suscripciones internas encache/perm/connection. - Exponer API imperativa pública en cada artefacto (
invalidate,refresh,cancelPrivateRequests,reauthenticateAll). - Reescribir
aappcomo 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 enarts/active-appmientras 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/cacheconoce el concepto "tenant".arts/permconoce el concepto "refresh".arts/connectionconoce 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):
orcano importa artefactos concretos salvo contratos comunes.- los artefactos no consumen
orca; solo publican eventos enbuss.- la aplicacion registra acciones en
orca.sess,cach,perm,connection,authohttpno deben depender deorcapara sus flujos internos.aapppuede crearBus,Timers,LoggeryOrchestration, 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 todoEso 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.tsdrá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
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
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:
// 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:
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 alcommit()si los tipos no llegan). - Ciclos de dependencias → error en
commit().
Runtime:
- Servicios
immediatese construyen en orden topológico alcommit(). - Si la construcción de uno falla, su
statequeda en'failed'y se aborta elcommit()con error agregado. - Servicios
lazyse construyen en el primer acceso; el error queda en sustate.
3.6 Type-safety
ActiveApp se vuelve genérico sobre el schema:
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:
// 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
- Modelo: núcleo (siempre presente) + servicios (opt-in declarado).
- Naming:
createActiveApp(options)(nonew ActiveApp(...)); helper de serviciodefineActive*/defineEngine*. La interfaz base se llamaAppService(sin "Active" — el "Active" del framework significa$state-reactivo y no aplica a todos los servicios). - Tipo del schema:
services: { [K in TName]: AppServiceFactory<...> }tipado, conKliteral para inferencia. - Servicios declarados que no existen como factory: error.
- Dependencias faltantes: error en compile-time (cuando los tipos
alcanzan) y en
commit()runtime como respaldo. - Init mode por servicio:
lazypor defecto en factories de servicios opcionales;immediatesolo si eldefine*()lo declara así. Override en la declaración del schema permitido. - Núcleo configurable vía opciones existentes (
lang,storage,frontend, …) en la raíz del options object. - Orquestación: vive en
orca, no en el schema de servicios. AppEventBus,APP_EVENT_*: sobreviven soloDISPOSE_STARTING. El resto se elimina.libs/active-app/events.tsse reduce a este único evento (o desaparece, ver §6).session-translator: se elimina.orca(o el bridge provisional) escuchaSESSION_EVENT_LIFECYCLE_*directamente.
5. Decisiones abiertas
- Ubicación final de
libs/active-app/: una vez vaciado, ¿se mueve todo aarts/active-app/(sin libs) o se mantienelibs/active-app/con soloconsts.tsyerrors.ts? Recomendación: mover todo aarts/active-app/. Solo el código del núcleo y los servicios es runtime; no hay contrato puro reusable que justifique una capa abstracta. - Tenant: ¿quién dueña el cambio de tenant? Si se confirma que es App,
APP_EVENT_TENANT_SWITCHEDsobrevive. Si pasa a un módulo (futurotenant), se elimina. Acción: investigar consumidores reales y decidir. - Connectivity: ¿
arts/connectionya publicaCONNECTION_EVENT_ONLINE/ OFFLINE? Si sí,APP_EVENT_CONNECTIVITY_CHANGEDse elimina y los listeners migran. Acción: verificar antes de Fase 1. bridges:provisional vs forzarorcadesde día uno: ¿conviene meter las suscripciones provisionales enaappcon un slot dedicado o esperar aorca?- 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:
- 1A — Verificar dueños reales:
- Confirmar que
connectionpublicaCONNECTION_EVENT_ONLINE/OFFLINE. Si no, considerar queconnectionlo añada antes de eliminarAPP_EVENT_CONNECTIVITY_CHANGED. - Confirmar que
tenantno tiene un módulo dueño y que mantenerlo en App es la opción correcta.
- Confirmar que
- 1B — Exponer API imperativa pública en
arts/cache,arts/perm,arts/connectionpara los efectos hoy automáticos:cache.invalidate({ on: 'userIdentityChange' | 'tenantSwitched' | ... })perm.invalidate(),perm.refresh({ cause? })connections.adoptIdentity(id),connections.reauthenticateAll()
- 1C — Eliminar suscripciones internas en los
active-*.svelte.tsde los tres arts. Conservar (de momento) el optionbus?: AppEventBuspara no romper firmas de creación. - 1D — Eliminar
session-translator.tsy sus tests. - 1E — Mover suscripciones provisionales a
aapp:arts/active-app/active-app.svelte.tsregistrabus.on(SESSION_EVENT_*, () => app.cache?.invalidate(...))como código transitorio.- Comentado:
// PROVISIONAL: when orca v0 lands, replace with App.orchestration.onEvent(...)
- 1F — Eliminar publishers/payloads/helpers de
libs/active-app/events.tssalvoDISPOSE_STARTING. Tests que publicabanpublishAppUserIdentityChanged(...)migran a publicarSESSION_EVENT_LIFECYCLE_*directamente o a llamar la API imperativa. - 1G — Limpiar imports en
arts/cache,arts/perm,arts/connectionque ya no apunten a$libs/active-app/events. - 1H — Actualizar
artifact-docs.tspara reflejar la realidad. - 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 (
invalidatevsclear,refreshvsreload).
Fase 3 — Modelo de servicios
Implementar AppServiceSchema, AppService, AppServiceFactory,
createActiveApp({ services: { … } }) con type-safety, init modes,
validación.
Subpasos:
- Definir tipos en
arts/active-app/services.ts. - Cada art expone su
defineActive*()/defineEngine*()factory. - Reescribir
createActiveApp()para construirse desde el schema. - Migrar las apps cliente (web/routes) a la nueva API.
- Eliminar las firmas legacy
App.createActive*().
Fase 4 — Integración con orca v0
Cuando arts/orca/ esté implementado:
App.orchestration(orca) reemplaza al bridge code provisional de Fase 1E.- Los registros provisionales
bus.on(SESSION_EVENT_*, ...)se reescriben comoApp.orchestration.onEvent(SESSION_EVENT_*, { id, stage, action }). aappdeja de tener cualquierbus.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,
domestá en núcleo pero solo tiene sentido en cliente. Quizásdomyfrontenddeberían ser servicios opcionales con defaultpresenten cliente yabsenten 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) vsarts/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 flagon: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-sourceenarts/connectiones un caso de adapter de App a Connection. Considerar moverlo aarts/active-app/integrations/para no contaminarconnectioncon 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 loscreateActive*()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
// 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
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:
- Suscripciones internas en los servicios (
Cache,Perms,Connection) — el problema diagnosticado en §1. wireSessionTranslator— republicaSESSION_EVENT_LIFECYCLE_*comoAPP_EVENT_USER_IDENTITY_CHANGED. Combinado con los presetsAPP_ORCHESTRATION_TRANSLATOR_*declarados enconsts.ts, esto es un orca embrionario sin tokens, sin stages, sin políticas, enterrado en aapp.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,createActiveAuthlanzan*AlreadyCreatedErrorsi se llaman dos veces.createActiveConnectionsmantiene unSet<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
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.
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)
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.loadPersistedFrontendPreferencesybindFrontendStorage— se mueven al factorydefineActiveFrontend. Viven con el artefacto, no en aapp.createAuthCacheInvalidator— pasa a preset opt-in.- Flags
Sess !== undefinedy erroresAPP_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;orcaTimeoutyorcaFataldefinidos pero no producidos). OrcaRunResultcon 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
CONTINUEyABORT_RUNdistinguen. Las demás se aceptan en el tipo y se tratan comoCONTINUE. - Timeouts: aceptados en el action interface, ignorados por el motor. Acciones que cuelgan, cuelgan. Documentado como limitación temporal.
- Concurrencia entre runs: solo modo
QUEUEimplícito. Eventos recibidos durante un run se encolan FIFO. - Compensación: campo
compensateaceptado, 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/orcacon 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 enlibs/.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 enarts/active-app/service-factories/por la misma razón: importan de las artes (createActiveCachedesde$cache) y del active-app (AppServiceFactorydesde./services). Si vivieran enarts/cache/, ese módulo importaríaAppServiceFactorydesde$active-appy 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.mdque 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)
- Modelo: núcleo (siempre presente: Logger/Bus/Timers/Orca) + servicios (opt-in declarado).
- Naming:
createActiveApp(options).defineActiveX/defineEngineXcomo helpers.App.Orcacomo campo del núcleo. - Tipo del schema:
services: { [K in TName]: AppServiceFactory<...> }con K literal para inferencia. - Servicios declarados que no existen como factory: error.
- Dependencias faltantes: error en compile-time (cuando los tipos
alcanzan) y en
commit()runtime como respaldo. - Init mode por servicio:
lazypor defecto.immediatesolo si eldefine*()lo declara así o el schema lo override. - Núcleo configurable vía opciones existentes (
lang,storage,frontend, …) en la raíz del options object. - Orquestación: vive en orca. Los presets reusables en
arts/active-app/presets/. - Eventos APP_*: sobreviven solo
DISPOSE_STARTING. El resto se elimina. session-translator: se elimina. Pasa a presetapplySessionRepublishIdentity.- Presets viven en
arts/active-app/presets/, no en cada art. define*()factories viven enarts/active-app/service-factories/.- Bus, Timers, Logger, Orca siempre construidos — núcleo, no opcional.
- Big-bang en rama dedicada, no coexistencia de APIs.
services: TSchemaopcional con default{}.- Orca v0.0 antes del refactor de aapp. Pre-requisito.
- Singleton uniforme para todos los servicios.
connectionspasa a singleton (rompiendo la asimetría actual conSet<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:
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)
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)
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)
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
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
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
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.
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.
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
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)
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
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 (
afterignorado). - No salta acciones por tokens presentes (
unlessignorado). - No bloquea acciones por tokens (
abortOnignorado). - No respeta timeouts (
actionTimeoutMsignorado). - No invoca compensaciones (
compensateignorado). - 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
EngineOrcav0.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) — Contratosservices.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 + agregadorapplyStandardOrca. +8 tests. - Paso 3A (commit
fb2e3ac) —App.Orcaañadido al núcleo decreateActiveApp(). - Paso 3B (commit
7148a5d) —services: TSchemaaceptado encreateActiveApp().App.cache,App.session, etc. (lowercase) accesibles. +6 tests. - Paso 3C (commit
60e130b) — APIs legacy marcadas@deprecatedcon guía de migración (cache/perm options,App.createActiveX(),App.Sess/Perms/Auth,ActiveAppOptions.{connections, permissions, auth, orchestration}, publishers de APP_EVENT_*).
- Paso 1 (commit
-
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:
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.tsreescrito 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.tsyauth-cache.ts.arts/connection/bus-session-source.ts.libs/active-app/directorio entero. Su contenido se consolidó enarts/active-app/{consts,errors,events}.ts.arts/cache:busyautoInvalidateOnoptions,wireAutoInvalidation,CACHE_AUTO_INVALIDATE_*constantes y types.arts/perm:busyautoInvalidateOn,wireAutoInvalidation,PERM_AUTO_INVALIDATE_*.arts/connection:busoption enEngineConnectionsOptions,shouldWireBusSessionSourcehelper.arts/active-app/active-app.svelte.ts:createSiumEngine(),createActiveSession(),createActiveConnections(),createActivePerms(),createActiveAuth()factory methods.App.Sess/App.Perms/App.Authgetters y singleton guards.- Sistema
APP_ORCHESTRATION_*entero (presets, translators,resolveActiveAppOrchestration,STANDARD_ORCHESTRATION_TRANSLATORS). APP_ERROR_ALREADY_CREATED_*yAPP_ERROR_CREATE_PERM_ENDPOINT_REQUIREDconstantes.
APP_EVENT_USER_IDENTITY_CHANGED,TENANT_SWITCHED,PERMISSIONS_REFRESH_REQUESTED,CACHE_INVALIDATE_REQUESTED,CONNECTIVITY_CHANGED. Solo sobreviveAPP_EVENT_DISPOSE_STARTING.- Sus payloads y
APP_USER_IDENTITY_CAUSE_*. publishApp{UserIdentityChanged, PermsRefreshRequested, CacheInvalidateRequested, TenantSwitched, ConnectivityChanged}. SobrevivenpublishAppDisposeStartingy el nuevoonAppDisposeStarting.
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).