33 KiB
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:
artifact events -> translators in aapp -> app events -> consumer reactions
Ejemplo:
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:
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:
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:
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:
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:
src/libs/aapp/events.ts
Ahí viven:
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:
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:
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:
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:
module event in -> app event out
5. Consumer Reactions
Las reacciones automáticas viven en el consumidor que muta su propio estado:
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
aappmediante 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:
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:
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.
errortiene 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:
AbortSignalpara cancelar listeners.onAny.publishAsyncseparado depublish.- 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:
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:
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:
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.
import { createEngineBus } from '$buss';
const Bus = createEngineBus<AppBusEvents>();
No usar:
createEngineBuss()
EngineBuss
Tampoco usar dentro de un módulo:
const Bus = createEngineBus<MyModuleEvents>(); // mal en un artefacto normal
La creación directa de createEngineBus() queda para:
- createActiveApp(), que crea App.Bus;
- tests unitarios aislados;
- servicios o herramientas que no viven dentro de una App activa.
Contrato Base
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:
Bus.publish(APP_EVENT_USER_IDENTITY_CHANGED, payload, {
context: {
tenantId,
previousActorId,
nextActorId,
permissionHash
}
});
Opciones
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
priorityenBusListenOptions. - Orden por registro.
maxListenersPerEventdefault:32._clearForTesting()existe para tests, no como API normal de producción.
Eventos Como Hechos
Correcto:
Bus.publish(SESS_EVENT_IDENTITY_CHANGED, {
previousActorId: 'actor-ada',
nextActorId: 'actor-linus'
});
Incorrecto:
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:
// 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:
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:
Bus.publish(APP_EVENT_USER_IDENTITY_CHANGED, payload);
Bus.on(SESS_EVENT_CHANGED, listener);
Incorrecto:
Bus.publish('app.user.identity.changed', payload);
Bus.on('sess.changed', listener);
Las constantes se centralizan por dueño, no todas dentro del bus:
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:
Bus.onAny((event) => {
App.Logger.debug('buss', event.type, { context: { envelope: event } });
});
Uso incorrecto:
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:
const Bus = createEngineBus<AppBusEvents>({
logger: Logger,
clock: Timers.clock
});
Y exponerlo:
App.Bus
Los artefactos reciben el bus opcionalmente:
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:
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:
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:
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:
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.
createActiveApp({
orchestration: 'standard'
});
createActiveApp({
orchestration: 'silent'
});
createActiveApp({
orchestration: ['identity', 'tenant-switched']
});
Tipos aproximados:
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:
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:
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:
App events nunca contienen credenciales.
Protecciones recomendadas:
- tipos de payload cerrados:
AppUserIdentityChangePayloadsolo puede contenerpreviousActorId,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
correlationIdy resuelve contraApp.Sesso contra su propio backend.
Decisión Adoptada Para v0.1
createActiveApp()creaApp.Bussiempre.createActiveApp()publica/traduce eventos de aplicación con presetstandard.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 dearts/buss;bussno conoce eventos de dominio.
Orden De Bootstrap
Para no perder eventos tempranos:
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():
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:
src/arts/aapp/integrations/session-translator.ts
src/arts/aapp/integrations/auth-translator.ts
src/arts/aapp/integrations/permission-translator.ts
Ejemplo:
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:
X pasó -> publico app.Y
No debe convertirse en servicio de negocio:
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:
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:
type BusListenerErrorMode = 'log-and-continue' | 'throw' | 'collect';
Comportamiento:
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:
runtime: log-and-continue
tests: throw
listenerErrorMode puede sobrescribirse por publicación:
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:
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:
phase: 'before-default' | 'default' | 'after-default'
Síncrono vs Async
Dos métodos separados:
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:
buss.event.published
buss.listener.started
buss.listener.completed
buss.listener.failed
buss.listener.cancelled
buss.listener.leak_warning
Con constantes full-prefix:
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:
Logger.info(BUSS_LOG_CATEGORY, envelope.type, {
context: { envelope }
});
API Surface Stability
Desde 0.1.x se considera API pública estable:
EngineBusBusEnvelopeBusPublishResultBusEventMapBusListenerBusSubscription- 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
publishsíncrono en async.
Bundle Budget
buss será always-present si lo crea aapp, así que su core debe ser pequeño.
Target:
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:
idatsource- 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:
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/bussexiste como engine mecánico.App.Busse crea siempre encreateActiveApp().APP_EVENT_*vive ensrc/libs/aapp/events.ts.SESS_EVENT_*vive ensrc/arts/sess/consts.ts.sesspublicasess.*cuando recibe bus.aapptraducesess.changed -> app.user.identity.changedconwireSessionTranslator.cach,permyconnreaccionan 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/busscreateEngineBuspublishpublishAsyncononceonAnylistenerCount_clearForTestingdisposeAbortSignalLoggerBusPublishResultBusAggregateListenerError- tests unitarios
v0.1
- Integración en
aapp - Translator
sess.* -> app.user.identity.changed - Reacciones opt-in en
cach,permyconn - Translator
auth.* -> app.user.identity.changedcuando auth publique eventos propios - Mover
APP_EVENT_*asrc/libs/aapp/events.ts - Mover
SESS_EVENT_*aarts/sess, fuera dearts/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:
lastEventeventCountlistenerCountonChange
- Página
/test/buss - Devtools/debug panel
v1
waitForconditiondebouncetakeLatest- 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
prioritynumérico. - No lógica de negocio compleja en translators.
Decisión Recomendada
Implementar buss como núcleo pequeño:
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.