@bayonai/offline 0.11.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.
@@ -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.11.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",