@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/src/index.ts CHANGED
@@ -11,6 +11,25 @@ export type PathData = Record<string, unknown>;
11
11
  */
12
12
  export type FieldErrors = Record<string, string | undefined>;
13
13
 
14
+ /**
15
+ * The return type of a `canMoveNext` or `canMovePrevious` guard.
16
+ *
17
+ * - `true` — allow navigation
18
+ * - `{ allowed: false }` — block silently (no message)
19
+ * - `{ allowed: false, reason: "..." }` — block with a message; the shell surfaces
20
+ * this as `snapshot.blockingError` between the step content and the nav buttons
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * canMoveNext: async ({ data }) => {
25
+ * const result = await checkEligibility(data.applicantId);
26
+ * if (!result.eligible) return { allowed: false, reason: result.reason };
27
+ * return true;
28
+ * }
29
+ * ```
30
+ */
31
+ export type GuardResult = true | { allowed: false; reason?: string };
32
+
14
33
  export interface SerializedPathState {
15
34
  version: 1;
16
35
  pathId: string;
@@ -29,7 +48,7 @@ export interface SerializedPathState {
29
48
  stepEntryData?: PathData;
30
49
  stepEnteredAt?: number;
31
50
  }>;
32
- _isNavigating: boolean;
51
+ _status: PathStatus;
33
52
  }
34
53
 
