@daltonr/pathwrite-core 0.3.0 → 0.5.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/README.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  Headless path engine with zero dependencies. Manages step navigation, navigation guards, lifecycle hooks, and stack-based sub-path orchestration. Works equally well driving a UI wizard or a backend document lifecycle — no framework required.
4
4
 
5
+ ## Quick Reference: Common Patterns
6
+
7
+ ### ✅ Write Defensive Guards
8
+ ```typescript
9
+ // Guards run BEFORE onEnter - always handle undefined data
10
+ canMoveNext: (ctx) => (ctx.data.name ?? "").trim().length > 0 // ✅ Safe
11
+ canMoveNext: (ctx) => ctx.data.name.trim().length > 0 // ❌ Crashes!
12
+ ```
13
+
14
+ ### ✅ Use `isFirstEntry` to Prevent Data Reset
15
+ ```typescript
16
+ onEnter: (ctx) => {
17
+ if (ctx.isFirstEntry) {
18
+ return { items: [], status: "pending" }; // Initialize only on first visit
19
+ }
20
+ // Don't reset when user navigates back
21
+ }
22
+ ```
23
+
24
+ ### ✅ Correlate Sub-Paths with `meta`
25
+ ```typescript
26
+ // Starting sub-path
27
+ engine.startSubPath(subPath, initialData, { itemIndex: i });
28
+
29
+ // In parent step
30
+ onSubPathComplete: (_id, subData, ctx, meta) => {
31
+ const index = meta?.itemIndex; // Correlate back to collection item
32
+ // ...
33
+ }
34
+ ```
35
+
36
+ ---
37
+
5
38
  ## Key types
6
39
 
7
40
  ```typescript
@@ -30,15 +63,22 @@ const path: PathDefinition<CourseData> = {
30
63
  | `PathStep<TData>` | A single step: guards, lifecycle hooks. |
31
64
  | `PathStepContext<TData>` | Passed to every hook and guard. `data` is a **readonly snapshot copy** — return a patch to update state. |
32
65
  | `PathSnapshot<TData>` | Point-in-time read of the engine: step ID, index, count, flags, and a copy of data. |
33
- | `PathEvent` | Union of `stateChanged`, `completed`, `cancelled`, and `resumed`. |
66
+ | `PathEvent` | Union of `stateChanged` (includes `cause`), `completed`, `cancelled`, and `resumed`. |
67
+ | `StateChangeCause` | Identifies the method that triggered a `stateChanged` event: `"start"` \| `"next"` \| `"previous"` \| `"goToStep"` \| `"goToStepChecked"` \| `"setData"` \| `"cancel"` \| `"restart"`. |
68
+ | `PathObserver` | `(event: PathEvent, engine: PathEngine) => void` — a function registered at construction time that receives every event for the engine's lifetime. |
69
+ | `PathEngineOptions` | `{ observers?: PathObserver[] }` — options accepted by the `PathEngine` constructor and `PathEngine.fromState()`. |
70
+ | `ObserverStrategy` | Union type for the five built-in trigger strategies: `"onEveryChange" \| "onNext" \| "onSubPathComplete" \| "onComplete" \| "manual"`. Import and use in your own observer factories. |
71
+ | `matchesStrategy` | `(strategy: ObserverStrategy, event: PathEvent) => boolean` — returns `true` when an event should trigger an observer under the given strategy. Shared by every observer so none re-implement the same "when do I fire?" logic. |
34
72
 
35
73
  ## PathEngine API
36
74
 
37
75
  ```typescript
38
- const engine = new PathEngine();
76
+ // Observers are wired before the first event fires
77
+ const engine = new PathEngine({ observers: [myObserver, anotherObserver] });
39
78
 
40
79
  engine.start(definition, initialData?); // start or re-start a path
41
- engine.startSubPath(definition, data?); // push sub-path onto the stack (requires active path)
80
+ engine.restart(definition, initialData?); // tear down stack and start fresh (no hooks, no cancelled event)
81
+ engine.startSubPath(definition, data?, meta?); // push sub-path onto the stack (requires active path)
42
82
  engine.next();
