@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.
- package/CHANGELOG.md +13 -0
- package/README.md +44 -1
- package/RECOVERY.md +13 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pendingChangeTracker.d.ts +71 -0
- package/dist/pendingChangeTracker.js +199 -0
- package/dist/pendingWriteConsolidation.js +2 -1
- package/dist/readCache.d.ts +31 -8
- package/dist/readCache.js +290 -37
- package/dist-cjs/index.d.ts +1 -0
- package/dist-cjs/index.js +1 -0
- package/dist-cjs/pendingChangeTracker.d.ts +71 -0
- package/dist-cjs/pendingChangeTracker.js +204 -0
- package/dist-cjs/pendingWriteConsolidation.js +2 -1
- package/dist-cjs/readCache.d.ts +31 -8
- package/dist-cjs/readCache.js +290 -37
- package/package.json +1 -1
package/dist-cjs/readCache.js
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return
|
|
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 &&
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
!
|
|
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;
|