You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1382 lines
33 KiB
1382 lines
33 KiB
|
5 months ago
|
# buss — Event Bus y coordinación del ecosistema
|
||
|
|
|
||
|
|
## Tesis
|
||
|
|
|
||
|
|
`buss` debe ser un bus de eventos tipado, no un simple `EventEmitter` genérico
|
||
|
|
y tampoco un contenedor de reglas de negocio. Su objetivo es transportar hechos
|
||
|
|
con envelope, orden, errores y observabilidad. La coordinación del ecosistema
|
||
|
|
se construye encima:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
artifact events -> translators in aapp -> app events -> consumer reactions
|
||
|
|
```
|
||
|
|
|
||
|
|
Ejemplo:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Sess emite: sess.changed
|
||
|
|
aapp traduce: app.user.identity.changed
|
||
|
|
Cach decide: si autoInvalidateOn incluye identity -> clear()
|
||
|
|
Perm decide: si autoInvalidateOn incluye identity -> invalidate()
|
||
|
|
Conn decide: si autoReauthOn + connection.session + connection.auth -> reauth/close
|
||
|
|
```
|
||
|
|
|
||
|
|
La regla principal:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Los módulos no deben conocerse entre sí.
|
||
|
|
```
|
||
|
|
|
||
|
|
`sess` no debe importar `conn`, `cach` ni `perm`. `conn` no debe saber de
|
||
|
|
`auth`. `cach` no debe saber de `sess`. La traducción vive en `aapp`; las
|
||
|
|
reacciones viven en la factory del consumidor que las ejecuta.
|
||
|
|
|
||
|
|
Además, los módulos no crean buses propios. El bus transversal del cliente es
|
||
|
|
`App.Bus`, creado una vez por `aapp`. Un artefacto recibe una referencia
|
||
|
|
inyectada (`EngineBus`, `AppEventBus` o `EventPublisher`) y nunca llama
|
||
|
|
`createEngineBus()` para abrir una isla privada.
|
||
|
|
|
||
|
|
## Modelo Cerrado
|
||
|
|
|
||
|
|
Este es el contrato que se debe implementar antes de seguir ampliando el bus.
|
||
|
|
|
||
|
|
### 1. Bus Engine
|
||
|
|
|
||
|
|
`arts/buss` es un artefacto raíz porque expone `createEngineBus()` y ciclo de
|
||
|
|
vida. Pero su dominio es **mecánico**, no de aplicación:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Sí pertenece a buss:
|
||
|
|
- EngineBus
|
||
|
|
- EventPublisher
|
||
|
|
- BusEnvelope
|
||
|
|
- BusSubscription
|
||
|
|
- publish / publishAsync
|
||
|
|
- on / once / onAny
|
||
|
|
- listenerErrorMode
|
||
|
|
- diagnostics del propio bus
|
||
|
|
- BUS_EVENT_ALL
|
||
|
|
|
||
|
|
No pertenece a buss:
|
||
|
|
- APP_EVENT_*
|
||
|
|
- SESS_EVENT_*
|
||
|
|
- AUTH_EVENT_*
|
||
|
|
- CACH_EVENT_*
|
||
|
|
- PERM_EVENT_*
|
||
|
|
- CONN_EVENT_*
|
||
|
|
- reglas de cache/permisos/conexiones
|
||
|
|
```
|
||
|
|
|
||
|
|
`buss` no debe conocer `sess`, `auth`, `cach`, `perm`, `conn`, `tenant`,
|
||
|
|
`actor`, `permissions`, `cache` ni `credentials`.
|
||
|
|
|
||
|
|
### 2. Module Events
|
||
|
|
|
||
|
|
Cada artefacto puede publicar eventos propios si recibe un `EventPublisher`.
|
||
|
|
Esos eventos pertenecen al artefacto que los emite:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
arts/sess/consts.ts -> SESS_EVENT_*
|
||
|
|
arts/auth/consts.ts -> AUTH_EVENT_*
|
||
|
|
arts/cach/consts.ts -> CACH_EVENT_*
|
||
|
|
arts/perm/consts.ts -> PERM_EVENT_*
|
||
|
|
arts/conn/consts.ts -> CONN_EVENT_*
|
||
|
|
```
|
||
|
|
|
||
|
|
Los module events son hechos internos del ecosistema. No son el contrato
|
||
|
|
público estable para plugins. Los consume `aapp` mediante traductores.
|
||
|
|
|
||
|
|
Regla:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Un artefacto puede publicar sus propios eventos.
|
||
|
|
Un artefacto no debe suscribirse a eventos privados de otro artefacto.
|
||
|
|
Un artefacto no crea su propio bus; recibe el bus central o un publisher
|
||
|
|
inyectado.
|
||
|
|
El código publica/escucha constantes de evento, no strings inline.
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. App Events
|
||
|
|
|
||
|
|
Los eventos públicos y estables de aplicación no pertenecen a `buss`; pertenecen
|
||
|
|
al contrato común de aplicación. Ubicación propuesta:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
src/libs/aapp/events.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Ahí viven:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
APP_EVENT_USER_IDENTITY_CHANGED
|
||
|
|
APP_EVENT_TENANT_SWITCHED
|
||
|
|
APP_EVENT_PERMISSIONS_REFRESH_REQUESTED
|
||
|
|
APP_EVENT_CONNECTIVITY_CHANGED
|
||
|
|
APP_EVENT_CACHE_INVALIDATE_REQUESTED
|
||
|
|
APP_EVENT_DISPOSE_STARTING
|
||
|
|
```
|
||
|
|
|
||
|
|
También viven ahí sus payloads y helpers:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
publishAppUserIdentityChanged(bus, payload)
|
||
|
|
onAppUserIdentityChanged(bus, listener)
|
||
|
|
```
|
||
|
|
|
||
|
|
`aapp` y los consumidores importan este contrato desde `libs`, no desde otro
|
||
|
|
artefacto.
|
||
|
|
|
||
|
|
### 4. Translators
|
||
|
|
|
||
|
|
`aapp` no ejecuta side-effects destructivos. `aapp` solo traduce eventos de
|
||
|
|
módulo a eventos de aplicación:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
SESS_EVENT_CHANGED -> APP_EVENT_USER_IDENTITY_CHANGED
|
||
|
|
AUTH_EVENT_SIGNED_IN -> APP_EVENT_USER_IDENTITY_CHANGED
|
||
|
|
AUTH_EVENT_SIGNED_OUT -> APP_EVENT_USER_IDENTITY_CHANGED
|
||
|
|
PERM_EVENT_POLICY_DIRTY -> APP_EVENT_PERMISSIONS_REFRESH_REQUESTED
|
||
|
|
```
|
||
|
|
|
||
|
|
Estos traductores viven en:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
src/arts/aapp/integrations/*-translator.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
No se llaman `*-orchestrator.ts` en el modelo final si solo traducen. La palabra
|
||
|
|
orquestador se reserva para una pieza que coordina flujo; aquí queremos piezas
|
||
|
|
pequeñas:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
module event in -> app event out
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. Consumer Reactions
|
||
|
|
|
||
|
|
Las reacciones automáticas viven en el consumidor que muta su propio estado:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
createActiveCache({
|
||
|
|
autoInvalidateOn: 'standard'
|
||
|
|
});
|
||
|
|
|
||
|
|
createActivePermissions({
|
||
|
|
autoInvalidateOn: 'standard'
|
||
|
|
});
|
||
|
|
|
||
|
|
createActiveConnections({
|
||
|
|
autoReauthOn: 'standard'
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
`aapp` nunca configura `invalidateCache`, `invalidatePermissions` ni
|
||
|
|
`reauthenticateConnections` porque eso mezcla traducción con side-effects.
|
||
|
|
|
||
|
|
### 6. Defaults
|
||
|
|
|
||
|
|
Los defaults quedan cerrados así:
|
||
|
|
|
||
|
|
| Capa | Default | Motivo |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `App.Bus` | siempre presente | superficie uniforme |
|
||
|
|
| Module events | se publican si el artefacto recibe bus | publicar hechos no muta otros módulos |
|
||
|
|
| `aapp` translators | `standard` por defecto | traducir a `app.*` no es destructivo |
|
||
|
|
| Consumer reactions | `none` por defecto | limpiar cache/permisos o reautenticar sockets sí es destructivo |
|
||
|
|
| `orchestration: 'silent'` | sin traductores automáticos | tests que publican `app.*` manualmente |
|
||
|
|
|
||
|
|
### 7. Payload Safety
|
||
|
|
|
||
|
|
Los `APP_EVENT_*` nunca contienen credenciales, tokens, passwords, headers de
|
||
|
|
authorization, secretos ni hashes sensibles. Si un consumidor necesita contexto
|
||
|
|
sensible, recibe `correlationId` y resuelve contra su propio estado o backend.
|
||
|
|
|
||
|
|
## Frame Estratégico
|
||
|
|
|
||
|
|
`buss` no es solo un refactor interno para quitar puentes directos. Es la
|
||
|
|
fundación del sistema de extensión del framework.
|
||
|
|
|
||
|
|
Hoy el ecosistema tiene artefactos fijos. Mañana un módulo externo como
|
||
|
|
`feature flags`, `analytics`, `queue`, `billing` o `audit trail` debe poder:
|
||
|
|
|
||
|
|
- escuchar hechos del runtime sin tocar el core;
|
||
|
|
- emitir hechos propios sin que el core lo conozca;
|
||
|
|
- integrarse desde `aapp` mediante translators;
|
||
|
|
- participar en trazas y diagnósticos con el mismo envelope.
|
||
|
|
|
||
|
|
Por eso `buss` debe tratarse como API pública desde el primer commit. Los
|
||
|
|
nombres de eventos canónicos, el shape del envelope y la semántica de
|
||
|
|
`publish` son contrato externo.
|
||
|
|
|
||
|
|
## Problema Que Resuelve
|
||
|
|
|
||
|
|
El framework ya tiene módulos potentes, pero los casos reales no ocurren en
|
||
|
|
aislado:
|
||
|
|
|
||
|
|
- Usuario conectado a un chat con un token.
|
||
|
|
- Cambia la identidad activa.
|
||
|
|
- Las conexiones deben reautenticarse o cerrarse.
|
||
|
|
- La cache actor-scoped o pública contaminada debe invalidarse.
|
||
|
|
- Los permisos cacheados deben vaciarse.
|
||
|
|
- Los logs deben contar qué política se ejecutó.
|
||
|
|
- El test debe verificar toda la cadena.
|
||
|
|
|
||
|
|
Sin bus, esto acaba en puentes directos:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
sess -> conn
|
||
|
|
auth -> cach
|
||
|
|
auth -> perm
|
||
|
|
perm -> cach
|
||
|
|
```
|
||
|
|
|
||
|
|
Ese patrón escala mal porque convierte la composición en una malla invisible.
|
||
|
|
|
||
|
|
Con `buss`, cada módulo publica hechos, `aapp` los traduce a un vocabulario
|
||
|
|
público de aplicación, y cada consumidor decide sus efectos opt-in.
|
||
|
|
|
||
|
|
## Frontera De Uso
|
||
|
|
|
||
|
|
`buss` es para hechos transversales entre artefactos. No reemplaza los eventos
|
||
|
|
privados de cada módulo.
|
||
|
|
|
||
|
|
Se quedan internos:
|
||
|
|
|
||
|
|
- `Session.onChange(...)`
|
||
|
|
- `Connection.onState(...)`
|
||
|
|
- `Connection.onAny(...)`
|
||
|
|
- `Cache.on(CACHE_EVENT_ALL, ...)`
|
||
|
|
- snapshots y listeners internos de `ActiveEngine`
|
||
|
|
|
||
|
|
Van a `buss`:
|
||
|
|
|
||
|
|
- cambio de identidad;
|
||
|
|
- sign-in/sign-out;
|
||
|
|
- sesión expirada/revocada;
|
||
|
|
- cambio de permisos;
|
||
|
|
- invalidación transversal de cache;
|
||
|
|
- reautenticación de conexiones por identidad;
|
||
|
|
- eventos públicos que extensiones externas puedan consumir.
|
||
|
|
|
||
|
|
Regla:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Si el evento solo interesa al módulo que lo emite, no va al bus.
|
||
|
|
Si el evento cambia política entre módulos, sí va al bus.
|
||
|
|
```
|
||
|
|
|
||
|
|
## Referentes
|
||
|
|
|
||
|
|
### Node EventEmitter
|
||
|
|
|
||
|
|
Bueno:
|
||
|
|
|
||
|
|
- API conocida: `on`, `emit`, `off`.
|
||
|
|
- Ejecución síncrona y determinista.
|
||
|
|
- Muy probado.
|
||
|
|
|
||
|
|
Malo:
|
||
|
|
|
||
|
|
- Tipado débil.
|
||
|
|
- `error` tiene semántica especial.
|
||
|
|
- Listener leaks si no se controlan.
|
||
|
|
- Poca estructura para metadatos, correlación o trazabilidad.
|
||
|
|
|
||
|
|
Qué copiar:
|
||
|
|
|
||
|
|
- Emisión síncrona por defecto para eventos internos.
|
||
|
|
- `once`.
|
||
|
|
- Limpieza explícita.
|
||
|
|
|
||
|
|
Qué evitar:
|
||
|
|
|
||
|
|
- API basada en `this`.
|
||
|
|
- Strings libres repartidos.
|
||
|
|
- Eventos especiales mágicos.
|
||
|
|
|
||
|
|
### mitt
|
||
|
|
|
||
|
|
Bueno:
|
||
|
|
|
||
|
|
- Muy pequeño.
|
||
|
|
- Sin dependencias.
|
||
|
|
- Funcional, sin `this`.
|
||
|
|
- Wildcard `*`.
|
||
|
|
- Tipable con mapa de eventos.
|
||
|
|
|
||
|
|
Malo:
|
||
|
|
|
||
|
|
- No tiene envelope.
|
||
|
|
- No tiene políticas de error.
|
||
|
|
- No tiene async orchestration.
|
||
|
|
- No tiene replay, prioridad, cola ni metadatos.
|
||
|
|
|
||
|
|
Qué copiar:
|
||
|
|
|
||
|
|
- Simplicidad.
|
||
|
|
- `on(type, handler)`, `off`, `emit`.
|
||
|
|
- `on('*')` para observabilidad.
|
||
|
|
|
||
|
|
Qué mejorar:
|
||
|
|
|
||
|
|
- Envelopes con `id`, `at`, `source`, `correlationId`.
|
||
|
|
- Constantes obligatorias.
|
||
|
|
- Integración con `Logger`.
|
||
|
|
|
||
|
|
### Emittery
|
||
|
|
|
||
|
|
Bueno:
|
||
|
|
|
||
|
|
- Async-first.
|
||
|
|
- `onAny`.
|
||
|
|
- Async iterators.
|
||
|
|
- `AbortSignal`.
|
||
|
|
- Limpieza cómoda.
|
||
|
|
- Tipado fuerte.
|
||
|
|
|
||
|
|
Malo:
|
||
|
|
|
||
|
|
- Async por defecto puede ocultar orden y carreras.
|
||
|
|
- Más superficie de la necesaria para el core.
|
||
|
|
|
||
|
|
Qué copiar:
|
||
|
|
|
||
|
|
- `AbortSignal` para cancelar listeners.
|
||
|
|
- `onAny`.
|
||
|
|
- `publishAsync` separado de `publish`.
|
||
|
|
- Limpieza idempotente.
|
||
|
|
|
||
|
|
Qué evitar:
|
||
|
|
|
||
|
|
- Hacer que todos los eventos internos sean async por defecto.
|
||
|
|
|
||
|
|
### NestJS Events
|
||
|
|
|
||
|
|
Bueno:
|
||
|
|
|
||
|
|
- Usa eventos para desacoplar módulos.
|
||
|
|
- Listeners registrados en composition root.
|
||
|
|
- Buen modelo mental para backend.
|
||
|
|
|
||
|
|
Malo:
|
||
|
|
|
||
|
|
- Decorators/DI pesados.
|
||
|
|
- Depende de `eventemitter2`.
|
||
|
|
- Wildcards potentes pero fáciles de abusar.
|
||
|
|
|
||
|
|
Qué copiar:
|
||
|
|
|
||
|
|
- Registro centralizado.
|
||
|
|
- Eventos como contrato entre módulos.
|
||
|
|
|
||
|
|
Qué evitar:
|
||
|
|
|
||
|
|
- Decorators.
|
||
|
|
- Dependencia externa.
|
||
|
|
- Magia de bootstrap.
|
||
|
|
|
||
|
|
### Redux Toolkit Listener Middleware
|
||
|
|
|
||
|
|
Bueno:
|
||
|
|
|
||
|
|
- No es solo pub/sub: es orquestación.
|
||
|
|
- Permite efectos al reaccionar a acciones.
|
||
|
|
- Tiene cancelación, `condition`, `take`, `delay`.
|
||
|
|
- Separa evento de efecto.
|
||
|
|
|
||
|
|
Malo:
|
||
|
|
|
||
|
|
- Está unido a store/actions Redux.
|
||
|
|
- Puede volverse demasiado flexible.
|
||
|
|
|
||
|
|
Qué copiar:
|
||
|
|
|
||
|
|
- Concepto de listener/orchestrator.
|
||
|
|
- Cancelación con `AbortSignal`.
|
||
|
|
- Helpers para workflows complejos en una fase posterior.
|
||
|
|
|
||
|
|
### Effect PubSub
|
||
|
|
|
||
|
|
Bueno:
|
||
|
|
|
||
|
|
- Capacidad bounded/unbounded.
|
||
|
|
- Estrategias: backpressure, dropping, sliding.
|
||
|
|
- Buen modelo para sistemas concurrentes.
|
||
|
|
|
||
|
|
Malo:
|
||
|
|
|
||
|
|
- Demasiado pesado para el core actual.
|
||
|
|
- Requiere adoptar modelo Effect.
|
||
|
|
|
||
|
|
Qué copiar a futuro:
|
||
|
|
|
||
|
|
- Cola bounded.
|
||
|
|
- Políticas de saturación.
|
||
|
|
- Replay opcional.
|
||
|
|
|
||
|
|
### XState Actors
|
||
|
|
|
||
|
|
Bueno:
|
||
|
|
|
||
|
|
- Eventos como mensajes tipados.
|
||
|
|
- Estado privado.
|
||
|
|
- Comunicación explícita.
|
||
|
|
|
||
|
|
Malo:
|
||
|
|
|
||
|
|
- Demasiado grande para un bus común.
|
||
|
|
|
||
|
|
Qué copiar:
|
||
|
|
|
||
|
|
- Los eventos describen hechos, no instrucciones.
|
||
|
|
|
||
|
|
## Diseño Propuesto
|
||
|
|
|
||
|
|
Directorio:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
src/arts/buss/
|
||
|
|
index.ts
|
||
|
|
consts.ts
|
||
|
|
types.ts
|
||
|
|
errors.ts
|
||
|
|
engine-bus.ts
|
||
|
|
matching.ts
|
||
|
|
diagnostics.ts
|
||
|
|
README.md
|
||
|
|
test/
|
||
|
|
```
|
||
|
|
|
||
|
|
Si necesitamos capa reactiva:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
src/arts/buss/
|
||
|
|
index.ts
|
||
|
|
active-bus.svelte.ts
|
||
|
|
types.ts
|
||
|
|
README.md
|
||
|
|
test/
|
||
|
|
```
|
||
|
|
|
||
|
|
La primera versión vive en `arts/buss` aunque sea pura y no use Svelte, porque
|
||
|
|
expone `createEngineBus()` y ciclo de vida. `libs` queda reservado para
|
||
|
|
primitivas compartidas sin factory raíz.
|
||
|
|
|
||
|
|
## Naming
|
||
|
|
|
||
|
|
Decisión:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Directorio: buss
|
||
|
|
Nombre público: Bus
|
||
|
|
Factory: createEngineBus()
|
||
|
|
Tipo raíz: EngineBus
|
||
|
|
```
|
||
|
|
|
||
|
|
Claude propone `bus/` porque `buss` puede leerse como typo. La objeción es
|
||
|
|
válida, pero la decisión recomendada para este framework es mantener `buss`
|
||
|
|
como directorio por coherencia con los artefactos de cuatro letras y exponer
|
||
|
|
siempre `Bus` en la superficie pública.
|
||
|
|
|
||
|
|
El usuario no debería escribir `Buss` salvo al importar desde la ruta interna.
|
||
|
|
|
||
|
|
```ts
|
||
|
|
import { createEngineBus } from '$buss';
|
||
|
|
|
||
|
|
const Bus = createEngineBus<AppBusEvents>();
|
||
|
|
```
|
||
|
|
|
||
|
|
No usar:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
createEngineBuss()
|
||
|
|
EngineBuss
|
||
|
|
```
|
||
|
|
|
||
|
|
Tampoco usar dentro de un módulo:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const Bus = createEngineBus<MyModuleEvents>(); // mal en un artefacto normal
|
||
|
|
```
|
||
|
|
|
||
|
|
La creación directa de `createEngineBus()` queda para:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
- createActiveApp(), que crea App.Bus;
|
||
|
|
- tests unitarios aislados;
|
||
|
|
- servicios o herramientas que no viven dentro de una App activa.
|
||
|
|
```
|
||
|
|
|
||
|
|
## Contrato Base
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export type BusEventMap = object;
|
||
|
|
|
||
|
|
export interface BusEnvelope<TType extends string = string, TPayload = unknown> {
|
||
|
|
readonly id: string;
|
||
|
|
readonly type: TType;
|
||
|
|
readonly payload: TPayload;
|
||
|
|
readonly at: number;
|
||
|
|
readonly source: string;
|
||
|
|
readonly correlationId?: string;
|
||
|
|
readonly causationId?: string;
|
||
|
|
readonly context?: Readonly<Record<string, unknown>>;
|
||
|
|
readonly tags?: readonly string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface BusListenerFailure {
|
||
|
|
readonly listenerId?: string;
|
||
|
|
readonly type: string;
|
||
|
|
readonly error: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface BusPublishResult<TType extends string = string, TPayload = unknown> {
|
||
|
|
readonly envelope: BusEnvelope<TType, TPayload>;
|
||
|
|
readonly errors: readonly BusListenerFailure[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export type BusListener<TPayload> = (
|
||
|
|
event: BusEnvelope<string, TPayload>,
|
||
|
|
context: BusListenerContext
|
||
|
|
) => void | Promise<void>;
|
||
|
|
|
||
|
|
export interface BusListenerContext {
|
||
|
|
readonly signal: AbortSignal;
|
||
|
|
readonly logger?: Logger;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EngineBus<TEvents extends BusEventMap = BusEventMap> {
|
||
|
|
publish<TType extends keyof TEvents & string>(
|
||
|
|
type: TType,
|
||
|
|
payload: TEvents[TType],
|
||
|
|
options?: BusPublishOptions
|
||
|
|
): BusPublishResult<TType, TEvents[TType]>;
|
||
|
|
|
||
|
|
publishAsync<TType extends keyof TEvents & string>(
|
||
|
|
type: TType,
|
||
|
|
payload: TEvents[TType],
|
||
|
|
options?: BusPublishOptions
|
||
|
|
): Promise<BusPublishResult<TType, TEvents[TType]>>;
|
||
|
|
|
||
|
|
on<TType extends keyof TEvents & string>(
|
||
|
|
type: TType,
|
||
|
|
listener: BusListener<TEvents[TType]>,
|
||
|
|
options?: BusListenOptions
|
||
|
|
): BusSubscription;
|
||
|
|
|
||
|
|
onAny(listener: BusAnyListener<TEvents>, options?: BusListenOptions): BusSubscription;
|
||
|
|
|
||
|
|
once<TType extends keyof TEvents & string>(
|
||
|
|
type: TType,
|
||
|
|
listener: BusListener<TEvents[TType]>,
|
||
|
|
options?: BusListenOptions
|
||
|
|
): BusSubscription;
|
||
|
|
|
||
|
|
listenerCount(type?: keyof TEvents & string): number;
|
||
|
|
_clearForTesting(type?: keyof TEvents & string): void;
|
||
|
|
dispose(): void;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Por Qué `context` Y No `tenantId` / `actorId`
|
||
|
|
|
||
|
|
El envelope pertenece a `arts/buss`, por tanto no debe acoplarse a un modelo
|
||
|
|
concreto de tenancy, sesión o usuario. `tenantId`, `actorId`, `locale`,
|
||
|
|
`permissionHash` y similares son claves convencionales dentro de `context`.
|
||
|
|
|
||
|
|
Ejemplo:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.publish(APP_EVENT_USER_IDENTITY_CHANGED, payload, {
|
||
|
|
context: {
|
||
|
|
tenantId,
|
||
|
|
previousActorId,
|
||
|
|
nextActorId,
|
||
|
|
permissionHash
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Opciones
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export interface BusPublishOptions {
|
||
|
|
readonly source?: string;
|
||
|
|
readonly correlationId?: string;
|
||
|
|
readonly causationId?: string;
|
||
|
|
readonly context?: Readonly<Record<string, unknown>>;
|
||
|
|
readonly tags?: readonly string[];
|
||
|
|
readonly listenerErrorMode?: BusListenerErrorMode;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface BusListenOptions {
|
||
|
|
readonly signal?: AbortSignal;
|
||
|
|
readonly once?: boolean;
|
||
|
|
readonly id?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type BusListenerErrorMode = 'log-and-continue' | 'throw' | 'collect';
|
||
|
|
|
||
|
|
export interface EngineBusOptions {
|
||
|
|
readonly logger?: Logger;
|
||
|
|
readonly clock?: { now(): number };
|
||
|
|
readonly idFactory?: () => string;
|
||
|
|
readonly maxListenersPerEvent?: number;
|
||
|
|
readonly listenerErrorMode?: BusListenerErrorMode;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Decisiones v0:
|
||
|
|
|
||
|
|
- Sin `priority` en `BusListenOptions`.
|
||
|
|
- Orden por registro.
|
||
|
|
- `maxListenersPerEvent` default: `32`.
|
||
|
|
- `_clearForTesting()` existe para tests, no como API normal de producción.
|
||
|
|
|
||
|
|
## Eventos Como Hechos
|
||
|
|
|
||
|
|
Correcto:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.publish(SESS_EVENT_IDENTITY_CHANGED, {
|
||
|
|
previousActorId: 'actor-ada',
|
||
|
|
nextActorId: 'actor-linus'
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Incorrecto:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.publish('cache.clear.now', {});
|
||
|
|
Bus.publish('connection.reauthenticate.now', {});
|
||
|
|
```
|
||
|
|
|
||
|
|
Los eventos deben decir qué ha pasado, no qué otro módulo tiene que hacer.
|
||
|
|
|
||
|
|
## Constantes
|
||
|
|
|
||
|
|
No debe haber magic strings:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
// arts/buss/consts.ts
|
||
|
|
export const BUS_EVENT_ALL = '*';
|
||
|
|
export const BUS_LISTENER_ERROR_MODE_COLLECT = 'collect';
|
||
|
|
|
||
|
|
// libs/aapp/events.ts
|
||
|
|
export const APP_EVENT_USER_IDENTITY_CHANGED = 'app.user.identity.changed';
|
||
|
|
|
||
|
|
// arts/sess/consts.ts
|
||
|
|
export const SESS_EVENT_CHANGED = 'sess.changed';
|
||
|
|
export const SESS_EVENT_REVOKED = 'sess.revoked';
|
||
|
|
```
|
||
|
|
|
||
|
|
Convención obligatoria:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
El símbolo exportado es UPPER_SNAKE_CASE y describe el dueño.
|
||
|
|
El valor puede ser dot-case porque es el nombre serializable del evento.
|
||
|
|
```
|
||
|
|
|
||
|
|
Correcto:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.publish(APP_EVENT_USER_IDENTITY_CHANGED, payload);
|
||
|
|
Bus.on(SESS_EVENT_CHANGED, listener);
|
||
|
|
```
|
||
|
|
|
||
|
|
Incorrecto:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.publish('app.user.identity.changed', payload);
|
||
|
|
Bus.on('sess.changed', listener);
|
||
|
|
```
|
||
|
|
|
||
|
|
Las constantes se centralizan por dueño, no todas dentro del bus:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
BUS_* -> contrato mecánico del bus
|
||
|
|
APP_EVENT_* -> contrato público de aplicación
|
||
|
|
SESS_EVENT_* -> contrato privado de sess
|
||
|
|
AUTH_EVENT_* -> contrato privado de auth
|
||
|
|
...
|
||
|
|
```
|
||
|
|
|
||
|
|
## onAny
|
||
|
|
|
||
|
|
`onAny` es una herramienta de observabilidad, testing y devtools.
|
||
|
|
|
||
|
|
Uso correcto:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.onAny((event) => {
|
||
|
|
App.Logger.debug('buss', event.type, { context: { envelope: event } });
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Uso incorrecto:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.onAny((event) => {
|
||
|
|
if (event.type === APP_EVENT_USER_IDENTITY_CHANGED) {
|
||
|
|
App.Cache.clear();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
La lógica de negocio debe estar en listeners concretos del consumidor o en
|
||
|
|
servicios explícitos, no en `onAny`.
|
||
|
|
|
||
|
|
## Integración Con aapp
|
||
|
|
|
||
|
|
`aapp` debería crear un bus por aplicación:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const Bus = createEngineBus<AppBusEvents>({
|
||
|
|
logger: Logger,
|
||
|
|
clock: Timers.clock
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Y exponerlo:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
App.Bus
|
||
|
|
```
|
||
|
|
|
||
|
|
Los artefactos reciben el bus opcionalmente:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
createActiveSession({ logger, bus: Bus });
|
||
|
|
createActiveConnections({ logger, bus: Bus });
|
||
|
|
createActiveCache({ logger, bus: Bus });
|
||
|
|
```
|
||
|
|
|
||
|
|
La regla es centralización: una app, un `App.Bus`. Si un módulo necesita emitir
|
||
|
|
hechos, recibe un `EventPublisher`; si necesita reaccionar, recibe un
|
||
|
|
`AppEventBus` o se configura desde su factory.
|
||
|
|
|
||
|
|
Regla de implementación para `sess`:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
createEngineSession({ bus }) publica sess.* desde el engine puro.
|
||
|
|
createActiveSession({ bus }) publica sess.* desde el wrapper activo, después de
|
||
|
|
sincronizar current/generation con $state.
|
||
|
|
```
|
||
|
|
|
||
|
|
Ese matiz evita que consumidores reactivos como `conn` reciban
|
||
|
|
`app.user.identity.changed` y lean todavía el snapshot anterior de
|
||
|
|
`ActiveSession.current` dentro del mismo tick.
|
||
|
|
|
||
|
|
Pero los módulos no deben depender de `aapp`. Solo aceptan una interfaz mínima:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
interface EventPublisher<TEvents extends BusEventMap> {
|
||
|
|
publish<TType extends keyof TEvents & string>(
|
||
|
|
type: TType,
|
||
|
|
payload: TEvents[TType],
|
||
|
|
options?: BusPublishOptions
|
||
|
|
): BusPublishResult<TType, TEvents[TType]>;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Modelo De Coordinación: Traducción vs Reacción
|
||
|
|
|
||
|
|
El bus por sí mismo reduce acoplamiento porque convierte dependencias directas
|
||
|
|
entre módulos en hechos públicos. Pero un translator default demasiado
|
||
|
|
prescriptivo puede reintroducir acoplamiento desde `aapp`.
|
||
|
|
|
||
|
|
Ejemplo peligroso:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
aapp escucha sess.changed
|
||
|
|
aapp ejecuta Permissions.invalidate() + Cache.clear() + Conn.reauthenticateAll()
|
||
|
|
```
|
||
|
|
|
||
|
|
Aunque `sess` no conozca `cache`, `permissions` ni `conn`, `aapp` estaría
|
||
|
|
asumiendo cómo deben comportarse todos los consumidores. Eso no escala:
|
||
|
|
|
||
|
|
- una app puede no tener sesión;
|
||
|
|
- una app puede no crear `Permissions`;
|
||
|
|
- una app puede querer conservar cache pública;
|
||
|
|
- una app puede tener cache segmentada que no depende de identidad;
|
||
|
|
- una app puede querer cerrar conexiones en vez de reautenticarlas;
|
||
|
|
- un plugin externo puede querer reaccionar sin tocar `aapp`.
|
||
|
|
|
||
|
|
Por tanto hay dos capas separadas:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Capa 1: traducción
|
||
|
|
aapp traduce eventos de módulo -> app.*
|
||
|
|
|
||
|
|
Capa 2: reacción
|
||
|
|
cada consumidor decide si escucha app.* y qué side-effect ejecuta
|
||
|
|
```
|
||
|
|
|
||
|
|
### Config De App: Translators
|
||
|
|
|
||
|
|
`createActiveApp().orchestration` solo decide qué traductores automáticos
|
||
|
|
corren. No decide qué hace cache, permissions o connections.
|
||
|
|
|
||
|
|
```ts
|
||
|
|
createActiveApp({
|
||
|
|
orchestration: 'standard'
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```ts
|
||
|
|
createActiveApp({
|
||
|
|
orchestration: 'silent'
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
```ts
|
||
|
|
createActiveApp({
|
||
|
|
orchestration: ['identity', 'tenant-switched']
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Tipos aproximados:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export type ActiveAppOrchestrationPreset = false | 'silent' | 'standard';
|
||
|
|
|
||
|
|
export type ActiveAppOrchestrationTranslator =
|
||
|
|
| 'identity'
|
||
|
|
| 'permissions-refresh'
|
||
|
|
| 'tenant-switched'
|
||
|
|
| 'connectivity'
|
||
|
|
| 'dispose';
|
||
|
|
|
||
|
|
export type ActiveAppOrchestrationOptions =
|
||
|
|
| ActiveAppOrchestrationPreset
|
||
|
|
| readonly ActiveAppOrchestrationTranslator[];
|
||
|
|
```
|
||
|
|
|
||
|
|
`createActiveApp()` equivale a translators `standard`: publica eventos `app.*`
|
||
|
|
útiles por defecto, pero no provoca side-effects destructivos porque los
|
||
|
|
consumidores no reaccionan automáticamente salvo que se configure su propio
|
||
|
|
`auto*On`.
|
||
|
|
|
||
|
|
### Config De Consumidores: Reacciones
|
||
|
|
|
||
|
|
Los efectos destructivos viven en el consumidor:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const App = createActiveApp({
|
||
|
|
orchestration: 'standard',
|
||
|
|
cache: {
|
||
|
|
autoInvalidateOn: 'standard'
|
||
|
|
},
|
||
|
|
permissions: {
|
||
|
|
endpoint: '/api/permissions',
|
||
|
|
autoInvalidateOn: 'standard'
|
||
|
|
},
|
||
|
|
connections: {
|
||
|
|
autoReauthOn: 'standard'
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Y también se pueden activar por factory:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const Permissions = App.createActivePermissions({
|
||
|
|
endpoint: '/api/permissions',
|
||
|
|
autoInvalidateOn: ['userIdentityChange']
|
||
|
|
});
|
||
|
|
|
||
|
|
const Connections = App.createActiveConnections({
|
||
|
|
autoReauthOn: 'standard'
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Modos De App
|
||
|
|
|
||
|
|
| Modo | Traduce a `app.*` | Side-effects | Uso |
|
||
|
|
| --- | --- | --- | --- |
|
||
|
|
| `createActiveApp()` | sí, preset `standard` | ninguno por sí mismo | Apps que quieren hechos públicos sin reacciones automáticas |
|
||
|
|
| `orchestration: 'standard'` | sí, preset cerrado | ninguno por sí mismo | Apps normales, getting started |
|
||
|
|
| `orchestration: ['identity']` | solo los traductores indicados | ninguno por sí mismo | Tests/producción con control fino |
|
||
|
|
| `orchestration: 'silent'` | no | ninguno | Tests que publican `app.*` manualmente |
|
||
|
|
|
||
|
|
### Translators `standard`
|
||
|
|
|
||
|
|
`'standard'` debe estar documentado como lista cerrada. No puede ser magia.
|
||
|
|
|
||
|
|
| Translator | Publica |
|
||
|
|
| --- | --- |
|
||
|
|
| `identity` | `app.user.identity.changed` desde eventos de `sess`/`auth` |
|
||
|
|
| `permissions-refresh` | `app.permissions.refresh` |
|
||
|
|
| `tenant-switched` | `app.tenant.switched` |
|
||
|
|
| `connectivity` | `app.connectivity.changed` |
|
||
|
|
| `dispose` | `app.dispose.starting` |
|
||
|
|
|
||
|
|
### Reacciones `standard` Por Consumidor
|
||
|
|
|
||
|
|
| Consumidor | Config | Reacciona a |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| `cach` | `autoInvalidateOn: 'standard'` | `userIdentityChange`, `tenantSwitched` |
|
||
|
|
| `perm` | `autoInvalidateOn: 'standard'` | `userIdentityChange`, `permissionsRefresh`, `tenantSwitched` |
|
||
|
|
| `conn` | `autoReauthOn: 'standard'` | `userIdentityChange` |
|
||
|
|
|
||
|
|
`conn.autoReauthOn` es opt-in porque puede cerrar o reautenticar sockets,
|
||
|
|
perder mensajes en vuelo si el transporte no bufferiza, producir un blink en
|
||
|
|
chat/realtime o disparar reconexiones masivas.
|
||
|
|
|
||
|
|
Para `conn`, `autoReauthOn` no basta por sí solo. La registry escucha el evento
|
||
|
|
de identidad, pero cada conexión debe activar `session.enabled` y definir
|
||
|
|
`auth` para que exista una credencial nueva que enviar. Sin `auth`, el evento
|
||
|
|
queda como señal observable o debe resolverse con reconnect/disconnect manual.
|
||
|
|
|
||
|
|
### App Events Sin Credenciales
|
||
|
|
|
||
|
|
Los eventos de aplicación son contrato público. Un día pueden acabar en logs,
|
||
|
|
devtools, tracing, outbox o dumps de auditoría. Por tanto:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
App events nunca contienen credenciales.
|
||
|
|
```
|
||
|
|
|
||
|
|
Protecciones recomendadas:
|
||
|
|
|
||
|
|
- tipos de payload cerrados: `AppUserIdentityChangePayload` solo puede contener
|
||
|
|
`previousActorId`, `nextActorId`, `tenantId`, `cause`, `generation`, etc.;
|
||
|
|
- lint/check en DEV antes de publicar: advertir si aparecen keys como `token`,
|
||
|
|
`secret`, `password`, `authorization`, o valores con forma de JWT;
|
||
|
|
- documentación explícita: si un consumidor necesita algo sensible, recibe
|
||
|
|
`correlationId` y resuelve contra `App.Sess` o contra su propio backend.
|
||
|
|
|
||
|
|
### Decisión Adoptada Para v0.1
|
||
|
|
|
||
|
|
- `createActiveApp()` crea `App.Bus` siempre.
|
||
|
|
- `createActiveApp()` publica/traduce eventos de aplicación con preset
|
||
|
|
`standard`.
|
||
|
|
- `orchestration: 'silent'` desactiva traductores automáticos.
|
||
|
|
- Los side-effects destructivos son opt-in en cada consumidor.
|
||
|
|
- Los consumidores escuchan `app.*`, no eventos internos de otros módulos.
|
||
|
|
- Los payloads `app.*` no contienen credenciales.
|
||
|
|
- `APP_EVENT_*` vive fuera de `arts/buss`; `buss` no conoce eventos de dominio.
|
||
|
|
|
||
|
|
## Orden De Bootstrap
|
||
|
|
|
||
|
|
Para no perder eventos tempranos:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
1. crear Logger
|
||
|
|
2. crear Timers
|
||
|
|
3. crear Bus
|
||
|
|
4. crear roots always-present
|
||
|
|
5. cablear translators
|
||
|
|
6. exponer factories que pueden emitir eventos
|
||
|
|
```
|
||
|
|
|
||
|
|
Los translators deben estar registrados antes de que se creen sesiones,
|
||
|
|
auth clients o conexiones que puedan publicar eventos.
|
||
|
|
|
||
|
|
## Orden De Teardown
|
||
|
|
|
||
|
|
En `App.dispose()`:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
1. desactivar factories/active modules que puedan emitir
|
||
|
|
2. desmontar translators
|
||
|
|
3. disponer Bus
|
||
|
|
4. disponer roots always-present
|
||
|
|
```
|
||
|
|
|
||
|
|
Debe haber test específico que verifique que un evento emitido durante dispose
|
||
|
|
no dispara listeners contra servicios ya disposed.
|
||
|
|
|
||
|
|
## Translators
|
||
|
|
|
||
|
|
Los traductores viven en integraciones de `aapp`:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
src/arts/aapp/integrations/session-translator.ts
|
||
|
|
src/arts/aapp/integrations/auth-translator.ts
|
||
|
|
src/arts/aapp/integrations/permission-translator.ts
|
||
|
|
```
|
||
|
|
|
||
|
|
Ejemplo:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export function wireSessionTranslator(bus: EngineBus<AppBusEvents & SessEventMap>): () => void {
|
||
|
|
const subscription = bus.on(SESS_EVENT_CHANGED, (event) => {
|
||
|
|
bus.publish(APP_EVENT_USER_IDENTITY_CHANGED, {
|
||
|
|
...event.payload,
|
||
|
|
cause: mapSessEventToCause(event.payload.event)
|
||
|
|
});
|
||
|
|
});
|
||
|
|
return () => subscription.unsubscribe();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Un translator debe ser fino:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
X pasó -> publico app.Y
|
||
|
|
```
|
||
|
|
|
||
|
|
No debe convertirse en servicio de negocio:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
X pasó -> leo cinco estados -> decido reglas de negocio -> mutaciones complejas
|
||
|
|
```
|
||
|
|
|
||
|
|
Si aparece lógica compleja, debe moverse a un servicio o engine específico.
|
||
|
|
|
||
|
|
## Política De Errores
|
||
|
|
|
||
|
|
Semántica cerrada:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
publish:
|
||
|
|
- ejecuta listeners síncronos en orden
|
||
|
|
- captura errores
|
||
|
|
- devuelve { envelope, errors }
|
||
|
|
- si listenerErrorMode === 'throw', lanza BusAggregateListenerError
|
||
|
|
|
||
|
|
publishAsync:
|
||
|
|
- ejecuta listeners en orden
|
||
|
|
- espera listeners async
|
||
|
|
- devuelve { envelope, errors }
|
||
|
|
- si listenerErrorMode === 'throw', lanza BusAggregateListenerError
|
||
|
|
```
|
||
|
|
|
||
|
|
Modos:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
type BusListenerErrorMode = 'log-and-continue' | 'throw' | 'collect';
|
||
|
|
```
|
||
|
|
|
||
|
|
Comportamiento:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
log-and-continue:
|
||
|
|
- loggea cada error
|
||
|
|
- continúa
|
||
|
|
- devuelve errors
|
||
|
|
|
||
|
|
collect:
|
||
|
|
- no loggea por defecto
|
||
|
|
- continúa
|
||
|
|
- devuelve errors
|
||
|
|
|
||
|
|
throw:
|
||
|
|
- acumula errores
|
||
|
|
- detiene según política de implementación
|
||
|
|
- lanza BusAggregateListenerError
|
||
|
|
```
|
||
|
|
|
||
|
|
Default recomendado:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
runtime: log-and-continue
|
||
|
|
tests: throw
|
||
|
|
```
|
||
|
|
|
||
|
|
`listenerErrorMode` puede sobrescribirse por publicación:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const result = Bus.publish(SESS_EVENT_CHANGED, payload, {
|
||
|
|
listenerErrorMode: 'collect'
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Esto permite que el bus tenga un default operativo (`log-and-continue`) y que
|
||
|
|
tests o flujos puntuales inspeccionen errores sin producir logs ni lanzar.
|
||
|
|
|
||
|
|
## Orden
|
||
|
|
|
||
|
|
El orden debe ser determinista:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
1. listeners específicos en orden de registro
|
||
|
|
2. once se elimina antes de invocar
|
||
|
|
3. onAny después de listeners específicos
|
||
|
|
```
|
||
|
|
|
||
|
|
No hay `priority` en v0. Las prioridades numéricas son un footgun porque crean
|
||
|
|
acoplamiento invisible entre listeners. Si v1 necesita orden especial, debe
|
||
|
|
ser con fases nombradas, no con números libres.
|
||
|
|
|
||
|
|
Ejemplo futuro aceptable:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
phase: 'before-default' | 'default' | 'after-default'
|
||
|
|
```
|
||
|
|
|
||
|
|
## Síncrono vs Async
|
||
|
|
|
||
|
|
Dos métodos separados:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Bus.publish(...);
|
||
|
|
await Bus.publishAsync(...);
|
||
|
|
```
|
||
|
|
|
||
|
|
`publish()` se usa para estado interno inmediato.
|
||
|
|
|
||
|
|
`publishAsync()` se usa para workflows que pueden esperar:
|
||
|
|
|
||
|
|
- auditoría remota;
|
||
|
|
- outbox;
|
||
|
|
- persistencia server-side;
|
||
|
|
- handlers externos;
|
||
|
|
- integraciones de plugins.
|
||
|
|
|
||
|
|
No mezclar ambas semánticas en un único `emit`.
|
||
|
|
|
||
|
|
## Observabilidad
|
||
|
|
|
||
|
|
El bus debe poder producir diagnósticos:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
buss.event.published
|
||
|
|
buss.listener.started
|
||
|
|
buss.listener.completed
|
||
|
|
buss.listener.failed
|
||
|
|
buss.listener.cancelled
|
||
|
|
buss.listener.leak_warning
|
||
|
|
```
|
||
|
|
|
||
|
|
Con constantes full-prefix:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export const BUS_DIAGNOSTIC_EVENTS = {
|
||
|
|
EVENT_PUBLISHED: 'buss.event.published',
|
||
|
|
LISTENER_STARTED: 'buss.listener.started',
|
||
|
|
LISTENER_COMPLETED: 'buss.listener.completed',
|
||
|
|
LISTENER_FAILED: 'buss.listener.failed',
|
||
|
|
LISTENER_CANCELLED: 'buss.listener.cancelled',
|
||
|
|
LISTENER_LEAK_WARNING: 'buss.listener.leak_warning'
|
||
|
|
} as const;
|
||
|
|
```
|
||
|
|
|
||
|
|
Debe usar `Logger` desde `libs/logr`, nunca acoplarse a `arts/logr`.
|
||
|
|
|
||
|
|
El envelope debe poder serializarse 1:1 en un log:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
Logger.info(BUSS_LOG_CATEGORY, envelope.type, {
|
||
|
|
context: { envelope }
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## API Surface Stability
|
||
|
|
|
||
|
|
Desde `0.1.x` se considera API pública estable:
|
||
|
|
|
||
|
|
- `EngineBus`
|
||
|
|
- `BusEnvelope`
|
||
|
|
- `BusPublishResult`
|
||
|
|
- `BusEventMap`
|
||
|
|
- `BusListener`
|
||
|
|
- `BusSubscription`
|
||
|
|
- semántica de `publish` / `publishAsync`
|
||
|
|
- orden de ejecución documentado
|
||
|
|
- política de errores documentada
|
||
|
|
|
||
|
|
Puede iterar sin romper:
|
||
|
|
|
||
|
|
- diagnósticos internos;
|
||
|
|
- opciones nuevas opcionales;
|
||
|
|
- helpers de testing;
|
||
|
|
- adapters futuros;
|
||
|
|
- active wrapper.
|
||
|
|
|
||
|
|
Los `APP_EVENT_*` tienen su propia estabilidad en `libs/aapp/events.ts`.
|
||
|
|
|
||
|
|
Rompe compatibilidad:
|
||
|
|
|
||
|
|
- renombrar eventos canónicos;
|
||
|
|
- quitar campos del envelope;
|
||
|
|
- cambiar el orden de ejecución;
|
||
|
|
- cambiar la semántica de errores;
|
||
|
|
- convertir `publish` síncrono en async.
|
||
|
|
|
||
|
|
## Bundle Budget
|
||
|
|
|
||
|
|
`buss` será always-present si lo crea `aapp`, así que su core debe ser pequeño.
|
||
|
|
|
||
|
|
Target:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
arts/buss core <= 3 KB gzip
|
||
|
|
```
|
||
|
|
|
||
|
|
Este límite aplica solo al core `arts/buss`, no a translators de `aapp`,
|
||
|
|
tests, páginas demo ni documentación. Debe añadirse al smoke test de bundle
|
||
|
|
cuando se implemente.
|
||
|
|
|
||
|
|
## Relación Con SemanticEngine / Taxis
|
||
|
|
|
||
|
|
`SemanticEngine` y `buss` pueden parecer registry + dispatch, pero pertenecen
|
||
|
|
a capas distintas.
|
||
|
|
|
||
|
|
| Eje | SemanticEngine | buss |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| Capa | Perceptiva / UI primitives | Dominio / runtime artifacts |
|
||
|
|
| Granularidad | Componente e interacción | Hecho de aplicación |
|
||
|
|
| Canales | Visual, sound, vibra, motion | Listeners y translators |
|
||
|
|
| Bloqueo | Visual hold / transition | `publish` sync, async opcional |
|
||
|
|
| Vocabulario | Verbos perceptivos | Hechos de dominio |
|
||
|
|
| Vida del evento | Transiente | Envelope/loggable |
|
||
|
|
|
||
|
|
No deben fusionarse.
|
||
|
|
|
||
|
|
Sí pueden compartir ideas:
|
||
|
|
|
||
|
|
- `id`
|
||
|
|
- `at`
|
||
|
|
- `source`
|
||
|
|
- correlación
|
||
|
|
|
||
|
|
Pero `SemanticEngine` sirve a la experiencia perceptiva y `buss` a la
|
||
|
|
composición de artefactos.
|
||
|
|
|
||
|
|
## Riesgos
|
||
|
|
|
||
|
|
### God Object
|
||
|
|
|
||
|
|
Si todo evento del runtime pasa por `buss`, deja de ser bus y se vuelve estado
|
||
|
|
central. La frontera inter/intra módulo debe aplicarse de forma estricta.
|
||
|
|
|
||
|
|
### Translators Con Lógica De Negocio
|
||
|
|
|
||
|
|
Los translators deben traducir hechos, no absorber decisiones de dominio.
|
||
|
|
|
||
|
|
### Drift Entre Envelope Y Logger
|
||
|
|
|
||
|
|
`buss` y `logr` deben alinearse. El envelope no debe inventar otra taxonomía
|
||
|
|
paralela de observabilidad.
|
||
|
|
|
||
|
|
### onAny Como Middleware Oculto
|
||
|
|
|
||
|
|
`onAny` solo observabilidad/devtools/testing. No policy.
|
||
|
|
|
||
|
|
### clear Destructivo
|
||
|
|
|
||
|
|
No exponer `clear(type?)` como API normal. Usar `_clearForTesting`.
|
||
|
|
|
||
|
|
## Test Compuesto Objetivo
|
||
|
|
|
||
|
|
Caso de regresión principal:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
1. Usuario Ada inicia sesión.
|
||
|
|
2. Chat abre conexión con token Ada.
|
||
|
|
3. Cache guarda presencia/proyecto para Ada.
|
||
|
|
4. Perm cachea decisión de Ada.
|
||
|
|
5. Se adopta sesión Linus.
|
||
|
|
6. Bus emite `sess.changed`.
|
||
|
|
7. Translator de App emite `app.user.identity.changed`.
|
||
|
|
8. Consumidores opt-in reaccionan:
|
||
|
|
- `perm.autoInvalidateOn` limpia decisiones
|
||
|
|
- `cach.autoInvalidateOn` limpia cache
|
||
|
|
- `conn.autoReauthOn` reautentica conexiones con `session.enabled` y `auth`
|
||
|
|
9. Chat envía auth frame con token Linus.
|
||
|
|
10. Ningún frame posterior contiene token Ada.
|
||
|
|
11. Cache refetchea para Linus.
|
||
|
|
```
|
||
|
|
|
||
|
|
Este test debe verificar ambas capas: traducción `sess.* -> app.*` y reacción
|
||
|
|
opt-in por consumidor.
|
||
|
|
|
||
|
|
## Roadmap
|
||
|
|
|
||
|
|
### Estado actual
|
||
|
|
|
||
|
|
- `arts/buss` existe como engine mecánico.
|
||
|
|
- `App.Bus` se crea siempre en `createActiveApp()`.
|
||
|
|
- `APP_EVENT_*` vive en `src/libs/aapp/events.ts`.
|
||
|
|
- `SESS_EVENT_*` vive en `src/arts/sess/consts.ts`.
|
||
|
|
- `sess` publica `sess.*` cuando recibe bus.
|
||
|
|
- `aapp` traduce `sess.changed -> app.user.identity.changed` con
|
||
|
|
`wireSessionTranslator`.
|
||
|
|
- `cach`, `perm` y `conn` reaccionan por opt-in desde sus propias factories.
|
||
|
|
- El test compuesto de cambio de usuario/chat/cache/permisos cubre la cadena
|
||
|
|
completa y evita credenciales antiguas después del cambio de actor.
|
||
|
|
|
||
|
|
### v0
|
||
|
|
|
||
|
|
- `arts/buss`
|
||
|
|
- `createEngineBus`
|
||
|
|
- `publish`
|
||
|
|
- `publishAsync`
|
||
|
|
- `on`
|
||
|
|
- `once`
|
||
|
|
- `onAny`
|
||
|
|
- `listenerCount`
|
||
|
|
- `_clearForTesting`
|
||
|
|
- `dispose`
|
||
|
|
- `AbortSignal`
|
||
|
|
- `Logger`
|
||
|
|
- `BusPublishResult`
|
||
|
|
- `BusAggregateListenerError`
|
||
|
|
- tests unitarios
|
||
|
|
|
||
|
|
### v0.1
|
||
|
|
|
||
|
|
- Integración en `aapp`
|
||
|
|
- Translator `sess.* -> app.user.identity.changed`
|
||
|
|
- Reacciones opt-in en `cach`, `perm` y `conn`
|
||
|
|
- Translator `auth.* -> app.user.identity.changed` cuando auth publique eventos propios
|
||
|
|
- Mover `APP_EVENT_*` a `src/libs/aapp/events.ts`
|
||
|
|
- Mover `SESS_EVENT_*` a `arts/sess`, fuera de `arts/buss`
|
||
|
|
- Orden de bootstrap documentado y testeado
|
||
|
|
- Orden de teardown documentado y testeado
|
||
|
|
- Test de dispose: evento emitido durante teardown no dispara contra servicios ya disposed
|
||
|
|
- Test compuesto cambio de usuario/chat/cache
|
||
|
|
- Bundle smoke para `buss <= 3 KB gzip`
|
||
|
|
|
||
|
|
### v0.2
|
||
|
|
|
||
|
|
- Active wrapper solo si hace falta:
|
||
|
|
- `lastEvent`
|
||
|
|
- `eventCount`
|
||
|
|
- `listenerCount`
|
||
|
|
- `onChange`
|
||
|
|
- Página `/test/buss`
|
||
|
|
- Devtools/debug panel
|
||
|
|
|
||
|
|
### v1
|
||
|
|
|
||
|
|
- `waitFor`
|
||
|
|
- `condition`
|
||
|
|
- `debounce`
|
||
|
|
- `takeLatest`
|
||
|
|
- fases nombradas si hay necesidad real
|
||
|
|
- replay opcional
|
||
|
|
- bounded queue opcional
|
||
|
|
- adapter server/outbox
|
||
|
|
|
||
|
|
## Qué No Meter Al Principio
|
||
|
|
|
||
|
|
- No decorators.
|
||
|
|
- No dependencia externa.
|
||
|
|
- No RxJS.
|
||
|
|
- No Effect.
|
||
|
|
- No wildcards complejos tipo `**` en v0.
|
||
|
|
- No bus distribuido.
|
||
|
|
- No persistence/outbox en v0.
|
||
|
|
- No reemplazar `Logger`.
|
||
|
|
- No convertir todos los logs en eventos.
|
||
|
|
- No eventos privados intra-módulo en el bus.
|
||
|
|
- No `priority` numérico.
|
||
|
|
- No lógica de negocio compleja en translators.
|
||
|
|
|
||
|
|
## Decisión Recomendada
|
||
|
|
|
||
|
|
Implementar `buss` como núcleo pequeño:
|
||
|
|
|
||
|
|
```txt
|
||
|
|
Core pequeño, eventos tipados, envelopes ricos, ejecución determinista,
|
||
|
|
translators en aapp, reacciones en consumidores y cero acoplamiento entre
|
||
|
|
artefactos.
|
||
|
|
```
|
||
|
|
|
||
|
|
Esto corrige el problema real del ecosistema: los módulos ya funcionan, pero
|
||
|
|
las transiciones entre módulos necesitan una gramática común, estable y
|
||
|
|
testeable.
|