@bayonai/offline 0.12.0 → 0.13.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,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.12.0 candidate - 2026-11-05
3
+ ## 0.13.0 - 2026-11-06
4
+
5
+ - Add opt-in host-classified read failure handling, conditional terminal
6
+ retirement, source-required reads and layered provenance without renewing
7
+ retained cache age. Snapshot identifiers protect newer cross-instance writes;
8
+ existing version-one envelopes remain readable without a database upgrade.
9
+
10
+ ## 0.12.0 - 2026-11-06
4
11
 
5
12
  - Add framework-neutral pending-change projection and observable tracker, with host-supplied existing queues/drafts, account and stale-read fencing, stable external-store snapshots, grouping/counts/guidance, and retained last known records on read failures.
6
13
 
package/README.md CHANGED
@@ -74,6 +74,15 @@ exceptions are logged and isolated from the read.
74
74
 
75
75
  When refresh fails, `refreshFailure: "use-retained"` returns retained data
76
76
  with `status: "stale"` and the error; `"throw"` propagates the loader error.
77
+ An opt-in callback can classify the original failure as `"use-retained"`,
78
+ `"throw"`, or `"retire"`. Retire propagates the original failure and
79
+ conditionally removes the captured snapshot; a later confirmation is preserved.
80
+ Built-in memory and IndexedDB drivers support atomic snapshot comparison.
81
+ Custom adapters must implement `deleteIfVersion` with equivalent atomic
82
+ ownership checks; without it, retirement is logged and cannot be guaranteed.
83
+ New envelopes carry an opaque snapshot identifier. Existing version-one
84
+ envelopes remain readable; legacy comparison is limited to serializable values
85
+ and cannot distinguish identical writes by older, identifier-unaware clients.
77
86
  This also applies to forced refresh. Set `forceFresh: true` on
78
87
  `loadReadCacheResource` or `readThroughCache` to bypass freshness, retain
79
88
  eligible data as the failure fallback, and advance the cache generation so an
@@ -81,6 +90,19 @@ older in-flight load cannot overwrite the refresh. `readThroughCache` returns
81
90
  the `status` field alongside its existing `fromCache`, `stale`, and `error`
82
91
  fields.
83
92
 
93
+ Set `includeProvenance: true` to receive `cache-hit`, `source-confirmed`, or
94
+ `retained-fallback` provenance. Cache-derived results include their original
95
+ `savedAt`; fallback also carries `originalError` without normalizing it. Layered
96
+ adapters may use `loadFreshResult` to pass a provenance-bearing
97
+ `readThroughCache` result. Cached and fallback values from an inner layer are
98
+ not persisted as fresh outer confirmations. `requireSource: true` rejects
99
+ cached or fallback results and preserves a fallback's original failure. The
100
+ host still owns authorization, account scoping and retention durations.
101
+ Source confirmations include `confirmedAt` when provenance is requested;
102
+ the built-in cache preserves that time on outer writes. Custom adapters may
103
+ implement `setIfVersionAt` to preserve it. Without that capability, layered
104
+ confirmations are delivered but not persisted by the outer adapter.
105
+
84
106
  For compatibility, callers that omit `policy` retain the previous `maxAgeMs`
85
107
  freshness behavior and have no age-based fallback expiry. New callers should
86
108
  use `policy`; when supplied it takes precedence over the legacy `maxAgeMs`.
@@ -383,7 +405,7 @@ pnpm --filter @bayonai/offline pack:smoke
383
405
  2. Run the 100% coverage gate and production dependency audit.
384
406
  3. Run `pnpm --filter @bayonai/offline pack:smoke` and confirm the tarball
385
407
  includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
386
- `CHANGELOG.md`, and `LICENSE`.
408
+ `CHANGELOG.md`, `RECOVERY.md`, and `LICENSE`.
387
409
  4. Install the generated tarball into an isolated consumer and verify ESM,
388
410
  CommonJS, and TypeScript declaration imports.
389
411
  5. Confirm `package.json` metadata, version, repository, license, and
