import type { DispatchContext, IgnoreReason, TransitionObserver, } from "./types"; export interface ReplayTraceEntry { readonly event: SerializedEvent; readonly outcome: | { readonly kind: "ignored"; readonly reason: IgnoreReason; stateKey: string; } | { readonly kind: "applied"; readonly stateKey: string; readonly commands: readonly unknown[]; }; } export interface ReplayTrace { readonly schemaVersion: number; readonly entries: readonly ReplayTraceEntry[]; } export interface MachineTraceEntry { readonly at: number; /** Monotonic process-local ordering for entries with the same timestamp. */ readonly sequence: number; readonly machine: string; readonly key?: string | number; readonly from: unknown; readonly event: unknown; readonly to: unknown; readonly commands: readonly unknown[]; readonly ignoredReason?: IgnoreReason; readonly messageId?: string; readonly correlationId?: string; readonly causationId?: string; } export interface TraceObserverOptions { /** Maximum entries retained for this machine/entity key. Defaults to 100. */ maxEntries?: number; describeState?: (state: State) => unknown; describeEvent?: (event: Event) => unknown; describeCommand?: (command: Command) => unknown; /** Return true for noisy events that should not be recorded. */ mute?: (event: Event) => boolean; } export interface MachineTraceDevtools { /** Machine names that have registered a trace observer. */ readonly index: readonly string[]; dump(machine?: string): readonly MachineTraceEntry[]; } const DEFAULT_MAX_ENTRIES = 100; const MAX_TOTAL_ENTRIES = 10_000; type TraceKey = string | number | undefined; const logs = new Map>(); const entriesBySequence = new Map< number, { machine: string; key: TraceKey; entry: MachineTraceEntry; } >(); const machineIndex: string[] = []; let nextSequence = 0; function defaultDescription(value: unknown): unknown { if (typeof value !== "object" || value === null) return value; if ("type" in value && typeof value.type === "string") return value.type; if ("status" in value && typeof value.status === "string") { return value.status; } return "[redacted untagged object]"; } function ensureMachine(machine: string): Map { const existing = logs.get(machine); if (existing) return existing; const rings = new Map(); logs.set(machine, rings); machineIndex.push(machine); return rings; } function compareEntries( left: MachineTraceEntry, right: MachineTraceEntry, ): number { return left.at - right.at || left.sequence - right.sequence; } function evictOldestEntry(): void { const oldest = entriesBySequence.values().next().value; if (oldest === undefined) return; entriesBySequence.delete(oldest.entry.sequence); const rings = logs.get(oldest.machine); const entries = rings?.get(oldest.key); if (entries?.[0] === oldest.entry) entries.shift(); if (entries?.length === 0) rings?.delete(oldest.key); } function record(entry: MachineTraceEntry, maxEntries: number): void { if (maxEntries === 0) return; const rings = ensureMachine(entry.machine); let entries = rings.get(entry.key); if (!entries) { entries = []; rings.set(entry.key, entries); } entries.push(entry); entriesBySequence.set(entry.sequence, { machine: entry.machine, key: entry.key, entry, }); if (entries.length > maxEntries) { const removed = entries.length - maxEntries; for (const removedEntry of entries.splice(0, removed)) { entriesBySequence.delete(removedEntry.sequence); } } while (entriesBySequence.size > MAX_TOTAL_ENTRIES) { evictOldestEntry(); } } function describeDispatchContext( dispatchContext: DispatchContext | undefined, ): Pick { if (!dispatchContext) return {}; return { messageId: dispatchContext.messageId, correlationId: dispatchContext.correlationId, causationId: dispatchContext.causationId, }; } /** * Return a snapshot of captured traces. Without a machine name, entries from * every machine are merged chronologically. * * By default, values with a string `type` or `status` discriminator are * recorded as that compact tag. Pass identity description functions when a * captured event sequence should retain enough data for deterministic replay * through a pure transition function. */ export function getTraceLog(machine?: string): readonly MachineTraceEntry[] { if (machine !== undefined) { return [...(logs.get(machine)?.values() ?? [])].flat().sort(compareEntries); } return [...logs.values()] .flatMap((rings) => [...rings.values()].flat()) .sort(compareEntries); } export function createTraceObserver< State, Event, Command, Reason extends IgnoreReason = IgnoreReason, >( machine: string, key?: string | number, options: TraceObserverOptions = {}, ): TransitionObserver { ensureMachine(machine); const maxEntries = Math.max( 0, Math.floor(options.maxEntries ?? DEFAULT_MAX_ENTRIES), ); const describeState = options.describeState ?? defaultDescription; const describeEvent = options.describeEvent ?? defaultDescription; const describeCommand = options.describeCommand ?? defaultDescription; return { onTransitionApplied: ({ previous, event, state, commands, dispatchContext, }) => { if (options.mute?.(event)) return; record( { at: Date.now(), sequence: nextSequence++, machine, key, from: describeState(previous), event: describeEvent(event), to: describeState(state), commands: commands.map(describeCommand), ...describeDispatchContext(dispatchContext), }, maxEntries, ); }, onEventIgnored: ({ state, event, reason, dispatchContext }) => { if (options.mute?.(event)) return; record( { at: Date.now(), sequence: nextSequence++, machine, key, from: describeState(state), event: describeEvent(event), to: describeState(state), commands: [], ignoredReason: reason, ...describeDispatchContext(dispatchContext), }, maxEntries, ); }, }; } export interface ReplaySerialization { readonly schemaVersion: number; serializeEvent(event: Event): SerializedEvent; deserializeEvent(event: SerializedEvent): Event; stateKey(state: State): string; describeCommand(command: Command): unknown; } /** * Creates an explicitly opt-in replay-grade recorder for development and * tests. Unlike debug traces, entries retain complete domain-serialized * events and are not exposed through production devtools. */ export function createReplayTraceObserver< State, Event, Command, SerializedEvent, Reason extends IgnoreReason = IgnoreReason, >( serialization: ReplaySerialization, ): { observer: TransitionObserver; getTrace(): ReplayTrace; } { const entries: ReplayTraceEntry[] = []; return { observer: { onTransitionApplied: ({ event, state, commands }) => { entries.push({ event: serialization.serializeEvent(event), outcome: { kind: "applied", stateKey: serialization.stateKey(state), commands: commands.map((command) => serialization.describeCommand(command), ), }, }); }, onEventIgnored: ({ state, event, reason }) => { entries.push({ event: serialization.serializeEvent(event), outcome: { kind: "ignored", reason, stateKey: serialization.stateKey(state), }, }); }, }, getTrace: () => ({ schemaVersion: serialization.schemaVersion, entries: [...entries], }), }; } declare global { interface Window { __dyadMachines?: MachineTraceDevtools; } } const isDebugBuild = process.env.NODE_ENV !== "production"; if (typeof window !== "undefined" && isDebugBuild) { window.__dyadMachines = { get index() { return [...machineIndex]; }, dump: getTraceLog, }; }