@bayonai/offline 0.5.1 → 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,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,22 +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 runBrowserStorageOperation({
18
- fallback: undefined,
19
- operationName: "clear object store",
20
- operation: async () => {
21
- const database = await databasePromise;
22
- await database.clear(storeName);
23
- },
24
- timeoutMs: operationTimeoutMs,
25
- });
40
+ await runMutation("clear object store", (objectStore) => objectStore.clear());
26
41
  },
27
42
  async close() {
28
43
  await runBrowserStorageOperation({
29
44
  fallback: undefined,
30
45
  operationName: "close database",
46
+ storeName,
31
47
  operation: async () => {
32
48
  const database = await databasePromise;
33
49
  database.close();
@@ -36,15 +52,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
36
52
  });
37
53
  },
38
54
  async delete(key) {
39
- await runBrowserStorageOperation({
40
- fallback: undefined,
41
- operationName: "delete key",
42
- operation: async () => {
43
- const database = await databasePromise;
44
- await database.delete(storeName, key);
45
- },
46
- timeoutMs: operationTimeoutMs,
47
- });
55
+ await runMutation("delete key", (objectStore) => objectStore.delete(key));
48
56
  },
49
57
  async deleteIf(key, predicate) {
50
58
  return runConditionalStorageOperation({
@@ -65,6 +73,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
65
73
  return runBrowserStorageOperation({
66
74
  fallback: null,
67
75
  operationName: "read key",
76
+ storeName,
68
77
  operation: async () => {
69
78
  const database = await databasePromise;
70
79
  return (await database.get(storeName, key)) ?? null;
@@ -76,6 +85,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
76
85
  return runBrowserStorageOperation({
77
86
  fallback: [],
78
87
  operationName: "list object store",
88
+ storeName,
79
89
  operation: async () => {
80
90
  const database = await databasePromise;
81
91
  return database.getAll(storeName);
@@ -87,6 +97,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
87
97
  return runBrowserStorageOperation({
88
98
  fallback: [],
89
99
  operationName: "list object-store entries",
100
+ storeName,
90
101
  operation: async () => {
91
102
  const database = await databasePromise;
92
103
  // Keys and values must come from one transaction. Separate database
@@ -108,15 +119,7 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
108
119
  });
109
120
  },
110
121
  async set(key, value) {
111
- await runBrowserStorageOperation({
112
- fallback: undefined,
113
- operationName: "write key",
114
- operation: async () => {
115
- const database = await databasePromise;
116
- await database.put(storeName, value, key);
117
- },
118
- timeoutMs: operationTimeoutMs,
119
- });
122
+ await runMutation("write key", (objectStore) => objectStore.put(value, key));
120
123
  },
121
124
  async setIf(key, value, predicate) {
122
125
  return runConditionalStorageOperation({
@@ -149,18 +152,27 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
149
152
  }
150
153
  return database;
151
154
  }
152
- async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
155
+ async function runBrowserStorageOperation({ cancelOnTimeout, failureMode = "fallback", fallback, operation, operationName, storeName, timeoutMs, }) {
153
156
  let timeoutId = null;
157
+ const startedAt = Date.now();
154
158
  try {
155
159
  return await Promise.race([
156
160
  operation(),
157
- new Promise((resolve) => {
161
+ new Promise((resolve, reject) => {
158
162
  timeoutId = setTimeout(() => {
159
- 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,
160
167
  timeoutMs,
161
168
  });
162
169
  if (!cancelOnTimeout || cancelOnTimeout()) {
163
- 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
+ }
164
176
  }
165
177
  }, timeoutMs);
166
178
  }),
@@ -168,6 +180,11 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
168
180
  }
169
181
  catch (error) {
170
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
+ }
171
188
  return fallback;
172
189
  }
173
190
  finally {
@@ -176,12 +193,46 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
176
193
  }
177
194
  }
178
195
  }
196
+ async function runTransactionalStorageOperation({ failureMode, fallback, operation, operationName, storeName, timeoutMs, }) {
197
+ let transaction;
198
+ let cancelled = false;
199
+ return runBrowserStorageOperation({
200
+ failureMode,
201
+ fallback,
202
+ operationName,
203
+ storeName,
204
+ timeoutMs,
205
+ cancelOnTimeout() {
206
+ cancelled = true;
207
+ if (!transaction)
208
+ return true;
209
+ try {
210
+ transaction.abort();
211
+ return true;
212
+ }
213
+ catch (error) {
214
+ console.warn("[Bayon offline] IndexedDB transaction already settling at timeout.", { error, operationName });
215
+ return false;
216
+ }
217
+ },
218
+ operation: async () => {
219
+ return operation((nextTransaction) => {
220
+ transaction = nextTransaction;
221
+ // Aborting can reject transaction.done. The operation also awaits it,
222
+ // but observing it here prevents a race from becoming unhandled.
223
+ void transaction.done.catch(() => undefined);
224
+ }, () => cancelled);
225
+ },
226
+ });
227
+ }
179
228
  async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
180
229
  let transaction;
181
230
  let cancelled = false;
182
231
  return runBrowserStorageOperation({
232
+ failureMode: "throw",
183
233
  fallback: false,
184
234
  operationName,
235
+ storeName,
185
236
  timeoutMs,
186
237
  cancelOnTimeout() {
187
238
  cancelled = true;
package/dist-cjs/index.js CHANGED
@@ -14,8 +14,12 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.OfflineStorageError = void 0;
17
18
  __exportStar(require("./readCache"), exports);
18
19
  __exportStar(require("./pendingWrites"), exports);
19
20
  __exportStar(require("./browserRecovery"), exports);
20
21
  __exportStar(require("./browserCoordination"), exports);
21
22
  __exportStar(require("./browserFallbackRecovery"), exports);
23
+ __exportStar(require("./durableCommandStore"), exports);
24
+ var idbDriver_1 = require("./idbDriver");
25
+ Object.defineProperty(exports, "OfflineStorageError", { enumerable: true, get: function () { return idbDriver_1.OfflineStorageError; } });
@@ -162,19 +162,34 @@ function createBrowserPendingWriteStore({ databaseName, databaseVersion, indexed
162
162
  operationTimeoutMs,
163
163
  storeName,
164
164
  });
165
- return createPendingWriteStore(driver ?? createNoopPendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
165
+ return createPendingWriteStore(driver ?? createUnavailablePendingWriteDriver(), { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion });
166
166
  }
167
167
  function createPendingWriteKey({ id, prefix }) {
168
168
  return `${prefix}:${encodeURIComponent(id)}`;
169
169
  }
170
170
  function createPendingWriteSnapshotId() {
171
- return globalThis.crypto.randomUUID();
171
+ const randomId = globalThis.crypto?.randomUUID?.();
172
+ if (randomId)
173
+ return randomId;
174
+ pendingWriteSnapshotSequence += 1;
175
+ return [
176
+ "snapshot",
177
+ Date.now().toString(36),
178
+ pendingWriteSnapshotSequence.toString(36),
179
+ Math.random().toString(36).slice(2),
180
+ ].join("-");
172
181
  }
182
+ let pendingWriteSnapshotSequence = 0;
173
183
  async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
174
184
  const pending = await store.get(id);
175
185
  if (!pending) {
176
186
  return { status: "idle" };
177
187
  }
188
+ // A delayed command remains in its ordered lane until its grace period has
189
+ // elapsed. This makes cancellation survive UI unmounts and app restarts.
190
+ if (pending.notBefore && Date.parse(pending.notBefore) > Date.parse(now())) {
191
+ return { status: "idle" };
192
+ }
178
193
  const claimedSnapshotId = createPendingWriteSnapshotId();
179
194
  const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
180
195
  ...pending,
@@ -187,7 +202,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
187
202
  return { status: "superseded" };
188
203
  }
189
204
  try {
190
- await sync(pending.payload);
205
+ await sync(pending.payload, { recordId: pending.id });
191
206
  }
192
207
  catch (error) {
193
208
  const message = error instanceof Error ? error.message : "Unable to sync pending write.";
@@ -204,7 +219,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
204
219
  store,
205
220
  });
206
221
  return markedFailed
207
- ? { error: message, status: "failed" }
222
+ ? { error: message, failure: error, status: "failed" }
208
223
  : { status: "superseded" };
209
224
  }
210
225
  return (await store.clearIfSnapshotId(id, claimedSnapshotId))
@@ -216,7 +231,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
216
231
  * same store/scope. Callers own the selection predicate and idempotent remote
217
232
  * mutation semantics.
218
233
  */
219
- function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
234
+ function syncPendingWrites({ now, scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
220
235
  let inFlightByScope = pendingWriteSyncs.get(store);
221
236
  if (!inFlightByScope) {
222
237
  inFlightByScope = new Map();
@@ -227,6 +242,7 @@ function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailur
227
242
  return existing;
228
243
  }
229
244
  const syncRun = performPendingWriteSync({
245
+ now,
230
246
  select,
231
247
  stopOnFailure,
232
248
  store,
@@ -273,7 +289,7 @@ function summarizePendingWrites(records) {
273
289
  }, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
274
290
  }
275
291
  const pendingWriteSyncs = new WeakMap();
276
- async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
292
+ async function performPendingWriteSync({ now, select, stopOnFailure, store, sync, }) {
277
293
  const summary = {
278
294
  attempted: 0,
279
295
  failed: 0,
@@ -286,6 +302,7 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
286
302
  for (const record of records) {
287
303
  const result = await syncPendingWrite({
288
304
  id: record.id,
305
+ now,
289
306
  store,
290
307
  sync,
291
308
  });
@@ -294,7 +311,9 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
294
311
  // Product decision: a superseded result can represent an older live claim.
295
312
  // Preserve strict ordering by leaving every later record for a future run.
296
313
  if (stopOnFailure &&
297
- (result.status === "failed" || result.status === "superseded")) {
314
+ (result.status === "failed" ||
315
+ result.status === "idle" ||
316
+ result.status === "superseded")) {
298
317
  break;
299
318
  }
300
319
  }
@@ -319,7 +338,7 @@ function isPendingWriteSyncClaimAvailable(record, now) {
319
338
  * workspace. Use its `recover` method for background replay and `run` for new
320
339
  * writes so a newer remote mutation cannot overtake an older queued mutation.
321
340
  */
322
- function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
341
+ function createOrderedPendingWriteExecutor({ now, scopeKey = "all", select = () => true, store, sync, }) {
323
342
  let execution = Promise.resolve();
324
343
  function inOrder(operation) {
325
344
  const result = execution.then(operation, operation);
@@ -328,6 +347,7 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
328
347
  }
329
348
  function recover() {
330
349
  return inOrder(() => syncPendingWrites({
350
+ now,
331
351
  scopeKey,
332
352
  select,
333
353
  stopOnFailure: true,
@@ -343,6 +363,7 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
343
363
  return runPendingWrite(options);
344
364
  }
345
365
  const replay = await syncPendingWrites({
366
+ now,
346
367
  scopeKey,
347
368
  select,
348
369
  stopOnFailure: true,
@@ -360,7 +381,8 @@ function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => tr
360
381
  },
361
382
  };
362
383
  }
363
- function createNoopPendingWriteDriver() {
384
+ function createUnavailablePendingWriteDriver() {
385
+ const unavailable = () => new idbDriver_1.OfflineStorageError("IndexedDB is unavailable; the pending write was not stored.");
364
386
  return {
365
387
  async clear() { },
366
388
  async clearIf() {
@@ -374,9 +396,11 @@ function createNoopPendingWriteDriver() {
374
396
  async list() {
375
397
  return [];
376
398
  },
377
- async set() { },
399
+ async set() {
400
+ throw unavailable();
401
+ },
378
402
  async setIf() {
379
- return false;
403
+ throw unavailable();
380
404
  },
381
405
  };
382
406
  }
@@ -416,6 +440,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
416
440
  return {
417
441
  ...(typeof record.error === "string" ? { error: record.error } : {}),
418
442
  id: record.id,
443
+ ...(typeof record.notBefore === "string"
444
+ ? { notBefore: record.notBefore }
445
+ : {}),
419
446
  payload,
420
447
  savedAt: record.savedAt,
421
448
  snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
@@ -444,6 +471,7 @@ function isPendingWriteRecord(value) {
444
471
  typeof value === "object" &&
445
472
  "id" in value &&
446
473
  typeof value.id === "string" &&
474
+ (!("notBefore" in value) || typeof value.notBefore === "string") &&
447
475
  "savedAt" in value &&
448
476
  typeof value.savedAt === "string" &&
449
477
  (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
@@ -13,7 +13,9 @@ exports.readCacheSchemaVersion = 1;
13
13
  exports.readCacheFreshnessMaxAgeMs = 30_000;
14
14
  exports.durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
15
15
  exports.identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
16
- exports.browserReadCacheOperationTimeoutMs = 2_000;
16
+ // Cache reads are optional and should yield to the fresh loader quickly when
17
+ // IndexedDB is blocked by a browser/WebView lifecycle or another tab.
18
+ exports.browserReadCacheOperationTimeoutMs = 1_000;
17
19
  const pendingFreshLoads = new WeakMap();
18
20
  const cachePruners = new WeakMap();
19
21
  function createReadCache(driver) {
@@ -143,8 +145,7 @@ function createBrowserReadCache({ databaseName, databaseVersion, indexedDB = glo
143
145
  */
144
146
  function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
145
147
  const resourceCache = cache.capture?.() ?? cache;
146
- return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
147
- Promise.resolve(0);
148
+ return (cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ?? Promise.resolve(0));
148
149
  }
149
150
  function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
150
151
  const serializedParams = Object.entries(params)
@@ -166,7 +167,15 @@ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, max
166
167
  if (forceFresh) {
167
168
  // Deletion advances the cache version, so a request started before this
168
169
  // post-write refresh cannot restore stale data after it finishes.
169
- await resourceCache.delete(key);
170
+ try {
171
+ await resourceCache.delete(key);
172
+ }
173
+ catch (error) {
174
+ console.error("[Bayon offline] Unable to invalidate the read cache.", {
175
+ error: normalizeError(error),
176
+ key,
177
+ });
178
+ }
170
179
  }
171
180
  const version = resourceCache.getVersion(key);
172
181
  const cached = await resourceCache.get(key);
@@ -180,7 +189,15 @@ async function readThroughCache({ cache, forceFresh = false, key, loadFresh, max
180
189
  }
181
190
  try {
182
191
  const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
183
- await resourceCache.setIfVersion(key, fresh, version);
192
+ try {
193
+ await resourceCache.setIfVersion(key, fresh, version);
194
+ }
195
+ catch (error) {
196
+ console.error("[Bayon offline] Unable to refresh the read cache.", {
197
+ error: normalizeError(error),
198
+ key,
199
+ });
200
+ }
184
201
  return {
185
202
  data: fresh,
186
203
  error: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.5.1",
3
+ "version": "0.8.0",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "fake-indexeddb": "^6.2.5",
59
- "typescript": "^5",
60
- "vitest": "^4.1.6"
59
+ "typescript": "^6.0.3",
60
+ "vitest": "^5.0.1"
61
61
  }
62
62
  }