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.

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 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:

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.
  • 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:

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 priority en BusListenOptions.
  • Orden por registro.
  • maxListenersPerEvent default: 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: 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:

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:

  • 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:

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:

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:

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.

Powered by TurnKey Linux.