@bayonai/offline 0.12.0 → 0.15.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/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,7 +48,44 @@ 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, receipt) {
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
+ // A driver may finish after the caller has retired this generation.
68
+ // Keep that retirement visible instead of issuing a new owned receipt.
69
+ if (receipt && getVersion(key) !== version)
70
+ return false;
71
+ }
72
+ else {
73
+ await driver.set(key, next);
74
+ }
75
+ invalidate(key);
76
+ if (receipt)
77
+ receipt.version = getVersion(key);
78
+ return true;
79
+ });
80
+ }
28
81
  const cache = {
82
+ async commitIfVersion(key, value, version, snapshot, confirmedAt) {
83
+ if (!driver.setIf)
84
+ throw new Error("[Bayon offline] Canonical presentation requires atomic conditional cache writes.");
85
+ const receipt = { version };
86
+ const written = await writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString(), receipt);
87
+ return { written, version: receipt.version };
88
+ },
29
89
  async clear() {
30
90
  pendingLoads.clear();
31
91
  globalVersion += 1;
@@ -39,40 +99,50 @@ export function createReadCache(driver) {
39
99
  invalidate(key);
40
100
  await enqueue(() => driver.delete(key));
41
101
  },
102
+ async deleteIfVersion(key, version, snapshot) {
103
+ return enqueue(async () => {
104
+ if (getVersion(key) !== version)
105
+ return false;
106
+ if (!capturedSnapshots.has(snapshot) || !driver.deleteIf) {
107
+ console.error("[Bayon offline] Cannot safely retire a read-cache snapshot: atomic deletion is unavailable.", { key });
108
+ return false;
109
+ }
110
+ const deleted = await driver.deleteIf(key, (current) => {
111
+ return getVersion(key) === version && ownsSnapshot(current, snapshot);
112
+ });
113
+ if (deleted)
114
+ invalidate(key);
115
+ return deleted;
116
+ });
117
+ },
42
118
  invalidate,
43
119
  async get(key) {
44
120
  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;
121
+ if (!envelope)
122
+ return null;
123
+ const hit = {
124
+ fromCache: true,
125
+ savedAt: envelope.savedAt,
126
+ value: envelope.value,
127
+ };
128
+ capturedSnapshots.set(hit, envelope);
129
+ return hit;
52
130
  },
53
131
  getVersion,
54
132
  async set(key, value) {
133
+ invalidate(key);
55
134
  await enqueue(() => driver.set(key, {
56
135
  savedAt: new Date().toISOString(),
57
136
  schemaVersion: readCacheSchemaVersion,
137
+ snapshotId: createPendingWriteSnapshotId(),
58
138
  value,
59
139
  }));
60
140
  },
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
- });
141
+ async setIfVersion(key, value, version, snapshot, confirmedAt) {
142
+ return writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString());
143
+ },
144
+ async setIfVersionAt(key, value, version, snapshot, confirmedAt) {
145
+ return writeIfVersion(key, value, version, snapshot, confirmedAt);
76
146
  },
77
147
  };
78
148
  pendingFreshLoads.set(cache, pendingLoads);
@@ -103,6 +173,12 @@ export function createMemoryReadCacheDriver() {
103
173
  async delete(key) {
104
174
  records.delete(key);
105
175
  },
176
+ async deleteIf(key, predicate) {
177
+ if (!predicate(records.get(key) ?? null))
178
+ return false;
179
+ records.delete(key);
180
+ return true;
181
+ },
106
182
  async get(key) {
107
183
  return records.get(key) ?? null;
108
184
  },
@@ -112,6 +188,12 @@ export function createMemoryReadCacheDriver() {
112
188
  async set(key, value) {
113
189
  records.set(key, value);
114
190
  },
191
+ async setIf(key, value, predicate) {
192
+ if (!predicate(records.get(key) ?? null))
193
+ return false;
194
+ records.set(key, value);
195
+ return true;
196
+ },
115
197
  };
116
198
  }
117
199
  export function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = browserReadCacheOperationTimeoutMs, storeName, }) {
@@ -127,9 +209,11 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
127
209
  clear: driver.clear,
128
210
  close: driver.close,
129
211
  delete: driver.delete,
212
+ deleteIf: driver.deleteIf,
130
213
  get: driver.get,
131
214
  listEntries: driver.listEntries,
132
215
  set: driver.set,
216
+ setIf: driver.setIf,
133
217
  }