@@ -35,7 +35,8 @@ export function projectPendingWriteFinalState(records, getKey, { isBarrier = ()
35
35
  const previous = latest.get(key);
36
36
  if (previous) {
37
37
  superseded.add(previous);
38
- for (const effect of dependents.get(key) ?? [])
38
+ // Every latest entry creates its matching dependency list below.
39
+ for (const effect of dependents.get(key))
39
40
  superseded.add(effect);
40
41
  }
41
42
  latest.set(key, record);
@@ -13,12 +13,14 @@ export type ReadCacheDriver = {
13
13
  clear: () => Promise<void>;
14
14
  close?: () => Promise<void>;
15
15
  delete: (key: string) => Promise<void>;
16
+ deleteIf?: (key: string, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
16
17
  get: (key: string) => Promise<unknown | null>;
17
18
  listEntries?: () => Promise<Array<{
18
19
  key: string;
19
20
  value: unknown;
20
21
  }>>;
21
22
  set: (key: string, value: unknown) => Promise<void>;
23
+ setIf?: (key: string, value: unknown, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
22
24
  };
23
25
  export type ReadCache = {
24
26
  /**
@@ -29,12 +31,15 @@ export type ReadCache = {
29
31
  clear: () => Promise<void>;
30
32
  close: () => Promise<void>;
31
33
  delete: (key: string) => Promise<void>;
34
+ /** Retire only a snapshot still owned by the captured generation. */
35
+ deleteIfVersion?: (key: string, version: string, snapshot: ReadCacheHit<unknown>) => Promise<boolean>;
32
36
  /** Advances a key's generation without deleting its retained value. */
33
37
  invalidate?: (key: string) => void;
34
38
  get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
35
39
  getVersion: (key: string) => string;
36
40
  set: <T>(key: string, value: T) => Promise<void>;
37
- setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
41
+ setIfVersion: <T>(key: string, value: T, version: string, snapshot?: ReadCacheHit<unknown> | null, confirmedAt?: string) => Promise<boolean>;
42
+ setIfVersionAt?: <T>(key: string, value: T, version: string, snapshot: ReadCacheHit<unknown> | null, confirmedAt: string) => Promise<boolean>;
38
43
  };
39
44
  export type ReadCachePolicy = {
40
45
  /** Time that a cached value can suppress a remote read. */
@@ -42,9 +47,10 @@ export type ReadCachePolicy = {
42
47
  /** Maximum age at which cached data remains eligible as an offline fallback. */
43
48
  retainForMs: number;
44
49
  /** Behavior when revalidation fails while retained data is available. */
45
- refreshFailure: "use-retained" | "throw";
50
+ refreshFailure: "use-retained" | "throw" | ((error: unknown) => "use-retained" | "throw" | "retire");
46
51
  };
47
52
  export type ReadCacheStatus = "fresh" | "stale" | "miss";
53
+ export type ReadCacheProvenance = "cache-hit" | "source-confirmed" | "retained-fallback";
48
54
  /** Payload-free cache lifecycle events suitable for host-owned telemetry. */
49
55
  export type ReadCacheOutcome = "fresh-hit" | "stale-hit" | "cache-miss" | "forced-refresh" | "loader-success" | "loader-failure";
50
56
  export type ReadThroughCacheResult<T> = {
@@ -53,12 +59,27 @@ export type ReadThroughCacheResult<T> = {
53
59
  fromCache: boolean;
54
60
  stale: boolean;
55
61
  status: ReadCacheStatus;
62
+ provenance?: ReadCacheProvenance;
63
+ confirmedAt?: string;
64
+ savedAt?: string;
65
+ originalError?: unknown;
56
66
  };
57
67
  export type ReadCacheResourceUpdate<T> = {
58
68
  data: T;
59
69
  error?: Error;
60
70
  fromCache: boolean;
61
71
  status: ReadCacheStatus;
72
+ provenance?: ReadCacheProvenance;
73
+ confirmedAt?: string;
74
+ savedAt?: string;
75
+ originalError?: unknown;
76
+ };
77
+ export type ReadCacheLoader<T> = {
78
+ loadFresh: () => Promise<T>;
79
+ loadFreshResult?: never;
80
+ } | {
81
+ loadFresh?: never;
82
+ loadFreshResult: () => Promise<ReadThroughCacheResult<T>>;
62
83
  };
63
84
  export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
64
85
  databaseName: string;
@@ -84,29 +105,31 @@ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
84
105
  now?: () => number;
85
106
  }): Promise<number>;
86
107
  export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
87
- export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
108
+ export declare function readThroughCache<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource, }: {
88
109
  cache: ReadCache;
89
110
  /**
90
111
  * Revalidates after advancing the key generation, retaining the prior value
91
112
  * as a possible fallback if refresh fails.
92
113
  */
93
114
  forceFresh?: boolean;
115
+ includeProvenance?: boolean;
94
116
  key: string;
95
- loadFresh: () => Promise<T>;
96
117
  maxAgeMs?: number;
97
118
  onOutcome?: (outcome: ReadCacheOutcome) => void;
98
119
  policy?: ReadCachePolicy;
99
- }): Promise<ReadThroughCacheResult<T>>;
100
- export declare function loadReadCacheResource<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, }: {
120
+ requireSource?: boolean;
121
+ } & ReadCacheLoader<T>): Promise<ReadThroughCacheResult<T>>;
122
+ export declare function loadReadCacheResource<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, requireSource, }: {
101
123
  cache: ReadCache;
102
124
  /** Revalidate while retaining eligible data as fallback on loader failure. */
103
125
  forceFresh?: boolean;
126
+ includeProvenance?: boolean;
104
127
  key: string;
105
- loadFresh: () => Promise<T>;
106
128
  maxAgeMs?: number;
107
129
  onOutcome?: (outcome: ReadCacheOutcome) => void;
108
130
  policy?: ReadCachePolicy;
109
131
  onCacheWriteError?: (error: Error) => void;
110
132
  onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
111
133
  skipCache?: boolean;
112
- }): Promise<T>;
134
+ requireSource?: boolean;
135
+ } & ReadCacheLoader<T>): Promise<T>;
package/dist/readCache.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createIdbKeyValueDriver, } from "./idbDriver.js";
2
+ import { createPendingWriteSnapshotId } from "./pendingWrites.js";
2
3
  export const readCacheSchemaVersion = 1;
3
4
  export const readCacheFreshnessMaxAgeMs = 30_000;
4
5
  export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
@@ -13,6 +14,28 @@ export function createReadCache(driver) {
13
14
  const keyVersions = new Map();
14
15
  let operationQueue = Promise.resolve();
15
16
  const pendingLoads = new Map();
17
+ const capturedSnapshots = new WeakMap();
18
+ function ownsSnapshot(current, snapshot) {
19
+ if (!snapshot)
20
+ return current === null || toReadCacheEnvelope(current) === null;
21
+ const captured = capturedSnapshots.get(snapshot);
22
+ const envelope = toReadCacheEnvelope(current);
23
+ if (!captured || !envelope)
24
+ return false;
25
+ if (captured.snapshotId)
26
+ return envelope.snapshotId === captured.snapshotId;
27
+ if (envelope.snapshotId || envelope.savedAt !== captured.savedAt)
28
+ return false;
29
+ try {
30
+ if (!isComparableLegacyValue(envelope.value) ||
31
+ !isComparableLegacyValue(captured.value))
32
+ return false;
33
+ return JSON.stringify(envelope.value) === JSON.stringify(captured.value);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
16
39
  function getVersion(key) {
17
40
  return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
18
41
  }
@@ -25,6 +48,30 @@ export function createReadCache(driver) {
25
48
  operationQueue = next.then(() => undefined, () => undefined);
26
49
  return next;
27
50
  }
51
+ async function writeIfVersion(key, value, version, snapshot, savedAt) {
52
+ if (getVersion(key) !== version)
53
+ return false;
54
+ return enqueue(async () => {
55
+ if (getVersion(key) !== version)
56
+ return false;
57
+ const next = {
58
+ savedAt,
59
+ schemaVersion: readCacheSchemaVersion,
60
+ snapshotId: createPendingWriteSnapshotId(),
61
+ value,
62
+ };
63
+ if (snapshot !== undefined && driver.setIf) {
64
+ const written = await driver.setIf(key, next, (current) => getVersion(key) === version && ownsSnapshot(current, snapshot));
65
+ if (!written)
66
+ return false;
67
+ }
68
+ else {
69
+ await driver.set(key, next);
70
+ }
71
+ invalidate(key);
72
+ return true;
73
+ });
74
+ }
28
75
  const cache = {
29
76
  async clear() {
30
77
  pendingLoads.clear();
@@ -39,40 +86,50 @@ export function createReadCache(driver) {
39
86
  invalidate(key);
40
87
  await enqueue(() => driver.delete(key));
41
88
  },
89
+ async deleteIfVersion(key, version, snapshot) {
90
+ return enqueue(async () => {
91
+ if (getVersion(key) !== version)
92
+ return false;
93
+ if (!capturedSnapshots.has(snapshot) || !driver.deleteIf) {
94
+ console.error("[Bayon offline] Cannot safely retire a read-cache snapshot: atomic deletion is unavailable.", { key });
95
+ return false;
96
+ }
97
+ const deleted = await driver.deleteIf(key, (current) => {
98
+ return getVersion(key) === version && ownsSnapshot(current, snapshot);
99
+ });
100
+ if (deleted)
101
+ invalidate(key);
102
+ return deleted;
103
+ });
104
+ },
42
105
  invalidate,
43
106
  async get(key) {
44
107
  const envelope = toReadCacheEnvelope(await driver.get(key));
45
- return envelope
46
- ? {
47
- fromCache: true,
48
- savedAt: envelope.savedAt,
49
- value: envelope.value,
50
- }
51
- : null;
108
+ if (!envelope)
109
+ return null;
110
+ const hit = {
111
+ fromCache: true,
112
+ savedAt: envelope.savedAt,
113
+ value: envelope.value,
114
+ };
115
+ capturedSnapshots.set(hit, envelope);
116
+ return hit;
52
117
  },
53
118
  getVersion,
54
119
  async set(key, value) {
120
+ invalidate(key);
55
121
  await enqueue(() => driver.set(key, {
56
122
  savedAt: new Date().toISOString(),
57
123
  schemaVersion: readCacheSchemaVersion,
124
+ snapshotId: createPendingWriteSnapshotId(),
58
125
  value,
59
126
  }));
60
127
  },
61
- async setIfVersion(key, value, version) {
62
- if (getVersion(key) !== version) {
63
- return false;
64
- }
65
- return enqueue(async () => {
66
- if (getVersion(key) !== version) {
67
- return false;
68
- }
69
- await driver.set(key, {
70
- savedAt: new Date().toISOString(),
71
- schemaVersion: readCacheSchemaVersion,
72
- value,
73
- });
74
- return true;
75
- });
128
+ async setIfVersion(key, value, version, snapshot, confirmedAt) {
129
+ return writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString());
130
+ },
131
+ async setIfVersionAt(key, value, version, snapshot, confirmedAt) {
132
+ return writeIfVersion(key, value, version, snapshot, confirmedAt);
76
133
  },
77
134
  };
78
135
  pendingFreshLoads.set(cache, pendingLoads);
@@ -103,6 +160,12 @@ export function createMemoryReadCacheDriver() {
103
160
  async delete(key) {
104
161
  records.delete(key);
105
162
  },
163
+ async deleteIf(key, predicate) {
164
+ if (!predicate(records.get(key) ?? null))
165
+ return false;
166
+ records.delete(key);
167
+ return true;
168
+ },
106
169
  async get(key) {
107
170
  return records.get(key) ?? null;
108
171
  },
@@ -112,6 +175,12 @@ export function createMemoryReadCacheDriver() {
112
175
  async set(key, value) {
113
176
  records.set(key, value);
114
177
  },
178
+ async setIf(key, value, predicate) {
179
+ if (!predicate(records.get(key) ?? null))
180
+ return false;
181
+ records.set(key, value);
182
+ return true;
183
+ },
115
184
  };
116
185
  }
117
186
  export function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = browserReadCacheOperationTimeoutMs, storeName, }) {
@@ -127,9 +196,11 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
127
196
  clear: driver.clear,
128
197
  close: driver.close,
129
198
  delete: driver.delete,
199
+ deleteIf: driver.deleteIf,
130
200
  get: driver.get,
131
201
  listEntries: driver.listEntries,
132
202
  set: driver.set,
203
+ setIf: driver.setIf,
133
204
  }
134
205
  : createNoopReadCacheDriver());
135
206
  }
