@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/README.md +91 -546
- package/dist/index.d.ts +87 -1
- package/dist/index.js +184 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +269 -1
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
|
+
}
|