@bayonai/offline 0.7.0 → 0.8.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 +22 -1
- package/README.md +36 -4
- package/dist/browserFallbackRecovery.js +5 -2
- package/dist/durableCommandStore.d.ts +8 -2
- package/dist/durableCommandStore.js +115 -50
- package/dist/idbDriver.d.ts +3 -0
- package/dist/idbDriver.js +52 -44
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pendingWrites.d.ts +10 -4
- package/dist/pendingWrites.js +28 -12
- package/dist/readCache.d.ts +1 -1
- package/dist/readCache.js +22 -5
- package/dist-cjs/browserFallbackRecovery.js +5 -2
- package/dist-cjs/durableCommandStore.js +115 -50
- package/dist-cjs/idbDriver.js +54 -44
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWrites.js +27 -11
- package/dist-cjs/readCache.js +22 -5
- package/package.json +3 -3
package/dist/pendingWrites.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createIdbKeyValueDriver, } from "./idbDriver.js";
|
|
1
|
+
import { createIdbKeyValueDriver, OfflineStorageError, } from "./idbDriver.js";
|
|
2
2
|
export const pendingWriteSchemaVersion = 1;
|
|
3
3
|
export const pendingWriteOperationTimeoutMs = 2_000;
|
|
4
4
|
export const pendingWriteSyncLeaseTimeoutMs = 60_000;
|
|
@@ -148,14 +148,24 @@ export function createBrowserPendingWriteStore({ databaseName, databaseVersion,
|
|
|
148
148
|
operationTimeoutMs,
|
|
149
149
|
storeName,
|
|
150
150
|
});
|
|
151
|
-
return createPendingWriteStore(driver ??
|
|
151
|
+
return createPendingWriteStore(driver ?? createUnavailablePendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
|
|
152
152
|
}
|
|
153
153
|
export function createPendingWriteKey({ id, prefix }) {
|
|
154
154
|
return `${prefix}:${encodeURIComponent(id)}`;
|
|
155
155
|
}
|
|
156
156
|
export function createPendingWriteSnapshotId() {
|
|
157
|
-
|
|
157
|
+
const randomId = globalThis.crypto?.randomUUID?.();
|
|
158
|
+
if (randomId)
|
|
159
|
+
return randomId;
|
|
160
|
+
pendingWriteSnapshotSequence += 1;
|
|
161
|
+
return [
|
|
162
|
+
"snapshot",
|
|
163
|
+
Date.now().toString(36),
|
|
164
|
+
pendingWriteSnapshotSequence.toString(36),
|
|
165
|
+
Math.random().toString(36).slice(2),
|
|
166
|
+
].join("-");
|
|
158
167
|
}
|
|
168
|
+
let pendingWriteSnapshotSequence = 0;
|
|
159
169
|
export async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
|
|
160
170
|
const pending = await store.get(id);
|
|
161
171
|
if (!pending) {
|
|
@@ -163,8 +173,7 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
|
|
|
163
173
|
}
|
|
164
174
|
// A delayed command remains in its ordered lane until its grace period has
|
|
165
175
|
// elapsed. This makes cancellation survive UI unmounts and app restarts.
|
|
166
|
-
if (pending.notBefore &&
|
|
167
|
-
Date.parse(pending.notBefore) > Date.parse(now())) {
|
|
176
|
+
if (pending.notBefore && Date.parse(pending.notBefore) > Date.parse(now())) {
|
|
168
177
|
return { status: "idle" };
|
|
169
178
|
}
|
|
170
179
|
const claimedSnapshotId = createPendingWriteSnapshotId();
|
|
@@ -179,7 +188,7 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
|
|
|
179
188
|
return { status: "superseded" };
|
|
180
189
|
}
|
|
181
190
|
try {
|
|
182
|
-
await sync(pending.payload);
|
|
191
|
+
await sync(pending.payload, { recordId: pending.id });
|
|
183
192
|
}
|
|
184
193
|
catch (error) {
|
|
185
194
|
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
@@ -208,7 +217,7 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
|
|
|
208
217
|
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
209
218
|
* mutation semantics.
|
|
210
219
|
*/
|
|
211
|
-
export function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
220
|
+
export function syncPendingWrites({ now, scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
212
221
|
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
213
222
|
if (!inFlightByScope) {
|
|
214
223
|
inFlightByScope = new Map();
|
|
@@ -219,6 +228,7 @@ export function syncPendingWrites({ scopeKey = "all", select = () => true, stopO
|
|
|
219
228
|
return existing;
|
|
220
229
|
}
|
|
221
230
|
const syncRun = performPendingWriteSync({
|
|
231
|
+
now,
|
|
222
232
|
select,
|
|
223
233
|
stopOnFailure,
|
|
224
234
|
store,
|
|
@@ -265,7 +275,7 @@ export function summarizePendingWrites(records) {
|
|
|
265
275
|
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
266
276
|
}
|
|
267
277
|
const pendingWriteSyncs = new WeakMap();
|
|
268
|
-
async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
|
|
278
|
+
async function performPendingWriteSync({ now, select, stopOnFailure, store, sync, }) {
|
|
269
279
|
const summary = {
|
|
270
280
|
attempted: 0,
|
|
271
281
|
failed: 0,
|
|
@@ -278,6 +288,7 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
278
288
|
for (const record of records) {
|
|
279
289
|
const result = await syncPendingWrite({
|
|
280
290
|
id: record.id,
|
|
291
|
+
now,
|
|
281
292
|
store,
|
|
282
293
|
sync,
|
|
283
294
|
});
|
|
@@ -313,7 +324,7 @@ function isPendingWriteSyncClaimAvailable(record, now) {
|
|
|
313
324
|
* workspace. Use its `recover` method for background replay and `run` for new
|
|
314
325
|
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
315
326
|
*/
|
|
316
|
-
export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
327
|
+
export function createOrderedPendingWriteExecutor({ now, scopeKey = "all", select = () => true, store, sync, }) {
|
|
317
328
|
let execution = Promise.resolve();
|
|
318
329
|
function inOrder(operation) {
|
|
319
330
|
const result = execution.then(operation, operation);
|
|
@@ -322,6 +333,7 @@ export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = (
|
|
|
322
333
|
}
|
|
323
334
|
function recover() {
|
|
324
335
|
return inOrder(() => syncPendingWrites({
|
|
336
|
+
now,
|
|
325
337
|
scopeKey,
|
|
326
338
|
select,
|
|
327
339
|
stopOnFailure: true,
|
|
@@ -337,6 +349,7 @@ export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = (
|
|
|
337
349
|
return runPendingWrite(options);
|
|
338
350
|
}
|
|
339
351
|
const replay = await syncPendingWrites({
|
|
352
|
+
now,
|
|
340
353
|
scopeKey,
|
|
341
354
|
select,
|
|
342
355
|
stopOnFailure: true,
|
|
@@ -354,7 +367,8 @@ export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = (
|
|
|
354
367
|
},
|
|
355
368
|
};
|
|
356
369
|
}
|
|
357
|
-
function
|
|
370
|
+
function createUnavailablePendingWriteDriver() {
|
|
371
|
+
const unavailable = () => new OfflineStorageError("IndexedDB is unavailable; the pending write was not stored.");
|
|
358
372
|
return {
|
|
359
373
|
async clear() { },
|
|
360
374
|
async clearIf() {
|
|
@@ -368,9 +382,11 @@ function createNoopPendingWriteDriver() {
|
|
|
368
382
|
async list() {
|
|
369
383
|
return [];
|
|
370
384
|
},
|
|
371
|
-
async set() {
|
|
385
|
+
async set() {
|
|
386
|
+
throw unavailable();
|
|
387
|
+
},
|
|
372
388
|
async setIf() {
|
|
373
|
-
|
|
389
|
+
throw unavailable();
|
|
374
390
|
},
|
|
375
391
|
};
|
|
376
392
|
}
|
package/dist/readCache.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export declare const readCacheSchemaVersion = 1;
|
|
|
3
3
|
export declare const readCacheFreshnessMaxAgeMs = 30000;
|
|
4
4
|
export declare const durableReadCacheFreshnessMaxAgeMs: number;
|
|
5
5
|
export declare const identityReadCacheFreshnessMaxAgeMs: number;
|
|
6
|
-
export declare const browserReadCacheOperationTimeoutMs =
|
|
6
|
+
export declare const browserReadCacheOperationTimeoutMs = 1000;
|
|
7
7
|
export type ReadCacheHit<T> = {
|
|
8
8
|
fromCache: true;
|
|
9
9
|
savedAt: string;
|
package/dist/readCache.js
CHANGED
|
@@ -3,7 +3,9 @@ export const readCacheSchemaVersion = 1;
|
|
|
3
3
|
export const readCacheFreshnessMaxAgeMs = 30_000;
|
|
4
4
|
export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
|
|
5
5
|
export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
|
|
6
|
-
|
|
6
|
+
// Cache reads are optional and should yield to the fresh loader quickly when
|
|
7
|
+
// IndexedDB is blocked by a browser/WebView lifecycle or another tab.
|
|
8
|
+
export const browserReadCacheOperationTimeoutMs = 1_000;
|
|
7
9
|
const pendingFreshLoads = new WeakMap();
|
|
8
10
|
const cachePruners = new WeakMap();
|
|
9
11
|
export function createReadCache(driver) {
|
|
@@ -133,8 +135,7 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
|
|
|
133
135
|
*/
|
|
134
136
|
export function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
|
|
135
137
|
const resourceCache = cache.capture?.() ?? cache;
|
|
136
|
-
return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
|
|
137
|
-
Promise.resolve(0);
|
|
138
|
+
return (cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ?? Promise.resolve(0));
|
|
138
139
|
}
|
|
139
140
|
export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
|
|
140
141
|
const serializedParams = Object.entries(params)
|
|
@@ -156,7 +157,15 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
156
157
|
if (forceFresh) {
|
|
157
158
|
// Deletion advances the cache version, so a request started before this
|
|
158
159
|
// post-write refresh cannot restore stale data after it finishes.
|
|
159
|
-
|
|
160
|
+
try {
|
|
161
|
+
await resourceCache.delete(key);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.error("[Bayon offline] Unable to invalidate the read cache.", {
|
|
165
|
+
error: normalizeError(error),
|
|
166
|
+
key,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
160
169
|
}
|
|
161
170
|
const version = resourceCache.getVersion(key);
|
|
162
171
|
const cached = await resourceCache.get(key);
|
|
@@ -170,7 +179,15 @@ export async function readThroughCache({ cache, forceFresh = false, key, loadFre
|
|
|
170
179
|
}
|
|
171
180
|
try {
|
|
172
181
|
const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
|
|
173
|
-
|
|
182
|
+
try {
|
|
183
|
+
await resourceCache.setIfVersion(key, fresh, version);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
console.error("[Bayon offline] Unable to refresh the read cache.", {
|
|
187
|
+
error: normalizeError(error),
|
|
188
|
+
key,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
174
191
|
return {
|
|
175
192
|
data: fresh,
|
|
176
193
|
error: null,
|
|
@@ -12,6 +12,7 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
|
|
|
12
12
|
let active = false;
|
|
13
13
|
let retrying = false;
|
|
14
14
|
let pullStartY = null;
|
|
15
|
+
const touchThreshold = pullThreshold === null ? Number.POSITIVE_INFINITY : pullThreshold;
|
|
15
16
|
function retry() {
|
|
16
17
|
if (retrying)
|
|
17
18
|
return;
|
|
@@ -23,7 +24,9 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
|
|
|
23
24
|
}
|
|
24
25
|
catch (error) {
|
|
25
26
|
// A failed navigation must leave the recovery action visible and usable.
|
|
26
|
-
console.error("[Bayon offline] Offline fallback reload failed.", {
|
|
27
|
+
console.error("[Bayon offline] Offline fallback reload failed.", {
|
|
28
|
+
error,
|
|
29
|
+
});
|
|
27
30
|
retrying = false;
|
|
28
31
|
control.disabled = false;
|
|
29
32
|
}
|
|
@@ -36,7 +39,7 @@ function createBrowserOfflineFallbackRecovery({ control, environment = createDef
|
|
|
36
39
|
const touchY = getSingleTouchY(event, "changedTouches");
|
|
37
40
|
if (pullStartY !== null &&
|
|
38
41
|
touchY !== null &&
|
|
39
|
-
touchY - pullStartY >=
|
|
42
|
+
touchY - pullStartY >= touchThreshold) {
|
|
40
43
|
retry();
|
|
41
44
|
}
|
|
42
45
|
pullStartY = null;
|
|
@@ -7,24 +7,59 @@ const pendingWrites_1 = require("./pendingWrites");
|
|
|
7
7
|
* durable command lifecycle. A command becomes visible immediately, but is
|
|
8
8
|
* never sent remotely until IndexedDB has accepted it.
|
|
9
9
|
*/
|
|
10
|
-
function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, store, }) {
|
|
10
|
+
function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, reconcile, shouldRetry = () => true, store, }) {
|
|
11
11
|
let baseState = initialState;
|
|
12
|
-
let
|
|
12
|
+
let baseStateGeneration = 0;
|
|
13
13
|
let savedAtMillis = 0;
|
|
14
|
+
let publishGeneration = 0;
|
|
15
|
+
let storedRecords = [];
|
|
14
16
|
let deferredRecoveryTimer = null;
|
|
17
|
+
const provisionalRecords = new Map();
|
|
18
|
+
const localStorageFailures = new Map();
|
|
15
19
|
let snapshot = {
|
|
16
20
|
operations: [],
|
|
17
21
|
state: initialState,
|
|
18
22
|
};
|
|
19
23
|
const listeners = new Set();
|
|
20
24
|
const executor = (0, pendingWrites_1.createOrderedPendingWriteExecutor)({
|
|
25
|
+
now,
|
|
21
26
|
scopeKey: "durable-command-store",
|
|
27
|
+
select: isActiveCommand,
|
|
22
28
|
store,
|
|
23
|
-
sync:
|
|
29
|
+
sync: async (command, { recordId }) => {
|
|
30
|
+
const result = await execute(command, { operationId: recordId });
|
|
31
|
+
// A completed remote write is newer than any read already in flight.
|
|
32
|
+
// Reconciliation is local-only: a projector bug must never retry a
|
|
33
|
+
// command whose remote effect may already have committed.
|
|
34
|
+
baseStateGeneration += 1;
|
|
35
|
+
if (reconcile) {
|
|
36
|
+
try {
|
|
37
|
+
baseState = reconcile(baseState, command, result);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error("[Bayon offline] Unable to reconcile a confirmed durable command.", { error, operationId: recordId });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
24
44
|
});
|
|
25
45
|
function notify() {
|
|
26
|
-
for (const listener of listeners)
|
|
27
|
-
|
|
46
|
+
for (const listener of listeners) {
|
|
47
|
+
try {
|
|
48
|
+
listener();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error("[Bayon offline] Durable-command listener failed.", {
|
|
52
|
+
error,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function classifyStoredError(error) {
|
|
58
|
+
return classifyError(new Error(error ?? "Unable to sync durable command."));
|
|
59
|
+
}
|
|
60
|
+
function isActiveCommand(record) {
|
|
61
|
+
return (record.syncStatus !== "failed" ||
|
|
62
|
+
shouldRetry(classifyStoredError(record.error), record.payload));
|
|
28
63
|
}
|
|
29
64
|
function scheduleDeferredRecovery(records) {
|
|
30
65
|
if (deferredRecoveryTimer) {
|
|
@@ -40,32 +75,62 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
40
75
|
}, null);
|
|
41
76
|
if (nextDeadline === null)
|
|
42
77
|
return;
|
|
43
|
-
deferredRecoveryTimer = setTimeout(
|
|
78
|
+
deferredRecoveryTimer = setTimeout(recoverInBackground, Math.max(0, nextDeadline - currentTime));
|
|
44
79
|
}
|
|
45
|
-
|
|
46
|
-
const records =
|
|
80
|
+
function currentRecords() {
|
|
81
|
+
const records = new Map(storedRecords.map((record) => [record.id, record]));
|
|
82
|
+
for (const record of provisionalRecords.values()) {
|
|
83
|
+
records.set(record.id, record);
|
|
84
|
+
}
|
|
85
|
+
return [...records.values()].sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
|
|
86
|
+
left.id.localeCompare(right.id));
|
|
87
|
+
}
|
|
88
|
+
function applySnapshot(records) {
|
|
47
89
|
snapshot = {
|
|
48
|
-
operations:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
90
|
+
operations: [
|
|
91
|
+
...records.map((record) => ({
|
|
92
|
+
command: record.payload,
|
|
93
|
+
...(record.syncStatus === "failed"
|
|
94
|
+
? { error: classifyStoredError(record.error) }
|
|
95
|
+
: {}),
|
|
96
|
+
id: record.id,
|
|
97
|
+
status: record.syncStatus === "failed"
|
|
98
|
+
? "failed"
|
|
99
|
+
: record.syncStatus === "syncing"
|
|
100
|
+
? "syncing"
|
|
101
|
+
: "accepted",
|
|
102
|
+
})),
|
|
103
|
+
...[...localStorageFailures].map(([id, command]) => ({
|
|
104
|
+
command,
|
|
105
|
+
error: "local-storage",
|
|
106
|
+
id,
|
|
107
|
+
status: "failed",
|
|
108
|
+
})),
|
|
109
|
+
],
|
|
110
|
+
state: project(baseState, records.filter(isActiveCommand)),
|
|
61
111
|
};
|
|
62
112
|
scheduleDeferredRecovery(records);
|
|
113
|
+
}
|
|
114
|
+
async function publish() {
|
|
115
|
+
const generation = ++publishGeneration;
|
|
116
|
+
const records = await store.list();
|
|
117
|
+
if (generation !== publishGeneration)
|
|
118
|
+
return;
|
|
119
|
+
storedRecords = records;
|
|
120
|
+
applySnapshot(currentRecords());
|
|
63
121
|
notify();
|
|
64
122
|
}
|
|
65
123
|
async function recover() {
|
|
66
124
|
await executor.recover();
|
|
67
125
|
await publish();
|
|
68
126
|
}
|
|
127
|
+
function recoverInBackground() {
|
|
128
|
+
void recover().catch((error) => {
|
|
129
|
+
console.error("[Bayon offline] Unable to recover durable commands.", {
|
|
130
|
+
error,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
69
134
|
const unsubscribe = store.subscribe(() => {
|
|
70
135
|
void publish().catch((error) => {
|
|
71
136
|
console.error("[Bayon offline] Unable to publish durable commands.", {
|
|
@@ -75,6 +140,11 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
75
140
|
});
|
|
76
141
|
return {
|
|
77
142
|
async cancel(operationId) {
|
|
143
|
+
if (localStorageFailures.delete(operationId)) {
|
|
144
|
+
applySnapshot(currentRecords());
|
|
145
|
+
notify();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
78
148
|
const record = await store.get(operationId);
|
|
79
149
|
if (!record ||
|
|
80
150
|
record.syncStatus === "syncing" ||
|
|
@@ -89,6 +159,7 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
89
159
|
close() {
|
|
90
160
|
if (deferredRecoveryTimer)
|
|
91
161
|
clearTimeout(deferredRecoveryTimer);
|
|
162
|
+
publishGeneration += 1;
|
|
92
163
|
unsubscribe();
|
|
93
164
|
},
|
|
94
165
|
dispatch(command, options = {}) {
|
|
@@ -103,36 +174,20 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
103
174
|
snapshotId: operationId,
|
|
104
175
|
syncStatus: "pending",
|
|
105
176
|
};
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
],
|
|
112
|
-
state: project(baseState, [
|
|
113
|
-
...snapshot.operations
|
|
114
|
-
.filter((operation) => operation.status !== "failed")
|
|
115
|
-
.map((operation) => ({
|
|
116
|
-
id: operation.id,
|
|
117
|
-
payload: operation.command,
|
|
118
|
-
savedAt: record.savedAt,
|
|
119
|
-
snapshotId: operation.id,
|
|
120
|
-
syncStatus: "pending",
|
|
121
|
-
})),
|
|
122
|
-
record,
|
|
123
|
-
]),
|
|
124
|
-
};
|
|
177
|
+
// Product decision: provisional records remain part of the same ordered
|
|
178
|
+
// projection as persisted retryable failures while storage settles.
|
|
179
|
+
provisionalRecords.set(operationId, record);
|
|
180
|
+
localStorageFailures.delete(operationId);
|
|
181
|
+
applySnapshot(currentRecords());
|
|
125
182
|
notify();
|
|
126
183
|
const accepted = store.set(record).then(async () => {
|
|
184
|
+
provisionalRecords.delete(operationId);
|
|
127
185
|
await publish();
|
|
128
|
-
|
|
186
|
+
recoverInBackground();
|
|
129
187
|
}, async (error) => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
: operation),
|
|
134
|
-
state: baseState,
|
|
135
|
-
};
|
|
188
|
+
provisionalRecords.delete(operationId);
|
|
189
|
+
localStorageFailures.set(operationId, command);
|
|
190
|
+
applySnapshot(currentRecords());
|
|
136
191
|
notify();
|
|
137
192
|
throw error;
|
|
138
193
|
});
|
|
@@ -143,9 +198,9 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
143
198
|
await recover();
|
|
144
199
|
},
|
|
145
200
|
async refresh(scope) {
|
|
146
|
-
const generation = ++
|
|
201
|
+
const generation = ++baseStateGeneration;
|
|
147
202
|
const loaded = await load(scope);
|
|
148
|
-
if (generation !==
|
|
203
|
+
if (generation !== baseStateGeneration)
|
|
149
204
|
return;
|
|
150
205
|
baseState = loaded;
|
|
151
206
|
await publish();
|
|
@@ -157,5 +212,15 @@ function createDurableCommandStore({ canCancel = () => true, classifyError = ()
|
|
|
157
212
|
};
|
|
158
213
|
}
|
|
159
214
|
function createDurableCommandId() {
|
|
160
|
-
return globalThis.crypto?.randomUUID?.() ??
|
|
215
|
+
return globalThis.crypto?.randomUUID?.() ?? createFallbackDurableCommandId();
|
|
216
|
+
}
|
|
217
|
+
let fallbackDurableCommandSequence = 0;
|
|
218
|
+
function createFallbackDurableCommandId() {
|
|
219
|
+
fallbackDurableCommandSequence += 1;
|
|
220
|
+
return [
|
|
221
|
+
"command",
|
|
222
|
+
Date.now().toString(36),
|
|
223
|
+
fallbackDurableCommandSequence.toString(36),
|
|
224
|
+
Math.random().toString(36).slice(2),
|
|
225
|
+
].join("-");
|
|
161
226
|
}
|
package/dist-cjs/idbDriver.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OfflineStorageError = void 0;
|
|
3
4
|
exports.createIdbKeyValueDriver = createIdbKeyValueDriver;
|
|
4
5
|
const idb_1 = require("idb");
|
|
6
|
+
class OfflineStorageError extends Error {
|
|
7
|
+
name = "OfflineStorageError";
|
|
8
|
+
}
|
|
9
|
+
exports.OfflineStorageError = OfflineStorageError;
|
|
5
10
|
function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
|
|
6
11
|
if (!indexedDB) {
|
|
7
12
|
return null;
|
|
@@ -12,25 +17,33 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
12
17
|
indexedDB,
|
|
13
18
|
storeName,
|
|
14
19
|
});
|
|
20
|
+
function runMutation(operationName, mutate) {
|
|
21
|
+
return runTransactionalStorageOperation({
|
|
22
|
+
failureMode: "throw",
|
|
23
|
+
fallback: undefined,
|
|
24
|
+
operationName,
|
|
25
|
+
storeName,
|
|
26
|
+
operation: async (setTransaction, isCancelled) => {
|
|
27
|
+
const database = await databasePromise;
|
|
28
|
+
if (isCancelled())
|
|
29
|
+
return;
|
|
30
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
31
|
+
setTransaction(transaction);
|
|
32
|
+
await mutate(transaction.objectStore(storeName));
|
|
33
|
+
await transaction.done;
|
|
34
|
+
},
|
|
35
|
+
timeoutMs: operationTimeoutMs,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
15
38
|
return {
|
|
16
39
|
async clear() {
|
|
17
|
-
await
|
|
18
|
-
fallback: undefined,
|
|
19
|
-
operationName: "clear object store",
|
|
20
|
-
operation: async (setTransaction) => {
|
|
21
|
-
const database = await databasePromise;
|
|
22
|
-
const transaction = database.transaction(storeName, "readwrite");
|
|
23
|
-
setTransaction(transaction);
|
|
24
|
-
await transaction.objectStore(storeName).clear();
|
|
25
|
-
await transaction.done;
|
|
26
|
-
},
|
|
27
|
-
timeoutMs: operationTimeoutMs,
|
|
28
|
-
});
|
|
40
|
+
await runMutation("clear object store", (objectStore) => objectStore.clear());
|
|
29
41
|
},
|
|
30
42
|
async close() {
|
|
31
43
|
await runBrowserStorageOperation({
|
|
32
44
|
fallback: undefined,
|
|
33
45
|
operationName: "close database",
|
|
46
|
+
storeName,
|
|
34
47
|
operation: async () => {
|
|
35
48
|
const database = await databasePromise;
|
|
36
49
|
database.close();
|
|
@@ -39,18 +52,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
39
52
|
});
|
|
40
53
|
},
|
|
41
54
|
async delete(key) {
|
|
42
|
-
await
|
|
43
|
-
fallback: undefined,
|
|
44
|
-
operationName: "delete key",
|
|
45
|
-
operation: async (setTransaction) => {
|
|
46
|
-
const database = await databasePromise;
|
|
47
|
-
const transaction = database.transaction(storeName, "readwrite");
|
|
48
|
-
setTransaction(transaction);
|
|
49
|
-
await transaction.objectStore(storeName).delete(key);
|
|
50
|
-
await transaction.done;
|
|
51
|
-
},
|
|
52
|
-
timeoutMs: operationTimeoutMs,
|
|
53
|
-
});
|
|
55
|
+
await runMutation("delete key", (objectStore) => objectStore.delete(key));
|
|
54
56
|
},
|
|
55
57
|
async deleteIf(key, predicate) {
|
|
56
58
|
return runConditionalStorageOperation({
|
|
@@ -71,6 +73,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
71
73
|
return runBrowserStorageOperation({
|
|
72
74
|
fallback: null,
|
|
73
75
|
operationName: "read key",
|
|
76
|
+
storeName,
|
|
74
77
|
operation: async () => {
|
|
75
78
|
const database = await databasePromise;
|
|
76
79
|
return (await database.get(storeName, key)) ?? null;
|
|
@@ -82,6 +85,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
82
85
|
return runBrowserStorageOperation({
|
|
83
86
|
fallback: [],
|
|
84
87
|
operationName: "list object store",
|
|
88
|
+
storeName,
|
|
85
89
|
operation: async () => {
|
|
86
90
|
const database = await databasePromise;
|
|
87
91
|
return database.getAll(storeName);
|
|
@@ -93,6 +97,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
93
97
|
return runBrowserStorageOperation({
|
|
94
98
|
fallback: [],
|
|
95
99
|
operationName: "list object-store entries",
|
|
100
|
+
storeName,
|
|
96
101
|
operation: async () => {
|
|
97
102
|
const database = await databasePromise;
|
|
98
103
|
// Keys and values must come from one transaction. Separate database
|
|
@@ -114,18 +119,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
114
119
|
});
|
|
115
120
|
},
|
|
116
121
|
async set(key, value) {
|
|
117
|
-
await
|
|
118
|
-
fallback: undefined,
|
|
119
|
-
operationName: "write key",
|
|
120
|
-
operation: async (setTransaction) => {
|
|
121
|
-
const database = await databasePromise;
|
|
122
|
-
const transaction = database.transaction(storeName, "readwrite");
|
|
123
|
-
setTransaction(transaction);
|
|
124
|
-
await transaction.objectStore(storeName).put(value, key);
|
|
125
|
-
await transaction.done;
|
|
126
|
-
},
|
|
127
|
-
timeoutMs: operationTimeoutMs,
|
|
128
|
-
});
|
|
122
|
+
await runMutation("write key", (objectStore) => objectStore.put(value, key));
|
|
129
123
|
},
|
|
130
124
|
async setIf(key, value, predicate) {
|
|
131
125
|
return runConditionalStorageOperation({
|
|
@@ -158,18 +152,27 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
158
152
|
}
|
|
159
153
|
return database;
|
|
160
154
|
}
|
|
161
|
-
async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
|
|
155
|
+
async function runBrowserStorageOperation({ cancelOnTimeout, failureMode = "fallback", fallback, operation, operationName, storeName, timeoutMs, }) {
|
|
162
156
|
let timeoutId = null;
|
|
157
|
+
const startedAt = Date.now();
|
|
163
158
|
try {
|
|
164
159
|
return await Promise.race([
|
|
165
160
|
operation(),
|
|
166
|
-
new Promise((resolve) => {
|
|
161
|
+
new Promise((resolve, reject) => {
|
|
167
162
|
timeoutId = setTimeout(() => {
|
|
168
|
-
console.
|
|
163
|
+
console.warn("[Bayon offline] IndexedDB operation timed out.", {
|
|
164
|
+
elapsedMs: Date.now() - startedAt,
|
|
165
|
+
operationName,
|
|
166
|
+
storeName,
|
|
169
167
|
timeoutMs,
|
|
170
168
|
});
|
|
171
169
|
if (!cancelOnTimeout || cancelOnTimeout()) {
|
|
172
|
-
|
|
170
|
+
if (failureMode === "throw") {
|
|
171
|
+
reject(new OfflineStorageError(`IndexedDB timed out while attempting to ${operationName} in ${storeName}.`));
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
resolve(fallback);
|
|
175
|
+
}
|
|
173
176
|
}
|
|
174
177
|
}, timeoutMs);
|
|
175
178
|
}),
|
|
@@ -177,6 +180,11 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
|
|
|
177
180
|
}
|
|
178
181
|
catch (error) {
|
|
179
182
|
console.error(`IndexedDB failed to ${operationName}.`, { error });
|
|
183
|
+
if (failureMode === "throw") {
|
|
184
|
+
if (error instanceof OfflineStorageError)
|
|
185
|
+
throw error;
|
|
186
|
+
throw new OfflineStorageError(`IndexedDB failed to ${operationName} in ${storeName}.`, { cause: error });
|
|
187
|
+
}
|
|
180
188
|
return fallback;
|
|
181
189
|
}
|
|
182
190
|
finally {
|
|
@@ -185,12 +193,14 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
|
|
|
185
193
|
}
|
|
186
194
|
}
|
|
187
195
|
}
|
|
188
|
-
async function runTransactionalStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
196
|
+
async function runTransactionalStorageOperation({ failureMode, fallback, operation, operationName, storeName, timeoutMs, }) {
|
|
189
197
|
let transaction;
|
|
190
198
|
let cancelled = false;
|
|
191
199
|
return runBrowserStorageOperation({
|
|
200
|
+
failureMode,
|
|
192
201
|
fallback,
|
|
193
202
|
operationName,
|
|
203
|
+
storeName,
|
|
194
204
|
timeoutMs,
|
|
195
205
|
cancelOnTimeout() {
|
|
196
206
|
cancelled = true;
|
|
@@ -206,14 +216,12 @@ async function runTransactionalStorageOperation({ fallback, operation, operation
|
|
|
206
216
|
}
|
|
207
217
|
},
|
|
208
218
|
operation: async () => {
|
|
209
|
-
if (cancelled)
|
|
210
|
-
return fallback;
|
|
211
219
|
return operation((nextTransaction) => {
|
|
212
220
|
transaction = nextTransaction;
|
|
213
221
|
// Aborting can reject transaction.done. The operation also awaits it,
|
|
214
222
|
// but observing it here prevents a race from becoming unhandled.
|
|
215
223
|
void transaction.done.catch(() => undefined);
|
|
216
|
-
});
|
|
224
|
+
}, () => cancelled);
|
|
217
225
|
},
|
|
218
226
|
});
|
|
219
227
|
}
|
|
@@ -221,8 +229,10 @@ async function runConditionalStorageOperation({ apply, databasePromise, operatio
|
|
|
221
229
|
let transaction;
|
|
222
230
|
let cancelled = false;
|
|
223
231
|
return runBrowserStorageOperation({
|
|
232
|
+
failureMode: "throw",
|
|
224
233
|
fallback: false,
|
|
225
234
|
operationName,
|
|
235
|
+
storeName,
|
|
226
236
|
timeoutMs,
|
|
227
237
|
cancelOnTimeout() {
|
|
228
238
|
cancelled = true;
|