@daltonr/pathwrite-core 0.10.0 → 0.11.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
@@ -328,6 +328,17 @@ export interface PathSnapshot<TData extends PathData = PathData> {
328
328
  canMoveNext: boolean;
329
329
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
330
330
  canMovePrevious: boolean;
331
+ /**
332
+ * True after `validate()` has been called on this engine (typically via a `PathRemote`
333
+ * from an outer shell). Unlike `hasAttemptedNext`, this flag is global — it does not
334
+ * reset when navigating between steps. Use this alongside `hasAttemptedNext` to gate
335
+ * inline error display in nested/tabbed shells where all steps must show errors at once:
336
+ *
337
+ * ```ts
338
+ * if (snapshot.hasAttemptedNext || snapshot.hasValidated) { // show errors }
339
+ * ```
340
+ */
341
+ hasValidated: boolean;
331
342
  /**
332
343
  * True after the user has clicked Next / Submit on this step at least once,
333
344
  * regardless of whether navigation succeeded. Resets to `false` when entering
@@ -415,7 +426,8 @@ export type StateChangeCause =
415
426
  | "cancel"
416
427
  | "restart"
417
428
  | "retry"
418
- | "suspend";
429
+ | "suspend"
430
+ | "validate";
419
431
 
420
432
  export type PathEvent =
421
433
  | { type: "stateChanged"; cause: StateChangeCause; snapshot: PathSnapshot }
@@ -495,6 +507,7 @@ export function matchesStrategy(strategy: ObserverStrategy, event: PathEvent): b
495
507
  }
496
508
  }
497
509
 
510
+
498
511
  /**
499
512
  * Options accepted by the `PathEngine` constructor and `PathEngine.fromState()`.
500
513
  */
@@ -565,6 +578,8 @@ export class PathEngine {
565
578
  private _status: PathStatus = "idle";
566
579
  /** True after the user has called next() on the current step at least once. Resets on step entry. */
567
580
  private _hasAttemptedNext = false;
581
+ /** True after validate() has been called. Global — does not reset on step navigation. Resets on start/restart. */
582
+ private _hasValidated = false;
568
583
  /** Blocking message from canMoveNext returning { allowed: false, reason }. Cleared on step entry. */
569
584
  private _blockingError: string | null = null;
570
585
  /** The path and initial data from the most recent top-level start() call. Used by restart(). */
@@ -686,6 +701,7 @@ export class PathEngine {
686
701
  this.assertPathHasSteps(path);
687
702
  this._rootPath = path;
688
703
  this._rootInitialData = initialData;
704
+ this._hasValidated = false;
689
705
  return this._startAsync(path, initialData);
690
706
  }
691
707
 
@@ -706,6 +722,7 @@ export class PathEngine {
706
722
  }
707
723
  this._status = "idle";
708
724
  this._blockingError = null;
725
+ this._hasValidated = false;
709
726
  this.activePath = null;
710
727
  this.pathStack.length = 0;
711
728
  return this._startAsync(this._rootPath, { ...this._rootInitialData });
@@ -864,6 +881,21 @@ export class PathEngine {
864
881
  return this._goToStepCheckedAsync(active, targetIndex);
865
882
  }
866
883
 
884
+ /**
885
+ * Marks the engine as validation-attempted without navigating. Sets the
886
+ * `hasValidated` flag on the snapshot so all step components can show their
887
+ * inline errors simultaneously.
888
+ *
889
+ * Intended for use with `PathRemote` — an outer shell calls this on an
890
+ * inner tabbed shell when the outer Next is clicked, so every inner tab
891
+ * reveals its errors at once rather than requiring the user to visit each one.
892
+ */
893
+ public validate(): void {
894
+ if (this._status !== "idle" || !this.activePath) return;
895
+ this._hasValidated = true;
896
+ this.emitStateChanged("validate");
897
+ }
898
+
867
899
  public snapshot(): PathSnapshot | null {
868
900
  if (this.activePath === null) {
869
901
  return null;
@@ -932,6 +964,7 @@ export class PathEngine {
932
964
  status: this._status,
933
965
  error: this._error,
934
966
  hasPersistence: this._hasPersistence,
967
+ hasValidated: this._hasValidated,
935
968
  hasAttemptedNext: this._hasAttemptedNext,
936
969
  blockingError: this._blockingError,
937
970
  canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
@@ -1679,3 +1712,238 @@ export class PathEngine {
1679
1712
  }
1680
1713
  }
1681
1714
 
1715
+
1716
+ // ---------------------------------------------------------------------------
1717
+ // Services utilities
1718
+ //
1719
+ // Wraps plain async service functions with declarative caching, in-flight
1720
+ // deduplication, configurable retry, and prefetch.
1721
+ // ---------------------------------------------------------------------------
1722
+
1723
+ /** Synchronous key-value store (e.g. localStorage, sessionStorage). */
1724
+ export interface SyncServiceStorage {
1725
+ getItem(key: string): string | null;
1726
+ setItem(key: string, value: string): void;
1727
+ removeItem(key: string): void;
1728
+ }
1729
+
1730
+ /** Asynchronous key-value store (e.g. React Native AsyncStorage). */
1731
+ export interface AsyncServiceStorage {
1732
+ getItem(key: string): Promise<string | null>;
1733
+ setItem(key: string, value: string): Promise<void>;
1734
+ removeItem(key: string): Promise<void>;
1735
+ }
1736
+
1737
+ /** Union accepted by defineServices — sync or async storage. */
1738
+ export type ServiceCacheStorage = SyncServiceStorage | AsyncServiceStorage;
1739
+
1740
+ export type CachePolicy = "auto" | "none";
1741
+
1742
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1743
+ type AnyFn = (...args: any[]) => Promise<any>;
1744
+
1745
+ export interface ServiceMethodConfig<F extends AnyFn> {
1746
+ fn: F;
1747
+ cache: CachePolicy;
1748
+ retry?: number;
1749
+ }
1750
+
1751
+ type ServiceConfig<T extends Record<string, AnyFn>> = {
1752
+ [K in keyof T]: ServiceMethodConfig<T[K]>;
1753
+ };
1754
+
1755
+ export interface DefineServicesOptions {
1756
+ storage?: ServiceCacheStorage;
1757
+ keyPrefix?: string;
1758
+ }
1759
+
1760
+ /**
1761
+ * Thrown when all retry attempts for a service method have been exhausted.
1762
+ */
1763
+ export class ServiceUnavailableError extends Error {
1764
+ constructor(
1765
+ public readonly method: string,
1766
+ public readonly attempts: number,
1767
+ public readonly cause: unknown
1768
+ ) {
1769
+ super(`Service method "${method}" failed after ${attempts} attempt(s).`);
1770
+ this.name = "ServiceUnavailableError";
1771
+ }
1772
+ }
1773
+
1774
+ export type PrefetchManifest<T extends Record<string, AnyFn>> = {
1775
+ [K in keyof T]?: Parameters<T[K]>[] | undefined;
1776
+ };
1777
+
1778
+ export type DefinedServices<T extends Record<string, AnyFn>> = T & {
1779
+ prefetch(manifest?: PrefetchManifest<T>): Promise<void>;
1780
+ };
1781
+
1782
+ function _svcStorageGet(
1783
+ storage: ServiceCacheStorage,
1784
+ key: string
1785
+ ): Promise<string | null> {
1786
+ const result = storage.getItem(key);
1787
+ if (result instanceof Promise) return result;
1788
+ return Promise.resolve(result);
1789
+ }
1790
+
1791
+ function _svcStorageSet(
1792
+ storage: ServiceCacheStorage,
1793
+ key: string,
1794
+ value: string
1795
+ ): Promise<void> {
1796
+ const result = storage.setItem(key, value);
1797
+ if (result instanceof Promise) return result;
1798
+ return Promise.resolve();
1799
+ }
1800
+
1801
+ function _svcSerializeArgs(args: unknown[]): string {
1802
+ try {
1803
+ return JSON.stringify(args);
1804
+ } catch {
1805
+ return args.map(String).join(",");
1806
+ }
1807
+ }
1808
+
1809
+ async function _svcWithRetry<T>(
1810
+ fn: () => Promise<T>,
1811
+ maxRetries: number,
1812
+ methodName: string
1813
+ ): Promise<T> {
1814
+ let lastErr: unknown;
1815
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1816
+ try {
1817
+ return await fn();
1818
+ } catch (err) {
1819
+ lastErr = err;
1820
+ if (attempt < maxRetries) {
1821
+ await new Promise((r) => setTimeout(r, 200 * Math.pow(2, attempt)));
1822
+ }
1823
+ }
1824
+ }
1825
+ throw new ServiceUnavailableError(methodName, maxRetries + 1, lastErr);
1826
+ }
1827
+
1828
+ /**
1829
+ * Wraps a set of async service methods with caching, deduplication, and retry.
1830
+ *
1831
+ * @example
1832
+ * ```ts
1833
+ * const services = defineServices(
1834
+ * {
1835
+ * getRoles: { fn: api.getRoles, cache: 'auto' },
1836
+ * getUser: { fn: api.getUser, cache: 'auto', retry: 2 },
1837
+ * submitForm: { fn: api.submitForm, cache: 'none' },
1838
+ * },
1839
+ * { storage: localStorage, keyPrefix: 'myapp:svc:' }
1840
+ * );
1841
+ *
1842
+ * await services.prefetch();
1843
+ * const roles = await services.getRoles();
1844
+ * ```
1845
+ */
1846
+ export function defineServices<T extends Record<string, AnyFn>>(
1847
+ config: ServiceConfig<T>,
1848
+ options: DefineServicesOptions = {}
1849
+ ): DefinedServices<T> {
1850
+ const { storage, keyPrefix = "pw-svc:" } = options;
1851
+ const memCache = new Map<string, unknown>();
1852
+ const inFlight = new Map<string, Promise<unknown>>();
1853
+
1854
+ if (storage) {
1855
+ const syncStorage = storage as SyncServiceStorage;
1856
+ if (typeof syncStorage.getItem === "function") {
1857
+ try {
1858
+ for (const [methodName, methodConfig] of Object.entries(config)) {
1859
+ if (methodConfig.cache !== "auto") continue;
1860
+ const baseKey = `${keyPrefix}${methodName}`;
1861
+ const raw = syncStorage.getItem(baseKey);
1862
+ if (raw !== null && !((raw as unknown) instanceof Promise)) {
1863
+ try { memCache.set(baseKey, JSON.parse(raw)); } catch { /* ignore */ }
1864
+ }
1865
+ }
1866
+ } catch { /* storage unavailable */ }
1867
+ }
1868
+ }
1869
+
1870
+ function cacheKey(methodName: string, args: unknown[]): string {
1871
+ return args.length === 0
1872
+ ? `${keyPrefix}${methodName}`
1873
+ : `${keyPrefix}${methodName}:${_svcSerializeArgs(args)}`;
1874
+ }
1875
+
1876
+ async function callMethod(
1877
+ methodName: string,
1878
+ methodConfig: ServiceMethodConfig<AnyFn>,
1879
+ args: unknown[]
1880
+ ): Promise<unknown> {
1881
+ if (methodConfig.cache === "none") {
1882
+ return _svcWithRetry(() => methodConfig.fn(...args), methodConfig.retry ?? 0, methodName);
1883
+ }
1884
+
1885
+ const key = cacheKey(methodName, args);
1886
+ if (memCache.has(key)) return memCache.get(key);
1887
+
1888
+ if (storage) {
1889
+ const existing = await _svcStorageGet(storage, key);
1890
+ if (existing !== null) {
1891
+ try {
1892
+ const parsed = JSON.parse(existing);
1893
+ memCache.set(key, parsed);
1894
+ return parsed;
1895
+ } catch { /* corrupt — fall through */ }
1896
+ }
1897
+ }
1898
+
1899
+ if (inFlight.has(key)) return inFlight.get(key);
1900
+
1901
+ const promise = _svcWithRetry(
1902
+ () => methodConfig.fn(...args),
1903
+ methodConfig.retry ?? 0,
1904
+ methodName
1905
+ )
1906
+ .then(async (value) => {
1907
+ memCache.set(key, value);
1908
+ inFlight.delete(key);
1909
+ if (storage) {
1910
+ try { await _svcStorageSet(storage, key, JSON.stringify(value)); } catch { /* non-fatal */ }
1911
+ }
1912
+ return value;
1913
+ })
1914
+ .catch((err) => { inFlight.delete(key); throw err; });
1915
+
1916
+ inFlight.set(key, promise);
1917
+ return promise;
1918
+ }
1919
+
1920
+ const wrapped: Record<string, AnyFn> = {};
1921
+
1922
+ for (const [methodName, methodConfig] of Object.entries(config)) {
1923
+ wrapped[methodName] = (...args: unknown[]) =>
1924
+ callMethod(methodName, methodConfig as ServiceMethodConfig<AnyFn>, args);
1925
+ }
1926
+
1927
+ wrapped.prefetch = async (manifest?: PrefetchManifest<T>): Promise<void> => {
1928
+ const tasks: Promise<unknown>[] = [];
1929
+ if (manifest) {
1930
+ for (const [methodName, argSets] of Object.entries(manifest) as [string, Parameters<AnyFn>[] | undefined][]) {
1931
+ const methodConfig = config[methodName];
1932
+ if (!methodConfig || methodConfig.cache === "none") continue;
1933
+ if (!argSets || argSets.length === 0) {
1934
+ tasks.push(callMethod(methodName, methodConfig, []));
1935
+ } else {
1936
+ for (const argSet of argSets) tasks.push(callMethod(methodName, methodConfig, argSet));
1937
+ }
1938
+ }
1939
+ } else {
1940
+ for (const [methodName, methodConfig] of Object.entries(config)) {
1941
+ if (methodConfig.cache !== "auto" || methodConfig.fn.length > 0) continue;
1942
+ tasks.push(callMethod(methodName, methodConfig, []));
1943
+ }
1944
+ }
1945
+ await Promise.allSettled(tasks);
1946
+ };
1947
+
1948
+ return wrapped as DefinedServices<T>;
1949
+ }