@estiva-app/platform 0.3.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @estiva-app/platform
2
2
 
3
+ ## 0.4.0
4
+
5
+ One read a tab holds on to, and reads again when it fails (FOL-5). Additive.
6
+
7
+ `createRetryingRead` is Peek's FOL-42 `useFolders` without React or a relay:
8
+ a failure is read again on a backoff (5 s doubling to 60 s, or the relay's own
9
+ `retry in Ns` for a quota refusal), a failure never replaces a good answer, and
10
+ the answer is held past the component that asked. Ship's Folder listing read
11
+ once and turned one failure into an empty list for the session, which dropped
12
+ every project out of its team until a reload.
13
+
14
+ - `createRetryingRead<T>({ retryable?, firstRetryMs?, maxRetryMs?, freshMs?, now?, timers? })`
15
+ → `{ snapshot, subscribe, ensure(load), reload(load), dispose, clear }`.
16
+ Wrap it with `useSyncExternalStore(read.subscribe, read.snapshot)`. Retries
17
+ run only while something is subscribed; `clear()` forgets the answer, and a
18
+ read in flight across it writes nothing.
19
+ - `retryDelayMs(failures, error)`, `RETRY_FIRST_MS`, `RETRY_MAX_MS`.
20
+
3
21
  ## 0.3.1
4
22
 
5
23
  Subscribers on one interval share one timer (PER-6). No API change.
package/dist/index.d.ts CHANGED
@@ -22,5 +22,6 @@
22
22
  export { MAX_FOLDERS_PER_SUBSCRIPTION, createLiveClient, createLiveClientHolder, type FolderWatch, type LiveClient, type LiveClientHolder, type LiveClientObservers, type LiveClientOptions, } from './live-client.js';
23
23
  export { createFolderActivity, type FolderActivity, type FolderListener, } from './folder-activity.js';
24
24
  export { assumeOnline, browserOnlineSource, type OnlineSource, type OnlineTarget } from './online.js';
25
+ export { RETRY_FIRST_MS, RETRY_MAX_MS, createRetryingRead, retryDelayMs, type RetryTimers, type RetryingRead, type RetryingReadOptions, type RetryingReadSnapshot, } from './retrying-read.js';
25
26
  export { DEFAULT_BACKOFF_MS, DEFAULT_MERGE_MS, MIN_BACKOFF_MS, createRefreshScheduler, createRelayBudget, isRateLimited, retryHintMs, type BudgetChannel, type FocusTarget, type RefreshScheduler, type RefreshSchedulerOptions, type RefreshSubscription, type RelayBudget, type RelayBudgetOptions, type SchedulerClock, type VisibilityTarget, } from './refresh.js';
26
27
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AACrG,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AACrG,OAAO,EACL,cAAc,EACd,YAAY,EACZ,kBAAkB,EAClB,YAAY,EACZ,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,gBAAgB,GACtB,MAAM,cAAc,CAAA"}
package/dist/index.js CHANGED
@@ -22,5 +22,6 @@
22
22
  export { MAX_FOLDERS_PER_SUBSCRIPTION, createLiveClient, createLiveClientHolder, } from './live-client.js';
23
23
  export { createFolderActivity, } from './folder-activity.js';
24
24
  export { assumeOnline, browserOnlineSource } from './online.js';
25
+ export { RETRY_FIRST_MS, RETRY_MAX_MS, createRetryingRead, retryDelayMs, } from './retrying-read.js';
25
26
  export { DEFAULT_BACKOFF_MS, DEFAULT_MERGE_MS, MIN_BACKOFF_MS, createRefreshScheduler, createRelayBudget, isRateLimited, retryHintMs, } from './refresh.js';
