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