@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.
@@ -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 ?? createNoopPendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
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
- return globalThis.crypto.randomUUID();
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 createNoopPendingWriteDriver() {
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
- return false;
389
+ throw unavailable();
374
390
  },
375
391
  };
376
392
  }
@@ -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 = 2000;
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
- export const browserReadCacheOperationTimeoutMs = 2_000;
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
- await resourceCache.delete(key);
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
- await resourceCache.setIfVersion(key, fresh, version);
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.", { error });
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 >= (pullThreshold ?? Number.POSITIVE_INFINITY)) {
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 refreshGeneration = 0;
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: execute,
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
- listener();
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(() => void recover(), Math.max(0, nextDeadline - currentTime));
78
+ deferredRecoveryTimer = setTimeout(recoverInBackground, Math.max(0, nextDeadline - currentTime));
44
79
  }
45
- async function publish() {
46
- const records = await store.list();
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: records.map((record) => ({
49
- command: record.payload,
50
- ...(record.error
51
- ? { error: classifyError(new Error(record.error)) }
52
- : {}),
53
- id: record.id,
54
- status: record.syncStatus === "failed"
55
- ? "failed"
56
- : record.syncStatus === "syncing"
57
- ? "syncing"
58
- : "accepted",
59
- })),
60
- state: project(baseState, records.filter((record) => record.syncStatus !== "failed")),
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
- // Project before storage settles so controls never wait on storage I/O.
107
- snapshot = {
108
- operations: [
109
- ...snapshot.operations,
110
- { command, id: operationId, status: "accepted" },
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
- void recover();
186
+ recoverInBackground();
129
187
  }, async (error) => {
130
- snapshot = {
131
- operations: snapshot.operations.map((operation) => operation.id === operationId
132
- ? { ...operation, error: "local-storage", status: "failed" }
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 = ++refreshGeneration;
201
+ const generation = ++baseStateGeneration;
147
202
  const loaded = await load(scope);
148
- if (generation !== refreshGeneration)
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?.() ?? `command-${Date.now()}`;
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
  }
@@ -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 runTransactionalStorageOperation({
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 runTransactionalStorageOperation({
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 runTransactionalStorageOperation({
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.error(`IndexedDB timed out while trying to ${operationName}.`, {
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
- resolve(fallback);
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;