@bayonai/offline 0.8.0 → 0.9.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,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 0.8.0 - Unreleased
3
+ ## 0.9.0 - 2026-09-26
4
+
5
+ - Added independent read-cache freshness and retention windows with explicit
6
+ `fresh`, `stale`, and `miss` outcomes and selectable refresh-failure behavior.
7
+ - Added retained fallback and stale in-flight response protection to forced
8
+ `loadReadCacheResource` refreshes.
9
+ - Added payload-free read-cache outcome callbacks for host-owned telemetry.
10
+ - Preserved existing `maxAgeMs` behavior for callers that do not opt into the
11
+ new policy. Forced refreshes retain fallback data while invalidating older
12
+ in-flight cache writes.
13
+ - Isolated pending-write listing and clearing by `keyPrefix` when logical
14
+ queues share one physical object store.
15
+ - Fixed NodeNext declaration resolution and supplied matching declarations for
16
+ both ESM and CommonJS consumers.
17
+ - Restored offline retry-control text when a reload attempt throws.
18
+
19
+ ## 0.8.0 - 2026-09-25
4
20
 
5
21
  - Added authoritative-result reconciliation to `createDurableCommandStore`,
6
22
  including rebasing remaining optimistic commands without a transient revert.
package/README.md CHANGED
@@ -25,8 +25,15 @@ import {
25
25
  createReadCacheKey,
26
26
  loadReadCacheResource,
27
27
  pruneReadCache,
28
+ type ReadCachePolicy,
28
29
  } from "@bayonai/offline";
29
30
 