43
83
  engine.previous();
44
84
  engine.cancel();
@@ -47,11 +87,58 @@ engine.goToStep(stepId); // jump to step by ID; bypasses guard
47
87
  engine.goToStepChecked(stepId); // jump to step by ID; checks canMoveNext / canMovePrevious first
48
88
  engine.snapshot(); // returns PathSnapshot | null
49
89
 
90
+ // Serialization API (for persistence)
91
+ const state = engine.exportState(); // returns SerializedPathState | null
92
+ const restoredEngine = PathEngine.fromState(state, pathDefinitions, { observers: [...] });
93
+
94
+ // Removable one-off listener (use subscribe when you need to unsubscribe)
50
95
  const unsubscribe = engine.subscribe((event) => { ... });
51
- unsubscribe(); // remove the listener
96
+ unsubscribe();
97
+ ```
98
+
99
+ ## Observers
100
+
101
+ Observers are functions registered at construction time. They receive every event for the engine's lifetime and cannot be removed — for removable listeners use `subscribe()`.
102
+
103
+ ```typescript
104
+ // A logger observer
105
+ const logger: PathObserver = (event) =>
106
+ console.log(`[${event.type}]`, 'cause' in event ? event.cause : '');
107
+
108
+ // A persistence observer using matchesStrategy from core
109
+ import { matchesStrategy } from "@daltonr/pathwrite-core";
110
+
111
+ const persist: PathObserver = (event, engine) => {
112
+ if (matchesStrategy("onNext", event)) {
113
+ myStore.save(engine.exportState());
114
+ }
115
+ };
116
+
117
+ const engine = new PathEngine({ observers: [logger, persist] });
118
+
119
+ // Observers are also passed through fromState — so restored engines are fully observed
120
+ const restoredEngine = PathEngine.fromState(saved, pathDefs, { observers: [logger, persist] });
52
121
  ```
53
122
 
54
- ## Lifecycle hooks
123
+ The second argument to each observer is the engine itself, which lets observers call `engine.exportState()`, `engine.snapshot()`, etc. without needing a separate reference.
124
+
125
+ ### Building your own observer factory
126
+
127
+ `ObserverStrategy` and `matchesStrategy` are exported from core so any observer — not just HTTP persistence — can share the same strategy logic without reimplementing it:
128
+
129
+ ```typescript
130
+ import { type ObserverStrategy, matchesStrategy, type PathObserver } from "@daltonr/pathwrite-core";
131
+
132
+ function myObserver(strategy: ObserverStrategy): PathObserver {
133
+ return (event, engine) => {
134
+ if (matchesStrategy(strategy, event)) {
135
+ // react — save to MongoDB, write to a log, fire analytics, etc.
136
+ }
137
+ };
138
+ }
139
+ ```
140
+
141
+
55
142
 
56
143
  All hooks are optional. Hooks that want to update data **return a partial patch** — the engine applies it automatically. Direct mutation of `ctx.data` is a no-op; the context receives a copy.
57
144
 
@@ -60,46 +147,375 @@ All hooks are optional. Hooks that want to update data **return a partial patch*
60
147
  | `onEnter` | On arrival at a step (start, next, previous, resume) | ✅ |
61
148
  | `onLeave` | On departure from a step (only when the guard allows) | ✅ |
62
149
  | `onSubPathComplete` | On the parent step when a sub-path finishes | ✅ |
150
+ | `onSubPathCancel` | On the parent step when a sub-path is cancelled | ✅ |
63
151
  | `canMoveNext` | Before advancing — return `false` to block | — |
64
152
  | `canMovePrevious` | Before going back — return `false` to block | — |
65
153
  | `validationMessages` | On every snapshot — return `string[]` explaining why the step is not yet valid | — |
66
154
 
155
+ ### Using `isFirstEntry` to Avoid Data Reset
156
+
157
+ **Problem:** `onEnter` fires EVERY time you enter a step, including when navigating backward. If you initialize data in `onEnter`, you'll overwrite user input when they return to the step.
158
+
159
+ **Solution:** Use `ctx.isFirstEntry` to distinguish first visit from re-entry:
160
+
161
+ ```typescript
162
+ {
163
+ id: "user-details",
164
+ onEnter: (ctx) => {
165
+ // Only initialize on first entry, not on re-entry
166
+ if (ctx.isFirstEntry) {
167
+ return {
168
+ name: "",
169
+ email: "",
170
+ preferences: { newsletter: true }
171
+ };
172
+ }
173
+ // On re-entry (e.g., user pressed Back), keep existing data
174
+ }
175
+ }
176
+ ```
177
+
178
+ **Common Patterns:**
179
+
180
+ ```typescript
181
+ // Initialize empty collection on first entry only
182
+ onEnter: (ctx) => {
183
+ if (ctx.isFirstEntry) {
184
+ return { approvals: [], comments: [] };
185
+ }
186
+ }
187
+
188
+ // Fetch data from API only once
189
+ onEnter: async (ctx) => {
190
+ if (ctx.isFirstEntry) {
191
+ const userData = await fetchUserProfile(ctx.data.userId);
192
+ return { ...userData };
193
+ }
194
+ }
195
+
196
+ // Set defaults but preserve user changes on re-entry
197
+ onEnter: (ctx) => {
198
+ if (ctx.isFirstEntry) {
199
+ return {
200
+ reviewStatus: "pending",
201
+ lastModified: new Date().toISOString()
202
+ };
203
+ }
204
+ }
205
+ ```
206
+
67
207
  ### Snapshot guard booleans
68
208
 
69
209
  The snapshot includes `canMoveNext` and `canMovePrevious` booleans — the evaluated results of the current step's guards. Use them to proactively disable navigation buttons. Sync guards reflect their real value; async guards default to `true` (optimistic). Both update automatically when data changes via `setData`.
70
210
 
71
- ### Example sub-path result merged into parent data
211
+ ### ⚠️ IMPORTANT: Guards Run Before `onEnter`
212
+
213
+ **Guards are evaluated BEFORE `onEnter` runs on first entry.** This is critical to understand:
214
+
215
+ 1. When a path starts, the engine creates the first snapshot immediately
216
+ 2. Guards (`canMoveNext`, `validationMessages`) are evaluated to populate that snapshot
217
+ 3. Only THEN does `onEnter` run to initialize data
218
+
219
+ **This means guards see `initialData`, not data that `onEnter` would set.**
220
+
221
+ #### Defensive Guard Patterns
222
+
223
+ Always write guards defensively to handle undefined/missing data:
72
224
 
73
225
  ```typescript
