@daltonr/pathwrite-core 0.2.1 → 0.4.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,9 +1,34 @@
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
+
3
20
  export interface PathStepContext<TData extends PathData = PathData> {
4
21
  readonly pathId: string;
5
22
  readonly stepId: string;
6
23
  readonly data: Readonly<TData>;
24
+ /**
25
+ * `true` the first time this step is entered within the current path
26
+ * instance. `false` on all subsequent re-entries (e.g. navigating back
27
+ * then forward again). Use inside `onEnter` to distinguish initialisation
28
+ * from re-entry so you don't accidentally overwrite data the user has
29
+ * already filled in.
30
+ */
31
+ readonly isFirstEntry: boolean;
7
32
  }
8
33
 
9
34
  export interface PathStep<TData extends PathData = PathData> {
@@ -22,10 +47,31 @@ export interface PathStep<TData extends PathData = PathData> {
22
47
  validationMessages?: (ctx: PathStepContext<TData>) => string[] | Promise<string[]>;
23
48
  onEnter?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
24
49
  onLeave?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
50
+ /**
51
+ * Called on the parent step when a sub-path completes naturally (user
52
+ * reached the last step). Receives the sub-path ID, its final data, the
53
+ * parent step context, and the optional `meta` object that was passed to
54
+ * `startSubPath()` for correlation (e.g. a collection item index).
55
+ */
25
56
  onSubPathComplete?: (
26
57
  subPathId: string,
27
58
  subPathData: PathData,
28
- ctx: PathStepContext<TData>
59
+ ctx: PathStepContext<TData>,
60
+ meta?: Record<string, unknown>
61
+ ) => Partial<TData> | void | Promise<Partial<TData> | void>;
62
+ /**
63
+ * Called on the parent step when a sub-path is cancelled — either via an
64
+ * explicit `cancel()` call or by pressing Back on the sub-path's first step.
65
+ * Receives the sub-path ID, its data at time of cancellation, the parent
66
+ * step context, and the optional `meta` passed to `startSubPath()`.
67
+ * Return a patch to update the parent path's data (e.g. to record a
68
+ * "skipped" or "declined" outcome).
69
+ */
70
+ onSubPathCancel?: (
71
+ subPathId: string,
72
+ subPathData: PathData,
73
+ ctx: PathStepContext<TData>,
74
+ meta?: Record<string, unknown>
29
75
  ) => Partial<TData> | void | Promise<Partial<TData> | void>;
30
76
  }
31
77
 
@@ -82,6 +128,8 @@ interface ActivePath {
82
128
  definition: PathDefinition;
83
129
  currentStepIndex: number;
84
130
  data: PathData;
131
+ visitedStepIds: Set<string>;
132
+ subPathMeta?: Record<string, unknown>;
85
133
  }
86
134
 
87
135
  export class PathEngine {
@@ -90,6 +138,71 @@ export class PathEngine {
90
138
  private readonly listeners = new Set<(event: PathEvent) => void>();
91
139
  private _isNavigating = false;
92
140
 
141
+ /**
142
+ * Restores a PathEngine from previously exported state.
143
+ *
144
+ * **Important:** You must provide the same path definitions that were
145
+ * active when the state was exported. The path IDs in `state` are used
146
+ * to match against the provided definitions.
147
+ *
148
+ * @param state The serialized state from `exportState()`.
149
+ * @param pathDefinitions A map of path ID → definition. Must include the
150
+ * active path and any paths in the stack.
151
+ * @returns A new PathEngine instance with the restored state.
152
+ * @throws If `state` references a path ID not present in `pathDefinitions`,
153
+ * or if the state format is invalid.
154
+ */
155
+ public static fromState(
156
+ state: SerializedPathState,
157
+ pathDefinitions: Record<string, PathDefinition>
158
+ ): PathEngine {
159
+ if (state.version !== 1) {
160
+ throw new Error(`Unsupported SerializedPathState version: ${state.version}`);
161
+ }
162
+
163
+ const engine = new PathEngine();
164
+
165
+ // Restore the path stack (sub-paths)
166
+ for (const stackItem of state.pathStack) {
167
+ const definition = pathDefinitions[stackItem.pathId];
168
+ if (!definition) {
169
+ throw new Error(
170
+ `Cannot restore state: path definition "${stackItem.pathId}" not found. ` +
171
+ `Provide all path definitions that were active when state was exported.`
172
+ );
173
+ }
174
+ engine.pathStack.push({
175
+ definition,
176
+ currentStepIndex: stackItem.currentStepIndex,
177
+ data: { ...stackItem.data },
178
+ visitedStepIds: new Set(stackItem.visitedStepIds),
179
+ subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
180
+ });
181
+ }
182
+
183
+ // Restore the active path
184
+ const activeDefinition = pathDefinitions[state.pathId];
185
+ if (!activeDefinition) {
186
+ throw new Error(
187
+ `Cannot restore state: active path definition "${state.pathId}" not found.`
188
+ );
189
+ }
190
+
191
+ engine.activePath = {
192
+ definition: activeDefinition,
193
+ currentStepIndex: state.currentStepIndex,
194
+ data: { ...state.data },
195
+ visitedStepIds: new Set(state.visitedStepIds),
196
+ // Active path's subPathMeta is not serialized (it's transient metadata
197
+ // from the parent when this path was started). On restore, it's undefined.
198
+ subPathMeta: undefined
199
+ };
200
+
201
+ engine._isNavigating = state._isNavigating;
202
+
203
+ return engine;
204
+ }
205
+
93
206
  public subscribe(listener: (event: PathEvent) => void): () => void {
94
207
  this.listeners.add(listener);
95
208
  return () => this.listeners.delete(listener);
@@ -104,10 +217,41 @@ export class PathEngine {
104
217
  return this._startAsync(path, initialData);
105
218
  }
106
219
 
107
- /** Starts a sub-path on top of the currently active path. Throws if no path is running. */
108
- public startSubPath(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
220
+ /**
221
+ * Tears down any active path (and the entire sub-path stack) without firing
222
+ * lifecycle hooks or emitting `cancelled`, then immediately starts the given
223
+ * path from scratch.
224
+ *
225
+ * Safe to call at any time — whether a path is running, already completed,
226
+ * or has never been started. Use this to implement a "Start over" button or
227
+ * to retry a path after completion without remounting the host component.
228
+ *
229
+ * @param path The path definition to (re)start.
230
+ * @param initialData Data to seed the fresh path with. Defaults to `{}`.
231
+ */
232
+ public restart(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
233
+ this.assertPathHasSteps(path);
234
+ this._isNavigating = false;
235
+ this.activePath = null;
236
+ this.pathStack.length = 0;
237
+ return this._startAsync(path, initialData);
238
+ }
239
+
240
+ /**
241
+ * Starts a sub-path on top of the currently active path. Throws if no path
242
+ * is running.
243
+ *
244
+ * @param path The sub-path definition to start.
245
+ * @param initialData Data to seed the sub-path with.
246
+ * @param meta Optional correlation object returned unchanged to the
247
+ * parent step's `onSubPathComplete` / `onSubPathCancel`
248
+ * hooks. Use to identify which collection item triggered
249
+ * the sub-path without embedding that information in the
250
+ * sub-path's own data.
251
+ */
252
+ public startSubPath(path: PathDefinition<any>, initialData: PathData = {}, meta?: Record<string, unknown>): Promise<void> {
109
253
  this.requireActivePath();
110
- return this.start(path, initialData);
254
+ return this._startAsync(path, initialData, meta);
111
255
  }
112
256
 
113
257
  public next(): Promise<void> {
@@ -120,7 +264,9 @@ export class PathEngine {
120
264
  return this._previousAsync(active);
121
265
  }
122
266
 
123
- /** Cancel is synchronous (no hooks). Returns a resolved Promise for API consistency. */
267
+ /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
268
+ * is async when an `onSubPathCancel` hook is present. Returns a Promise for
269
+ * API consistency. */
124
270
  public cancel(): Promise<void> {
125
271
  const active = this.requireActivePath();
126
272
  if (this._isNavigating) return Promise.resolve();
@@ -129,9 +275,10 @@ export class PathEngine {
129
275
  const cancelledData = { ...active.data };
130
276
 
131
277
  if (this.pathStack.length > 0) {
132
- this.activePath = this.pathStack.pop() ?? null;
133
- this.emitStateChanged();
134
- return Promise.resolve();
278
+ // Get meta from the parent in the stack
279
+ const parent = this.pathStack[this.pathStack.length - 1];
280
+ const cancelledMeta = parent.subPathMeta;
281
+ return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
135
282
  }
136
283
 
137
284
  this.activePath = null;
@@ -215,21 +362,63 @@ export class PathEngine {
215
362
  };
216
363
  }
217
364
 
365
+ /**
366
+ * Exports the current engine state as a plain JSON-serializable object.
367
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
368
+ * persist and restore wizard progress.
369
+ *
370
+ * Returns `null` if no path is active.
371
+ *
372
+ * **Important:** This only exports the _state_ (data, step position, etc.),
373
+ * not the path definition. When restoring, you must provide the same
374
+ * `PathDefinition` to `fromState()`.
375
+ */
376
+ public exportState(): SerializedPathState | null {
377
+ if (this.activePath === null) {
378
+ return null;
379
+ }
380
+
381
+ const active = this.activePath;
382
+
383
+ return {
384
+ version: 1,
385
+ pathId: active.definition.id,
386
+ currentStepIndex: active.currentStepIndex,
387
+ data: { ...active.data },
388
+ visitedStepIds: Array.from(active.visitedStepIds),
389
+ pathStack: this.pathStack.map((p) => ({
390
+ pathId: p.definition.id,
391
+ currentStepIndex: p.currentStepIndex,
392
+ data: { ...p.data },
393
+ visitedStepIds: Array.from(p.visitedStepIds),
394
+ subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
395
+ })),
396
+ _isNavigating: this._isNavigating
397
+ };
398
+ }
399
+
218
400
  // ---------------------------------------------------------------------------
219
401
  // Private async helpers
220
402
  // ---------------------------------------------------------------------------
221
403
 
222
- private async _startAsync(path: PathDefinition, initialData: PathData): Promise<void> {
404
+ private async _startAsync(path: PathDefinition, initialData: PathData, subPathMeta?: Record<string, unknown>): Promise<void> {
223
405
  if (this._isNavigating) return;
224
406
 
225
407
  if (this.activePath !== null) {
226
- this.pathStack.push(this.activePath);
408
+ // Store the meta on the parent before pushing to stack
409
+ const parentWithMeta: ActivePath = {
410
+ ...this.activePath,
411
+ subPathMeta
412
+ };
413
+ this.pathStack.push(parentWithMeta);
227
414
  }
228
415
 
229
416
  this.activePath = {
230
417
  definition: path,
231
418
  currentStepIndex: 0,
232
- data: { ...initialData }
419
+ data: { ...initialData },
420
+ visitedStepIds: new Set(),
421
+ subPathMeta: undefined
233
422
  };
234
423
 
235
424
  this._isNavigating = true;
@@ -374,24 +563,67 @@ export class PathEngine {
374
563
  }
375
564
  }
376
565
 
566
+ private async _cancelSubPathAsync(
567
+ cancelledPathId: string,
568
+ cancelledData: PathData,
569
+ cancelledMeta?: Record<string, unknown>
570
+ ): Promise<void> {
571
+ // Pop the stack BEFORE emitting so snapshot() always reflects the parent
572
+ // path (which has a valid currentStepIndex) rather than the cancelled
573
+ // sub-path (which may have currentStepIndex = -1).
574
+ this.activePath = this.pathStack.pop() ?? null;
575
+
576
+ this._isNavigating = true;
577
+ this.emitStateChanged();
578
+
579
+ try {
580
+ const parent = this.activePath;
581
+
582
+ if (parent) {
583
+ const parentStep = this.getCurrentStep(parent);
584
+ if (parentStep.onSubPathCancel) {
585
+ const ctx: PathStepContext = {
586
+ pathId: parent.definition.id,
587
+ stepId: parentStep.id,
588
+ data: { ...parent.data },
589
+ isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
590
+ };
591
+ this.applyPatch(
592
+ await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
593
+ );
594
+ }
595
+ }
596
+
597
+ this._isNavigating = false;
598
+ this.emitStateChanged();
599
+ } catch (err) {
600
+ this._isNavigating = false;
601
+ this.emitStateChanged();
602
+ throw err;
603
+ }
604
+ }
605
+
377
606
  private async finishActivePath(): Promise<void> {
378
607
  const finished = this.requireActivePath();
379
608
  const finishedPathId = finished.definition.id;
380
609
  const finishedData = { ...finished.data };
381
610
 
382
611
  if (this.pathStack.length > 0) {
383
- this.activePath = this.pathStack.pop()!;
384
- const parent = this.activePath;
612
+ const parent = this.pathStack.pop()!;
613
+ // The meta is stored on the parent, not the sub-path
614
+ const finishedMeta = parent.subPathMeta;
615
+ this.activePath = parent;
385
616
  const parentStep = this.getCurrentStep(parent);
386
617
 
387
618
  if (parentStep.onSubPathComplete) {
388
619
  const ctx: PathStepContext = {
389
620
  pathId: parent.definition.id,
390
621
  stepId: parentStep.id,
391
- data: { ...parent.data }
622
+ data: { ...parent.data },
623
+ isFirstEntry: !parent.visitedStepIds.has(parentStep.id)
392
624
  };
393
625
  this.applyPatch(
394
- await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx)
626
+ await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
395
627
  );
396
628
  }
397
629
 
@@ -460,7 +692,8 @@ export class PathEngine {
460
692
  const ctx: PathStepContext = {
461
693
  pathId: active.definition.id,
462
694
  stepId: step.id,
463
- data: { ...active.data }
695
+ data: { ...active.data },
696
+ isFirstEntry: !active.visitedStepIds.has(step.id)
464
697
  };
465
698
  const skip = await step.shouldSkip(ctx);
466
699
  if (!skip) break;
@@ -472,11 +705,16 @@ export class PathEngine {
472
705
  const active = this.activePath;
473
706
  if (!active) return;
474
707
  const step = this.getCurrentStep(active);
708
+ const isFirstEntry = !active.visitedStepIds.has(step.id);
709
+ // Mark as visited before calling onEnter so re-entrant calls see the
710
+ // correct isFirstEntry value.
711
+ active.visitedStepIds.add(step.id);
475
712
  if (!step.onEnter) return;
476
713
  const ctx: PathStepContext = {
477
714
  pathId: active.definition.id,
478
715
  stepId: step.id,
479
- data: { ...active.data }
716
+ data: { ...active.data },
717
+ isFirstEntry
480
718
  };
481
719
  return step.onEnter(ctx);
482
720
  }
@@ -489,7 +727,8 @@ export class PathEngine {
489
727
  const ctx: PathStepContext = {
490
728
  pathId: active.definition.id,
491
729
  stepId: step.id,
492
- data: { ...active.data }
730
+ data: { ...active.data },
731
+ isFirstEntry: !active.visitedStepIds.has(step.id)
493
732
  };
494
733
  return step.onLeave(ctx);
495
734
  }
@@ -502,7 +741,8 @@ export class PathEngine {
502
741
  const ctx: PathStepContext = {
503
742
  pathId: active.definition.id,
504
743
  stepId: step.id,
505
- data: { ...active.data }
744
+ data: { ...active.data },
745
+ isFirstEntry: !active.visitedStepIds.has(step.id)
506
746
  };
507
747
  return step.canMoveNext(ctx);
508
748
  }
@@ -515,7 +755,8 @@ export class PathEngine {
515
755
  const ctx: PathStepContext = {
516
756
  pathId: active.definition.id,
517
757
  stepId: step.id,
518
- data: { ...active.data }
758
+ data: { ...active.data },
759
+ isFirstEntry: !active.visitedStepIds.has(step.id)
519
760
  };
520
761
  return step.canMovePrevious(ctx);
521
762
  }
@@ -524,42 +765,99 @@ export class PathEngine {
524
765
  * Evaluates a guard function synchronously for inclusion in the snapshot.
525
766
  * If the guard is absent, returns `true`.
526
767
  * If the guard returns a `Promise`, returns `true` (optimistic default).
768
+ *
769
+ * **Note:** Guards are evaluated on every snapshot, including the very first one
770
+ * emitted at the start of a path — _before_ `onEnter` has run on that step.
771
+ * This means `data` will still reflect the `initialData` passed to `start()`.
772
+ * Write guards defensively (e.g. `(data.name ?? "").trim().length > 0`) so they
773
+ * do not throw when optional fields are absent on first entry.
774
+ *
775
+ * If a guard throws, the error is caught, a `console.warn` is emitted, and the
776
+ * safe default (`true`) is returned so the UI remains operable.
527
777
  */
528
778
  private evaluateGuardSync(
529
779
  guard: ((ctx: PathStepContext) => boolean | Promise<boolean>) | undefined,
530
780
  active: ActivePath
531
781
  ): boolean {
532
782
  if (!guard) return true;
783
+ const step = this.getCurrentStep(active);
533
784
  const ctx: PathStepContext = {
534
785
  pathId: active.definition.id,
535
- stepId: this.getCurrentStep(active).id,
536
- data: { ...active.data }
786
+ stepId: step.id,
787
+ data: { ...active.data },
788
+ isFirstEntry: !active.visitedStepIds.has(step.id)
537
789
  };
538
- const result = guard(ctx);
539
- if (typeof result === "boolean") return result;
540
- // Async guard default to true (optimistic); the engine will enforce the real result on navigation.
541
- return true;
790
+ try {
791
+ const result = guard(ctx);
792
+ if (typeof result === "boolean") return result;
793
+ // Async guard detected - warn and return optimistic default
794
+ if (result && typeof result.then === "function") {
795
+ console.warn(
796
+ `[pathwrite] Async guard detected on step "${step.id}". ` +
797
+ `Guards in snapshots must be synchronous. ` +
798
+ `Returning true (optimistic) as default. ` +
799
+ `The async guard will still be enforced during actual navigation.`
800
+ );
801
+ }
802
+ return true;
803
+ } catch (err) {
804
+ console.warn(
805
+ `[pathwrite] Guard on step "${step.id}" threw an error during snapshot evaluation. ` +
806
+ `Returning true (allow navigation) as a safe default. ` +
807
+ `Note: guards are evaluated before onEnter runs on first entry — ` +
808
+ `ensure guards handle missing/undefined data gracefully.`,
809
+ err
810
+ );
811
+ return true;
812
+ }
542
813
  }
543
814
 
544
815
  /**
545
816
  * Evaluates a validationMessages function synchronously for inclusion in the snapshot.
546
817
  * If the hook is absent, returns `[]`.
547
818
  * If the hook returns a `Promise`, returns `[]` (async hooks are not supported in snapshots).
819
+ *
820
+ * **Note:** Like guards, `validationMessages` is evaluated before `onEnter` runs on first
821
+ * entry. Write it defensively so it does not throw when fields are absent.
822
+ *
823
+ * If the function throws, the error is caught, a `console.warn` is emitted, and `[]`
824
+ * is returned so validation messages do not block the UI unexpectedly.
548
825
  */
549
826
  private evaluateValidationMessagesSync(
550
827
  fn: ((ctx: PathStepContext) => string[] | Promise<string[]>) | undefined,
551
828
  active: ActivePath
552
829
  ): string[] {
553
830
  if (!fn) return [];
831
+ const step = this.getCurrentStep(active);
554
832
  const ctx: PathStepContext = {
555
833
  pathId: active.definition.id,
556
- stepId: this.getCurrentStep(active).id,
557
- data: { ...active.data }
834
+ stepId: step.id,
835
+ data: { ...active.data },
836
+ isFirstEntry: !active.visitedStepIds.has(step.id)
558
837
  };
559
- const result = fn(ctx);
560
- if (Array.isArray(result)) return result;
561
- // Async hook — default to empty; consumers should keep validationMessages synchronous.
562
- return [];
838
+ try {
839
+ const result = fn(ctx);
840
+ if (Array.isArray(result)) return result;
841
+ // Async validationMessages detected - warn and return empty array
842
+ if (result && typeof result.then === "function") {
843
+ console.warn(
844
+ `[pathwrite] Async validationMessages detected on step "${step.id}". ` +
845
+ `validationMessages in snapshots must be synchronous. ` +
846
+ `Returning [] as default. ` +
847
+ `Use synchronous validation or move async checks to canMoveNext.`
848
+ );
849
+ }
850
+ return [];
851
+ } catch (err) {
852
+ console.warn(
853
+ `[pathwrite] validationMessages on step "${step.id}" threw an error during snapshot evaluation. ` +
854
+ `Returning [] as a safe default. ` +
855
+ `Note: validationMessages is evaluated before onEnter runs on first entry — ` +
856
+ `ensure it handles missing/undefined data gracefully.`,
857
+ err
858
+ );
859
+ return [];
860
+ }
563
861
  }
564
862
  }
565
863