@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/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,6 +48,30 @@ 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) {
|
|
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
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
await driver.set(key, next);
|
|
70
|
+
}
|
|
71
|
+
invalidate(key);
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
28
75
|
const cache = {
|
|
29
76
|
async clear() {
|
|
30
77
|
pendingLoads.clear();
|
|
@@ -39,40 +86,50 @@ export function createReadCache(driver) {
|
|
|
39
86
|
invalidate(key);
|
|
40
87
|
await enqueue(() => driver.delete(key));
|
|
41
88
|
},
|
|
89
|
+
async deleteIfVersion(key, version, snapshot) {
|
|
90
|
+
return enqueue(async () => {
|
|
91
|
+
if (getVersion(key) !== version)
|
|
92
|
+
return false;
|
|
93
|
+
if (!capturedSnapshots.has(snapshot) || !driver.deleteIf) {
|
|
94
|
+
console.error("[Bayon offline] Cannot safely retire a read-cache snapshot: atomic deletion is unavailable.", { key });
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const deleted = await driver.deleteIf(key, (current) => {
|
|
98
|
+
return getVersion(key) === version && ownsSnapshot(current, snapshot);
|
|
99
|
+
});
|
|
100
|
+
if (deleted)
|
|
101
|
+
invalidate(key);
|
|
102
|
+
return deleted;
|
|
103
|
+
});
|
|
104
|
+
},
|
|
42
105
|
invalidate,
|
|
43
106
|
async get(key) {
|
|
44
107
|
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
108
|
+
if (!envelope)
|
|
109
|
+
return null;
|
|
110
|
+
const hit = {
|
|
111
|
+
fromCache: true,
|
|
112
|
+
savedAt: envelope.savedAt,
|
|
113
|
+
value: envelope.value,
|
|
114
|
+
};
|
|
115
|
+
capturedSnapshots.set(hit, envelope);
|
|
116
|
+
return hit;
|
|
52
117
|
},
|
|
53
118
|
getVersion,
|
|
54
119
|
async set(key, value) {
|
|
120
|
+
invalidate(key);
|
|
55
121
|
await enqueue(() => driver.set(key, {
|
|
56
122
|
savedAt: new Date().toISOString(),
|
|
57
123
|
schemaVersion: readCacheSchemaVersion,
|
|
124
|
+
snapshotId: createPendingWriteSnapshotId(),
|
|
58
125
|
value,
|
|
59
126
|
}));
|
|
60
127
|
},
|
|
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
|
-
});
|
|
128
|
+
async setIfVersion(key, value, version, snapshot, confirmedAt) {
|
|
129
|
+
return writeIfVersion(key, value, version, snapshot, confirmedAt ?? new Date().toISOString());
|
|
130
|
+
},
|
|
131
|
+
async setIfVersionAt(key, value, version, snapshot, confirmedAt) {
|
|
132
|
+
return writeIfVersion(key, value, version, snapshot, confirmedAt);
|
|
76
133
|
},
|
|
77
134
|
};
|
|
78
135
|
pendingFreshLoads.set(cache, pendingLoads);
|
|
@@ -103,6 +160,12 @@ export function createMemoryReadCacheDriver() {
|
|
|
103
160
|
async delete(key) {
|
|
104
161
|
records.delete(key);
|
|
105
162
|
},
|
|
163
|
+
async deleteIf(key, predicate) {
|
|
164
|
+
if (!predicate(records.get(key) ?? null))
|
|
165
|
+
return false;
|
|
166
|
+
records.delete(key);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
106
169
|
async get(key) {
|
|
107
170
|
return records.get(key) ?? null;
|
|
108
171
|
},
|
|
@@ -112,6 +175,12 @@ export function createMemoryReadCacheDriver() {
|
|
|
112
175
|
async set(key, value) {
|
|
113
176
|
records.set(key, value);
|
|
114
177
|
},
|
|
178
|
+
async setIf(key, value, predicate) {
|
|
179
|
+
if (!predicate(records.get(key) ?? null))
|
|
180
|
+
return false;
|
|
181
|
+
records.set(key, value);
|
|
182
|
+
return true;
|
|
183
|
+
},
|
|
115
184
|
};
|
|
116
185
|
}
|
|
117
186
|
export function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = browserReadCacheOperationTimeoutMs, storeName, }) {
|
|
@@ -127,9 +196,11 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
|
|
|
127
196
|
clear: driver.clear,
|
|
128
197
|
close: driver.close,
|
|
129
198
|
delete: driver.delete,
|
|
199
|
+
deleteIf: driver.deleteIf,
|
|
130
200
|
get: driver.get,
|
|
131
201
|
listEntries: driver.listEntries,
|
|
132
202
|
set: driver.set,
|
|
203
|
+
setIf: driver.setIf,
|
|
133
204
|
}
|
|
134
205
|
: createNoopReadCacheDriver());
|
|
135
206
|
}
|
|
@@ -156,10 +227,12 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
156
227
|
.filter(Boolean)
|
|
157
228
|
.join(":");
|
|
158
229
|
}
|
|
159
|
-
export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
|
|
230
|
+
export async function readThroughCache({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource = false, }) {
|
|
160
231
|
const resourceCache = cache.capture?.() ?? cache;
|
|
161
232
|
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
233
|
+
const capturedVersion = resourceCache.getVersion(key);
|
|
162
234
|
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
235
|
+
const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
|
|
163
236
|
if (forceFresh) {
|
|
164
237
|
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
165
238
|
// Built-in caches advance generation without deleting fallback data.
|
|
@@ -180,9 +253,13 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
180
253
|
}
|
|
181
254
|
}
|
|
182
255
|
const version = resourceCache.getVersion(key);
|
|
256
|
+
const retirementVersion = changedDuringCapture ? capturedVersion : version;
|
|
183
257
|
const cached = forcedFallback ?? (await resourceCache.get(key));
|
|
184
258
|
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
185
|
-
if (!forceFresh &&
|
|
259
|
+
if (!forceFresh &&
|
|
260
|
+
!requireSource &&
|
|
261
|
+
retained &&
|
|
262
|
+
isCacheFresh(cached.savedAt, resolvedPolicy)) {
|
|
186
263
|
reportReadCacheOutcome(onOutcome, "fresh-hit");
|
|
187
264
|
return {
|
|
188
265
|
data: cached.value,
|
|
@@ -190,14 +267,33 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
190
267
|
fromCache: true,
|
|
191
268
|
stale: false,
|
|
192
269
|
status: "fresh",
|
|
270
|
+
...(includeProvenance
|
|
271
|
+
? { provenance: "cache-hit", savedAt: cached.savedAt }
|
|
272
|
+
: {}),
|
|
193
273
|
};
|
|
194
274
|
}
|
|
195
275
|
reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
|
|
196
276
|
try {
|
|
197
|
-
const
|
|
277
|
+
const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
|
|
278
|
+
if (source.provenance !== "source-confirmed") {
|
|
279
|
+
if (requireSource)
|
|
280
|
+
throw (source.originalError ??
|
|
281
|
+
source.error ??
|
|
282
|
+
new Error("[Bayon offline] Source confirmation was required."));
|
|
283
|
+
return includeProvenance
|
|
284
|
+
? source
|
|
285
|
+
: {
|
|
286
|
+
data: source.data,
|
|
287
|
+
error: source.error,
|
|
288
|
+
fromCache: source.fromCache,
|
|
289
|
+
stale: source.stale,
|
|
290
|
+
status: source.status,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const fresh = source.data;
|
|
198
294
|
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
199
295
|
try {
|
|
200
|
-
await resourceCache
|
|
296
|
+
await writeConfirmedResource(resourceCache, key, fresh, version, cached, source.confirmedAt, Boolean(loadFreshResult));
|
|
201
297
|
}
|
|
202
298
|
catch (error) {
|
|
203
299
|
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
@@ -211,26 +307,44 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
211
307
|
fromCache: false,
|
|
212
308
|
stale: false,
|
|
213
309
|
status: retained ? "fresh" : "miss",
|
|
310
|
+
...(includeProvenance
|
|
311
|
+
? {
|
|
312
|
+
provenance: "source-confirmed",
|
|
313
|
+
confirmedAt: source.confirmedAt,
|
|
314
|
+
}
|
|
315
|
+
: {}),
|
|
214
316
|
};
|
|
215
317
|
}
|
|
216
318
|
catch (error) {
|
|
217
319
|
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
218
|
-
if (
|
|
320
|
+
if (error instanceof ReadCacheLoaderContractError)
|
|
321
|
+
throw error;
|
|
322
|
+
const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
|
|
323
|
+
if (!requireSource && retained && failure === "use-retained") {
|
|
219
324
|
return {
|
|
220
325
|
data: cached.value,
|
|
221
326
|
error: normalizeError(error),
|
|
222
327
|
fromCache: true,
|
|
223
328
|
stale: true,
|
|
224
329
|
status: "stale",
|
|
330
|
+
...(includeProvenance
|
|
331
|
+
? {
|
|
332
|
+
provenance: "retained-fallback",
|
|
333
|
+
savedAt: cached.savedAt,
|
|
334
|
+
originalError: error,
|
|
335
|
+
}
|
|
336
|
+
: {}),
|
|
225
337
|
};
|
|
226
338
|
}
|
|
227
339
|
throw error;
|
|
228
340
|
}
|
|
229
341
|
}
|
|
230
|
-
export async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
342
|
+
export async function loadReadCacheResource({ cache, forceFresh = false, includeProvenance = false, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, requireSource = false, }) {
|
|
231
343
|
const resourceCache = cache.capture?.() ?? cache;
|
|
232
344
|
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
345
|
+
const capturedVersion = resourceCache.getVersion(key);
|
|
233
346
|
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
347
|
+
const changedDuringCapture = forceFresh && resourceCache.getVersion(key) !== capturedVersion;
|
|
234
348
|
if (forceFresh) {
|
|
235
349
|
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
236
350
|
try {
|
|
@@ -249,19 +363,23 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
|
|
|
249
363
|
}
|
|
250
364
|
}
|
|
251
365
|
const version = resourceCache.getVersion(key);
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
366
|
+
const retirementVersion = changedDuringCapture ? capturedVersion : version;
|
|
367
|
+
// Capture ownership even when presentation skips the cached value.
|
|
368
|
+
const writeSnapshot = forcedFallback ?? (await resourceCache.get(key));
|
|
369
|
+
const cached = skipCache ? null : writeSnapshot;
|
|
255
370
|
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
256
|
-
if (retained) {
|
|
371
|
+
if (retained && !requireSource) {
|
|
257
372
|
const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
|
|
258
373
|
reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
|
|
259
374
|
onUpdate({
|
|
260
375
|
data: cached.value,
|
|
261
376
|
fromCache: true,
|
|
262
377
|
status: fresh && !forceFresh ? "fresh" : "stale",
|
|
378
|
+
...(includeProvenance
|
|
379
|
+
? { provenance: "cache-hit", savedAt: cached.savedAt }
|
|
380
|
+
: {}),
|
|
263
381
|
});
|
|
264
|
-
if (fresh && !forceFresh) {
|
|
382
|
+
if (fresh && !forceFresh && !requireSource) {
|
|
265
383
|
return cached.value;
|
|
266
384
|
}
|
|
267
385
|
}
|
|
@@ -269,16 +387,44 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
|
|
|
269
387
|
reportReadCacheOutcome(onOutcome, "cache-miss");
|
|
270
388
|
}
|
|
271
389
|
try {
|
|
272
|
-
const
|
|
390
|
+
const source = await loadSharedFreshResource(resourceCache, key, () => resolveFreshLoad(loadFresh, loadFreshResult));
|
|
391
|
+
if (source.provenance !== "source-confirmed") {
|
|
392
|
+
if (requireSource)
|
|
393
|
+
throw (source.originalError ??
|
|
394
|
+
source.error ??
|
|
395
|
+
new Error("[Bayon offline] Source confirmation was required."));
|
|
396
|
+
if (resourceCache.getVersion(key) === version)
|
|
397
|
+
onUpdate({
|
|
398
|
+
data: source.data,
|
|
399
|
+
fromCache: source.fromCache,
|
|
400
|
+
status: source.status,
|
|
401
|
+
...(source.error ? { error: source.error } : {}),
|
|
402
|
+
...(includeProvenance
|
|
403
|
+
? {
|
|
404
|
+
provenance: source.provenance,
|
|
405
|
+
savedAt: source.savedAt,
|
|
406
|
+
originalError: source.originalError,
|
|
407
|
+
}
|
|
408
|
+
: {}),
|
|
409
|
+
});
|
|
410
|
+
return source.data;
|
|
411
|
+
}
|
|
412
|
+
const fresh = source.data;
|
|
273
413
|
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
274
414
|
if (resourceCache.getVersion(key) === version) {
|
|
275
415
|
onUpdate({
|
|
276
416
|
data: fresh,
|
|
277
417
|
fromCache: false,
|
|
278
418
|
status: retained ? "fresh" : "miss",
|
|
419
|
+
...(includeProvenance
|
|
420
|
+
? {
|
|
421
|
+
provenance: "source-confirmed",
|
|
422
|
+
confirmedAt: source.confirmedAt,
|
|
423
|
+
}
|
|
424
|
+
: {}),
|
|
279
425
|
});
|
|
280
426
|
}
|
|
281
|
-
void resourceCache
|
|
427
|
+
void writeConfirmedResource(resourceCache, key, fresh, version, writeSnapshot, source.confirmedAt, Boolean(loadFreshResult)).catch((error) => {
|
|
282
428
|
const normalizedError = normalizeError(error);
|
|
283
429
|
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
284
430
|
error: normalizedError,
|
|
@@ -290,7 +436,10 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
|
|
|
290
436
|
}
|
|
291
437
|
catch (error) {
|
|
292
438
|
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
293
|
-
if (
|
|
439
|
+
if (error instanceof ReadCacheLoaderContractError)
|
|
440
|
+
throw error;
|
|
441
|
+
const failure = await handleReadFailure(resourceCache, key, retirementVersion, cached, resolvedPolicy, error);
|
|
442
|
+
if (requireSource || !retained || failure !== "use-retained") {
|
|
294
443
|
throw error;
|
|
295
444
|
}
|
|
296
445
|
onUpdate({
|
|
@@ -298,10 +447,112 @@ export async function loadReadCacheResource({ cache, forceFresh = false, key, lo
|
|
|
298
447
|
error: normalizeError(error),
|
|
299
448
|
fromCache: true,
|
|
300
449
|
status: "stale",
|
|
450
|
+
...(includeProvenance
|
|
451
|
+
? {
|
|
452
|
+
provenance: "retained-fallback",
|
|
453
|
+
savedAt: cached.savedAt,
|
|
454
|
+
originalError: error,
|
|
455
|
+
}
|
|
456
|
+
: {}),
|
|
301
457
|
});
|
|
302
458
|
return cached.value;
|
|
303
459
|
}
|
|
304
460
|
}
|
|
461
|
+
class ReadCacheLoaderContractError extends TypeError {
|
|
462
|
+
}
|
|
463
|
+
// Older envelopes have no identity token. Avoid comparisons that JSON would
|
|
464
|
+
// collapse, such as distinct Maps, non-finite numbers, or cyclic values.
|
|
465
|
+
function isComparableLegacyValue(value, seen = new Set()) {
|
|
466
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
467
|
+
return true;
|
|
468
|
+
if (typeof value === "number")
|
|
469
|
+
return Number.isFinite(value);
|
|
470
|
+
if (typeof value !== "object" || seen.has(value))
|
|
471
|
+
return false;
|
|
472
|
+
if (value instanceof Date)
|
|
473
|
+
return Number.isFinite(value.getTime());
|
|
474
|
+
if (Array.isArray(value) &&
|
|
475
|
+
(Object.keys(value).length !== value.length ||
|
|
476
|
+
Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).some((present) => !present)))
|
|
477
|
+
return false;
|
|
478
|
+
if (!Array.isArray(value) &&
|
|
479
|
+
Object.getPrototypeOf(value) !== Object.prototype)
|
|
480
|
+
return false;
|
|
481
|
+
seen.add(value);
|
|
482
|
+
const comparable = Object.values(value).every((item) => isComparableLegacyValue(item, seen));
|
|
483
|
+
seen.delete(value);
|
|
484
|
+
return comparable;
|
|
485
|
+
}
|
|
486
|
+
async function resolveFreshLoad(loadFresh, loadFreshResult) {
|
|
487
|
+
if (!!loadFresh === !!loadFreshResult)
|
|
488
|
+
throw new ReadCacheLoaderContractError("[Bayon offline] Provide exactly one read loader.");
|
|
489
|
+
if (loadFreshResult) {
|
|
490
|
+
const result = await loadFreshResult();
|
|
491
|
+
if (!result ||
|
|
492
|
+
!["source-confirmed", "cache-hit", "retained-fallback"].includes(result.provenance ?? ""))
|
|
493
|
+
throw new ReadCacheLoaderContractError("[Bayon offline] Layered read must report valid provenance.");
|
|
494
|
+
const cached = result.provenance !== "source-confirmed";
|
|
495
|
+
if (result.confirmedAt !== undefined &&
|
|
496
|
+
(typeof result.confirmedAt !== "string" ||
|
|
497
|
+
!Number.isFinite(Date.parse(result.confirmedAt)))) {
|
|
498
|
+
throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has an invalid confirmedAt timestamp.");
|
|
499
|
+
}
|
|
500
|
+
if (result.fromCache !== cached ||
|
|
501
|
+
(cached &&
|
|
502
|
+
(typeof result.savedAt !== "string" ||
|
|
503
|
+
!Number.isFinite(Date.parse(result.savedAt)))) ||
|
|
504
|
+
(result.provenance === "retained-fallback"
|
|
505
|
+
? !result.error || !result.stale
|
|
506
|
+
: result.error !== null)) {
|
|
507
|
+
throw new ReadCacheLoaderContractError("[Bayon offline] Layered read has inconsistent provenance metadata.");
|
|
508
|
+
}
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
const data = await loadFresh();
|
|
512
|
+
return {
|
|
513
|
+
data,
|
|
514
|
+
error: null,
|
|
515
|
+
fromCache: false,
|
|
516
|
+
stale: false,
|
|
517
|
+
status: "miss",
|
|
518
|
+
provenance: "source-confirmed",
|
|
519
|
+
confirmedAt: new Date().toISOString(),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function writeConfirmedResource(cache, key, value, version, snapshot, confirmedAt, layered) {
|
|
523
|
+
// Preserve the established write hook for ordinary, value-only loaders.
|
|
524
|
+
if (!layered)
|
|
525
|
+
return cache.setIfVersion(key, value, version, snapshot, confirmedAt);
|
|
526
|
+
if (confirmedAt && cache.setIfVersionAt)
|
|
527
|
+
return cache.setIfVersionAt(key, value, version, snapshot, confirmedAt);
|
|
528
|
+
// A legacy adapter cannot preserve the age of a layered confirmation.
|
|
529
|
+
return Promise.resolve(false);
|
|
530
|
+
}
|
|
531
|
+
async function handleReadFailure(cache, key, version, snapshot, policy, error) {
|
|
532
|
+
let failure;
|
|
533
|
+
try {
|
|
534
|
+
failure =
|
|
535
|
+
typeof policy.refreshFailure === "function"
|
|
536
|
+
? policy.refreshFailure(error)
|
|
537
|
+
: policy.refreshFailure;
|
|
538
|
+
}
|
|
539
|
+
catch (classificationError) {
|
|
540
|
+
console.error("[Bayon offline] Unable to classify the read failure; preserving the source error.", { error: normalizeError(classificationError), key });
|
|
541
|
+
return "throw";
|
|
542
|
+
}
|
|
543
|
+
if (failure === "retire" && snapshot) {
|
|
544
|
+
try {
|
|
545
|
+
if (cache.deleteIfVersion)
|
|
546
|
+
await cache.deleteIfVersion(key, version, snapshot);
|
|
547
|
+
else
|
|
548
|
+
console.error("[Bayon offline] Cannot safely retire a read-cache entry: adapter lacks deleteIfVersion.", { key });
|
|
549
|
+
}
|
|
550
|
+
catch (retirementError) {
|
|
551
|
+
console.error("[Bayon offline] Unable to retire the denied read-cache entry.", { key, error: normalizeError(retirementError) });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return failure;
|
|
555
|
+
}
|
|
305
556
|
function reportReadCacheOutcome(onOutcome, outcome) {
|
|
306
557
|
if (!onOutcome)
|
|
307
558
|
return;
|
|
@@ -342,7 +593,9 @@ function resolvePolicy(policy, maxAgeMs) {
|
|
|
342
593
|
policy.freshForMs < 0 ||
|
|
343
594
|
!Number.isFinite(policy.retainForMs) ||
|
|
344
595
|
policy.retainForMs < policy.freshForMs ||
|
|
345
|
-
!
|
|
596
|
+
!(typeof policy.refreshFailure === "function" ||
|
|
597
|
+
policy.refreshFailure === "use-retained" ||
|
|
598
|
+
policy.refreshFailure === "throw")) {
|
|
346
599
|
throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
|
|
347
600
|
}
|
|
348
601
|
return policy;
|
package/dist-cjs/index.d.ts
CHANGED
|
@@ -9,3 +9,4 @@ export * from "./readBoundary.js";
|
|
|
9
9
|
export * from "./recoveryPolicy.js";
|
|
10
10
|
export * from "./pendingWriteSupersession.js";
|
|
11
11
|
export { projectPendingWriteFinalState, type PendingWriteSupersessionKey, type PendingWriteConsolidationOptions, } from "./pendingWriteConsolidation.js";
|
|
12
|
+
export * from "./pendingChangeTracker.js";
|
package/dist-cjs/index.js
CHANGED
|
@@ -28,3 +28,4 @@ __exportStar(require("./recoveryPolicy"), exports);
|
|
|
28
28
|
__exportStar(require("./pendingWriteSupersession"), exports);
|
|
29
29
|
var pendingWriteConsolidation_1 = require("./pendingWriteConsolidation");
|
|
30
30
|
Object.defineProperty(exports, "projectPendingWriteFinalState", { enumerable: true, get: function () { return pendingWriteConsolidation_1.projectPendingWriteFinalState; } });
|
|
31
|
+
__exportStar(require("./pendingChangeTracker"), exports);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type PendingChangeStatus = "draft" | "waiting" | "sending" | "attention" | "storage-error";
|
|
2
|
+
/** Hosts own domain labels and recovery actions; this is only a queue projection. */
|
|
3
|
+
export type PendingChangeItem = {
|
|
4
|
+
id: string;
|
|
5
|
+
accountKey: string;
|
|
6
|
+
groupKey: string;
|
|
7
|
+
groupTitle: string;
|
|
8
|
+
label: string;
|
|
9
|
+
status: PendingChangeStatus;
|
|
10
|
+
attemptedText?: string;
|
|
11
|
+
needsAttention: boolean;
|
|
12
|
+
storageError: boolean;
|
|
13
|
+
sending: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type PendingChangeGroup<T extends PendingChangeItem = PendingChangeItem> = {
|
|
16
|
+
key: string;
|
|
17
|
+
title: string;
|
|
18
|
+
items: readonly T[];
|
|
19
|
+
};
|
|
20
|
+
export type PendingChangeSnapshot<T extends PendingChangeItem = PendingChangeItem> = {
|
|
21
|
+
accountKey: string | null;
|
|
22
|
+
items: readonly T[];
|
|
23
|
+
groups: readonly PendingChangeGroup<T>[];
|
|
24
|
+
count: number;
|
|
25
|
+
draftCount: number;
|
|
26
|
+
readyCount: number;
|
|
27
|
+
attentionCount: number;
|
|
28
|
+
storageErrorCount: number;
|
|
29
|
+
sendingCount: number;
|
|
30
|
+
guidance: string;
|
|
31
|
+
loading: boolean;
|
|
32
|
+
readError: unknown | null;
|
|
33
|
+
};
|
|
34
|
+
/** Groups host-scoped rows without imposing account or domain semantics. */
|
|
35
|
+
export declare function groupPendingChangeItems<T extends {
|
|
36
|
+
groupKey: string;
|
|
37
|
+
groupTitle: string;
|
|
38
|
+
}>(items: readonly T[]): Array<{
|
|
39
|
+
key: string;
|
|
40
|
+
title: string;
|
|
41
|
+
items: T[];
|
|
42
|
+
}>;
|
|
43
|
+
/** Same-ID captured edits replace durable rows; insertion order remains stable. */
|
|
44
|
+
export declare function createPendingChangeSnapshot<T extends PendingChangeItem>({ accountKey, items, isOffline, loading, readError, }: {
|
|
45
|
+
accountKey: string | null;
|
|
46
|
+
items: readonly T[];
|
|
47
|
+
isOffline?: boolean;
|
|
48
|
+
loading?: boolean;
|
|
49
|
+
readError?: unknown;
|
|
50
|
+
}): PendingChangeSnapshot<T>;
|
|
51
|
+
export type PendingChangeTracker<T extends PendingChangeItem = PendingChangeItem> = {
|
|
52
|
+
getSnapshot: () => PendingChangeSnapshot<T>;
|
|
53
|
+
subscribe: (listener: () => void) => () => void;
|
|
54
|
+
refresh: () => Promise<void>;
|
|
55
|
+
setAccountKey: (accountKey: string | null) => void;
|
|
56
|
+
setOffline: (isOffline: boolean) => void;
|
|
57
|
+
close: () => void;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Observes existing host queues, never persists or replays changes. Reads fail
|
|
61
|
+
* closed across accounts but retain the current account's last known rows.
|
|
62
|
+
* Hosts must notify for draft, command-store and connectivity source changes.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createPendingChangeTracker<T extends PendingChangeItem>(options: {
|
|
65
|
+
accountKey?: string | null;
|
|
66
|
+
isOffline?: boolean;
|
|
67
|
+
load: (accountKey: string) => Promise<readonly T[]>;
|
|
68
|
+
capture?: (accountKey: string) => readonly T[];
|
|
69
|
+
subscribe?: (onChange: () => void) => () => void;
|
|
70
|
+
onError?: (error: unknown) => void;
|
|
71
|
+
}): PendingChangeTracker<T>;
|