@@ -156,10 +227,12 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
156
227
  .filter(Boolean)
157
228
  .join(":");
158
229
  }
159
- export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
230
+ export async function readThroughCache({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource = false, }) {
160
231
  const resourceCache = cache.capture?.() ?? cache;
161
232
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
233
+ const capturedVersion = resourceCache.getVersion(key);
162
234
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
235
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
163
236
  if (forceFresh) {
164
237
  reportReadCacheOutcome(onOutcome, "forced-refresh");
165
238
  // Built-in caches advance generation without deleting fallback data.
@@ -180,9 +253,13 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
180
253
  }
181
254
  }
182
255
  const version = resourceCache.getVersion(key);
256
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
183
257
  const cached = forcedFallback ?? (await resourceCache.get(key));
184
258
  const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
185
- if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
259
+ if (!forceFresh &&
260
+ !requireSource &&
261
+ retained &&
262
+ isCacheFresh(cached.savedAt, resolvedPolicy)) {
186
263
  reportReadCacheOutcome(onOutcome, "fresh-hit");
187
264
  return {
188
265
  data: cached.value,
@@ -190,14 +267,33 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
190
267
  fromCache: true,
191
268
  stale: false,
192
269
  status: "fresh",
270
+ ...(includeProvenance
271
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
272
+ : {}),
193
273
  };
194
274
  }
195
275
  reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
