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