@bayonai/offline 0.1.1 → 0.4.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 +33 -0
- package/README.md +102 -7
- package/dist/browserCoordination.d.ts +23 -0
- package/dist/browserCoordination.js +57 -0
- package/dist/browserRecovery.d.ts +42 -0
- package/dist/browserRecovery.js +178 -0
- package/dist/idbDriver.d.ts +4 -0
- package/dist/idbDriver.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pendingWrites.d.ts +95 -2
- package/dist/pendingWrites.js +223 -15
- package/dist/readCache.d.ts +19 -1
- package/dist/readCache.js +48 -3
- package/dist-cjs/browserCoordination.js +60 -0
- package/dist-cjs/browserRecovery.js +182 -0
- package/dist-cjs/idbDriver.js +24 -0
- package/dist-cjs/index.js +2 -0
- package/dist-cjs/pendingWrites.js +228 -16
- package/dist-cjs/readCache.js +49 -3
- package/package.json +2 -2
package/dist-cjs/idbDriver.js
CHANGED
|
@@ -90,6 +90,30 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
90
90
|
timeoutMs: operationTimeoutMs,
|
|
91
91
|
});
|
|
92
92
|
},
|
|
93
|
+
async listEntries() {
|
|
94
|
+
return runBrowserStorageOperation({
|
|
95
|
+
fallback: [],
|
|
96
|
+
operationName: "list object-store entries",
|
|
97
|
+
operation: async () => {
|
|
98
|
+
const database = await databasePromise;
|
|
99
|
+
// Keys and values must come from one transaction. Separate database
|
|
100
|
+
// reads could pair a key from one cross-tab storage state with a
|
|
101
|
+
// value from another, causing retention code to delete the wrong key.
|
|
102
|
+
const transaction = database.transaction(storeName, "readonly");
|
|
103
|
+
const objectStore = transaction.objectStore(storeName);
|
|
104
|
+
const [keys, values] = await Promise.all([
|
|
105
|
+
objectStore.getAllKeys(),
|
|
106
|
+
objectStore.getAll(),
|
|
107
|
+
]);
|
|
108
|
+
await transaction.done;
|
|
109
|
+
return keys.map((key, index) => ({
|
|
110
|
+
key: String(key),
|
|
111
|
+
value: values[index],
|
|
112
|
+
}));
|
|
113
|
+
},
|
|
114
|
+
timeoutMs: operationTimeoutMs,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
93
117
|
async set(key, value) {
|
|
94
118
|
await runBrowserStorageOperation({
|
|
95
119
|
fallback: undefined,
|
package/dist-cjs/index.js
CHANGED
|
@@ -16,3 +16,5 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./readCache"), exports);
|
|
18
18
|
__exportStar(require("./pendingWrites"), exports);
|
|
19
|
+
__exportStar(require("./browserRecovery"), exports);
|
|
20
|
+
__exportStar(require("./browserCoordination"), exports);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.pendingWriteOperationTimeoutMs = exports.pendingWriteSchemaVersion = void 0;
|
|
3
|
+
exports.pendingWriteSyncLeaseTimeoutMs = exports.pendingWriteOperationTimeoutMs = exports.pendingWriteSchemaVersion = void 0;
|
|
4
4
|
exports.createPendingWriteStore = createPendingWriteStore;
|
|
5
5
|
exports.createMemoryPendingWriteDriver = createMemoryPendingWriteDriver;
|
|
6
6
|
exports.createBrowserPendingWriteDriver = createBrowserPendingWriteDriver;
|
|
@@ -8,22 +8,61 @@ exports.createBrowserPendingWriteStore = createBrowserPendingWriteStore;
|
|
|
8
8
|
exports.createPendingWriteKey = createPendingWriteKey;
|
|
9
9
|
exports.createPendingWriteSnapshotId = createPendingWriteSnapshotId;
|
|
10
10
|
exports.syncPendingWrite = syncPendingWrite;
|
|
11
|
+
exports.syncPendingWrites = syncPendingWrites;
|
|
12
|
+
exports.runPendingWrite = runPendingWrite;
|
|
13
|
+
exports.summarizePendingWrites = summarizePendingWrites;
|
|
14
|
+
exports.createOrderedPendingWriteExecutor = createOrderedPendingWriteExecutor;
|
|
11
15
|
const idbDriver_1 = require("./idbDriver");
|
|
12
16
|
exports.pendingWriteSchemaVersion = 1;
|
|
13
17
|
exports.pendingWriteOperationTimeoutMs = 2_000;
|
|
18
|
+
exports.pendingWriteSyncLeaseTimeoutMs = 60_000;
|
|
14
19
|
function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
|
|
20
|
+
const listeners = new Set();
|
|
21
|
+
const notify = (change) => {
|
|
22
|
+
for (const listener of listeners) {
|
|
23
|
+
try {
|
|
24
|
+
listener(change);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error("[Bayon offline] Pending-write store listener failed.", {
|
|
28
|
+
error,
|
|
29
|
+
type: change.type,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
15
34
|
return {
|
|
35
|
+
async claimIfSnapshotId(id, snapshotId, record, now) {
|
|
36
|
+
const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
|
|
37
|
+
const current = toPendingWriteEnvelope(value, schemaVersion, {
|
|
38
|
+
normalizeLegacyRecord,
|
|
39
|
+
normalizePayload,
|
|
40
|
+
})?.record;
|
|
41
|
+
return (current?.snapshotId === snapshotId &&
|
|
42
|
+
isPendingWriteSyncClaimAvailable(current, now));
|
|
43
|
+
});
|
|
44
|
+
if (claimed) {
|
|
45
|
+
notify({ record, type: "replace" });
|
|
46
|
+
}
|
|
47
|
+
return claimed;
|
|
48
|
+
},
|
|
16
49
|
async clear(id) {
|
|
17
50
|
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
51
|
+
notify({ id, type: "clear" });
|
|
18
52
|
},
|
|
19
53
|
async clearAll() {
|
|
20
54
|
await driver.clearAll();
|
|
55
|
+
notify({ type: "clearAll" });
|
|
21
56
|
},
|
|
22
57
|
async clearIfSnapshotId(id, snapshotId) {
|
|
23
|
-
|
|
58
|
+
const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
24
59
|
normalizeLegacyRecord,
|
|
25
60
|
normalizePayload,
|
|
26
61
|
})?.record.snapshotId === snapshotId);
|
|
62
|
+
if (cleared) {
|
|
63
|
+
notify({ id, type: "clear" });
|
|
64
|
+
}
|
|
65
|
+
return cleared;
|
|
27
66
|
},
|
|
28
67
|
async close() {
|
|
29
68
|
await driver.close?.();
|
|
@@ -38,13 +77,18 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
38
77
|
normalizePayload,
|
|
39
78
|
})?.record)
|
|
40
79
|
.filter((record) => Boolean(record))
|
|
41
|
-
.sort((left, right) => left.savedAt.localeCompare(right.savedAt)
|
|
80
|
+
.sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
81
|
+
left.id.localeCompare(right.id));
|
|
42
82
|
},
|
|
43
83
|
async replaceIfSnapshotId(id, snapshotId, record) {
|
|
44
|
-
|
|
84
|
+
const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
45
85
|
normalizeLegacyRecord,
|
|
46
86
|
normalizePayload,
|
|
47
87
|
})?.record.snapshotId === snapshotId);
|
|
88
|
+
if (replaced) {
|
|
89
|
+
notify({ record, type: "replace" });
|
|
90
|
+
}
|
|
91
|
+
return replaced;
|
|
48
92
|
},
|
|
49
93
|
async set(record) {
|
|
50
94
|
const storedRecord = {
|
|
@@ -55,8 +99,13 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
55
99
|
record: storedRecord,
|
|
56
100
|
schemaVersion,
|
|
57
101
|
});
|
|
102
|
+
notify({ record: storedRecord, type: "set" });
|
|
58
103
|
return storedRecord;
|
|
59
104
|
},
|
|
105
|
+
subscribe(listener) {
|
|
106
|
+
listeners.add(listener);
|
|
107
|
+
return () => listeners.delete(listener);
|
|
108
|
+
},
|
|
60
109
|
};
|
|
61
110
|
}
|
|
62
111
|
function createMemoryPendingWriteDriver() {
|
|
@@ -121,34 +170,193 @@ function createPendingWriteKey({ id, prefix }) {
|
|
|
121
170
|
function createPendingWriteSnapshotId() {
|
|
122
171
|
return globalThis.crypto.randomUUID();
|
|
123
172
|
}
|
|
124
|
-
async function syncPendingWrite({ id, store, sync, }) {
|
|
173
|
+
async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
|
|
125
174
|
const pending = await store.get(id);
|
|
126
175
|
if (!pending) {
|
|
127
176
|
return { status: "idle" };
|
|
128
177
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
178
|
+
const claimedSnapshotId = createPendingWriteSnapshotId();
|
|
179
|
+
const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
|
|
180
|
+
...pending,
|
|
181
|
+
error: undefined,
|
|
182
|
+
snapshotId: claimedSnapshotId,
|
|
183
|
+
syncingAt: now(),
|
|
184
|
+
syncStatus: "syncing",
|
|
185
|
+
}, now());
|
|
186
|
+
if (!claimed) {
|
|
187
|
+
return { status: "superseded" };
|
|
188
|
+
}
|
|
135
189
|
try {
|
|
136
190
|
await sync(pending.payload);
|
|
137
191
|
}
|
|
138
192
|
catch (error) {
|
|
139
193
|
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
140
|
-
|
|
194
|
+
const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
|
|
195
|
+
const markedFailed = await markPendingSnapshot({
|
|
141
196
|
id,
|
|
142
|
-
nextRecord: {
|
|
143
|
-
|
|
197
|
+
nextRecord: {
|
|
198
|
+
...pendingWithoutSyncClaim,
|
|
199
|
+
error: message,
|
|
200
|
+
snapshotId: claimedSnapshotId,
|
|
201
|
+
syncStatus: "failed",
|
|
202
|
+
},
|
|
203
|
+
snapshotId: claimedSnapshotId,
|
|
144
204
|
store,
|
|
145
205
|
});
|
|
146
|
-
return
|
|
206
|
+
return markedFailed
|
|
207
|
+
? { error: message, status: "failed" }
|
|
208
|
+
: { status: "superseded" };
|
|
147
209
|
}
|
|
148
|
-
return (await store.clearIfSnapshotId(id,
|
|
210
|
+
return (await store.clearIfSnapshotId(id, claimedSnapshotId))
|
|
149
211
|
? { status: "synced" }
|
|
150
212
|
: { status: "superseded" };
|
|
151
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Replays selected writes oldest first and coalesces overlapping runs for the
|
|
216
|
+
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
217
|
+
* mutation semantics.
|
|
218
|
+
*/
|
|
219
|
+
function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
220
|
+
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
221
|
+
if (!inFlightByScope) {
|
|
222
|
+
inFlightByScope = new Map();
|
|
223
|
+
pendingWriteSyncs.set(store, inFlightByScope);
|
|
224
|
+
}
|
|
225
|
+
const existing = inFlightByScope.get(scopeKey);
|
|
226
|
+
if (existing) {
|
|
227
|
+
return existing;
|
|
228
|
+
}
|
|
229
|
+
const syncRun = performPendingWriteSync({
|
|
230
|
+
select,
|
|
231
|
+
stopOnFailure,
|
|
232
|
+
store,
|
|
233
|
+
sync,
|
|
234
|
+
});
|
|
235
|
+
inFlightByScope.set(scopeKey, syncRun);
|
|
236
|
+
const clearInFlight = () => {
|
|
237
|
+
inFlightByScope.delete(scopeKey);
|
|
238
|
+
};
|
|
239
|
+
void syncRun.then(clearInFlight, clearInFlight);
|
|
240
|
+
return syncRun;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Attempts a remote write now and persists a caller-created durable fallback
|
|
244
|
+
* only when offline or when the supplied retry classifier accepts the error.
|
|
245
|
+
*/
|
|
246
|
+
async function runPendingWrite({ enqueue, isOnline, shouldQueue, write, }) {
|
|
247
|
+
if (!isOnline()) {
|
|
248
|
+
return { queued: await enqueue(), status: "queued" };
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
return { result: await write(), status: "synced" };
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (!shouldQueue(error)) {
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
return { queued: await enqueue(), status: "queued" };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function summarizePendingWrites(records) {
|
|
261
|
+
return records.reduce((summary, record) => {
|
|
262
|
+
summary.totalCount += 1;
|
|
263
|
+
if (record.syncStatus === "failed") {
|
|
264
|
+
summary.failedCount += 1;
|
|
265
|
+
}
|
|
266
|
+
else if (record.syncStatus === "syncing") {
|
|
267
|
+
summary.syncingCount += 1;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
summary.pendingCount += 1;
|
|
271
|
+
}
|
|
272
|
+
return summary;
|
|
273
|
+
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
274
|
+
}
|
|
275
|
+
const pendingWriteSyncs = new WeakMap();
|
|
276
|
+
async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
|
|
277
|
+
const summary = {
|
|
278
|
+
attempted: 0,
|
|
279
|
+
failed: 0,
|
|
280
|
+
idle: 0,
|
|
281
|
+
remaining: 0,
|
|
282
|
+
superseded: 0,
|
|
283
|
+
synced: 0,
|
|
284
|
+
};
|
|
285
|
+
const records = (await store.list()).filter(select);
|
|
286
|
+
for (const record of records) {
|
|
287
|
+
const result = await syncPendingWrite({
|
|
288
|
+
id: record.id,
|
|
289
|
+
store,
|
|
290
|
+
sync,
|
|
291
|
+
});
|
|
292
|
+
summary.attempted += 1;
|
|
293
|
+
summary[result.status] += 1;
|
|
294
|
+
if (stopOnFailure && result.status === "failed") {
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
summary.remaining = (await store.list()).filter(select).length;
|
|
299
|
+
return summary;
|
|
300
|
+
}
|
|
301
|
+
function isPendingWriteSyncClaimAvailable(record, now) {
|
|
302
|
+
if (record.syncStatus !== "syncing") {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (!record.syncingAt) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const startedAt = Date.parse(record.syncingAt);
|
|
309
|
+
const currentTime = Date.parse(now);
|
|
310
|
+
return (!Number.isFinite(startedAt) ||
|
|
311
|
+
!Number.isFinite(currentTime) ||
|
|
312
|
+
currentTime - startedAt >= exports.pendingWriteSyncLeaseTimeoutMs);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Creates a serial execution lane for a durable scope such as one user or
|
|
316
|
+
* workspace. Use its `recover` method for background replay and `run` for new
|
|
317
|
+
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
318
|
+
*/
|
|
319
|
+
function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
320
|
+
let execution = Promise.resolve();
|
|
321
|
+
function inOrder(operation) {
|
|
322
|
+
const result = execution.then(operation, operation);
|
|
323
|
+
execution = result.then(() => undefined, () => undefined);
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
function recover() {
|
|
327
|
+
return inOrder(() => syncPendingWrites({
|
|
328
|
+
scopeKey,
|
|
329
|
+
select,
|
|
330
|
+
stopOnFailure: true,
|
|
331
|
+
store,
|
|
332
|
+
sync,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
recover,
|
|
337
|
+
run(options) {
|
|
338
|
+
return inOrder(async () => {
|
|
339
|
+
if (!options.isOnline()) {
|
|
340
|
+
return runPendingWrite(options);
|
|
341
|
+
}
|
|
342
|
+
const replay = await syncPendingWrites({
|
|
343
|
+
scopeKey,
|
|
344
|
+
select,
|
|
345
|
+
stopOnFailure: true,
|
|
346
|
+
store,
|
|
347
|
+
sync,
|
|
348
|
+
});
|
|
349
|
+
// Product decision: unresolved older records retain their position in
|
|
350
|
+
// this scope. Queue the later write rather than letting it overtake a
|
|
351
|
+
// replay whose remote effect may be ambiguous.
|
|
352
|
+
if (replay.remaining > 0) {
|
|
353
|
+
return { queued: await options.enqueue(), status: "queued" };
|
|
354
|
+
}
|
|
355
|
+
return runPendingWrite(options);
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
152
360
|
function createNoopPendingWriteDriver() {
|
|
153
361
|
return {
|
|
154
362
|
async clear() { },
|
|
@@ -208,6 +416,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
208
416
|
payload,
|
|
209
417
|
savedAt: record.savedAt,
|
|
210
418
|
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
419
|
+
...(typeof record.syncingAt === "string"
|
|
420
|
+
? { syncingAt: record.syncingAt }
|
|
421
|
+
: {}),
|
|
211
422
|
syncStatus: record.syncStatus,
|
|
212
423
|
};
|
|
213
424
|
}
|
|
@@ -233,6 +444,7 @@ function isPendingWriteRecord(value) {
|
|
|
233
444
|
"savedAt" in value &&
|
|
234
445
|
typeof value.savedAt === "string" &&
|
|
235
446
|
(!("snapshotId" in value) || typeof value.snapshotId === "string") &&
|
|
447
|
+
(!("syncingAt" in value) || typeof value.syncingAt === "string") &&
|
|
236
448
|
"syncStatus" in value &&
|
|
237
449
|
(value.syncStatus === "pending" ||
|
|
238
450
|
value.syncStatus === "syncing" ||
|
package/dist-cjs/readCache.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.browserReadCacheOperationTimeoutMs = exports.identityReadCacheFreshnessM
|
|
|
4
4
|
exports.createReadCache = createReadCache;
|
|
5
5
|
exports.createMemoryReadCacheDriver = createMemoryReadCacheDriver;
|
|
6
6
|
exports.createBrowserReadCache = createBrowserReadCache;
|
|
7
|
+
exports.pruneReadCache = pruneReadCache;
|
|
7
8
|
exports.createReadCacheKey = createReadCacheKey;
|
|
8
9
|
exports.readThroughCache = readThroughCache;
|
|
9
10
|
exports.loadReadCacheResource = loadReadCacheResource;
|
|
@@ -14,6 +15,7 @@ exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
|
|
|
14
15
|
exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
|
|
15
16
|
exports.browserReadCacheOperationTimeoutMs = 2_000;
|
|
16
17
|
const pendingFreshLoads = new WeakMap();
|
|
18
|
+
const cachePruners = new WeakMap();
|
|
17
19
|
function createReadCache(driver) {
|
|
18
20
|
let globalVersion = 0;
|
|
19
21
|
const keyVersions = new Map();
|
|
@@ -78,6 +80,21 @@ function createReadCache(driver) {
|
|
|
78
80
|
},
|
|
79
81
|
};
|
|
80
82
|
pendingFreshLoads.set(cache, pendingLoads);
|
|
83
|
+
cachePruners.set(cache, async ({ maxAgeMs, now }) => {
|
|
84
|
+
if (!driver.listEntries || maxAgeMs < 0) {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
const cutoff = now() - maxAgeMs;
|
|
88
|
+
const expiredKeys = (await driver.listEntries())
|
|
89
|
+
.filter(({ value }) => {
|
|
90
|
+
const envelope = toReadCacheEnvelope(value);
|
|
91
|
+
const savedAt = envelope ? Date.parse(envelope.savedAt) : Number.NaN;
|
|
92
|
+
return Number.isFinite(savedAt) && savedAt < cutoff;
|
|
93
|
+
})
|
|
94
|
+
.map(({ key }) => key);
|
|
95
|
+
await Promise.all(expiredKeys.map((key) => cache.delete(key)));
|
|
96
|
+
return expiredKeys.length;
|
|
97
|
+
});
|
|
81
98
|
return cache;
|
|
82
99
|
}
|
|
83
100
|
function createMemoryReadCacheDriver() {
|
|
@@ -93,6 +110,9 @@ function createMemoryReadCacheDriver() {
|
|
|
93
110
|
async get(key) {
|
|
94
111
|
return records.get(key) ?? null;
|
|
95
112
|
},
|
|
113
|
+
async listEntries() {
|
|
114
|
+
return [...records.entries()].map(([key, value]) => ({ key, value }));
|
|
115
|
+
},
|
|
96
116
|
async set(key, value) {
|
|
97
117
|
records.set(key, value);
|
|
98
118
|
},
|
|
@@ -106,7 +126,25 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
|
|
|
106
126
|
operationTimeoutMs,
|
|
107
127
|
storeName,
|
|
108
128
|
});
|
|
109
|
-
return createReadCache(driver
|
|
129
|
+
return createReadCache(driver
|
|
130
|
+
? {
|
|
131
|
+
clear: driver.clear,
|
|
132
|
+
close: driver.close,
|
|
133
|
+
delete: driver.delete,
|
|
134
|
+
get: driver.get,
|
|
135
|
+
listEntries: driver.listEntries,
|
|
136
|
+
set: driver.set,
|
|
137
|
+
}
|
|
138
|
+
: createNoopReadCacheDriver());
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Deletes only valid read-cache envelopes older than the supplied age. It is
|
|
142
|
+
* opt-in and returns zero for custom cache adapters that cannot enumerate keys.
|
|
143
|
+
*/
|
|
144
|
+
function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
|
|
145
|
+
const resourceCache = cache.capture?.() ?? cache;
|
|
146
|
+
return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
|
|
147
|
+
Promise.resolve(0);
|
|
110
148
|
}
|
|
111
149
|
function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
112
150
|
const serializedParams = Object.entries(params)
|
|
@@ -123,8 +161,13 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
123
161
|
.filter(Boolean)
|
|
124
162
|
.join(":");
|
|
125
163
|
}
|
|
126
|
-
async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
|
|
164
|
+
async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
|
|
127
165
|
const resourceCache = cache.capture?.() ?? cache;
|
|
166
|
+
if (forceFresh) {
|
|
167
|
+
// Deletion advances the cache version, so a request started before this
|
|
168
|
+
// post-write refresh cannot restore stale data after it finishes.
|
|
169
|
+
await resourceCache.delete(key);
|
|
170
|
+
}
|
|
128
171
|
const version = resourceCache.getVersion(key);
|
|
129
172
|
const cached = await resourceCache.get(key);
|
|
130
173
|
if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
@@ -136,7 +179,7 @@ async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
|
|
|
136
179
|
};
|
|
137
180
|
}
|
|
138
181
|
try {
|
|
139
|
-
const fresh = await loadFresh
|
|
182
|
+
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
140
183
|
await resourceCache.setIfVersion(key, fresh, version);
|
|
141
184
|
return {
|
|
142
185
|
data: fresh,
|
|
@@ -200,6 +243,9 @@ function createNoopReadCacheDriver() {
|
|
|
200
243
|
async get() {
|
|
201
244
|
return null;
|
|
202
245
|
},
|
|
246
|
+
async listEntries() {
|
|
247
|
+
return [];
|
|
248
|
+
},
|
|
203
249
|
async set() { },
|
|
204
250
|
};
|
|
205
251
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bayonai/offline",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Reusable offline read-cache
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bayon AI",
|
|
7
7
|
"homepage": "https://github.com/toledoroy/bounded/tree/main/packages/offline#readme",
|