@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
package/dist/readCache.js
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
import { createIdbKeyValueDriver, } from "./idbDriver.js";
|
|
2
2
|
export const readCacheSchemaVersion = 1;
|
|
3
3
|
export const readCacheFreshnessMaxAgeMs = 30_000;
|
|
4
|
+
export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
|
|
5
|
+
export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
|
|
4
6
|
export const browserReadCacheOperationTimeoutMs = 2_000;
|
|
5
|
-
const pendingFreshLoads = new
|
|
7
|
+
const pendingFreshLoads = new WeakMap();
|
|
8
|
+
const cachePruners = new WeakMap();
|
|
6
9
|
export function createReadCache(driver) {
|
|
7
|
-
|
|
10
|
+
let globalVersion = 0;
|
|
11
|
+
const keyVersions = new Map();
|
|
12
|
+
let operationQueue = Promise.resolve();
|
|
13
|
+
const pendingLoads = new Map();
|
|
14
|
+
function getVersion(key) {
|
|
15
|
+
return `${globalVersion}:${keyVersions.get(key) ?? 0}`;
|
|
16
|
+
}
|
|
17
|
+
function enqueue(operation) {
|
|
18
|
+
const next = operationQueue.then(operation, operation);
|
|
19
|
+
operationQueue = next.then(() => undefined, () => undefined);
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
const cache = {
|
|
8
23
|
async clear() {
|
|
9
|
-
|
|
10
|
-
|
|
24
|
+
pendingLoads.clear();
|
|
25
|
+
globalVersion += 1;
|
|
26
|
+
keyVersions.clear();
|
|
27
|
+
await enqueue(() => driver.clear());
|
|
11
28
|
},
|
|
12
29
|
async close() {
|
|
13
|
-
await driver.close?.();
|
|
30
|
+
await enqueue(async () => driver.close?.());
|
|
31
|
+
},
|
|
32
|
+
async delete(key) {
|
|
33
|
+
pendingLoads.delete(key);
|
|
34
|
+
keyVersions.set(key, (keyVersions.get(key) ?? 0) + 1);
|
|
35
|
+
await enqueue(() => driver.delete(key));
|
|
14
36
|
},
|
|
15
37
|
async get(key) {
|
|
16
38
|
const envelope = toReadCacheEnvelope(await driver.get(key));
|
|
@@ -22,14 +44,48 @@ export function createReadCache(driver) {
|
|
|
22
44
|
}
|
|
23
45
|
: null;
|
|
24
46
|
},
|
|
47
|
+
getVersion,
|
|
25
48
|
async set(key, value) {
|
|
26
|
-
await driver.set(key, {
|
|
49
|
+
await enqueue(() => driver.set(key, {
|
|
27
50
|
savedAt: new Date().toISOString(),
|
|
28
51
|
schemaVersion: readCacheSchemaVersion,
|
|
29
52
|
value,
|
|
53
|
+
}));
|
|
54
|
+
},
|
|
55
|
+
async setIfVersion(key, value, version) {
|
|
56
|
+
if (getVersion(key) !== version) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return enqueue(async () => {
|
|
60
|
+
if (getVersion(key) !== version) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
await driver.set(key, {
|
|
64
|
+
savedAt: new Date().toISOString(),
|
|
65
|
+
schemaVersion: readCacheSchemaVersion,
|
|
66
|
+
value,
|
|
67
|
+
});
|
|
68
|
+
return true;
|
|
30
69
|
});
|
|
31
70
|
},
|
|
32
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
|
+
});
|
|
88
|
+
return cache;
|
|
33
89
|
}
|
|
34
90
|
export function createMemoryReadCacheDriver() {
|
|
35
91
|
const records = new Map();
|
|
@@ -38,9 +94,15 @@ export function createMemoryReadCacheDriver() {
|
|
|
38
94
|
records.clear();
|
|
39
95
|
},
|
|
40
96
|
async close() { },
|
|
97
|
+
async delete(key) {
|
|
98
|
+
records.delete(key);
|
|
99
|
+
},
|
|
41
100
|
async get(key) {
|
|
42
101
|
return records.get(key) ?? null;
|
|
43
102
|
},
|
|
103
|
+
async listEntries() {
|
|
104
|
+
return [...records.entries()].map(([key, value]) => ({ key, value }));
|
|
105
|
+
},
|
|
44
106
|
async set(key, value) {
|
|
45
107
|
records.set(key, value);
|
|
46
108
|
},
|
|
@@ -54,7 +116,25 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
|
|
|
54
116
|
operationTimeoutMs,
|
|
55
117
|
storeName,
|
|
56
118
|
});
|
|
57
|
-
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);
|
|
58
138
|
}
|
|
59
139
|
export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
60
140
|
const serializedParams = Object.entries(params)
|
|
@@ -71,11 +151,26 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
|
71
151
|
.filter(Boolean)
|
|
72
152
|
.join(":");
|
|
73
153
|
}
|
|
74
|
-
export async function readThroughCache({ cache, key, loadFresh, }) {
|
|
75
|
-
const
|
|
154
|
+
export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
|
|
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
|
+
}
|
|
161
|
+
const version = resourceCache.getVersion(key);
|
|
162
|
+
const cached = await resourceCache.get(key);
|
|
163
|
+
if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
164
|
+
return {
|
|
165
|
+
data: cached.value,
|
|
166
|
+
error: null,
|
|
167
|
+
fromCache: true,
|
|
168
|
+
stale: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
76
171
|
try {
|
|
77
|
-
const fresh = await loadFresh
|
|
78
|
-
await
|
|
172
|
+
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
173
|
+
await resourceCache.setIfVersion(key, fresh, version);
|
|
79
174
|
return {
|
|
80
175
|
data: fresh,
|
|
81
176
|
error: null,
|
|
@@ -95,8 +190,10 @@ export async function readThroughCache({ cache, key, loadFresh, }) {
|
|
|
95
190
|
throw error;
|
|
96
191
|
}
|
|
97
192
|
}
|
|
98
|
-
export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onUpdate, }) {
|
|
99
|
-
const
|
|
193
|
+
export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, onCacheWriteError, onUpdate, skipCache = false, }) {
|
|
194
|
+
const resourceCache = cache.capture?.() ?? cache;
|
|
195
|
+
const version = resourceCache.getVersion(key);
|
|
196
|
+
const cached = skipCache ? null : await resourceCache.get(key);
|
|
100
197
|
if (cached) {
|
|
101
198
|
onUpdate({ data: cached.value, fromCache: true });
|
|
102
199
|
if (isCacheFresh(cached.savedAt, maxAgeMs)) {
|
|
@@ -104,9 +201,16 @@ export async function loadReadCacheResource({ cache, key, loadFresh, maxAgeMs, o
|
|
|
104
201
|
}
|
|
105
202
|
}
|
|
106
203
|
try {
|
|
107
|
-
const fresh = await loadSharedFreshResource(key, loadFresh);
|
|
204
|
+
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
108
205
|
onUpdate({ data: fresh, fromCache: false });
|
|
109
|
-
void
|
|
206
|
+
void resourceCache.setIfVersion(key, fresh, version).catch((error) => {
|
|
207
|
+
const normalizedError = normalizeError(error);
|
|
208
|
+
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
209
|
+
error: normalizedError,
|
|
210
|
+
key,
|
|
211
|
+
});
|
|
212
|
+
onCacheWriteError?.(normalizedError);
|
|
213
|
+
});
|
|
110
214
|
return fresh;
|
|
111
215
|
}
|
|
112
216
|
catch (error) {
|
|
@@ -125,9 +229,13 @@ function createNoopReadCacheDriver() {
|
|
|
125
229
|
return {
|
|
126
230
|
async clear() { },
|
|
127
231
|
async close() { },
|
|
232
|
+
async delete() { },
|
|
128
233
|
async get() {
|
|
129
234
|
return null;
|
|
130
235
|
},
|
|
236
|
+
async listEntries() {
|
|
237
|
+
return [];
|
|
238
|
+
},
|
|
131
239
|
async set() { },
|
|
132
240
|
};
|
|
133
241
|
}
|
|
@@ -138,22 +246,32 @@ function isCacheFresh(savedAt, maxAgeMs) {
|
|
|
138
246
|
const savedTime = Date.parse(savedAt);
|
|
139
247
|
return Number.isFinite(savedTime) && Date.now() - savedTime <= maxAgeMs;
|
|
140
248
|
}
|
|
141
|
-
async function loadSharedFreshResource(key, loadFresh) {
|
|
142
|
-
const
|
|
249
|
+
async function loadSharedFreshResource(cache, key, loadFresh) {
|
|
250
|
+
const pendingLoads = getPendingFreshLoads(cache);
|
|
251
|
+
const pendingLoad = pendingLoads.get(key);
|
|
143
252
|
if (pendingLoad) {
|
|
144
253
|
return (await pendingLoad);
|
|
145
254
|
}
|
|
146
255
|
const nextLoad = loadFresh();
|
|
147
|
-
|
|
256
|
+
pendingLoads.set(key, nextLoad);
|
|
148
257
|
try {
|
|
149
258
|
return await nextLoad;
|
|
150
259
|
}
|
|
151
260
|
finally {
|
|
152
|
-
if (
|
|
153
|
-
|
|
261
|
+
if (pendingLoads.get(key) === nextLoad) {
|
|
262
|
+
pendingLoads.delete(key);
|
|
154
263
|
}
|
|
155
264
|
}
|
|
156
265
|
}
|
|
266
|
+
function getPendingFreshLoads(cache) {
|
|
267
|
+
const existing = pendingFreshLoads.get(cache);
|
|
268
|
+
if (existing) {
|
|
269
|
+
return existing;
|
|
270
|
+
}
|
|
271
|
+
const next = new Map();
|
|
272
|
+
pendingFreshLoads.set(cache, next);
|
|
273
|
+
return next;
|
|
274
|
+
}
|
|
157
275
|
function toReadCacheEnvelope(value) {
|
|
158
276
|
if (!value || typeof value !== "object") {
|
|
159
277
|
return null;
|
|
@@ -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,176 @@
|
|
|
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({ environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = exports.initialPendingWriteRetryDelayMs, maxRetryDelayMs = exports.maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, sync, }) {
|
|
9
|
+
let active = false;
|
|
10
|
+
let retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
11
|
+
let retryTimer;
|
|
12
|
+
let recoveryPromise;
|
|
13
|
+
let immediateRecoveryRequested = false;
|
|
14
|
+
let unsubscribePeerWakeups;
|
|
15
|
+
let unsubscribe;
|
|
16
|
+
function clearRetryTimer() {
|
|
17
|
+
if (retryTimer) {
|
|
18
|
+
environment.clearTimeout(retryTimer);
|
|
19
|
+
}
|
|
20
|
+
retryTimer = undefined;
|
|
21
|
+
}
|
|
22
|
+
function emitResult(result) {
|
|
23
|
+
try {
|
|
24
|
+
onResult?.(result);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error("[Bayon offline] Pending-write recovery result listener failed.", {
|
|
28
|
+
error,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function emitError(error) {
|
|
33
|
+
console.error("[Bayon offline] Pending-write recovery failed.", {
|
|
34
|
+
error,
|
|
35
|
+
scopeKey,
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
onError?.(error);
|
|
39
|
+
}
|
|
40
|
+
catch (callbackError) {
|
|
41
|
+
console.error("[Bayon offline] Pending-write recovery error listener failed.", {
|
|
42
|
+
callbackError,
|
|
43
|
+
scopeKey,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function scheduleRetry() {
|
|
48
|
+
if (!active || !environment.isOnline() || retryTimer) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
retryTimer = environment.setTimeout(() => {
|
|
52
|
+
retryTimer = undefined;
|
|
53
|
+
void recoverNow();
|
|
54
|
+
}, retryDelayMs);
|
|
55
|
+
retryDelayMs = Math.min(Math.max(initialRetryDelayMs, retryDelayMs * 2), Math.max(initialRetryDelayMs, maxRetryDelayMs));
|
|
56
|
+
}
|
|
57
|
+
function recoverNow() {
|
|
58
|
+
if (!environment.isOnline()) {
|
|
59
|
+
clearRetryTimer();
|
|
60
|
+
return Promise.resolve(null);
|
|
61
|
+
}
|
|
62
|
+
if (recoveryPromise) {
|
|
63
|
+
return recoveryPromise;
|
|
64
|
+
}
|
|
65
|
+
const attempt = (async () => {
|
|
66
|
+
try {
|
|
67
|
+
const run = () => (0, pendingWrites_1.syncPendingWrites)({
|
|
68
|
+
scopeKey,
|
|
69
|
+
select,
|
|
70
|
+
store,
|
|
71
|
+
sync,
|
|
72
|
+
});
|
|
73
|
+
const result = coordination
|
|
74
|
+
? await coordination.runExclusive(run)
|
|
75
|
+
: await run();
|
|
76
|
+
if (!result) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
emitResult(result);
|
|
80
|
+
if (result.remaining > 0) {
|
|
81
|
+
scheduleRetry();
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
emitError(error);
|
|
90
|
+
scheduleRetry();
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
recoveryPromise = attempt.finally(() => {
|
|
95
|
+
recoveryPromise = undefined;
|
|
96
|
+
if (active && immediateRecoveryRequested) {
|
|
97
|
+
immediateRecoveryRequested = false;
|
|
98
|
+
triggerImmediateRecovery();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
return recoveryPromise;
|
|
102
|
+
}
|
|
103
|
+
function triggerImmediateRecovery() {
|
|
104
|
+
if (!active) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
immediateRecoveryRequested ||= Boolean(recoveryPromise);
|
|
108
|
+
clearRetryTimer();
|
|
109
|
+
retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
110
|
+
void recoverNow();
|
|
111
|
+
}
|
|
112
|
+
function handleOffline() {
|
|
113
|
+
clearRetryTimer();
|
|
114
|
+
}
|
|
115
|
+
function handleStoreChange(change) {
|
|
116
|
+
if (change.type === "set") {
|
|
117
|
+
coordination?.notify();
|
|
118
|
+
triggerImmediateRecovery();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
notifyQueued() {
|
|
123
|
+
coordination?.notify();
|
|
124
|
+
triggerImmediateRecovery();
|
|
125
|
+
},
|
|
126
|
+
recoverNow,
|
|
127
|
+
start() {
|
|
128
|
+
if (active) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
active = true;
|
|
132
|
+
unsubscribe = store.subscribe(handleStoreChange);
|
|
133
|
+
unsubscribePeerWakeups = coordination?.subscribe(triggerImmediateRecovery);
|
|
134
|
+
environment.addEventListener("focus", triggerImmediateRecovery);
|
|
135
|
+
environment.addEventListener("online", triggerImmediateRecovery);
|
|
136
|
+
environment.addEventListener("offline", handleOffline);
|
|
137
|
+
if (environment.isOnline()) {
|
|
138
|
+
triggerImmediateRecovery();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
stop() {
|
|
142
|
+
if (!active) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
active = false;
|
|
146
|
+
clearRetryTimer();
|
|
147
|
+
unsubscribe?.();
|
|
148
|
+
unsubscribe = undefined;
|
|
149
|
+
unsubscribePeerWakeups?.();
|
|
150
|
+
unsubscribePeerWakeups = undefined;
|
|
151
|
+
environment.removeEventListener("focus", triggerImmediateRecovery);
|
|
152
|
+
environment.removeEventListener("online", triggerImmediateRecovery);
|
|
153
|
+
environment.removeEventListener("offline", handleOffline);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function createDefaultBrowserRecoveryEnvironment() {
|
|
158
|
+
const target = typeof window === "undefined" ? null : window;
|
|
159
|
+
return {
|
|
160
|
+
addEventListener(type, listener) {
|
|
161
|
+
target?.addEventListener(type, listener);
|
|
162
|
+
},
|
|
163
|
+
clearTimeout(timer) {
|
|
164
|
+
globalThis.clearTimeout(timer);
|
|
165
|
+
},
|
|
166
|
+
isOnline() {
|
|
167
|
+
return typeof navigator !== "undefined" && navigator.onLine;
|
|
168
|
+
},
|
|
169
|
+
removeEventListener(type, listener) {
|
|
170
|
+
target?.removeEventListener(type, listener);
|
|
171
|
+
},
|
|
172
|
+
setTimeout(callback, delayMs) {
|
|
173
|
+
return globalThis.setTimeout(callback, delayMs);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
package/dist-cjs/idbDriver.js
CHANGED
|
@@ -16,6 +16,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
16
16
|
async clear() {
|
|
17
17
|
await runBrowserStorageOperation({
|
|
18
18
|
fallback: undefined,
|
|
19
|
+
operationName: "clear object store",
|
|
19
20
|
operation: async () => {
|
|
20
21
|
const database = await databasePromise;
|
|
21
22
|
await database.clear(storeName);
|
|
@@ -26,6 +27,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
26
27
|
async close() {
|
|
27
28
|
await runBrowserStorageOperation({
|
|
28
29
|
fallback: undefined,
|
|
30
|
+
operationName: "close database",
|
|
29
31
|
operation: async () => {
|
|
30
32
|
const database = await databasePromise;
|
|
31
33
|
database.close();
|
|
@@ -36,6 +38,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
36
38
|
async delete(key) {
|
|
37
39
|
await runBrowserStorageOperation({
|
|
38
40
|
fallback: undefined,
|
|
41
|
+
operationName: "delete key",
|
|
39
42
|
operation: async () => {
|
|
40
43
|
const database = await databasePromise;
|
|
41
44
|
await database.delete(storeName, key);
|
|
@@ -43,9 +46,32 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
43
46
|
timeoutMs: operationTimeoutMs,
|
|
44
47
|
});
|
|
45
48
|
},
|
|
49
|
+
async deleteIf(key, predicate) {
|
|
50
|
+
return runBrowserStorageOperation({
|
|
51
|
+
fallback: false,
|
|
52
|
+
operationName: "conditionally delete key",
|
|
53
|
+
operation: async () => {
|
|
54
|
+
const database = await databasePromise;
|
|
55
|
+
// The ownership check and deletion must share a transaction; splitting
|
|
56
|
+
// them can delete a newer offline snapshot written by another trigger.
|
|
57
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
58
|
+
const objectStore = transaction.objectStore(storeName);
|
|
59
|
+
const current = (await objectStore.get(key)) ?? null;
|
|
60
|
+
if (!predicate(current)) {
|
|
61
|
+
await transaction.done;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
await objectStore.delete(key);
|
|
65
|
+
await transaction.done;
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
timeoutMs: operationTimeoutMs,
|
|
69
|
+
});
|
|
70
|
+
},
|
|
46
71
|
async get(key) {
|
|
47
72
|
return runBrowserStorageOperation({
|
|
48
73
|
fallback: null,
|
|
74
|
+
operationName: "read key",
|
|
49
75
|
operation: async () => {
|
|
50
76
|
const database = await databasePromise;
|
|
51
77
|
return (await database.get(storeName, key)) ?? null;
|
|
@@ -56,6 +82,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
56
82
|
async list() {
|
|
57
83
|
return runBrowserStorageOperation({
|
|
58
84
|
fallback: [],
|
|
85
|
+
operationName: "list object store",
|
|
59
86
|
operation: async () => {
|
|
60
87
|
const database = await databasePromise;
|
|
61
88
|
return database.getAll(storeName);
|
|
@@ -63,9 +90,34 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
63
90
|
timeoutMs: operationTimeoutMs,
|
|
64
91
|
});
|
|
65
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
|
+
},
|
|
66
117
|
async set(key, value) {
|
|
67
118
|
await runBrowserStorageOperation({
|
|
68
119
|
fallback: undefined,
|
|
120
|
+
operationName: "write key",
|
|
69
121
|
operation: async () => {
|
|
70
122
|
const database = await databasePromise;
|
|
71
123
|
await database.put(storeName, value, key);
|
|
@@ -73,6 +125,26 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
73
125
|
timeoutMs: operationTimeoutMs,
|
|
74
126
|
});
|
|
75
127
|
},
|
|
128
|
+
async setIf(key, value, predicate) {
|
|
129
|
+
return runBrowserStorageOperation({
|
|
130
|
+
fallback: false,
|
|
131
|
+
operationName: "conditionally replace key",
|
|
132
|
+
operation: async () => {
|
|
133
|
+
const database = await databasePromise;
|
|
134
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
135
|
+
const objectStore = transaction.objectStore(storeName);
|
|
136
|
+
const current = (await objectStore.get(key)) ?? null;
|
|
137
|
+
if (!predicate(current)) {
|
|
138
|
+
await transaction.done;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
await objectStore.put(value, key);
|
|
142
|
+
await transaction.done;
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
timeoutMs: operationTimeoutMs,
|
|
146
|
+
});
|
|
147
|
+
},
|
|
76
148
|
};
|
|
77
149
|
}
|
|
78
150
|
async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB, storeName, }) {
|
|
@@ -89,17 +161,23 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
89
161
|
}
|
|
90
162
|
return database;
|
|
91
163
|
}
|
|
92
|
-
async function runBrowserStorageOperation({ fallback, operation, timeoutMs, }) {
|
|
164
|
+
async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
93
165
|
let timeoutId = null;
|
|
94
166
|
try {
|
|
95
167
|
return await Promise.race([
|
|
96
168
|
operation(),
|
|
97
169
|
new Promise((resolve) => {
|
|
98
|
-
timeoutId = setTimeout(() =>
|
|
170
|
+
timeoutId = setTimeout(() => {
|
|
171
|
+
console.error(`IndexedDB timed out while trying to ${operationName}.`, {
|
|
172
|
+
timeoutMs,
|
|
173
|
+
});
|
|
174
|
+
resolve(fallback);
|
|
175
|
+
}, timeoutMs);
|
|
99
176
|
}),
|
|
100
177
|
]);
|
|
101
178
|
}
|
|
102
|
-
catch {
|
|
179
|
+
catch (error) {
|
|
180
|
+
console.error(`IndexedDB failed to ${operationName}.`, { error });
|
|
103
181
|
return fallback;
|
|
104
182
|
}
|
|
105
183
|
finally {
|
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);
|