226
+ // ❌ WRONG - Crashes on first snapshot when initialData = {}
74
227
  {
75
- id: "subjects-list",
76
- onSubPathComplete: (_id, subData, ctx) => ({
77
- subjects: [...(ctx.data.subjects ?? []), { name: subData.name, teacher: subData.teacher }]
78
- })
228
+ id: "user-details",
229
+ canMoveNext: (ctx) => ctx.data.name.trim().length > 0, // TypeError!
230
+ onEnter: () => ({ name: "" }) // Too late - guard already ran
231
+ }
232
+
233
+ // ✅ CORRECT - Use nullish coalescing
234
+ {
235
+ id: "user-details",
236
+ canMoveNext: (ctx) => (ctx.data.name ?? "").trim().length > 0,
237
+ onEnter: () => ({ name: "" })
238
+ }
239
+
240
+ // ✅ ALSO CORRECT - Provide initialData so fields exist from the start
241
+ engine.start(path, { name: "", email: "" });
242
+ ```
243
+
244
+ #### More Defensive Patterns
245
+
246
+ ```typescript
247
+ // Arrays
248
+ canMoveNext: (ctx) => (ctx.data.items ?? []).length > 0
249
+
250
+ // Numbers
251
+ canMoveNext: (ctx) => (ctx.data.age ?? 0) >= 18
252
+
253
+ // Complex objects
254
+ canMoveNext: (ctx) => {
255
+ const address = ctx.data.address ?? {};
256
+ return (address.street ?? "").length > 0 && (address.city ?? "").length > 0;
257
+ }
258
+
259
+ // Validation messages
260
+ validationMessages: (ctx) => {
261
+ const messages = [];
262
+ if (!(ctx.data.email ?? "").includes("@")) {
263
+ messages.push("Please enter a valid email");
264
+ }
265
+ return messages;
79
266
  }
80
267
  ```
81
268
 
82
- ## Sub-path flow
269
+ #### Error Handling
270
+
271
+ If a guard or `validationMessages` hook throws, Pathwrite catches the error, emits a `console.warn` (with the step ID and thrown value), and returns the safe default (`true` / `[]`) so the UI remains operable. However, **relying on error handling is not recommended** — write defensive guards instead.
83
272
 
273
+ ### Sub-path example with meta correlation
274
+
275
+ ```typescript
276
+ {
277
+ id: "subjects-list",
278
+ onSubPathComplete: (_id, subData, ctx, meta) => {
279
+ // meta contains the correlation object passed to startSubPath
280
+ const index = meta?.index as number;
281
+ return {
282
+ subjects: [...(ctx.data.subjects ?? []), {
283
+ index,
284
+ name: subData.name,
285
+ teacher: subData.teacher
286
+ }]
287
+ };
288
+ },
289
+ onSubPathCancel: (_id, ctx, meta) => {
290
+ // Called when user cancels sub-path (e.g., Back on first step)
291
+ const index = meta?.index as number;
292
+ console.log(`User skipped subject ${index}`);
293
+ // Return patch to record the skip, or return nothing to ignore
294
+ }
295
+ }
84
296
  ```
297
+
298
+ ## Sub-Paths: Comprehensive Guide
299
+
300
+ Sub-paths let you nest workflows — for example, running a mini-wizard for each item in a collection.
301
+
302
+ ### Basic Flow
303
+
304
+ ```typescript
85
305
  engine.start(mainPath) → stack: [] active: main
86
306
  engine.startSubPath(subPath) → stack: [main] active: sub
87
307
  engine.next() // sub finishes
88
308
  → onSubPathComplete fires on the parent step
