@drakkar.software/starfish-client 3.0.0-alpha.23 → 3.0.0-alpha.26

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/dist/client.d.ts CHANGED
@@ -92,6 +92,9 @@ export declare class StarfishClient {
92
92
  private readonly fetch;
93
93
  private readonly cache?;
94
94
  private readonly cacheMaxAgeMs?;
95
+ private readonly cacheFallbackStatuses?;
96
+ private readonly onRevalidated?;
97
+ private readonly revalidating;
95
98
  /**
96
99
  * Installed client-side plugins. Currently stored as inert data; no
97
100
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -164,6 +167,16 @@ export declare class StarfishClient {
164
167
  pull(path: string, options: PullOptions): Promise<PullResult>;
165
168
  /** Pull an append-only collection. Extracts and returns `data[appendField]` as `T[]`. */
166
169
  pull<T = unknown>(path: string, options: AppendPullOptions): Promise<T[]>;
170
+ /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
171
+ private scheduleRevalidate;
172
+ /**
173
+ * Background revalidation loop for a {@link cacheFallbackStatuses} hit.
174
+ * Retries the pull (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS}
175
+ * times. On a live 2xx response the fresh snapshot is written through to the
176
+ * cache and {@link onRevalidated} fires. Stops early on a non-fallback HTTP
177
+ * status (e.g. 404/403 — the server gave a genuine answer).
178
+ */
179
+ private revalidateLoop;
167
180
  /**
168
181
  * Read the cached snapshot for a document `path` WITHOUT hitting the network —
169
182
  * the basis for cache-first paint (seed the UI from the last-synced snapshot,
package/dist/fetch.d.ts CHANGED
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Parse a `Retry-After` header value into milliseconds.
3
+ *
4
+ * - Numeric string (`"30"`) — treated as seconds × 1000.
5
+ * - HTTP-date string — delta from now in ms (floored to 0).
6
+ * - `null`, empty, or unparseable — returns `opts.fallbackMs`.
7
+ *
8
+ * All results are clamped to `[0, opts.maxMs]`.
9
+ */
10
+ export declare function parseRetryAfterMs(header: string | null | undefined, opts: {
11
+ fallbackMs: number;
12
+ maxMs: number;
13
+ }): number;
1
14
  /** Error category returned by classifyError. */
2
15
  export type ErrorCategory = "network" | "auth" | "conflict" | "rate-limited" | "server" | "client" | "unknown";
3
16
  /** Classify an error from a fetch response or network failure. */
package/dist/fetch.js CHANGED
@@ -1,4 +1,15 @@
1
1
  // src/fetch.ts
2
+ function parseRetryAfterMs(header, opts) {
3
+ const { fallbackMs, maxMs } = opts;
4
+ const trimmed = header?.trim();
5
+ if (trimmed) {
6
+ const seconds = Number(trimmed);
7
+ if (!isNaN(seconds)) return Math.min(seconds * 1e3, maxMs);
8
+ const date = Date.parse(trimmed);
9
+ if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs);
10
+ }
11
+ return Math.min(fallbackMs, maxMs);
12
+ }
2
13
  function classifyError(err) {
3
14
  if (err instanceof Response || err && typeof err === "object" && "status" in err) {
4
15
  const status = err.status;
@@ -25,19 +36,12 @@ function createRetryFetch(options) {
25
36
  if (res.ok || attempt >= maxRetries) return res;
26
37
  const category = classifyError(res);
27
38
  if (category !== "rate-limited" && category !== "server") return res;
28
- const retryAfter = res.headers.get("Retry-After")?.trim();
29
- let delay;
30
- if (retryAfter) {
31
- const seconds = Number(retryAfter);
32
- if (retryAfter !== "" && !isNaN(seconds)) {
33
- delay = Math.min(seconds * 1e3, maxDelay);
34
- } else {
35
- const date = Date.parse(retryAfter);
36
- delay = isNaN(date) ? initialDelay : Math.min(Math.max(date - Date.now(), 0), maxDelay);
37
- }
38
- } else {
39
- delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
40
- }
39
+ const retryAfterHeader = res.headers.get("Retry-After");
40
+ const exponentialDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay);
41
+ const delay = parseRetryAfterMs(retryAfterHeader, {
42
+ fallbackMs: retryAfterHeader?.trim() ? initialDelay : exponentialDelay,
43
+ maxMs: maxDelay
44
+ });
41
45
  await new Promise((r) => setTimeout(r, delay));
42
46
  attempt++;
43
47
  } catch (err) {
@@ -136,6 +140,7 @@ export {
136
140
  classifyError,
137
141
  createCompressedFetch,
138
142
  createResilientFetch,
139
- createRetryFetch
143
+ createRetryFetch,
144
+ parseRetryAfterMs
140
145
  };
141
146
  //# sourceMappingURL=fetch.js.map
package/dist/fetch.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/fetch.ts"],
4
- "sourcesContent": ["/** Error category returned by classifyError. */\nexport type ErrorCategory =\n | \"network\"\n | \"auth\"\n | \"conflict\"\n | \"rate-limited\"\n | \"server\"\n | \"client\"\n | \"unknown\"\n\n/** Classify an error from a fetch response or network failure. */\nexport function classifyError(err: unknown): ErrorCategory {\n if (err instanceof Response || (err && typeof err === \"object\" && \"status\" in err)) {\n const status = (err as { status: unknown }).status\n if (typeof status !== \"number\" || isNaN(status)) return \"unknown\"\n if (status === 0) return \"network\"\n if (status === 401 || status === 403) return \"auth\"\n if (status === 409) return \"conflict\"\n if (status === 429) return \"rate-limited\"\n if (status >= 500) return \"server\"\n if (status >= 400) return \"client\"\n }\n if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return \"network\"\n return \"unknown\"\n}\n\nexport interface RetryOptions {\n /** Max number of retries (default: 3). */\n maxRetries?: number\n /** Initial delay in ms before first retry (default: 500). */\n initialDelayMs?: number\n /** Maximum delay in ms (default: 10000). */\n maxDelayMs?: number\n}\n\n/**\n * Wraps a fetch function with automatic retry for retriable errors\n * (network failures, 429, 5xx). Respects Retry-After headers.\n */\nexport function createRetryFetch(options?: RetryOptions): typeof globalThis.fetch {\n const maxRetries = Math.max(0, options?.maxRetries ?? 3)\n const initialDelay = options?.initialDelayMs ?? 500\n const maxDelay = options?.maxDelayMs ?? 10_000\n\n return async (input, init?) => {\n let attempt = 0\n while (true) {\n try {\n const res = await globalThis.fetch(input, init)\n if (res.ok || attempt >= maxRetries) return res\n\n const category = classifyError(res)\n if (category !== \"rate-limited\" && category !== \"server\") return res\n\n const retryAfter = res.headers.get(\"Retry-After\")?.trim()\n let delay: number\n if (retryAfter) {\n const seconds = Number(retryAfter)\n if (retryAfter !== \"\" && !isNaN(seconds)) {\n delay = Math.min(seconds * 1000, maxDelay)\n } else {\n const date = Date.parse(retryAfter)\n delay = isNaN(date) ? initialDelay : Math.min(Math.max(date - Date.now(), 0), maxDelay)\n }\n } else {\n delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n }\n\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n } catch (err) {\n if (attempt >= maxRetries) throw err\n const category = classifyError(err)\n if (category !== \"network\") throw err\n\n const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n }\n }\n }\n}\n\ntype BreakerState = \"closed\" | \"open\" | \"half-open\"\n\nexport interface CircuitBreakerOptions {\n /** Number of consecutive failures to open the circuit (default: 5). */\n threshold?: number\n /** Cooldown in ms before transitioning from open to half-open (default: 30000). */\n cooldownMs?: number\n}\n\n/** Circuit breaker that prevents requests when the backend is unavailable. */\nexport class CircuitBreaker {\n private state: BreakerState = \"closed\"\n private failures = 0\n private openedAt = 0\n private readonly threshold: number\n private readonly cooldownMs: number\n\n constructor(options?: CircuitBreakerOptions) {\n this.threshold = options?.threshold ?? 5\n this.cooldownMs = options?.cooldownMs ?? 30_000\n }\n\n getState(): BreakerState {\n this.maybeTransition()\n return this.state\n }\n\n isOpen(): boolean {\n return this.getState() === \"open\"\n }\n\n recordSuccess(): void {\n this.failures = 0\n this.state = \"closed\"\n }\n\n recordFailure(): void {\n this.failures++\n if (this.state === \"half-open\" || this.failures >= this.threshold) {\n this.state = \"open\"\n this.openedAt = Date.now()\n }\n }\n\n private maybeTransition(): void {\n if (this.state === \"open\" && Date.now() - this.openedAt >= this.cooldownMs) {\n this.state = \"half-open\"\n }\n }\n}\n\n/**\n * Wraps fetch to gzip-compress string request bodies using the CompressionStream API.\n * Adds Content-Encoding: gzip header. Non-string bodies (ArrayBuffer, Blob, etc.)\n * are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).\n */\nexport function createCompressedFetch(inner?: typeof globalThis.fetch): typeof globalThis.fetch {\n const baseFetch = inner ?? globalThis.fetch.bind(globalThis)\n return async (input, init?) => {\n if (!init?.body || typeof CompressionStream === \"undefined\") {\n return baseFetch(input, init)\n }\n\n const bodyText = typeof init.body === \"string\" ? init.body : null\n if (!bodyText) return baseFetch(input, init)\n\n try {\n const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream(\"gzip\"))\n const compressed = await new Response(stream).arrayBuffer()\n\n const normalized = Object.fromEntries(new Headers(init.headers as HeadersInit).entries())\n normalized[\"content-encoding\"] = \"gzip\"\n\n return baseFetch(input, {\n ...init,\n body: compressed,\n headers: normalized,\n })\n } catch {\n return baseFetch(input, init)\n }\n }\n}\n\n/**\n * Combines retry and circuit breaker into a single resilient fetch wrapper.\n * Rejects immediately when the circuit is open.\n */\nexport function createResilientFetch(\n retryOptions?: RetryOptions,\n breakerOptions?: CircuitBreakerOptions,\n): { fetch: typeof globalThis.fetch; breaker: CircuitBreaker } {\n const breaker = new CircuitBreaker(breakerOptions)\n const retryFetch = createRetryFetch(retryOptions)\n\n const resilientFetch: typeof globalThis.fetch = async (input, init?) => {\n if (breaker.isOpen()) {\n const cooldown = Math.ceil((breakerOptions?.cooldownMs ?? 30_000) / 1000)\n throw new Error(`Request blocked: too many consecutive failures. Retry in ${cooldown}s.`)\n }\n\n try {\n const res = await retryFetch(input, init)\n if (res.status >= 500) {\n breaker.recordFailure()\n } else {\n breaker.recordSuccess()\n }\n return res\n } catch (err) {\n breaker.recordFailure()\n throw err\n }\n }\n\n return { fetch: resilientFetch, breaker }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Parse a `Retry-After` header value into milliseconds.\n *\n * - Numeric string (`\"30\"`) \u2014 treated as seconds \u00D7 1000.\n * - HTTP-date string \u2014 delta from now in ms (floored to 0).\n * - `null`, empty, or unparseable \u2014 returns `opts.fallbackMs`.\n *\n * All results are clamped to `[0, opts.maxMs]`.\n */\nexport function parseRetryAfterMs(\n header: string | null | undefined,\n opts: { fallbackMs: number; maxMs: number },\n): number {\n const { fallbackMs, maxMs } = opts\n const trimmed = header?.trim()\n if (trimmed) {\n const seconds = Number(trimmed)\n if (!isNaN(seconds)) return Math.min(seconds * 1000, maxMs)\n const date = Date.parse(trimmed)\n if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs)\n }\n return Math.min(fallbackMs, maxMs)\n}\n\n/** Error category returned by classifyError. */\nexport type ErrorCategory =\n | \"network\"\n | \"auth\"\n | \"conflict\"\n | \"rate-limited\"\n | \"server\"\n | \"client\"\n | \"unknown\"\n\n/** Classify an error from a fetch response or network failure. */\nexport function classifyError(err: unknown): ErrorCategory {\n if (err instanceof Response || (err && typeof err === \"object\" && \"status\" in err)) {\n const status = (err as { status: unknown }).status\n if (typeof status !== \"number\" || isNaN(status)) return \"unknown\"\n if (status === 0) return \"network\"\n if (status === 401 || status === 403) return \"auth\"\n if (status === 409) return \"conflict\"\n if (status === 429) return \"rate-limited\"\n if (status >= 500) return \"server\"\n if (status >= 400) return \"client\"\n }\n if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return \"network\"\n return \"unknown\"\n}\n\nexport interface RetryOptions {\n /** Max number of retries (default: 3). */\n maxRetries?: number\n /** Initial delay in ms before first retry (default: 500). */\n initialDelayMs?: number\n /** Maximum delay in ms (default: 10000). */\n maxDelayMs?: number\n}\n\n/**\n * Wraps a fetch function with automatic retry for retriable errors\n * (network failures, 429, 5xx). Respects Retry-After headers.\n */\nexport function createRetryFetch(options?: RetryOptions): typeof globalThis.fetch {\n const maxRetries = Math.max(0, options?.maxRetries ?? 3)\n const initialDelay = options?.initialDelayMs ?? 500\n const maxDelay = options?.maxDelayMs ?? 10_000\n\n return async (input, init?) => {\n let attempt = 0\n while (true) {\n try {\n const res = await globalThis.fetch(input, init)\n if (res.ok || attempt >= maxRetries) return res\n\n const category = classifyError(res)\n if (category !== \"rate-limited\" && category !== \"server\") return res\n\n const retryAfterHeader = res.headers.get(\"Retry-After\")\n const exponentialDelay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n // When the header is present but unparseable, original falls back to\n // initialDelay (not exponential). Preserve that by checking presence first.\n const delay = parseRetryAfterMs(retryAfterHeader, {\n fallbackMs: retryAfterHeader?.trim() ? initialDelay : exponentialDelay,\n maxMs: maxDelay,\n })\n\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n } catch (err) {\n if (attempt >= maxRetries) throw err\n const category = classifyError(err)\n if (category !== \"network\") throw err\n\n const delay = Math.min(initialDelay * Math.pow(2, attempt), maxDelay)\n await new Promise<void>((r) => setTimeout(r, delay))\n attempt++\n }\n }\n }\n}\n\ntype BreakerState = \"closed\" | \"open\" | \"half-open\"\n\nexport interface CircuitBreakerOptions {\n /** Number of consecutive failures to open the circuit (default: 5). */\n threshold?: number\n /** Cooldown in ms before transitioning from open to half-open (default: 30000). */\n cooldownMs?: number\n}\n\n/** Circuit breaker that prevents requests when the backend is unavailable. */\nexport class CircuitBreaker {\n private state: BreakerState = \"closed\"\n private failures = 0\n private openedAt = 0\n private readonly threshold: number\n private readonly cooldownMs: number\n\n constructor(options?: CircuitBreakerOptions) {\n this.threshold = options?.threshold ?? 5\n this.cooldownMs = options?.cooldownMs ?? 30_000\n }\n\n getState(): BreakerState {\n this.maybeTransition()\n return this.state\n }\n\n isOpen(): boolean {\n return this.getState() === \"open\"\n }\n\n recordSuccess(): void {\n this.failures = 0\n this.state = \"closed\"\n }\n\n recordFailure(): void {\n this.failures++\n if (this.state === \"half-open\" || this.failures >= this.threshold) {\n this.state = \"open\"\n this.openedAt = Date.now()\n }\n }\n\n private maybeTransition(): void {\n if (this.state === \"open\" && Date.now() - this.openedAt >= this.cooldownMs) {\n this.state = \"half-open\"\n }\n }\n}\n\n/**\n * Wraps fetch to gzip-compress string request bodies using the CompressionStream API.\n * Adds Content-Encoding: gzip header. Non-string bodies (ArrayBuffer, Blob, etc.)\n * are passed through uncompressed. Requires CompressionStream (browsers, Node.js 18+, Deno).\n */\nexport function createCompressedFetch(inner?: typeof globalThis.fetch): typeof globalThis.fetch {\n const baseFetch = inner ?? globalThis.fetch.bind(globalThis)\n return async (input, init?) => {\n if (!init?.body || typeof CompressionStream === \"undefined\") {\n return baseFetch(input, init)\n }\n\n const bodyText = typeof init.body === \"string\" ? init.body : null\n if (!bodyText) return baseFetch(input, init)\n\n try {\n const stream = new Blob([bodyText]).stream().pipeThrough(new CompressionStream(\"gzip\"))\n const compressed = await new Response(stream).arrayBuffer()\n\n const normalized = Object.fromEntries(new Headers(init.headers as HeadersInit).entries())\n normalized[\"content-encoding\"] = \"gzip\"\n\n return baseFetch(input, {\n ...init,\n body: compressed,\n headers: normalized,\n })\n } catch {\n return baseFetch(input, init)\n }\n }\n}\n\n/**\n * Combines retry and circuit breaker into a single resilient fetch wrapper.\n * Rejects immediately when the circuit is open.\n */\nexport function createResilientFetch(\n retryOptions?: RetryOptions,\n breakerOptions?: CircuitBreakerOptions,\n): { fetch: typeof globalThis.fetch; breaker: CircuitBreaker } {\n const breaker = new CircuitBreaker(breakerOptions)\n const retryFetch = createRetryFetch(retryOptions)\n\n const resilientFetch: typeof globalThis.fetch = async (input, init?) => {\n if (breaker.isOpen()) {\n const cooldown = Math.ceil((breakerOptions?.cooldownMs ?? 30_000) / 1000)\n throw new Error(`Request blocked: too many consecutive failures. Retry in ${cooldown}s.`)\n }\n\n try {\n const res = await retryFetch(input, init)\n if (res.status >= 500) {\n breaker.recordFailure()\n } else {\n breaker.recordSuccess()\n }\n return res\n } catch (err) {\n breaker.recordFailure()\n throw err\n }\n }\n\n return { fetch: resilientFetch, breaker }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export { createMigrator } from "./migrate.js";
20
20
  export type { MigrationFn, MigrationConfig } from "./migrate.js";
21
21
  export { ValidationError, createSchemaValidator } from "./validate.js";
22
22
  export type { Validator, ValidationResult } from "./validate.js";
23
- export { classifyError } from "./fetch.js";
23
+ export { classifyError, parseRetryAfterMs } from "./fetch.js";
24
24
  export type { ErrorCategory } from "./fetch.js";
25
25
  export { createUnionMerge, createSoftDeleteResolver, timestampWinner, pruneTombstones, withConflictMeta, } from "./resolvers.js";
26
26
  export type { ConflictMeta, ConflictResolverWithMeta } from "./resolvers.js";
package/dist/index.js CHANGED
@@ -39,8 +39,41 @@ var StarfishHttpError = class extends Error {
39
39
  }
40
40
  };
41
41
 
42
+ // src/fetch.ts
43
+ function parseRetryAfterMs(header, opts) {
44
+ const { fallbackMs, maxMs } = opts;
45
+ const trimmed = header?.trim();
46
+ if (trimmed) {
47
+ const seconds = Number(trimmed);
48
+ if (!isNaN(seconds)) return Math.min(seconds * 1e3, maxMs);
49
+ const date = Date.parse(trimmed);
50
+ if (!isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), maxMs);
51
+ }
52
+ return Math.min(fallbackMs, maxMs);
53
+ }
54
+ function classifyError(err) {
55
+ if (err instanceof Response || err && typeof err === "object" && "status" in err) {
56
+ const status = err.status;
57
+ if (typeof status !== "number" || isNaN(status)) return "unknown";
58
+ if (status === 0) return "network";
59
+ if (status === 401 || status === 403) return "auth";
60
+ if (status === 409) return "conflict";
61
+ if (status === 429) return "rate-limited";
62
+ if (status >= 500) return "server";
63
+ if (status >= 400) return "client";
64
+ }
65
+ if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
66
+ return "unknown";
67
+ }
68
+
42
69
  // src/client.ts
43
70
  var APPEND_DEFAULT_FIELD = "items";
71
+ var MAX_REVALIDATE_ATTEMPTS = 5;
72
+ var REVALIDATE_INITIAL_DELAY_MS = 1e3;
73
+ var REVALIDATE_MAX_DELAY_MS = 3e4;
74
+ function sleep(ms) {
75
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
+ }
44
77
  function pullCacheKey(pathAndQuery) {
45
78
  const q = pathAndQuery.indexOf("?");
46
79
  return q === -1 ? pathAndQuery : pathAndQuery.slice(0, q);
@@ -67,6 +100,9 @@ var StarfishClient = class {
67
100
  fetch;
68
101
  cache;
69
102
  cacheMaxAgeMs;
103
+ cacheFallbackStatuses;
104
+ onRevalidated;
105
+ revalidating = /* @__PURE__ */ new Set();
70
106
  /**
71
107
  * Installed client-side plugins. Currently stored as inert data; no
72
108
  * hooks fire yet. Extensions can inspect this list if needed.
@@ -79,6 +115,8 @@ var StarfishClient = class {
79
115
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
80
116
  this.cache = options.cache;
81
117
  this.cacheMaxAgeMs = options.cacheMaxAgeMs;
118
+ this.cacheFallbackStatuses = options.cacheFallbackStatuses;
119
+ this.onRevalidated = options.onRevalidated;
82
120
  this.plugins = options.plugins ? [...options.plugins] : [];
83
121
  }
84
122
  /**
@@ -234,7 +272,17 @@ var StarfishClient = class {
234
272
  throw err;
235
273
  }
236
274
  if (!res.ok) {
237
- throw new StarfishHttpError(res.status, await res.text());
275
+ const status = res.status;
276
+ if (cacheKey && this.cacheFallbackStatuses?.includes(status)) {
277
+ const retryAfterHeader = res.headers.get("Retry-After");
278
+ this.scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader);
279
+ const cached = await this.readCache(cacheKey);
280
+ if (cached) {
281
+ void res.body?.cancel();
282
+ return cached;
283
+ }
284
+ }
285
+ throw new StarfishHttpError(status, await res.text());
238
286
  }
239
287
  const result = await res.json();
240
288
  if (appendField !== void 0) {
@@ -253,6 +301,63 @@ var StarfishClient = class {
253
301
  }
254
302
  return result;
255
303
  }
304
+ /** Deduplicated fire-and-forget: starts one revalidation loop per cacheKey. */
305
+ scheduleRevalidate(cacheKey, pathAndQuery, retryAfterHeader) {
306
+ if (this.revalidating.has(cacheKey)) return;
307
+ this.revalidating.add(cacheKey);
308
+ void this.revalidateLoop(cacheKey, pathAndQuery, retryAfterHeader).finally(() => {
309
+ this.revalidating.delete(cacheKey);
310
+ });
311
+ }
312
+ /**
313
+ * Background revalidation loop for a {@link cacheFallbackStatuses} hit.
314
+ * Retries the pull (honoring `Retry-After`) up to {@link MAX_REVALIDATE_ATTEMPTS}
315
+ * times. On a live 2xx response the fresh snapshot is written through to the
316
+ * cache and {@link onRevalidated} fires. Stops early on a non-fallback HTTP
317
+ * status (e.g. 404/403 — the server gave a genuine answer).
318
+ */
319
+ async revalidateLoop(cacheKey, pathAndQuery, firstRetryAfter) {
320
+ let retryAfterHeader = firstRetryAfter;
321
+ for (let attempt = 0; attempt < MAX_REVALIDATE_ATTEMPTS; attempt++) {
322
+ const delay = parseRetryAfterMs(retryAfterHeader, {
323
+ fallbackMs: Math.min(
324
+ REVALIDATE_INITIAL_DELAY_MS * Math.pow(2, attempt),
325
+ REVALIDATE_MAX_DELAY_MS
326
+ ),
327
+ maxMs: REVALIDATE_MAX_DELAY_MS
328
+ });
329
+ await sleep(delay);
330
+ try {
331
+ const url = `${this.baseUrl}${pathAndQuery}`;
332
+ const authHeaders = await this.buildAuthHeaders("GET", pathAndQuery, void 0);
333
+ const res = await this.fetch(url, {
334
+ method: "GET",
335
+ headers: { [HEADER_ACCEPT]: "application/json", ...authHeaders }
336
+ });
337
+ if (res.ok) {
338
+ const result = await res.json();
339
+ if (this.cache) {
340
+ const snapshot = {
341
+ data: result.data,
342
+ hash: result.hash,
343
+ timestamp: result.timestamp,
344
+ cachedAt: Date.now()
345
+ };
346
+ void this.cache.set(cacheKey, JSON.stringify(snapshot)).catch(() => {
347
+ });
348
+ }
349
+ this.onRevalidated?.(pathAndQuery, result);
350
+ return;
351
+ }
352
+ if (!this.cacheFallbackStatuses?.includes(res.status)) {
353
+ return;
354
+ }
355
+ retryAfterHeader = res.headers.get("Retry-After");
356
+ } catch {
357
+ retryAfterHeader = null;
358
+ }
359
+ }
360
+ }
256
361
  /**
257
362
  * Read the cached snapshot for a document `path` WITHOUT hitting the network —
258
363
  * the basis for cache-first paint (seed the UI from the last-synced snapshot,
@@ -1008,22 +1113,6 @@ function createMigrator(config) {
1008
1113
  };
1009
1114
  }
1010
1115
 
1011
- // src/fetch.ts
1012
- function classifyError(err) {
1013
- if (err instanceof Response || err && typeof err === "object" && "status" in err) {
1014
- const status = err.status;
1015
- if (typeof status !== "number" || isNaN(status)) return "unknown";
1016
- if (status === 0) return "network";
1017
- if (status === 401 || status === 403) return "auth";
1018
- if (status === 409) return "conflict";
1019
- if (status === 429) return "rate-limited";
1020
- if (status >= 500) return "server";
1021
- if (status >= 400) return "client";
1022
- }
1023
- if (err instanceof Error && /failed to fetch|fetch failed|network|load failed|ECONNREFUSED|ENOTFOUND/i.test(err.message)) return "network";
1024
- return "unknown";
1025
- }
1026
-
1027
1116
  // src/resolvers.ts
1028
1117
  function shallowEqual(a, b) {
1029
1118
  if (a === b) return true;
@@ -1811,6 +1900,7 @@ export {
1811
1900
  isServiceWorkerSupported,
1812
1901
  mutateDoc,
1813
1902
  noopSyncLogger,
1903
+ parseRetryAfterMs,
1814
1904
  pruneTombstones,
1815
1905
  pullWasFromCache,
1816
1906
  registerBackgroundSync,