@ -40,7 +40,7 @@ The framework distinguishes two layers of events that share a single
- **Module events** (`SESS_EVENT_*`, `AUTH_EVENT_*` , `CACH_EVENT_*` , …)
— internal facts emitted by the artifact that owns them. They can
iterate; they are not part of the public contract.
- **App events** (`APP_EVENT_*`) — the **stable public contract** .
- **App events** (`AA PP_EVENT_*`) — the **stable public contract** .
Universal facts that consumers and external plugins listen to.
Renaming or removing one is a breaking change.
@ -55,7 +55,7 @@ The model is **Domain Events + Integration Events** with an
| Active term | DDD term |
| ------------------------------ | ----------------------------------- |
| Module events (`SESS_EVENT_*`, …) | Domain events (private, iterable) |
| App events (`APP_EVENT_*`) | Integration events (public, stable) |
| App events (`AA PP_EVENT_*`) | Integration events (public, stable) |
| `aapp/integrations/*-translator.ts` | Anti-corruption layer + event mapper |
| Per-consumer auto-reactions | Stateless process managers |
@ -81,8 +81,8 @@ arts/buss/svelte/ ← Svelte adapter
arts/buss/active-bus.svelte.ts ← reactive wrappers (minimal surface)
createBusRecent() { lastEvent, count, clear, dispose }
libs/aapp/events.ts ← APP_EVENT_* constants + payloads
+ APP_EVENT_RUNTIMES metadata
libs/aapp/events.ts ← AA PP_EVENT_* constants + payloads
+ AA PP_EVENT_RUNTIMES metadata
libs/aapp/bus-context.svelte.ts ← getBus / setBus via createContext
@ -244,7 +244,7 @@ export type BusAnyListener<TEvents extends BusEventMap> = (
The convention remains conservative: `onAny` is for diagnostics,
devtools, event capture and tests. Business side effects should subscribe
to the exact `A PP_EVENT_*` they need.
to the exact `A A PP_EVENT_*` they need.
### Re-entrancy
@ -275,34 +275,60 @@ Each module owns and emits its own facts. Constants live in the module's
```ts
// arts/sess/consts.ts
export const SESS_EVENT_CHANGED = 'sess.changed';
export const SESS_EVENT_IDENTITY_CHANGED = 'sess.identity.changed';
export const SESS_EVENT_REVOKED = 'sess.revoked';
export const SESS_EVENT_EXPIRED = 'sess.expired';
export const SESS_EVENT_REFRESHED = 'sess.refreshed';
// arts/sess/types.ts
export interface SessIdentityChangedPayload {
readonly previousActorId: string | null;
readonly nextActorId: string | null;
export interface SessLifecyclePayload {
readonly event: SessionEvent;
readonly generation: number;
readonly identity: {
readonly from: SessionIdentityState;
readonly to: SessionIdentityState;
};
}
// arts/sess/bus-helpers.ts
import type { BusEnvelope, BusSubscription, EngineBus, EventPublisher }
from '$buss';
export function publishSessIdentityChanged(
bus: EventPublisher,
payload: SessIdentityChangedPayload
) {
return bus.publish(SESS_EVENT_IDENTITY_CHANGED, payload);
import type { BusPublishOptions, EventPublisher } from '$libs/buss';
// One publisher fans out to every event the lifecycle implies.
// `sess.changed` is always emitted; `sess.identity.changed` only when
// identity actually transitions; `sess.revoked` / `sess.expired` /
// `sess.refreshed` only for the matching lifecycle event.
export function publishSessLifecycleEvent(
bus: SessEventPublisher,
payload: SessLifecyclePayload,
options: BusPublishOptions = {}
): void {
if (payload.event === SESS_EVENT_LIFECYCLE_INITIAL) return;
const opts = { source: SESS_MODULE, ...options };
bus.publish(SESS_EVENT_CHANGED, payload, opts);
if (payload.identity.from !== payload.identity.to) {
bus.publish(SESS_EVENT_IDENTITY_CHANGED, payload, opts);
}
if (payload.event === SESS_EVENT_LIFECYCLE_REVOKED) {
bus.publish(SESS_EVENT_REVOKED, payload, opts);
}
if (payload.event === SESS_EVENT_LIFECYCLE_EXPIRED) {
bus.publish(SESS_EVENT_EXPIRED, payload, opts);
}
if (payload.event === SESS_EVENT_LIFECYCLE_REFRESHED) {
bus.publish(SESS_EVENT_REFRESHED, payload, opts);
}
}
export function onSessIdentityChanged(
bus: EngineBus,
listener: (
e: BusEnvelope< typeof SESS_EVENT_IDENTITY_CHANGED , SessIdentityChangedPayload >
) => void
// Subscriber: one event, one listener. `onSessChanged` covers every
// lifecycle transition; subscribe to `SESS_EVENT_IDENTITY_CHANGED` /
// `SESS_EVENT_REVOKED` / etc directly when you only care about a slice.
export function onSessChanged(
bus: EngineBus< SessEventMap > ,
listener: (event: SessChangedEnvelope) => void | Promise< void >
): BusSubscription {
return bus.on(SESS_EVENT_IDENTITY_CHANGED, listener);
return bus.on(SESS_EVENT_CHANGED, (event) => listener(event as SessChangedEnvelope) );
}
```
@ -316,15 +342,15 @@ payloads — never `any`. No credentials.**
```ts
// libs/aapp/events.ts
export const APP_EVENT_USER_IDENTITY_CHANGED = 'app.user.identity.changed';
export const APP_EVENT_TENANT_SWITCHED = 'app.tenant.switched';
export const APP_EVENT_PERMISSIONS_REFRESH_REQUESTED = 'app.permissions.refresh.requested';
export const APP_EVENT_CONNECTIVITY_CHANGED = 'app.connectivity.changed';
export const APP_EVENT_CACHE_INVALIDATE_REQUESTED = 'app.cache.invalidate.requested';
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
export const AA PP_EVENT_USER_IDENTITY_CHANGED = 'a app.user.identity.changed';
export const AA PP_EVENT_TENANT_SWITCHED = 'a app.tenant.switched';
export const AA PP_EVENT_PERMISSIONS_REFRESH_REQUESTED = 'a app.permissions.refresh.requested';
export const AA PP_EVENT_CONNECTIVITY_CHANGED = 'a app.connectivity.changed';
export const AA PP_EVENT_CACHE_INVALIDATE_REQUESTED = 'a app.cache.invalidate.requested';
export const AA PP_EVENT_DISPOSE_STARTING = 'a app.dispose.starting';
```
Renaming any `A PP_EVENT_*`, removing it, or changing its payload shape
Renaming any `A A PP_EVENT_*`, removing it, or changing its payload shape
non-additively is a **major bump** . Adding new app events is a minor.
## App.Bus is always-present per render scope
@ -415,11 +441,11 @@ function directly) inside `$effect`:
```svelte
< script lang = "ts" >
import { APP_EVENT_USER_IDENTITY_CHANGED } from '$libs/aapp/events';
import { AA PP_EVENT_USER_IDENTITY_CHANGED } from '$libs/aapp/events';
const Bus = getBus();
$effect(() =>
Bus.subscribe(APP_EVENT_USER_IDENTITY_CHANGED, (event) => {
Bus.subscribe(AA PP_EVENT_USER_IDENTITY_CHANGED, (event) => {
// react
})
);
@ -473,7 +499,7 @@ create transitive dependencies. Callers wrap reactive state with
`$state.snapshot(...)` before publish:
```ts
publishSessIdentityChanged (Bus, $state.snapshot(payload));
publishSessLifecycleEvent (Bus, $state.snapshot(payload));
```
### Rule 6 — Bus per render scope, disposed on tear-down
@ -490,23 +516,25 @@ component's lifecycle.
### Rule 7 — Server / client / both-runtime events
Each `A PP_EVENT_*` declares where it is allowed to fire:
Each `A A PP_EVENT_*` declares where it is allowed to fire:
```ts
// libs/aapp/events.ts
export const APP_EVENT_RUNTIMES = {
[APP_EVENT_USER_IDENTITY_CHANGED]: 'both',
[APP_EVENT_TENANT_SWITCHED]: 'both',
[APP_EVENT_PERMISSIONS_REFRESH_REQUESTED]: 'both',
[APP_EVENT_CONNECTIVITY_CHANGED]: 'client',
[APP_EVENT_CACHE_INVALIDATE_REQUESTED]: 'both',
[APP_EVENT_DISPOSE_STARTING]: 'both'
} as const;
export type AppEventRuntime = 'both' | 'client';
export const AAPP_EVENT_RUNTIMES: Readonly< Record < string , AppEventRuntime > > = {
[AAPP_EVENT_USER_IDENTITY_CHANGED]: AAPP_EVENT_RUNTIME_BOTH,
[AAPP_EVENT_TENANT_SWITCHED]: AAPP_EVENT_RUNTIME_BOTH,
[AAPP_EVENT_PERMISSIONS_REFRESH_REQUESTED]: AAPP_EVENT_RUNTIME_BOTH,
[AAPP_EVENT_CONNECTIVITY_CHANGED]: AAPP_EVENT_RUNTIME_CLIENT,
[AAPP_EVENT_CACHE_INVALIDATE_REQUESTED]: AAPP_EVENT_RUNTIME_BOTH,
[AAPP_EVENT_DISPOSE_STARTING]: AAPP_EVENT_RUNTIME_BOTH
};
export type AppEventRuntime =
| typeof AAPP_EVENT_RUNTIME_BOTH
| typeof AAPP_EVENT_RUNTIME_CLIENT;
export function assertEventCanFire(type: string, where: 'server' | 'client'): void {
const runtime = APP_EVENT_RUNTIMES[type];
const runtime = AA PP_EVENT_RUNTIMES[type];
if (runtime & & runtime !== 'both' & & runtime !== where) {
throw new AappInvalidEventRuntimeError(type, runtime, where);
}
@ -544,14 +572,41 @@ execute domain logic.
```ts
// arts/aapp/integrations/session-translator.ts
import {
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED,
AAPP_USER_IDENTITY_CAUSE_SESSION_ADOPTED,
AAPP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER,
AAPP_USER_IDENTITY_CAUSE_SESSION_EXPIRED,
AAPP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED,
AAPP_USER_IDENTITY_CAUSE_SESSION_REFRESHED,
AAPP_USER_IDENTITY_CAUSE_SESSION_REVOKED,
publishAppUserIdentityChanged
} from '$libs/aapp/events';
import { SESS_EVENT_CHANGED } from '$sess/consts';
import {
SESS_EVENT_CHANGED,
SESS_EVENT_LIFECYCLE_ADOPTED,
SESS_EVENT_LIFECYCLE_ADOPTED_SERVER,
SESS_EVENT_LIFECYCLE_EXPIRED,
SESS_EVENT_LIFECYCLE_EXTERNAL_CHANGED,
SESS_EVENT_LIFECYCLE_REFRESHED,
SESS_EVENT_LIFECYCLE_REVOKED
} from '$sess/consts';
import type { EngineBus } from '$libs/buss';
// Each lifecycle event maps to its own app-level cause. Hardcoding a
// single cause would mislabel five out of six transitions.
function resolveAppIdentityCause(event) {
if (event === SESS_EVENT_LIFECYCLE_ADOPTED) return AAPP_USER_IDENTITY_CAUSE_SESSION_ADOPTED;
if (event === SESS_EVENT_LIFECYCLE_ADOPTED_SERVER) return AAPP_USER_IDENTITY_CAUSE_SESSION_ADOPTED_SERVER;
if (event === SESS_EVENT_LIFECYCLE_REFRESHED) return AAPP_USER_IDENTITY_CAUSE_SESSION_REFRESHED;
if (event === SESS_EVENT_LIFECYCLE_REVOKED) return AAPP_USER_IDENTITY_CAUSE_SESSION_REVOKED;
if (event === SESS_EVENT_LIFECYCLE_EXPIRED) return AAPP_USER_IDENTITY_CAUSE_SESSION_EXPIRED;
if (event === SESS_EVENT_LIFECYCLE_EXTERNAL_CHANGED) return AAPP_USER_IDENTITY_CAUSE_SESSION_EXTERNAL_CHANGED;
return undefined;
}
export function wireSessionTranslator(bus: EngineBus): () => void {
const sub = bus.on(SESS_EVENT_CHANGED, (event) => {
const cause = resolveAppIdentityCause(event.payload.event);
if (cause === undefined) return;
publishAppUserIdentityChanged(bus, {
event: event.payload.event,
generation: event.payload.generation,
@ -559,9 +614,9 @@ export function wireSessionTranslator(bus: EngineBus): () => void {
from: event.payload.identity.from,
to: event.payload.identity.to
},
cause: APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED
cause
}, {
source: 'app'
source: AAPP_MODULE
});
});
return () => sub.unsubscribe();
@ -608,11 +663,11 @@ Translators wired by `'standard'`:
| Translator | Source events | Publishes |
| --------------- | -------------------------------------------------------------------------- | ------------------------------------ |
| `identity` | `SESS_EVENT_CHANGED` from `App.createActiveSession(...)` | `A PP_EVENT_USER_IDENTITY_CHANGED` |
| `dispose` | `App.dispose()` entry | `A PP_EVENT_DISPOSE_STARTING` |
| `tenant-switched` | typed public contract / explicit publish point | `A PP_EVENT_TENANT_SWITCHED` |
| `permissions-refresh` | typed public contract / explicit publish point | `A PP_EVENT_PERMISSIONS_REFRESH_REQUESTED` |
| `connectivity` | typed public contract / explicit publish point | `A PP_EVENT_CONNECTIVITY_CHANGED` |
| `identity` | `SESS_EVENT_CHANGED` from `App.createActiveSession(...)` | `A A PP_EVENT_USER_IDENTITY_CHANGED` |
| `dispose` | `App.dispose()` entry | `A A PP_EVENT_DISPOSE_STARTING` |
| `tenant-switched` | typed public contract / explicit publish point | `A A PP_EVENT_TENANT_SWITCHED` |
| `permissions-refresh` | typed public contract / explicit publish point | `A A PP_EVENT_PERMISSIONS_REFRESH_REQUESTED` |
| `connectivity` | typed public contract / explicit publish point | `A A PP_EVENT_CONNECTIVITY_CHANGED` |
### Per consumer: which app events auto-trigger side-effects
@ -648,10 +703,10 @@ Consumers (`cach`, `perm`, `conn`, third-party plugins) listen **only**
to `app.*` events.
```ts
import { APP_EVENT_USER_IDENTITY_CHANGED } from '$libs/aapp/events';
import { AA PP_EVENT_USER_IDENTITY_CHANGED } from '$libs/aapp/events';
$effect(() =>
App.Bus.subscribe(APP_EVENT_USER_IDENTITY_CHANGED, () => {
App.Bus.subscribe(AA PP_EVENT_USER_IDENTITY_CHANGED, () => {
Cache.invalidate();
})
);
@ -699,12 +754,12 @@ Start small. Six facts cover the universal needs:
| Constant | Meaning |
| ------------------------------------- | ----------------------------------------------------------- |
| `A PP_EVENT_USER_IDENTITY_CHANGED` | Sign-in, sign-out, refresh, impersonation, … |
| `A PP_EVENT_TENANT_SWITCHED` | Multi-tenant switch |
| `A PP_EVENT_PERMISSIONS_REFRESH_REQUESTED` | Policies changed, consumers may drop permission caches |
| `A PP_EVENT_CONNECTIVITY_CHANGED` | Online ↔ offline (client-only) |
| `A PP_EVENT_CACHE_INVALIDATE_REQUESTED` | Broad invalidation requested |
| `A PP_EVENT_DISPOSE_STARTING` | App.dispose() entered, last chance to flush |
| `A A PP_EVENT_USER_IDENTITY_CHANGED` | Sign-in, sign-out, refresh, impersonation, … |
| `A A PP_EVENT_TENANT_SWITCHED` | Multi-tenant switch |
| `A A PP_EVENT_PERMISSIONS_REFRESH_REQUESTED` | Policies changed, consumers may drop permission caches |
| `A A PP_EVENT_CONNECTIVITY_CHANGED` | Online ↔ offline (client-only) |
| `A A PP_EVENT_CACHE_INVALIDATE_REQUESTED` | Broad invalidation requested |
| `A A PP_EVENT_DISPOSE_STARTING` | App.dispose() entered, last chance to flush |
Grow this list deliberately. A bus with 50 app events is the same
god-object problem at a different layer.
@ -727,7 +782,7 @@ Three protections, applied together:
part such as `token` , `secret` , `password` , `hash` , `authorization` ,
`cookie` , `credential` , `csrf` or `header` .
3. **Documented invariant.** Every `A PP_EVENT_*` declaration carries the
3. **Documented invariant.** Every `A A PP_EVENT_*` declaration carries the
comment: _"App events never contain credentials. To pass sensitive
data, use `correlationId` and let the consumer resolve it against
`App.Sess` directly."_
@ -798,7 +853,7 @@ Frozen for `0.1.x`:
`BusListenerErrorMode` , `BusAnyListener` , `BusEventUnion` .
- Generic `BUSS_*` constants (modes, defaults, `BUSS_EVENT_ALL` ,
`BusInvalidPayloadError` , `BusReentrancyLimitError` ).
- All `A PP_EVENT_*` constants and their payload types.
- All `A A PP_EVENT_*` constants and their payload types.
- Sync-vs-async semantics, listener order, error handling contract.
- Re-entrancy semantics (DFS + maxReentrancyDepth).
@ -858,7 +913,7 @@ Out of scope for the current cut:
## Naming convention reminder
- `BUSS_*` — generic bus constants (live in `arts/buss` ).
- `A PP_EVENT_*` — public app event names (live in `libs/aapp/events.ts` ).
- `A A PP_EVENT_*` — public app event names (live in `libs/aapp/events.ts` ).
- `<MOD>_EVENT_*` (`SESS_EVENT_*`, `AUTH_EVENT_*` , `CACH_EVENT_*` ,
`PERM_EVENT_*` , `CONN_EVENT_*` ) — module event names, live in their
owning artifact's `consts.ts` .
@ -870,7 +925,7 @@ Out of scope for the current cut:
- `arts/buss` does not import other artifacts.
- `arts/buss` does not export domain event constants. Domain events
belong to their owning artifact; `A PP_EVENT_*` belongs to `libs/aapp` .
belong to their owning artifact; `A A PP_EVENT_*` belongs to `libs/aapp` .
- Events describe what happened; they never command another module to
do something.
- Automatic side-effects are opt-in per consumer.