@daltonr/pathwrite-core 0.9.0 → 0.10.1

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.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,7 +48,7 @@ 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.
@@ -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,17 +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;
383
483
  /** The path and initial data from the most recent top-level start() call. Used by restart(). */
384
484
  private _rootPath;
385
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;
386
497
  constructor(options?: PathEngineOptions);
387
498
  /**
388
499
  * Restores a PathEngine from previously exported state.
@@ -428,6 +539,29 @@ export declare class PathEngine {
428
539
  startSubPath(path: PathDefinition<any>, initialData?: PathData, meta?: Record<string, unknown>): Promise<void>;
429
540
  next(): Promise<void>;
430
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>;
431
565
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
432
566
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
433
567
  * API consistency. */
@@ -473,6 +607,22 @@ export declare class PathEngine {
473
607
  private _goToStepCheckedAsync;
474
608
  private _cancelSubPathAsync;
475
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;
476
626
  private requireActivePath;
477
627
  private assertPathHasSteps;
478
628
  private emit;
@@ -497,6 +647,7 @@ export declare class PathEngine {
497
647
  private leaveCurrentStep;
498
648
  private canMoveNext;
499
649
  private canMovePrevious;
650
+ private static normaliseGuardResult;
500
651
  /**
501
652
  * Evaluates a guard function synchronously for inclusion in the snapshot.
502
653
  * If the guard is absent, returns `true`.
@@ -538,3 +689,66 @@ export declare class PathEngine {
538
689
  */
539
690
  private computeIsDirty;
540
691
  }
692
+ /** Synchronous key-value store (e.g. localStorage, sessionStorage). */
693
+ export interface SyncServiceStorage {
694
+ getItem(key: string): string | null;
695
+ setItem(key: string, value: string): void;
696
+ removeItem(key: string): void;
697
+ }
698
+ /** Asynchronous key-value store (e.g. React Native AsyncStorage). */
699
+ export interface AsyncServiceStorage {
700
+ getItem(key: string): Promise<string | null>;
701
+ setItem(key: string, value: string): Promise<void>;
702
+ removeItem(key: string): Promise<void>;
703
+ }
704
+ /** Union accepted by defineServices — sync or async storage. */
705
+ export type ServiceCacheStorage = SyncServiceStorage | AsyncServiceStorage;
706
+ export type CachePolicy = "auto" | "none";
707
+ type AnyFn = (...args: any[]) => Promise<any>;
708
+ export interface ServiceMethodConfig<F extends AnyFn> {
709
+ fn: F;
710
+ cache: CachePolicy;
711
+ retry?: number;
712
+ }
713
+ type ServiceConfig<T extends Record<string, AnyFn>> = {
714
+ [K in keyof T]: ServiceMethodConfig<T[K]>;
715
+ };
716
+ export interface DefineServicesOptions {
717
+ storage?: ServiceCacheStorage;
718
+ keyPrefix?: string;
719
+ }
720
+ /**
721
+ * Thrown when all retry attempts for a service method have been exhausted.
722
+ */
723
+ export declare class ServiceUnavailableError extends Error {
724
+ readonly method: string;
725
+ readonly attempts: number;
726
+ readonly cause: unknown;
727
+ constructor(method: string, attempts: number, cause: unknown);
728
+ }
729
+ export type PrefetchManifest<T extends Record<string, AnyFn>> = {
730
+ [K in keyof T]?: Parameters<T[K]>[] | undefined;
731
+ };
732
+ export type DefinedServices<T extends Record<string, AnyFn>> = T & {
733
+ prefetch(manifest?: PrefetchManifest<T>): Promise<void>;
734
+ };
735
+ /**
736
+ * Wraps a set of async service methods with caching, deduplication, and retry.
737
+ *
738
+ * @example
739
+ * ```ts
740
+ * const services = defineServices(
741
+ * {
742
+ * getRoles: { fn: api.getRoles, cache: 'auto' },
743
+ * getUser: { fn: api.getUser, cache: 'auto', retry: 2 },
744
+ * submitForm: { fn: api.submitForm, cache: 'none' },
745
+ * },
746
+ * { storage: localStorage, keyPrefix: 'myapp:svc:' }
747
+ * );
748
+ *
749
+ * await services.prefetch();
750
+ * const roles = await services.getRoles();
751
+ * ```
752
+ */
753
+ export declare function defineServices<T extends Record<string, AnyFn>>(config: ServiceConfig<T>, options?: DefineServicesOptions): DefinedServices<T>;
754
+ export {};