@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/CHANGELOG.md +28 -1
- package/README.md +98 -1
- package/RECOVERY.md +3 -0
- package/dist/browserReadLifecycle.d.ts +5 -0
- package/dist/browserReadLifecycle.js +24 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/pendingWriteConsolidation.js +2 -1
- package/dist/pendingWrites.d.ts +3 -3
- package/dist/pendingWrites.js +20 -15
- package/dist/readCache.d.ts +38 -8
- package/dist/readCache.js +396 -41
- package/dist/readResourceBatch.d.ts +13 -0
- package/dist/readResourceBatch.js +116 -0
- package/dist/readResourceObserver.d.ts +57 -0
- package/dist/readResourceObserver.js +108 -0
- package/dist-cjs/browserReadLifecycle.d.ts +5 -0
- package/dist-cjs/browserReadLifecycle.js +27 -0
- package/dist-cjs/index.d.ts +3 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWriteConsolidation.js +2 -1
- package/dist-cjs/pendingWrites.d.ts +3 -3
- package/dist-cjs/pendingWrites.js +20 -15
- package/dist-cjs/readCache.d.ts +38 -8
- package/dist-cjs/readCache.js +396 -41
- package/dist-cjs/readResourceBatch.d.ts +13 -0
- package/dist-cjs/readResourceBatch.js +119 -0
- package/dist-cjs/readResourceObserver.d.ts +57 -0
- package/dist-cjs/readResourceObserver.js +111 -0
- 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,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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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 &&
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
!
|
|
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[]>;
|