26
27
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,GAMvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,GAGrB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAwC,MAAM,aAAa,CAAA;AACrG,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACb,WAAW,GAUZ,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EACL,4BAA4B,EAC5B,gBAAgB,EAChB,sBAAsB,GAMvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,oBAAoB,GAGrB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAwC,MAAM,aAAa,CAAA;AACrG,OAAO,EACL,cAAc,EACd,YAAY,EACZ,kBAAkB,EAClB,YAAY,GAKb,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACb,WAAW,GAUZ,MAAM,cAAc,CAAA"}
@@ -0,0 +1,62 @@
1
+ /** The first wait before reading a failed read again. Peek's `FOLDERS_RETRY_FIRST_MS`. */
2
+ export declare const RETRY_FIRST_MS = 5000;
3
+ /** The longest wait between re-reads while a read keeps failing. */
4
+ export declare const RETRY_MAX_MS = 60000;
5
+ /** Declared here because this package compiles with neither the DOM nor the Node lib. */
6
+ export interface RetryTimers {
7
+ setTimeout(run: () => void, ms: number): unknown;
8
+ clearTimeout(handle: unknown): void;
9
+ }
10
+ export interface RetryingReadOptions {
11
+ /**
12
+ * Whether this failure could answer differently next time. Default: every
13
+ * failure is. A caller that can tell a refusal (`not_a_member`, `refused`)
14
+ * from an outage says so, and the refusal is not asked again.
15
+ */
16
+ retryable?: (error: unknown) => boolean;
17
+ firstRetryMs?: number;
18
+ maxRetryMs?: number;
19
+ /**
20
+ * How long an answer is fresh enough that {@link RetryingRead.ensure} does
21
+ * not ask again. Default: for ever — read once per tab, which is what a
22
+ * navigation listing wants; {@link RetryingRead.reload} always asks.
23
+ */
24
+ freshMs?: number;
25
+ now?: () => number;
26
+ /** Looked up on every call when absent, so a test's fake timers apply. */
27
+ timers?: RetryTimers;
28
+ }
29
+ /** Replaced, never mutated: a stable snapshot for `useSyncExternalStore` between changes. */
30
+ export interface RetryingReadSnapshot<T> {
31
+ /** The last answer, once any read has succeeded. */
32
+ value?: T;
33
+ /** Why the last read failed — only while there is no value to keep. */
34
+ error?: unknown;
35
+ /** A re-read is scheduled: a failure shown now is not the final word. */
36
+ retrying: boolean;
37
+ }
38
+ export interface RetryingRead<T> {
39
+ snapshot(): RetryingReadSnapshot<T>;
40
+ subscribe(listener: () => void): () => void;
41
+ /**
42
+ * Read, unless an answer fresher than `freshMs` is held, a read is in
43
+ * flight, or a retry is already scheduled. What a mount calls.
44
+ *
45
+ * Retries run only while something is subscribed: the last unsubscribe
46
+ * cancels a scheduled one, and the next `ensure` reads in its place.
47
+ */
48
+ ensure(load: () => Promise<T>): Promise<void>;
49
+ /** Read now, whatever is held; a scheduled retry is replaced by this read. */
50
+ reload(load: () => Promise<T>): Promise<void>;
51
+ /** Cancel a scheduled retry. The held answer stays. */
52
+ dispose(): void;
53
+ /**
54
+ * Forget everything held — for a new viewer, or between test cases. A read
55
+ * in flight across it neither writes its answer nor schedules a retry.
56
+ */
57
+ clear(): void;
58
+ }
59
+ /** The wait before the next attempt, after `failures` consecutive retryable failures. */
60
+ export declare function retryDelayMs(failures: number, error: unknown, first?: number, max?: number): number;
61
+ export declare function createRetryingRead<T>(options?: RetryingReadOptions): RetryingRead<T>;
62
+ //# sourceMappingURL=retrying-read.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retrying-read.d.ts","sourceRoot":"","sources":["../src/retrying-read.ts"],"names":[],"mappings":"AA2BA,0FAA0F;AAC1F,eAAO,MAAM,cAAc,OAAQ,CAAA;AACnC,oEAAoE;AACpE,eAAO,MAAM,YAAY,QAAS,CAAA;AAElC,yFAAyF;AACzF,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,GAAG,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAA;IAChD,YAAY,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAA;CACpC;AAID,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAA;IACvC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,6FAA6F;AAC7F,MAAM,WAAW,oBAAoB,CAAC,CAAC;IACrC,oDAAoD;IACpD,KAAK,CAAC,EAAE,CAAC,CAAA;IACT,uEAAuE;IACvE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,yEAAyE;IACzE,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,QAAQ,IAAI,oBAAoB,CAAC,CAAC,CAAC,CAAA;IACnC,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;IAC3C;;;;;;OAMG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,8EAA8E;IAC9E,MAAM,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,uDAAuD;IACvD,OAAO,IAAI,IAAI,CAAA;IACf;;;OAGG;IACH,KAAK,IAAI,IAAI,CAAA;CACd;AAED,yFAAyF;AACzF,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,SAAiB,EAAE,GAAG,SAAe,GAAG,MAAM,CAOjH;AAED,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,OAAO,GAAE,mBAAwB,GAAG,YAAY,CAAC,CAAC,CAAC,CA2FxF"}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * One read a tab holds on to, and reads again when it fails (FOL-5).
3
+ *
4
+ * Came from Peek's `useFolders` (FOL-42), which Ship needed word for word.
5
+ * Ship's read the Folder listing once and turned a failure into an empty list
6
+ * for the rest of the session: on production one refused read dropped every
7
+ * project out of its team until a reload. Peek had already paid for that
8
+ * lesson — a refused read left a project's channel unplaced and its team's dot
9
+ * dark — and fixed it with three rules, which are the whole of this object:
10
+ *
11
+ * 1. **A failure is read again**, on a backoff from `firstRetryMs` doubling to
12
+ * `maxRetryMs`, until one succeeds. A quota refusal waits the relay's own
13
+ * stated time instead ({@link retryHintMs}), never less than
14
+ * {@link MIN_BACKOFF_MS}. A failure the caller says will answer the same
15
+ * way (`retryable`) is not retried.
16
+ * 2. **A failure never replaces a good answer.** The last value stays; the
17
+ * failure is reported only while there is nothing else to show.
18
+ * 3. **Held past the component that asked.** Every page mounts its own
19
+ * sidebar; one of these per tab means a page change draws the answer the
20
+ * tab already has rather than a skeleton (Peek's SID-2).
21
+ *
22
+ * No React, no relay: the app hands in the read and wraps the object in its
23
+ * own hook — `useSyncExternalStore(read.subscribe, read.snapshot)` — so the
24
+ * package stays framework-free (ADR 0002 §10), as `createRefreshScheduler` is.
25
+ */
26
+ import { MIN_BACKOFF_MS, isRateLimited, retryHintMs } from './refresh.js';
27
+ /** The first wait before reading a failed read again. Peek's `FOLDERS_RETRY_FIRST_MS`. */
28
+ export const RETRY_FIRST_MS = 5_000;
29
+ /** The longest wait between re-reads while a read keeps failing. */
30
+ export const RETRY_MAX_MS = 60_000;
31
+ const host = globalThis;
32
+ /** The wait before the next attempt, after `failures` consecutive retryable failures. */
33
+ export function retryDelayMs(failures, error, first = RETRY_FIRST_MS, max = RETRY_MAX_MS) {
34
+ const text = error instanceof Error ? error.message : String(error);
35
+ if (isRateLimited(text)) {
36
+ const hint = retryHintMs(text);
37
+ if (hint !== undefined)
38
+ return Math.max(MIN_BACKOFF_MS, hint);
39
+ }
40
+ return Math.min(first * 2 ** Math.max(0, failures - 1), max);
41
+ }
42
+ export function createRetryingRead(options = {}) {
43
+ const retryable = options.retryable ?? (() => true);
44
+ const first = options.firstRetryMs ?? RETRY_FIRST_MS;
45
+ const max = options.maxRetryMs ?? RETRY_MAX_MS;
46
+ const freshMs = options.freshMs ?? Number.POSITIVE_INFINITY;
47
+ // Taken when the object is made, as Peek's listing cache did: freshness is
48
+ // wall-clock, and a test's fake `Date` must not make a held answer look
49
+ // fresh (or stale) to the next test. Pass `now` to control it.
50
+ const now = options.now ?? Date.now;
51
+ const timers = () => options.timers ?? host;
52
+ let snapshot = { retrying: false };
53
+ let answeredAt = 0;
54
+ let failures = 0;
55
+ let inflight;
56
+ let timer;
57
+ const listeners = new Set();
58
+ const set = (next) => {
59
+ snapshot = next;
60
+ for (const listener of [...listeners])
61
+ listener();
62
+ };
63
+ const cancel = () => {
64
+ if (timer === undefined)
65
+ return;
66
+ timers().clearTimeout(timer);
67
+ timer = undefined;
68
+ };
69
+ /* Bumped by `clear`, so a read in flight across it cannot write the old
70
+ answer back or schedule a retry for it. */
71
+ let generation = 0;
72
+ const run = (load) => {
73
+ if (inflight)
74
+ return inflight;
75
+ cancel();
76
+ const started = generation;
77
+ inflight = (async () => {
78
+ try {
79
+ const value = await load();
80
+ if (started !== generation)
81
+ return;
82
+ failures = 0;
83
+ answeredAt = now();
84
+ set({ value, retrying: false });
85
+ }
86
+ catch (error) {
87
+ if (started !== generation)
88
+ return;
89
+ const again = retryable(error);
90
+ if (again) {
91
+ failures += 1;
92
+ timer = timers().setTimeout(() => {
93
+ timer = undefined;
94
+ void run(load);
95
+ }, retryDelayMs(failures, error, first, max));
96
+ }
97
+ // A good answer outlives any failure after it.
98
+ set(snapshot.value !== undefined ? { value: snapshot.value, retrying: again } : { error, retrying: again });
99
+ }
100
+ finally {
101
+ if (started === generation)
102
+ inflight = undefined;
103
+ }
104
+ })();
105
+ return inflight;
106
+ };
107
+ return {
108
+ snapshot: () => snapshot,
109
+ subscribe(listener) {
110
+ listeners.add(listener);
111
+ return () => {
112
+ listeners.delete(listener);
113
+ // Nobody is looking: a retry would spend the relay's budget on an
114
+ // answer no one draws. The snapshot still says a retry is due, and
115
+ // the next `ensure` makes it.
116
+ if (listeners.size === 0)
117
+ cancel();
118
+ };
119
+ },
120
+ ensure(load) {
121
+ if (inflight)
122
+ return inflight;
123
+ if (timer !== undefined)
124
+ return Promise.resolve();
125
+ if (snapshot.value !== undefined && now() - answeredAt < freshMs)
126
+ return Promise.resolve();
127
+ return run(load);
128
+ },
129
+ reload: run,
130
+ dispose: cancel,
131
+ clear() {
132
+ generation += 1;
133
+ cancel();
134
+ inflight = undefined;
135
+ failures = 0;
136
+ answeredAt = 0;
137
+ set({ retrying: false });
138
+ },
139
+ };
140
+ }
141
+ //# sourceMappingURL=retrying-read.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retrying-read.js","sourceRoot":"","sources":["../src/retrying-read.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/platform",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Browser lifecycle two Estiva apps share: the one live relay socket a tab should hold, its credential read per connect, the network coming back, and when a tab re-reads.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -35,6 +35,16 @@ export {
35
35
  type FolderListener,
36
36
  } from './folder-activity.js'
