@daltonr/pathwrite-core 0.8.0 → 0.10.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
@@ -520,7 +520,7 @@ engine.subscribe((event) => {
520
520
  });
521
521
  ```
522
522
 
523
- 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.
523
+ 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` package uses `event.cause === "next"` to implement the `onNext` persistence strategy.
524
524
 
525
525
  ## State Persistence
526
526
 
package/dist/index.d.ts CHANGED
@@ -9,6 +9,27 @@ export type PathData = Record<string, unknown>;
9
9
  * ```
10
10
  */
11
11
  export type FieldErrors = Record<string, string | undefined>;
12
+ /**
13
+ * The return type of a `canMoveNext` or `canMovePrevious` guard.
14
+ *
15
+ * - `true` — allow navigation
16
+ * - `{ allowed: false }` — block silently (no message)
17
+ * - `{ allowed: false, reason: "..." }` — block with a message; the shell surfaces
18
+ * this as `snapshot.blockingError` between the step content and the nav buttons
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * canMoveNext: async ({ data }) => {
23
+ * const result = await checkEligibility(data.applicantId);
24
+ * if (!result.eligible) return { allowed: false, reason: result.reason };
25
+ * return true;
26
+ * }
27
+ * ```
28
+ */
29
+ export type GuardResult = true | {
30
+ allowed: false;
31
+ reason?: string;
32
+ };
12
33
  export interface SerializedPathState {
13
34
  version: 1;
14
35
  pathId: string;
@@ -27,14 +48,14 @@ export interface SerializedPathState {
27
48
  stepEntryData?: PathData;
28
49
  stepEnteredAt?: number;
29
50
  }>;
30
- _isNavigating: boolean;
51
+ _status: PathStatus;
31
52
  }
32
53
  /**
33
54
  * The interface every path state store must implement.
34
55
  *
35
- * `HttpStore` from `@daltonr/pathwrite-store-http` is the reference
56
+ * `HttpStore` from `@daltonr/pathwrite-store` is the reference
36
57
  * implementation. Any backend — MongoDB, Redis, localStorage, etc. —
37
- * implements this interface and works with `httpPersistence` and
58
+ * implements this interface and works with `persistence` and
38
59
  * `restoreOrStart` without any other changes.
39
60
  */
