@bayonai/offline 0.1.1 → 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 +21 -0
- package/README.md +82 -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 +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 +64 -1
- package/dist/pendingWrites.js +112 -3
- 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 +176 -0
- package/dist-cjs/idbDriver.js +24 -0
- package/dist-cjs/index.js +2 -0
- package/dist-cjs/pendingWrites.js +115 -3
- package/dist-cjs/readCache.js +49 -3
- package/package.json +2 -2
|
@@ -8,22 +8,45 @@ 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;
|
|
11
14
|
const idbDriver_1 = require("./idbDriver");
|
|
12
15
|
exports.pendingWriteSchemaVersion = 1;
|
|
13
16
|
exports.pendingWriteOperationTimeoutMs = 2_000;
|
|
14
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
|
+
};
|
|
15
32
|
return {
|
|
16
33
|
async clear(id) {
|
|
17
34
|
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
35
|
+
notify({ id, type: "clear" });
|
|
18
36
|
},
|
|
19
37
|
async clearAll() {
|
|
20
38
|
await driver.clearAll();
|
|
39
|
+
notify({ type: "clearAll" });
|
|
21
40
|
},
|
|
22
41
|
async clearIfSnapshotId(id, snapshotId) {
|
|
23
|
-
|
|
42
|
+
const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
24
43
|
normalizeLegacyRecord,
|
|
25
44
|
normalizePayload,
|
|
26
45
|
})?.record.snapshotId === snapshotId);
|
|
46
|
+
if (cleared) {
|
|
47
|
+
notify({ id, type: "clear" });
|
|
48
|
+
}
|
|
49
|
+
return cleared;
|
|
27
50
|
},
|
|
28
51
|
async close() {
|
|
29
52
|
await driver.close?.();
|
|
@@ -38,13 +61,18 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
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));
|
|
42
66
|
},
|
|
43
67
|
async replaceIfSnapshotId(id, snapshotId, record) {
|
|
44
|
-
|
|
68
|
+
const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
45
69
|
normalizeLegacyRecord,
|
|
46
70
|
normalizePayload,
|
|
47
71
|
})?.record.snapshotId === snapshotId);
|
|
72
|
+
if (replaced) {
|
|
73
|
+
notify({ record, type: "replace" });
|
|
74
|
+
}
|
|
75
|
+
return replaced;
|
|
48
76
|
},
|
|
49
77
|
async set(record) {
|
|
50
78
|
const storedRecord = {
|
|
@@ -55,8 +83,13 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
55
83
|
record: storedRecord,
|
|
56
84
|
schemaVersion,
|
|
57
85
|
});
|
|
86
|
+
notify({ record: storedRecord, type: "set" });
|
|
58
87
|
return storedRecord;
|
|
59
88
|
},
|
|
89
|
+
subscribe(listener) {
|
|
90
|
+
listeners.add(listener);
|
|
91
|
+
return () => listeners.delete(listener);
|
|
92
|
+
},
|
|
60
93
|
};
|
|
61
94
|
}
|
|
62
95
|
function createMemoryPendingWriteDriver() {
|
|
@@ -149,6 +182,85 @@ async function syncPendingWrite({ id, store, sync, }) {
|
|
|
149
182
|
? { status: "synced" }
|
|
150
183
|
: { status: "superseded" };
|
|
151
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
|
+
}
|
|
152
264
|
function createNoopPendingWriteDriver() {
|
|
153
265
|
return {
|
|
154
266
|
async clear() { },
|
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.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",
|