@estiva-app/platform 0.3.0 → 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,38 @@
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
+
21
+ ## 0.3.1
22
+
23
+ Subscribers on one interval share one timer (PER-6). No API change.
24
+
25
+ 0.3.0 started a `setInterval` per subscriber, at the moment it subscribed, so
26
+ three views on one page on the same 10 s woke at three unrelated phases — Ship's
27
+ project page read every ~3 s, and never inside the 1 s merge. Now there is one
28
+ timer per distinct `intervalMs`, and every subscriber on it wakes in the same
29
+ tick.
30
+
31
+ - A subscriber that joined less than half an interval ago sits out a tick: its
32
+ caller has just done its own first read. Its first scheduled refresh comes
33
+ between 0.5× and 1.5× the interval after subscribing, then on the shared beat.
34
+ - Different intervals still have separate timers, and separate phases.
35
+
3
36
  ## 0.3.0
4
37
 
5
38
  When a tab re-reads, and when the relay has asked it to wait (PER-13). Additive.
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"}
package/dist/refresh.d.ts CHANGED
@@ -153,6 +153,8 @@ export interface RefreshScheduler {
153
153
  /**
154
154
  * Re-run `refresh` on focus, on becoming visible, and every `intervalMs`
155
155
  * while visible. Never on subscribe — the caller owns its first read.
156
+ * Every subscriber on one `intervalMs` shares one timer and wakes in the same
157
+ * tick; the first scheduled refresh comes 0.5–1.5 intervals after subscribing.
156
158
  * Returns an unsubscribe; calling it twice is harmless.
157
159
  */
158
160
  subscribe(refresh: () => void | Promise<void>, options?: RefreshSubscription): () => void;
@@ -1 +1 @@
1
- {"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"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"}
package/dist/refresh.js CHANGED
@@ -143,6 +143,7 @@ export function createRefreshScheduler(options) {
143
143
  clearInterval: (handle) => host.clearInterval(handle),
144
144
  };
145
145
  const subscribers = new Set();
146
+ const groups = new Map();
146
147
  let attached = false;
147
148
  const visible = () => document.visibilityState === 'visible';
148
149
  const tick = (subscriber) => {
@@ -173,18 +174,67 @@ export function createRefreshScheduler(options) {
173
174
  if (visible())
174
175
  fanOut();
175
176
  };
177
+ /*
178
+ One timer per interval, not per subscriber (PER-6).
179
+
180
+ 0.3.0 gave each subscriber its own `setInterval`, started when it
181
+ subscribed. Three views on one page, each on the same 10 s, therefore woke
182
+ at three unrelated phases — a read every three seconds or so, none of them
183
+ close enough to merge — and a page with a widget per section read "every
184
+ couple of seconds" at an interval nobody had chosen. Sharing the timer
185
+ puts every subscriber on an interval into the same wake-up.
186
+
187
+ A subscriber that joined less than half an interval ago sits the tick out:
188
+ its caller has just done its own first read, and a shared phase would
189
+ otherwise re-read it moments later. So the first scheduled refresh comes
190
+ between a half and one and a half intervals after subscribing, and every
191
+ one after that on the shared beat.
192
+ */
193
+ const intervalTick = (group, intervalMs) => {
194
+ // Checked at fire time rather than started and stopped on every
195
+ // visibility change: a hidden tab has nothing on screen to be stale.
196
+ if (!visible())
197
+ return;
198
+ const now = clock.now();
199
+ for (const subscriber of [...group.members]) {
200
+ if (now - subscriber.joinedAt < intervalMs / 2)
201
+ continue;
202
+ tick(subscriber);
203
+ }
204
+ };
205
+ const join = (subscriber, intervalMs) => {
206
+ let group = groups.get(intervalMs);
207
+ if (!group) {
208
+ const created = { members: new Set(), timer: undefined };
209
+ created.timer = clock.setInterval(() => intervalTick(created, intervalMs), intervalMs);
210
+ groups.set(intervalMs, created);
211
+ group = created;
212
+ }
213
+ group.members.add(subscriber);
214
+ };
215
+ const leave = (subscriber) => {
216
+ if (subscriber.intervalMs === undefined)
217
+ return;
218
+ const group = groups.get(subscriber.intervalMs);
219
+ if (!group)
220
+ return;
221
+ group.members.delete(subscriber);
222
+ if (group.members.size === 0) {
223
+ clock.clearInterval(group.timer);
224
+ groups.delete(subscriber.intervalMs);
225
+ }
226
+ };
176
227
  return {
177
228
  subscribe(refresh, { interval = true, intervalMs = defaultIntervalMs } = {}) {
178
- const subscriber = { refresh, lastAt: 0, timer: undefined };
229
+ const subscriber = {
230
+ refresh,
231
+ lastAt: 0,
232
+ joinedAt: clock.now(),
233
+ intervalMs: interval ? intervalMs : undefined,
234
+ };
179
235
  subscribers.add(subscriber);
180
- if (interval) {
181
- // Checked at fire time rather than started and stopped on every
182
- // visibility change: a hidden tab has nothing on screen to be stale.
183
- subscriber.timer = clock.setInterval(() => {
184
- if (visible())
185
- tick(subscriber);
186
- }, intervalMs);
187
- }
236
+ if (interval)
237
+ join(subscriber, intervalMs);
188
238
  if (!attached) {
189
239
  // On first use and never at module scope, so importing registers
190
240
  // nothing — and one pair of listeners however many subscribe.
@@ -195,8 +245,7 @@ export function createRefreshScheduler(options) {
195
245
  return () => {
196
246
  if (!subscribers.delete(subscriber))
197
247
  return;
198
- if (subscriber.timer !== undefined)
199
- clock.clearInterval(subscriber.timer);
248
+ leave(subscriber);
200
249
  if (subscribers.size === 0 && attached) {
201
250
  document.removeEventListener('visibilitychange', onVisibility);
202
251
  window.removeEventListener('focus', fanOut);
@@ -1 +1 @@
1
- {"version":3,"file":"refresh.js","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"refresh.js","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"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"}
@@ -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.0",
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,
package/src/refresh.ts CHANGED
@@ -238,6 +238,8 @@ export interface RefreshScheduler {
238
238
  /**
239
239
  * Re-run `refresh` on focus, on becoming visible, and every `intervalMs`
240
240
  * while visible. Never on subscribe — the caller owns its first read.
241
+ * Every subscriber on one `intervalMs` shares one timer and wakes in the same
242
+ * tick; the first scheduled refresh comes 0.5–1.5 intervals after subscribing.
241
243
  * Returns an unsubscribe; calling it twice is harmless.
242
244
  */
243
245
  subscribe(refresh: () => void | Promise<void>, options?: RefreshSubscription): () => void
@@ -249,6 +251,15 @@ interface Subscriber {
249
251
  refresh: () => void | Promise<void>
250
252
  /** When this subscriber last ran. Merge and pause are judged per subscriber. */
251
253
  lastAt: number
254
+ /** When it subscribed — its caller's own first read, which this never sees. */
255
+ joinedAt: number
256
+ /** The interval it ticks on, or `undefined` for focus and visibility only. */
257
+ intervalMs: number | undefined
258
+ }
259
+
260
+ /** Every subscriber on one interval, and the one timer they share (PER-6). */
261
+ interface IntervalGroup {
262
+ members: Set<Subscriber>
252
263
  timer: unknown
253
264
  }
254
265
 
@@ -263,6 +274,7 @@ export function createRefreshScheduler(options: RefreshSchedulerOptions): Refres
263
274
  clearInterval: (handle) => host.clearInterval(handle),
264
275
  }
265
276
  const subscribers = new Set<Subscriber>()
277
+ const groups = new Map<number, IntervalGroup>()
266
278
  let attached = false
267
279
 
268
280
  const visible = () => document.visibilityState === 'visible'
@@ -291,17 +303,63 @@ export function createRefreshScheduler(options: RefreshSchedulerOptions): Refres
291
303
  if (visible()) fanOut()
292
304
  }
293
305
 
306
+ /*
307
+ One timer per interval, not per subscriber (PER-6).
308
+
309
+ 0.3.0 gave each subscriber its own `setInterval`, started when it
310
+ subscribed. Three views on one page, each on the same 10 s, therefore woke
311
+ at three unrelated phases — a read every three seconds or so, none of them
312
+ close enough to merge — and a page with a widget per section read "every
313
+ couple of seconds" at an interval nobody had chosen. Sharing the timer
314
+ puts every subscriber on an interval into the same wake-up.
315
+
316
+ A subscriber that joined less than half an interval ago sits the tick out:
317
+ its caller has just done its own first read, and a shared phase would
318
+ otherwise re-read it moments later. So the first scheduled refresh comes
319
+ between a half and one and a half intervals after subscribing, and every
320
+ one after that on the shared beat.
321
+ */
322
+ const intervalTick = (group: IntervalGroup, intervalMs: number) => {
323
+ // Checked at fire time rather than started and stopped on every
324
+ // visibility change: a hidden tab has nothing on screen to be stale.
325
+ if (!visible()) return
326
+ const now = clock.now()
327
+ for (const subscriber of [...group.members]) {
328
+ if (now - subscriber.joinedAt < intervalMs / 2) continue
329
+ tick(subscriber)
330
+ }
331
+ }
332
+ const join = (subscriber: Subscriber, intervalMs: number) => {
333
+ let group = groups.get(intervalMs)
334
+ if (!group) {
335
+ const created: IntervalGroup = { members: new Set(), timer: undefined }
336
+ created.timer = clock.setInterval(() => intervalTick(created, intervalMs), intervalMs)
337
+ groups.set(intervalMs, created)
338
+ group = created
339
+ }
340
+ group.members.add(subscriber)
341
+ }
342
+ const leave = (subscriber: Subscriber) => {
343
+ if (subscriber.intervalMs === undefined) return
344
+ const group = groups.get(subscriber.intervalMs)
345
+ if (!group) return
346
+ group.members.delete(subscriber)
347
+ if (group.members.size === 0) {
348
+ clock.clearInterval(group.timer)
349
+ groups.delete(subscriber.intervalMs)
350
+ }
351
+ }
352
+
294
353
  return {
295
354
  subscribe(refresh, { interval = true, intervalMs = defaultIntervalMs } = {}) {
296
- const subscriber: Subscriber = { refresh, lastAt: 0, timer: undefined }
297
- subscribers.add(subscriber)
298
- if (interval) {
299
- // Checked at fire time rather than started and stopped on every
300
- // visibility change: a hidden tab has nothing on screen to be stale.
301
- subscriber.timer = clock.setInterval(() => {
302
- if (visible()) tick(subscriber)
303
- }, intervalMs)
355
+ const subscriber: Subscriber = {
356
+ refresh,
357
+ lastAt: 0,
358
+ joinedAt: clock.now(),
359
+ intervalMs: interval ? intervalMs : undefined,
304
360
  }
361
+ subscribers.add(subscriber)
362
+ if (interval) join(subscriber, intervalMs)
305
363
  if (!attached) {
306
364
  // On first use and never at module scope, so importing registers
307
365
  // nothing — and one pair of listeners however many subscribe.
@@ -311,7 +369,7 @@ export function createRefreshScheduler(options: RefreshSchedulerOptions): Refres
311
369
  }
312
370
  return () => {
313
371
  if (!subscribers.delete(subscriber)) return
314
- if (subscriber.timer !== undefined) clock.clearInterval(subscriber.timer)
372
+ leave(subscriber)
315
373
  if (subscribers.size === 0 && attached) {
316
374
  document.removeEventListener('visibilitychange', onVisibility)
317
375
  window.removeEventListener('focus', fanOut)
@@ -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
+ }