89
- → stack: [] active: main
309
+ → stack: [] active: main (resumed)
90
310
  ```
91
311
 
92
- Cancelling a sub-path pops it off the stack silently — `onSubPathComplete` is **not** called.
312
+ ### Complete Example: Document Approval Workflow
313
+
314
+ ```typescript
315
+ interface ApprovalData extends PathData {
316
+ documentTitle: string;
317
+ approvers: Array<{ name: string; email: string }>;
318
+ approvals: Array<{ approverIndex: number; decision: string; comments: string }>;
319
+ }
320
+
321
+ interface ApproverReviewData extends PathData {
322
+ documentTitle: string; // Passed from parent
323
+ decision?: "approve" | "reject";
324
+ comments?: string;
325
+ }
326
+
327
+ // Main path
328
+ const approvalPath: PathDefinition<ApprovalData> = {
329
+ id: "approval-workflow",
330
+ steps: [
331
+ {
332
+ id: "setup",
333
+ onEnter: (ctx) => {
334
+ if (ctx.isFirstEntry) {
335
+ return { approvers: [], approvals: [] };
336
+ }
337
+ }
338
+ },
339
+ {
340
+ id: "run-approvals",
341
+ // Block next until all approvers have completed
342
+ canMoveNext: (ctx) => {
343
+ const approversCount = (ctx.data.approvers ?? []).length;
344
+ const approvalsCount = (ctx.data.approvals ?? []).length;
345
+ return approversCount > 0 && approvalsCount === approversCount;
346
+ },
347
+ validationMessages: (ctx) => {
348
+ const approversCount = (ctx.data.approvers ?? []).length;
349
+ const approvalsCount = (ctx.data.approvals ?? []).length;
350
+ const remaining = approversCount - approvalsCount;
351
+ if (remaining > 0) {
352
+ return [`${remaining} approver(s) still need to complete their review`];
353
+ }
354
+ return [];
355
+ },
356
+ // Called when each approver sub-path completes
357
+ onSubPathComplete: (_subPathId, subData, ctx, meta) => {
358
+ const reviewData = subData as ApproverReviewData;
359
+ const approverIndex = meta?.approverIndex as number;
360
+
361
+ return {
362
+ approvals: [
363
+ ...(ctx.data.approvals ?? []),
364
+ {
365
+ approverIndex,
366
+ decision: reviewData.decision!,
367
+ comments: reviewData.comments ?? ""
368
+ }
369
+ ]
370
+ };
371
+ },
372
+ // Called when approver cancels (presses Back on first step)
373
+ onSubPathCancel: (_subPathId, ctx, meta) => {
374
+ const approverIndex = meta?.approverIndex as number;
375
+ console.log(`Approver ${approverIndex} declined to review`);
376
+ // Could add to a "skipped" list or just ignore
377
+ }
378
+ },
379
+ { id: "summary" }
380
+ ]
381
+ };
382
+
383
+ // Sub-path for each approver
384
+ const approverReviewPath: PathDefinition<ApproverReviewData> = {
385
+ id: "approver-review",
386
+ steps: [
387
+ { id: "review-document" },
388
+ {
389
+ id: "make-decision",
390
+ canMoveNext: (ctx) => ctx.data.decision !== undefined
391
+ },
392
+ { id: "add-comments" }
393
+ ]
394
+ };
395
+
396
+ // Usage in UI component
397
+ function ReviewStep() {
398
+ const approvers = snapshot.data.approvers ?? [];
399
+ const approvals = snapshot.data.approvals ?? [];
400
+
401
+ const startReview = (approverIndex: number) => {
402
+ const approver = approvers[approverIndex];
403
+
404
+ // Start sub-path with meta correlation
405
+ engine.startSubPath(
406
+ approverReviewPath,
407
+ {
408
+ documentTitle: snapshot.data.documentTitle, // Pass context from parent
409
+ // decision and comments will be filled during sub-path
410
+ },
411
+ { approverIndex } // Meta: correlates completion back to this approver
412
+ );
413
+ };
414
+
415
+ return (
416
+ <div>
417
+ {approvers.map((approver, i) => (
418
+ <div key={i}>
419
+ {approver.name}
420
+ {approvals.some(a => a.approverIndex === i) ? (
421
+ <span>✓ Reviewed</span>
422
+ ) : (
423
+ <button onClick={() => startReview(i)}>Start Review</button>
424
+ )}
425
+ </div>
426
+ ))}
427
+ </div>
428
+ );
429
+ }
430
+ ```
431
+
432
+ ### Sub-Path Key Concepts
433
+
434
+ 1. **Stack-based**: Sub-paths push onto a stack. Parent is paused while sub-path is active.
435
+
436
+ 2. **Meta correlation**: Pass a `meta` object to `startSubPath()` to identify which collection item triggered the sub-path. It's passed back unchanged to `onSubPathComplete` and `onSubPathCancel`.
437
+
438
+ 3. **Data isolation**: Sub-path data is separate from parent data. Pass needed context (like `documentTitle`) in `initialData`.
439
+
440
+ 4. **Completion vs Cancellation**:
441
+ - **Complete**: User reaches the last step → `onSubPathComplete` fires
442
+ - **Cancel**: User presses Back on first step → `onSubPathCancel` fires
443
+ - `onSubPathComplete` is NOT called on cancellation
444
+
445
+ 5. **Parent remains on same step**: After sub-path completes/cancels, parent resumes at the same step (not advanced automatically).
446
+
447
+ 6. **Guards still apply**: Parent step's `canMoveNext` is evaluated when resuming. Use it to block until all sub-paths complete.
448
+
449
+ ### What the Shell Renders During Sub-Paths
450
+
451
+ When a sub-path is active:
452
+ - Progress bar shows sub-path steps (parent steps disappear)
453
+ - Back button on sub-path's first step cancels the sub-path
454
+ - Completing the sub-path returns to parent (parent step re-renders)
455
+
456
+ ### Nesting Levels
457
+
458
+ Sub-paths can themselves start sub-paths (unlimited nesting). Use `snapshot.nestingLevel` to determine depth:
459
+ - `0` = top-level path
460
+ - `1` = first-level sub-path
461
+ - `2+` = deeper nesting
93
462
 
94
463
  ## Events
95
464
 
96
465
  ```typescript