35
54
  /**
@@ -105,8 +124,8 @@ export interface PathStep<TData extends PathData = PathData> {
105
124
  title?: string;
106
125
  meta?: Record<string, unknown>;
107
126
  shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
108
- canMoveNext?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
109
- canMovePrevious?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
127
+ canMoveNext?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
128
+ canMovePrevious?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
110
129
  /**
111
130
  * Returns a map of field ID → error message explaining why the step is not
112
131
  * yet valid. The shell displays these messages below the step content (labeled
@@ -195,6 +214,32 @@ export interface PathDefinition<TData extends PathData = PathData> {
195
214
 
196
215
  export type StepStatus = "completed" | "current" | "upcoming";
197
216
 
217
+ /**
218
+ * The engine's current operational state. Exposed as `snapshot.status`.
219
+ *
220
+ * | Status | Meaning |
221
+ * |---------------|--------------------------------------------------------------|
222
+ * | `idle` | On a step, waiting for user input |
223
+ * | `entering` | `onEnter` hook is running |
224
+ * | `validating` | `canMoveNext` / `canMovePrevious` guard is running |
225
+ * | `leaving` | `onLeave` hook is running |
226
+ * | `completing` | `PathDefinition.onComplete` is running |
227
+ * | `error` | An async operation failed — see `snapshot.error` for details |
228
+ */
229
+ export type PathStatus =
230
+ | "idle"
231
+ | "entering"
232
+ | "validating"
233
+ | "leaving"
234
+ | "completing"
235
+ | "error";
236
+
237
+ /**
238
+ * The subset of `PathStatus` values that identify which phase was active
239
+ * when an error occurred. Used by `snapshot.error.phase`.
240
+ */
241
+ export type ErrorPhase = Exclude<PathStatus, "idle" | "error">;
242
+
198
243
  export interface StepSummary {
199
244
  id: string;
200
245
  title?: string;
@@ -253,8 +298,32 @@ export interface PathSnapshot<TData extends PathData = PathData> {
253
298
  * progress bar above the sub-path's own progress bar.
254
299
  */
255
300
  rootProgress?: RootProgress;
256
- /** True while an async guard or hook is executing. Use to disable navigation controls. */
257
- isNavigating: boolean;
301
+ /**
302
+ * The engine's current operational state. Use this instead of multiple boolean flags.
303
+ *
304
+ * Common patterns:
305
+ * - `status !== "idle"` — disable all navigation buttons (engine is busy or errored)
306
+ * - `status === "entering"` — show a skeleton/spinner inside the step area
307
+ * - `status === "validating"` — show "Checking…" on the Next button
308
+ * - `status === "error"` — show the retry / suspend error UI
309
+ */
310
+ status: PathStatus;
311
+ /**
312
+ * Structured error set when an engine-invoked async operation throws. `null` when no error is active.
313
+ *
314
+ * `phase` identifies which operation failed so shells can adapt the message.
315
+ * `retryCount` counts how many times the user has explicitly called `retry()` — starts at 0 on
316
+ * first failure, increments on each retry. Use this to escalate from "Try again" to "Come back later".
317
+ *
318
+ * Cleared automatically when navigation succeeds or when `retry()` is called.
319
+ */
320
+ error: { message: string; phase: ErrorPhase; retryCount: number } | null;
321
+ /**
322
+ * `true` when a `PathStore` is attached via `PathEngineOptions.hasPersistence`.
323
+ * Shells use this to decide whether to promise the user that progress is saved
324
+ * when showing the "come back later" escalation message.
325
+ */
326
+ hasPersistence: boolean;
258
327
  /** 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. */
259
328
  canMoveNext: boolean;
260
329
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
@@ -306,6 +375,15 @@ export interface PathSnapshot<TData extends PathData = PathData> {
306
375
  * navigating back to a previously visited step).
307
376
  */
308
377
  stepEnteredAt: number;
378
+ /**
379
+ * A guard-level blocking message set when `canMoveNext` returns
380
+ * `{ allowed: false, reason: "..." }`. `null` when there is no blocking message.
381
+ *
382
+ * Distinct from `fieldErrors` (field-attached) and `error` (async crash).
383
+ * Shells render this between the step content and the navigation buttons.
384
+ * Cleared automatically when the user successfully navigates to a new step.
385
+ */
386
+ blockingError: string | null;
309
387
  /**
310
388
  * Field-keyed validation messages for the current step. Empty object when there are none.
311
389
  * Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
@@ -335,12 +413,15 @@ export type StateChangeCause =
335
413
  | "setData"
336
414
  | "resetStep"
337
415
  | "cancel"
338
- | "restart";
416
+ | "restart"
417
+ | "retry"
418
+ | "suspend";
339
419
 
340
420
  export type PathEvent =
341
421
  | { type: "stateChanged"; cause: StateChangeCause; snapshot: PathSnapshot }
342
422
  | { type: "completed"; pathId: string; data: PathData }
343
423
  | { type: "cancelled"; pathId: string; data: PathData }
424
+ | { type: "suspended"; pathId: string; data: PathData }
344
425
  | {
345
426
  type: "resumed";
346
427
  resumedPathId: string;
@@ -396,14 +477,15 @@ export type ObserverStrategy =
396
477
  export function matchesStrategy(strategy: ObserverStrategy, event: PathEvent): boolean {
397
478
  switch (strategy) {
398
479
  case "onEveryChange":
399
- // Only react once navigation has settled — stateChanged fires twice per
400
- // navigation (isNavigating:true then false).
401
- return (event.type === "stateChanged" && !event.snapshot.isNavigating)
480
+ // Only react once the engine has settled — stateChanged fires on every
481
+ // phase transition; only "idle" and "error" are settled states.
482
+ return (event.type === "stateChanged" &&
483
+ (event.snapshot.status === "idle" || event.snapshot.status === "error"))
402
484
  || event.type === "resumed";
403
485
  case "onNext":
404
486
  return event.type === "stateChanged"
405
487
  && event.cause === "next"
406
- && !event.snapshot.isNavigating;
488
+ && (event.snapshot.status === "idle" || event.snapshot.status === "error");
407
489
  case "onSubPathComplete":
408
490
  return event.type === "resumed";
409
491
  case "onComplete":
@@ -424,6 +506,12 @@ export interface PathEngineOptions {
424
506
  * listeners use `engine.subscribe()`.
425
507
  */
426
508
  observers?: PathObserver[];
509
+ /**
510
+ * Set to `true` when a `PathStore` is attached and will persist path state.
511
+ * Exposed as `snapshot.hasPersistence` so shells can honestly tell the user
512
+ * their progress is saved when showing the "come back later" escalation message.
513
+ */
514
+ hasPersistence?: boolean;
427
515
  }
428
516
 
429
517
  interface ActivePath {
@@ -438,22 +526,61 @@ interface ActivePath {
438
526
  stepEnteredAt: number;
439
527
  /** The selected inner step when the current slot is a StepChoice. Cached on entry. */
440
528
  resolvedChoiceStep?: PathStep;
529
+ /** Step IDs that have been confirmed skipped via shouldSkip during navigation. Used for accurate stepCount / progress in snapshots. */
530
+ resolvedSkips: Set<string>;
441
531
  }
442
532
 
443
533
  function isStepChoice(item: PathStep | StepChoice): item is StepChoice {
444
534
  return "select" in item && "steps" in item;
445
535
  }
446
536
 
537
+ /**
538
+ * Converts a camelCase or lowercase field key to a display label.
539
+ * `"firstName"` → `"First Name"`, `"email"` → `"Email"`.
540
+ * Used by shells to render labeled field-error summaries.
541
+ */
542
+ export function formatFieldKey(key: string): string {
543
+ return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase()).trim();
544
+ }
545
+
546
+ /**
547
+ * Returns a human-readable description of which operation failed, keyed by
548
+ * the `ErrorPhase` value on `snapshot.error.phase`. Used by shells to render
549
+ * the error panel message.
550
+ */
551
+ export function errorPhaseMessage(phase: string): string {
552
+ switch (phase) {
553
+ case "entering": return "Failed to load this step.";
554
+ case "validating": return "The check could not be completed.";
555
+ case "leaving": return "Failed to save your progress.";
556
+ case "completing": return "Your submission could not be sent.";
557
+ default: return "An unexpected error occurred.";
558
+ }
559
+ }
560
+
447
561
  export class PathEngine {
448
562
  private activePath: ActivePath | null = null;
449
563
  private readonly pathStack: ActivePath[] = [];
450
564
  private readonly listeners = new Set<(event: PathEvent) => void>();
451
- private _isNavigating = false;
565
+ private _status: PathStatus = "idle";
452
566
  /** True after the user has called next() on the current step at least once. Resets on step entry. */
453
567
  private _hasAttemptedNext = false;
568
+ /** Blocking message from canMoveNext returning { allowed: false, reason }. Cleared on step entry. */
569
+ private _blockingError: string | null = null;
454
570
  /** The path and initial data from the most recent top-level start() call. Used by restart(). */
455
571
  private _rootPath: PathDefinition<any> | null = null;
456
572
  private _rootInitialData: PathData = {};
573
+ /** Structured error from the most recent failed async operation. Null when no error is active. */
574
+ private _error: { message: string; phase: ErrorPhase; retryCount: number } | null = null;
575
+ /** Stored retry function. Null when no error is pending. */
576
+ private _pendingRetry: (() => Promise<void>) | null = null;
577
+ /**
578
+ * Counts how many times `retry()` has been called for the current error sequence.
579
+ * Reset to 0 by `next()` (fresh navigation). Incremented by `retry()`.
580
+ */
581
+ private _retryCount = 0;
582
+ private _hasPersistence = false;
583
+ private _hasWarnedAsyncShouldSkip = false;
457
584
 
458
585
  constructor(options?: PathEngineOptions) {
459
586
  if (options?.observers) {
@@ -462,6 +589,9 @@ export class PathEngine {
462
589
  this.listeners.add((event) => observer(event, this));
463
590
  }
464
591
  }
592
+ if (options?.hasPersistence) {
593
+ this._hasPersistence = true;
594
+ }
465
595
  }
466
596
 
467
597
  /**
@@ -503,6 +633,7 @@ export class PathEngine {
503
633
  currentStepIndex: stackItem.currentStepIndex,
504
634
  data: { ...stackItem.data },
505
635
  visitedStepIds: new Set(stackItem.visitedStepIds),
636
+ resolvedSkips: new Set(),
506
637
  subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
507
638
  stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
508
639
  stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
@@ -522,6 +653,7 @@ export class PathEngine {
522
653
  currentStepIndex: state.currentStepIndex,
523
654
  data: { ...state.data },
524
655
  visitedStepIds: new Set(state.visitedStepIds),
656
+ resolvedSkips: new Set(),
525
657
  // Active path's subPathMeta is not serialized (it's transient metadata
526
658
  // from the parent when this path was started). On restore, it's undefined.
527
659
  subPathMeta: undefined,
@@ -529,7 +661,7 @@ export class PathEngine {
529
661
  stepEnteredAt: state.stepEnteredAt ?? Date.now()
530
662
  };
531
663
 
532
- engine._isNavigating = state._isNavigating;
664
+ engine._status = state._status ?? "idle";
533
665
 
534
666
  // Re-derive the selected inner step for any StepChoice slots (not serialized —
535
667
  // always recomputed from current data on restore).
@@ -572,7 +704,8 @@ export class PathEngine {
572
704
  if (!this._rootPath) {
573
705
  throw new Error("Cannot restart: engine has not been started. Call start() first.");
574
706
  }
575
- this._isNavigating = false;
707
+ this._status = "idle";
708
+ this._blockingError = null;
576
709
  this.activePath = null;
577
710
  this.pathStack.length = 0;
578
711
  return this._startAsync(this._rootPath, { ...this._rootInitialData });
@@ -597,6 +730,14 @@ export class PathEngine {
597
730
 
598
731
  public next(): Promise<void> {
599
732
  const active = this.requireActivePath();
733
+ // Reset the retry sequence. If we're recovering from an error the user
734
+ // explicitly clicked Next again — clear the error and reset to idle so
735
+ // _nextAsync's entry guard passes. For any other non-idle status (busy)
736
+ // the guard in _nextAsync will drop this call.
737
+ this._retryCount = 0;
738
+ this._error = null;
739
+ this._pendingRetry = null;
740
+ if (this._status === "error") this._status = "idle";
600
741
  return this._nextAsync(active);
601
742
  }
602
743
 
@@ -605,12 +746,54 @@ export class PathEngine {
605
746
  return this._previousAsync(active);
606
747
  }
607
748
 
749
+ /**
750
+ * Re-runs the operation that caused the most recent `snapshot.error`.
751
+ * Increments `snapshot.error.retryCount` so shells can escalate from
752
+ * "Try again" to "Come back later" after repeated failures.
753
+ *
754
+ * No-op if there is no pending error or if navigation is in progress.
755
+ */
756
+ public retry(): Promise<void> {
757
+ if (!this._pendingRetry || this._status !== "error") return Promise.resolve();
758
+ this._retryCount++;
759
+ const fn = this._pendingRetry;
760
+ this._pendingRetry = null;
761
+ this._error = null;
762
+ this._status = "idle"; // allow the retry fn's entry guard to pass
763
+ return fn();
764
+ }
765
+
766
+ /**
767
+ * Pauses the path with intent to return. Preserves all state and data.
768
+ *
769
+ * - Clears any active error state
770
+ * - Emits a `suspended` event that the application can listen for to dismiss
771
+ * the wizard UI (close a modal, navigate away, etc.)
772
+ * - The engine remains in its current state — call `start()` / `restoreOrStart()`
773
+ * to resume when the user returns
774
+ *
775
+ * Use in the "Come back later" escalation path when `snapshot.error.retryCount`
776
+ * has crossed `retryThreshold`. The `suspended` event signals the app to dismiss
777
+ * the UI; Pathwrite's persistence layer handles saving progress automatically via
778
+ * the configured store and observer strategy.
779
+ */
780
+ public suspend(): Promise<void> {
781
+ const active = this.activePath;
782
+ const pathId = active?.definition.id ?? "";
783
+ const data = active ? { ...active.data } : {};
784
+ this._error = null;
785
+ this._pendingRetry = null;
786
+ this._status = "idle";
787
+ this.emit({ type: "suspended", pathId, data });
788
+ return Promise.resolve();
789
+ }
790
+
608
791
  /** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
609
792
  * is async when an `onSubPathCancel` hook is present. Returns a Promise for
610
793
  * API consistency. */
611
794
  public async cancel(): Promise<void> {
612
795
  const active = this.requireActivePath();
613
- if (this._isNavigating) return;
796
+ if (this._status !== "idle") return;
614
797
 
615
798
  const cancelledPathId = active.definition.id;
616
799
  const cancelledData = { ...active.data };
@@ -690,25 +873,34 @@ export class PathEngine {
690
873
  const item = this.getCurrentItem(active);
691
874
  const effectiveStep = this.getEffectiveStep(active);
692
875
  const { steps } = active.definition;
693
- const stepCount = steps.length;
876
+
877
+ // Filter out steps confirmed as skipped during navigation. Steps not yet
878
+ // evaluated (e.g. on first render) are included optimistically.
879
+ const visibleSteps = steps.filter(s => !active.resolvedSkips.has(s.id));
880
+ const stepCount = visibleSteps.length;
881
+ const visibleIndex = visibleSteps.findIndex(s => s.id === item.id);
882
+ // Fall back to raw index if not found (should not happen in normal use)
883
+ const effectiveStepIndex = visibleIndex >= 0 ? visibleIndex : active.currentStepIndex;
694
884
 
695
885
  // Build rootProgress from the bottom of the stack (the top-level path)
696
886
  let rootProgress: RootProgress | undefined;
697
887
  if (this.pathStack.length > 0) {
698
888
  const root = this.pathStack[0];
699
- const rootSteps = root.definition.steps;
700
- const rootStepCount = rootSteps.length;
889
+ const rootVisibleSteps = root.definition.steps.filter(s => !root.resolvedSkips.has(s.id));
890
+ const rootStepCount = rootVisibleSteps.length;
891
+ const rootVisibleIndex = rootVisibleSteps.findIndex(s => s.id === root.definition.steps[root.currentStepIndex]?.id);
892
+ const rootEffectiveIndex = rootVisibleIndex >= 0 ? rootVisibleIndex : root.currentStepIndex;
701
893
  rootProgress = {
702
894
  pathId: root.definition.id,
703
- stepIndex: root.currentStepIndex,
895
+ stepIndex: rootEffectiveIndex,
704
896
  stepCount: rootStepCount,
705
- progress: rootStepCount <= 1 ? 1 : root.currentStepIndex / (rootStepCount - 1),
706
- steps: rootSteps.map((s, i) => ({
897
+ progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
898
+ steps: rootVisibleSteps.map((s, i) => ({
707
899
  id: s.id,
708
900
  title: s.title,
709
901
  meta: s.meta,
710
- status: i < root.currentStepIndex ? "completed" as const
711
- : i === root.currentStepIndex ? "current" as const
902
+ status: i < rootEffectiveIndex ? "completed" as const
903
+ : i === rootEffectiveIndex ? "current" as const
712
904
  : "upcoming" as const
713
905
  }))
714
906
  };
@@ -720,25 +912,28 @@ export class PathEngine {
720
912
  stepTitle: effectiveStep.title ?? item.title,
721
913
  stepMeta: effectiveStep.meta ?? item.meta,
722
914
  formId: isStepChoice(item) ? effectiveStep.id : undefined,
723
- stepIndex: active.currentStepIndex,
915
+ stepIndex: effectiveStepIndex,
724
916
  stepCount,
725
- progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
726
- steps: steps.map((s, i) => ({
917
+ progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
918
+ steps: visibleSteps.map((s, i) => ({
727
919
  id: s.id,
728
920
  title: s.title,
729
921
  meta: s.meta,
730
- status: i < active.currentStepIndex ? "completed" as const
731
- : i === active.currentStepIndex ? "current" as const
922
+ status: i < effectiveStepIndex ? "completed" as const
923
+ : i === effectiveStepIndex ? "current" as const
732
924
  : "upcoming" as const
733
925
  })),
734
- isFirstStep: active.currentStepIndex === 0,
926
+ isFirstStep: effectiveStepIndex === 0,
735
927
  isLastStep:
736
- active.currentStepIndex === stepCount - 1 &&
928
+ effectiveStepIndex === stepCount - 1 &&
737
929
  this.pathStack.length === 0,
738
930
  nestingLevel: this.pathStack.length,
739
931
  rootProgress,
740
- isNavigating: this._isNavigating,
932
+ status: this._status,
933
+ error: this._error,
934
+ hasPersistence: this._hasPersistence,
741
935
  hasAttemptedNext: this._hasAttemptedNext,
936
+ blockingError: this._blockingError,
742
937
  canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
743
938
  canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
744
939
  fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
@@ -784,7 +979,7 @@ export class PathEngine {
784
979
  stepEntryData: { ...p.stepEntryData },
785
980
  stepEnteredAt: p.stepEnteredAt
786
981
  })),
787
- _isNavigating: this._isNavigating
982
+ _status: this._status
788
983
  };
789
984
  }
790
985
 
@@ -793,7 +988,7 @@ export class PathEngine {
793
988
  // ---------------------------------------------------------------------------
794
989
 
795
990
  private async _startAsync(path: PathDefinition, initialData: PathData, subPathMeta?: Record<string, unknown>): Promise<void> {
796
- if (this._isNavigating) return;
991
+ if (this._status !== "idle") return;
797
992
 
798
993
  if (this.activePath !== null) {
799
994
  // Store the meta on the parent before pushing to stack
@@ -809,90 +1004,104 @@ export class PathEngine {
809
1004
  currentStepIndex: 0,
810
1005
  data: { ...initialData },
811
1006
  visitedStepIds: new Set(),
1007
+ resolvedSkips: new Set(),
812
1008
  subPathMeta: undefined,
813
1009
  stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
814
1010
  stepEnteredAt: 0 // Will be set in enterCurrentStep
815
1011
  };
816
1012
 
817
- this._isNavigating = true;
818
-
819
1013
  await this.skipSteps(1);
820
1014
 
821
1015
  if (this.activePath.currentStepIndex >= path.steps.length) {
822
- this._isNavigating = false;
823
- await this.finishActivePath();
1016
+ await this._finishActivePathWithErrorHandling();
824
1017
  return;
825
1018
  }
826
1019
 
1020
+ this._status = "entering";
827
1021
  this.emitStateChanged("start");
828
1022
 
829
- try {
830
- this.applyPatch(await this.enterCurrentStep());
831
- this._isNavigating = false;
832
- this.emitStateChanged("start");
833
- } catch (err) {
834
- this._isNavigating = false;
835
- this.emitStateChanged("start");
836
- throw err;
837
- }
1023
+ await this._enterCurrentStepWithErrorHandling("start");
838
1024
  }
839
1025
 
840
1026
  private async _nextAsync(active: ActivePath): Promise<void> {
841
- if (this._isNavigating) return;
1027
+ if (this._status !== "idle") return;
842
1028
 
843
1029
  // Record that the user has attempted to advance — used by shells and step
844
1030
  // templates to gate error display ("punish late, reward early").
845
1031
  this._hasAttemptedNext = true;
846
- this._isNavigating = true;
1032
+
1033
+ // Phase: validating — canMoveNext guard
1034
+ this._status = "validating";
847
1035
  this.emitStateChanged("next");
848
1036
 
1037
+ let guardResult: { allowed: boolean; reason: string | null };
849
1038
  try {
850
- const step = this.getEffectiveStep(active);
1039
+ guardResult = await this.canMoveNext(active, this.getEffectiveStep(active));
1040
+ } catch (err) {
1041
+ this._error = { message: PathEngine.errorMessage(err), phase: "validating", retryCount: this._retryCount };
1042
+ this._pendingRetry = () => this._nextAsync(active);
1043
+ this._status = "error";
1044
+ this.emitStateChanged("next");
1045
+ return;
1046
+ }
851
1047
 
852
- if (await this.canMoveNext(active, step)) {
853
- this.applyPatch(await this.leaveCurrentStep(active, step));
854
- active.currentStepIndex += 1;
855
- await this.skipSteps(1);
1048
+ if (guardResult.allowed) {
1049
+ // Phase: leaving — onLeave hook
1050
+ this._status = "leaving";
1051
+ this.emitStateChanged("next");
1052
+ try {
1053
+ this.applyPatch(await this.leaveCurrentStep(active, this.getEffectiveStep(active)));
1054
+ } catch (err) {
1055
+ this._error = { message: PathEngine.errorMessage(err), phase: "leaving", retryCount: this._retryCount };
1056
+ this._pendingRetry = () => this._nextAsync(active);
1057
+ this._status = "error";
1058
+ this.emitStateChanged("next");
1059
+ return;
1060
+ }
856
1061
 
857
- if (active.currentStepIndex >= active.definition.steps.length) {
858
- this._isNavigating = false;
859
- await this.finishActivePath();
860
- return;
861
- }
1062
+ active.currentStepIndex += 1;
1063
+ await this.skipSteps(1);
862
1064
 
863
- this.applyPatch(await this.enterCurrentStep());
1065
+ if (active.currentStepIndex >= active.definition.steps.length) {
1066
+ // Phase: completing — PathDefinition.onComplete
1067
+ await this._finishActivePathWithErrorHandling();
1068
+ return;
864
1069
  }
865
1070
 
866
- this._isNavigating = false;
867
- this.emitStateChanged("next");
868
- } catch (err) {
869
- this._isNavigating = false;
870
- this.emitStateChanged("next");
871
- throw err;
1071
+ // Phase: entering — onEnter hook on the new step
1072
+ await this._enterCurrentStepWithErrorHandling("next");
1073
+ return;
872
1074
  }
1075
+
1076
+ this._blockingError = guardResult.reason;
1077
+ this._status = "idle";
1078
+ this.emitStateChanged("next");
873
1079
  }
874
1080
 
875
1081
  private async _previousAsync(active: ActivePath): Promise<void> {
876
- if (this._isNavigating) return;
1082
+ if (this._status !== "idle") return;
877
1083
 
878
1084
  // No-op when already on the first step of a top-level path.
879
1085
  // Sub-paths still cancel/pop back to the parent when previous() is called
880
1086
  // on their first step (the currentStepIndex < 0 branch below handles that).
881
1087
  if (active.currentStepIndex === 0 && this.pathStack.length === 0) return;
882
1088
 
883
- this._isNavigating = true;
1089
+ this._status = "leaving";
884
1090
  this.emitStateChanged("previous");
885
1091
 
886
1092
  try {
887
1093
  const step = this.getEffectiveStep(active);
888
1094
 
889
- if (await this.canMovePrevious(active, step)) {
1095
+ const prevGuard = await this.canMovePrevious(active, step);
1096
+ if (!prevGuard.allowed) this._blockingError = prevGuard.reason;
1097
+
1098
+ if (prevGuard.allowed) {
890
1099
  this.applyPatch(await this.leaveCurrentStep(active, step));
891
1100
  active.currentStepIndex -= 1;
892
1101
  await this.skipSteps(-1);
893
1102
 
894
1103
  if (active.currentStepIndex < 0) {
895
- this._isNavigating = false;
1104
+ this._status = "idle";
896
1105
  await this.cancel();
897
1106
  return;
898
1107
  }
@@ -900,19 +1109,19 @@ export class PathEngine {
900
1109
  this.applyPatch(await this.enterCurrentStep());
901
1110
  }
902
1111
 
903
- this._isNavigating = false;
1112
+ this._status = "idle";
904
1113
  this.emitStateChanged("previous");
905
1114
  } catch (err) {
906
- this._isNavigating = false;
1115
+ this._status = "idle";
907
1116
  this.emitStateChanged("previous");
908
1117
  throw err;
909
1118
  }
910
1119
  }
911
1120
 
912
1121
  private async _goToStepAsync(active: ActivePath, targetIndex: number): Promise<void> {
913
- if (this._isNavigating) return;
1122
+ if (this._status !== "idle") return;
914
1123
 
915
- this._isNavigating = true;
1124
+ this._status = "leaving";
916
1125
  this.emitStateChanged("goToStep");
917
1126
 
918
1127
  try {
@@ -922,38 +1131,41 @@ export class PathEngine {
922
1131
  active.currentStepIndex = targetIndex;
923
1132
 
924
1133
  this.applyPatch(await this.enterCurrentStep());
925
- this._isNavigating = false;
1134
+ this._status = "idle";
926
1135
  this.emitStateChanged("goToStep");
927
1136
  } catch (err) {
928
- this._isNavigating = false;
1137
+ this._status = "idle";
929
1138
  this.emitStateChanged("goToStep");
930
1139
  throw err;
931
1140
  }
932
1141
  }
933
1142
 
934
1143
  private async _goToStepCheckedAsync(active: ActivePath, targetIndex: number): Promise<void> {
935
- if (this._isNavigating) return;
1144
+ if (this._status !== "idle") return;
936
1145
 
937
- this._isNavigating = true;
1146
+ this._status = "validating";
938
1147
  this.emitStateChanged("goToStepChecked");
939
1148
 
940
1149
  try {
941
1150
  const currentStep = this.getEffectiveStep(active);
942
1151
  const goingForward = targetIndex > active.currentStepIndex;
943
- const allowed = goingForward
1152
+ const guardResult = goingForward
944
1153
  ? await this.canMoveNext(active, currentStep)
945
1154
  : await this.canMovePrevious(active, currentStep);
946
1155
 
947
- if (allowed) {
1156
+ if (!guardResult.allowed) this._blockingError = guardResult.reason;
1157
+
1158
+ if (guardResult.allowed) {
1159
+ this._status = "leaving";
948
1160
  this.applyPatch(await this.leaveCurrentStep(active, currentStep));
949
1161
  active.currentStepIndex = targetIndex;
950
1162
  this.applyPatch(await this.enterCurrentStep());
951
1163
  }
952
1164
 
953
- this._isNavigating = false;
1165
+ this._status = "idle";
954
1166
  this.emitStateChanged("goToStepChecked");
955
1167
  } catch (err) {
956
- this._isNavigating = false;
1168
+ this._status = "idle";
957
1169
  this.emitStateChanged("goToStepChecked");
958
1170
  throw err;
959
1171
  }
@@ -969,7 +1181,7 @@ export class PathEngine {
969
1181
  // sub-path (which may have currentStepIndex = -1).
970
1182
  this.activePath = this.pathStack.pop() ?? null;
971
1183
 
972
- this._isNavigating = true;
1184
+ this._status = "leaving";
973
1185
  this.emitStateChanged("cancel");
974
1186
 
975
1187
  try {
@@ -991,10 +1203,10 @@ export class PathEngine {
991
1203
  }
992
1204
  }
993
1205
 
994
- this._isNavigating = false;
1206
+ this._status = "idle";
995
1207
  this.emitStateChanged("cancel");
996
1208
  } catch (err) {
997
- this._isNavigating = false;
1209
+ this._status = "idle";
998
1210
  this.emitStateChanged("cancel");
999
1211
  throw err;
1000
1212
  }
@@ -1032,12 +1244,66 @@ export class PathEngine {
1032
1244
  snapshot: this.snapshot()!
1033
1245
  });
1034
1246
  } else {
1035
- // Top-level path completed — call onComplete hook if defined
1036
- this.activePath = null;
1037
- this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
1247
+ // Top-level path completed — call onComplete before clearing activePath so
1248
+ // that if it throws the engine remains on the final step and can retry.
1038
1249
  if (finished.definition.onComplete) {
1039
1250
  await finished.definition.onComplete(finishedData);
1040
1251
  }
1252
+ this.activePath = null;
1253
+ this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * Wraps `finishActivePath` with error handling for the `completing` phase.
1259
+ * On failure: sets `_error`, stores a retry that re-calls `finishActivePath`,
1260
+ * resets status to `"error"`, and emits `stateChanged`.
1261
+ * On success: resets status to `"idle"` (finishActivePath sets activePath = null,
1262
+ * so no stateChanged is needed — the `completed` event is the terminal signal).
1263
+ */
1264
+ private async _finishActivePathWithErrorHandling(): Promise<void> {
1265
+ const active = this.activePath;
1266
+ this._status = "completing";
1267
+ try {
1268
+ await this.finishActivePath();
1269
+ this._status = "idle";
1270
+ // No stateChanged here — finishActivePath emits "completed" or "resumed"
1271
+ } catch (err) {
1272
+ this._error = { message: PathEngine.errorMessage(err), phase: "completing", retryCount: this._retryCount };
1273
+ // Retry: call finishActivePath again (activePath is still set because onComplete
1274
+ // throws before this.activePath = null in the restructured finishActivePath)
1275
+ this._pendingRetry = () => this._finishActivePathWithErrorHandling();
1276
+ this._status = "error";
1277
+ if (active) {
1278
+ // Restore activePath if it was cleared mid-throw (defensive)
1279
+ if (!this.activePath) this.activePath = active;
1280
+ // Back up to the last valid step so snapshot() can render it while error is shown
1281
+ if (this.activePath.currentStepIndex >= this.activePath.definition.steps.length) {
1282
+ this.activePath.currentStepIndex = this.activePath.definition.steps.length - 1;
1283
+ }
1284
+ this.emitStateChanged("next");
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Wraps `enterCurrentStep` with error handling for the `entering` phase.
1291
+ * Called by both `_startAsync` and `_nextAsync` after advancing to a new step.
1292
+ * On failure: sets `_error`, stores a retry that re-calls this method,
1293
+ * resets status to `"error"`, and emits `stateChanged` with the given `cause`.
1294
+ */
1295
+ private async _enterCurrentStepWithErrorHandling(cause: StateChangeCause): Promise<void> {
1296
+ this._status = "entering";
1297
+ try {
1298
+ this.applyPatch(await this.enterCurrentStep());
1299
+ this._status = "idle";
1300
+ this.emitStateChanged(cause);
1301
+ } catch (err) {
1302
+ this._error = { message: PathEngine.errorMessage(err), phase: "entering", retryCount: this._retryCount };
1303
+ // Retry: re-enter the current step (don't repeat guards/leave)
1304
+ this._pendingRetry = () => this._enterCurrentStepWithErrorHandling(cause);
1305
+ this._status = "error";
1306
+ this.emitStateChanged(cause);
1041
1307
  }
1042
1308
  }
1043
1309
 
@@ -1045,6 +1311,10 @@ export class PathEngine {
1045
1311
  // Private helpers
1046
1312
  // ---------------------------------------------------------------------------
1047
1313
 
1314
+ private static errorMessage(err: unknown): string {
1315
+ return err instanceof Error ? err.message : String(err);
1316
+ }
1317
+
1048
1318
  private requireActivePath(): ActivePath {
1049
1319
  if (this.activePath === null) {
1050
1320
  throw new Error("No active path.");
@@ -1143,15 +1413,36 @@ export class PathEngine {
1143
1413
  active.currentStepIndex < active.definition.steps.length
1144
1414
  ) {
1145
1415
  const item = active.definition.steps[active.currentStepIndex];
1146
- if (!item.shouldSkip) break;
1416
+ if (!item.shouldSkip) {
1417
+ // This step has no shouldSkip — it is definitely visible. Remove it from
1418
+ // the cache in case a previous navigation had marked it as skipped.
1419
+ active.resolvedSkips.delete(item.id);
1420
+ break;
1421
+ }
1147
1422
  const ctx: PathStepContext = {
1148
1423
  pathId: active.definition.id,
1149
1424
  stepId: item.id,
1150
1425
  data: { ...active.data },
1151
1426
  isFirstEntry: !active.visitedStepIds.has(item.id)
1152
1427
  };
1153
- const skip = await item.shouldSkip(ctx);
1154
- if (!skip) break;
1428
+ const rawResult = item.shouldSkip(ctx);
1429
+ if (rawResult && typeof (rawResult as Promise<boolean>).then === "function") {
1430
+ if (!this._hasWarnedAsyncShouldSkip) {
1431
+ this._hasWarnedAsyncShouldSkip = true;
1432
+ console.warn(
1433
+ `[Pathwrite] Step "${item.id}" has an async shouldSkip. ` +
1434
+ `snapshot().stepCount and progress may be approximate until after the first navigation.`
1435
+ );
1436
+ }
1437
+ }
1438
+ const skip = await rawResult;
1439
+ if (!skip) {
1440
+ // This step resolved as NOT skipped — remove from cache in case it was
1441
+ // previously skipped (data changed since last navigation).
1442
+ active.resolvedSkips.delete(item.id);
1443
+ break;
1444
+ }
1445
+ active.resolvedSkips.add(item.id);
1155
1446
  active.currentStepIndex += direction;
1156
1447
  }
1157
1448
  }
@@ -1159,6 +1450,7 @@ export class PathEngine {
1159
1450
  private async enterCurrentStep(): Promise<Partial<PathData> | void> {
1160
1451
  // Each step starts fresh — errors are not shown until the user attempts to proceed.
1161
1452
  this._hasAttemptedNext = false;
1453
+ this._blockingError = null;
1162
1454
  const active = this.activePath;
1163
1455
  if (!active) return;
1164
1456
 
@@ -1205,7 +1497,7 @@ export class PathEngine {
1205
1497
  private async canMoveNext(
1206
1498
  active: ActivePath,
1207
1499
  step: PathStep
1208
- ): Promise<boolean> {
1500
+ ): Promise<{ allowed: boolean; reason: string | null }> {
1209
1501
  if (step.canMoveNext) {
1210
1502
  const ctx: PathStepContext = {
1211
1503
  pathId: active.definition.id,
@@ -1213,26 +1505,34 @@ export class PathEngine {
1213
1505
  data: { ...active.data },
1214
1506
  isFirstEntry: !active.visitedStepIds.has(step.id)
1215
1507
  };
1216
- return step.canMoveNext(ctx);
1508
+ const result = await step.canMoveNext(ctx);
1509
+ return PathEngine.normaliseGuardResult(result);
1217
1510
  }
1218
1511
  if (step.fieldErrors) {
1219
- return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
1512
+ const allowed = Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
1513
+ return { allowed, reason: null };
1220
1514
  }
1221
- return true;
1515
+ return { allowed: true, reason: null };
1222
1516
  }
1223
1517
 
1224
1518
  private async canMovePrevious(
1225
1519
  active: ActivePath,
1226
1520
  step: PathStep
1227
- ): Promise<boolean> {
1228
- if (!step.canMovePrevious) return true;
1521
+ ): Promise<{ allowed: boolean; reason: string | null }> {
1522
+ if (!step.canMovePrevious) return { allowed: true, reason: null };
1229
1523
  const ctx: PathStepContext = {
1230
1524
  pathId: active.definition.id,
1231
1525
  stepId: step.id,
1232
1526
  data: { ...active.data },
1233
1527
  isFirstEntry: !active.visitedStepIds.has(step.id)
1234
1528
  };
1235
- return step.canMovePrevious(ctx);
1529
+ const result = await step.canMovePrevious(ctx);
1530
+ return PathEngine.normaliseGuardResult(result);
1531
+ }
1532
+
1533
+ private static normaliseGuardResult(result: GuardResult): { allowed: boolean; reason: string | null } {
1534
+ if (result === true) return { allowed: true, reason: null };
1535
+ return { allowed: false, reason: result.reason ?? null };
1236
1536
  }
1237
1537
 
1238
1538
  /**
@@ -1250,7 +1550,7 @@ export class PathEngine {
1250
1550
  * safe default (`true`) is returned so the UI remains operable.
1251
1551
  */
1252
1552
  private evaluateGuardSync(
1253
- guard: ((ctx: PathStepContext) => boolean | Promise<boolean>) | undefined,
1553
+ guard: ((ctx: PathStepContext) => GuardResult | Promise<GuardResult>) | undefined,
1254
1554
  active: ActivePath
1255
1555
  ): boolean {
1256
1556
  if (!guard) return true;
@@ -1263,17 +1563,20 @@ export class PathEngine {
1263
1563
  };
1264
1564
  try {
1265
1565
  const result = guard(ctx);
1266
- if (typeof result === "boolean") return result;
1267
- // Async guard detected - warn and return optimistic default
1268
- if (result && typeof result.then === "function") {
1566
+ if (result === true) return true;
1567
+ if (result && typeof (result as Promise<unknown>).then === "function") {
1568
+ // Async guard detected - suppress the unhandled rejection, warn, return optimistic default
1569
+ (result as Promise<unknown>).catch(() => {});
1269
1570
  console.warn(
1270
1571
  `[pathwrite] Async guard detected on step "${item.id}". ` +
1271
1572
  `Guards in snapshots must be synchronous. ` +
1272
1573
  `Returning true (optimistic) as default. ` +
1273
1574
  `The async guard will still be enforced during actual navigation.`
1274
1575
  );
1576
+ return true;
1275
1577
  }
1276
- return true;
1578
+ // { allowed: false, reason? } object returned synchronously
1579
+ return false;
1277
1580
  } catch (err) {
1278
1581
  console.warn(
1279
1582
  `[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
@@ -1376,3 +1679,238 @@ export class PathEngine {
1376
1679
  }
1377
1680
  }
1378
1681
 
1682
+
1683
+ // ---------------------------------------------------------------------------
1684
+ // Services utilities
1685
+ //
1686
+ // Wraps plain async service functions with declarative caching, in-flight
1687
+ // deduplication, configurable retry, and prefetch.
1688
+ // ---------------------------------------------------------------------------
1689
+
1690
+ /** Synchronous key-value store (e.g. localStorage, sessionStorage). */
1691
+ export interface SyncServiceStorage {
1692
+ getItem(key: string): string | null;
1693
+ setItem(key: string, value: string): void;
1694
+ removeItem(key: string): void;
1695
+ }
1696
+
1697
+ /** Asynchronous key-value store (e.g. React Native AsyncStorage). */
1698
+ export interface AsyncServiceStorage {
1699
+ getItem(key: string): Promise<string | null>;
1700
+ setItem(key: string, value: string): Promise<void>;
1701
+ removeItem(key: string): Promise<void>;
1702
+ }
1703
+
1704
+ /** Union accepted by defineServices — sync or async storage. */
1705
+ export type ServiceCacheStorage = SyncServiceStorage | AsyncServiceStorage;
1706
+
1707
+ export type CachePolicy = "auto" | "none";
1708
+
1709
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1710
+ type AnyFn = (...args: any[]) => Promise<any>;
1711
+
1712
+ export interface ServiceMethodConfig<F extends AnyFn> {
1713
+ fn: F;
1714
+ cache: CachePolicy;
1715
+ retry?: number;
1716
+ }
1717
+
1718
+ type ServiceConfig<T extends Record<string, AnyFn>> = {
1719
+ [K in keyof T]: ServiceMethodConfig<T[K]>;
1720
+ };
1721
+
1722
+ export interface DefineServicesOptions {
1723
+ storage?: ServiceCacheStorage;
1724
+ keyPrefix?: string;
1725
+ }
1726
+
1727
+ /**
1728
+ * Thrown when all retry attempts for a service method have been exhausted.
1729
+ */
1730
+ export class ServiceUnavailableError extends Error {
1731
+ constructor(
1732
+ public readonly method: string,
1733
+ public readonly attempts: number,
1734
+ public readonly cause: unknown
1735
+ ) {
1736
+ super(`Service method "${method}" failed after ${attempts} attempt(s).`);
1737
+ this.name = "ServiceUnavailableError";
1738
+ }
1739
+ }
1740
+
1741
+ export type PrefetchManifest<T extends Record<string, AnyFn>> = {
1742
+ [K in keyof T]?: Parameters<T[K]>[] | undefined;
1743
+ };
1744
+
1745
+ export type DefinedServices<T extends Record<string, AnyFn>> = T & {
1746
+ prefetch(manifest?: PrefetchManifest<T>): Promise<void>;
1747
+ };
1748
+
1749
+ function _svcStorageGet(
1750
+ storage: ServiceCacheStorage,
1751
+ key: string
1752
+ ): Promise<string | null> {
1753
+ const result = storage.getItem(key);
1754
+ if (result instanceof Promise) return result;
1755
+ return Promise.resolve(result);
1756
+ }
1757
+
1758
+ function _svcStorageSet(
1759
+ storage: ServiceCacheStorage,
1760
+ key: string,
1761
+ value: string
1762
+ ): Promise<void> {
1763
+ const result = storage.setItem(key, value);
1764
+ if (result instanceof Promise) return result;
1765
+ return Promise.resolve();
1766
+ }
1767
+
1768
+ function _svcSerializeArgs(args: unknown[]): string {
1769
+ try {
1770
+ return JSON.stringify(args);
1771
+ } catch {
1772
+ return args.map(String).join(",");
1773
+ }
1774
+ }
1775
+
1776
+ async function _svcWithRetry<T>(
1777
+ fn: () => Promise<T>,
1778
+ maxRetries: number,
1779
+ methodName: string
1780
+ ): Promise<T> {
1781
+ let lastErr: unknown;
1782
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1783
+ try {
1784
+ return await fn();
1785
+ } catch (err) {
1786
+ lastErr = err;
1787
+ if (attempt < maxRetries) {
1788
+ await new Promise((r) => setTimeout(r, 200 * Math.pow(2, attempt)));
1789
+ }
1790
+ }
1791
+ }
1792
+ throw new ServiceUnavailableError(methodName, maxRetries + 1, lastErr);
1793
+ }
1794
+
1795
+ /**
1796
+ * Wraps a set of async service methods with caching, deduplication, and retry.
1797
+ *
1798
+ * @example
1799
+ * ```ts
1800
+ * const services = defineServices(
1801
+ * {
1802
+ * getRoles: { fn: api.getRoles, cache: 'auto' },
1803
+ * getUser: { fn: api.getUser, cache: 'auto', retry: 2 },
1804
+ * submitForm: { fn: api.submitForm, cache: 'none' },
1805
+ * },
1806
+ * { storage: localStorage, keyPrefix: 'myapp:svc:' }
1807
+ * );
1808
+ *
1809
+ * await services.prefetch();
1810
+ * const roles = await services.getRoles();
1811
+ * ```
1812
+ */
1813
+ export function defineServices<T extends Record<string, AnyFn>>(
1814
+ config: ServiceConfig<T>,
1815
+ options: DefineServicesOptions = {}
1816
+ ): DefinedServices<T> {
1817
+ const { storage, keyPrefix = "pw-svc:" } = options;
1818
+ const memCache = new Map<string, unknown>();
1819
+ const inFlight = new Map<string, Promise<unknown>>();
1820
+
1821
+ if (storage) {
1822
+ const syncStorage = storage as SyncServiceStorage;
1823
+ if (typeof syncStorage.getItem === "function") {
1824
+ try {
1825
+ for (const [methodName, methodConfig] of Object.entries(config)) {
1826
+ if (methodConfig.cache !== "auto") continue;
1827
+ const baseKey = `${keyPrefix}${methodName}`;
1828
+ const raw = syncStorage.getItem(baseKey);
1829
+ if (raw !== null && !((raw as unknown) instanceof Promise)) {
1830
+ try { memCache.set(baseKey, JSON.parse(raw)); } catch { /* ignore */ }
1831
+ }
1832
+ }
1833
+ } catch { /* storage unavailable */ }
1834
+ }
1835
+ }
1836
+
1837
+ function cacheKey(methodName: string, args: unknown[]): string {
1838
+ return args.length === 0
1839
+ ? `${keyPrefix}${methodName}`
1840
+ : `${keyPrefix}${methodName}:${_svcSerializeArgs(args)}`;
1841
+ }
1842
+
1843
+ async function callMethod(
1844
+ methodName: string,
1845
+ methodConfig: ServiceMethodConfig<AnyFn>,
1846
+ args: unknown[]
1847
+ ): Promise<unknown> {
1848
+ if (methodConfig.cache === "none") {
1849
+ return _svcWithRetry(() => methodConfig.fn(...args), methodConfig.retry ?? 0, methodName);
1850
+ }
1851
+
1852
+ const key = cacheKey(methodName, args);
1853
+ if (memCache.has(key)) return memCache.get(key);
1854
+
1855
+ if (storage) {
1856
+ const existing = await _svcStorageGet(storage, key);
1857
+ if (existing !== null) {
1858
+ try {
1859
+ const parsed = JSON.parse(existing);
1860
+ memCache.set(key, parsed);
1861
+ return parsed;
1862
+ } catch { /* corrupt — fall through */ }
1863
+ }
1864
+ }
1865
+
1866
+ if (inFlight.has(key)) return inFlight.get(key);
1867
+
1868
+ const promise = _svcWithRetry(
1869
+ () => methodConfig.fn(...args),
1870
+ methodConfig.retry ?? 0,
1871
+ methodName
1872
+ )
1873
+ .then(async (value) => {
1874
+ memCache.set(key, value);
1875
+ inFlight.delete(key);
1876
+ if (storage) {
1877
+ try { await _svcStorageSet(storage, key, JSON.stringify(value)); } catch { /* non-fatal */ }
1878
+ }
1879
+ return value;
1880
+ })
1881
+ .catch((err) => { inFlight.delete(key); throw err; });
1882
+
1883
+ inFlight.set(key, promise);
1884
+ return promise;
1885
+ }
1886
+
1887
+ const wrapped: Record<string, AnyFn> = {};
1888
+
1889
+ for (const [methodName, methodConfig] of Object.entries(config)) {
1890
+ wrapped[methodName] = (...args: unknown[]) =>
1891
+ callMethod(methodName, methodConfig as ServiceMethodConfig<AnyFn>, args);
1892
+ }
1893
+
1894
+ wrapped.prefetch = async (manifest?: PrefetchManifest<T>): Promise<void> => {
1895
+ const tasks: Promise<unknown>[] = [];
1896
+ if (manifest) {
1897
+ for (const [methodName, argSets] of Object.entries(manifest) as [string, Parameters<AnyFn>[] | undefined][]) {
1898
+ const methodConfig = config[methodName];
1899
+ if (!methodConfig || methodConfig.cache === "none") continue;
1900
+ if (!argSets || argSets.length === 0) {
1901
+ tasks.push(callMethod(methodName, methodConfig, []));
1902
+ } else {
1903
+ for (const argSet of argSets) tasks.push(callMethod(methodName, methodConfig, argSet));
1904
+ }
1905
+ }
1906
+ } else {
1907
+ for (const [methodName, methodConfig] of Object.entries(config)) {
1908
+ if (methodConfig.cache !== "auto" || methodConfig.fn.length > 0) continue;
1909
+ tasks.push(callMethod(methodName, methodConfig, []));
1910
+ }
1911
+ }
1912
+ await Promise.allSettled(tasks);
1913
+ };
1914
+
1915
+ return wrapped as DefinedServices<T>;
1916
+ }