@bayonai/offline 0.8.0 → 0.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +107 -16
  3. package/RECOVERY.md +64 -0
  4. package/dist/browserFallbackRecovery.d.ts +10 -9
  5. package/dist/browserFallbackRecovery.js +100 -28
  6. package/dist/browserRecovery.d.ts +2 -2
  7. package/dist/durableCommandStore.d.ts +1 -1
  8. package/dist/idbDriver.d.ts +3 -1
  9. package/dist/idbDriver.js +29 -14
  10. package/dist/index.d.ts +10 -7
  11. package/dist/index.js +3 -0
  12. package/dist/pendingWriteSupersession.d.ts +11 -0
  13. package/dist/pendingWriteSupersession.js +25 -0
  14. package/dist/pendingWrites.d.ts +6 -2
  15. package/dist/pendingWrites.js +45 -11
  16. package/dist/readBoundary.d.ts +13 -0
  17. package/dist/readBoundary.js +23 -0
  18. package/dist/readCache.d.ts +26 -5
  19. package/dist/readCache.js +113 -19
  20. package/dist/recoveryPolicy.d.ts +20 -0
  21. package/dist/recoveryPolicy.js +73 -0
  22. package/dist-cjs/browserCoordination.d.ts +23 -0
  23. package/dist-cjs/browserFallbackRecovery.d.ts +33 -0
  24. package/dist-cjs/browserFallbackRecovery.js +100 -28
  25. package/dist-cjs/browserRecovery.d.ts +42 -0
  26. package/dist-cjs/durableCommandStore.d.ts +49 -0
  27. package/dist-cjs/idbDriver.d.ts +28 -0
  28. package/dist-cjs/idbDriver.js +29 -14
  29. package/dist-cjs/index.d.ts +10 -0
  30. package/dist-cjs/index.js +3 -0
  31. package/dist-cjs/pendingWriteSupersession.d.ts +11 -0
  32. package/dist-cjs/pendingWriteSupersession.js +28 -0
  33. package/dist-cjs/pendingWrites.d.ts +179 -0
  34. package/dist-cjs/pendingWrites.js +45 -11
  35. package/dist-cjs/readBoundary.d.ts +13 -0
  36. package/dist-cjs/readBoundary.js +26 -0
  37. package/dist-cjs/readCache.d.ts +112 -0
  38. package/dist-cjs/readCache.js +113 -19
  39. package/dist-cjs/recoveryPolicy.d.ts +20 -0
  40. package/dist-cjs/recoveryPolicy.js +78 -0
  41. package/package.json +16 -7
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
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";
8
+ export * from "./readBoundary.js";
9
+ export * from "./recoveryPolicy.js";
10
+ export * from "./pendingWriteSupersession.js";
package/dist/index.js CHANGED
@@ -5,3 +5,6 @@ export * from "./browserCoordination.js";
5
5
  export * from "./browserFallbackRecovery.js";
6
6
  export * from "./durableCommandStore.js";
7
7
  export { OfflineStorageError } from "./idbDriver.js";
8
+ export * from "./readBoundary.js";
9
+ export * from "./recoveryPolicy.js";
10
+ export * from "./pendingWriteSupersession.js";
@@ -0,0 +1,11 @@
1
+ import type { PendingWriteRecord, PendingWriteStore } from "./pendingWrites.js";
2
+ /**
3
+ * After remote acknowledgment, retire only rejected snapshots superseded by an
4
+ * explicit host-owned account/entity/field key. Never infer keys from payloads.
5
+ */
6
+ export declare function clearSupersededPendingWriteFailures<Payload>({ acknowledged, getSupersessionKey, isRejected, store, }: {
7
+ acknowledged: PendingWriteRecord<Payload>;
8
+ getSupersessionKey: (record: PendingWriteRecord<Payload>) => string | null;
9
+ isRejected: (record: PendingWriteRecord<Payload>) => boolean;
10
+ store: PendingWriteStore<Payload>;
11
+ }): Promise<number>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * After remote acknowledgment, retire only rejected snapshots superseded by an
3
+ * explicit host-owned account/entity/field key. Never infer keys from payloads.
4
+ */
5
+ export async function clearSupersededPendingWriteFailures({ acknowledged, getSupersessionKey, isRejected, store, }) {
6
+ const key = getSupersessionKey(acknowledged);
7
+ if (key === null)
8
+ return 0;
9
+ let cleared = 0;
10
+ for (const previous of await store.list()) {
11
+ const older = previous.savedAt.localeCompare(acknowledged.savedAt) ||
12
+ previous.id.localeCompare(acknowledged.id);
13
+ if (previous.id === acknowledged.id ||
14
+ older >= 0 ||
15
+ previous.syncStatus !== "failed" ||
16
+ previous.syncingAt ||
17
+ !isRejected(previous) ||
18
+ getSupersessionKey(previous) !== key)
19
+ continue;
20
+ // Snapshot fencing protects a correction or claim accepted during the scan.
21
+ if (await store.clearIfSnapshotId(previous.id, previous.snapshotId))
22
+ cleared++;
23
+ }
24
+ return cleared;
25
+ }
@@ -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
  },
