@estiva-app/platform 0.3.1 → 0.5.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.
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@estiva-app/platform",
3
- "version": "0.3.1",
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.",
3
+ "version": "0.5.0",
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, when a tab re-reads, and the copy on the device a reload draws from.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {