@bayonai/offline 0.7.0 → 0.9.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 +38 -1
- package/README.md +81 -8
- package/dist/browserFallbackRecovery.js +7 -2
- package/dist/browserRecovery.d.ts +2 -2
- package/dist/durableCommandStore.d.ts +9 -3
- package/dist/durableCommandStore.js +115 -50
- package/dist/idbDriver.d.ts +3 -0
- package/dist/idbDriver.js +52 -44
- package/dist/index.d.ts +7 -6
- package/dist/index.js +1 -0
- package/dist/pendingWrites.d.ts +16 -6
- package/dist/pendingWrites.js +70 -21
- package/dist/readCache.d.ts +27 -6
- package/dist/readCache.js +134 -23
- package/dist-cjs/browserCoordination.d.ts +23 -0
- package/dist-cjs/browserFallbackRecovery.d.ts +32 -0
- package/dist-cjs/browserFallbackRecovery.js +7 -2
- package/dist-cjs/browserRecovery.d.ts +42 -0
- package/dist-cjs/durableCommandStore.d.ts +49 -0
- package/dist-cjs/durableCommandStore.js +115 -50
- package/dist-cjs/idbDriver.d.ts +26 -0
- package/dist-cjs/idbDriver.js +54 -44
- package/dist-cjs/index.d.ts +7 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWrites.d.ts +179 -0
- package/dist-cjs/pendingWrites.js +69 -20
- package/dist-cjs/readCache.d.ts +112 -0
- package/dist-cjs/readCache.js +134 -23
- package/package.json +15 -7
|
@@ -31,6 +31,29 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
+
async function listScopedRecords() {
|
|
35
|
+
const readOptions = { normalizeLegacyRecord, normalizePayload };
|
|
36
|
+
const entries = await driver.listEntries?.();
|
|
37
|
+
if (entries) {
|
|
38
|
+
const keyPrefixBoundary = `${keyPrefix}:`;
|
|
39
|
+
return entries
|
|
40
|
+
.filter(({ key }) => key.startsWith(keyPrefixBoundary))
|
|
41
|
+
.map(({ value }) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
|
|
42
|
+
?.record)
|
|
43
|
+
.filter((record) => Boolean(record));
|
|
44
|
+
}
|
|
45
|
+
// Compatibility path for custom drivers created before listEntries was
|
|
46
|
+
// available. Point reads prove that each candidate belongs to this prefix.
|
|
47
|
+
const candidateIds = new Set((await driver.list())
|
|
48
|
+
.map((value) => toPendingWriteEnvelope(value, schemaVersion, readOptions)
|
|
49
|
+
?.record.id)
|
|
50
|
+
.filter((id) => Boolean(id)));
|
|
51
|
+
const records = await Promise.all([...candidateIds].map(async (id) => {
|
|
52
|
+
const value = await driver.get(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
53
|
+
return toPendingWriteEnvelope(value, schemaVersion, readOptions)?.record;
|
|
54
|
+
}));
|
|
55
|
+
return records.filter((record) => Boolean(record));
|
|
56
|
+
}
|
|
34
57
|
return {
|
|
35
58
|
async claimIfSnapshotId(id, snapshotId, record, now) {
|
|
36
59
|
const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
|
|
@@ -51,7 +74,20 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
51
74
|
notify({ id, type: "clear" });
|
|
52
75
|
},
|
|
53
76
|
async clearAll() {
|
|
54
|
-
await driver.
|
|
77
|
+
const entries = await driver.listEntries?.();
|
|
78
|
+
if (entries) {
|
|
79
|
+
const keyPrefixBoundary = `${keyPrefix}:`;
|
|
80
|
+
await Promise.all(entries
|
|
81
|
+
.filter(({ key }) => key.startsWith(keyPrefixBoundary))
|
|
82
|
+
.map(({ key }) => driver.clear(key)));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const records = await listScopedRecords();
|
|
86
|
+
await Promise.all(records.map((record) => driver.clearIf(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
87
|
+
normalizeLegacyRecord,
|
|
88
|
+
normalizePayload,
|
|
89
|
+
})?.record.snapshotId === record.snapshotId)));
|
|
90
|
+
}
|
|
55
91
|
notify({ type: "clearAll" });
|
|
56
92
|
},
|
|
57
93
|
async clearIfSnapshotId(id, snapshotId) {
|
|
@@ -71,13 +107,7 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
71
107
|
return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
|
|
72
108
|
},
|
|
73
109
|
async list() {
|
|
74
|
-
return (await
|
|
75
|
-
.map((value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
76
|
-
normalizeLegacyRecord,
|
|
77
|
-
normalizePayload,
|
|
78
|
-
})?.record)
|
|
79
|
-
.filter((record) => Boolean(record))
|
|
80
|
-
.sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
110
|
+
return (await listScopedRecords()).sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
81
111
|
left.id.localeCompare(right.id));
|
|
82
112
|
},
|
|
83
113
|
async replaceIfSnapshotId(id, snapshotId, record) {
|
|
@@ -132,6 +162,9 @@ function createMemoryPendingWriteDriver() {
|
|
|
132
162
|
async list() {
|
|
133
163
|
return [...records.values()];
|
|
134
164
|
},
|
|
165
|
+
async listEntries() {
|
|
166
|
+
return [...records].map(([key, value]) => ({ key, value }));
|
|
167
|
+
},
|
|
135
168
|
async set(key, value) {
|
|
136
169
|
records.set(key, value);
|
|
137
170
|
},
|
|
@@ -162,14 +195,24 @@ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexed
|
|
|
162
195
|
operationTimeoutMs,
|
|
163
196
|
storeName,
|
|
164
197
|
});
|
|
165
|
-
return createPendingWriteStore(driver ??
|
|
198
|
+
return createPendingWriteStore(driver ?? createUnavailablePendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
|
|
166
199
|
}
|
|
167
200
|
function createPendingWriteKey({ id, prefix }) {
|
|
168
201
|
return `${prefix}:${encodeURIComponent(id)}`;
|
|
169
202
|
}
|
|
170
203
|
function createPendingWriteSnapshotId() {
|
|
171
|
-
|
|
204
|
+
const randomId = globalThis.crypto?.randomUUID?.();
|
|
205
|
+
if (randomId)
|
|
206
|
+
return randomId;
|
|
207
|
+
pendingWriteSnapshotSequence += 1;
|
|
208
|
+
return [
|
|
209
|
+
"snapshot",
|
|
210
|
+
Date.now().toString(36),
|
|
211
|
+
pendingWriteSnapshotSequence.toString(36),
|
|
212
|
+
Math.random().toString(36).slice(2),
|
|
213
|
+
].join("-");
|
|
172
214
|
}
|
|
215
|
+
let pendingWriteSnapshotSequence = 0;
|
|
173
216
|
async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
|
|
174
217
|
const pending = await store.get(id);
|
|
175
218
|
if (!pending) {
|
|
@@ -177,8 +220,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
|
|
|
177
220
|
}
|
|
178
221
|
// A delayed command remains in its ordered lane until its grace period has
|
|
179
222
|
// elapsed. This makes cancellation survive UI unmounts and app restarts.
|
|
180
|
-
if (pending.notBefore &&
|
|
181
|
-
Date.parse(pending.notBefore) > Date.parse(now())) {
|
|
223
|
+
if (pending.notBefore && Date.parse(pending.notBefore) > Date.parse(now())) {
|
|
182
224
|
return { status: "idle" };
|
|
183
225
|
}
|
|
184
226
|
const claimedSnapshotId = createPendingWriteSnapshotId();
|
|
@@ -193,7 +235,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
|
|
|
193
235
|
return { status: "superseded" };
|
|
194
236
|
}
|
|
195
237
|
try {
|
|
196
|
-
await sync(pending.payload);
|
|
238
|
+
await sync(pending.payload, { recordId: pending.id });
|
|
197
239
|
}
|
|
198
240
|
catch (error) {
|
|
199
241
|
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
@@ -222,7 +264,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
|
|
|
222
264
|
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
223
265
|
* mutation semantics.
|
|
224
266
|
*/
|
|
225
|
-
function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
267
|
+
function syncPendingWrites({ now, scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
226
268
|
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
227
269
|
if (!inFlightByScope) {
|
|
228
270
|
inFlightByScope = new Map();
|
|
@@ -233,6 +275,7 @@ function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailur
|
|
|
233
275
|
return existing;
|
|
234
276
|
}
|
|
235
277
|
const syncRun = performPendingWriteSync({
|
|
278
|
+
now,
|
|
236
279
|
select,
|
|
237
280
|
stopOnFailure,
|
|
238
281
|
store,
|
|
@@ -279,7 +322,7 @@ function summarizePendingWrites(records) {
|
|
|
279
322
|
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
280
323
|
}
|
|
281
324
|
const pendingWriteSyncs = new WeakMap();
|
|
282
|
-
async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
|
|
325
|
+
async function performPendingWriteSync({ now, select, stopOnFailure, store, sync, }) {
|
|
283
326
|
const summary = {
|
|
284
327
|
attempted: 0,
|
|
285
328
|
failed: 0,
|
|
@@ -292,6 +335,7 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
292
335
|
for (const record of records) {
|
|
293
336
|
const result = await syncPendingWrite({
|
|
294
337
|
id: record.id,
|
|
338
|
+
now,
|
|
295
339
|
store,
|
|
296
340
|
sync,
|
|
297
341
|
});
|
|
@@ -327,7 +371,7 @@ function isPendingWriteSyncClaimAvailable(record, now) {
|
|
|
327
371
|
* workspace. Use its `recover` method for background replay and `run` for new
|
|
328
372
|
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
329
373
|
*/
|
|
330
|
-
function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
374
|
+
function createOrderedPendingWriteExecutor({ now, scopeKey = "all", select = () => true, store, sync, }) {
|
|
331
375
|
let execution = Promise.resolve();
|
|
332
376
|
function inOrder(operation) {
|
|
333
377
|
const result = execution.then(operation, operation);
|
|
@@ -336,6 +380,7 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
|
|
|
336
380
|
}
|
|
337
381
|
function recover() {
|
|
338
382
|
return inOrder(() => syncPendingWrites({
|
|
383
|
+
now,
|
|
339
384
|
scopeKey,
|
|
340
385
|
select,
|
|
341
386
|
stopOnFailure: true,
|
|
@@ -351,6 +396,7 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
|
|
|
351
396
|
return runPendingWrite(options);
|
|
352
397
|
}
|
|
353
398
|
const replay = await syncPendingWrites({
|
|
399
|
+
now,
|
|
354
400
|
scopeKey,
|
|
355
401
|
select,
|
|
356
402
|
stopOnFailure: true,
|
|
@@ -368,13 +414,13 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
|
|
|
368
414
|
},
|
|
369
415
|
};
|
|
370
416
|
}
|
|
371
|
-
function
|
|
417
|
+
function createUnavailablePendingWriteDriver() {
|
|
418
|
+
const unavailable = () => new idbDriver_1.OfflineStorageError("IndexedDB is unavailable; the pending write was not stored.");
|
|
372
419
|
return {
|
|
373
420
|
async clear() { },
|
|
374
421
|
async clearIf() {
|
|
375
422
|
return false;
|
|
376
423
|
},
|
|
377
|
-
async clearAll() { },
|
|
378
424
|
async close() { },
|
|
379
425
|
async get() {
|
|
380
426
|
return null;
|
|
@@ -382,9 +428,11 @@ function createNoopPendingWriteDriver() {
|
|
|
382
428
|
async list() {
|
|
383
429
|
return [];
|
|
384
430
|
},
|
|
385
|
-
async set() {
|
|
431
|
+
async set() {
|
|
432
|
+
throw unavailable();
|
|
433
|
+
},
|
|
386
434
|
async setIf() {
|
|
387
|
-
|
|
435
|
+
throw unavailable();
|
|
388
436
|
},
|
|
389
437
|
};
|
|
390
438
|
}
|
|
@@ -396,6 +444,7 @@ function toPendingWriteDriver(driver) {
|
|
|
396
444
|
close: driver.close,
|
|
397
445
|
get: driver.get,
|
|
398
446
|
list: driver.list,
|
|
447
|
+
listEntries: driver.listEntries,
|
|
399
448
|
set: driver.set,
|
|
400
449
|
setIf: driver.setIf,
|
|
401
450
|
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type BrowserKeyValueDriverOptions } from "./idbDriver.js";
|
|
2
|
+
export declare const readCacheSchemaVersion = 1;
|
|
3
|
+
export declare const readCacheFreshnessMaxAgeMs = 30000;
|
|
4
|
+
export declare const durableReadCacheFreshnessMaxAgeMs: number;
|
|
5
|
+
export declare const identityReadCacheFreshnessMaxAgeMs: number;
|
|
6
|
+
export declare const browserReadCacheOperationTimeoutMs = 1000;
|
|
7
|
+
export type ReadCacheHit<T> = {
|
|
8
|
+
fromCache: true;
|
|
9
|
+
savedAt: string;
|
|
10
|
+
value: T;
|
|
11
|
+
};
|
|
12
|
+
export type ReadCacheDriver = {
|
|
13
|
+
clear: () => Promise<void>;
|
|
14
|
+
close?: () => Promise<void>;
|
|
15
|
+
delete: (key: string) => Promise<void>;
|
|
16
|
+
get: (key: string) => Promise<unknown | null>;
|
|
17
|
+
listEntries?: () => Promise<Array<{
|
|
18
|
+
key: string;
|
|
19
|
+
value: unknown;
|
|
20
|
+
}>>;
|
|
21
|
+
set: (key: string, value: unknown) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export type ReadCache = {
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a stable cache for the duration of a resource load. Dynamic
|
|
26
|
+
* adapters use this to pin the active account before asynchronous work.
|
|
27
|
+
*/
|
|
28
|
+
capture?: () => ReadCache;
|
|
29
|
+
clear: () => Promise<void>;
|
|
30
|
+
close: () => Promise<void>;
|
|
31
|
+
delete: (key: string) => Promise<void>;
|
|
32
|
+
/** Advances a key's generation without deleting its retained value. */
|
|
33
|
+
invalidate?: (key: string) => void;
|
|
34
|
+
get: <T>(key: string) => Promise<ReadCacheHit<T> | null>;
|
|
35
|
+
getVersion: (key: string) => string;
|
|
36
|
+
set: <T>(key: string, value: T) => Promise<void>;
|
|
37
|
+
setIfVersion: <T>(key: string, value: T, version: string) => Promise<boolean>;
|
|
38
|
+
};
|
|
39
|
+
export type ReadCachePolicy = {
|
|
40
|
+
/** Time that a cached value can suppress a remote read. */
|
|
41
|
+
freshForMs: number;
|
|
42
|
+
/** Maximum age at which cached data remains eligible as an offline fallback. */
|
|
43
|
+
retainForMs: number;
|
|
44
|
+
/** Behavior when revalidation fails while retained data is available. */
|
|
45
|
+
refreshFailure: "use-retained" | "throw";
|
|
46
|
+
};
|
|
47
|
+
export type ReadCacheStatus = "fresh" | "stale" | "miss";
|
|
48
|
+
/** Payload-free cache lifecycle events suitable for host-owned telemetry. */
|
|
49
|
+
export type ReadCacheOutcome = "fresh-hit" | "stale-hit" | "cache-miss" | "forced-refresh" | "loader-success" | "loader-failure";
|
|
50
|
+
export type ReadThroughCacheResult<T> = {
|
|
51
|
+
data: T;
|
|
52
|
+
error: Error | null;
|
|
53
|
+
fromCache: boolean;
|
|
54
|
+
stale: boolean;
|
|
55
|
+
status: ReadCacheStatus;
|
|
56
|
+
};
|
|
57
|
+
export type ReadCacheResourceUpdate<T> = {
|
|
58
|
+
data: T;
|
|
59
|
+
error?: Error;
|
|
60
|
+
fromCache: boolean;
|
|
61
|
+
status: ReadCacheStatus;
|
|
62
|
+
};
|
|
63
|
+
export type BrowserReadCacheOptions = Partial<Pick<BrowserKeyValueDriverOptions, "databaseVersion" | "indexedDB">> & {
|
|
64
|
+
databaseName: string;
|
|
65
|
+
operationTimeoutMs?: number;
|
|
66
|
+
storeName: string;
|
|
67
|
+
};
|
|
68
|
+
export type ReadCacheKeyOptions = {
|
|
69
|
+
namespace: string;
|
|
70
|
+
params?: Record<string, string | number | boolean | null | undefined>;
|
|
71
|
+
prefix: string;
|
|
72
|
+
scope: string;
|
|
73
|
+
};
|
|
74
|
+
export declare function createReadCache(driver: ReadCacheDriver): ReadCache;
|
|
75
|
+
export declare function createMemoryReadCacheDriver(): ReadCacheDriver;
|
|
76
|
+
export declare function createBrowserReadCache({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserReadCacheOptions): ReadCache;
|
|
77
|
+
/**
|
|
78
|
+
* Deletes only valid read-cache envelopes older than the supplied age. It is
|
|
79
|
+
* opt-in and returns zero for custom cache adapters that cannot enumerate keys.
|
|
80
|
+
*/
|
|
81
|
+
export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
|
|
82
|
+
cache: ReadCache;
|
|
83
|
+
maxAgeMs: number;
|
|
84
|
+
now?: () => number;
|
|
85
|
+
}): Promise<number>;
|
|
86
|
+
export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
|
|
87
|
+
export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, }: {
|
|
88
|
+
cache: ReadCache;
|
|
89
|
+
/**
|
|
90
|
+
* Revalidates after advancing the key generation, retaining the prior value
|
|
91
|
+
* as a possible fallback if refresh fails.
|
|
92
|
+
*/
|
|
93
|
+
forceFresh?: boolean;
|
|
94
|
+
key: string;
|
|
95
|
+
loadFresh: () => Promise<T>;
|
|
96
|
+
maxAgeMs?: number;
|
|
97
|
+
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
98
|
+
policy?: ReadCachePolicy;
|
|
99
|
+
}): Promise<ReadThroughCacheResult<T>>;
|
|
100
|
+
export declare function loadReadCacheResource<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache, }: {
|
|
101
|
+
cache: ReadCache;
|
|
102
|
+
/** Revalidate while retaining eligible data as fallback on loader failure. */
|
|
103
|
+
forceFresh?: boolean;
|
|
104
|
+
key: string;
|
|
105
|
+
loadFresh: () => Promise<T>;
|
|
106
|
+
maxAgeMs?: number;
|
|
107
|
+
onOutcome?: (outcome: ReadCacheOutcome) => void;
|
|
108
|
+
policy?: ReadCachePolicy;
|
|
109
|
+
onCacheWriteError?: (error: Error) => void;
|
|
110
|
+
onUpdate: (update: ReadCacheResourceUpdate<T>) => void;
|
|
111
|
+
skipCache?: boolean;
|
|
112
|
+
}): Promise<T>;
|
package/dist-cjs/readCache.js
CHANGED
|
@@ -13,7 +13,9 @@ exports.readCacheSchemaVersion = 1;
|
|
|
13
13
|
exports.readCacheFreshnessMaxAgeMs = 30_000;
|
|
14
14
|
exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
|
|
15
15
|
exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
|
|
16
|
-
|
|
16
|
+
// Cache reads are optional and should yield to the fresh loader quickly when
|
|
17
|
+
// IndexedDB is blocked by a browser/WebView lifecycle or another tab.
|
|
18
|
+
exports.browserReadCacheOperationTimeoutMs = 1_000;
|
|
17
19
|
const pendingFreshLoads = new WeakMap();
|
|
18
20
|
const cachePruners = new WeakMap();
|
|
19
21
|
function createReadCache(driver) {
|
|
@@ -24,6 +26,10 @@ function createReadCache(driver) {
|
|
|
24
26
|
function getVersion(key) {
|
|
25
27
|
return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
|
|
26
28
|
}
|
|
29
|
+
function invalidate(key) {
|
|
30
|
+
pendingLoads.delete(key);
|
|
31
|
+
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
32
|
+
}
|
|
27
33
|
function enqueue(operation) {
|
|
28
34
|
const next = operationQueue.then(operation, operation);
|
|
29
35
|
operationQueue = next.then(() => undefined, () => undefined);
|
|
@@ -40,10 +46,10 @@ function createReadCache(driver) {
|
|
|
40
46
|
await enqueue(async () => driver.close?.());
|
|
41
47
|
},
|
|
42
48
|
async delete(key) {
|
|
43
|
-
|
|
44
|
-
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
49
|
+
invalidate(key);
|
|
45
50
|
await enqueue(() => driver.delete(key));
|
|
46
51
|
},
|
|
52
|
+
invalidate,
|
|
47
53
|
async get(key) {
|
|
48
54
|
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
49
55
|
return envelope
|
|
@@ -143,8 +149,7 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
|
|
|
143
149
|
*/
|
|
144
150
|
function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
|
|
145
151
|
const resourceCache = cache.capture?.() ?? cache;
|
|
146
|
-
return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
|
|
147
|
-
Promise.resolve(0);
|
|
152
|
+
return (cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ?? Promise.resolve(0));
|
|
148
153
|
}
|
|
149
154
|
function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
150
155
|
const serializedParams = Object.entries(params)
|
|
@@ -161,58 +166,128 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
161
166
|
.filter(Boolean)
|
|
162
167
|
.join(":");
|
|
163
168
|
}
|
|
164
|
-
async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
|
|
169
|
+
async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, }) {
|
|
165
170
|
const resourceCache = cache.capture?.() ?? cache;
|
|
171
|
+
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
172
|
+
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
166
173
|
if (forceFresh) {
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
174
|
+
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
175
|
+
// Built-in caches advance generation without deleting fallback data.
|
|
176
|
+
// Legacy custom adapters retain their old delete behavior.
|
|
177
|
+
try {
|
|
178
|
+
if (resourceCache.invalidate) {
|
|
179
|
+
resourceCache.invalidate(key);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
await resourceCache.delete(key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
187
|
+
error: normalizeError(error),
|
|
188
|
+
key,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
170
191
|
}
|
|
171
192
|
const version = resourceCache.getVersion(key);
|
|
172
|
-
const cached = await resourceCache.get(key);
|
|
173
|
-
|
|
193
|
+
const cached = forcedFallback ?? (await resourceCache.get(key));
|
|
194
|
+
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
195
|
+
if (!forceFresh && retained && isCacheFresh(cached.savedAt, resolvedPolicy)) {
|
|
196
|
+
reportReadCacheOutcome(onOutcome, "fresh-hit");
|
|
174
197
|
return {
|
|
175
198
|
data: cached.value,
|
|
176
199
|
error: null,
|
|
177
200
|
fromCache: true,
|
|
178
201
|
stale: false,
|
|
202
|
+
status: "fresh",
|
|
179
203
|
};
|
|
180
204
|
}
|
|
205
|
+
reportReadCacheOutcome(onOutcome, retained ? "stale-hit" : "cache-miss");
|
|
181
206
|
try {
|
|
182
207
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
183
|
-
|
|
208
|
+
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
209
|
+
try {
|
|
210
|
+
await resourceCache.setIfVersion(key, fresh, version);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
214
|
+
error: normalizeError(error),
|
|
215
|
+
key,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
184
218
|
return {
|
|
185
219
|
data: fresh,
|
|
186
220
|
error: null,
|
|
187
221
|
fromCache: false,
|
|
188
222
|
stale: false,
|
|
223
|
+
status: retained ? "fresh" : "miss",
|
|
189
224
|
};
|
|
190
225
|
}
|
|
191
226
|
catch (error) {
|
|
192
|
-
|
|
227
|
+
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
228
|
+
if (retained && resolvedPolicy.refreshFailure === "use-retained") {
|
|
193
229
|
return {
|
|
194
230
|
data: cached.value,
|
|
195
231
|
error: normalizeError(error),
|
|
196
232
|
fromCache: true,
|
|
197
233
|
stale: true,
|
|
234
|
+
status: "stale",
|
|
198
235
|
};
|
|
199
236
|
}
|
|
200
237
|
throw error;
|
|
201
238
|
}
|
|
202
239
|
}
|
|
203
|
-
async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
240
|
+
async function loadReadCacheResource({ cache, forceFresh = false, key, loadFresh, maxAgeMs, onOutcome, policy, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
204
241
|
const resourceCache = cache.capture?.() ?? cache;
|
|
242
|
+
const resolvedPolicy = resolvePolicy(policy, maxAgeMs);
|
|
243
|
+
const forcedFallback = forceFresh ? await resourceCache.get(key) : null;
|
|
244
|
+
if (forceFresh) {
|
|
245
|
+
reportReadCacheOutcome(onOutcome, "forced-refresh");
|
|
246
|
+
try {
|
|
247
|
+
if (resourceCache.invalidate) {
|
|
248
|
+
resourceCache.invalidate(key);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
await resourceCache.delete(key);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
256
|
+
error: normalizeError(error),
|
|
257
|
+
key,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
205
261
|
const version = resourceCache.getVersion(key);
|
|
206
|
-
const cached = skipCache
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
262
|
+
const cached = skipCache
|
|
263
|
+
? null
|
|
264
|
+
: (forcedFallback ?? (await resourceCache.get(key)));
|
|
265
|
+
const retained = cached && isCacheRetained(cached.savedAt, resolvedPolicy);
|
|
266
|
+
if (retained) {
|
|
267
|
+
const fresh = isCacheFresh(cached.savedAt, resolvedPolicy);
|
|
268
|
+
reportReadCacheOutcome(onOutcome, fresh && !forceFresh ? "fresh-hit" : "stale-hit");
|
|
269
|
+
onUpdate({
|
|
270
|
+
data: cached.value,
|
|
271
|
+
fromCache: true,
|
|
272
|
+
status: fresh && !forceFresh ? "fresh" : "stale",
|
|
273
|
+
});
|
|
274
|
+
if (fresh && !forceFresh) {
|
|
210
275
|
return cached.value;
|
|
211
276
|
}
|
|
212
277
|
}
|
|
278
|
+
else {
|
|
279
|
+
reportReadCacheOutcome(onOutcome, "cache-miss");
|
|
280
|
+
}
|
|
213
281
|
try {
|
|
214
282
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
215
|
-
|
|
283
|
+
reportReadCacheOutcome(onOutcome, "loader-success");
|
|
284
|
+
if (resourceCache.getVersion(key) === version) {
|
|
285
|
+
onUpdate({
|
|
286
|
+
data: fresh,
|
|
287
|
+
fromCache: false,
|
|
288
|
+
status: retained ? "fresh" : "miss",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
216
291
|
void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
|
|
217
292
|
const normalizedError = normalizeError(error);
|
|
218
293
|
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
@@ -224,17 +299,32 @@ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheW
|
|
|
224
299
|
return fresh;
|
|
225
300
|
}
|
|
226
301
|
catch (error) {
|
|
227
|
-
|
|
302
|
+
reportReadCacheOutcome(onOutcome, "loader-failure");
|
|
303
|
+
if (!retained || resolvedPolicy.refreshFailure === "throw") {
|
|
228
304
|
throw error;
|
|
229
305
|
}
|
|
230
306
|
onUpdate({
|
|
231
307
|
data: cached.value,
|
|
232
308
|
error: normalizeError(error),
|
|
233
309
|
fromCache: true,
|
|
310
|
+
status: "stale",
|
|
234
311
|
});
|
|
235
312
|
return cached.value;
|
|
236
313
|
}
|
|
237
314
|
}
|
|
315
|
+
function reportReadCacheOutcome(onOutcome, outcome) {
|
|
316
|
+
if (!onOutcome)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
onOutcome(outcome);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
console.error("[Bayon offline] Read-cache outcome callback failed.", {
|
|
323
|
+
error: normalizeError(error),
|
|
324
|
+
outcome,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
238
328
|
function createNoopReadCacheDriver() {
|
|
239
329
|
return {
|
|
240
330
|
async clear() { },
|
|
@@ -249,12 +339,33 @@ function createNoopReadCacheDriver() {
|
|
|
249
339
|
async set() { },
|
|
250
340
|
};
|
|
251
341
|
}
|
|
252
|
-
function isCacheFresh(savedAt,
|
|
253
|
-
if (
|
|
342
|
+
function isCacheFresh(savedAt, policy) {
|
|
343
|
+
if (policy.freshForMs <= 0) {
|
|
254
344
|
return false;
|
|
255
345
|
}
|
|
256
346
|
const savedTime = Date.parse(savedAt);
|
|
257
|
-
return Number.isFinite(savedTime) && Date.now() - savedTime <=
|
|
347
|
+
return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.freshForMs);
|
|
348
|
+
}
|
|
349
|
+
function resolvePolicy(policy, maxAgeMs) {
|
|
350
|
+
if (policy) {
|
|
351
|
+
if (!Number.isFinite(policy.freshForMs) ||
|
|
352
|
+
policy.freshForMs < 0 ||
|
|
353
|
+
!Number.isFinite(policy.retainForMs) ||
|
|
354
|
+
policy.retainForMs < policy.freshForMs ||
|
|
355
|
+
!["use-retained", "throw"].includes(policy.refreshFailure)) {
|
|
356
|
+
throw new RangeError("[Bayon offline] Invalid read-cache policy: require finite non-negative durations, retainForMs >= freshForMs, and a supported refreshFailure mode.");
|
|
357
|
+
}
|
|
358
|
+
return policy;
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
freshForMs: maxAgeMs ?? 0,
|
|
362
|
+
retainForMs: Number.POSITIVE_INFINITY,
|
|
363
|
+
refreshFailure: "use-retained",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function isCacheRetained(savedAt, policy) {
|
|
367
|
+
const savedTime = Date.parse(savedAt);
|
|
368
|
+
return (Number.isFinite(savedTime) && Date.now() - savedTime <= policy.retainForMs);
|
|
258
369
|
}
|
|
259
370
|
async function loadSharedFreshResource(cache, key, loadFresh) {
|
|
260
371
|
const pendingLoads = getPendingFreshLoads(cache);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bayonai/offline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bayon AI",
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
"idb",
|
|
22
22
|
"typescript"
|
|
23
23
|
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
24
27
|
"sideEffects": false,
|
|
25
28
|
"type": "module",
|
|
26
29
|
"main": "./dist-cjs/index.js",
|
|
@@ -28,9 +31,14 @@
|
|
|
28
31
|
"types": "./dist/index.d.ts",
|
|
29
32
|
"exports": {
|
|
30
33
|
".": {
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"require": {
|
|
39
|
+
"types": "./dist-cjs/index.d.ts",
|
|
40
|
+
"default": "./dist-cjs/index.js"
|
|
41
|
+
}
|
|
34
42
|
}
|
|
35
43
|
},
|
|
36
44
|
"files": [
|
|
@@ -44,7 +52,7 @@
|
|
|
44
52
|
"access": "public"
|
|
45
53
|
},
|
|
46
54
|
"scripts": {
|
|
47
|
-
"build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs",
|
|
55
|
+
"build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs && node scripts/verify-dist.mjs",
|
|
48
56
|
"lint": "tsc --noEmit -p tsconfig.json",
|
|
49
57
|
"pack:smoke": "corepack pnpm run build && npm pack --dry-run --access public",
|
|
50
58
|
"prepack": "corepack pnpm run build",
|
|
@@ -56,7 +64,7 @@
|
|
|
56
64
|
},
|
|
57
65
|
"devDependencies": {
|
|
58
66
|
"fake-indexeddb": "^6.2.5",
|
|
59
|
-
"typescript": "^
|
|
60
|
-
"vitest": "^
|
|
67
|
+
"typescript": "^6.0.3",
|
|
68
|
+
"vitest": "^5.0.1"
|
|
61
69
|
}
|
|
62
70
|
}
|