196
276
  try {
197
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
277
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
278
+ if (source.provenance !== "source-confirmed") {
279
+ if (requireSource)
280
+ throw (source.originalError ??
281
+ source.error ??
282
+ new Error("[Bayon offline] Source confirmation was required."));
283
+ return includeProvenance
284
+ ? source
285
+ : {
286
+ data: source.data,
287
+ error: source.error,
288
+ fromCache: source.fromCache,
289
+ stale: source.stale,
290
+ status: source.status,
291
+ };
292
+ }
293
+ const fresh = source.data;
198
294
  reportReadCacheOutcome(onOutcome, "loader-success");
199
295
  try {
200
- await resourceCache.setIfVersion(key, fresh, version);
296
+ await writeConfirmedResource(resourceCache, key, fresh, version, cached, source.confirmedAt, Boolean(loadFreshResult));
201
297
  }
202
298
  catch (error) {
203
299
  console.error("[Bayon offline] Unable to refresh the read cache.", {
@@ -211,26 +307,44 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
211
307
  fromCache: false,
212
308
  stale: false,
213
309
  status: retained ? "fresh" : "miss",
310
+ ...(includeProvenance
311
+ ? {
312
+ provenance: "source-confirmed",
313
+ confirmedAt: source.confirmedAt,
314
+ }
315
+ : {}),
214
316
  };
215
317
  }
216
318
  catch (error) {
217
319
  reportReadCacheOutcome(onOutcome, "loader-failure");
218
- if (retained && resolvedPolicy.refreshFailure === "use-retained") {
320
+ if (error instanceof ReadCacheLoaderContractError)
321
+ throw error;
322
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
323
+ if (!requireSource && retained && failure === "use-retained") {
219
324
  return {
220
325
  data: cached.value,
221
326
  error: normalizeError(error),
222
327
  fromCache: true,
223
328
  stale: true,
224
329
  status: "stale",
330
+ ...(includeProvenance
331
+ ? {
332
+ provenance: "retained-fallback",
333
+ savedAt: cached.savedAt,
334
+ originalError: error,
335
+ }
336
+ : {}),
225
337
  };
226
338
  }
227
339
  throw error;
228
340
  }
229
341
  }
230
- export async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
342
+ export async function loadReadCacheResource({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
231
343
  const resourceCache = cache.capture?.() ?? cache;
232
344
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
345
+ const capturedVersion = resourceCache.getVersion(key);
233
346
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
347
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
234
348
  if (forceFresh) {
235
349
  reportReadCacheOutcome(onOutcome, "forced-refresh");
236
350
  try {
@@ -249,19 +363,23 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
249
363
  }
250
364
  }
251
365
  const version = resourceCache.getVersion(key);
252
- const cached = skipCache
253
- ? null
254
- : (forcedFallback ?? (await resourceCache.get(key)));
366
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
367
+ // Capture ownership even when presentation skips the cached value.
368
+ const writeSnapshot = forcedFallback ?? (await resourceCache.get(key));
369
+ const cached = skipCache ? null : writeSnapshot;
255
370
  const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
256
- if (retained) {
371
+ if (retained && !requireSource) {
257
372
  const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
258
373
  reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
259
374
  onUpdate({
260
375
  data: cached.value,
261
376
  fromCache: true,
262
377
  status: fresh && !forceFresh ? "fresh" : "stale",
378
+ ...(includeProvenance
379
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
380
+ : {}),
263
381
  });
264
- if (fresh && !forceFresh) {
382
+ if (fresh && !forceFresh && !requireSource) {
265
383
  return cached.value;
266
384
  }
267
385
  }
@@ -269,16 +387,44 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
269
387
  reportReadCacheOutcome(onOutcome, "cache-miss");
270
388
  }
271
389
  try {
272
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
390
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
391
+ if (source.provenance !== "source-confirmed") {
392
+ if (requireSource)
393
+ throw (source.originalError ??
394
+ source.error ??
395
+ new Error("[Bayon offline] Source confirmation was required."));
396
+ if (resourceCache.getVersion(key) === version)
397
+ onUpdate({
398
+ data: source.data,
399
+ fromCache: source.fromCache,
400
+ status: source.status,
401
+ ...(source.error ? { error: source.error } : {}),
402
+ ...(includeProvenance
403
+ ? {
404
+ provenance: source.provenance,
405
+ savedAt: source.savedAt,
406
+ originalError: source.originalError,
407
+ }
408
+ : {}),
409
+ });
410
+ return source.data;
411
+ }
412
+ const fresh = source.data;
273
413
  reportReadCacheOutcome(onOutcome, "loader-success");
274
414
  if (resourceCache.getVersion(key) === version) {
275
415
  onUpdate({
276
416
  data: fresh,
277
417
  fromCache: false,
278
418
  status: retained ? "fresh" : "miss",
419
+ ...(includeProvenance
420
+ ? {
421
+ provenance: "source-confirmed",
422
+ confirmedAt: source.confirmedAt,
423
+ }
424
+ : {}),
279
425
  });
280
426
  }
281
- void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
427
+ void writeConfirmedResource(resourceCache, key, fresh, version, writeSnapshot, source.confirmedAt, Boolean(loadFreshResult)).catch((error) => {
282
428
  const normalizedError = normalizeError(error);
283
429
  console.error("[Bayon offline] Unable to refresh the read cache.", {
284
430
  error: normalizedError,
@@ -290,7 +436,10 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
290
436
  }
291
437
  catch (error) {
292
438
  reportReadCacheOutcome(onOutcome, "loader-failure");
293
- if (!retained || resolvedPolicy.refreshFailure === "throw") {
439
+ if (error instanceof ReadCacheLoaderContractError)
440
+ throw error;
441
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
442
+ if (requireSource || !retained || failure !== "use-retained") {
294
443
  throw error;
295
444
  }
296
445
  onUpdate({
@@ -298,10 +447,112 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
298
447
  error: normalizeError(error),
299
448
  fromCache: true,
300
449
  status: "stale",
450
+ ...(includeProvenance
451
+ ? {
452
+ provenance: "retained-fallback",
453
+ savedAt: cached.savedAt,
454
+ originalError: error,
455
+ }
456
+ : {}),
301
457
  });
302
458
  return cached.value;
303
459
  }
304
460
  }
461
+ class ReadCacheLoaderContractError extends TypeError {
462
+ }
463
+ // Older envelopes have no identity token. Avoid comparisons that JSON would
464
+ // collapse, such as distinct Maps, non-finite numbers, or cyclic values.
465
+ function isComparableLegacyValue(value, seen = new Set()) {
466
+ if (value === null || typeof value === "string" || typeof value === "boolean")
467
+ return true;
468
+ if (typeof value === "number")
469
+ return Number.isFinite(value);
470
+ if (typeof value !== "object" || seen.has(value))
471
+ return false;
472
+ if (value instanceof Date)
473
+ return Number.isFinite(value.getTime());
474
+ if (Array.isArray(value) &&
475
+ (Object.keys(value).length !== value.length ||
476
+ Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).some((present) => !present)))
477
+ return false;
478
+ if (!Array.isArray(value) &&
479
+ Object.getPrototypeOf(value) !== Object.prototype)
480
+ return false;
481
+ seen.add(value);
482
+ const comparable = Object.values(value).every((item) => isComparableLegacyValue(item, seen));
483
+ seen.delete(value);
484
+ return comparable;
485
+ }
486
+ async function resolveFreshLoad(loadFresh, loadFreshResult) {
487
+ if (!!loadFresh === !!loadFreshResult)
488
+ throw new ReadCacheLoaderContractError("[Bayon offline] Provide exactly one read loader.");
489
+ if (loadFreshResult) {
490
+ const result = await loadFreshResult();
491
+ if (!result ||
492
+ !["source-confirmed", "cache-hit", "retained-fallback"].includes(result.provenance ?? ""))
493
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read must report valid provenance.");
494
+ const cached = result.provenance !== "source-confirmed";
495
+ if (result.confirmedAt !== undefined &&
496
+ (typeof result.confirmedAt !== "string" ||
497
+ !Number.isFinite(Date.parse(result.confirmedAt)))) {
498
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has an invalid confirmedAt timestamp.");
499
+ }
500
+ if (result.fromCache !== cached ||
501
+ (cached &&
502
+ (typeof result.savedAt !== "string" ||
503
+ !Number.isFinite(Date.parse(result.savedAt)))) ||
504
+ (result.provenance === "retained-fallback"
505
+ ? !result.error || !result.stale
506
+ : result.error !== null)) {
507
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has inconsistent provenance metadata.");
508
+ }
509
+ return result;
510
+ }
511
+ const data = await loadFresh();
512
+ return {
513
+ data,
514
+ error: null,
515
+ fromCache: false,
516
+ stale: false,
517
+ status: "miss",
518
+ provenance: "source-confirmed",
519
+ confirmedAt: new Date().toISOString(),
520
+ };
521
+ }
522
+ function writeConfirmedResource(cache, key, value, version, snapshot, confirmedAt, layered) {
523
+ // Preserve the established write hook for ordinary, value-only loaders.
524
+ if (!layered)
525
+ return cache.setIfVersion(key, value, version, snapshot, confirmedAt);
526
+ if (confirmedAt && cache.setIfVersionAt)
527
+ return cache.setIfVersionAt(key, value, version, snapshot, confirmedAt);
528
+ // A legacy adapter cannot preserve the age of a layered confirmation.
529
+ return Promise.resolve(false);
530
+ }
531
+ async function handleReadFailure(cache, key, version, snapshot, policy, error) {
532
+ let failure;
533
+ try {
534
+ failure =
535
+ typeof policy.refreshFailure === "function"
536
+ ? policy.refreshFailure(error)
537
+ : policy.refreshFailure;
538
+ }
539
+ catch (classificationError) {
540
+ console.error("[Bayon offline] Unable to classify the read failure; preserving the source error.", { error: normalizeError(classificationError), key });
541
+ return "throw";
542
+ }
543
+ if (failure === "retire" && snapshot) {
544
+ try {
545
+ if (cache.deleteIfVersion)
546
+ await cache.deleteIfVersion(key, version, snapshot);
547
+ else
548
+ console.error("[Bayon offline] Cannot safely retire a read-cache entry: adapter lacks deleteIfVersion.", { key });
549
+ }
550
+ catch (retirementError) {
551
+ console.error("[Bayon offline] Unable to retire the denied read-cache entry.", { key, error: normalizeError(retirementError) });
552
+ }
553
+ }
554
+ return failure;
555
+ }
305
556
  function reportReadCacheOutcome(onOutcome, outcome) {
306
557
  if (!onOutcome)
307
558
  return;
@@ -342,7 +593,9 @@ function resolvePolicy(policy, maxAgeMs) {
342
593
  policy.freshForMs < 0 ||
343
594
  !Number.isFinite(policy.retainForMs) ||
344
595
  policy.retainForMs < policy.freshForMs ||
345
- !["use-retained", "throw"].includes(policy.refreshFailure)) {
596
+ !(typeof policy.refreshFailure === "function" ||
597
+ policy.refreshFailure === "use-retained" ||
598
+ policy.refreshFailure === "throw")) {
346
599
  throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
347
600
  }
348
601
  return policy;
@@ -38,7 +38,8 @@ function projectPendingWriteFinalState(records, getKey, { isBarrier = () => true
38
38
  const previous = latest.get(key);
39
39
  if (previous) {
40
40
  superseded.add(previous);
41
- for (const effect of dependents.get(key) ?? [])
41
+ // Every latest entry creates its matching dependency list below.
42
+ for (const effect of dependents.get(key))
42
43
  superseded.add(effect);
43
44
  }
44
45
  latest.set(key, record);
@@ -13,12 +13,14 @@ export type ReadCacheDriver = {
13
13
  clear: () => Promise<void>;
14
14
  close?: () => Promise<void>;
15
15
  delete: (key: string) => Promise<void>;
16
+ deleteIf?: (key: string, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
16
17
  get: (key: string) => Promise<unknown | null>;
17
18
  listEntries?: () => Promise<Array<{
18
19
  key: string;
19
20
  value: unknown;
20
21
  }>>;
21
22
  set: (key: string, value: unknown) => Promise<void>;
23
+ setIf?: (key: string, value: unknown, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
22
24
  };
23
25
  export type ReadCache = {
24
26
  /**
@@ -29,12 +31,15 @@ export type ReadCache = {
29
31
  clear: () => Promise<void>;
30
32
  close: () => Promise<void>;
31
33
  delete: (key: string) => Promise<void>;
34
+ /** Retire only a snapshot still owned by the captured generation. */
35
+ deleteIfVersion?: (key: string, version: string, snapshot: ReadCacheHit<unknown>) => Promise<boolean>;
32
36
  /** Advances a key's generation without deleting its retained value. */
33
37
  invalidate?: (key: string) => void;
34
38
  get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
35
39
  getVersion: (key: string) => string;
36
40
  set: <T>(key: string, value: T) => Promise<void>;
37
- setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
41
+ setIfVersion: <T>(key: string, value: T, version: string, snapshot?: ReadCacheHit<unknown> | null, confirmedAt?: string) => Promise<boolean>;
42
+ setIfVersionAt?: <T>(key: string, value: T, version: string, snapshot: ReadCacheHit<unknown> | null, confirmedAt: string) => Promise<boolean>;
38
43
  };
39
44
  export type ReadCachePolicy = {
40
45
  /** Time that a cached value can suppress a remote read. */
@@ -42,9 +47,10 @@ export type ReadCachePolicy = {
42
47
  /** Maximum age at which cached data remains eligible as an offline fallback. */
43
48
  retainForMs: number;
44
49
  /** Behavior when revalidation fails while retained data is available. */
45
- refreshFailure: "use-retained" | "throw";
50
+ refreshFailure: "use-retained" | "throw" | ((error: unknown) => "use-retained" | "throw" | "retire");
46
51
  };
47
52
  export type ReadCacheStatus = "fresh" | "stale" | "miss";
53
+ export type ReadCacheProvenance = "cache-hit" | "source-confirmed" | "retained-fallback";
48
54
  /** Payload-free cache lifecycle events suitable for host-owned telemetry. */
49
55
  export type ReadCacheOutcome = "fresh-hit" | "stale-hit" | "cache-miss" | "forced-refresh" | "loader-success" | "loader-failure";
50
56
  export type ReadThroughCacheResult<T> = {
@@ -53,12 +59,27 @@ export type ReadThroughCacheResult<T> = {
53
59
  fromCache: boolean;
54
60
  stale: boolean;
55
61
  status: ReadCacheStatus;
62
+ provenance?: ReadCacheProvenance;
63
+ confirmedAt?: string;
64
+ savedAt?: string;
65
+ originalError?: unknown;
56
66
  };
57
67
  export type ReadCacheResourceUpdate<T> = {
58
68
  data: T;
59
69
  error?: Error;
60
70
  fromCache: boolean;
61
71
  status: ReadCacheStatus;
72
+ provenance?: ReadCacheProvenance;
73
+ confirmedAt?: string;
74
+ savedAt?: string;
75
+ originalError?: unknown;
76
+ };
77
+ export type ReadCacheLoader<T> = {
78
+ loadFresh: () => Promise<T>;
79
+ loadFreshResult?: never;
80
+ } | {
81
+ loadFresh?: never;
82
+ loadFreshResult: () => Promise<ReadThroughCacheResult<T>>;
62
83
  };
63
84
  export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
64
85
  databaseName: string;
@@ -84,29 +105,31 @@ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
84
105
  now?: () => number;
85
106
  }): Promise<number>;
86
107
  export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
87
- export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
108
+ export declare function readThroughCache<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource, }: {
88
109
  cache: ReadCache;
89
110
  /**
90
111
  * Revalidates after advancing the key generation, retaining the prior value
91
112
  * as a possible fallback if refresh fails.
92
113
  */
93
114
  forceFresh?: boolean;
115
+ includeProvenance?: boolean;
94
116
  key: string;
95
- loadFresh: () => Promise<T>;
96
117
  maxAgeMs?: number;
97
118
  onOutcome?: (outcome: ReadCacheOutcome) => void;
98
119
  policy?: ReadCachePolicy;
99
- }): Promise<ReadThroughCacheResult<T>>;
100
- export declare function loadReadCacheResource<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, }: {
120
+ requireSource?: boolean;
121
+ } & ReadCacheLoader<T>): Promise<ReadThroughCacheResult<T>>;
122
+ export declare function loadReadCacheResource<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, requireSource, }: {
101
123
  cache: ReadCache;
102
124
  /** Revalidate while retaining eligible data as fallback on loader failure. */
103
125
  forceFresh?: boolean;
126
+ includeProvenance?: boolean;
104
127
  key: string;
105
- loadFresh: () => Promise<T>;
106
128
  maxAgeMs?: number;
107
129
  onOutcome?: (outcome: ReadCacheOutcome) => void;
108
130
  policy?: ReadCachePolicy;
109
131
  onCacheWriteError?: (error: Error) => void;
110
132
  onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
111
133
  skipCache?: boolean;
112
- }): Promise<T>;
134
+ requireSource?: boolean;
135
+ } & ReadCacheLoader<T>): Promise<T>;
@@ -9,6 +9,7 @@ exports.createReadCacheKey = createReadCacheKey;
9
9
  exports.readThroughCache = readThroughCache;
10
10
  exports.loadReadCacheResource = loadReadCacheResource;
11
11
  const idbDriver_1 = require("./idbDriver");
12
+ const pendingWrites_1 = require("./pendingWrites");
12
13
  exports.readCacheSchemaVersion = 1;
13
14
  exports.readCacheFreshnessMaxAgeMs = 30_000;
14
15
  exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
@@ -23,6 +24,28 @@ function createReadCache(driver) {
23
24
  const keyVersions = new Map();
24
25
  let operationQueue = Promise.resolve();
25
26
  const pendingLoads = new Map();
27
+ const capturedSnapshots = new WeakMap();
28
+ function ownsSnapshot(current, snapshot) {
29
+ if (!snapshot)
30
+ return current === null || toReadCacheEnvelope(current) === null;
31
+ const captured = capturedSnapshots.get(snapshot);
32
+ const envelope = toReadCacheEnvelope(current);
33
+ if (!captured || !envelope)
34
+ return false;
35
+ if (captured.snapshotId)
36
+ return envelope.snapshotId === captured.snapshotId;
37
+ if (envelope.snapshotId || envelope.savedAt !== captured.savedAt)
38
+ return false;
39
+ try {
40
+ if (!isComparableLegacyValue(envelope.value) ||
41
+ !isComparableLegacyValue(captured.value))
42
+ return false;
43
+ return JSON.stringify(envelope.value) === JSON.stringify(captured.value);
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
26
49
  function getVersion(key) {
27
50
  return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
28
51
  }
@@ -35,6 +58,30 @@ function createReadCache(driver) {
35
58
  operationQueue = next.then(() => undefined, () => undefined);
36
59
  return next;
37
60
  }
61
+ async function writeIfVersion(key, value, version, snapshot, savedAt) {
62
+ if (getVersion(key) !== version)
63
+ return false;
64
+ return enqueue(async () => {
65
+ if (getVersion(key) !== version)
66
+ return false;
67
+ const next = {
68
+ savedAt,
69
+ schemaVersion: exports.readCacheSchemaVersion,
70
+ snapshotId: (0, pendingWrites_1.createPendingWriteSnapshotId)(),
71
+ value,
72
+ };
73
+ if (snapshot !== undefined && driver.setIf) {
74
+ const written = await driver.setIf(key, next, (current) => getVersion(key) === version && ownsSnapshot(current, snapshot));
75
+ if (!written)
76
+ return false;
77
+ }
78
+ else {
79
+ await driver.set(key, next);
80
+ }
81
+ invalidate(key);
82
+ return true;
83
+ });
84
+ }
38
85
  const cache = {
39
86
  async clear() {
40
87
  pendingLoads.clear();
@@ -49,40 +96,50 @@ function createReadCache(driver) {
49
96
  invalidate(key);
50
97
  await enqueue(() => driver.delete(key));
51
98
  },
99
+ async deleteIfVersion(key, version, snapshot) {
100
+ return enqueue(async () => {
101
+ if (getVersion(key) !== version)
102
+ return false;
103
+ if (!capturedSnapshots.has(snapshot) || !driver.deleteIf) {
104
+ console.error("[Bayon offline] Cannot safely retire a read-cache snapshot: atomic deletion is unavailable.", { key });
105
+ return false;
106
+ }
107
+ const deleted = await driver.deleteIf(key, (current) => {
108
+ return getVersion(key) === version && ownsSnapshot(current, snapshot);
109
+ });
110
+ if (deleted)
111
+ invalidate(key);
112
+ return deleted;
113
+ });
114
+ },
52
115
  invalidate,
53
116
  async get(key) {
54
117
  const envelope = toReadCacheEnvelope(await driver.get(key));
55
- return envelope
56
- ? {
57
- fromCache: true,
58
- savedAt: envelope.savedAt,
59
- value: envelope.value,
60
- }
61
- : null;
118
+ if (!envelope)
119
+ return null;
120
+ const hit = {
121
+ fromCache: true,
122
+ savedAt: envelope.savedAt,
123
+ value: envelope.value,
124
+ };
125
+ capturedSnapshots.set(hit, envelope);
126
+ return hit;
62
127
  },
63
128
  getVersion,
64
129
  async set(key, value) {
130
+ invalidate(key);
65
131
  await enqueue(() => driver.set(key, {
66
132
  savedAt: new Date().toISOString(),
67
133
  schemaVersion: exports.readCacheSchemaVersion,
134
+ snapshotId: (0, pendingWrites_1.createPendingWriteSnapshotId)(),
68
135
  value,
69
136
  }));
70
137
  },
71
- async setIfVersion(key, value, version) {
72
- if (getVersion(key) !== version) {
73
- return false;
74
- }
75
- return enqueue(async () => {
76
- if (getVersion(key) !== version) {
77
- return false;
78
- }
79
- await driver.set(key, {
80
- savedAt: new Date().toISOString(),
81
- schemaVersion: exports.readCacheSchemaVersion,
82
- value,
83
- });
84
- return true;
85
- });
138
+ async setIfVersion(key, value, version, snapshot, confirmedAt) {
139
+ return writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString());
140
+ },
141
+ async setIfVersionAt(key, value, version, snapshot, confirmedAt) {
142
+ return writeIfVersion(key, value, version, snapshot, confirmedAt);
86
143
  },
87
144
  };
88
145
  pendingFreshLoads.set(cache, pendingLoads);
@@ -113,6 +170,12 @@ function createMemoryReadCacheDriver() {
113
170
  async delete(key) {
114
171
  records.delete(key);
115
172
  },
173
+ async deleteIf(key, predicate) {
174
+ if (!predicate(records.get(key) ?? null))
175
+ return false;
176
+ records.delete(key);
177
+ return true;
178
+ },
116
179
  async get(key) {
117
180
  return records.get(key) ?? null;
118
181
  },
@@ -122,6 +185,12 @@ function createMemoryReadCacheDriver() {
122
185
  async set(key, value) {
123
186
  records.set(key, value);
124
187
  },
188
+ async setIf(key, value, predicate) {
189
+ if (!predicate(records.get(key) ?? null))
190
+ return false;
191
+ records.set(key, value);
192
+ return true;
193
+ },
125
194
  };
126
195
  }
127
196
  function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = exports.browserReadCacheOperationTimeoutMs, storeName, }) {
@@ -137,9 +206,11 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
137
206
  clear: driver.clear,
138
207
  close: driver.close,
139
208
  delete: driver.delete,
209
+ deleteIf: driver.deleteIf,
140
210
  get: driver.get,
141
211
  listEntries: driver.listEntries,
142
212
  set: driver.set,
213
+ setIf: driver.setIf,
143
214
  }
144
215
  : createNoopReadCacheDriver());
145
216
  }
@@ -166,10 +237,12 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
166
237
  .filter(Boolean)
167
238
  .join(":");
168
239
  }
169
- async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
240
+ async function readThroughCache({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource = false, }) {
170
241
  const resourceCache = cache.capture?.() ?? cache;
171
242
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
243
+ const capturedVersion = resourceCache.getVersion(key);
172
244
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
245
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
173
246
  if (forceFresh) {
174
247
  reportReadCacheOutcome(onOutcome, "forced-refresh");
175
248
  // Built-in caches advance generation without deleting fallback data.
@@ -190,9 +263,13 @@ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, max
190
263
  }
191
264
  }
192
265
  const version = resourceCache.getVersion(key);
266
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
193
267
  const cached = forcedFallback ?? (await resourceCache.get(key));
194
268
  const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
195
- if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
269
+ if (!forceFresh &&
270
+ !requireSource &&
271
+ retained &&
272
+ isCacheFresh(cached.savedAt, resolvedPolicy)) {
196
273
  reportReadCacheOutcome(onOutcome, "fresh-hit");
197
274
  return {
198
275
  data: cached.value,
@@ -200,14 +277,33 @@ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, max
200
277
  fromCache: true,
201
278
  stale: false,
202
279
  status: "fresh",
280
+ ...(includeProvenance
281
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
282
+ : {}),
203
283
  };
204
284
  }
205
285
  reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
206
286
  try {
207
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
287
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
288
+ if (source.provenance !== "source-confirmed") {
289
+ if (requireSource)
290
+ throw (source.originalError ??
291
+ source.error ??
292
+ new Error("[Bayon offline] Source confirmation was required."));
293
+ return includeProvenance
294
+ ? source
295
+ : {
296
+ data: source.data,
297
+ error: source.error,
298
+ fromCache: source.fromCache,
299
+ stale: source.stale,
300
+ status: source.status,
301
+ };
302
+ }
303
+ const fresh = source.data;
208
304
  reportReadCacheOutcome(onOutcome, "loader-success");
209
305
  try {
210
- await resourceCache.setIfVersion(key, fresh, version);
306
+ await writeConfirmedResource(resourceCache, key, fresh, version, cached, source.confirmedAt, Boolean(loadFreshResult));
211
307
  }
212
308
  catch (error) {
213
309
  console.error("[Bayon offline] Unable to refresh the read cache.", {
@@ -221,26 +317,44 @@ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, max
221
317
  fromCache: false,
222
318
  stale: false,
223
319
  status: retained ? "fresh" : "miss",
320
+ ...(includeProvenance
321
+ ? {
322
+ provenance: "source-confirmed",
323
+ confirmedAt: source.confirmedAt,
324
+ }
325
+ : {}),
224
326
  };
225
327
  }
226
328
  catch (error) {
227
329
  reportReadCacheOutcome(onOutcome, "loader-failure");
228
- if (retained && resolvedPolicy.refreshFailure === "use-retained") {
330
+ if (error instanceof ReadCacheLoaderContractError)
331
+ throw error;
332
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
333
+ if (!requireSource && retained && failure === "use-retained") {
229
334
  return {
230
335
  data: cached.value,
231
336
  error: normalizeError(error),
232
337
  fromCache: true,
233
338
  stale: true,
234
339
  status: "stale",
340
+ ...(includeProvenance
341
+ ? {
342
+ provenance: "retained-fallback",
343
+ savedAt: cached.savedAt,
344
+ originalError: error,
345
+ }
346
+ : {}),
235
347
  };
236
348
  }
237
349
  throw error;
238
350
  }
239
351
  }
240
- async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
352
+ async function loadReadCacheResource({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
241
353
  const resourceCache = cache.capture?.() ?? cache;
242
354
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
355
+ const capturedVersion = resourceCache.getVersion(key);
243
356
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
357
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
244
358
  if (forceFresh) {
245
359
  reportReadCacheOutcome(onOutcome, "forced-refresh");
246
360
  try {
@@ -259,19 +373,23 @@ async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh
259
373
  }
260
374
  }
261
375
  const version = resourceCache.getVersion(key);
262
- const cached = skipCache
263
- ? null
264
- : (forcedFallback ?? (await resourceCache.get(key)));
376
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
377
+ // Capture ownership even when presentation skips the cached value.
378
+ const writeSnapshot = forcedFallback ?? (await resourceCache.get(key));
379
+ const cached = skipCache ? null : writeSnapshot;
265
380
  const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
266
- if (retained) {
381
+ if (retained && !requireSource) {
267
382
  const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
268
383
  reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
269
384
  onUpdate({
270
385
  data: cached.value,
271
386
  fromCache: true,
272
387
  status: fresh && !forceFresh ? "fresh" : "stale",
388
+ ...(includeProvenance
389
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
390
+ : {}),
273
391
  });
274
- if (fresh && !forceFresh) {
392
+ if (fresh && !forceFresh && !requireSource) {
275
393
  return cached.value;
276
394
  }
277
395
  }
@@ -279,16 +397,44 @@ async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh
279
397
  reportReadCacheOutcome(onOutcome, "cache-miss");
280
398
  }
281
399
  try {
282
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
400
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
401
+ if (source.provenance !== "source-confirmed") {
402
+ if (requireSource)
403
+ throw (source.originalError ??
404
+ source.error ??
405
+ new Error("[Bayon offline] Source confirmation was required."));
406
+ if (resourceCache.getVersion(key) === version)
407
+ onUpdate({
408
+ data: source.data,
409
+ fromCache: source.fromCache,
410
+ status: source.status,
411
+ ...(source.error ? { error: source.error } : {}),
412
+ ...(includeProvenance
413
+ ? {
414
+ provenance: source.provenance,
415
+ savedAt: source.savedAt,
416
+ originalError: source.originalError,
417
+ }
418
+ : {}),
419
+ });
420
+ return source.data;
421
+ }
422
+ const fresh = source.data;
283
423
  reportReadCacheOutcome(onOutcome, "loader-success");
284
424
  if (resourceCache.getVersion(key) === version) {
285
425
  onUpdate({
286
426
  data: fresh,
287
427
  fromCache: false,
288
428
  status: retained ? "fresh" : "miss",
429
+ ...(includeProvenance
430
+ ? {
431
+ provenance: "source-confirmed",
432
+ confirmedAt: source.confirmedAt,
433
+ }
434
+ : {}),
289
435
  });
290
436
  }
291
- void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
437
+ void writeConfirmedResource(resourceCache, key, fresh, version, writeSnapshot, source.confirmedAt, Boolean(loadFreshResult)).catch((error) => {
292
438
  const normalizedError = normalizeError(error);
293
439
  console.error("[Bayon offline] Unable to refresh the read cache.", {
294
440
  error: normalizedError,
@@ -300,7 +446,10 @@ async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh
300
446
  }
301
447
  catch (error) {
302
448
  reportReadCacheOutcome(onOutcome, "loader-failure");
303
- if (!retained || resolvedPolicy.refreshFailure === "throw") {
449
+ if (error instanceof ReadCacheLoaderContractError)
450
+ throw error;
451
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
452
+ if (requireSource || !retained || failure !== "use-retained") {
304
453
  throw error;
305
454
  }
306
455
  onUpdate({
@@ -308,10 +457,112 @@ async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh
308
457
  error: normalizeError(error),
309
458
  fromCache: true,
310
459
  status: "stale",
460
+ ...(includeProvenance
461
+ ? {
462
+ provenance: "retained-fallback",
463
+ savedAt: cached.savedAt,
464
+ originalError: error,
465
+ }
466
+ : {}),
311
467
  });
312
468
  return cached.value;
313
469
  }
314
470
  }
471
+ class ReadCacheLoaderContractError extends TypeError {
472
+ }
473
+ // Older envelopes have no identity token. Avoid comparisons that JSON would
474
+ // collapse, such as distinct Maps, non-finite numbers, or cyclic values.
475
+ function isComparableLegacyValue(value, seen = new Set()) {
476
+ if (value === null || typeof value === "string" || typeof value === "boolean")
477
+ return true;
478
+ if (typeof value === "number")
479
+ return Number.isFinite(value);
480
+ if (typeof value !== "object" || seen.has(value))
481
+ return false;
482
+ if (value instanceof Date)
483
+ return Number.isFinite(value.getTime());
484
+ if (Array.isArray(value) &&
485
+ (Object.keys(value).length !== value.length ||
486
+ Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).some((present) => !present)))
487
+ return false;
488
+ if (!Array.isArray(value) &&
489
+ Object.getPrototypeOf(value) !== Object.prototype)
490
+ return false;
491
+ seen.add(value);
492
+ const comparable = Object.values(value).every((item) => isComparableLegacyValue(item, seen));
493
+ seen.delete(value);
494
+ return comparable;
495
+ }
496
+ async function resolveFreshLoad(loadFresh, loadFreshResult) {
497
+ if (!!loadFresh === !!loadFreshResult)
498
+ throw new ReadCacheLoaderContractError("[Bayon offline] Provide exactly one read loader.");
499
+ if (loadFreshResult) {
500
+ const result = await loadFreshResult();
501
+ if (!result ||
502
+ !["source-confirmed", "cache-hit", "retained-fallback"].includes(result.provenance ?? ""))
503
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read must report valid provenance.");
504
+ const cached = result.provenance !== "source-confirmed";
505
+ if (result.confirmedAt !== undefined &&
506
+ (typeof result.confirmedAt !== "string" ||
507
+ !Number.isFinite(Date.parse(result.confirmedAt)))) {
508
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has an invalid confirmedAt timestamp.");
509
+ }
510
+ if (result.fromCache !== cached ||
511
+ (cached &&
512
+ (typeof result.savedAt !== "string" ||
513
+ !Number.isFinite(Date.parse(result.savedAt)))) ||
514
+ (result.provenance === "retained-fallback"
515
+ ? !result.error || !result.stale
516
+ : result.error !== null)) {
517
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has inconsistent provenance metadata.");
518
+ }
519
+ return result;
520
+ }
521
+ const data = await loadFresh();
522
+ return {
523
+ data,
524
+ error: null,
525
+ fromCache: false,
526
+ stale: false,
527
+ status: "miss",
528
+ provenance: "source-confirmed",
529
+ confirmedAt: new Date().toISOString(),
530
+ };
531
+ }
532
+ function writeConfirmedResource(cache, key, value, version, snapshot, confirmedAt, layered) {
533
+ // Preserve the established write hook for ordinary, value-only loaders.
534
+ if (!layered)
535
+ return cache.setIfVersion(key, value, version, snapshot, confirmedAt);
536
+ if (confirmedAt && cache.setIfVersionAt)
537
+ return cache.setIfVersionAt(key, value, version, snapshot, confirmedAt);
538
+ // A legacy adapter cannot preserve the age of a layered confirmation.
539
+ return Promise.resolve(false);
540
+ }
541
+ async function handleReadFailure(cache, key, version, snapshot, policy, error) {
542
+ let failure;
543
+ try {
544
+ failure =
545
+ typeof policy.refreshFailure === "function"
546
+ ? policy.refreshFailure(error)
547
+ : policy.refreshFailure;
548
+ }
549
+ catch (classificationError) {
550
+ console.error("[Bayon offline] Unable to classify the read failure; preserving the source error.", { error: normalizeError(classificationError), key });
551
+ return "throw";
552
+ }
553
+ if (failure === "retire" && snapshot) {
554
+ try {
555
+ if (cache.deleteIfVersion)
556
+ await cache.deleteIfVersion(key, version, snapshot);
557
+ else
558
+ console.error("[Bayon offline] Cannot safely retire a read-cache entry: adapter lacks deleteIfVersion.", { key });
559
+ }
560
+ catch (retirementError) {
561
+ console.error("[Bayon offline] Unable to retire the denied read-cache entry.", { key, error: normalizeError(retirementError) });
562
+ }
563
+ }
564
+ return failure;
565
+ }
315
566
  function reportReadCacheOutcome(onOutcome, outcome) {
316
567
  if (!onOutcome)
317
568
  return;
@@ -352,7 +603,9 @@ function resolvePolicy(policy, maxAgeMs) {
352
603
  policy.freshForMs < 0 ||
353
604
  !Number.isFinite(policy.retainForMs) ||
354
605
  policy.retainForMs < policy.freshForMs ||
355
- !["use-retained", "throw"].includes(policy.refreshFailure)) {
606
+ !(typeof policy.refreshFailure === "function" ||
607
+ policy.refreshFailure === "use-retained" ||
608
+ policy.refreshFailure === "throw")) {
356
609
  throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
357
610
  }
358
611
  return policy;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",