@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/pendingWrites.js
CHANGED
|
@@ -1,19 +1,54 @@
|
|
|
1
1
|
import { createIdbKeyValueDriver, } from "./idbDriver.js";
|
|
2
2
|
export const pendingWriteSchemaVersion = 1;
|
|
3
3
|
export const pendingWriteOperationTimeoutMs = 2_000;
|
|
4
|
+
export const pendingWriteSyncLeaseTimeoutMs = 60_000;
|
|
4
5
|
export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = pendingWriteSchemaVersion, }) {
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
const notify = (change) => {
|
|
8
|
+
for (const listener of listeners) {
|
|
9
|
+
try {
|
|
10
|
+
listener(change);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
console.error("[Bayon offline] Pending-write store listener failed.", {
|
|
14
|
+
error,
|
|
15
|
+
type: change.type,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
5
20
|
return {
|
|
21
|
+
async claimIfSnapshotId(id, snapshotId, record, now) {
|
|
22
|
+
const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
|
|
23
|
+
const current = toPendingWriteEnvelope(value, schemaVersion, {
|
|
24
|
+
normalizeLegacyRecord,
|
|
25
|
+
normalizePayload,
|
|
26
|
+
})?.record;
|
|
27
|
+
return (current?.snapshotId === snapshotId &&
|
|
28
|
+
isPendingWriteSyncClaimAvailable(current, now));
|
|
29
|
+
});
|
|
30
|
+
if (claimed) {
|
|
31
|
+
notify({ record, type: "replace" });
|
|
32
|
+
}
|
|
33
|
+
return claimed;
|
|
34
|
+
},
|
|
6
35
|
async clear(id) {
|
|
7
36
|
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
37
|
+
notify({ id, type: "clear" });
|
|
8
38
|
},
|
|
9
39
|
async clearAll() {
|
|
10
40
|
await driver.clearAll();
|
|
41
|
+
notify({ type: "clearAll" });
|
|
11
42
|
},
|
|
12
43
|
async clearIfSnapshotId(id, snapshotId) {
|
|
13
|
-
|
|
44
|
+
const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
14
45
|
normalizeLegacyRecord,
|
|
15
46
|
normalizePayload,
|
|
16
47
|
})?.record.snapshotId === snapshotId);
|
|
48
|
+
if (cleared) {
|
|
49
|
+
notify({ id, type: "clear" });
|
|
50
|
+
}
|
|
51
|
+
return cleared;
|
|
17
52
|
},
|
|
18
53
|
async close() {
|
|
19
54
|
await driver.close?.();
|
|
@@ -28,13 +63,18 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
|
|
|
28
63
|
normalizePayload,
|
|
29
64
|
})?.record)
|
|
30
65
|
.filter((record) => Boolean(record))
|
|
31
|
-
.sort((left, right) => left.savedAt.localeCompare(right.savedAt)
|
|
66
|
+
.sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
67
|
+
left.id.localeCompare(right.id));
|
|
32
68
|
},
|
|
33
69
|
async replaceIfSnapshotId(id, snapshotId, record) {
|
|
34
|
-
|
|
70
|
+
const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
|
|
35
71
|
normalizeLegacyRecord,
|
|
36
72
|
normalizePayload,
|
|
37
73
|
})?.record.snapshotId === snapshotId);
|
|
74
|
+
if (replaced) {
|
|
75
|
+
notify({ record, type: "replace" });
|
|
76
|
+
}
|
|
77
|
+
return replaced;
|
|
38
78
|
},
|
|
39
79
|
async set(record) {
|
|
40
80
|
const storedRecord = {
|
|
@@ -45,8 +85,13 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
|
|
|
45
85
|
record: storedRecord,
|
|
46
86
|
schemaVersion,
|
|
47
87
|
});
|
|
88
|
+
notify({ record: storedRecord, type: "set" });
|
|
48
89
|
return storedRecord;
|
|
49
90
|
},
|
|
91
|
+
subscribe(listener) {
|
|
92
|
+
listeners.add(listener);
|
|
93
|
+
return () => listeners.delete(listener);
|
|
94
|
+
},
|
|
50
95
|
};
|
|
51
96
|
}
|
|
52
97
|
export function createMemoryPendingWriteDriver() {
|
|
@@ -111,34 +156,193 @@ export function createPendingWriteKey({ id, prefix }) {
|
|
|
111
156
|
export function createPendingWriteSnapshotId() {
|
|
112
157
|
return globalThis.crypto.randomUUID();
|
|
113
158
|
}
|
|
114
|
-
export async function syncPendingWrite({ id, store, sync, }) {
|
|
159
|
+
export async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
|
|
115
160
|
const pending = await store.get(id);
|
|
116
161
|
if (!pending) {
|
|
117
162
|
return { status: "idle" };
|
|
118
163
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
164
|
+
const claimedSnapshotId = createPendingWriteSnapshotId();
|
|
165
|
+
const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
|
|
166
|
+
...pending,
|
|
167
|
+
error: undefined,
|
|
168
|
+
snapshotId: claimedSnapshotId,
|
|
169
|
+
syncingAt: now(),
|
|
170
|
+
syncStatus: "syncing",
|
|
171
|
+
}, now());
|
|
172
|
+
if (!claimed) {
|
|
173
|
+
return { status: "superseded" };
|
|
174
|
+
}
|
|
125
175
|
try {
|
|
126
176
|
await sync(pending.payload);
|
|
127
177
|
}
|
|
128
178
|
catch (error) {
|
|
129
179
|
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
130
|
-
|
|
180
|
+
const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
|
|
181
|
+
const markedFailed = await markPendingSnapshot({
|
|
131
182
|
id,
|
|
132
|
-
nextRecord: {
|
|
133
|
-
|
|
183
|
+
nextRecord: {
|
|
184
|
+
...pendingWithoutSyncClaim,
|
|
185
|
+
error: message,
|
|
186
|
+
snapshotId: claimedSnapshotId,
|
|
187
|
+
syncStatus: "failed",
|
|
188
|
+
},
|
|
189
|
+
snapshotId: claimedSnapshotId,
|
|
134
190
|
store,
|
|
135
191
|
});
|
|
136
|
-
return
|
|
192
|
+
return markedFailed
|
|
193
|
+
? { error: message, status: "failed" }
|
|
194
|
+
: { status: "superseded" };
|
|
137
195
|
}
|
|
138
|
-
return (await store.clearIfSnapshotId(id,
|
|
196
|
+
return (await store.clearIfSnapshotId(id, claimedSnapshotId))
|
|
139
197
|
? { status: "synced" }
|
|
140
198
|
: { status: "superseded" };
|
|
141
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Replays selected writes oldest first and coalesces overlapping runs for the
|
|
202
|
+
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
203
|
+
* mutation semantics.
|
|
204
|
+
*/
|
|
205
|
+
export function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
206
|
+
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
207
|
+
if (!inFlightByScope) {
|
|
208
|
+
inFlightByScope = new Map();
|
|
209
|
+
pendingWriteSyncs.set(store, inFlightByScope);
|
|
210
|
+
}
|
|
211
|
+
const existing = inFlightByScope.get(scopeKey);
|
|
212
|
+
if (existing) {
|
|
213
|
+
return existing;
|
|
214
|
+
}
|
|
215
|
+
const syncRun = performPendingWriteSync({
|
|
216
|
+
select,
|
|
217
|
+
stopOnFailure,
|
|
218
|
+
store,
|
|
219
|
+
sync,
|
|
220
|
+
});
|
|
221
|
+
inFlightByScope.set(scopeKey, syncRun);
|
|
222
|
+
const clearInFlight = () => {
|
|
223
|
+
inFlightByScope.delete(scopeKey);
|
|
224
|
+
};
|
|
225
|
+
void syncRun.then(clearInFlight, clearInFlight);
|
|
226
|
+
return syncRun;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Attempts a remote write now and persists a caller-created durable fallback
|
|
230
|
+
* only when offline or when the supplied retry classifier accepts the error.
|
|
231
|
+
*/
|
|
232
|
+
export async function runPendingWrite({ enqueue, isOnline, shouldQueue, write, }) {
|
|
233
|
+
if (!isOnline()) {
|
|
234
|
+
return { queued: await enqueue(), status: "queued" };
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return { result: await write(), status: "synced" };
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (!shouldQueue(error)) {
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
return { queued: await enqueue(), status: "queued" };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export function summarizePendingWrites(records) {
|
|
247
|
+
return records.reduce((summary, record) => {
|
|
248
|
+
summary.totalCount += 1;
|
|
249
|
+
if (record.syncStatus === "failed") {
|
|
250
|
+
summary.failedCount += 1;
|
|
251
|
+
}
|
|
252
|
+
else if (record.syncStatus === "syncing") {
|
|
253
|
+
summary.syncingCount += 1;
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
summary.pendingCount += 1;
|
|
257
|
+
}
|
|
258
|
+
return summary;
|
|
259
|
+
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
260
|
+
}
|
|
261
|
+
const pendingWriteSyncs = new WeakMap();
|
|
262
|
+
async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
|
|
263
|
+
const summary = {
|
|
264
|
+
attempted: 0,
|
|
265
|
+
failed: 0,
|
|
266
|
+
idle: 0,
|
|
267
|
+
remaining: 0,
|
|
268
|
+
superseded: 0,
|
|
269
|
+
synced: 0,
|
|
270
|
+
};
|
|
271
|
+
const records = (await store.list()).filter(select);
|
|
272
|
+
for (const record of records) {
|
|
273
|
+
const result = await syncPendingWrite({
|
|
274
|
+
id: record.id,
|
|
275
|
+
store,
|
|
276
|
+
sync,
|
|
277
|
+
});
|
|
278
|
+
summary.attempted += 1;
|
|
279
|
+
summary[result.status] += 1;
|
|
280
|
+
if (stopOnFailure && result.status === "failed") {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
summary.remaining = (await store.list()).filter(select).length;
|
|
285
|
+
return summary;
|
|
286
|
+
}
|
|
287
|
+
function isPendingWriteSyncClaimAvailable(record, now) {
|
|
288
|
+
if (record.syncStatus !== "syncing") {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
if (!record.syncingAt) {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
const startedAt = Date.parse(record.syncingAt);
|
|
295
|
+
const currentTime = Date.parse(now);
|
|
296
|
+
return (!Number.isFinite(startedAt) ||
|
|
297
|
+
!Number.isFinite(currentTime) ||
|
|
298
|
+
currentTime - startedAt >= pendingWriteSyncLeaseTimeoutMs);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Creates a serial execution lane for a durable scope such as one user or
|
|
302
|
+
* workspace. Use its `recover` method for background replay and `run` for new
|
|
303
|
+
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
304
|
+
*/
|
|
305
|
+
export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
306
|
+
let execution = Promise.resolve();
|
|
307
|
+
function inOrder(operation) {
|
|
308
|
+
const result = execution.then(operation, operation);
|
|
309
|
+
execution = result.then(() => undefined, () => undefined);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
function recover() {
|
|
313
|
+
return inOrder(() => syncPendingWrites({
|
|
314
|
+
scopeKey,
|
|
315
|
+
select,
|
|
316
|
+
stopOnFailure: true,
|
|
317
|
+
store,
|
|
318
|
+
sync,
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
recover,
|
|
323
|
+
run(options) {
|
|
324
|
+
return inOrder(async () => {
|
|
325
|
+
if (!options.isOnline()) {
|
|
326
|
+
return runPendingWrite(options);
|
|
327
|
+
}
|
|
328
|
+
const replay = await syncPendingWrites({
|
|
329
|
+
scopeKey,
|
|
330
|
+
select,
|
|
331
|
+
stopOnFailure: true,
|
|
332
|
+
store,
|
|
333
|
+
sync,
|
|
334
|
+
});
|
|
335
|
+
// Product decision: unresolved older records retain their position in
|
|
336
|
+
// this scope. Queue the later write rather than letting it overtake a
|
|
337
|
+
// replay whose remote effect may be ambiguous.
|
|
338
|
+
if (replay.remaining > 0) {
|
|
339
|
+
return { queued: await options.enqueue(), status: "queued" };
|
|
340
|
+
}
|
|
341
|
+
return runPendingWrite(options);
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
142
346
|
function createNoopPendingWriteDriver() {
|
|
143
347
|
return {
|
|
144
348
|
async clear() { },
|
|
@@ -198,6 +402,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
198
402
|
payload,
|
|
199
403
|
savedAt: record.savedAt,
|
|
200
404
|
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
405
|
+
...(typeof record.syncingAt === "string"
|
|
406
|
+
? { syncingAt: record.syncingAt }
|
|
407
|
+
: {}),
|
|
201
408
|
syncStatus: record.syncStatus,
|
|
202
409
|
};
|
|
203
410
|
}
|
|
@@ -223,6 +430,7 @@ function isPendingWriteRecord(value) {
|
|
|
223
430
|
"savedAt" in value &&
|
|
224
431
|
typeof value.savedAt === "string" &&
|
|
225
432
|
(!("snapshotId" in value) || typeof value.snapshotId === "string") &&
|
|
433
|
+
(!("syncingAt" in value) || typeof value.syncingAt === "string") &&
|
|
226
434
|
"syncStatus" in value &&
|
|
227
435
|
(value.syncStatus === "pending" ||
|
|
228
436
|
value.syncStatus === "syncing" ||
|
package/dist/readCache.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ export type ReadCacheDriver = {
|
|
|
14
14
|
close?: () => Promise<void>;
|
|
15
15
|
delete: (key: string) => Promise<void>;
|
|
16
16
|
get: (key: string) => Promise<unknown | null>;
|
|
17
|
+
listEntries?: () => Promise<Array<{
|
|
18
|
+
key: string;
|
|
19
|
+
value: unknown;
|
|
20
|
+
}>>;
|
|
17
21
|
set: (key: string, value: unknown) => Promise<void>;
|
|
18
22
|
};
|
|
19
23
|
export type ReadCache = {
|
|
@@ -55,9 +59,23 @@ export type ReadCacheKeyOptions = {
|
|
|
55
59
|
export declare function createReadCache(driver: ReadCacheDriver): ReadCache;
|
|
56
60
|
export declare function createMemoryReadCacheDriver(): ReadCacheDriver;
|
|
57
61
|
export declare function createBrowserReadCache({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserReadCacheOptions): ReadCache;
|
|
62
|
+
/**
|
|
63
|
+
* Deletes only valid read-cache envelopes older than the supplied age. It is
|
|
64
|
+
* opt-in and returns zero for custom cache adapters that cannot enumerate keys.
|
|
65
|
+
*/
|
|
66
|
+
export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
|
|
67
|
+
cache: ReadCache;
|
|
68
|
+
maxAgeMs: number;
|
|
69
|
+
now?: () => number;
|
|
70
|
+
}): Promise<number>;
|
|
58
71
|
export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
|
|
59
|
-
export declare function readThroughCache<T>({ cache, key, loadFresh, maxAgeMs, }: {
|
|
72
|
+
export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, }: {
|
|
60
73
|
cache: ReadCache;
|
|
74
|
+
/**
|
|
75
|
+
* Invalidates the current value before loading. Use after a successful write
|
|
76
|
+
* when a cached or earlier in-flight response must not become authoritative.
|
|
77
|
+
*/
|
|
78
|
+
forceFresh?: boolean;
|
|
61
79
|
key: string;
|
|
62
80
|
loadFresh: () => Promise<T>;
|
|
63
81
|
maxAgeMs?: number;
|
package/dist/readCache.js
CHANGED
|
@@ -5,6 +5,7 @@ export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
|
|
|
5
5
|
export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
|
|
6
6
|
export const browserReadCacheOperationTimeoutMs = 2_000;
|
|
7
7
|
const pendingFreshLoads = new WeakMap();
|
|
8
|
+
const cachePruners = new WeakMap();
|
|
8
9
|
export function createReadCache(driver) {
|
|
9
10
|
let globalVersion = 0;
|
|
10
11
|
const keyVersions = new Map();
|
|
@@ -69,6 +70,21 @@ export function createReadCache(driver) {
|
|
|
69
70
|
},
|
|
70
71
|
};
|
|
71
72
|
pendingFreshLoads.set(cache, pendingLoads);
|
|
73
|
+
cachePruners.set(cache, async ({ maxAgeMs, now }) => {
|
|
74
|
+
if (!driver.listEntries || maxAgeMs < 0) {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const cutoff = now() - maxAgeMs;
|
|
78
|
+
const expiredKeys = (await driver.listEntries())
|
|
79
|
+
.filter(({ value }) => {
|
|
80
|
+
const envelope = toReadCacheEnvelope(value);
|
|
81
|
+
const savedAt = envelope ? Date.parse(envelope.savedAt) : Number.NaN;
|
|
82
|
+
return Number.isFinite(savedAt) && savedAt < cutoff;
|
|
83
|
+
})
|
|
84
|
+
.map(({ key }) => key);
|
|
85
|
+
await Promise.all(expiredKeys.map((key) => cache.delete(key)));
|
|
86
|
+
return expiredKeys.length;
|
|
87
|
+
});
|
|
72
88
|
return cache;
|
|
73
89
|
}
|
|
74
90
|
export function createMemoryReadCacheDriver() {
|
|
@@ -84,6 +100,9 @@ export function createMemoryReadCacheDriver() {
|
|
|
84
100
|
async get(key) {
|
|
85
101
|
return records.get(key) ?? null;
|
|
86
102
|
},
|
|
103
|
+
async listEntries() {
|
|
104
|
+
return [...records.entries()].map(([key, value]) => ({ key, value }));
|
|
105
|
+
},
|
|
87
106
|
async set(key, value) {
|
|
88
107
|
records.set(key, value);
|
|
89
108
|
},
|
|
@@ -97,7 +116,25 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
|
|
|
97
116
|
operationTimeoutMs,
|
|
98
117
|
storeName,
|
|
99
118
|
});
|
|
100
|
-
return createReadCache(driver
|
|
119
|
+
return createReadCache(driver
|
|
120
|
+
? {
|
|
121
|
+
clear: driver.clear,
|
|
122
|
+
close: driver.close,
|
|
123
|
+
delete: driver.delete,
|
|
124
|
+
get: driver.get,
|
|
125
|
+
listEntries: driver.listEntries,
|
|
126
|
+
set: driver.set,
|
|
127
|
+
}
|
|
128
|
+
: createNoopReadCacheDriver());
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Deletes only valid read-cache envelopes older than the supplied age. It is
|
|
132
|
+
* opt-in and returns zero for custom cache adapters that cannot enumerate keys.
|
|
133
|
+
*/
|
|
134
|
+
export function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
|
|
135
|
+
const resourceCache = cache.capture?.() ?? cache;
|
|
136
|
+
return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
|
|
137
|
+
Promise.resolve(0);
|
|
101
138
|
}
|
|
102
139
|
export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
103
140
|
const serializedParams = Object.entries(params)
|
|
@@ -114,8 +151,13 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
114
151
|
.filter(Boolean)
|
|
115
152
|
.join(":");
|
|
116
153
|
}
|
|
117
|
-
export async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
|
|
154
|
+
export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
|
|
118
155
|
const resourceCache = cache.capture?.() ?? cache;
|
|
156
|
+
if (forceFresh) {
|
|
157
|
+
// Deletion advances the cache version, so a request started before this
|
|
158
|
+
// post-write refresh cannot restore stale data after it finishes.
|
|
159
|
+
await resourceCache.delete(key);
|
|
160
|
+
}
|
|
119
161
|
const version = resourceCache.getVersion(key);
|
|
120
162
|
const cached = await resourceCache.get(key);
|
|
121
163
|
if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
@@ -127,7 +169,7 @@ export async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
|
|
|
127
169
|
};
|
|
128
170
|
}
|
|
129
171
|
try {
|
|
130
|
-
const fresh = await loadFresh
|
|
172
|
+
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
131
173
|
await resourceCache.setIfVersion(key, fresh, version);
|
|
132
174
|
return {
|
|
133
175
|
data: fresh,
|
|
@@ -191,6 +233,9 @@ function createNoopReadCacheDriver() {
|
|
|
191
233
|
async get() {
|
|
192
234
|
return null;
|
|
193
235
|
},
|
|
236
|
+
async listEntries() {
|
|
237
|
+
return [];
|
|
238
|
+
},
|
|
194
239
|
async set() { },
|
|
195
240
|
};
|
|
196
241
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createBrowserPendingWriteRecoveryCoordination = createBrowserPendingWriteRecoveryCoordination;
|
|
4
|
+
function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel = createDefaultChannel(channelName), locks = readDefaultLockManager(), }) {
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
const handleMessage = () => {
|
|
7
|
+
for (const listener of listeners) {
|
|
8
|
+
try {
|
|
9
|
+
listener();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
console.error("[Bayon offline] Cross-tab recovery listener failed.", {
|
|
13
|
+
channelName,
|
|
14
|
+
error,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
channel?.addEventListener("message", handleMessage);
|
|
20
|
+
return {
|
|
21
|
+
close() {
|
|
22
|
+
channel?.removeEventListener("message", handleMessage);
|
|
23
|
+
channel?.close();
|
|
24
|
+
listeners.clear();
|
|
25
|
+
},
|
|
26
|
+
notify() {
|
|
27
|
+
try {
|
|
28
|
+
channel?.postMessage("pending-write-recovery");
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
console.error("[Bayon offline] Unable to wake cross-tab recovery.", {
|
|
32
|
+
channelName,
|
|
33
|
+
error,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
async runExclusive(attempt) {
|
|
38
|
+
if (!locks) {
|
|
39
|
+
return attempt();
|
|
40
|
+
}
|
|
41
|
+
return locks.request(leaseName, { ifAvailable: true }, (lock) => lock ? attempt() : Promise.resolve(null));
|
|
42
|
+
},
|
|
43
|
+
subscribe(listener) {
|
|
44
|
+
listeners.add(listener);
|
|
45
|
+
return () => listeners.delete(listener);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function createDefaultChannel(name) {
|
|
50
|
+
if (typeof BroadcastChannel === "undefined") {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return new BroadcastChannel(name);
|
|
54
|
+
}
|
|
55
|
+
function readDefaultLockManager() {
|
|
56
|
+
if (typeof navigator === "undefined") {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return navigator.locks ?? null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.maxPendingWriteRetryDelayMs = exports.initialPendingWriteRetryDelayMs = void 0;
|
|
4
|
+
exports.createBrowserPendingWriteRecovery = createBrowserPendingWriteRecovery;
|
|
5
|
+
const pendingWrites_1 = require("./pendingWrites");
|
|
6
|
+
exports.initialPendingWriteRetryDelayMs = 2_000;
|
|
7
|
+
exports.maxPendingWriteRetryDelayMs = 60_000;
|
|
8
|
+
function createBrowserPendingWriteRecovery(options) {
|
|
9
|
+
const { environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = exports.initialPendingWriteRetryDelayMs, maxRetryDelayMs = exports.maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, } = options;
|
|
10
|
+
let active = false;
|
|
11
|
+
let retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
12
|
+
let retryTimer;
|
|
13
|
+
let recoveryPromise;
|
|
14
|
+
let immediateRecoveryRequested = false;
|
|
15
|
+
let unsubscribePeerWakeups;
|
|
16
|
+
let unsubscribe;
|
|
17
|
+
function clearRetryTimer() {
|
|
18
|
+
if (retryTimer) {
|
|
19
|
+
environment.clearTimeout(retryTimer);
|
|
20
|
+
}
|
|
21
|
+
retryTimer = undefined;
|
|
22
|
+
}
|
|
23
|
+
function emitResult(result) {
|
|
24
|
+
try {
|
|
25
|
+
onResult?.(result);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.error("[Bayon offline] Pending-write recovery result listener failed.", {
|
|
29
|
+
error,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function emitError(error) {
|
|
34
|
+
console.error("[Bayon offline] Pending-write recovery failed.", {
|
|
35
|
+
error,
|
|
36
|
+
scopeKey,
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
onError?.(error);
|
|
40
|
+
}
|
|
41
|
+
catch (callbackError) {
|
|
42
|
+
console.error("[Bayon offline] Pending-write recovery error listener failed.", {
|
|
43
|
+
callbackError,
|
|
44
|
+
scopeKey,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function scheduleRetry() {
|
|
49
|
+
if (!active || !environment.isOnline() || retryTimer) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
retryTimer = environment.setTimeout(() => {
|
|
53
|
+
retryTimer = undefined;
|
|
54
|
+
void recoverNow();
|
|
55
|
+
}, retryDelayMs);
|
|
56
|
+
retryDelayMs = Math.min(Math.max(initialRetryDelayMs, retryDelayMs * 2), Math.max(initialRetryDelayMs, maxRetryDelayMs));
|
|
57
|
+
}
|
|
58
|
+
function recoverNow() {
|
|
59
|
+
if (!environment.isOnline()) {
|
|
60
|
+
clearRetryTimer();
|
|
61
|
+
return Promise.resolve(null);
|
|
62
|
+
}
|
|
63
|
+
if (recoveryPromise) {
|
|
64
|
+
return recoveryPromise;
|
|
65
|
+
}
|
|
66
|
+
const attempt = (async () => {
|
|
67
|
+
try {
|
|
68
|
+
const run = () => options.recover
|
|
69
|
+
? options.recover()
|
|
70
|
+
: (0, pendingWrites_1.syncPendingWrites)({
|
|
71
|
+
scopeKey,
|
|
72
|
+
select,
|
|
73
|
+
store,
|
|
74
|
+
sync: options.sync,
|
|
75
|
+
});
|
|
76
|
+
const result = coordination
|
|
77
|
+
? await coordination.runExclusive(run)
|
|
78
|
+
: await run();
|
|
79
|
+
if (!result) {
|
|
80
|
+
// Another tab currently owns the lease. It may disappear before
|
|
81
|
+
// completion, so active recovery must retry rather than go dormant.
|
|
82
|
+
scheduleRetry();
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
emitResult(result);
|
|
86
|
+
if (result.remaining > 0) {
|
|
87
|
+
scheduleRetry();
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
emitError(error);
|
|
96
|
+
scheduleRetry();
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
recoveryPromise = attempt.finally(() => {
|
|
101
|
+
recoveryPromise = undefined;
|
|
102
|
+
if (active && immediateRecoveryRequested) {
|
|
103
|
+
immediateRecoveryRequested = false;
|
|
104
|
+
triggerImmediateRecovery();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return recoveryPromise;
|
|
108
|
+
}
|
|
109
|
+
function triggerImmediateRecovery() {
|
|
110
|
+
if (!active) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
immediateRecoveryRequested ||= Boolean(recoveryPromise);
|
|
114
|
+
clearRetryTimer();
|
|
115
|
+
retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
116
|
+
void recoverNow();
|
|
117
|
+
}
|
|
118
|
+
function handleOffline() {
|
|
119
|
+
clearRetryTimer();
|
|
120
|
+
}
|
|
121
|
+
function handleStoreChange(change) {
|
|
122
|
+
if (change.type === "set") {
|
|
123
|
+
coordination?.notify();
|
|
124
|
+
triggerImmediateRecovery();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
notifyQueued() {
|
|
129
|
+
coordination?.notify();
|
|
130
|
+
triggerImmediateRecovery();
|
|
131
|
+
},
|
|
132
|
+
recoverNow,
|
|
133
|
+
start() {
|
|
134
|
+
if (active) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
active = true;
|
|
138
|
+
unsubscribe = store.subscribe(handleStoreChange);
|
|
139
|
+
unsubscribePeerWakeups = coordination?.subscribe(triggerImmediateRecovery);
|
|
140
|
+
environment.addEventListener("focus", triggerImmediateRecovery);
|
|
141
|
+
environment.addEventListener("online", triggerImmediateRecovery);
|
|
142
|
+
environment.addEventListener("offline", handleOffline);
|
|
143
|
+
if (environment.isOnline()) {
|
|
144
|
+
triggerImmediateRecovery();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
stop() {
|
|
148
|
+
if (!active) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
active = false;
|
|
152
|
+
clearRetryTimer();
|
|
153
|
+
unsubscribe?.();
|
|
154
|
+
unsubscribe = undefined;
|
|
155
|
+
unsubscribePeerWakeups?.();
|
|
156
|
+
unsubscribePeerWakeups = undefined;
|
|
157
|
+
environment.removeEventListener("focus", triggerImmediateRecovery);
|
|
158
|
+
environment.removeEventListener("online", triggerImmediateRecovery);
|
|
159
|
+
environment.removeEventListener("offline", handleOffline);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function createDefaultBrowserRecoveryEnvironment() {
|
|
164
|
+
const target = typeof window === "undefined" ? null : window;
|
|
165
|
+
return {
|
|
166
|
+
addEventListener(type, listener) {
|
|
167
|
+
target?.addEventListener(type, listener);
|
|
168
|
+
},
|
|
169
|
+
clearTimeout(timer) {
|
|
170
|
+
globalThis.clearTimeout(timer);
|
|
171
|
+
},
|
|
172
|
+
isOnline() {
|
|
173
|
+
return typeof navigator !== "undefined" && navigator.onLine;
|
|
174
|
+
},
|
|
175
|
+
removeEventListener(type, listener) {
|
|
176
|
+
target?.removeEventListener(type, listener);
|
|
177
|
+
},
|
|
178
|
+
setTimeout(callback, delayMs) {
|
|
179
|
+
return globalThis.setTimeout(callback, delayMs);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|