37
37
  export { assumeOnline, browserOnlineSource, type OnlineSource, type OnlineTarget } from './online.js'
38
+ export {
39
+ RETRY_FIRST_MS,
40
+ RETRY_MAX_MS,
41
+ createRetryingRead,
42
+ retryDelayMs,
43
+ type RetryTimers,
44
+ type RetryingRead,
45
+ type RetryingReadOptions,
46
+ type RetryingReadSnapshot,
47
+ } from './retrying-read.js'
38
48
  export {
39
49
  DEFAULT_BACKOFF_MS,
40
50
  DEFAULT_MERGE_MS,
@@ -0,0 +1,194 @@
1
+ /**
2
+ * One read a tab holds on to, and reads again when it fails (FOL-5).
3
+ *
4
+ * Came from Peek's `useFolders` (FOL-42), which Ship needed word for word.
5
+ * Ship's read the Folder listing once and turned a failure into an empty list
6
+ * for the rest of the session: on production one refused read dropped every
7
+ * project out of its team until a reload. Peek had already paid for that
8
+ * lesson — a refused read left a project's channel unplaced and its team's dot
9
+ * dark — and fixed it with three rules, which are the whole of this object:
10
+ *
11
+ * 1. **A failure is read again**, on a backoff from `firstRetryMs` doubling to
12
+ * `maxRetryMs`, until one succeeds. A quota refusal waits the relay's own
13
+ * stated time instead ({@link retryHintMs}), never less than
14
+ * {@link MIN_BACKOFF_MS}. A failure the caller says will answer the same
15
+ * way (`retryable`) is not retried.
16
+ * 2. **A failure never replaces a good answer.** The last value stays; the
17
+ * failure is reported only while there is nothing else to show.
18
+ * 3. **Held past the component that asked.** Every page mounts its own
19
+ * sidebar; one of these per tab means a page change draws the answer the
20
+ * tab already has rather than a skeleton (Peek's SID-2).
21
+ *
22
+ * No React, no relay: the app hands in the read and wraps the object in its
23
+ * own hook — `useSyncExternalStore(read.subscribe, read.snapshot)` — so the
24
+ * package stays framework-free (ADR 0002 §10), as `createRefreshScheduler` is.
25
+ */
26
+ import { MIN_BACKOFF_MS, isRateLimited, retryHintMs } from './refresh.js'
27
+
28
+ /** The first wait before reading a failed read again. Peek's `FOLDERS_RETRY_FIRST_MS`. */
29
+ export const RETRY_FIRST_MS = 5_000
30
+ /** The longest wait between re-reads while a read keeps failing. */
31
+ export const RETRY_MAX_MS = 60_000
32
+
33
+ /** Declared here because this package compiles with neither the DOM nor the Node lib. */
34
+ export interface RetryTimers {
35
+ setTimeout(run: () => void, ms: number): unknown
36
+ clearTimeout(handle: unknown): void
37
+ }
38
+
39
+ const host = globalThis as unknown as RetryTimers
40
+
41
+ export interface RetryingReadOptions {
42
+ /**
43
+ * Whether this failure could answer differently next time. Default: every
44
+ * failure is. A caller that can tell a refusal (`not_a_member`, `refused`)
45
+ * from an outage says so, and the refusal is not asked again.
46
+ */
47
+ retryable?: (error: unknown) => boolean
48
+ firstRetryMs?: number
49
+ maxRetryMs?: number
50
+ /**
51
+ * How long an answer is fresh enough that {@link RetryingRead.ensure} does
52
+ * not ask again. Default: for ever — read once per tab, which is what a
53
+ * navigation listing wants; {@link RetryingRead.reload} always asks.
54
+ */
55
+ freshMs?: number
56
+ now?: () => number
57
+ /** Looked up on every call when absent, so a test's fake timers apply. */
58
+ timers?: RetryTimers
59
+ }
60
+
61
+ /** Replaced, never mutated: a stable snapshot for `useSyncExternalStore` between changes. */
62
+ export interface RetryingReadSnapshot<T> {
63
+ /** The last answer, once any read has succeeded. */
64
+ value?: T
65
+ /** Why the last read failed — only while there is no value to keep. */
66
+ error?: unknown
67
+ /** A re-read is scheduled: a failure shown now is not the final word. */
68
+ retrying: boolean
69
+ }
70
+
71
+ export interface RetryingRead<T> {
72
+ snapshot(): RetryingReadSnapshot<T>
73
+ subscribe(listener: () => void): () => void
74
+ /**
75
+ * Read, unless an answer fresher than `freshMs` is held, a read is in
76
+ * flight, or a retry is already scheduled. What a mount calls.
77
+ *
78
+ * Retries run only while something is subscribed: the last unsubscribe
79
+ * cancels a scheduled one, and the next `ensure` reads in its place.
80
+ */
81
+ ensure(load: () => Promise<T>): Promise<void>
82
+ /** Read now, whatever is held; a scheduled retry is replaced by this read. */
83
+ reload(load: () => Promise<T>): Promise<void>
84
+ /** Cancel a scheduled retry. The held answer stays. */
85
+ dispose(): void
86
+ /**
87
+ * Forget everything held — for a new viewer, or between test cases. A read
88
+ * in flight across it neither writes its answer nor schedules a retry.
89
+ */
90
+ clear(): void
91
+ }
92
+
93
+ /** The wait before the next attempt, after `failures` consecutive retryable failures. */
94
+ export function retryDelayMs(failures: number, error: unknown, first = RETRY_FIRST_MS, max = RETRY_MAX_MS): number {
95
+ const text = error instanceof Error ? error.message : String(error)
96
+ if (isRateLimited(text)) {
97
+ const hint = retryHintMs(text)
98
+ if (hint !== undefined) return Math.max(MIN_BACKOFF_MS, hint)
99
+ }
100
+ return Math.min(first * 2 ** Math.max(0, failures - 1), max)
101
+ }
102
+
103
+ export function createRetryingRead<T>(options: RetryingReadOptions = {}): RetryingRead<T> {
104
+ const retryable = options.retryable ?? (() => true)
105
+ const first = options.firstRetryMs ?? RETRY_FIRST_MS
106
+ const max = options.maxRetryMs ?? RETRY_MAX_MS
107
+ const freshMs = options.freshMs ?? Number.POSITIVE_INFINITY
108
+ // Taken when the object is made, as Peek's listing cache did: freshness is
109
+ // wall-clock, and a test's fake `Date` must not make a held answer look
110
+ // fresh (or stale) to the next test. Pass `now` to control it.
111
+ const now = options.now ?? Date.now
112
+ const timers = () => options.timers ?? host
113
+
114
+ let snapshot: RetryingReadSnapshot<T> = { retrying: false }
115
+ let answeredAt = 0
116
+ let failures = 0
117
+ let inflight: Promise<void> | undefined
118
+ let timer: unknown
119
+ const listeners = new Set<() => void>()
120
+
121
+ const set = (next: RetryingReadSnapshot<T>) => {
122
+ snapshot = next
123
+ for (const listener of [...listeners]) listener()
124
+ }
125
+ const cancel = () => {
126
+ if (timer === undefined) return
127
+ timers().clearTimeout(timer)
128
+ timer = undefined
129
+ }
130
+
131
+ /* Bumped by `clear`, so a read in flight across it cannot write the old
132
+ answer back or schedule a retry for it. */
133
+ let generation = 0
134
+
135
+ const run = (load: () => Promise<T>): Promise<void> => {
136
+ if (inflight) return inflight
137
+ cancel()
138
+ const started = generation
139
+ inflight = (async () => {
140
+ try {
141
+ const value = await load()
142
+ if (started !== generation) return
143
+ failures = 0
144
+ answeredAt = now()
145
+ set({ value, retrying: false })
146
+ } catch (error: unknown) {
147
+ if (started !== generation) return
148
+ const again = retryable(error)
149
+ if (again) {
150
+ failures += 1
151
+ timer = timers().setTimeout(() => {
152
+ timer = undefined
153
+ void run(load)
154
+ }, retryDelayMs(failures, error, first, max))
155
+ }
156
+ // A good answer outlives any failure after it.
157
+ set(snapshot.value !== undefined ? { value: snapshot.value, retrying: again } : { error, retrying: again })
158
+ } finally {
159
+ if (started === generation) inflight = undefined
160
+ }
161
+ })()
162
+ return inflight
163
+ }
164
+
165
+ return {
166
+ snapshot: () => snapshot,
167
+ subscribe(listener) {
168
+ listeners.add(listener)
169
+ return () => {
170
+ listeners.delete(listener)
171
+ // Nobody is looking: a retry would spend the relay's budget on an
172
+ // answer no one draws. The snapshot still says a retry is due, and
173
+ // the next `ensure` makes it.
174
+ if (listeners.size === 0) cancel()
175
+ }
176
+ },
177
+ ensure(load) {
178
+ if (inflight) return inflight
179
+ if (timer !== undefined) return Promise.resolve()
180
+ if (snapshot.value !== undefined && now() - answeredAt < freshMs) return Promise.resolve()
181
+ return run(load)
182
+ },
183
+ reload: run,
184
+ dispose: cancel,
185
+ clear() {
186
+ generation += 1
187
+ cancel()
188
+ inflight = undefined
189
+ failures = 0
190
+ answeredAt = 0
191
+ set({ retrying: false })
192
+ },
193
+ }
194
+ }