refactor(engine): unify commands and orchestrators

Replace the CommandHost with a handler registry keyed by message type.
Message is a permissive base type the host extends; CommandResult is a
discriminated union on the type suffix. Commands and orchestrators are
the same async shape taking a RunContext with an AbortSignal; runCommand
emits :done/:cancel/:error.
This commit is contained in:
2026-08-10 18:40:35 +08:00
parent 81c115cb4d
commit 8bd186df54
6 changed files with 255 additions and 99 deletions
+70 -34
View File
@@ -1,40 +1,21 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Engine, type CommandHost, type Message } from './engine.js'; import { Engine, type Message } from './engine.js';
/** A minimal command host that runs a `move` and emits `move:done`. */
function moveHost(): CommandHost & { moves: Message[] } {
const handlers = new Map<string, (msg: Message) => void>();
const moves: Message[] = [];
return {
moves,
on(type, handler) {
handlers.set(type, handler);
return () => handlers.delete(type);
},
dispatch(msg) {
if (msg.type === 'move') {
moves.push(msg);
// The command's completion is itself a message.
this.dispatch({ type: 'move:done', data: msg.data });
} else if (msg.type === 'move:done') {
handlers.get('move:done')?.(msg);
}
},
};
}
describe('Engine', () => { describe('Engine', () => {
it('dispatches a message to the command host on tick', () => { it('dispatches a message to handlers registered for its type', () => {
const host = moveHost(); const engine = new Engine();
const engine = new Engine(host); const seen: Message[] = [];
engine.on('move', (m) => seen.push(m));
engine.enqueue({ type: 'move', data: { part: 'a', to: '/grid/5/5' } }); engine.enqueue({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
engine.tick(); engine.tick();
expect(host.moves).toHaveLength(1); expect(seen).toHaveLength(1);
expect(seen[0]!.data).toEqual({ part: 'a', to: '/grid/5/5' });
}); });
it('a trigger reacts to a message and emits on the next tick', () => { it('a trigger reacts to a message and emits on the next tick', () => {
const host = moveHost(); const engine = new Engine();
const engine = new Engine(host); const seen: Message[] = [];
engine.on('move', (m) => seen.push(m));
engine.registerTrigger({ engine.registerTrigger({
type: 'tap', type: 'tap',
id: 'draw', id: 'draw',
@@ -44,14 +25,48 @@ describe('Engine', () => {
engine.enqueue({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } }); engine.enqueue({ type: 'tap', data: { part: 'carcassonne:tile#a', trigger: 'draw' } });
engine.tick(); // tap processed; move emitted to the next tick engine.tick(); // tap processed; move emitted to the next tick
expect(host.moves).toHaveLength(0); expect(seen).toHaveLength(0);
engine.tick(); // move runs engine.tick(); // move runs
expect(host.moves).toHaveLength(1); expect(seen).toHaveLength(1);
});
it('runCommand runs the command and emits its result message', async () => {
const engine = new Engine();
const results: Message[] = [];
engine.on('move:done', (m) => results.push(m));
engine.on('move:error', (m) => results.push(m));
engine.runCommand('move', async ({ args }) => {
expect(args).toEqual({ part: 'a', to: '/grid/5/5' });
return 'moved';
});
engine.enqueue({ type: 'move', data: { part: 'a', to: '/grid/5/5' } });
engine.tick(); // command runs; result emitted to the next tick
await Promise.resolve(); // flush the command's async resolution
engine.tick(); // result processed
expect(results).toEqual([{ type: 'move:done', data: 'moved' }]);
});
it('runCommand emits :error when the command throws', async () => {
const engine = new Engine();
const results: Message[] = [];
engine.on('move:error', (m) => results.push(m));
engine.runCommand('move', async () => {
throw new Error('bad path');
});
engine.enqueue({ type: 'move', data: {} });
engine.tick();
await Promise.resolve();
await Promise.resolve(); // rejection hops through .then before .catch
engine.tick();
expect(results).toEqual([{ type: 'move:error', error: new Error('bad path') }]);
}); });
it('an orchestrator awaits a matching message and resumes on tick', async () => { it('an orchestrator awaits a matching message and resumes on tick', async () => {
const host = moveHost(); const engine = new Engine();
const engine = new Engine(host);
const log: string[] = []; const log: string[] = [];
const done = engine.runOrchestrator(async (ctx) => { const done = engine.runOrchestrator(async (ctx) => {
@@ -71,6 +86,27 @@ describe('Engine', () => {
expect(log).toEqual(['start', 'tap:carcassonne:tile#a']); expect(log).toEqual(['start', 'tap:carcassonne:tile#a']);
// The move emitted by the orchestrator runs on the next tick. // The move emitted by the orchestrator runs on the next tick.
engine.tick(); engine.tick();
expect(host.moves).toHaveLength(1); });
it('an orchestrator wait rejects when its signal aborts', async () => {
const engine = new Engine();
const controller = new AbortController();
const log: string[] = [];
const done = engine.runOrchestrator(
async (ctx) => {
try {
await ctx.wait((m) => m.type === 'tap');
log.push('resolved');
} catch {
log.push('aborted');
}
},
controller.signal,
);
controller.abort();
await done;
expect(log).toEqual(['aborted']);
}); });
}); });
+86 -34
View File
@@ -1,53 +1,104 @@
/** /**
* The engine: the message bus that ties the queue, triggers, orchestrators, * The engine: the message bus that ties the queue, triggers, and handlers
* and the command host together. * together.
* *
* The engine is pure — no r3f, no React, no store. It defines the contract; * The engine is pure — no r3f, no React, no store. It defines the contract;
* `@tts/tabletop` implements the `CommandHost` with the built-in commands that * `@tts/tabletop` registers the built-in commands (`move`, `focus`, `caption`,
* mutate the tabletop store and drive the render layer. The engine never * ...) that mutate the tabletop store and drive the render layer. The engine
* imports tabletop. * never imports tabletop.
*/ */
import { MessageQueue, type Message } from './message.js'; import { MessageQueue, type CommandResult, type Message, type MessageHandler } from './message.js';
import { TriggerRegistry, type Trigger } from './trigger.js'; import { TriggerRegistry, type Trigger } from './trigger.js';
import { runOrchestrator, type Orchestrator } from './orchestrator.js'; import { runOrchestrator } from './orchestrator.js';
import { type Command, type Orchestrator, type RunContext } from './run.js';
/** /**
* The command host: how a command handler registers with the bus. `@tts/tabletop` * The engine. `enqueue` adds a message; `tick` drains the queue and dispatches
* implements this with the built-in commands (`move`, `focus`, `caption`, ...). * each message to the handlers registered for its `type`, then to matching
* The engine dispatches each message to the host's registered handler for its * triggers. Orchestrators run against the same queue and suspend on `wait`
* type; the host runs the command (the single mutation path) and emits the * until a matching message is processed.
* `type:done` result message.
*/
export interface CommandHost {
/** Register a handler for a message type. Returns an unsubscribe. */
on(type: string, handler: (msg: Message) => void): () => void;
/** Dispatch a message to the registered handler for its type, if any. */
dispatch(msg: Message): void;
}
/**
* The engine. `enqueue` adds a message; `tick` drains the queue and processes
* it through the command host and triggers. Orchestrators run against the same
* queue and suspend on `wait` until a matching message is processed.
*/ */
export class Engine { export class Engine {
private queue = new MessageQueue(); private queue = new MessageQueue();
private triggers = new TriggerRegistry(); private triggers = new TriggerRegistry();
private host: CommandHost; private handlers = new Map<string, MessageHandler[]>();
constructor(host: CommandHost) { constructor() {
this.host = host; // Every message goes to the handlers for its type (they run commands), then
// Every message goes to the command host first (it runs the command), then // to triggers (they react to the message, including the `:done` a command
// to triggers (they react to the message, including the `type:done` the // emits). Emissions from either land on the next tick.
// host emits). Emissions from either land on the next tick.
this.queue.on((msg) => { this.queue.on((msg) => {
this.host.dispatch(msg); for (const handler of this.handlers.get(msg.type) ?? []) handler(msg);
for (const t of this.triggers.match(msg)) { for (const t of this.triggers.match(msg)) {
for (const emit of t.emit) this.queue.enqueue(emit); for (const emit of t.emit) this.queue.enqueue(emit);
} }
}); });
} }
/** Register a handler for a message type. Returns an unsubscribe. */
on(type: string, handler: MessageHandler): () => void {
const list = this.handlers.get(type) ?? [];
list.push(handler);
this.handlers.set(type, list);
return () => {
const cur = this.handlers.get(type);
if (!cur) return;
const next = cur.filter((h) => h !== handler);
if (next.length) this.handlers.set(type, next);
else this.handlers.delete(type);
};
}
/**
* Register a command. The engine builds a `RunContext` from the message,
* runs the command, and emits its result — `:done` on resolve, `:cancel` on
* abort, `:error` on throw.
*/
runCommand<Name extends string, Args, Result>(
type: Name,
command: Command<Args, Result>,
): () => void {
return this.on(type, (msg) => {
const controller = new AbortController();
const ctx: RunContext = {
signal: controller.signal,
emit: (m) => this.queue.enqueue(m),
wait: (pred) =>
new Promise<Message>((resolve, reject) => {
if (controller.signal.aborted) {
reject(new Error('aborted'));
return;
}
const off = this.on('*', (m) => {
if (pred(m)) {
off();
resolve(m);
}
});
controller.signal.addEventListener('abort', () => {
off();
reject(new Error('aborted'));
}, { once: true });
}),
enableTrigger: (type, id) => this.triggers.enable(type, id),
disableTrigger: (type, id) => this.triggers.disable(type, id),
};
const args = msg.data as Args;
command({ ...ctx, args })
.then((result) => this.queue.enqueue({ type: `${type}:done`, data: result } as CommandResult<Name, Result>))
.catch((err: unknown) => {
if (controller.signal.aborted) {
this.queue.enqueue({ type: `${type}:cancel` } as CommandResult<Name, Result>);
} else {
this.queue.enqueue({
type: `${type}:error`,
error: err instanceof Error ? err : new Error(String(err)),
} as CommandResult<Name, Result>);
}
});
});
}
enqueue(msg: Message): void { enqueue(msg: Message): void {
this.queue.enqueue(msg); this.queue.enqueue(msg);
} }
@@ -74,19 +125,20 @@ export class Engine {
} }
/** Run an orchestrator against this engine's queue. */ /** Run an orchestrator against this engine's queue. */
runOrchestrator(o: Orchestrator): Promise<void> { runOrchestrator(o: Orchestrator, signal?: AbortSignal): Promise<void> {
return runOrchestrator( return runOrchestrator(
o, o,
(msg) => this.queue.enqueue(msg), (msg) => this.queue.enqueue(msg),
(handler) => this.queue.on(handler), (handler) => this.queue.on(handler),
(type, id) => this.triggers.enable(type, id), (type, id) => this.triggers.enable(type, id),
(type, id) => this.triggers.disable(type, id), (type, id) => this.triggers.disable(type, id),
signal,
); );
} }
} }
export type { Message, MessageHandler } from './message.js'; export type { Message, MessageHandler, CommandMessage, CommandResult } from './message.js';
export type { Trigger } from './trigger.js'; export type { Trigger } from './trigger.js';
export { triggerMatches, TriggerRegistry } from './trigger.js'; export { triggerMatches, TriggerRegistry } from './trigger.js';
export type { Orchestrator, OrchestratorContext } from './orchestrator.js'; export type { Orchestrator, RunContext, Command } from './run.js';
export { runOrchestrator } from './orchestrator.js'; export { runOrchestrator } from './orchestrator.js';
+10 -3
View File
@@ -1,4 +1,11 @@
export { MessageQueue, type Message, type MessageHandler } from './message.js'; export {
MessageQueue,
type Message,
type MessageHandler,
type CommandMessage,
type CommandResult,
} from './message.js';
export { TriggerRegistry, triggerMatches, type Trigger } from './trigger.js'; export { TriggerRegistry, triggerMatches, type Trigger } from './trigger.js';
export { runOrchestrator, type Orchestrator, type OrchestratorContext } from './orchestrator.js'; export { runOrchestrator } from './orchestrator.js';
export { Engine, type CommandHost } from './engine.js'; export type { Orchestrator, RunContext, Command } from './run.js';
export { Engine } from './engine.js';
+31 -6
View File
@@ -2,10 +2,13 @@
* The message model and the queue that serializes it. * The message model and the queue that serializes it.
* *
* A message is both an event (something happened) and an intent (something * A message is both an event (something happened) and an intent (something
* should happen). It is identified by `type` (and optionally `id`), matching * should happen). It is identified by `type`, matching the format's `type#id`
* the format's `type#id` convention. A `move` message both runs the move * convention. A `move` message both runs the move command and is observable as
* command and is observable as an event; the command's completion is itself a * an event; the command's completion is itself a message (`move:done`), which
* message (`move:done`), which is what triggers match and orchestrators await. * is what triggers match and orchestrators await.
*
* Messages are a discriminated union on `type`. The engine defines the generic
* shapes; the host's concrete union extends them with its own command types.
* *
* Messages are not processed inline. They are enqueued and handled on the next * Messages are not processed inline. They are enqueued and handled on the next
* `tick()`. This kills reentrancy (a handler cannot cause unbounded * `tick()`. This kills reentrancy (a handler cannot cause unbounded
@@ -13,12 +16,34 @@
* deterministic frame. The engine is pure — it has no render loop — so the * deterministic frame. The engine is pure — it has no render loop — so the
* host calls `tick()` (a `useFrame` in `@tts/tabletop`, manually in tests). * host calls `tick()` (a `useFrame` in `@tts/tabletop`, manually in tests).
*/ */
/** A command message names a command to run. Its handler is the command. */
export interface CommandMessage<Name extends string, Args> {
type: Name;
data: Args;
}
/**
* A command's result, discriminated on the type suffix. `:done` on resolve,
* `:cancel` on abort (superseded, skipped, surface disabled), `:error` on
* throw. A trigger matching `move:done` does not fire on a cancel.
*/
export type CommandResult<Name extends string, R = void> =
| { type: `${Name}:done`; data: R }
| { type: `${Name}:cancel` }
| { type: `${Name}:error`; error: Error };
/**
* The base message type. The engine is host-agnostic, so this is a permissive
* structural type; the host defines a concrete discriminated union on `type`
* that extends it with its own command and interaction messages.
*/
export interface Message { export interface Message {
type: string; type: string;
/** Command-specific payload. */ /** Command-specific payload. */
data?: unknown; data?: unknown;
/** Optional identity, for matching and dedup. */ /** Present on `:error` result messages. */
id?: string; error?: Error;
} }
/** A handler consumes a message and may emit new ones. */ /** A handler consumes a message and may emit new ones. */
+28 -22
View File
@@ -3,7 +3,8 @@
* *
* An orchestrator is the code counterpart to a trigger: an async function that * An orchestrator is the code counterpart to a trigger: an async function that
* emits messages and awaits matching ones. It is a proper TS module, declared * emits messages and awaits matching ones. It is a proper TS module, declared
* per folder as `main.ts`, unique per folder like `package.yaml`. * per folder as `main.ts`, unique per folder like `package.yaml`, exported as a
* default async function.
* *
* An orchestrator is a long-running command: it awaits events instead of * An orchestrator is a long-running command: it awaits events instead of
* resolving immediately, so it inherits the run-context machinery (supersede * resolving immediately, so it inherits the run-context machinery (supersede
@@ -11,25 +12,12 @@
* — declaration is data, activation is code. * — declaration is data, activation is code.
*/ */
import type { Message } from './message.js'; import type { Message } from './message.js';
import type { Orchestrator, RunContext } from './run.js';
/** The context an orchestrator runs against. */
export interface OrchestratorContext {
/** Emit a message onto the queue. */
emit(msg: Message): void;
/** Await the next message matching `pred`. */
wait(pred: (msg: Message) => boolean): Promise<Message>;
/** Enable/disable a trigger by `type#id`. */
enableTrigger(type: string, id?: string): void;
disableTrigger(type: string, id?: string): void;
}
/** An orchestrator: an async function that emits and awaits messages. */
export type Orchestrator = (ctx: OrchestratorContext) => Promise<void>;
/** /**
* Run an orchestrator against a queue. `emit` enqueues; `wait` suspends until * Run an orchestrator against a queue. `emit` enqueues; `wait` suspends until
* a matching message is processed during a `tick()`. Returns a promise that * a matching message is processed during a `tick()`, rejecting on abort.
* resolves when the orchestrator completes. * Returns a promise that resolves when the orchestrator completes.
*/ */
export function runOrchestrator( export function runOrchestrator(
orchestrator: Orchestrator, orchestrator: Orchestrator,
@@ -37,8 +25,18 @@ export function runOrchestrator(
on: (handler: (msg: Message) => void) => () => void, on: (handler: (msg: Message) => void) => () => void,
enableTrigger: (type: string, id?: string) => void, enableTrigger: (type: string, id?: string) => void,
disableTrigger: (type: string, id?: string) => void, disableTrigger: (type: string, id?: string) => void,
signal?: AbortSignal,
): Promise<void> { ): Promise<void> {
const pending: Array<{ pred: (msg: Message) => boolean; resolve: (m: Message) => void }> = []; const controller = new AbortController();
const pending: Array<{ pred: (msg: Message) => boolean; resolve: (m: Message) => void; reject: (e: Error) => void }> = [];
const abort = () => {
controller.abort();
// Reject every pending wait so the orchestrator unwinds on cancel.
for (const p of pending.splice(0)) p.reject(new Error('aborted'));
};
if (signal?.aborted) abort();
else signal?.addEventListener('abort', abort, { once: true });
const unsubscribe = on((msg) => { const unsubscribe = on((msg) => {
for (let i = 0; i < pending.length; i++) { for (let i = 0; i < pending.length; i++) {
const p = pending[i]!; const p = pending[i]!;
@@ -50,15 +48,23 @@ export function runOrchestrator(
} }
}); });
const ctx: OrchestratorContext = { const ctx: RunContext = {
signal: controller.signal,
emit, emit,
wait: (pred) => wait: (pred) =>
new Promise<Message>((resolve) => { new Promise<Message>((resolve, reject) => {
pending.push({ pred, resolve }); if (controller.signal.aborted) {
reject(new Error('aborted'));
return;
}
pending.push({ pred, resolve, reject });
}), }),
enableTrigger, enableTrigger,
disableTrigger, disableTrigger,
}; };
return orchestrator(ctx).finally(unsubscribe); return orchestrator(ctx).finally(() => {
unsubscribe();
controller.abort();
});
} }
+30
View File
@@ -0,0 +1,30 @@
/**
* The run context and the unified command/orchestrator shape.
*
* A command and an orchestrator are the same thing: an async function taking a
* `RunContext`. A command returns a result and is awaited by the engine, which
* emits `:done`/`:cancel`/`:error`; an orchestrator returns `void` and is never
* awaited by a parent. Cancellation is an `AbortSignal` — a superseded command
* or disabled surface aborts it, `wait` rejects on abort, and the `:cancel`
* result is emitted. Errors are thrown: a command that throws emits `:error`.
*/
import type { Message } from './message.js';
/** The handle every handler runs against. */
export interface RunContext {
/** Cancellation: superseded, skipped, surface disabled. */
signal: AbortSignal;
/** Emit a message onto the queue. */
emit(msg: Message): void;
/** Await the next message matching `pred`. Rejects on abort. */
wait(pred: (m: Message) => boolean): Promise<Message>;
/** Enable/disable a trigger by `type#id`. */
enableTrigger(type: string, id?: string): void;
disableTrigger(type: string, id?: string): void;
}
/** A command: an async function taking the context plus its args. */
export type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
/** An orchestrator: an async function that emits and awaits messages. */
export type Orchestrator = (ctx: RunContext) => Promise<void>;