40
61
  export interface PathStore {
@@ -99,8 +120,8 @@ export interface PathStep<TData extends PathData = PathData> {
99
120
  title?: string;
100
121
  meta?: Record<string, unknown>;
101
122
  shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
102
- canMoveNext?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
103
- canMovePrevious?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
123
+ canMoveNext?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
124
+ canMovePrevious?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
104
125
  /**
105
126
  * Returns a map of field ID → error message explaining why the step is not
106
127
  * yet valid. The shell displays these messages below the step content (labeled
@@ -176,6 +197,24 @@ export interface PathDefinition<TData extends PathData = PathData> {
176
197
  onCancel?: (data: TData) => void | Promise<void>;
177
198
  }
178
199
  export type StepStatus = "completed" | "current" | "upcoming";
200
+ /**
201
+ * The engine's current operational state. Exposed as `snapshot.status`.
202
+ *
203
+ * | Status | Meaning |
204
+ * |---------------|--------------------------------------------------------------|
205
+ * | `idle` | On a step, waiting for user input |
206
+ * | `entering` | `onEnter` hook is running |
207
+ * | `validating` | `canMoveNext` / `canMovePrevious` guard is running |
208
+ * | `leaving` | `onLeave` hook is running |
209
+ * | `completing` | `PathDefinition.onComplete` is running |
210
+ * | `error` | An async operation failed — see `snapshot.error` for details |
211
+ */
212
+ export type PathStatus = "idle" | "entering" | "validating" | "leaving" | "completing" | "error";
213
+ /**
214
+ * The subset of `PathStatus` values that identify which phase was active
215
+ * when an error occurred. Used by `snapshot.error.phase`.
216
+ */
217
+ export type ErrorPhase = Exclude<PathStatus, "idle" | "error">;
179
218
  export interface StepSummary {
180
219
  id: string;
181
220
  title?: string;
@@ -231,8 +270,36 @@ export interface PathSnapshot<TData extends PathData = PathData> {
231
270
  * progress bar above the sub-path's own progress bar.
232
271
  */
233
272
  rootProgress?: RootProgress;
234
- /** True while an async guard or hook is executing. Use to disable navigation controls. */
235
- isNavigating: boolean;
273
+ /**
274
+ * The engine's current operational state. Use this instead of multiple boolean flags.
275
+ *
276
+ * Common patterns:
277
+ * - `status !== "idle"` — disable all navigation buttons (engine is busy or errored)
278
+ * - `status === "entering"` — show a skeleton/spinner inside the step area
279
+ * - `status === "validating"` — show "Checking…" on the Next button
280
+ * - `status === "error"` — show the retry / suspend error UI
281
+ */
282
+ status: PathStatus;
283
+ /**
284
+ * Structured error set when an engine-invoked async operation throws. `null` when no error is active.
285
+ *
286
+ * `phase` identifies which operation failed so shells can adapt the message.
287
+ * `retryCount` counts how many times the user has explicitly called `retry()` — starts at 0 on
288
+ * first failure, increments on each retry. Use this to escalate from "Try again" to "Come back later".
289
+ *
290
+ * Cleared automatically when navigation succeeds or when `retry()` is called.
291
+ */
292
+ error: {
293
+ message: string;
294
+ phase: ErrorPhase;
295
+ retryCount: number;
296
+ } | null;
297
+ /**
298
+ * `true` when a `PathStore` is attached via `PathEngineOptions.hasPersistence`.
299
+ * Shells use this to decide whether to promise the user that progress is saved
300
+ * when showing the "come back later" escalation message.
301
+ */
302
+ hasPersistence: boolean;
236
303
  /** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `fieldErrors` is defined and returns no messages, and `canMoveNext` is not explicitly defined. */
237
304
  canMoveNext: boolean;
238
305
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
@@ -284,6 +351,15 @@ export interface PathSnapshot<TData extends PathData = PathData> {
284
351
  * navigating back to a previously visited step).
285
352
  */
286
353
  stepEnteredAt: number;
354
+ /**
355
+ * A guard-level blocking message set when `canMoveNext` returns
356
+ * `{ allowed: false, reason: "..." }`. `null` when there is no blocking message.
357
+ *
358
+ * Distinct from `fieldErrors` (field-attached) and `error` (async crash).
359
+ * Shells render this between the step content and the navigation buttons.
360
+ * Cleared automatically when the user successfully navigates to a new step.
361
+ */
362
+ blockingError: string | null;
287
363
  /**
288
364
  * Field-keyed validation messages for the current step. Empty object when there are none.
289
365
  * Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
@@ -303,7 +379,7 @@ export interface PathSnapshot<TData extends PathData = PathData> {
303
379
  /**
304
380
  * Identifies the public method that triggered a `stateChanged` event.
305
381
  */
306
- export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "resetStep" | "cancel" | "restart";
382
+ export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "resetStep" | "cancel" | "restart" | "retry" | "suspend";
307
383
  export type PathEvent = {
308
384
  type: "stateChanged";
309
385
  cause: StateChangeCause;
@@ -316,6 +392,10 @@ export type PathEvent = {
316
392
  type: "cancelled";
317
393
  pathId: string;
318
394
  data: PathData;
395
+ } | {
396
+ type: "suspended";
397
+ pathId: string;
398
+ data: PathData;
319
399
  } | {
320
400
  type: "resumed";
321
401
  resumedPathId: string;
@@ -372,14 +452,48 @@ export interface PathEngineOptions {
372
452
  * listeners use `engine.subscribe()`.
373
453
  */
374
454
  observers?: PathObserver[];
455
+ /**
456
+ * Set to `true` when a `PathStore` is attached and will persist path state.
457
+ * Exposed as `snapshot.hasPersistence` so shells can honestly tell the user
458
+ * their progress is saved when showing the "come back later" escalation message.
459
+ */
460
+ hasPersistence?: boolean;
375
461
  }
462
+ /**
463
+ * Converts a camelCase or lowercase field key to a display label.
464
+ * `"firstName"` → `"First Name"`, `"email"` → `"Email"`.
465
+ * Used by shells to render labeled field-error summaries.
466
+ */
467
+ export declare function formatFieldKey(key: string): string;
468
+ /**
469
+ * Returns a human-readable description of which operation failed, keyed by
470
+ * the `ErrorPhase` value on `snapshot.error.phase`. Used by shells to render
471
+ * the error panel message.
472
+ */
473
+ export declare function errorPhaseMessage(phase: string): string;
376
474
  export declare class PathEngine {
377
475
  private activePath;
378
476
  private readonly pathStack;
379
477
  private readonly listeners;
380
- private _isNavigating;
478
+ private _status;
381
479
  /** True after the user has called next() on the current step at least once. Resets on step entry. */
382
480
  private _hasAttemptedNext;
481
+ /** Blocking message from canMoveNext returning { allowed: false, reason }. Cleared on step entry. */
482
+ private _blockingError;
483
+ /** The path and initial data from the most recent top-level start() call. Used by restart(). */
484
+ private _rootPath;
485
+ private _rootInitialData;
486
+ /** Structured error from the most recent failed async operation. Null when no error is active. */
487
+ private _error;
488
+ /** Stored retry function. Null when no error is pending. */
489
+ private _pendingRetry;
490
+ /**
491
+ * Counts how many times `retry()` has been called for the current error sequence.
492
+ * Reset to 0 by `next()` (fresh navigation). Incremented by `retry()`.
493
+ */
494
+ private _retryCount;
495
+ private _hasPersistence;
496
+ private _hasWarnedAsyncShouldSkip;
383
497
  constructor(options?: PathEngineOptions);
384
498
  /**
385
499
  * Restores a PathEngine from previously exported state.
@@ -400,17 +514,16 @@ export declare class PathEngine {
400
514
  start(path: PathDefinition<any>, initialData?: PathData): Promise<void>;
401
515
  /**
402
516
  * Tears down any active path (and the entire sub-path stack) without firing
403
- * lifecycle hooks or emitting `cancelled`, then immediately starts the given
404
- * path from scratch.
517
+ * lifecycle hooks or emitting `cancelled`, then immediately restarts the same
518
+ * path with the same initial data that was passed to the original `start()` call.
405
519
  *
406
520
  * Safe to call at any time — whether a path is running, already completed,
407
521
  * or has never been started. Use this to implement a "Start over" button or
408
522
  * to retry a path after completion without remounting the host component.
409
523
  *
410
- * @param path The path definition to (re)start.
411
- * @param initialData Data to seed the fresh path with. Defaults to `{}`.
524
+ * @throws If `restart()` is called before `start()` has ever been called.
412
525
  */
413
- restart(path: PathDefinition<any>, initialData?: PathData): Promise<void>;
526
+ restart(): Promise<void>;
414
527
  /**
415
528
  * Starts a sub-path on top of the currently active path. Throws if no path
416
529
  * is running.
@@ -426,6 +539,29 @@ export declare class PathEngine {
426
539
  startSubPath(path: PathDefinition<any>, initialData?: PathData, meta?: Record<string, unknown>): Promise<void>;
427
540
  next(): Promise<void>;
428
541
  previous(): Promise<void>;
542
+ /**
543
+ * Re-runs the operation that caused the most recent `snapshot.error`.
544
+ * Increments `snapshot.error.retryCount` so shells can escalate from
545
+ * "Try again" to "Come back later" after repeated failures.
546
+ *
547
+ * No-op if there is no pending error or if navigation is in progress.
548
+ */
549
+ retry(): Promise<void>;
550
+ /**
551
+ * Pauses the path with intent to return. Preserves all state and data.
552
+ *
553
+ * - Clears any active error state
554
+ * - Emits a `suspended` event that the application can listen for to dismiss
555
+ * the wizard UI (close a modal, navigate away, etc.)
556
+ * - The engine remains in its current state — call `start()` / `restoreOrStart()`
557
+ * to resume when the user returns
558
+ *
559
+ * Use in the "Come back later" escalation path when `snapshot.error.retryCount`
560
+ * has crossed `retryThreshold`. The `suspended` event signals the app to dismiss
561
+ * the UI; Pathwrite's persistence layer handles saving progress automatically via
562
+ * the configured store and observer strategy.
563
+ */
564
+ suspend(): Promise<void>;
429
565
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
430
566
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
431
567
  * API consistency. */
@@ -454,7 +590,7 @@ export declare class PathEngine {
454
590
  snapshot(): PathSnapshot | null;
455
591
  /**
456
592
  * Exports the current engine state as a plain JSON-serializable object.
457
- * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
593
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
458
594
  * persist and restore wizard progress.
459
595
  *
460
596
  * Returns `null` if no path is active.
@@ -471,6 +607,22 @@ export declare class PathEngine {
471
607
  private _goToStepCheckedAsync;
472
608
  private _cancelSubPathAsync;
473
609
  private finishActivePath;
610
+ /**
611
+ * Wraps `finishActivePath` with error handling for the `completing` phase.
612
+ * On failure: sets `_error`, stores a retry that re-calls `finishActivePath`,
613
+ * resets status to `"error"`, and emits `stateChanged`.
614
+ * On success: resets status to `"idle"` (finishActivePath sets activePath = null,
615
+ * so no stateChanged is needed — the `completed` event is the terminal signal).
616
+ */
617
+ private _finishActivePathWithErrorHandling;
618
+ /**
619
+ * Wraps `enterCurrentStep` with error handling for the `entering` phase.
620
+ * Called by both `_startAsync` and `_nextAsync` after advancing to a new step.
621
+ * On failure: sets `_error`, stores a retry that re-calls this method,
622
+ * resets status to `"error"`, and emits `stateChanged` with the given `cause`.
623
+ */
624
+ private _enterCurrentStepWithErrorHandling;
625
+ private static errorMessage;
474
626
  private requireActivePath;
475
627
  private assertPathHasSteps;
476
628
  private emit;
@@ -495,6 +647,7 @@ export declare class PathEngine {
495
647
  private leaveCurrentStep;
496
648
  private canMoveNext;
497
649
  private canMovePrevious;
650
+ private static normaliseGuardResult;
498
651
  /**
499
652
  * Evaluates a guard function synchronously for inclusion in the snapshot.
500
653
  * If the guard is absent, returns `true`.