import { SnapshotStore } from "./snapshot_store"; import { validateTransitionResult } from "./transition_validation"; import type { DispatchContext, IgnoreReason, TransitionObserver, TransitionResult, } from "./types"; export type DispatcherErrorStage = | "transition" | "validation" | "before-commit" | "projection" | "subscriber" | "observer" | "outcome" | "scheduler" | "command" | "command-error-mapper"; export interface DispatcherError { readonly stage: DispatcherErrorStage; readonly error: unknown; readonly command?: Command; } export type DispatchTicketOutcome = | { readonly kind: "applied"; readonly state: State; } | { readonly kind: "ignored"; readonly state: State; readonly reason: Reason; } | { readonly kind: "failed"; readonly stage: "transition" | "validation" | "before-admission"; readonly error: unknown; } | { readonly kind: "disposed"; }; export interface DispatchTicket { readonly settled: Promise>; } interface PendingDispatch { readonly event: Event; readonly dispatchContext?: DispatchContext; readonly onSettled?: ( outcome: DispatchTicketOutcome, ) => void; readonly settle: ( outcome: DispatchTicketOutcome, ) => void; } export interface ReservedCommandBatch { readonly sequence: number; readonly commands: readonly Command[]; } export interface TransitionOutcomePublisher { (outcome: Outcome): unknown; /** * Optional atomic publication hook. Correlated registries use this to mark * every committed outcome terminal before any settlement listener reenters. */ publishBatch?(outcomes: readonly Outcome[]): unknown; } export type CommandExecutor = (command: Command) => Promise; /** * Domain policy for starting reserved command batches. The dispatcher calls * this exactly once per applied event, in event order, after notification. * The scheduler decides whether and when commands run serially or concurrently. */ export interface CommandScheduler { schedule( batch: ReservedCommandBatch, execute: CommandExecutor, ): void | Promise; } export interface TransactionalDispatcherOptions< State, Event, Command, Reason extends IgnoreReason = IgnoreReason, Outcome = never, > { initialState: State; transition( state: State, event: Event, ): TransitionResult; runCommand( command: Command, emit: (event: Event) => void, ): void | Promise; scheduler: CommandScheduler; /** * Runs after validation and reservation but immediately before commit. * This narrow hook exists for cancelling state-owned leases before the old * state exits. It must not start commands or publish application state. * * A throw is reported but does not veto commit: this effectful hook may * already have partially completed, so pretending to reject atomically * would instead drop the event against a partially modified old state. * Domains must also reject stale callbacks by operation/state-instance token. */ beforeCommit?(previous: State, next: State): void; project?(snapshot: State): void; observer?: TransitionObserver; /** * Publishes explicit transition outcomes after the authoritative snapshot * commit. Failures are isolated and cannot roll back the transition. */ publishOutcome?: TransitionOutcomePublisher; mapUnexpectedCommandError?( command: Command, error: unknown, ): Event | undefined; reportError?(error: DispatcherError): void; } /** * Policy-free event transaction runtime. * * Each admitted event runs transition and validation once, reserves a command * batch without invoking domain code, commits, projects, publishes correlated * outcomes, notifies subscribers and observers, and only then asks the injected * scheduler to start the batch. Re-entrant sends append to the same FIFO. */ export class TransactionalDispatcher< State, Event, Command, Reason extends IgnoreReason = IgnoreReason, Outcome = never, > { private readonly pendingEvents: PendingDispatch[] = []; private readonly store: SnapshotStore; private processing = false; private accepting = true; private disposed = false; private nextBatchSequence = 1; constructor( private readonly options: TransactionalDispatcherOptions< State, Event, Command, Reason, Outcome >, ) { if ( options.publishOutcome?.constructor.name === "AsyncFunction" || options.publishOutcome?.publishBatch?.constructor.name === "AsyncFunction" ) { throw new Error( "Transition outcome publisher must be synchronous and non-thenable", ); } this.store = new SnapshotStore(options.initialState); } getSnapshot = (): State => this.store.getSnapshot(); subscribe = (subscriber: () => void): (() => void) => { if (!this.accepting) return () => undefined; return this.store.subscribe(() => { try { subscriber(); } catch (error) { this.report({ stage: "subscriber", error }); } }); }; enqueue = ( event: Event, onSettled?: (outcome: DispatchTicketOutcome) => void, dispatchContext?: DispatchContext, ): DispatchTicket => { let settle!: (outcome: DispatchTicketOutcome) => void; const settled = new Promise>( (resolve) => { settle = resolve; }, ); const ticket = { settled }; if (!this.accepting) { settle({ kind: "disposed" }); return ticket; } this.pendingEvents.push({ event, dispatchContext, onSettled: onSettled as | ((outcome: DispatchTicketOutcome) => void) | undefined, settle: (outcome) => settle(outcome as DispatchTicketOutcome), }); if (this.processing) return ticket; this.processing = true; try { for ( let next = this.pendingEvents.shift(); next !== undefined; next = this.pendingEvents.shift() ) { if (!this.accepting) { next.settle({ kind: "disposed" }); break; } this.processOne(next); } } finally { this.processing = false; if (!this.accepting) this.settlePendingAsDisposed(); } return ticket; }; send = (event: Event, dispatchContext?: DispatchContext): void => { void this.enqueue(event, undefined, dispatchContext); }; /** * Starts a domain-owned finalizer batch through the same guarded executor. * This is intended for controller disposal, not normal event processing. */ startFinalizers(commands: readonly Command[]): void { if (commands.length === 0) return; this.startBatch(this.reserve(commands), this.executeFinalizer); } dispose(): void { if (this.disposed) return; this.stopAdmission(); this.disposed = true; this.store.dispose(); } /** * Stops new events and settles queued entries while preserving the current * snapshot and subscriptions for an owner's ordered disposal projection. */ stopAdmission(): void { if (!this.accepting) return; this.accepting = false; if (!this.processing) this.settlePendingAsDisposed(); } isAccepting(): boolean { return this.accepting; } isDisposed(): boolean { return this.disposed; } private processOne({ event, dispatchContext, onSettled, settle, }: PendingDispatch): void { const settleCurrent = ( outcome: DispatchTicketOutcome, ): void => { onSettled?.(outcome); settle(outcome); }; const previous = this.store.getSnapshot(); let result: TransitionResult; try { result = this.options.transition(previous, event); } catch (error) { this.report({ stage: "transition", error }); settleCurrent({ kind: "failed", stage: "transition", error }); return; } try { validateTransitionResult(previous, event, result); } catch (error) { this.report({ stage: "validation", error }); settleCurrent({ kind: "failed", stage: "validation", error }); return; } if (result.kind === "ignored") { this.notifyObserver(previous, event, result, dispatchContext); settleCurrent({ kind: "ignored", state: this.store.getSnapshot(), reason: result.reason, }); return; } // Reservation is deliberately data-only: no scheduler or adapter code. const batch = this.reserve(result.commands); const outcomes = Object.freeze([...(result.outcomes ?? [])]); try { this.options.beforeCommit?.(previous, result.state); } catch (error) { this.report({ stage: "before-commit", error }); } if (!this.accepting) { settleCurrent({ kind: "disposed" }); return; } // Linearization point. Every callback below reads this committed snapshot. if (result.state === previous) { this.project(result.state); this.publishOutcomes(outcomes); } else { this.store.setState(result.state, () => { this.project(result.state); // SetState has committed before this callback and has not notified // subscribers yet. Authoritative settlement therefore wins over // teardown reentered by snapshot/observer callbacks. this.publishOutcomes(outcomes); }); } this.notifyObserver(previous, event, result, dispatchContext); if (this.accepting) this.startBatch(batch); settleCurrent({ kind: "applied", state: this.store.getSnapshot() }); } private reserve(commands: readonly Command[]): ReservedCommandBatch { return Object.freeze({ sequence: this.nextBatchSequence++, commands: Object.freeze([...commands]), }); } private startBatch( batch: ReservedCommandBatch, execute: CommandExecutor = this.executeCommand, ): void { try { const scheduled = this.options.scheduler.schedule(batch, execute); void Promise.resolve(scheduled).catch((error) => { this.report({ stage: "scheduler", error }); }); } catch (error) { this.report({ stage: "scheduler", error }); } } private executeCommand: CommandExecutor = async (command) => { if (!this.accepting) return; await this.runGuardedCommand(command, this.send, true); }; private executeFinalizer: CommandExecutor = async (command) => { await this.runGuardedCommand(command, () => undefined, false); }; private async runGuardedCommand( command: Command, emit: (event: Event) => void, mapUnexpectedError: boolean, ): Promise { try { await this.options.runCommand(command, emit); } catch (error) { this.report({ stage: "command", error, command }); if (!mapUnexpectedError || !this.options.mapUnexpectedCommandError) { return; } try { const event = this.options.mapUnexpectedCommandError(command, error); if (event !== undefined) this.send(event); } catch (mappingError) { this.report({ stage: "command-error-mapper", error: mappingError, command, }); } } } private notifyObserver( previous: State, event: Event, result: TransitionResult, dispatchContext?: DispatchContext, ): void { try { if (result.kind === "ignored") { this.options.observer?.onEventIgnored?.({ state: this.store.getSnapshot(), event, reason: result.reason, ...(dispatchContext ? { dispatchContext } : {}), }); } else { this.options.observer?.onTransitionApplied?.({ previous, event, state: this.store.getSnapshot(), commands: result.commands, ...(dispatchContext ? { dispatchContext } : {}), }); } } catch (error) { this.report({ stage: "observer", error }); } } private publishOutcomes(outcomes: readonly Outcome[]): void { if (!this.options.publishOutcome) return; if (this.options.publishOutcome.publishBatch) { try { this.rejectThenableOutcomePublication( this.options.publishOutcome.publishBatch(outcomes), ); } catch (error) { this.report({ stage: "outcome", error }); } return; } for (const outcome of outcomes) { try { this.rejectThenableOutcomePublication( this.options.publishOutcome(outcome), ); } catch (error) { this.report({ stage: "outcome", error }); } } } private rejectThenableOutcomePublication(published: unknown): void { if ( ((typeof published === "object" && published !== null) || typeof published === "function") && "then" in published ) { void Promise.resolve(published).catch((error) => { this.report({ stage: "outcome", error }); }); this.report({ stage: "outcome", error: new Error( "Transition outcome publisher must be synchronous and non-thenable", ), }); } } private project(snapshot: State): void { if (!this.options.project) return; try { this.options.project(snapshot); } catch (error) { this.report({ stage: "projection", error }); } } private report(error: DispatcherError): void { if (!this.options.reportError) { console.error( `State-machine dispatcher ${error.stage} failure:`, error.error, ); return; } try { this.options.reportError(error); } catch { // Error reporting must never become another queue failure. } } private settlePendingAsDisposed(): void { for ( let pending = this.pendingEvents.shift(); pending !== undefined; pending = this.pendingEvents.shift() ) { pending.settle({ kind: "disposed" }); } } }