@@ -137,6 +170,7 @@ export function createBrowserPendingWriteDriver({ databaseName, databaseVersion,
137
170
  indexedDB,
138
171
  operationTimeoutMs,
139
172
  storeName,
173
+ readFailureMode: "throw",
140
174
  });
141
175
  return driver ? toPendingWriteDriver(driver) : null;
142
176
  }
@@ -374,13 +408,12 @@ function createUnavailablePendingWriteDriver() {
374
408
  async clearIf() {
375
409
  return false;
376
410
  },
377
- async clearAll() { },
378
411
  async close() { },
379
412
  async get() {
380
- return null;
413
+ throw unavailable();
381
414
  },
382
415
  async list() {
383
- return [];
416
+ throw unavailable();
384
417
  },
385
418
  async set() {
386
419
  throw unavailable();
@@ -398,6 +431,7 @@ function toPendingWriteDriver(driver) {
398
431
  close: driver.close,
399
432
  get: driver.get,
400
433
  list: driver.list,
434
+ listEntries: driver.listEntries,
401
435
  set: driver.set,
402
436
  setIf: driver.setIf,
403
437
  };
@@ -0,0 +1,13 @@
1
+ export type ReadBoundaryOptions = {
2
+ modules: Readonly<Record<string, readonly string[]>>;
3
+ roots: readonly string[];
4
+ allowedAdapters: readonly string[];
5
+ isForbidden: (moduleId: string) => boolean;
6
+ };
7
+ /**
8
+ * Host contract: supply every reachable local runtime dependency, including
9
+ * re-exports and dynamic imports. Absent nodes are external leaves. Exact
10
+ * trusted adapter IDs stop traversal; hosts verify their cache policy separately.
11
+ * This test utility deliberately owns no parser, framework, or provider policy.
12
+ */
13
+ export declare function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }: ReadBoundaryOptions): void;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Host contract: supply every reachable local runtime dependency, including
3
+ * re-exports and dynamic imports. Absent nodes are external leaves. Exact
4
+ * trusted adapter IDs stop traversal; hosts verify their cache policy separately.
5
+ * This test utility deliberately owns no parser, framework, or provider policy.
6
+ */
7
+ export function assertReadBoundary({ modules, roots, allowedAdapters, isForbidden, }) {
8
+ const adapters = new Set(allowedAdapters);
9
+ const visited = new Set();
10
+ function visit(id, path) {
11
+ if (adapters.has(id) || visited.has(id))
12
+ return;
13
+ const nextPath = [...path, id];
14
+ if (isForbidden(id)) {
15
+ throw new Error(`Read boundary violation: ${nextPath.join(" -> ")}`);
16
+ }
17
+ visited.add(id);
18
+ for (const dependency of modules[id] ?? [])
19
+ visit(dependency, nextPath);
20
+ }
21
+ for (const root of roots)
22
+ visit(root, []);
23
+ }
@@ -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,20 @@
1
+ /** Shared product decision: recovery is application work, never a retry prompt. */
2
+ export declare const offlineRecoveryCopy: {
3
+ readonly reload: "Reload";
4
+ readonly reloading: "Reconnecting…";
5
+ readonly preservedChanges: "Some changes could not be saved. Your edits are preserved.";
6
+ };
7
+ export type PendingChangesGuidanceState = {
8
+ hasReadyChanges: boolean;
9
+ hasDrafts: boolean;
10
+ hasErrors: boolean;
11
+ isOffline: boolean;
12
+ };
13
+ /** Hosts project domain records to flags; payloads never enter shared guidance. */
14
+ export declare function getPendingChangesGuidance({ hasReadyChanges, hasDrafts, hasErrors, isOffline, }: PendingChangesGuidanceState): string;
15
+ /**
16
+ * Recognize known transient transport/service failures without a provider SDK.
17
+ * Hosts still own domain validation, idempotency, account scope and replay selection.
18
+ * Unknown failures are not permission to blindly repeat a mutation.
19
+ */
20
+ export declare function isTransientOfflineError(error: unknown): boolean;
@@ -0,0 +1,73 @@
1
+ /** Shared product decision: recovery is application work, never a retry prompt. */
2
+ export const offlineRecoveryCopy = {
3
+ reload: "Reload",
4
+ reloading: "Reconnecting…",
5
+ preservedChanges: "Some changes could not be saved. Your edits are preserved.",
6
+ };
7
+ /** Hosts project domain records to flags; payloads never enter shared guidance. */
8
+ export function getPendingChangesGuidance({ hasReadyChanges, hasDrafts, hasErrors, isOffline, }) {
9
+ if (!hasReadyChanges)
10
+ return hasDrafts
11
+ ? "Finish editing these drafts to save them."
12
+ : offlineRecoveryCopy.preservedChanges;
13
+ if (!hasDrafts && !hasErrors)
14
+ return isOffline
15
+ ? "We’ll sync when you’re back online."
16
+ : "We’ll sync automatically.";
17
+ const sync = isOffline
18
+ ? "Changes ready to send will sync when you’re back online."
19
+ : "Changes ready to send will sync automatically.";
20
+ return hasDrafts ? `${sync} Finish editing drafts to save them.` : sync;
21
+ }
22
+ const terminalCodes = new Set([
23
+ "unauthenticated",
24
+ "permission-denied",
25
+ "resource-exhausted",
26
+ "invalid-argument",
27
+ "failed-precondition",
28
+ "not-found",
29
+ "already-exists",
30
+ ]);
31
+ const transientCodes = new Set([
32
+ "unavailable",
33
+ "deadline-exceeded",
34
+ "cancelled",
35
+ "network-request-failed",
36
+ "internal",
37
+ "aborted",
38
+ ]);
39
+ // Exact transport diagnostics are safe fallbacks only when no structured code
40
+ // exists. Domain messages mentioning a network/offline setting are not signals.
41
+ const connectivityMessages = new Set([
42
+ "failed to fetch",
43
+ "fetch failed",
44
+ "network request failed",
45
+ "load failed",
46
+ "offline",
47
+ "network unavailable",
48
+ "failed to get documents from server. these documents may exist in the local cache",
49
+ "failed to get document because the client is offline",
50
+ ]);
51
+ /**
52
+ * Recognize known transient transport/service failures without a provider SDK.
53
+ * Hosts still own domain validation, idempotency, account scope and replay selection.
54
+ * Unknown failures are not permission to blindly repeat a mutation.
55
+ */
56
+ export function isTransientOfflineError(error) {
57
+ const record = error && typeof error === "object" ? error : {};
58
+ const rawCode = "code" in record ? record.code : undefined;
59
+ const code = typeof rawCode === "string" ? rawCode.toLowerCase().split("/").at(-1) : "";
60
+ if (terminalCodes.has(code))
61
+ return false;
62
+ if (transientCodes.has(code))
63
+ return true;
64
+ if (code)
65
+ return false;
66
+ const rawMessage = typeof error === "string"
67
+ ? error
68
+ : "message" in record
69
+ ? record.message
70
+ : undefined;
71
+ const message = typeof rawMessage === "string" ? rawMessage.toLowerCase() : "";
72
+ return connectivityMessages.has(message.trim().replace(/[.!]$/, ""));
73
+ }
@@ -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;