@bayonai/offline 0.12.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 +8 -1
- package/README.md +23 -1
- package/dist/pendingWriteConsolidation.js +2 -1
- package/dist/readCache.d.ts +31 -8
- package/dist/readCache.js +290 -37
- 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/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.13.0 - 2026-11-06
|
|
4
|
+
|
|
5
|
+
- Add opt-in host-classified read failure handling, conditional terminal
|
|
6
|
+
retirement, source-required reads and layered provenance without renewing
|
|
7
|
+
retained cache age. Snapshot identifiers protect newer cross-instance writes;
|
|
8
|
+
existing version-one envelopes remain readable without a database upgrade.
|
|
9
|
+
|
|
10
|
+
## 0.12.0 - 2026-11-06
|
|
4
11
|
|
|
5
12
|
- Add framework-neutral pending-change projection and observable tracker, with host-supplied existing queues/drafts, account and stale-read fencing, stable external-store snapshots, grouping/counts/guidance, and retained last known records on read failures.
|
|
6
13
|
|
package/README.md
CHANGED
|
@@ -74,6 +74,15 @@ exceptions are logged and isolated from the read.
|
|
|
74
74
|
|
|
75
75
|
When refresh fails, `refreshFailure: "use-retained"` returns retained data
|
|
76
76
|
with `status: "stale"` and the error; `"throw"` propagates the loader error.
|
|
77
|
+
An opt-in callback can classify the original failure as `"use-retained"`,
|
|
78
|
+
`"throw"`, or `"retire"`. Retire propagates the original failure and
|
|
79
|
+
conditionally removes the captured snapshot; a later confirmation is preserved.
|
|
80
|
+
Built-in memory and IndexedDB drivers support atomic snapshot comparison.
|
|
81
|
+
Custom adapters must implement `deleteIfVersion` with equivalent atomic
|
|
82
|
+
ownership checks; without it, retirement is logged and cannot be guaranteed.
|
|
83
|
+
New envelopes carry an opaque snapshot identifier. Existing version-one
|
|
84
|
+
envelopes remain readable; legacy comparison is limited to serializable values
|
|
85
|
+
and cannot distinguish identical writes by older, identifier-unaware clients.
|
|
77
86
|
This also applies to forced refresh. Set `forceFresh: true` on
|
|
78
87
|
`loadReadCacheResource` or `readThroughCache` to bypass freshness, retain
|
|
79
88
|
eligible data as the failure fallback, and advance the cache generation so an
|
|
@@ -81,6 +90,19 @@ older in-flight load cannot overwrite the refresh. `readThroughCache` returns
|
|
|
81
90
|
the `status` field alongside its existing `fromCache`, `stale`, and `error`
|
|
82
91
|
fields.
|
|
83
92
|
|
|
93
|
+
Set `includeProvenance: true` to receive `cache-hit`, `source-confirmed`, or
|
|
94
|
+
`retained-fallback` provenance. Cache-derived results include their original
|
|
95
|
+
`savedAt`; fallback also carries `originalError` without normalizing it. Layered
|
|
96
|
+
adapters may use `loadFreshResult` to pass a provenance-bearing
|
|
97
|
+
`readThroughCache` result. Cached and fallback values from an inner layer are
|
|
98
|
+
not persisted as fresh outer confirmations. `requireSource: true` rejects
|
|
99
|
+
cached or fallback results and preserves a fallback's original failure. The
|
|
100
|
+
host still owns authorization, account scoping and retention durations.
|
|
101
|
+
Source confirmations include `confirmedAt` when provenance is requested;
|
|
102
|
+
the built-in cache preserves that time on outer writes. Custom adapters may
|
|
103
|
+
implement `setIfVersionAt` to preserve it. Without that capability, layered
|
|
104
|
+
confirmations are delivered but not persisted by the outer adapter.
|
|
105
|
+
|
|
84
106
|
For compatibility, callers that omit `policy` retain the previous `maxAgeMs`
|
|
85
107
|
freshness behavior and have no age-based fallback expiry. New callers should
|
|
86
108
|
use `policy`; when supplied it takes precedence over the legacy `maxAgeMs`.
|
|
@@ -383,7 +405,7 @@ pnpm --filter @bayonai/offline pack:smoke
|
|
|
383
405
|
2. Run the 100% coverage gate and production dependency audit.
|
|
384
406
|
3. Run `pnpm --filter @bayonai/offline pack:smoke` and confirm the tarball
|
|
385
407
|
includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
|
|
386
|
-
`CHANGELOG.md`, and `LICENSE`.
|
|
408
|
+
`CHANGELOG.md`, `RECOVERY.md`, and `LICENSE`.
|
|
387
409
|
4. Install the generated tarball into an isolated consumer and verify ESM,
|
|
388
410
|
CommonJS, and TypeScript declaration imports.
|
|
389
411
|
5. Confirm `package.json` metadata, version, repository, license, and
|
|
@@ -35,7 +35,8 @@ export function projectPendingWriteFinalState(records, getKey, { isBarrier = ()
|
|
|
35
35
|
const previous = latest.get(key);
|
|
36
36
|
if (previous) {
|
|
37
37
|
superseded.add(previous);
|
|
38
|
-
|
|
38
|
+
// Every latest entry creates its matching dependency list below.
|
|
39
|
+
for (const effect of dependents.get(key))
|
|
39
40
|
superseded.add(effect);
|
|
40
41
|
}
|
|
41
42
|
latest.set(key, record);
|
package/dist/readCache.d.ts
CHANGED
|
@@ -13,12 +13,14 @@ export type ReadCacheDriver = {
|
|
|
13
13
|
clear: () => Promise<void>;
|
|
14
14
|
close?: () => Promise<void>;
|
|
15
15
|
delete: (key: string) => Promise<void>;
|
|
16
|
+
deleteIf?: (key: string, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
|
|
16
17
|
get: (key: string) => Promise<unknown | null>;
|
|
17
18
|
listEntries?: () => Promise<Array<{
|
|
18
19
|
key: string;
|
|
19
20
|
value: unknown;
|
|
20
21
|
}>>;
|
|
21
22
|
set: (key: string, value: unknown) => Promise<void>;
|
|
23
|
+
setIf?: (key: string, value: unknown, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
|
|
22
24
|
};
|
|
23
25
|
export type ReadCache = {
|
|
24
26
|
/**
|
|
@@ -29,12 +31,15 @@ export type ReadCache = {
|
|
|
29
31
|
clear: () => Promise<void>;
|
|
30
32
|
close: () => Promise<void>;
|
|
31
33
|
delete: (key: string) => Promise<void>;
|
|
34
|
+
/** Retire only a snapshot still owned by the captured generation. */
|
|
35
|
+
deleteIfVersion?: (key: string, version: string, snapshot: ReadCacheHit<unknown>) => Promise<boolean>;
|
|
32
36
|
/** Advances a key's generation without deleting its retained value. */
|
|
33
37
|
invalidate?: (key: string) => void;
|
|
34
38
|
get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
|
|
35
39
|
getVersion: (key: string) => string;
|
|
36
40
|
set: <T>(key: string, value: T) => Promise<void>;
|
|
37
|
-
setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
|
|
41
|
+
setIfVersion: <T>(key: string, value: T, version: string, snapshot?: ReadCacheHit<unknown> | null, confirmedAt?: string) => Promise<boolean>;
|
|
42
|
+
setIfVersionAt?: <T>(key: string, value: T, version: string, snapshot: ReadCacheHit<unknown> | null, confirmedAt: string) => Promise<boolean>;
|
|
38
43
|
};
|
|
39
44
|
export type ReadCachePolicy = {
|
|
40
45
|
/** Time that a cached value can suppress a remote read. */
|
|
@@ -42,9 +47,10 @@ export type ReadCachePolicy = {
|
|
|
42
47
|
/** Maximum age at which cached data remains eligible as an offline fallback. */
|
|
43
48
|
retainForMs: number;
|
|
44
49
|
/** Behavior when revalidation fails while retained data is available. */
|
|
45
|
-
refreshFailure: "use-retained" | "throw";
|
|
50
|
+
refreshFailure: "use-retained" | "throw" | ((error: unknown) => "use-retained" | "throw" | "retire");
|
|
46
51
|
};
|
|
47
52
|
export type ReadCacheStatus = "fresh" | "stale" | "miss";
|
|
53
|
+
export type ReadCacheProvenance = "cache-hit" | "source-confirmed" | "retained-fallback";
|
|
48
54
|
/** Payload-free cache lifecycle events suitable for host-owned telemetry. */
|
|
49
55
|
export type ReadCacheOutcome = "fresh-hit" | "stale-hit" | "cache-miss" | "forced-refresh" | "loader-success" | "loader-failure";
|
|
50
56
|
export type ReadThroughCacheResult<T> = {
|
|
@@ -53,12 +59,27 @@ export type ReadThroughCacheResult<T> = {
|
|
|
53
59
|
fromCache: boolean;
|
|
54
60
|
stale: boolean;
|
|
55
61
|
status: ReadCacheStatus;
|
|
62
|
+
provenance?: ReadCacheProvenance;
|
|
63
|
+
confirmedAt?: string;
|
|
64
|
+
savedAt?: string;
|
|
65
|
+
originalError?: unknown;
|
|
56
66
|
};
|
|
57
67
|
export type ReadCacheResourceUpdate<T> = {
|
|
58
68
|
data: T;
|
|
59
69
|
error?: Error;
|
|
60
70
|
fromCache: boolean;
|
|
61
71
|
status: ReadCacheStatus;
|
|
72
|
+
provenance?: ReadCacheProvenance;
|
|
73
|
+
confirmedAt?: string;
|
|
74
|
+
savedAt?: string;
|
|
75
|
+
originalError?: unknown;
|
|
76
|
+
};
|
|
77
|
+
export type ReadCacheLoader<T> = {
|
|
78
|
+
loadFresh: () => Promise<T>;
|
|
79
|
+
loadFreshResult?: never;
|
|
80
|
+
} | {
|
|
81
|
+
loadFresh?: never;
|
|
82
|
+
loadFreshResult: () => Promise<ReadThroughCacheResult<T>>;
|
|
62
83
|
};
|
|
63
84
|
export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
|
|
64
85
|
databaseName: string;
|
|
@@ -84,29 +105,31 @@ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
|
|
|
84
105
|
now?: () => number;
|
|
85
106
|
}): Promise<number>;
|
|
86
107
|
export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
|
|
87
|
-
export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
|
|
108
|
+
export declare function readThroughCache<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource, }: {
|
|
88
109
|
cache: ReadCache;
|
|
89
110
|
/**
|
|
90
111
|
* Revalidates after advancing the key generation, retaining the prior value
|
|
91
112
|
* as a possible fallback if refresh fails.
|
|
92
113
|
*/
|
|
93
114
|
forceFresh?: boolean;
|
|
115
|
+
includeProvenance?: boolean;
|
|
94
116
|
key: string;
|
|
95
|
-
loadFresh: () => Promise<T>;
|
|
96
117
|
maxAgeMs?: number;
|
|
97
118
|
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
98
119
|
policy?: ReadCachePolicy;
|
|
99
|
-
|
|
100
|
-
|
|
120
|
+
requireSource?: boolean;
|
|
121
|
+
} & ReadCacheLoader<T>): Promise<ReadThroughCacheResult<T>>;
|
|
122
|
+
export declare function loadReadCacheResource<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, requireSource, }: {
|
|
101
123
|
cache: ReadCache;
|
|
102
124
|
/** Revalidate while retaining eligible data as fallback on loader failure. */
|
|
103
125
|
forceFresh?: boolean;
|
|
126
|
+
includeProvenance?: boolean;
|
|
104
127
|
key: string;
|
|
105
|
-
loadFresh: () => Promise<T>;
|
|
106
128
|
maxAgeMs?: number;
|
|
107
129
|
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
108
130
|
policy?: ReadCachePolicy;
|
|
109
131
|
onCacheWriteError?: (error: Error) => void;
|
|
110
132
|
onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
|
|
111
133
|
skipCache?: boolean;
|
|
112
|
-
|
|
134
|
+
requireSource?: boolean;
|
|
135
|
+
} & ReadCacheLoader<T>): Promise<T>;
|
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;
|
|
@@ -38,7 +38,8 @@ function projectPendingWriteFinalState(records, getKey, { isBarrier = () => true
|
|
|
38
38
|
const previous = latest.get(key);
|
|
39
39
|
if (previous) {
|
|
40
40
|
superseded.add(previous);
|
|
41
|
-
|
|
41
|
+
// Every latest entry creates its matching dependency list below.
|
|
42
|
+
for (const effect of dependents.get(key))
|
|
42
43
|
superseded.add(effect);
|
|
43
44
|
}
|
|
44
45
|
latest.set(key, record);
|
package/dist-cjs/readCache.d.ts
CHANGED
|
@@ -13,12 +13,14 @@ export type ReadCacheDriver = {
|
|
|
13
13
|
clear: () => Promise<void>;
|
|
14
14
|
close?: () => Promise<void>;
|
|
15
15
|
delete: (key: string) => Promise<void>;
|
|
16
|
+
deleteIf?: (key: string, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
|
|
16
17
|
get: (key: string) => Promise<unknown | null>;
|
|
17
18
|
listEntries?: () => Promise<Array<{
|
|
18
19
|
key: string;
|
|
19
20
|
value: unknown;
|
|
20
21
|
}>>;
|
|
21
22
|
set: (key: string, value: unknown) => Promise<void>;
|
|
23
|
+
setIf?: (key: string, value: unknown, predicate: (value: unknown | null) => boolean) => Promise<boolean>;
|
|
22
24
|
};
|
|
23
25
|
export type ReadCache = {
|
|
24
26
|
/**
|
|
@@ -29,12 +31,15 @@ export type ReadCache = {
|
|
|
29
31
|
clear: () => Promise<void>;
|
|
30
32
|
close: () => Promise<void>;
|
|
31
33
|
delete: (key: string) => Promise<void>;
|
|
34
|
+
/** Retire only a snapshot still owned by the captured generation. */
|
|
35
|
+
deleteIfVersion?: (key: string, version: string, snapshot: ReadCacheHit<unknown>) => Promise<boolean>;
|
|
32
36
|
/** Advances a key's generation without deleting its retained value. */
|
|
33
37
|
invalidate?: (key: string) => void;
|
|
34
38
|
get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
|
|
35
39
|
getVersion: (key: string) => string;
|
|
36
40
|
set: <T>(key: string, value: T) => Promise<void>;
|
|
37
|
-
setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
|
|
41
|
+
setIfVersion: <T>(key: string, value: T, version: string, snapshot?: ReadCacheHit<unknown> | null, confirmedAt?: string) => Promise<boolean>;
|
|
42
|
+
setIfVersionAt?: <T>(key: string, value: T, version: string, snapshot: ReadCacheHit<unknown> | null, confirmedAt: string) => Promise<boolean>;
|
|
38
43
|
};
|
|
39
44
|
export type ReadCachePolicy = {
|
|
40
45
|
/** Time that a cached value can suppress a remote read. */
|
|
@@ -42,9 +47,10 @@ export type ReadCachePolicy = {
|
|
|
42
47
|
/** Maximum age at which cached data remains eligible as an offline fallback. */
|
|
43
48
|
retainForMs: number;
|
|
44
49
|
/** Behavior when revalidation fails while retained data is available. */
|
|
45
|
-
refreshFailure: "use-retained" | "throw";
|
|
50
|
+
refreshFailure: "use-retained" | "throw" | ((error: unknown) => "use-retained" | "throw" | "retire");
|
|
46
51
|
};
|
|
47
52
|
export type ReadCacheStatus = "fresh" | "stale" | "miss";
|
|
53
|
+
export type ReadCacheProvenance = "cache-hit" | "source-confirmed" | "retained-fallback";
|
|
48
54
|
/** Payload-free cache lifecycle events suitable for host-owned telemetry. */
|
|
49
55
|
export type ReadCacheOutcome = "fresh-hit" | "stale-hit" | "cache-miss" | "forced-refresh" | "loader-success" | "loader-failure";
|
|
50
56
|
export type ReadThroughCacheResult<T> = {
|
|
@@ -53,12 +59,27 @@ export type ReadThroughCacheResult<T> = {
|
|
|
53
59
|
fromCache: boolean;
|
|
54
60
|
stale: boolean;
|
|
55
61
|
status: ReadCacheStatus;
|
|
62
|
+
provenance?: ReadCacheProvenance;
|
|
63
|
+
confirmedAt?: string;
|
|
64
|
+
savedAt?: string;
|
|
65
|
+
originalError?: unknown;
|
|
56
66
|
};
|
|
57
67
|
export type ReadCacheResourceUpdate<T> = {
|
|
58
68
|
data: T;
|
|
59
69
|
error?: Error;
|
|
60
70
|
fromCache: boolean;
|
|
61
71
|
status: ReadCacheStatus;
|
|
72
|
+
provenance?: ReadCacheProvenance;
|
|
73
|
+
confirmedAt?: string;
|
|
74
|
+
savedAt?: string;
|
|
75
|
+
originalError?: unknown;
|
|
76
|
+
};
|
|
77
|
+
export type ReadCacheLoader<T> = {
|
|
78
|
+
loadFresh: () => Promise<T>;
|
|
79
|
+
loadFreshResult?: never;
|
|
80
|
+
} | {
|
|
81
|
+
loadFresh?: never;
|
|
82
|
+
loadFreshResult: () => Promise<ReadThroughCacheResult<T>>;
|
|
62
83
|
};
|
|
63
84
|
export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
|
|
64
85
|
databaseName: string;
|
|
@@ -84,29 +105,31 @@ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
|
|
|
84
105
|
now?: () => number;
|
|
85
106
|
}): Promise<number>;
|
|
86
107
|
export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
|
|
87
|
-
export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
|
|
108
|
+
export declare function readThroughCache<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, requireSource, }: {
|
|
88
109
|
cache: ReadCache;
|
|
89
110
|
/**
|
|
90
111
|
* Revalidates after advancing the key generation, retaining the prior value
|
|
91
112
|
* as a possible fallback if refresh fails.
|
|
92
113
|
*/
|
|
93
114
|
forceFresh?: boolean;
|
|
115
|
+
includeProvenance?: boolean;
|
|
94
116
|
key: string;
|
|
95
|
-
loadFresh: () => Promise<T>;
|
|
96
117
|
maxAgeMs?: number;
|
|
97
118
|
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
98
119
|
policy?: ReadCachePolicy;
|
|
99
|
-
|
|
100
|
-
|
|
120
|
+
requireSource?: boolean;
|
|
121
|
+
} & ReadCacheLoader<T>): Promise<ReadThroughCacheResult<T>>;
|
|
122
|
+
export declare function loadReadCacheResource<T>({ cache, forceFresh, includeProvenance, key, loadFresh, loadFreshResult, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, requireSource, }: {
|
|
101
123
|
cache: ReadCache;
|
|
102
124
|
/** Revalidate while retaining eligible data as fallback on loader failure. */
|
|
103
125
|
forceFresh?: boolean;
|
|
126
|
+
includeProvenance?: boolean;
|
|
104
127
|
key: string;
|
|
105
|
-
loadFresh: () => Promise<T>;
|
|
106
128
|
maxAgeMs?: number;
|
|
107
129
|
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
108
130
|
policy?: ReadCachePolicy;
|
|
109
131
|
onCacheWriteError?: (error: Error) => void;
|
|
110
132
|
onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
|
|
111
133
|
skipCache?: boolean;
|
|
112
|
-
|
|
134
|
+
requireSource?: boolean;
|
|
135
|
+
} & ReadCacheLoader<T>): Promise<T>;
|
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;
|