134
218
  : createNoopReadCacheDriver());
135
219
  }
@@ -156,10 +240,12 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
156
240
  .filter(Boolean)
157
241
  .join(":");
158
242
  }
159
- export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
243
+ export async function readThroughCache({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource = false, }) {
160
244
  const resourceCache = cache.capture?.() ?? cache;
161
245
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
246
+ const capturedVersion = resourceCache.getVersion(key);
162
247
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
248
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
163
249
  if (forceFresh) {
164
250
  reportReadCacheOutcome(onOutcome, "forced-refresh");
165
251
  // Built-in caches advance generation without deleting fallback data.
@@ -180,9 +266,13 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
180
266
  }
181
267
  }
182
268
  const version = resourceCache.getVersion(key);
269
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
183
270
  const cached = forcedFallback ?? (await resourceCache.get(key));
184
271
  const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
185
- if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
272
+ if (!forceFresh &&
273
+ !requireSource &&
274
+ retained &&
275
+ isCacheFresh(cached.savedAt, resolvedPolicy)) {
186
276
  reportReadCacheOutcome(onOutcome, "fresh-hit");
187
277
  return {
188
278
  data: cached.value,
@@ -190,14 +280,33 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
190
280
  fromCache: true,
191
281
  stale: false,
192
282
  status: "fresh",
283
+ ...(includeProvenance
284
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
285
+ : {}),
193
286
  };
194
287
  }
195
288
  reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