31
+ const policy: ReadCachePolicy = {
32
+ freshForMs: 30_000,
33
+ retainForMs: 7 * 24 * 60 * 60_000,
34
+ refreshFailure: "use-retained",
35
+ };
36
+
30
37
  const cache = createBrowserReadCache({
31
38
  databaseName: "my-app:v1",
32
39
  storeName: "read-cache",
@@ -43,13 +50,41 @@ const projects = await loadReadCacheResource({
43
50
  cache,
44
51
  key,
45
52
  loadFresh: () => fetchProjects(),
46
- maxAgeMs: 30_000,
47
- onUpdate: ({ data, fromCache }) => {
48
- renderProjects(data, { fromCache });
53
+ onOutcome: (outcome) => telemetry.record("offline-read", { outcome }),
54
+ policy,
55
+ onUpdate: ({ data, status, fromCache }) => {
56
+ renderProjects(data, { fromCache, status });
49
57
  },
50
58
  });
51
59
  ```
52
60
 
61
+ `freshForMs` controls how long a cached value can avoid a remote read.
62
+ `retainForMs` independently controls how long it can be shown as an offline
63
+ fallback. A stale-but-retained value is emitted immediately and revalidated;
64
+ an expired value is not emitted and cannot mask a loader failure. The
65
+ `status` is `fresh`, `stale`, or `miss`: a stale update is retained data while
66
+ refreshing or after a failed refresh, and a miss is a successful loader result
67
+ with no retained cache. Successful remote results are `fresh` when replacing
68
+ stale data and `miss` when there was no usable retained data.
69
+
70
+ `onOutcome` emits only a fixed event name (`fresh-hit`, `stale-hit`,
71
+ `cache-miss`, `forced-refresh`, `loader-success`, or `loader-failure`). It never
72
+ includes cache keys, user identifiers, payloads, or error messages. Callback
73
+ exceptions are logged and isolated from the read.
74
+
75
+ When refresh fails, `refreshFailure: "use-retained"` returns retained data
76
+ with `status: "stale"` and the error; `"throw"` propagates the loader error.
77
+ This also applies to forced refresh. Set `forceFresh: true` on
78
+ `loadReadCacheResource` or `readThroughCache` to bypass freshness, retain
79
+ eligible data as the failure fallback, and advance the cache generation so an
80
+ older in-flight load cannot overwrite the refresh. `readThroughCache` returns
81
+ the `status` field alongside its existing `fromCache`, `stale`, and `error`
82
+ fields.
83
+
84
+ For compatibility, callers that omit `policy` retain the previous `maxAgeMs`
85
+ freshness behavior and have no age-based fallback expiry. New callers should
86
+ use `policy`; when supplied it takes precedence over the legacy `maxAgeMs`.
87
+
53
88
  After a successful write, use `readThroughCache({ forceFresh: true })` when
54
89
  the next read must be server-confirmed rather than a pre-write cached or
55
90
  in-flight response. Concurrent ordinary reads for the same cache/key share one
@@ -102,6 +137,11 @@ Failed sync results include a safe persisted `error` string and the original
102
137
  non-persisted `failure` value. Hosts may use `failure` for structured logging or
103
138
  domain error classification; it is never written into the pending-write store.
104
139
 
140
+ `keyPrefix` is an isolation boundary inside a shared object store. Listing,
141
+ recovery, and `clearAll()` affect only that prefix, so independent logical
142
+ queues may safely share the same IndexedDB database and store when their
143
+ prefixes are distinct.
144
+
105
145
  For a browser application that wants durable, silent recovery, mount the
106
146
  headless controller once for the active account or workspace:
107
147
 
@@ -306,15 +346,16 @@ opened at the same version.
306
346
 
307
347
  ```bash
308
348
  pnpm --filter @bayonai/offline test
349
+ pnpm --filter @bayonai/offline test:coverage
309
350
  pnpm --filter @bayonai/offline run build
310
- pnpm --filter @bayonai/offline pack --dry-run
351
+ pnpm --filter @bayonai/offline pack:smoke
311
352
  ```
312
353
 
313
354
  ## Publish Checklist
314
355
 
315
356
  1. Run the package tests and build.
316
357
  2. Run the 100% coverage gate and production dependency audit.
317
- 3. Run `pnpm --filter @bayonai/offline pack --dry-run` and confirm the tarball
358
+ 3. Run `pnpm --filter @bayonai/offline pack:smoke` and confirm the tarball
318
359
  includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
319
360
  `CHANGELOG.md`, and `LICENSE`.
320
361
  4. Install the generated tarball into an isolated consumer and verify ESM,
@@ -12,6 +12,7 @@ export function createBrowserOfflineFallbackRecovery({ control, environment = cr
12
12
  function retry() {
13
13
  if (retrying)
14
14
  return;
15
+ const previousText = control.textContent;
15
16
  retrying = true;
16
17
  control.disabled = true;
17
18
  control.textContent = retryingText;
@@ -25,6 +26,7 @@ export function createBrowserOfflineFallbackRecovery({ control, environment = cr
25
26
  });
26
27
  retrying = false;
27
28
  control.disabled = false;
29
+ control.textContent = previousText;
28
30
  }
29
31
  }
30
32
  function handleTouchStart(event) {
@@ -1,5 +1,5 @@
1
- import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites";
2
- import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination";
1
+ import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites.js";
2
+ import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination.js";
3
3
  export declare const initialPendingWriteRetryDelayMs = 2000;
4
4
  export declare const maxPendingWriteRetryDelayMs = 60000;
5
5
  export type BrowserPendingWriteRecoveryEnvironment = {
@@ -1,4 +1,4 @@
1
- import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites";
1
+ import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites.js";
2
2
  export type DurableCommandErrorCategory = "auth" | "conflict" | "connectivity" | "local-storage" | "permission" | "unexpected" | "validation";
3
3
  export type DurableCommandOperation<Command> = {
4
4
  command: Command;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- export * from "./readCache";
2
- export * from "./pendingWrites";
3
- export * from "./browserRecovery";
4
- export * from "./browserCoordination";
5
- export * from "./browserFallbackRecovery";
6
- export * from "./durableCommandStore";
7
- export { OfflineStorageError } from "./idbDriver";
1
+ export * from "./readCache.js";
2
+ export * from "./pendingWrites.js";
3
+ export * from "./browserRecovery.js";
4
+ export * from "./browserCoordination.js";
5
+ export * from "./browserFallbackRecovery.js";
6
+ export * from "./durableCommandStore.js";
7
+ export { OfflineStorageError } from "./idbDriver.js";
@@ -1,4 +1,4 @@
1
- import { type BrowserKeyValueDriverOptions } from "./idbDriver";
1
+ import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
2
2
  export declare const pendingWriteSchemaVersion = 1;
3
3
  export declare const pendingWriteOperationTimeoutMs = 2000;
4
4
  export declare const pendingWriteSyncLeaseTimeoutMs = 60000;
@@ -22,10 +22,14 @@ export type PendingWritePayloadNormalizer<Payload = unknown> = (payload: unknown
22
22
  export type PendingWriteDriver = {
23
23
  clear: (key: string) => Promise<void>;
24
24
  clearIf: (key: string, predicate: PendingWritePredicate) => Promise<boolean>;
25
- clearAll: () => Promise<void>;
25
+ clearAll?: () => Promise<void>;
26
26
  close?: () => Promise<void>;
27
27
  get: (key: string) => Promise<unknown | null>;
28
28
  list: () => Promise<unknown[]>;
29
+ listEntries?: () => Promise<Array<{
30
+ key: string;
31
+ value: unknown;
32
+ }>>;
29
33
  set: (key: string, value: unknown) => Promise<void>;
30
34
  setIf: (key: string, value: unknown, predicate: PendingWritePredicate) => Promise<boolean>;
31
35
  };
@@ -17,6 +17,29 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
17
17
  }
18
18
  }
19
19
  };
20
+ async function listScopedRecords() {
21
+ const readOptions = { normalizeLegacyRecord, normalizePayload };
22
+ const entries = await driver.listEntries?.();
23
+ if (entries) {
24
+ const keyPrefixBoundary = `${keyPrefix}:`;
25
+ return entries
26
+ .filter(({ key }) => key.startsWith(keyPrefixBoundary))
27
+ .map(({ value }) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
28
+ ?.record)
29
+ .filter((record) => Boolean(record));
30
+ }
31
+ // Compatibility path for custom drivers created before listEntries was
32
+ // available. Point reads prove that each candidate belongs to this prefix.
33
+ const candidateIds = new Set((await driver.list())
34
+ .map((value) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
35
+ ?.record.id)
36
+ .filter((id) => Boolean(id)));
37
+ const records = await Promise.all([...candidateIds].map(async (id) => {
38
+ const value = await driver.get(createPendingWriteKey({ id, prefix: keyPrefix }));
39
+ return toPendingWriteEnvelope(value, schemaVersion, readOptions)?.record;
40
+ }));
41
+ return records.filter((record) => Boolean(record));
42
+ }
20
43
  return {
21
44
  async claimIfSnapshotId(id, snapshotId, record, now) {
22
45
  const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
@@ -37,7 +60,20 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
37
60
  notify({ id, type: "clear" });
38
61
  },
39
62
  async clearAll() {
40
- await driver.clearAll();
63
+ const entries = await driver.listEntries?.();
64
+ if (entries) {
65
+ const keyPrefixBoundary = `${keyPrefix}:`;
66
+ await Promise.all(entries
67
+ .filter(({ key }) => key.startsWith(keyPrefixBoundary))
68
+ .map(({ key }) => driver.clear(key)));
69
+ }
70
+ else {
71
+ const records = await listScopedRecords();
72
+ await Promise.all(records.map((record) => driver.clearIf(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
73
+ normalizeLegacyRecord,
74
+ normalizePayload,
75
+ })?.record.snapshotId === record.snapshotId)));
76
+ }
41
77
  notify({ type: "clearAll" });
42
78
  },
43
79
  async clearIfSnapshotId(id, snapshotId) {
@@ -57,13 +93,7 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
57
93
  return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
58
94
  },
59
95
  async list() {
60
- return (await driver.list())
61
- .map((value) => toPendingWriteEnvelope(value, schemaVersion, {
62
- normalizeLegacyRecord,
63
- normalizePayload,
64
- })?.record)
65
- .filter((record) => Boolean(record))
66
- .sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
96
+ return (await listScopedRecords()).sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
67
97
  left.id.localeCompare(right.id));
68
98
  },
69
99
  async replaceIfSnapshotId(id, snapshotId, record) {
@@ -118,6 +148,9 @@ export function createMemoryPendingWriteDriver() {
118
148
  async list() {
119
149
  return [...records.values()];
120
150
  },
151
+ async listEntries() {
152
+ return [...records].map(([key, value]) => ({ key, value }));
153
+ },
121
154
  async set(key, value) {
122
155
  records.set(key, value);
123
156
  },
@@ -374,7 +407,6 @@ function createUnavailablePendingWriteDriver() {
374
407
  async clearIf() {
375
408
  return false;
376
409
  },
377
- async clearAll() { },
378
410
  async close() { },
379
411
  async get() {
380
412
  return null;
@@ -398,6 +430,7 @@ function toPendingWriteDriver(driver) {
398
430
  close: driver.close,
399
431
  get: driver.get,
400
432
  list: driver.list,
433
+ listEntries: driver.listEntries,
401
434
  set: driver.set,
402
435
  setIf: driver.setIf,
403
436
  };
@@ -1,4 +1,4 @@
1
- import { type BrowserKeyValueDriverOptions } from "./idbDriver";
1
+ import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
2
2
  export declare const readCacheSchemaVersion = 1;
3
3
  export declare const readCacheFreshnessMaxAgeMs = 30000;
4
4
  export declare const durableReadCacheFreshnessMaxAgeMs: number;
@@ -29,21 +29,36 @@ export type ReadCache = {
29
29
  clear: () => Promise<void>;
30
30
  close: () => Promise<void>;
31
31
  delete: (key: string) => Promise<void>;
32
+ /** Advances a key's generation without deleting its retained value. */
33
+ invalidate?: (key: string) => void;
32
34
  get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
33
35
  getVersion: (key: string) => string;
34
36
  set: <T>(key: string, value: T) => Promise<void>;
35
37
  setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
36
38
  };
39
+ export type ReadCachePolicy = {
40
+ /** Time that a cached value can suppress a remote read. */
41
+ freshForMs: number;
42
+ /** Maximum age at which cached data remains eligible as an offline fallback. */
43
+ retainForMs: number;
44
+ /** Behavior when revalidation fails while retained data is available. */
45
+ refreshFailure: "use-retained" | "throw";
46
+ };
47
+ export type ReadCacheStatus = "fresh" | "stale" | "miss";
48
+ /** Payload-free cache lifecycle events suitable for host-owned telemetry. */
49
+ export type ReadCacheOutcome = "fresh-hit" | "stale-hit" | "cache-miss" | "forced-refresh" | "loader-success" | "loader-failure";
37
50
  export type ReadThroughCacheResult<T> = {
38
51
  data: T;
39
52
  error: Error | null;
40
53
  fromCache: boolean;
41
54
  stale: boolean;
55
+ status: ReadCacheStatus;
42
56
  };
43
57
  export type ReadCacheResourceUpdate<T> = {
44
58
  data: T;
45
59
  error?: Error;
46
60
  fromCache: boolean;
61
+ status: ReadCacheStatus;
47
62
  };
48
63
  export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
49
64
  databaseName: string;
@@ -69,22 +84,28 @@ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
69
84
  now?: () => number;
70
85
  }): Promise<number>;
71
86
  export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
72
- export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, }: {
87
+ export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
73
88
  cache: ReadCache;
74
89
  /**
75
- * Invalidates the current value before loading. Use after a successful write
76
- * when a cached or earlier in-flight response must not become authoritative.
90
+ * Revalidates after advancing the key generation, retaining the prior value
91
+ * as a possible fallback if refresh fails.
77
92
  */
78
93
  forceFresh?: boolean;
79
94
  key: string;
80
95
  loadFresh: () => Promise<T>;
81
96
  maxAgeMs?: number;
97
+ onOutcome?: (outcome: ReadCacheOutcome) => void;
98
+ policy?: ReadCachePolicy;
82
99
  }): Promise<ReadThroughCacheResult<T>>;
83
- export declare function loadReadCacheResource<T>({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache, }: {
100
+ export declare function loadReadCacheResource<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, }: {
84
101
  cache: ReadCache;
102
+ /** Revalidate while retaining eligible data as fallback on loader failure. */
103
+ forceFresh?: boolean;
85
104
  key: string;
86
105
  loadFresh: () => Promise<T>;
87
106
  maxAgeMs?: number;
107
+ onOutcome?: (outcome: ReadCacheOutcome) => void;
108
+ policy?: ReadCachePolicy;
88
109
  onCacheWriteError?: (error: Error) => void;
89
110
  onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
90
111
  skipCache?: boolean;
package/dist/readCache.js CHANGED
@@ -16,6 +16,10 @@ export function createReadCache(driver) {
16
16
  function getVersion(key) {
17
17
  return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
18
18
  }
19
+ function invalidate(key) {
20
+ pendingLoads.delete(key);
21
+ keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
22
+ }
19
23
  function enqueue(operation) {
20
24
  const next = operationQueue.then(operation, operation);
21
25
  operationQueue = next.then(() => undefined, () => undefined);
@@ -32,10 +36,10 @@ export function createReadCache(driver) {
32
36
  await enqueue(async () => driver.close?.());
33
37
  },
34
38
  async delete(key) {
35
- pendingLoads.delete(key);
36
- keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
39
+ invalidate(key);
37
40
  await enqueue(() => driver.delete(key));
38
41
  },
42
+ invalidate,
39
43
  async get(key) {
40
44
  const envelope = toReadCacheEnvelope(await driver.get(key));
41
45
  return envelope
@@ -152,13 +156,21 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
152
156
  .filter(Boolean)
153
157
  .join(":");
154
158
  }
155
- export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
159
+ export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
156
160
  const resourceCache = cache.capture?.() ?? cache;
161
+ const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
162
+ const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
157
163
  if (forceFresh) {
158
- // Deletion advances the cache version, so a request started before this
159
- // post-write refresh cannot restore stale data after it finishes.
164
+ reportReadCacheOutcome(onOutcome, "forced-refresh");
165
+ // Built-in caches advance generation without deleting fallback data.
166
+ // Legacy custom adapters retain their old delete behavior.
160
167
  try {
161
- await resourceCache.delete(key);
168
+ if (resourceCache.invalidate) {
169
+ resourceCache.invalidate(key);
170
+ }
171
+ else {
172
+ await resourceCache.delete(key);
173
+ }
162
174
  }
163
175
  catch (error) {
164
176
  console.error("[Bayon offline] Unable to invalidate the read cache.", {
@@ -168,17 +180,22 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
168
180
  }
169
181
  }
170
182
  const version = resourceCache.getVersion(key);
171
- const cached = await resourceCache.get(key);
172
- if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
183
+ const cached = forcedFallback ?? (await resourceCache.get(key));
184
+ const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
185
+ if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
186
+ reportReadCacheOutcome(onOutcome, "fresh-hit");
173
187
  return {
174
188
  data: cached.value,
175
189
  error: null,
176
190
  fromCache: true,
177
191
  stale: false,
192
+ status: "fresh",
178
193
  };
179
194
  }
195
+ reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
180
196
  try {
181
197
  const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
198
+ reportReadCacheOutcome(onOutcome, "loader-success");
182
199
  try {
183
200
  await resourceCache.setIfVersion(key, fresh, version);
184
201
  }
@@ -193,33 +210,74 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
193
210
  error: null,
194
211
  fromCache: false,
195
212
  stale: false,
213
+ status: retained ? "fresh" : "miss",
196
214
  };
197
215
  }
198
216
  catch (error) {
199
- if (cached) {
217
+ reportReadCacheOutcome(onOutcome, "loader-failure");
218
+ if (retained && resolvedPolicy.refreshFailure === "use-retained") {
200
219
  return {
201
220
  data: cached.value,
202
221
  error: normalizeError(error),
203
222
  fromCache: true,
204
223
  stale: true,
224
+ status: "stale",
205
225
  };
206
226
  }
207
227
  throw error;
208
228
  }
209
229
  }
210
- export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
230
+ export async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
211
231
  const resourceCache = cache.capture?.() ?? cache;
232
+ const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
233
+ const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
234
+ if (forceFresh) {
235
+ reportReadCacheOutcome(onOutcome, "forced-refresh");
236
+ try {
237
+ if (resourceCache.invalidate) {
238
+ resourceCache.invalidate(key);
239
+ }
240
+ else {
241
+ await resourceCache.delete(key);
242
+ }
243
+ }
244
+ catch (error) {
245
+ console.error("[Bayon offline] Unable to invalidate the read cache.", {
246
+ error: normalizeError(error),
247
+ key,
248
+ });
249
+ }
250
+ }
212
251
  const version = resourceCache.getVersion(key);
213
- const cached = skipCache ? null : await resourceCache.get(key);
214
- if (cached) {
215
- onUpdate({ data: cached.value, fromCache: true });
216
- if (isCacheFresh(cached.savedAt, maxAgeMs)) {
252
+ const cached = skipCache
253
+ ? null
254
+ : (forcedFallback ?? (await resourceCache.get(key)));
255
+ const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
256
+ if (retained) {
257
+ const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
258
+ reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
259
+ onUpdate({
260
+ data: cached.value,
261
+ fromCache: true,
262
+ status: fresh && !forceFresh ? "fresh" : "stale",
263
+ });
264
+ if (fresh && !forceFresh) {
217
265
  return cached.value;
218
266
  }
219
267
  }
268
+ else {
269
+ reportReadCacheOutcome(onOutcome, "cache-miss");
270
+ }
220
271
  try {
221
272
  const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
222
- onUpdate({ data: fresh, fromCache: false });
273
+ reportReadCacheOutcome(onOutcome, "loader-success");
274
+ if (resourceCache.getVersion(key) === version) {
275
+ onUpdate({
276
+ data: fresh,
277
+ fromCache: false,
278
+ status: retained ? "fresh" : "miss",
279
+ });
280
+ }
223
281
  void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
224
282
  const normalizedError = normalizeError(error);
225
283
  console.error("[Bayon offline] Unable to refresh the read cache.", {
@@ -231,17 +289,32 @@ export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, o
231
289
  return fresh;
232
290
  }
233
291
  catch (error) {
234
- if (!cached) {
292
+ reportReadCacheOutcome(onOutcome, "loader-failure");
293
+ if (!retained || resolvedPolicy.refreshFailure === "throw") {
235
294
  throw error;
236
295
  }
237
296
  onUpdate({
238
297
  data: cached.value,
239
298
  error: normalizeError(error),
240
299
  fromCache: true,
300
+ status: "stale",
241
301
  });
242
302
  return cached.value;
243
303
  }
244
304
  }
305
+ function reportReadCacheOutcome(onOutcome, outcome) {
306
+ if (!onOutcome)
307
+ return;
308
+ try {
309
+ onOutcome(outcome);
310
+ }
311
+ catch (error) {
312
+ console.error("[Bayon offline] Read-cache outcome callback failed.", {
313
+ error: normalizeError(error),
314
+ outcome,
315
+ });
316
+ }
317
+ }
245
318
  function createNoopReadCacheDriver() {
246
319
  return {
247
320
  async clear() { },
@@ -256,12 +329,33 @@ function createNoopReadCacheDriver() {
256
329
  async set() { },
257
330
  };
258
331
  }
259
- function isCacheFresh(savedAt, maxAgeMs) {
260
- if (!maxAgeMs || maxAgeMs <= 0) {
332
+ function isCacheFresh(savedAt, policy) {
333
+ if (policy.freshForMs <= 0) {
261
334
  return false;
262
335
  }
263
336
  const savedTime = Date.parse(savedAt);
264
- return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
337
+ return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.freshForMs);
338
+ }
339
+ function resolvePolicy(policy, maxAgeMs) {
340
+ if (policy) {
341
+ if (!Number.isFinite(policy.freshForMs) ||
342
+ policy.freshForMs < 0 ||
343
+ !Number.isFinite(policy.retainForMs) ||
344
+ policy.retainForMs < policy.freshForMs ||
345
+ !["use-retained", "throw"].includes(policy.refreshFailure)) {
346
+ throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
347
+ }
348
+ return policy;
349
+ }
350
+ return {
351
+ freshForMs: maxAgeMs ?? 0,
352
+ retainForMs: Number.POSITIVE_INFINITY,
353
+ refreshFailure: "use-retained",
354
+ };
355
+ }
356
+ function isCacheRetained(savedAt, policy) {
357
+ const savedTime = Date.parse(savedAt);
358
+ return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.retainForMs);
265
359
  }
266
360
  async function loadSharedFreshResource(cache, key, loadFresh) {
267
361
  const pendingLoads = getPendingFreshLoads(cache);
@@ -0,0 +1,23 @@
1
+ export type BrowserRecoveryMessageChannel = {
2
+ addEventListener: (type: "message", listener: () => void) => void;
3
+ close: () => void;
4
+ postMessage: (message: "pending-write-recovery") => void;
5
+ removeEventListener: (type: "message", listener: () => void) => void;
6
+ };
7
+ export type BrowserRecoveryLockManager = {
8
+ request: <T>(name: string, options: {
9
+ ifAvailable: true;
10
+ }, callback: (lock: unknown | null) => Promise<T | null>) => Promise<T | null>;
11
+ };
12
+ export type BrowserPendingWriteRecoveryCoordination = {
13
+ close: () => void;
14
+ notify: () => void;
15
+ runExclusive: <T>(attempt: () => Promise<T>) => Promise<T | null>;
16
+ subscribe: (listener: () => void) => () => void;
17
+ };
18
+ export declare function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel, locks, }: {
19
+ channel?: BrowserRecoveryMessageChannel | null;
20
+ channelName: string;
21
+ leaseName: string;
22
+ locks?: BrowserRecoveryLockManager | null;
23
+ }): BrowserPendingWriteRecoveryCoordination;
@@ -0,0 +1,32 @@
1
+ export declare const defaultOfflineFallbackRetryEvent = "app:network-restored";
2
+ export declare const defaultOfflineFallbackPullThreshold = 72;
3
+ export type BrowserOfflineFallbackRecoveryEnvironment = {
4
+ addEventListener: (type: string, listener: (event: unknown) => void) => void;
5
+ getScrollY: () => number;
6
+ reload: () => void;
7
+ removeEventListener: (type: string, listener: (event: unknown) => void) => void;
8
+ };
9
+ export type OfflineFallbackRetryControl = {
10
+ disabled: boolean;
11
+ textContent: string | null;
12
+ };
13
+ export type BrowserOfflineFallbackRecovery = {
14
+ /** Attempts recovery once. Repeated calls are ignored while navigation starts. */
15
+ retry: () => void;
16
+ start: () => void;
17
+ stop: () => void;
18
+ };
19
+ export type BrowserOfflineFallbackRecoveryOptions = {
20
+ control: OfflineFallbackRetryControl;
21
+ environment?: BrowserOfflineFallbackRecoveryEnvironment;
22
+ /** A host-native validation callback may dispatch this event after connectivity returns. */
23
+ retryEvent?: string;
24
+ retryingText?: string;
25
+ /** Set to `null` to omit touch pull-to-retry. */
26
+ pullThreshold?: number | null;
27
+ };
28
+ /**
29
+ * Owns generic recovery mechanics for a static offline fallback. Hosts retain
30
+ * all markup, copy, styling, and the choice of a native reconnect event.
31
+ */
32
+ export declare function createBrowserOfflineFallbackRecovery({ control, environment, pullThreshold, retryEvent, retryingText, }: BrowserOfflineFallbackRecoveryOptions): BrowserOfflineFallbackRecovery;
@@ -16,6 +16,7 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
16
16
  function retry() {
17
17
  if (retrying)
18
18
  return;
19
+ const previousText = control.textContent;
19
20
  retrying = true;
20
21
  control.disabled = true;
21
22
  control.textContent = retryingText;
@@ -29,6 +30,7 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
29
30
  });
30
31
  retrying = false;
31
32
  control.disabled = false;
33
+ control.textContent = previousText;
32
34
  }
33
35
  }
34
36
  function handleTouchStart(event) {