@rytejs/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ZodType, z } from 'zod';
2
2
 
3
3
  interface WorkflowConfig {
4
+ modelVersion?: number;
4
5
  states: Record<string, ZodType>;
5
6
  commands: Record<string, ZodType>;
6
7
  events: Record<string, ZodType>;
@@ -29,7 +30,7 @@ type Workflow<TConfig extends WorkflowConfig = WorkflowConfig> = {
29
30
  }[StateNames<TConfig>];
30
31
  type PipelineError<TConfig extends WorkflowConfig = WorkflowConfig> = {
31
32
  category: "validation";
32
- source: "command" | "state" | "event" | "transition";
33
+ source: "command" | "state" | "event" | "transition" | "restore";
33
34
  issues: z.core.$ZodIssue[];
34
35
  message: string;
35
36
  } | {
@@ -54,9 +55,9 @@ type DispatchResult<TConfig extends WorkflowConfig = WorkflowConfig> = {
54
55
  };
55
56
  /** Thrown internally when Zod validation fails during dispatch. */
56
57
  declare class ValidationError extends Error {
57
- readonly source: "command" | "state" | "event" | "transition";
58
+ readonly source: "command" | "state" | "event" | "transition" | "restore";
58
59
  readonly issues: z.core.$ZodIssue[];
59
- constructor(source: "command" | "state" | "event" | "transition", issues: z.core.$ZodIssue[]);
60
+ constructor(source: "command" | "state" | "event" | "transition" | "restore", issues: z.core.$ZodIssue[]);
60
61
  }
61
62
  /** Thrown internally when a handler calls ctx.error(). Caught by the router. */
62
63
  declare class DomainErrorSignal extends Error {
@@ -65,6 +66,17 @@ declare class DomainErrorSignal extends Error {
65
66
  constructor(code: string, data: unknown);
66
67
  }
67
68
 
69
+ /** A plain, JSON-safe representation of a workflow's state. */
70
+ interface WorkflowSnapshot<TConfig extends WorkflowConfig = WorkflowConfig> {
71
+ readonly id: string;
72
+ readonly definitionName: string;
73
+ readonly state: StateNames<TConfig>;
74
+ readonly data: unknown;
75
+ readonly createdAt: string;
76
+ readonly updatedAt: string;
77
+ readonly modelVersion: number;
78
+ }
79
+
68
80
  /** The result of defineWorkflow() — holds schemas and creates workflow instances. */
69
81
  interface WorkflowDefinition<TConfig extends WorkflowConfig = WorkflowConfig> {
70
82
  readonly config: TConfig;
@@ -78,6 +90,14 @@ interface WorkflowDefinition<TConfig extends WorkflowConfig = WorkflowConfig> {
78
90
  getEventSchema(eventName: string): ZodType;
79
91
  getErrorSchema(errorCode: string): ZodType;
80
92
  hasState(stateName: string): boolean;
93
+ snapshot(workflow: Workflow<TConfig>): WorkflowSnapshot<TConfig>;
94
+ restore(snapshot: WorkflowSnapshot<TConfig>): {
95
+ ok: true;
96
+ workflow: Workflow<TConfig>;
97
+ } | {
98
+ ok: false;
99
+ error: ValidationError;
100
+ };
81
101
  }
82
102
  /**
83
103
  * Creates a workflow definition from a name and Zod schema configuration.
@@ -125,6 +145,9 @@ interface Context<TConfig extends WorkflowConfig, TDeps, TState extends StateNam
125
145
  /** Terminal handler function — receives fully typed context with state and command narrowing. */
126
146
  type Handler<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig>, TCommand extends CommandNames<TConfig>> = (ctx: Context<TConfig, TDeps, TState, TCommand>) => void | Promise<void>;
127
147
 
148
+ /** The lifecycle hook event names. */
149
+ type HookEvent = "dispatch:start" | "dispatch:end" | "transition" | "error" | "event";
150
+
128
151
  /**
129
152
  * Koa-style middleware function with full context narrowing via defaults.
130
153
  *
@@ -134,11 +157,20 @@ type Handler<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TC
134
157
  */
135
158
  type Middleware<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig> = StateNames<TConfig>, TCommand extends CommandNames<TConfig> = CommandNames<TConfig>> = (ctx: Context<TConfig, TDeps, TState, TCommand>, next: () => Promise<void>) => Promise<void>;
136
159
 
160
+ /**
161
+ * Read-only subset of Context for hook callbacks.
162
+ * Includes context-key access (set/get) but excludes dispatch mutation methods.
163
+ */
164
+ type ReadonlyContext<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig> = StateNames<TConfig>, TCommand extends CommandNames<TConfig> = CommandNames<TConfig>> = Omit<Context<TConfig, TDeps, TState, TCommand>, "update" | "transition" | "emit" | "error" | "getWorkflowSnapshot">;
165
+
137
166
  type AnyMiddleware = (ctx: any, next: () => Promise<void>) => Promise<void>;
138
167
  type HandlerEntry = {
139
168
  inlineMiddleware: AnyMiddleware[];
140
169
  handler: AnyMiddleware;
141
170
  };
171
+ interface RouterOptions {
172
+ onHookError?: (error: unknown) => void;
173
+ }
142
174
  declare class StateBuilder<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig>> {
143
175
  /** @internal */ readonly middleware: AnyMiddleware[];
144
176
  /** @internal */ readonly handlers: Map<string, HandlerEntry>;
@@ -158,13 +190,26 @@ declare class WorkflowRouter<TConfig extends WorkflowConfig, TDeps = {}> {
158
190
  private singleStateBuilders;
159
191
  private multiStateBuilders;
160
192
  private wildcardHandlers;
161
- constructor(definition: WorkflowDefinition<TConfig>, deps?: TDeps);
162
- /** Adds global middleware that wraps all dispatches. */
163
- use(middleware: (ctx: Context<TConfig, TDeps>, next: () => Promise<void>) => Promise<void>): this;
193
+ private hookRegistry;
194
+ private readonly onHookError;
195
+ constructor(definition: WorkflowDefinition<TConfig>, deps?: TDeps, options?: RouterOptions);
196
+ /** Adds global middleware, merges another router, or applies a plugin. */
197
+ use(arg: ((ctx: Context<TConfig, TDeps>, next: () => Promise<void>) => Promise<void>) | WorkflowRouter<TConfig, TDeps> | Plugin<TConfig, TDeps>): this;
198
+ private merge;
199
+ private mergeStateBuilders;
164
200
  /** Registers handlers for one or more states. */
165
201
  state<P extends StateNames<TConfig> | readonly StateNames<TConfig>[]>(name: P, setup: (state: StateBuilder<TConfig, TDeps, P extends readonly (infer S)[] ? S & StateNames<TConfig> : P & StateNames<TConfig>>) => void): this;
202
+ /** Registers a lifecycle hook callback. */
203
+ on(event: "dispatch:start", callback: (ctx: ReadonlyContext<TConfig, TDeps>) => void | Promise<void>): this;
204
+ on(event: "dispatch:end", callback: (ctx: ReadonlyContext<TConfig, TDeps>, result: DispatchResult<TConfig>) => void | Promise<void>): this;
205
+ on(event: "transition", callback: (from: StateNames<TConfig>, to: StateNames<TConfig>, workflow: Workflow<TConfig>) => void | Promise<void>): this;
206
+ on(event: "error", callback: (error: PipelineError<TConfig>, ctx: ReadonlyContext<TConfig, TDeps>) => void | Promise<void>): this;
207
+ on(event: "event", callback: (event: {
208
+ type: EventNames<TConfig>;
209
+ data: unknown;
210
+ }, workflow: Workflow<TConfig>) => void | Promise<void>): this;
166
211
  /** Registers a wildcard handler that matches any state. */
167
- on<C extends CommandNames<TConfig>>(_state: "*", command: C, ...fns: [
212
+ on<C extends CommandNames<TConfig>>(state: "*", command: C, ...fns: [
168
213
  ...AnyMiddleware[],
169
214
  (ctx: Context<TConfig, TDeps, StateNames<TConfig>, C>) => void | Promise<void>
170
215
  ]): this;
@@ -175,4 +220,14 @@ declare class WorkflowRouter<TConfig extends WorkflowConfig, TDeps = {}> {
175
220
  }): Promise<DispatchResult<TConfig>>;
176
221
  }
177
222
 
178
- export { type CommandNames, type CommandPayload, type Context, type ContextKey, type DispatchResult, DomainErrorSignal, type ErrorCodes, type ErrorData, type EventData, type EventNames, type Handler, type Middleware, type PipelineError, type StateData, type StateNames, ValidationError, type Workflow, type WorkflowConfig, type WorkflowDefinition, type WorkflowOf, WorkflowRouter, createKey, defineWorkflow };
223
+ declare const PLUGIN_SYMBOL: unique symbol;
224
+ /** A branded plugin function that can be passed to router.use(). */
225
+ type Plugin<TConfig extends WorkflowConfig, TDeps> = ((router: WorkflowRouter<TConfig, TDeps>) => void) & {
226
+ readonly [PLUGIN_SYMBOL]: true;
227
+ };
228
+ /** Brands a function as a Ryte plugin for use with router.use(). */
229
+ declare function definePlugin<TConfig extends WorkflowConfig, TDeps>(fn: (router: WorkflowRouter<TConfig, TDeps>) => void): Plugin<TConfig, TDeps>;
230
+ /** Checks whether a value is a branded Ryte plugin. */
231
+ declare function isPlugin(value: unknown): value is Plugin<WorkflowConfig, unknown>;
232
+
233
+ export { type CommandNames, type CommandPayload, type Context, type ContextKey, type DispatchResult, DomainErrorSignal, type ErrorCodes, type ErrorData, type EventData, type EventNames, type Handler, type HookEvent, type Middleware, type PipelineError, type Plugin, type ReadonlyContext, type RouterOptions, type StateData, type StateNames, ValidationError, type Workflow, type WorkflowConfig, type WorkflowDefinition, type WorkflowOf, WorkflowRouter, type WorkflowSnapshot, createKey, definePlugin, defineWorkflow, isPlugin };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ZodType, z } from 'zod';
2
2
 
3
3
  interface WorkflowConfig {
4
+ modelVersion?: number;
4
5
  states: Record<string, ZodType>;
5
6
  commands: Record<string, ZodType>;
6
7
  events: Record<string, ZodType>;
@@ -29,7 +30,7 @@ type Workflow<TConfig extends WorkflowConfig = WorkflowConfig> = {
29
30
  }[StateNames<TConfig>];
30
31
  type PipelineError<TConfig extends WorkflowConfig = WorkflowConfig> = {
31
32
  category: "validation";
32
- source: "command" | "state" | "event" | "transition";
33
+ source: "command" | "state" | "event" | "transition" | "restore";
33
34
  issues: z.core.$ZodIssue[];
34
35
  message: string;
35
36
  } | {
@@ -54,9 +55,9 @@ type DispatchResult<TConfig extends WorkflowConfig = WorkflowConfig> = {
54
55
  };
55
56
  /** Thrown internally when Zod validation fails during dispatch. */
56
57
  declare class ValidationError extends Error {
57
- readonly source: "command" | "state" | "event" | "transition";
58
+ readonly source: "command" | "state" | "event" | "transition" | "restore";
58
59
  readonly issues: z.core.$ZodIssue[];
59
- constructor(source: "command" | "state" | "event" | "transition", issues: z.core.$ZodIssue[]);
60
+ constructor(source: "command" | "state" | "event" | "transition" | "restore", issues: z.core.$ZodIssue[]);
60
61
  }
61
62
  /** Thrown internally when a handler calls ctx.error(). Caught by the router. */
62
63
  declare class DomainErrorSignal extends Error {
@@ -65,6 +66,17 @@ declare class DomainErrorSignal extends Error {
65
66
  constructor(code: string, data: unknown);
66
67
  }
67
68
 
69
+ /** A plain, JSON-safe representation of a workflow's state. */
70
+ interface WorkflowSnapshot<TConfig extends WorkflowConfig = WorkflowConfig> {
71
+ readonly id: string;
72
+ readonly definitionName: string;
73
+ readonly state: StateNames<TConfig>;
74
+ readonly data: unknown;
75
+ readonly createdAt: string;
76
+ readonly updatedAt: string;
77
+ readonly modelVersion: number;
78
+ }
79
+
68
80
  /** The result of defineWorkflow() — holds schemas and creates workflow instances. */
69
81
  interface WorkflowDefinition<TConfig extends WorkflowConfig = WorkflowConfig> {
70
82
  readonly config: TConfig;
@@ -78,6 +90,14 @@ interface WorkflowDefinition<TConfig extends WorkflowConfig = WorkflowConfig> {
78
90
  getEventSchema(eventName: string): ZodType;
79
91
  getErrorSchema(errorCode: string): ZodType;
80
92
  hasState(stateName: string): boolean;
93
+ snapshot(workflow: Workflow<TConfig>): WorkflowSnapshot<TConfig>;
94
+ restore(snapshot: WorkflowSnapshot<TConfig>): {
95
+ ok: true;
96
+ workflow: Workflow<TConfig>;
97
+ } | {
98
+ ok: false;
99
+ error: ValidationError;
100
+ };
81
101
  }
82
102
  /**
83
103
  * Creates a workflow definition from a name and Zod schema configuration.
@@ -125,6 +145,9 @@ interface Context<TConfig extends WorkflowConfig, TDeps, TState extends StateNam
125
145
  /** Terminal handler function — receives fully typed context with state and command narrowing. */
126
146
  type Handler<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig>, TCommand extends CommandNames<TConfig>> = (ctx: Context<TConfig, TDeps, TState, TCommand>) => void | Promise<void>;
127
147
 
148
+ /** The lifecycle hook event names. */
149
+ type HookEvent = "dispatch:start" | "dispatch:end" | "transition" | "error" | "event";
150
+
128
151
  /**
129
152
  * Koa-style middleware function with full context narrowing via defaults.
130
153
  *
@@ -134,11 +157,20 @@ type Handler<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TC
134
157
  */
135
158
  type Middleware<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig> = StateNames<TConfig>, TCommand extends CommandNames<TConfig> = CommandNames<TConfig>> = (ctx: Context<TConfig, TDeps, TState, TCommand>, next: () => Promise<void>) => Promise<void>;
136
159
 
160
+ /**
161
+ * Read-only subset of Context for hook callbacks.
162
+ * Includes context-key access (set/get) but excludes dispatch mutation methods.
163
+ */
164
+ type ReadonlyContext<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig> = StateNames<TConfig>, TCommand extends CommandNames<TConfig> = CommandNames<TConfig>> = Omit<Context<TConfig, TDeps, TState, TCommand>, "update" | "transition" | "emit" | "error" | "getWorkflowSnapshot">;
165
+
137
166
  type AnyMiddleware = (ctx: any, next: () => Promise<void>) => Promise<void>;
138
167
  type HandlerEntry = {
139
168
  inlineMiddleware: AnyMiddleware[];
140
169
  handler: AnyMiddleware;
141
170
  };
171
+ interface RouterOptions {
172
+ onHookError?: (error: unknown) => void;
173
+ }
142
174
  declare class StateBuilder<TConfig extends WorkflowConfig, TDeps, TState extends StateNames<TConfig>> {
143
175
  /** @internal */ readonly middleware: AnyMiddleware[];
144
176
  /** @internal */ readonly handlers: Map<string, HandlerEntry>;
@@ -158,13 +190,26 @@ declare class WorkflowRouter<TConfig extends WorkflowConfig, TDeps = {}> {
158
190
  private singleStateBuilders;
159
191
  private multiStateBuilders;
160
192
  private wildcardHandlers;
161
- constructor(definition: WorkflowDefinition<TConfig>, deps?: TDeps);
162
- /** Adds global middleware that wraps all dispatches. */
163
- use(middleware: (ctx: Context<TConfig, TDeps>, next: () => Promise<void>) => Promise<void>): this;
193
+ private hookRegistry;
194
+ private readonly onHookError;
195
+ constructor(definition: WorkflowDefinition<TConfig>, deps?: TDeps, options?: RouterOptions);
196
+ /** Adds global middleware, merges another router, or applies a plugin. */
197
+ use(arg: ((ctx: Context<TConfig, TDeps>, next: () => Promise<void>) => Promise<void>) | WorkflowRouter<TConfig, TDeps> | Plugin<TConfig, TDeps>): this;
198
+ private merge;
199
+ private mergeStateBuilders;
164
200
  /** Registers handlers for one or more states. */
165
201
  state<P extends StateNames<TConfig> | readonly StateNames<TConfig>[]>(name: P, setup: (state: StateBuilder<TConfig, TDeps, P extends readonly (infer S)[] ? S & StateNames<TConfig> : P & StateNames<TConfig>>) => void): this;
202
+ /** Registers a lifecycle hook callback. */
203
+ on(event: "dispatch:start", callback: (ctx: ReadonlyContext<TConfig, TDeps>) => void | Promise<void>): this;
204
+ on(event: "dispatch:end", callback: (ctx: ReadonlyContext<TConfig, TDeps>, result: DispatchResult<TConfig>) => void | Promise<void>): this;
205
+ on(event: "transition", callback: (from: StateNames<TConfig>, to: StateNames<TConfig>, workflow: Workflow<TConfig>) => void | Promise<void>): this;
206
+ on(event: "error", callback: (error: PipelineError<TConfig>, ctx: ReadonlyContext<TConfig, TDeps>) => void | Promise<void>): this;
207
+ on(event: "event", callback: (event: {
208
+ type: EventNames<TConfig>;
209
+ data: unknown;
210
+ }, workflow: Workflow<TConfig>) => void | Promise<void>): this;
166
211
  /** Registers a wildcard handler that matches any state. */
167
- on<C extends CommandNames<TConfig>>(_state: "*", command: C, ...fns: [
212
+ on<C extends CommandNames<TConfig>>(state: "*", command: C, ...fns: [
168
213
  ...AnyMiddleware[],
169
214
  (ctx: Context<TConfig, TDeps, StateNames<TConfig>, C>) => void | Promise<void>
170
215
  ]): this;
@@ -175,4 +220,14 @@ declare class WorkflowRouter<TConfig extends WorkflowConfig, TDeps = {}> {
175
220
  }): Promise<DispatchResult<TConfig>>;
176
221
  }
177
222
 
178
- export { type CommandNames, type CommandPayload, type Context, type ContextKey, type DispatchResult, DomainErrorSignal, type ErrorCodes, type ErrorData, type EventData, type EventNames, type Handler, type Middleware, type PipelineError, type StateData, type StateNames, ValidationError, type Workflow, type WorkflowConfig, type WorkflowDefinition, type WorkflowOf, WorkflowRouter, createKey, defineWorkflow };
223
+ declare const PLUGIN_SYMBOL: unique symbol;
224
+ /** A branded plugin function that can be passed to router.use(). */
225
+ type Plugin<TConfig extends WorkflowConfig, TDeps> = ((router: WorkflowRouter<TConfig, TDeps>) => void) & {
226
+ readonly [PLUGIN_SYMBOL]: true;
227
+ };
228
+ /** Brands a function as a Ryte plugin for use with router.use(). */
229
+ declare function definePlugin<TConfig extends WorkflowConfig, TDeps>(fn: (router: WorkflowRouter<TConfig, TDeps>) => void): Plugin<TConfig, TDeps>;
230
+ /** Checks whether a value is a branded Ryte plugin. */
231
+ declare function isPlugin(value: unknown): value is Plugin<WorkflowConfig, unknown>;
232
+
233
+ export { type CommandNames, type CommandPayload, type Context, type ContextKey, type DispatchResult, DomainErrorSignal, type ErrorCodes, type ErrorData, type EventData, type EventNames, type Handler, type HookEvent, type Middleware, type PipelineError, type Plugin, type ReadonlyContext, type RouterOptions, type StateData, type StateNames, ValidationError, type Workflow, type WorkflowConfig, type WorkflowDefinition, type WorkflowOf, WorkflowRouter, type WorkflowSnapshot, createKey, definePlugin, defineWorkflow, isPlugin };
package/dist/index.js CHANGED
@@ -1,3 +1,21 @@
1
+ // src/types.ts
2
+ var ValidationError = class extends Error {
3
+ constructor(source, issues) {
4
+ super(`Validation failed (${source}): ${issues.map((i) => i.message).join(", ")}`);
5
+ this.source = source;
6
+ this.issues = issues;
7
+ this.name = "ValidationError";
8
+ }
9
+ };
10
+ var DomainErrorSignal = class extends Error {
11
+ constructor(code, data) {
12
+ super(`Domain error: ${code}`);
13
+ this.code = code;
14
+ this.data = data;
15
+ this.name = "DomainErrorSignal";
16
+ }
17
+ };
18
+
1
19
  // src/definition.ts
2
20
  function defineWorkflow(name, config) {
3
21
  return {
@@ -44,6 +62,51 @@ function defineWorkflow(name, config) {
44
62
  },
45
63
  hasState(stateName) {
46
64
  return stateName in config.states;
65
+ },
66
+ snapshot(workflow) {
67
+ return {
68
+ id: workflow.id,
69
+ definitionName: name,
70
+ state: workflow.state,
71
+ data: workflow.data,
72
+ createdAt: workflow.createdAt.toISOString(),
73
+ updatedAt: workflow.updatedAt.toISOString(),
74
+ modelVersion: config.modelVersion ?? 1
75
+ };
76
+ },
77
+ restore(snap) {
78
+ const stateSchema = config.states[snap.state];
79
+ if (!stateSchema) {
80
+ return {
81
+ ok: false,
82
+ error: new ValidationError("restore", [
83
+ {
84
+ code: "custom",
85
+ message: `Unknown state: ${snap.state}`,
86
+ input: snap.state,
87
+ path: ["state"]
88
+ }
89
+ ])
90
+ };
91
+ }
92
+ const result = stateSchema.safeParse(snap.data);
93
+ if (!result.success) {
94
+ return {
95
+ ok: false,
96
+ error: new ValidationError("restore", result.error.issues)
97
+ };
98
+ }
99
+ return {
100
+ ok: true,
101
+ workflow: {
102
+ id: snap.id,
103
+ definitionName: snap.definitionName,
104
+ state: snap.state,
105
+ data: result.data,
106
+ createdAt: new Date(snap.createdAt),
107
+ updatedAt: new Date(snap.updatedAt)
108
+ }
109
+ };
47
110
  }
48
111
  };
49
112
  }
@@ -53,6 +116,17 @@ function createKey(name) {
53
116
  return { id: Symbol(name) };
54
117
  }
55
118
 
119
+ // src/plugin.ts
120
+ var PLUGIN_SYMBOL = /* @__PURE__ */ Symbol.for("ryte:plugin");
121
+ function definePlugin(fn) {
122
+ const plugin = fn;
123
+ Object.defineProperty(plugin, PLUGIN_SYMBOL, { value: true, writable: false });
124
+ return plugin;
125
+ }
126
+ function isPlugin(value) {
127
+ return typeof value === "function" && PLUGIN_SYMBOL in value;
128
+ }
129
+
56
130
  // src/compose.ts
57
131
  function compose(middleware) {
58
132
  return async (ctx) => {
@@ -68,24 +142,6 @@ function compose(middleware) {
68
142
  };
69
143
  }
70
144
 
71
- // src/types.ts
72
- var ValidationError = class extends Error {
73
- constructor(source, issues) {
74
- super(`Validation failed (${source}): ${issues.map((i) => i.message).join(", ")}`);
75
- this.source = source;
76
- this.issues = issues;
77
- this.name = "ValidationError";
78
- }
79
- };
80
- var DomainErrorSignal = class extends Error {
81
- constructor(code, data) {
82
- super(`Domain error: ${code}`);
83
- this.code = code;
84
- this.data = data;
85
- this.name = "DomainErrorSignal";
86
- }
87
- };
88
-
89
145
  // src/context.ts
90
146
  function createContext(definition, originalWorkflow, command, deps) {
91
147
  let mutableState = originalWorkflow.state;
@@ -169,6 +225,46 @@ function createContext(definition, originalWorkflow, command, deps) {
169
225
  return ctx;
170
226
  }
171
227
 
228
+ // src/hooks.ts
229
+ var HOOK_EVENTS = /* @__PURE__ */ new Set([
230
+ "dispatch:start",
231
+ "dispatch:end",
232
+ "transition",
233
+ "error",
234
+ "event"
235
+ ]);
236
+ var HookRegistry = class {
237
+ // biome-ignore lint/complexity/noBannedTypes: callbacks have varying signatures per hook event
238
+ hooks = /* @__PURE__ */ new Map();
239
+ /** Register a callback for a hook event. */
240
+ // biome-ignore lint/complexity/noBannedTypes: callbacks have varying signatures per hook event
241
+ add(event, callback) {
242
+ const existing = this.hooks.get(event) ?? [];
243
+ existing.push(callback);
244
+ this.hooks.set(event, existing);
245
+ }
246
+ /** Emit a hook event, calling all registered callbacks. Errors are caught and forwarded. */
247
+ async emit(event, onError, ...args) {
248
+ const callbacks = this.hooks.get(event);
249
+ if (!callbacks) return;
250
+ for (const cb of callbacks) {
251
+ try {
252
+ await cb(...args);
253
+ } catch (err) {
254
+ onError(err);
255
+ }
256
+ }
257
+ }
258
+ /** Merge another registry's hooks into this one (used by composable routers). */
259
+ merge(other) {
260
+ for (const [event, callbacks] of other.hooks) {
261
+ const existing = this.hooks.get(event) ?? [];
262
+ existing.push(...callbacks);
263
+ this.hooks.set(event, existing);
264
+ }
265
+ }
266
+ };
267
+
172
268
  // src/router.ts
173
269
  var StateBuilder = class {
174
270
  /** @internal */
@@ -190,20 +286,68 @@ var StateBuilder = class {
190
286
  return this;
191
287
  }
192
288
  };
193
- var WorkflowRouter = class {
194
- constructor(definition, deps = {}) {
289
+ var WorkflowRouter = class _WorkflowRouter {
290
+ constructor(definition, deps = {}, options = {}) {
195
291
  this.definition = definition;
196
292
  this.deps = deps;
293
+ this.onHookError = options.onHookError ?? console.error;
197
294
  }
198
295
  globalMiddleware = [];
296
+ // biome-ignore lint/suspicious/noExplicitAny: type erasure — builders store handlers for different state types
199
297
  singleStateBuilders = /* @__PURE__ */ new Map();
298
+ // biome-ignore lint/suspicious/noExplicitAny: type erasure — builders store handlers for different state types
200
299
  multiStateBuilders = /* @__PURE__ */ new Map();
201
300
  wildcardHandlers = /* @__PURE__ */ new Map();
202
- /** Adds global middleware that wraps all dispatches. */
203
- use(middleware) {
204
- this.globalMiddleware.push(middleware);
301
+ hookRegistry = new HookRegistry();
302
+ onHookError;
303
+ /** Adds global middleware, merges another router, or applies a plugin. */
304
+ use(arg) {
305
+ if (arg instanceof _WorkflowRouter) {
306
+ this.merge(arg);
307
+ } else if (isPlugin(arg)) {
308
+ arg(this);
309
+ } else {
310
+ this.globalMiddleware.push(arg);
311
+ }
205
312
  return this;
206
313
  }
314
+ merge(child) {
315
+ if (child.definition !== this.definition) {
316
+ throw new Error(
317
+ `Cannot merge router for '${child.definition.name}' into router for '${this.definition.name}': definition mismatch`
318
+ );
319
+ }
320
+ this.globalMiddleware.push(...child.globalMiddleware);
321
+ this.mergeStateBuilders(this.singleStateBuilders, child.singleStateBuilders);
322
+ this.mergeStateBuilders(this.multiStateBuilders, child.multiStateBuilders);
323
+ for (const [command, entry] of child.wildcardHandlers) {
324
+ if (!this.wildcardHandlers.has(command)) {
325
+ this.wildcardHandlers.set(command, {
326
+ inlineMiddleware: [...entry.inlineMiddleware],
327
+ handler: entry.handler
328
+ });
329
+ }
330
+ }
331
+ this.hookRegistry.merge(child.hookRegistry);
332
+ }
333
+ mergeStateBuilders(target, source) {
334
+ for (const [stateName, childBuilder] of source) {
335
+ let parentBuilder = target.get(stateName);
336
+ if (!parentBuilder) {
337
+ parentBuilder = new StateBuilder();
338
+ target.set(stateName, parentBuilder);
339
+ }
340
+ for (const [command, entry] of childBuilder.handlers) {
341
+ if (!parentBuilder.handlers.has(command)) {
342
+ parentBuilder.handlers.set(command, {
343
+ inlineMiddleware: [...entry.inlineMiddleware],
344
+ handler: entry.handler
345
+ });
346
+ }
347
+ }
348
+ parentBuilder.middleware.push(...childBuilder.middleware);
349
+ }
350
+ }
207
351
  /** Registers handlers for one or more states. */
208
352
  state(name, setup) {
209
353
  const names = Array.isArray(name) ? name : [name];
@@ -219,19 +363,29 @@ var WorkflowRouter = class {
219
363
  }
220
364
  return this;
221
365
  }
222
- /** Registers a wildcard handler that matches any state. */
223
- on(_state, command, ...fns) {
224
- if (fns.length === 0) throw new Error("on() requires at least a handler");
225
- const handler = fns.pop();
226
- const inlineMiddleware = fns;
227
- const wrappedHandler = async (ctx, _next) => {
228
- await handler(ctx);
229
- };
230
- this.wildcardHandlers.set(command, {
231
- inlineMiddleware,
232
- handler: wrappedHandler
233
- });
234
- return this;
366
+ // biome-ignore lint/suspicious/noExplicitAny: implementation signature must be loose to handle all overloads
367
+ on(...args) {
368
+ const first = args[0];
369
+ if (HOOK_EVENTS.has(first)) {
370
+ this.hookRegistry.add(first, args[1]);
371
+ return this;
372
+ }
373
+ if (first === "*") {
374
+ const command = args[1];
375
+ const fns = args.slice(2);
376
+ if (fns.length === 0) throw new Error("on() requires at least a handler");
377
+ const handler = fns.pop();
378
+ const inlineMiddleware = fns;
379
+ const wrappedHandler = async (ctx, _next) => {
380
+ await handler(ctx);
381
+ };
382
+ this.wildcardHandlers.set(command, {
383
+ inlineMiddleware,
384
+ handler: wrappedHandler
385
+ });
386
+ return this;
387
+ }
388
+ throw new Error(`Unknown event or state: ${first}`);
235
389
  }
236
390
  /** Dispatches a command to the appropriate handler and returns the result. */
237
391
  async dispatch(workflow, command) {
@@ -305,17 +459,35 @@ var WorkflowRouter = class {
305
459
  validatedCommand,
306
460
  this.deps
307
461
  );
462
+ await this.hookRegistry.emit("dispatch:start", this.onHookError, ctx);
308
463
  try {
309
464
  const composed = compose(chain);
310
465
  await composed(ctx);
311
- return {
466
+ const result = {
312
467
  ok: true,
313
468
  workflow: ctx.getWorkflowSnapshot(),
314
469
  events: [...ctx.events]
315
470
  };
471
+ if (result.ok && result.workflow.state !== workflow.state) {
472
+ await this.hookRegistry.emit(
473
+ "transition",
474
+ this.onHookError,
475
+ workflow.state,
476
+ result.workflow.state,
477
+ result.workflow
478
+ );
479
+ }
480
+ if (result.ok) {
481
+ for (const event of result.events) {
482
+ await this.hookRegistry.emit("event", this.onHookError, event, result.workflow);
483
+ }
484
+ }
485
+ await this.hookRegistry.emit("dispatch:end", this.onHookError, ctx, result);
486
+ return result;
316
487
  } catch (err) {
488
+ let result;
317
489
  if (err instanceof DomainErrorSignal) {
318
- return {
490
+ result = {
319
491
  ok: false,
320
492
  error: {
321
493
  category: "domain",
@@ -323,9 +495,8 @@ var WorkflowRouter = class {
323
495
  data: err.data
324
496
  }
325
497
  };
326
- }
327
- if (err instanceof ValidationError) {
328
- return {
498
+ } else if (err instanceof ValidationError) {
499
+ result = {
329
500
  ok: false,
330
501
  error: {
331
502
  category: "validation",
@@ -334,8 +505,12 @@ var WorkflowRouter = class {
334
505
  message: err.message
335
506
  }
336
507
  };
508
+ } else {
509
+ throw err;
337
510
  }
338
- throw err;
511
+ await this.hookRegistry.emit("error", this.onHookError, result.error, ctx);
512
+ await this.hookRegistry.emit("dispatch:end", this.onHookError, ctx, result);
513
+ return result;
339
514
  }
340
515
  }
341
516
  };
@@ -344,6 +519,8 @@ export {
344
519
  ValidationError,
345
520
  WorkflowRouter,
346
521
  createKey,
347
- defineWorkflow
522
+ definePlugin,
523
+ defineWorkflow,
524
+ isPlugin
348
525
  };
349
526
  //# sourceMappingURL=index.js.map