196
289
  try {
197
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
290
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
291
+ if (source.provenance !== "source-confirmed") {
292
+ if (requireSource)
293
+ throw (source.originalError ??
294
+ source.error ??
295
+ new Error("[Bayon offline] Source confirmation was required."));
296
+ return includeProvenance
297
+ ? source
298
+ : {
299
+ data: source.data,
300
+ error: source.error,
301
+ fromCache: source.fromCache,
302
+ stale: source.stale,
303
+ status: source.status,
304
+ };
305
+ }
306
+ const fresh = source.data;
198
307
  reportReadCacheOutcome(onOutcome, "loader-success");
199
308
  try {
200
- await resourceCache.setIfVersion(key, fresh, version);
309
+ await writeConfirmedResource(resourceCache, key, fresh, version, cached, source.confirmedAt, Boolean(loadFreshResult));
201
310
  }
202
311
  catch (error) {
203
312
  console.error("[Bayon offline] Unable to refresh the read cache.", {
@@ -211,26 +320,44 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
211
320
  fromCache: false,
212
321
  stale: false,
213
322
  status: retained ? "fresh" : "miss",
323
+ ...(includeProvenance
324
+ ? {
325
+ provenance: "source-confirmed",
326
+ confirmedAt: source.confirmedAt,
327
+ }
328
+ : {}),
214
329
  };
215
330
  }
216
331
  catch (error) {
217
332
  reportReadCacheOutcome(onOutcome, "loader-failure");
218
- if (retained && resolvedPolicy.refreshFailure === "use-retained") {
333
+ if (error instanceof ReadCacheLoaderContractError)
334
+ throw error;
335
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
336
+ if (!requireSource && retained && failure === "use-retained") {
219
337
  return {
220
338
  data: cached.value,
221
339
  error: normalizeError(error),
222
340
  fromCache: true,
223
341
  stale: true,
224
342
  status: "stale",
343
+ ...(includeProvenance
344
+ ? {
345
+ provenance: "retained-fallback",
346
+ savedAt: cached.savedAt,
347
+ originalError: error,
348
+ }
349
+ : {}),
225
350
  };
226
351
  }
227
352
  throw error;
228
353
  }
229
354
  }
230
- export async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
355
+ export async function loadReadCacheResource({ cache, deliveryPolicy = "immediate", forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
231
356
  const resourceCache = cache.capture?.() ?? cache;
232
357
  const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
358
+ const capturedVersion = resourceCache.getVersion(key);
233
359
  const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
360
+ const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
234
361
  if (forceFresh) {
235
362
  reportReadCacheOutcome(onOutcome, "forced-refresh");
236
363
  try {
@@ -249,19 +376,68 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
249
376
  }
250
377
  }
251
378
  const version = resourceCache.getVersion(key);
252
- const cached = skipCache
253
- ? null
254
- : (forcedFallback ?? (await resourceCache.get(key)));
255
- const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
256
- if (retained) {
379
+ const retirementVersion = changedDuringCapture ? capturedVersion : version;
380
+ // Capture ownership even when presentation skips the cached value.
381
+ const writeSnapshot = forcedFallback ?? (await resourceCache.get(key));
382
+ const cached = skipCache ? null : writeSnapshot;
383
+ const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy)
384
+ && (deliveryPolicy === "immediate" || resourceCache.getVersion(key) === version);
385
+ function publishUpdate(update) {
386
+ if (deliveryPolicy === "immediate") {
387
+ onUpdate(update);
388
+ return;
389
+ }
390
+ try {
391
+ onUpdate(update);
392
+ }
393
+ catch (error) {
394
+ // Opted-in presentation is independent of the caller's source outcome.
395
+ console.error("[Bayon offline] Read-cache presentation callback failed.", {
396
+ error: normalizeError(error), key,
397
+ });
398
+ }
399
+ }
400
+ function reportPresentationStorageError(error) {
401
+ const normalizedError = normalizeError(error);
402
+ console.error("[Bayon offline] Unable to confirm current read-cache presentation.", {
403
+ error: normalizedError, key,
404
+ });
405
+ onCacheWriteError?.(normalizedError);
406
+ }
407
+ async function canonicalUpdate(expectedVersion) {
408
+ if (resourceCache.getVersion(key) !== expectedVersion)
409
+ return;
410
+ let canonical;
411
+ try {
412
+ canonical = await resourceCache.get(key);
413
+ }
414
+ catch (error) {
415
+ reportPresentationStorageError(error);
416
+ return;
417
+ }
418
+ if (!canonical || resourceCache.getVersion(key) !== expectedVersion
419
+ || !isCacheRetained(canonical.savedAt, resolvedPolicy))
420
+ return;
421
+ return {
422
+ data: canonical.value, fromCache: true,
423
+ status: isCacheFresh(canonical.savedAt, resolvedPolicy) ? "fresh" : "stale",
424
+ ...(includeProvenance ? {
425
+ provenance: "cache-hit", savedAt: canonical.savedAt,
426
+ } : {}),
427
+ };
428
+ }
429
+ if (retained && !requireSource) {
257
430
  const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
258
431
  reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
259
- onUpdate({
432
+ publishUpdate({
260
433
  data: cached.value,
261
434
  fromCache: true,
262
435
  status: fresh && !forceFresh ? "fresh" : "stale",
436
+ ...(includeProvenance
437
+ ? { provenance: "cache-hit", savedAt: cached.savedAt }
438
+ : {}),
263
439
  });
264
- if (fresh && !forceFresh) {
440
+ if (fresh && !forceFresh && !requireSource) {
265
441
  return cached.value;
266
442
  }
267
443
  }
@@ -269,16 +445,77 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
269
445
  reportReadCacheOutcome(onOutcome, "cache-miss");
270
446
  }
271
447
  try {
272
- const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
448
+ const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
449
+ if (source.provenance !== "source-confirmed") {
450
+ if (requireSource)
451
+ throw (source.originalError ??
452
+ source.error ??
453
+ new Error("[Bayon offline] Source confirmation was required."));
454
+ if (deliveryPolicy === "cache-current") {
455
+ const update = await canonicalUpdate(version);
456
+ if (update)
457
+ publishUpdate(update);
458
+ return source.data;
459
+ }
460
+ if (resourceCache.getVersion(key) === version)
461
+ publishUpdate({
462
+ data: source.data,
463
+ fromCache: source.fromCache,
464
+ status: source.status,
465
+ ...(source.error ? { error: source.error } : {}),
466
+ ...(includeProvenance
467
+ ? {
468
+ provenance: source.provenance,
469
+ savedAt: source.savedAt,
470
+ originalError: source.originalError,
471
+ }
472
+ : {}),
473
+ });
474
+ return source.data;
475
+ }
476
+ const fresh = source.data;
273
477
  reportReadCacheOutcome(onOutcome, "loader-success");
478
+ if (deliveryPolicy === "cache-current") {
479
+ let update;
480
+ try {
481
+ if (!resourceCache.commitIfVersion)
482
+ throw new Error("[Bayon offline] Canonical presentation requires an atomic commit receipt.");
483
+ const receipt = await resourceCache.commitIfVersion(key, fresh, version, writeSnapshot, source.confirmedAt);
484
+ if (requireSource) {
485
+ // Source-required consumers may never mistake a readback for proof.
486
+ if (receipt.written && resourceCache.getVersion(key) === receipt.version)
487
+ update = {
488
+ data: fresh, fromCache: false, status: retained ? "fresh" : "miss",
489
+ ...(includeProvenance ? {
490
+ provenance: "source-confirmed", confirmedAt: source.confirmedAt,
491
+ } : {}),
492
+ };
493
+ }
494
+ else
495
+ update = await canonicalUpdate(receipt.version);
496
+ }
497
+ catch (error) {
498
+ reportPresentationStorageError(error);
499
+ }
500
+ // Keep observer exceptions outside the storage diagnostic boundary.
501
+ if (update)
502
+ publishUpdate(update);
503
+ return fresh;
504
+ }
274
505
  if (resourceCache.getVersion(key) === version) {
275
- onUpdate({
506
+ publishUpdate({
276
507
  data: fresh,
277
508
  fromCache: false,
278
509
  status: retained ? "fresh" : "miss",
510
+ ...(includeProvenance
511
+ ? {
512
+ provenance: "source-confirmed",
513
+ confirmedAt: source.confirmedAt,
514
+ }
515
+ : {}),
279
516
  });
280
517
  }
281
- void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
518
+ void writeConfirmedResource(resourceCache, key, fresh, version, writeSnapshot, source.confirmedAt, Boolean(loadFreshResult)).catch((error) => {
282
519
  const normalizedError = normalizeError(error);
283
520
  console.error("[Bayon offline] Unable to refresh the read cache.", {
284
521
  error: normalizedError,
@@ -290,18 +527,134 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
290
527
  }
291
528
  catch (error) {
292
529
  reportReadCacheOutcome(onOutcome, "loader-failure");
293
- if (!retained || resolvedPolicy.refreshFailure === "throw") {
530
+ if (error instanceof ReadCacheLoaderContractError)
531
+ throw error;
532
+ const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
533
+ if (requireSource || !retained || failure !== "use-retained") {
294
534
  throw error;
295
535
  }
296
- onUpdate({
536
+ if (deliveryPolicy === "cache-current") {
537
+ const update = await canonicalUpdate(version);
538
+ if (update)
539
+ publishUpdate({
540
+ ...update, status: "stale", error: normalizeError(error),
541
+ ...(includeProvenance ? {
542
+ provenance: "retained-fallback", originalError: error,
543
+ } : {}),
544
+ });
545
+ return cached.value;
546
+ }
547
+ publishUpdate({
297
548
  data: cached.value,
298
549
  error: normalizeError(error),
299
550
  fromCache: true,
300
551
  status: "stale",
552
+ ...(includeProvenance
553
+ ? {
554
+ provenance: "retained-fallback",
555
+ savedAt: cached.savedAt,
556
+ originalError: error,
557
+ }
558
+ : {}),
301
559
  });
302
560
  return cached.value;
303
561
  }
304
562
  }
563
+ class ReadCacheLoaderContractError extends TypeError {
564
+ }
565
+ // Older envelopes have no identity token. Avoid comparisons that JSON would
566
+ // collapse, such as distinct Maps, non-finite numbers, or cyclic values.
567
+ function isComparableLegacyValue(value, seen = new Set()) {
568
+ if (value === null || typeof value === "string" || typeof value === "boolean")
569
+ return true;
570
+ if (typeof value === "number")
571
+ return Number.isFinite(value);
572
+ if (typeof value !== "object" || seen.has(value))
573
+ return false;
574
+ if (value instanceof Date)
575
+ return Number.isFinite(value.getTime());
576
+ if (Array.isArray(value) &&
577
+ (Object.keys(value).length !== value.length ||
578
+ Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).some((present) => !present)))
579
+ return false;
580
+ if (!Array.isArray(value) &&
581
+ Object.getPrototypeOf(value) !== Object.prototype)
582
+ return false;
583
+ seen.add(value);
584
+ const comparable = Object.values(value).every((item) => isComparableLegacyValue(item, seen));
585
+ seen.delete(value);
586
+ return comparable;
587
+ }
588
+ async function resolveFreshLoad(loadFresh, loadFreshResult) {
589
+ if (!!loadFresh === !!loadFreshResult)
590
+ throw new ReadCacheLoaderContractError("[Bayon offline] Provide exactly one read loader.");
591
+ if (loadFreshResult) {
592
+ const result = await loadFreshResult();
593
+ if (!result ||
594
+ !["source-confirmed", "cache-hit", "retained-fallback"].includes(result.provenance ?? ""))
595
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read must report valid provenance.");
596
+ const cached = result.provenance !== "source-confirmed";
597
+ if (result.confirmedAt !== undefined &&
598
+ (typeof result.confirmedAt !== "string" ||
599
+ !Number.isFinite(Date.parse(result.confirmedAt)))) {
600
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has an invalid confirmedAt timestamp.");
601
+ }
602
+ if (result.fromCache !== cached ||
603
+ (cached &&
604
+ (typeof result.savedAt !== "string" ||
605
+ !Number.isFinite(Date.parse(result.savedAt)))) ||
606
+ (result.provenance === "retained-fallback"
607
+ ? !result.error || !result.stale
608
+ : result.error !== null)) {
609
+ throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has inconsistent provenance metadata.");
610
+ }
611
+ return result;
612
+ }
613
+ const data = await loadFresh();
614
+ return {
615
+ data,
616
+ error: null,
617
+ fromCache: false,
618
+ stale: false,
619
+ status: "miss",
620
+ provenance: "source-confirmed",
621
+ confirmedAt: new Date().toISOString(),
622
+ };
623
+ }
624
+ function writeConfirmedResource(cache, key, value, version, snapshot, confirmedAt, layered) {
625
+ // Preserve the established write hook for ordinary, value-only loaders.
626
+ if (!layered)
627
+ return cache.setIfVersion(key, value, version, snapshot, confirmedAt);
628
+ if (confirmedAt && cache.setIfVersionAt)
629
+ return cache.setIfVersionAt(key, value, version, snapshot, confirmedAt);
630
+ // A legacy adapter cannot preserve the age of a layered confirmation.
631
+ return Promise.resolve(false);
632
+ }
633
+ async function handleReadFailure(cache, key, version, snapshot, policy, error) {
634
+ let failure;
635
+ try {
636
+ failure =
637
+ typeof policy.refreshFailure === "function"
638
+ ? policy.refreshFailure(error)
639
+ : policy.refreshFailure;
640
+ }
641
+ catch (classificationError) {
642
+ console.error("[Bayon offline] Unable to classify the read failure; preserving the source error.", { error: normalizeError(classificationError), key });
643
+ return "throw";
644
+ }
645
+ if (failure === "retire" && snapshot) {
646
+ try {
647
+ if (cache.deleteIfVersion)
648
+ await cache.deleteIfVersion(key, version, snapshot);
649
+ else
650
+ console.error("[Bayon offline] Cannot safely retire a read-cache entry: adapter lacks deleteIfVersion.", { key });
651
+ }
652
+ catch (retirementError) {
653
+ console.error("[Bayon offline] Unable to retire the denied read-cache entry.", { key, error: normalizeError(retirementError) });
654
+ }
655
+ }
656
+ return failure;
657
+ }
305
658
  function reportReadCacheOutcome(onOutcome, outcome) {
306
659
  if (!onOutcome)
307
660
  return;
@@ -342,7 +695,9 @@ function resolvePolicy(policy, maxAgeMs) {
342
695
  policy.freshForMs < 0 ||
343
696
  !Number.isFinite(policy.retainForMs) ||
344
697
  policy.retainForMs < policy.freshForMs ||
345
- !["use-retained", "throw"].includes(policy.refreshFailure)) {
698
+ !(typeof policy.refreshFailure === "function" ||
699
+ policy.refreshFailure === "use-retained" ||
700
+ policy.refreshFailure === "throw")) {
346
701
  throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
347
702
  }
348
703
  return policy;
@@ -0,0 +1,13 @@
1
+ import type { ReadResourceObservation, ReadResourceUpdate } from "./readResourceObserver.js";
2
+ /** A mounted presentation barrier; child tasks retain sole network/cache ownership. */
3
+ export declare function observeReadResourceBatch<T>({ count, observe, freshForMs, now, current, canRetain, retiredError, subscribe, onUpdate }: {
4
+ count: number;
5
+ observe: (index: number, update: (value: ReadResourceUpdate<T>) => void, age: (createdAt: number) => void) => ReadResourceObservation<T>;
6
+ freshForMs: number[];
7
+ now: () => number;
8
+ current: () => boolean;
9
+ canRetain: (value: T) => boolean;
10
+ retiredError: () => Error;
11
+ subscribe: (listener: () => Promise<void>) => () => void;
12
+ onUpdate: (update: ReadResourceUpdate<T[]>) => void;
13
+ }): ReadResourceObservation<T[]>;