97
466
  engine.subscribe((event) => {
98
467
  switch (event.type) {
99
- case "stateChanged": // event.snapshot
468
+ case "stateChanged": // event.cause ("start" | "next" | "previous" | ...), event.snapshot
100
469
  case "completed": // event.pathId, event.data
101
470
  case "cancelled": // event.pathId, event.data
102
471
  case "resumed": // event.resumedPathId, event.fromSubPathId, event.snapshot
103
472
  }
104
473
  });
105
474
  ```
475
+
476
+ Every `stateChanged` event includes a `cause` field (`StateChangeCause`) identifying which public method triggered it. Use this to react to specific operations — for example, the `store-http` package uses `event.cause === "next"` to implement the `onNext` persistence strategy.
477
+
478
+ ## State Persistence
479
+
480
+ The engine supports exporting and restoring state for persistence scenarios (e.g., saving wizard progress to a server or localStorage).
481
+
482
+ ### exportState()
483
+
484
+ Returns a plain JSON-serializable object (`SerializedPathState`) containing the current state:
485
+ - Current path ID and step index
486
+ - Path data
487
+ - Visited step IDs
488
+ - Sub-path stack (if nested paths are active)
489
+ - Navigation flags
490
+
491
+ Returns `null` if no path is active.
492
+
493
+ ```typescript
494
+ const state = engine.exportState();
495
+ if (state) {
496
+ const json = JSON.stringify(state);
497
+ // Save to localStorage, send to server, etc.
498
+ }
499
+ ```
500
+
501
+ ### PathEngine.fromState()
502
+
503
+ Restores a PathEngine from previously exported state. **Important:** You must provide the same path definitions that were active when the state was exported.
504
+
505
+ ```typescript
506
+ const state = JSON.parse(savedJson);
507
+ const engine = PathEngine.fromState(state, {
508
+ "main-path": mainPathDefinition,
509
+ "sub-path": subPathDefinition
510
+ });
511
+
512
+ // Engine is restored to the exact step and state
513
+ const snapshot = engine.snapshot();
514
+ ```
515
+
516
+ Throws if:
517
+ - State references a path ID not in `pathDefinitions`
518
+ - State version is unsupported
519
+
520
+ The restored engine is fully functional — you can continue navigation, modify data, complete or cancel paths normally.
521
+ ```
package/dist/index.d.ts CHANGED
@@ -1,4 +1,33 @@
1
1
  export type PathData = Record<string, unknown>;
