@bayonai/offline 0.1.0 → 0.3.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 +25 -1
- package/README.md +89 -1
- package/dist/browserCoordination.d.ts +23 -0
- package/dist/browserCoordination.js +57 -0
- package/dist/browserRecovery.d.ts +34 -0
- package/dist/browserRecovery.js +172 -0
- package/dist/idbDriver.d.ts +7 -0
- package/dist/idbDriver.js +81 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/pendingWrites.d.ts +78 -5
- package/dist/pendingWrites.js +189 -35
- package/dist/readCache.d.ts +34 -2
- package/dist/readCache.js +138 -20
- package/dist-cjs/browserCoordination.js +60 -0
- package/dist-cjs/browserRecovery.js +176 -0
- package/dist-cjs/idbDriver.js +81 -3
- package/dist-cjs/index.js +2 -0
- package/dist-cjs/pendingWrites.js +193 -35
- package/dist-cjs/readCache.js +140 -21
- package/package.json +6 -4
|
@@ -6,45 +6,89 @@ exports.createMemoryPendingWriteDriver = createMemoryPendingWriteDriver;
|
|
|
6
6
|
exports.createBrowserPendingWriteDriver = createBrowserPendingWriteDriver;
|
|
7
7
|
exports.createBrowserPendingWriteStore = createBrowserPendingWriteStore;
|
|
8
8
|
exports.createPendingWriteKey = createPendingWriteKey;
|
|
9
|
+
exports.createPendingWriteSnapshotId = createPendingWriteSnapshotId;
|
|
9
10
|
exports.syncPendingWrite = syncPendingWrite;
|
|
11
|
+
exports.syncPendingWrites = syncPendingWrites;
|
|
12
|
+
exports.runPendingWrite = runPendingWrite;
|
|
13
|
+
exports.summarizePendingWrites = summarizePendingWrites;
|
|
10
14
|
const idbDriver_1 = require("./idbDriver");
|
|
11
15
|
exports.pendingWriteSchemaVersion = 1;
|
|
12
16
|
exports.pendingWriteOperationTimeoutMs = 2_000;
|
|
13
|
-
function createPendingWriteStore(driver, { keyPrefix, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
|
|
17
|
+
function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
const notify = (change) => {
|
|
20
|
+
for (const listener of listeners) {
|
|
21
|
+
try {
|
|
22
|
+
listener(change);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
console.error("[Bayon offline] Pending-write store listener failed.", {
|
|
26
|
+
error,
|
|
27
|
+
type: change.type,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
14
32
|
return {
|
|
15
33
|
async clear(id) {
|
|
16
34
|
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
35
|
+
notify({ id, type: "clear" });
|
|
17
36
|
},
|
|
18
37
|
async clearAll() {
|
|
19
38
|
await driver.clearAll();
|
|
39
|
+
notify({ type: "clearAll" });
|
|
20
40
|
},
|
|
21
|
-
async
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
async clearIfSnapshotId(id, snapshotId) {
|
|
42
|
+
const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
43
|
+
normalizeLegacyRecord,
|
|
44
|
+
normalizePayload,
|
|
45
|
+
})?.record.snapshotId === snapshotId);
|
|
46
|
+
if (cleared) {
|
|
47
|
+
notify({ id, type: "clear" });
|
|
25
48
|
}
|
|
26
|
-
|
|
27
|
-
return true;
|
|
49
|
+
return cleared;
|
|
28
50
|
},
|
|
29
51
|
async close() {
|
|
30
52
|
await driver.close?.();
|
|
31
53
|
},
|
|
32
54
|
async get(id) {
|
|
33
|
-
return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizePayload })?.record ?? null);
|
|
55
|
+
return (toPendingWriteEnvelope(await driver.get(createPendingWriteKey({ id, prefix: keyPrefix })), schemaVersion, { normalizeLegacyRecord, normalizePayload })?.record ?? null);
|
|
34
56
|
},
|
|
35
57
|
async list() {
|
|
36
58
|
return (await driver.list())
|
|
37
59
|
.map((value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
60
|
+
normalizeLegacyRecord,
|
|
38
61
|
normalizePayload,
|
|
39
62
|
})?.record)
|
|
40
63
|
.filter((record) => Boolean(record))
|
|
41
|
-
.sort((left, right) => left.savedAt.localeCompare(right.savedAt)
|
|
64
|
+
.sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
65
|
+
left.id.localeCompare(right.id));
|
|
66
|
+
},
|
|
67
|
+
async replaceIfSnapshotId(id, snapshotId, record) {
|
|
68
|
+
const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
69
|
+
normalizeLegacyRecord,
|
|
70
|
+
normalizePayload,
|
|
71
|
+
})?.record.snapshotId === snapshotId);
|
|
72
|
+
if (replaced) {
|
|
73
|
+
notify({ record, type: "replace" });
|
|
74
|
+
}
|
|
75
|
+
return replaced;
|
|
42
76
|
},
|
|
43
77
|
async set(record) {
|
|
78
|
+
const storedRecord = {
|
|
79
|
+
...record,
|
|
80
|
+
snapshotId: record.snapshotId ?? createPendingWriteSnapshotId(),
|
|
81
|
+
};
|
|
44
82
|
await driver.set(createPendingWriteKey({ id: record.id, prefix: keyPrefix }), {
|
|
45
|
-
record,
|
|
83
|
+
record: storedRecord,
|
|
46
84
|
schemaVersion,
|
|
47
85
|
});
|
|
86
|
+
notify({ record: storedRecord, type: "set" });
|
|
87
|
+
return storedRecord;
|
|
88
|
+
},
|
|
89
|
+
subscribe(listener) {
|
|
90
|
+
listeners.add(listener);
|
|
91
|
+
return () => listeners.delete(listener);
|
|
48
92
|
},
|
|
49
93
|
};
|
|
50
94
|
}
|
|
@@ -54,6 +98,14 @@ function createMemoryPendingWriteDriver() {
|
|
|
54
98
|
async clear(key) {
|
|
55
99
|
records.delete(key);
|
|
56
100
|
},
|
|
101
|
+
async clearIf(key, predicate) {
|
|
102
|
+
const current = records.get(key) ?? null;
|
|
103
|
+
if (!predicate(current)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
records.delete(key);
|
|
107
|
+
return true;
|
|
108
|
+
},
|
|
57
109
|
async clearAll() {
|
|
58
110
|
records.clear();
|
|
59
111
|
},
|
|
@@ -67,6 +119,13 @@ function createMemoryPendingWriteDriver() {
|
|
|
67
119
|
async set(key, value) {
|
|
68
120
|
records.set(key, value);
|
|
69
121
|
},
|
|
122
|
+
async setIf(key, value, predicate) {
|
|
123
|
+
if (!predicate(records.get(key) ?? null)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
records.set(key, value);
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
70
129
|
};
|
|
71
130
|
}
|
|
72
131
|
function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, storeName, }) {
|
|
@@ -79,7 +138,7 @@ function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexe
|
|
|
79
138
|
});
|
|
80
139
|
return driver ? toPendingWriteDriver(driver) : null;
|
|
81
140
|
}
|
|
82
|
-
function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizePayload, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, schemaVersion = exports.pendingWriteSchemaVersion, storeName, }) {
|
|
141
|
+
function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexedDB = globalThis.indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs = exports.pendingWriteOperationTimeoutMs, schemaVersion = exports.pendingWriteSchemaVersion, storeName, }) {
|
|
83
142
|
const driver = createBrowserPendingWriteDriver({
|
|
84
143
|
databaseName,
|
|
85
144
|
databaseVersion,
|
|
@@ -87,11 +146,14 @@ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexed
|
|
|
87
146
|
operationTimeoutMs,
|
|
88
147
|
storeName,
|
|
89
148
|
});
|
|
90
|
-
return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizePayload, schemaVersion });
|
|
149
|
+
return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
|
|
91
150
|
}
|
|
92
151
|
function createPendingWriteKey({ id, prefix }) {
|
|
93
152
|
return `${prefix}:${encodeURIComponent(id)}`;
|
|
94
153
|
}
|
|
154
|
+
function createPendingWriteSnapshotId() {
|
|
155
|
+
return globalThis.crypto.randomUUID();
|
|
156
|
+
}
|
|
95
157
|
async function syncPendingWrite({ id, store, sync, }) {
|
|
96
158
|
const pending = await store.get(id);
|
|
97
159
|
if (!pending) {
|
|
@@ -100,7 +162,7 @@ async function syncPendingWrite({ id, store, sync, }) {
|
|
|
100
162
|
await markPendingSnapshot({
|
|
101
163
|
id,
|
|
102
164
|
nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
|
|
103
|
-
|
|
165
|
+
snapshotId: pending.snapshotId,
|
|
104
166
|
store,
|
|
105
167
|
});
|
|
106
168
|
try {
|
|
@@ -111,18 +173,100 @@ async function syncPendingWrite({ id, store, sync, }) {
|
|
|
111
173
|
await markPendingSnapshot({
|
|
112
174
|
id,
|
|
113
175
|
nextRecord: { ...pending, error: message, syncStatus: "failed" },
|
|
114
|
-
|
|
176
|
+
snapshotId: pending.snapshotId,
|
|
115
177
|
store,
|
|
116
178
|
});
|
|
117
179
|
return { error: message, status: "failed" };
|
|
118
180
|
}
|
|
119
|
-
return (await store.
|
|
181
|
+
return (await store.clearIfSnapshotId(id, pending.snapshotId))
|
|
120
182
|
? { status: "synced" }
|
|
121
183
|
: { status: "superseded" };
|
|
122
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Replays selected writes oldest first and coalesces overlapping runs for the
|
|
187
|
+
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
188
|
+
* mutation semantics.
|
|
189
|
+
*/
|
|
190
|
+
function syncPendingWrites({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
191
|
+
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
192
|
+
if (!inFlightByScope) {
|
|
193
|
+
inFlightByScope = new Map();
|
|
194
|
+
pendingWriteSyncs.set(store, inFlightByScope);
|
|
195
|
+
}
|
|
196
|
+
const existing = inFlightByScope.get(scopeKey);
|
|
197
|
+
if (existing) {
|
|
198
|
+
return existing;
|
|
199
|
+
}
|
|
200
|
+
const syncRun = performPendingWriteSync({ select, store, sync });
|
|
201
|
+
inFlightByScope.set(scopeKey, syncRun);
|
|
202
|
+
const clearInFlight = () => {
|
|
203
|
+
inFlightByScope.delete(scopeKey);
|
|
204
|
+
};
|
|
205
|
+
void syncRun.then(clearInFlight, clearInFlight);
|
|
206
|
+
return syncRun;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Attempts a remote write now and persists a caller-created durable fallback
|
|
210
|
+
* only when offline or when the supplied retry classifier accepts the error.
|
|
211
|
+
*/
|
|
212
|
+
async function runPendingWrite({ enqueue, isOnline, shouldQueue, write, }) {
|
|
213
|
+
if (!isOnline()) {
|
|
214
|
+
return { queued: await enqueue(), status: "queued" };
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return { result: await write(), status: "synced" };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (!shouldQueue(error)) {
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
return { queued: await enqueue(), status: "queued" };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function summarizePendingWrites(records) {
|
|
227
|
+
return records.reduce((summary, record) => {
|
|
228
|
+
summary.totalCount += 1;
|
|
229
|
+
if (record.syncStatus === "failed") {
|
|
230
|
+
summary.failedCount += 1;
|
|
231
|
+
}
|
|
232
|
+
else if (record.syncStatus === "syncing") {
|
|
233
|
+
summary.syncingCount += 1;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
summary.pendingCount += 1;
|
|
237
|
+
}
|
|
238
|
+
return summary;
|
|
239
|
+
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
240
|
+
}
|
|
241
|
+
const pendingWriteSyncs = new WeakMap();
|
|
242
|
+
async function performPendingWriteSync({ select, store, sync, }) {
|
|
243
|
+
const summary = {
|
|
244
|
+
attempted: 0,
|
|
245
|
+
failed: 0,
|
|
246
|
+
idle: 0,
|
|
247
|
+
remaining: 0,
|
|
248
|
+
superseded: 0,
|
|
249
|
+
synced: 0,
|
|
250
|
+
};
|
|
251
|
+
const records = (await store.list()).filter(select);
|
|
252
|
+
for (const record of records) {
|
|
253
|
+
const result = await syncPendingWrite({
|
|
254
|
+
id: record.id,
|
|
255
|
+
store,
|
|
256
|
+
sync,
|
|
257
|
+
});
|
|
258
|
+
summary.attempted += 1;
|
|
259
|
+
summary[result.status] += 1;
|
|
260
|
+
}
|
|
261
|
+
summary.remaining = (await store.list()).filter(select).length;
|
|
262
|
+
return summary;
|
|
263
|
+
}
|
|
123
264
|
function createNoopPendingWriteDriver() {
|
|
124
265
|
return {
|
|
125
266
|
async clear() { },
|
|
267
|
+
async clearIf() {
|
|
268
|
+
return false;
|
|
269
|
+
},
|
|
126
270
|
async clearAll() { },
|
|
127
271
|
async close() { },
|
|
128
272
|
async get() {
|
|
@@ -132,43 +276,41 @@ function createNoopPendingWriteDriver() {
|
|
|
132
276
|
return [];
|
|
133
277
|
},
|
|
134
278
|
async set() { },
|
|
279
|
+
async setIf() {
|
|
280
|
+
return false;
|
|
281
|
+
},
|
|
135
282
|
};
|
|
136
283
|
}
|
|
137
284
|
function toPendingWriteDriver(driver) {
|
|
138
285
|
return {
|
|
139
286
|
clear: driver.delete,
|
|
287
|
+
clearIf: driver.deleteIf,
|
|
140
288
|
clearAll: driver.clear,
|
|
141
289
|
close: driver.close,
|
|
142
290
|
get: driver.get,
|
|
143
291
|
list: driver.list,
|
|
144
292
|
set: driver.set,
|
|
293
|
+
setIf: driver.setIf,
|
|
145
294
|
};
|
|
146
295
|
}
|
|
147
|
-
async function markPendingSnapshot({ id, nextRecord,
|
|
148
|
-
|
|
149
|
-
if (current?.savedAt !== savedAt) {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
await store.set(nextRecord);
|
|
153
|
-
return true;
|
|
296
|
+
async function markPendingSnapshot({ id, nextRecord, snapshotId, store, }) {
|
|
297
|
+
return store.replaceIfSnapshotId(id, snapshotId, nextRecord);
|
|
154
298
|
}
|
|
155
|
-
function toPendingWriteEnvelope(value, schemaVersion, { normalizePayload }) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
value
|
|
160
|
-
|
|
161
|
-
!isPendingWriteRecord(value.record)) {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
const record = normalizePendingWriteRecord(value.record, normalizePayload);
|
|
299
|
+
function toPendingWriteEnvelope(value, schemaVersion, { normalizeLegacyRecord, normalizePayload }) {
|
|
300
|
+
const envelopeRecord = readPendingWriteEnvelopeRecord(value, schemaVersion);
|
|
301
|
+
const legacyRecord = envelopeRecord
|
|
302
|
+
? null
|
|
303
|
+
: (normalizeLegacyRecord?.(value) ?? null);
|
|
304
|
+
const record = normalizePendingWriteRecord(envelopeRecord ?? legacyRecord, normalizePayload);
|
|
165
305
|
return record ? { record, schemaVersion } : null;
|
|
166
306
|
}
|
|
167
307
|
function normalizePendingWriteRecord(record, normalizePayload) {
|
|
168
|
-
if (!
|
|
169
|
-
return
|
|
308
|
+
if (!record) {
|
|
309
|
+
return null;
|
|
170
310
|
}
|
|
171
|
-
const payload = normalizePayload
|
|
311
|
+
const payload = normalizePayload
|
|
312
|
+
? normalizePayload(record.payload)
|
|
313
|
+
: record.payload;
|
|
172
314
|
if (payload === null) {
|
|
173
315
|
return null;
|
|
174
316
|
}
|
|
@@ -177,9 +319,24 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
177
319
|
id: record.id,
|
|
178
320
|
payload,
|
|
179
321
|
savedAt: record.savedAt,
|
|
322
|
+
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
180
323
|
syncStatus: record.syncStatus,
|
|
181
324
|
};
|
|
182
325
|
}
|
|
326
|
+
function readPendingWriteEnvelopeRecord(value, schemaVersion) {
|
|
327
|
+
if (!value ||
|
|
328
|
+
typeof value !== "object" ||
|
|
329
|
+
!("schemaVersion" in value) ||
|
|
330
|
+
value.schemaVersion !== schemaVersion ||
|
|
331
|
+
!("record" in value) ||
|
|
332
|
+
!isPendingWriteRecord(value.record)) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
return value.record;
|
|
336
|
+
}
|
|
337
|
+
function createLegacyPendingWriteSnapshotId(record) {
|
|
338
|
+
return `legacy:${encodeURIComponent(record.id)}:${encodeURIComponent(record.savedAt)}`;
|
|
339
|
+
}
|
|
183
340
|
function isPendingWriteRecord(value) {
|
|
184
341
|
return (value !== null &&
|
|
185
342
|
typeof value === "object" &&
|
|
@@ -187,6 +344,7 @@ function isPendingWriteRecord(value) {
|
|
|
187
344
|
typeof value.id === "string" &&
|
|
188
345
|
"savedAt" in value &&
|
|
189
346
|
typeof value.savedAt === "string" &&
|
|
347
|
+
(!("snapshotId" in value) || typeof value.snapshotId === "string") &&
|
|
190
348
|
"syncStatus" in value &&
|
|
191
349
|
(value.syncStatus === "pending" ||
|
|
192
350
|
value.syncStatus === "syncing" ||
|
package/dist-cjs/readCache.js
CHANGED
|
@@ -1,25 +1,48 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.browserReadCacheOperationTimeoutMs = exports.readCacheFreshnessMaxAgeMs = exports.readCacheSchemaVersion = void 0;
|
|
3
|
+
exports.browserReadCacheOperationTimeoutMs = exports.identityReadCacheFreshnessMaxAgeMs = exports.durableReadCacheFreshnessMaxAgeMs = exports.readCacheFreshnessMaxAgeMs = exports.readCacheSchemaVersion = void 0;
|
|
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;
|
|
10
11
|
const idbDriver_1 = require("./idbDriver");
|
|
11
12
|
exports.readCacheSchemaVersion = 1;
|
|
12
13
|
exports.readCacheFreshnessMaxAgeMs = 30_000;
|
|
14
|
+
exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
|
|
15
|
+
exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
|
|
13
16
|
exports.browserReadCacheOperationTimeoutMs = 2_000;
|
|
14
|
-
const pendingFreshLoads = new
|
|
17
|
+
const pendingFreshLoads = new WeakMap();
|
|
18
|
+
const cachePruners = new WeakMap();
|
|
15
19
|
function createReadCache(driver) {
|
|
16
|
-
|
|
20
|
+
let globalVersion = 0;
|
|
21
|
+
const keyVersions = new Map();
|
|
22
|
+
let operationQueue = Promise.resolve();
|
|
23
|
+
const pendingLoads = new Map();
|
|
24
|
+
function getVersion(key) {
|
|
25
|
+
return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
|
|
26
|
+
}
|
|
27
|
+
function enqueue(operation) {
|
|
28
|
+
const next = operationQueue.then(operation, operation);
|
|
29
|
+
operationQueue = next.then(() => undefined, () => undefined);
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
const cache = {
|
|
17
33
|
async clear() {
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
pendingLoads.clear();
|
|
35
|
+
globalVersion += 1;
|
|
36
|
+
keyVersions.clear();
|
|
37
|
+
await enqueue(() => driver.clear());
|
|
20
38
|
},
|
|
21
39
|
async close() {
|
|
22
|
-
await driver.close?.();
|
|
40
|
+
await enqueue(async () => driver.close?.());
|
|
41
|
+
},
|
|
42
|
+
async delete(key) {
|
|
43
|
+
pendingLoads.delete(key);
|
|
44
|
+
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
45
|
+
await enqueue(() => driver.delete(key));
|
|
23
46
|
},
|
|
24
47
|
async get(key) {
|
|
25
48
|
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
@@ -31,14 +54,48 @@ function createReadCache(driver) {
|
|
|
31
54
|
}
|
|
32
55
|
: null;
|
|
33
56
|
},
|
|
57
|
+
getVersion,
|
|
34
58
|
async set(key, value) {
|
|
35
|
-
await driver.set(key, {
|
|
59
|
+
await enqueue(() => driver.set(key, {
|
|
36
60
|
savedAt: new Date().toISOString(),
|
|
37
61
|
schemaVersion: exports.readCacheSchemaVersion,
|
|
38
62
|
value,
|
|
63
|
+
}));
|
|
64
|
+
},
|
|
65
|
+
async setIfVersion(key, value, version) {
|
|
66
|
+
if (getVersion(key) !== version) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return enqueue(async () => {
|
|
70
|
+
if (getVersion(key) !== version) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
await driver.set(key, {
|
|
74
|
+
savedAt: new Date().toISOString(),
|
|
75
|
+
schemaVersion: exports.readCacheSchemaVersion,
|
|
76
|
+
value,
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
39
79
|
});
|
|
40
80
|
},
|
|
41
81
|
};
|
|
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
|
+
});
|
|
98
|
+
return cache;
|
|
42
99
|
}
|
|
43
100
|
function createMemoryReadCacheDriver() {
|
|
44
101
|
const records = new Map();
|
|
@@ -47,9 +104,15 @@ function createMemoryReadCacheDriver() {
|
|
|
47
104
|
records.clear();
|
|
48
105
|
},
|
|
49
106
|
async close() { },
|
|
107
|
+
async delete(key) {
|
|
108
|
+
records.delete(key);
|
|
109
|
+
},
|
|
50
110
|
async get(key) {
|
|
51
111
|
return records.get(key) ?? null;
|
|
52
112
|
},
|
|
113
|
+
async listEntries() {
|
|
114
|
+
return [...records.entries()].map(([key, value]) => ({ key, value }));
|
|
115
|
+
},
|
|
53
116
|
async set(key, value) {
|
|
54
117
|
records.set(key, value);
|
|
55
118
|
},
|
|
@@ -63,7 +126,25 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
|
|
|
63
126
|
operationTimeoutMs,
|
|
64
127
|
storeName,
|
|
65
128
|
});
|
|
66
|
-
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);
|
|
67
148
|
}
|
|
68
149
|
function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
69
150
|
const serializedParams = Object.entries(params)
|
|
@@ -80,11 +161,26 @@ function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
80
161
|
.filter(Boolean)
|
|
81
162
|
.join(":");
|
|
82
163
|
}
|
|
83
|
-
async function readThroughCache({ cache, key, loadFresh, }) {
|
|
84
|
-
const
|
|
164
|
+
async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
|
|
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
|
+
}
|
|
171
|
+
const version = resourceCache.getVersion(key);
|
|
172
|
+
const cached = await resourceCache.get(key);
|
|
173
|
+
if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
174
|
+
return {
|
|
175
|
+
data: cached.value,
|
|
176
|
+
error: null,
|
|
177
|
+
fromCache: true,
|
|
178
|
+
stale: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
85
181
|
try {
|
|
86
|
-
const fresh = await loadFresh
|
|
87
|
-
await
|
|
182
|
+
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
183
|
+
await resourceCache.setIfVersion(key, fresh, version);
|
|
88
184
|
return {
|
|
89
185
|
data: fresh,
|
|
90
186
|
error: null,
|
|
@@ -104,8 +200,10 @@ async function readThroughCache({ cache, key, loadFresh, }) {
|
|
|
104
200
|
throw error;
|
|
105
201
|
}
|
|
106
202
|
}
|
|
107
|
-
async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate, }) {
|
|
108
|
-
const
|
|
203
|
+
async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
204
|
+
const resourceCache = cache.capture?.() ?? cache;
|
|
205
|
+
const version = resourceCache.getVersion(key);
|
|
206
|
+
const cached = skipCache ? null : await resourceCache.get(key);
|
|
109
207
|
if (cached) {
|
|
110
208
|
onUpdate({ data: cached.value, fromCache: true });
|
|
111
209
|
if (isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
@@ -113,9 +211,16 @@ async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate
|
|
|
113
211
|
}
|
|
114
212
|
}
|
|
115
213
|
try {
|
|
116
|
-
const fresh = await loadSharedFreshResource(key, loadFresh);
|
|
214
|
+
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
117
215
|
onUpdate({ data: fresh, fromCache: false });
|
|
118
|
-
void
|
|
216
|
+
void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
|
|
217
|
+
const normalizedError = normalizeError(error);
|
|
218
|
+
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
219
|
+
error: normalizedError,
|
|
220
|
+
key,
|
|
221
|
+
});
|
|
222
|
+
onCacheWriteError?.(normalizedError);
|
|
223
|
+
});
|
|
119
224
|
return fresh;
|
|
120
225
|
}
|
|
121
226
|
catch (error) {
|
|
@@ -134,9 +239,13 @@ function createNoopReadCacheDriver() {
|
|
|
134
239
|
return {
|
|
135
240
|
async clear() { },
|
|
136
241
|
async close() { },
|
|
242
|
+
async delete() { },
|
|
137
243
|
async get() {
|
|
138
244
|
return null;
|
|
139
245
|
},
|
|
246
|
+
async listEntries() {
|
|
247
|
+
return [];
|
|
248
|
+
},
|
|
140
249
|
async set() { },
|
|
141
250
|
};
|
|
142
251
|
}
|
|
@@ -147,22 +256,32 @@ function isCacheFresh(savedAt, maxAgeMs) {
|
|
|
147
256
|
const savedTime = Date.parse(savedAt);
|
|
148
257
|
return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
|
|
149
258
|
}
|
|
150
|
-
async function loadSharedFreshResource(key, loadFresh) {
|
|
151
|
-
const
|
|
259
|
+
async function loadSharedFreshResource(cache, key, loadFresh) {
|
|
260
|
+
const pendingLoads = getPendingFreshLoads(cache);
|
|
261
|
+
const pendingLoad = pendingLoads.get(key);
|
|
152
262
|
if (pendingLoad) {
|
|
153
263
|
return (await pendingLoad);
|
|
154
264
|
}
|
|
155
265
|
const nextLoad = loadFresh();
|
|
156
|
-
|
|
266
|
+
pendingLoads.set(key, nextLoad);
|
|
157
267
|
try {
|
|
158
268
|
return await nextLoad;
|
|
159
269
|
}
|
|
160
270
|
finally {
|
|
161
|
-
if (
|
|
162
|
-
|
|
271
|
+
if (pendingLoads.get(key) === nextLoad) {
|
|
272
|
+
pendingLoads.delete(key);
|
|
163
273
|
}
|
|
164
274
|
}
|
|
165
275
|
}
|
|
276
|
+
function getPendingFreshLoads(cache) {
|
|
277
|
+
const existing = pendingFreshLoads.get(cache);
|
|
278
|
+
if (existing) {
|
|
279
|
+
return existing;
|
|
280
|
+
}
|
|
281
|
+
const next = new Map();
|
|
282
|
+
pendingFreshLoads.set(cache, next);
|
|
283
|
+
return next;
|
|
284
|
+
}
|
|
166
285
|
function toReadCacheEnvelope(value) {
|
|
167
286
|
if (!value || typeof value !== "object") {
|
|
168
287
|
return null;
|
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.3.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",
|
|
@@ -46,8 +46,10 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "tsc -p tsconfig.json && node scripts/write-esm-extensions.mjs && tsc -p tsconfig.cjs.json && node scripts/write-cjs.mjs",
|
|
48
48
|
"lint": "tsc --noEmit -p tsconfig.json",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
49
|
+
"pack:smoke": "corepack pnpm run build && npm pack --dry-run --access public",
|
|
50
|
+
"prepack": "corepack pnpm run build",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:coverage": "vitest run --coverage"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
55
|
"idb": "^8.0.3"
|