@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/src/index.ts CHANGED
@@ -1,5 +1,36 @@
1
1
  export type PathData = Record<string, unknown>;
2
2
 
3
+ export interface SerializedPathState {
4
+ version: 1;
5
+ pathId: string;
6
+ currentStepIndex: number;
7
+ data: PathData;
8
+ visitedStepIds: string[];
9
+ subPathMeta?: Record<string, unknown>;
10
+ pathStack: Array<{
11
+ pathId: string;
12
+ currentStepIndex: number;
13
+ data: PathData;
14
+ visitedStepIds: string[];
15
+ subPathMeta?: Record<string, unknown>;
16
+ }>;
17
+ _isNavigating: boolean;
18
+ }
19
+
20
+ /**
21
+ * The interface every path state store must implement.
22
+ *
23
+ * `HttpStore` from `@daltonr/pathwrite-store-http` is the reference
24
+ * implementation. Any backend — MongoDB, Redis, localStorage, etc. —
25
+ * implements this interface and works with `httpPersistence` and
26
+ * `restoreOrStart` without any other changes.
27
+ */
28
+ export interface PathStore {
29
+ save(key: string, state: SerializedPathState): Promise<void>;
30
+ load(key: string): Promise<SerializedPathState | null>;
31
+ delete(key: string): Promise<void>;
32
+ }
33
+
3
34
  export interface PathStepContext<TData extends PathData = PathData> {
4
35
  readonly pathId: string;
5
36
  readonly stepId: string;
@@ -96,8 +127,21 @@ export interface PathSnapshot<TData extends PathData = PathData> {
96
127
  data: TData;
97
128
  }
98
129
 
130
+ /**
131
+ * Identifies the public method that triggered a `stateChanged` event.
132
+ */
133
+ export type StateChangeCause =
134
+ | "start"
135
+ | "next"
136
+ | "previous"
137
+ | "goToStep"
138
+ | "goToStepChecked"
139
+ | "setData"
140
+ | "cancel"
141
+ | "restart";
142
+
99
143
  export type PathEvent =
100
- | { type: "stateChanged"; snapshot: PathSnapshot }
144
+ | { type: "stateChanged"; cause: StateChangeCause; snapshot: PathSnapshot }
101
145
  | { type: "completed"; pathId: string; data: PathData }
102
146
  | { type: "cancelled"; pathId: string; data: PathData }
103
147
  | {
@@ -107,6 +151,84 @@ export type PathEvent =
107
151
  snapshot: PathSnapshot;
108
152
  };
109
153
 
154
+ /**
155
+ * A function called on every engine event. Observers are registered at
156
+ * construction time and receive every event for the lifetime of the engine.
157
+ *
158
+ * The second argument is the engine itself — useful when the observer needs to
159
+ * read current state (e.g. calling `engine.exportState()` for persistence).
160
+ *
161
+ * ```typescript
162
+ * const logger: PathObserver = (event) => console.log(event.type);
163
+ * const persist: PathObserver = (event, engine) => { ... };
164
+ * ```
165
+ */
166
+ export type PathObserver = (event: PathEvent, engine: PathEngine) => void;
167
+
168
+ /**
169
+ * Determines which engine events an observer should react to.
170
+ *
171
+ * | Strategy | Triggers when |
172
+ * |---------------------|------------------------------------------------------------|
173
+ * | `"onEveryChange"` | Any settled `stateChanged` or `resumed` |
174
+ * | `"onNext"` | `next()` completes navigation *(default)* |
175
+ * | `"onSubPathComplete"` | Sub-path finishes and the parent resumes |
176
+ * | `"onComplete"` | The entire path completes |
177
+ * | `"manual"` | Never — caller decides when to act |
178
+ */
179
+ export type ObserverStrategy =
180
+ | "onEveryChange"
181
+ | "onNext"
182
+ | "onSubPathComplete"
183
+ | "onComplete"
184
+ | "manual";
185
+
186
+ /**
187
+ * Returns `true` when `event` matches the trigger condition for `strategy`.
188
+ *
189
+ * Use this in any `PathObserver` factory to centralise the
190
+ * "which events should I react to?" decision so every observer
191
+ * (HTTP, MongoDB, logger, analytics…) shares the same semantics.
192
+ *
193
+ * ```typescript
194
+ * const observer: PathObserver = (event, engine) => {
195
+ * if (matchesStrategy(strategy, event)) doWork(engine);
196
+ * };
197
+ * ```
198
+ */
199
+ export function matchesStrategy(strategy: ObserverStrategy, event: PathEvent): boolean {
200
+ switch (strategy) {
201
+ case "onEveryChange":
202
+ // Only react once navigation has settled — stateChanged fires twice per
203
+ // navigation (isNavigating:true then false).
204
+ return (event.type === "stateChanged" && !event.snapshot.isNavigating)
205
+ || event.type === "resumed";
206
+ case "onNext":
207
+ return event.type === "stateChanged"
208
+ && event.cause === "next"
209
+ && !event.snapshot.isNavigating;
210
+ case "onSubPathComplete":
211
+ return event.type === "resumed";
212
+ case "onComplete":
213
+ return event.type === "completed";
214
+ case "manual":
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Options accepted by the `PathEngine` constructor and `PathEngine.fromState()`.
221
+ */
222
+ export interface PathEngineOptions {
223
+ /**
224
+ * Zero or more observers to register before the first event fires.
225
+ * Each observer is called synchronously on every engine event for the
226
+ * lifetime of the engine. Observers cannot be removed; for removable
227
+ * listeners use `engine.subscribe()`.
228
+ */
229
+ observers?: PathObserver[];
230
+ }
231
+
110
232
  interface ActivePath {
111
233
  definition: PathDefinition;
112
234
  currentStepIndex: number;
@@ -121,6 +243,81 @@ export class PathEngine {
121
243
  private readonly listeners = new Set<(event: PathEvent) => void>();
122
244
  private _isNavigating = false;
123
245
 
246
+ constructor(options?: PathEngineOptions) {
247
+ if (options?.observers) {
248
+ for (const observer of options.observers) {
249
+ // Wrap so observer receives the engine instance as the second argument
250
+ this.listeners.add((event) => observer(event, this));
251
+ }
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Restores a PathEngine from previously exported state.
257
+ *
258
+ * **Important:** You must provide the same path definitions that were
259
+ * active when the state was exported. The path IDs in `state` are used
260
+ * to match against the provided definitions.
261
+ *
262
+ * @param state The serialized state from `exportState()`.
263
+ * @param pathDefinitions A map of path ID → definition. Must include the
264
+ * active path and any paths in the stack.
265
+ * @returns A new PathEngine instance with the restored state.
266
+ * @throws If `state` references a path ID not present in `pathDefinitions`,
267
+ * or if the state format is invalid.
268
+ */
269
+ public static fromState(
270
+ state: SerializedPathState,
271
+ pathDefinitions: Record<string, PathDefinition>,
272
+ options?: PathEngineOptions
273
+ ): PathEngine {
274
+ if (state.version !== 1) {
275
+ throw new Error(`Unsupported SerializedPathState version: ${state.version}`);
276
+ }
277
+
278
+ const engine = new PathEngine(options);
279
+
280
+ // Restore the path stack (sub-paths)
281
+ for (const stackItem of state.pathStack) {
282
+ const definition = pathDefinitions[stackItem.pathId];
283
+ if (!definition) {
284
+ throw new Error(
285
+ `Cannot restore state: path definition "${stackItem.pathId}" not found. ` +
286
+ `Provide all path definitions that were active when state was exported.`
287
+ );
288
+ }
289
+ engine.pathStack.push({
290
+ definition,
291
+ currentStepIndex: stackItem.currentStepIndex,
292
+ data: { ...stackItem.data },
293
+ visitedStepIds: new Set(stackItem.visitedStepIds),
294
+ subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
295
+ });
296
+ }
297
+
298
+ // Restore the active path
299
+ const activeDefinition = pathDefinitions[state.pathId];
300
+ if (!activeDefinition) {
301
+ throw new Error(
302
+ `Cannot restore state: active path definition "${state.pathId}" not found.`
303
+ );
304
+ }
305
+
306
+ engine.activePath = {
307
+ definition: activeDefinition,
308
+ currentStepIndex: state.currentStepIndex,
309
+ data: { ...state.data },
310
+ visitedStepIds: new Set(state.visitedStepIds),
311
+ // Active path's subPathMeta is not serialized (it's transient metadata
312
+ // from the parent when this path was started). On restore, it's undefined.
313
+ subPathMeta: undefined
314
+ };
315
+
316
+ engine._isNavigating = state._isNavigating;
317
+
318
+ return engine;
319
+ }
320
+
124
321
  public subscribe(listener: (event: PathEvent) => void): () => void {
125
322
  this.listeners.add(listener);
126
323
  return () => this.listeners.delete(listener);
@@ -135,6 +332,26 @@ export class PathEngine {
135
332
  return this._startAsync(path, initialData);
136
333
  }
137
334
 
335
+ /**
336
+ * Tears down any active path (and the entire sub-path stack) without firing
337
+ * lifecycle hooks or emitting `cancelled`, then immediately starts the given
338
+ * path from scratch.
339
+ *
340
+ * Safe to call at any time — whether a path is running, already completed,
341
+ * or has never been started. Use this to implement a "Start over" button or
342
+ * to retry a path after completion without remounting the host component.
343
+ *
344
+ * @param path The path definition to (re)start.
345
+ * @param initialData Data to seed the fresh path with. Defaults to `{}`.
346
+ */
347
+ public restart(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
348
+ this.assertPathHasSteps(path);
349
+ this._isNavigating = false;
350
+ this.activePath = null;
351
+ this.pathStack.length = 0;
352
+ return this._startAsync(path, initialData);
353
+ }
354
+
138
355
  /**
139
356
  * Starts a sub-path on top of the currently active path. Throws if no path
140
357
  * is running.
@@ -171,9 +388,11 @@ export class PathEngine {
171
388
 
172
389
  const cancelledPathId = active.definition.id;
173
390
  const cancelledData = { ...active.data };
174
- const cancelledMeta = active.subPathMeta;
175
391
 
176
392
  if (this.pathStack.length > 0) {
393
+ // Get meta from the parent in the stack
394
+ const parent = this.pathStack[this.pathStack.length - 1];
395
+ const cancelledMeta = parent.subPathMeta;
177
396
  return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
178
397
  }
179
398
 
@@ -185,7 +404,7 @@ export class PathEngine {
185
404
  public setData(key: string, value: unknown): Promise<void> {
186
405
  const active = this.requireActivePath();
187
406
  active.data[key] = value;
188
- this.emitStateChanged();
407
+ this.emitStateChanged("setData");
189
408
  return Promise.resolve();
190
409
  }
191
410
 
@@ -258,6 +477,41 @@ export class PathEngine {
258
477
  };
259
478
  }
260
479
 
480
+ /**
481
+ * Exports the current engine state as a plain JSON-serializable object.
482
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
483
+ * persist and restore wizard progress.
484
+ *
485
+ * Returns `null` if no path is active.
486
+ *
487
+ * **Important:** This only exports the _state_ (data, step position, etc.),
488
+ * not the path definition. When restoring, you must provide the same
489
+ * `PathDefinition` to `fromState()`.
490
+ */
491
+ public exportState(): SerializedPathState | null {
492
+ if (this.activePath === null) {
493
+ return null;
494
+ }
495
+
496
+ const active = this.activePath;
497
+
498
+ return {
499
+ version: 1,
500
+ pathId: active.definition.id,
501
+ currentStepIndex: active.currentStepIndex,
502
+ data: { ...active.data },
503
+ visitedStepIds: Array.from(active.visitedStepIds),
504
+ pathStack: this.pathStack.map((p) => ({
505
+ pathId: p.definition.id,
506
+ currentStepIndex: p.currentStepIndex,
507
+ data: { ...p.data },
508
+ visitedStepIds: Array.from(p.visitedStepIds),
509
+ subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
510
+ })),
511
+ _isNavigating: this._isNavigating
512
+ };
513
+ }
514
+
261
515
  // ---------------------------------------------------------------------------
262
516
  // Private async helpers
263
517
  // ---------------------------------------------------------------------------
@@ -266,7 +520,12 @@ export class PathEngine {
266
520
  if (this._isNavigating) return;
267
521
 
268
522
  if (this.activePath !== null) {
269
- this.pathStack.push(this.activePath);
523
+ // Store the meta on the parent before pushing to stack
524
+ const parentWithMeta: ActivePath = {
525
+ ...this.activePath,
526
+ subPathMeta
527
+ };
528
+ this.pathStack.push(parentWithMeta);
270
529
  }
271
530
 
272
531
  this.activePath = {
@@ -274,7 +533,7 @@ export class PathEngine {
274
533
  currentStepIndex: 0,
275
534
  data: { ...initialData },
276
535
  visitedStepIds: new Set(),
277
- subPathMeta
536
+ subPathMeta: undefined
278
537
  };
279
538
 
280
539
  this._isNavigating = true;
@@ -287,15 +546,15 @@ export class PathEngine {
287
546
  return;
288
547
  }
289
548
 
290
- this.emitStateChanged();
549
+ this.emitStateChanged("start");
291
550
 
292
551
  try {
293
552
  this.applyPatch(await this.enterCurrentStep());
294
553
  this._isNavigating = false;
295
- this.emitStateChanged();
554
+ this.emitStateChanged("start");
296
555
  } catch (err) {
297
556
  this._isNavigating = false;
298
- this.emitStateChanged();
557
+ this.emitStateChanged("start");
299
558
  throw err;
300
559
  }
301
560
  }
@@ -304,7 +563,7 @@ export class PathEngine {
304
563
  if (this._isNavigating) return;
305
564
 
306
565
  this._isNavigating = true;
307
- this.emitStateChanged();
566
+ this.emitStateChanged("next");
308
567
 
309
568
  try {
310
569
  const step = this.getCurrentStep(active);
@@ -324,10 +583,10 @@ export class PathEngine {
324
583
  }
325
584
 
326
585
  this._isNavigating = false;
327
- this.emitStateChanged();
586
+ this.emitStateChanged("next");
328
587
  } catch (err) {
329
588
  this._isNavigating = false;
330
- this.emitStateChanged();
589
+ this.emitStateChanged("next");
331
590
  throw err;
332
591
  }
333
592
  }
@@ -341,7 +600,7 @@ export class PathEngine {
341
600
  if (active.currentStepIndex === 0 && this.pathStack.length === 0) return;
342
601
 
343
602
  this._isNavigating = true;
344
- this.emitStateChanged();
603
+ this.emitStateChanged("previous");
345
604
 
346
605
  try {
347
606
  const step = this.getCurrentStep(active);
@@ -361,10 +620,10 @@ export class PathEngine {
361
620
  }
362
621
 
363
622
  this._isNavigating = false;
364
- this.emitStateChanged();
623
+ this.emitStateChanged("previous");
365
624
  } catch (err) {
366
625
  this._isNavigating = false;
367
- this.emitStateChanged();
626
+ this.emitStateChanged("previous");
368
627
  throw err;
369
628
  }
370
629
  }
@@ -373,7 +632,7 @@ export class PathEngine {
373
632
  if (this._isNavigating) return;
374
633
 
375
634
  this._isNavigating = true;
376
- this.emitStateChanged();
635
+ this.emitStateChanged("goToStep");
377
636
 
378
637
  try {
379
638
  const currentStep = this.getCurrentStep(active);
@@ -383,10 +642,10 @@ export class PathEngine {
383
642
 
384
643
  this.applyPatch(await this.enterCurrentStep());
385
644
  this._isNavigating = false;
386
- this.emitStateChanged();
645
+ this.emitStateChanged("goToStep");
387
646
  } catch (err) {
388
647
  this._isNavigating = false;
389
- this.emitStateChanged();
648
+ this.emitStateChanged("goToStep");
390
649
  throw err;
391
650
  }
392
651
  }
@@ -395,7 +654,7 @@ export class PathEngine {
395
654
  if (this._isNavigating) return;
396
655
 
397
656
  this._isNavigating = true;
398
- this.emitStateChanged();
657
+ this.emitStateChanged("goToStepChecked");
399
658
 
400
659
  try {
401
660
  const currentStep = this.getCurrentStep(active);
@@ -411,10 +670,10 @@ export class PathEngine {
411
670
  }
412
671
 
413
672
  this._isNavigating = false;
414
- this.emitStateChanged();
673
+ this.emitStateChanged("goToStepChecked");
415
674
  } catch (err) {
416
675
  this._isNavigating = false;
417
- this.emitStateChanged();
676
+ this.emitStateChanged("goToStepChecked");
418
677
  throw err;
419
678
  }
420
679
  }
@@ -430,7 +689,7 @@ export class PathEngine {
430
689
  this.activePath = this.pathStack.pop() ?? null;
431
690
 
432
691
  this._isNavigating = true;
433
- this.emitStateChanged();
692
+ this.emitStateChanged("cancel");
434
693
 
435
694
  try {
436
695
  const parent = this.activePath;
@@ -451,10 +710,10 @@ export class PathEngine {
451
710
  }
452
711
 
453
712
  this._isNavigating = false;
454
- this.emitStateChanged();
713
+ this.emitStateChanged("cancel");
455
714
  } catch (err) {
456
715
  this._isNavigating = false;
457
- this.emitStateChanged();
716
+ this.emitStateChanged("cancel");
458
717
  throw err;
459
718
  }
460
719
  }
@@ -463,11 +722,12 @@ export class PathEngine {
463
722
  const finished = this.requireActivePath();
464
723
  const finishedPathId = finished.definition.id;
465
724
  const finishedData = { ...finished.data };
466
- const finishedMeta = finished.subPathMeta;
467
725
 
468
726
  if (this.pathStack.length > 0) {
469
- this.activePath = this.pathStack.pop()!;
470
- const parent = this.activePath;
727
+ const parent = this.pathStack.pop()!;
728
+ // The meta is stored on the parent, not the sub-path
729
+ const finishedMeta = parent.subPathMeta;
730
+ this.activePath = parent;
471
731
  const parentStep = this.getCurrentStep(parent);
472
732
 
473
733
  if (parentStep.onSubPathComplete) {
@@ -517,8 +777,8 @@ export class PathEngine {
517
777
  }
518
778
  }
519
779
 
520
- private emitStateChanged(): void {
521
- this.emit({ type: "stateChanged", snapshot: this.snapshot()! });
780
+ private emitStateChanged(cause: StateChangeCause): void {
781
+ this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
522
782
  }
523
783
 
524
784
  private getCurrentStep(active: ActivePath): PathStep {
@@ -620,6 +880,15 @@ export class PathEngine {
620
880
  * Evaluates a guard function synchronously for inclusion in the snapshot.
621
881
  * If the guard is absent, returns `true`.
622
882
  * If the guard returns a `Promise`, returns `true` (optimistic default).
883
+ *
884
+ * **Note:** Guards are evaluated on every snapshot, including the very first one
885
+ * emitted at the start of a path — _before_ `onEnter` has run on that step.
886
+ * This means `data` will still reflect the `initialData` passed to `start()`.
887
+ * Write guards defensively (e.g. `(data.name ?? "").trim().length > 0`) so they
888
+ * do not throw when optional fields are absent on first entry.
889
+ *
890
+ * If a guard throws, the error is caught, a `console.warn` is emitted, and the
891
+ * safe default (`true`) is returned so the UI remains operable.
623
892
  */
624
893
  private evaluateGuardSync(
625
894
  guard: ((ctx: PathStepContext) => boolean | Promise<boolean>) | undefined,
@@ -633,15 +902,41 @@ export class PathEngine {
633
902
  data: { ...active.data },
634
903
  isFirstEntry: !active.visitedStepIds.has(step.id)
635
904
  };
636
- const result = guard(ctx);
637
- if (typeof result === "boolean") return result;
638
- return true;
905
+ try {
906
+ const result = guard(ctx);
907
+ if (typeof result === "boolean") return result;
908
+ // Async guard detected - warn and return optimistic default
909
+ if (result && typeof result.then === "function") {
910
+ console.warn(
911
+ `[pathwrite] Async guard detected on step "${step.id}". ` +
912
+ `Guards in snapshots must be synchronous. ` +
913
+ `Returning true (optimistic) as default. ` +
914
+ `The async guard will still be enforced during actual navigation.`
915
+ );
916
+ }
917
+ return true;
918
+ } catch (err) {
919
+ console.warn(
920
+ `[pathwrite] Guard on step "${step.id}" threw an error during snapshot evaluation. ` +
921
+ `Returning true (allow navigation) as a safe default. ` +
922
+ `Note: guards are evaluated before onEnter runs on first entry — ` +
923
+ `ensure guards handle missing/undefined data gracefully.`,
924
+ err
925
+ );
926
+ return true;
927
+ }
639
928
  }
640
929
 
641
930
  /**
642
931
  * Evaluates a validationMessages function synchronously for inclusion in the snapshot.
643
932
  * If the hook is absent, returns `[]`.
644
933
  * If the hook returns a `Promise`, returns `[]` (async hooks are not supported in snapshots).
934
+ *
935
+ * **Note:** Like guards, `validationMessages` is evaluated before `onEnter` runs on first
936
+ * entry. Write it defensively so it does not throw when fields are absent.
937
+ *
938
+ * If the function throws, the error is caught, a `console.warn` is emitted, and `[]`
939
+ * is returned so validation messages do not block the UI unexpectedly.
645
940
  */
646
941
  private evaluateValidationMessagesSync(
647
942
  fn: ((ctx: PathStepContext) => string[] | Promise<string[]>) | undefined,
@@ -655,9 +950,29 @@ export class PathEngine {
655
950
  data: { ...active.data },
656
951
  isFirstEntry: !active.visitedStepIds.has(step.id)
657
952
  };
658
- const result = fn(ctx);
659
- if (Array.isArray(result)) return result;
660
- return [];
953
+ try {
954
+ const result = fn(ctx);
955
+ if (Array.isArray(result)) return result;
956
+ // Async validationMessages detected - warn and return empty array
957
+ if (result && typeof result.then === "function") {
958
+ console.warn(
959
+ `[pathwrite] Async validationMessages detected on step "${step.id}". ` +
960
+ `validationMessages in snapshots must be synchronous. ` +
961
+ `Returning [] as default. ` +
962
+ `Use synchronous validation or move async checks to canMoveNext.`
963
+ );
964
+ }
965
+ return [];
966
+ } catch (err) {
967
+ console.warn(
968
+ `[pathwrite] validationMessages on step "${step.id}" threw an error during snapshot evaluation. ` +
969
+ `Returning [] as a safe default. ` +
970
+ `Note: validationMessages is evaluated before onEnter runs on first entry — ` +
971
+ `ensure it handles missing/undefined data gracefully.`,
972
+ err
973
+ );
974
+ return [];
975
+ }
661
976
  }
662
977
  }
663
978