2
+ export interface SerializedPathState {
3
+ version: 1;
4
+ pathId: string;
5
+ currentStepIndex: number;
6
+ data: PathData;
7
+ visitedStepIds: string[];
8
+ subPathMeta?: Record<string, unknown>;
9
+ pathStack: Array<{
10
+ pathId: string;
11
+ currentStepIndex: number;
12
+ data: PathData;
13
+ visitedStepIds: string[];
14
+ subPathMeta?: Record<string, unknown>;
15
+ }>;
16
+ _isNavigating: boolean;
17
+ }
18
+ /**
19
+ * The interface every path state store must implement.
20
+ *
21
+ * `HttpStore` from `@daltonr/pathwrite-store-http` is the reference
22
+ * implementation. Any backend — MongoDB, Redis, localStorage, etc. —
23
+ * implements this interface and works with `httpPersistence` and
24
+ * `restoreOrStart` without any other changes.
25
+ */
26
+ export interface PathStore {
27
+ save(key: string, state: SerializedPathState): Promise<void>;
28
+ load(key: string): Promise<SerializedPathState | null>;
29
+ delete(key: string): Promise<void>;
30
+ }
2
31
  export interface PathStepContext<TData extends PathData = PathData> {
3
32
  readonly pathId: string;
4
33
  readonly stepId: string;
@@ -79,8 +108,13 @@ export interface PathSnapshot<TData extends PathData = PathData> {
79
108
  validationMessages: string[];
80
109
  data: TData;
81
110
  }
111
+ /**
112
+ * Identifies the public method that triggered a `stateChanged` event.
113
+ */
114
+ export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "cancel" | "restart";
82
115
  export type PathEvent = {
83
116
  type: "stateChanged";
117
+ cause: StateChangeCause;
84
118
  snapshot: PathSnapshot;
85
119
  } | {
86
120
  type: "completed";
@@ -96,13 +130,93 @@ export type PathEvent = {
96
130
  fromSubPathId: string;
97
131
  snapshot: PathSnapshot;
98
132
  };
133
+ /**
134
+ * A function called on every engine event. Observers are registered at
135
+ * construction time and receive every event for the lifetime of the engine.
136
+ *
137
+ * The second argument is the engine itself — useful when the observer needs to
138
+ * read current state (e.g. calling `engine.exportState()` for persistence).
139
+ *
140
+ * ```typescript
141
+ * const logger: PathObserver = (event) => console.log(event.type);
142
+ * const persist: PathObserver = (event, engine) => { ... };
143
+ * ```
144
+ */
145
+ export type PathObserver = (event: PathEvent, engine: PathEngine) => void;
146
+ /**
147
+ * Determines which engine events an observer should react to.
148
+ *
149
+ * | Strategy | Triggers when |
150
+ * |---------------------|------------------------------------------------------------|
151
+ * | `"onEveryChange"` | Any settled `stateChanged` or `resumed` |
152
+ * | `"onNext"` | `next()` completes navigation *(default)* |
153
+ * | `"onSubPathComplete"` | Sub-path finishes and the parent resumes |
154
+ * | `"onComplete"` | The entire path completes |
155
+ * | `"manual"` | Never — caller decides when to act |
156
+ */
157
+ export type ObserverStrategy = "onEveryChange" | "onNext" | "onSubPathComplete" | "onComplete" | "manual";
158
+ /**
159
+ * Returns `true` when `event` matches the trigger condition for `strategy`.
160
+ *
161
+ * Use this in any `PathObserver` factory to centralise the
162
+ * "which events should I react to?" decision so every observer
163
+ * (HTTP, MongoDB, logger, analytics…) shares the same semantics.
164
+ *
165
+ * ```typescript
166
+ * const observer: PathObserver = (event, engine) => {
167
+ * if (matchesStrategy(strategy, event)) doWork(engine);
168
+ * };
169
+ * ```
170
+ */
171
+ export declare function matchesStrategy(strategy: ObserverStrategy, event: PathEvent): boolean;
172
+ /**
173
+ * Options accepted by the `PathEngine` constructor and `PathEngine.fromState()`.
174
+ */
175
+ export interface PathEngineOptions {
176
+ /**
177
+ * Zero or more observers to register before the first event fires.
178
+ * Each observer is called synchronously on every engine event for the
179
+ * lifetime of the engine. Observers cannot be removed; for removable
180
+ * listeners use `engine.subscribe()`.
181
+ */
182
+ observers?: PathObserver[];
183
+ }
99
184
  export declare class PathEngine {
100
185
  private activePath;
101
186
  private readonly pathStack;
102
187
  private readonly listeners;
103
188
  private _isNavigating;
189
+ constructor(options?: PathEngineOptions);
190
+ /**
191
+ * Restores a PathEngine from previously exported state.
192
+ *
193
+ * **Important:** You must provide the same path definitions that were
194
+ * active when the state was exported. The path IDs in `state` are used
195
+ * to match against the provided definitions.
196
+ *
197
+ * @param state The serialized state from `exportState()`.
198
+ * @param pathDefinitions A map of path ID → definition. Must include the
199
+ * active path and any paths in the stack.
200
+ * @returns A new PathEngine instance with the restored state.
201
+ * @throws If `state` references a path ID not present in `pathDefinitions`,
202
+ * or if the state format is invalid.
203
+ */
204
+ static fromState(state: SerializedPathState, pathDefinitions: Record<string, PathDefinition>, options?: PathEngineOptions): PathEngine;
104
205
  subscribe(listener: (event: PathEvent) => void): () => void;
105
206
  start(path: PathDefinition<any>, initialData?: PathData): Promise<void>;
207
+ /**
208
+ * Tears down any active path (and the entire sub-path stack) without firing
209
+ * lifecycle hooks or emitting `cancelled`, then immediately starts the given
210
+ * path from scratch.
211
+ *
212
+ * Safe to call at any time — whether a path is running, already completed,
213
+ * or has never been started. Use this to implement a "Start over" button or
214
+ * to retry a path after completion without remounting the host component.
215
+ *
216
+ * @param path The path definition to (re)start.
217
+ * @param initialData Data to seed the fresh path with. Defaults to `{}`.
218
+ */
219
+ restart(path: PathDefinition<any>, initialData?: PathData): Promise<void>;
106
220
  /**
107
221
  * Starts a sub-path on top of the currently active path. Throws if no path
108
222
  * is running.
@@ -137,6 +251,18 @@ export declare class PathEngine {
137
251
  */
138
252
  goToStepChecked(stepId: string): Promise<void>;
139
253
  snapshot(): PathSnapshot | null;
254
+ /**
255
+ * Exports the current engine state as a plain JSON-serializable object.
256
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
257
+ * persist and restore wizard progress.
258
+ *
259
+ * Returns `null` if no path is active.
260
+ *
261
+ * **Important:** This only exports the _state_ (data, step position, etc.),
262
+ * not the path definition. When restoring, you must provide the same
263
+ * `PathDefinition` to `fromState()`.
264
+ */
265
+ exportState(): SerializedPathState | null;
140
266
  private _startAsync;
141
267
  private _nextAsync;
142
268
  private _previousAsync;
@@ -159,12 +285,27 @@ export declare class PathEngine {
159
285
  * Evaluates a guard function synchronously for inclusion in the snapshot.
160
286
  * If the guard is absent, returns `true`.
161
287
  * If the guard returns a `Promise`, returns `true` (optimistic default).
288
+ *
289
+ * **Note:** Guards are evaluated on every snapshot, including the very first one
290
+ * emitted at the start of a path — _before_ `onEnter` has run on that step.
291
+ * This means `data` will still reflect the `initialData` passed to `start()`.
292
+ * Write guards defensively (e.g. `(data.name ?? "").trim().length > 0`) so they
293
+ * do not throw when optional fields are absent on first entry.
294
+ *
295
+ * If a guard throws, the error is caught, a `console.warn` is emitted, and the
296
+ * safe default (`true`) is returned so the UI remains operable.
162
297
  */
163
298
  private evaluateGuardSync;
164
299
  /**
165
300
  * Evaluates a validationMessages function synchronously for inclusion in the snapshot.
166
301
  * If the hook is absent, returns `[]`.
167
302
  * If the hook returns a `Promise`, returns `[]` (async hooks are not supported in snapshots).
303
+ *
304
+ * **Note:** Like guards, `validationMessages` is evaluated before `onEnter` runs on first
305
+ * entry. Write it defensively so it does not throw when fields are absent.
306
+ *
307
+ * If the function throws, the error is caught, a `console.warn` is emitted, and `[]`
308
+ * is returned so validation messages do not block the UI unexpectedly.
168
309
  */
169
310
  private evaluateValidationMessagesSync;
170
311
  }