@bayonai/offline 0.5.0 → 0.5.1

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 - 2026-09-12
4
+
5
+ - Prevented timed-out conditional IndexedDB replacements and deletions from
6
+ committing after the operation has already reported failure.
7
+ - Preserved strict chronological replay when an older record has an active
8
+ claim by leaving it and all later records for a future ordered run.
9
+
3
10
  ## 0.5.0 - 2026-09-12
4
11
 
5
12
  - Added `createBrowserOfflineFallbackRecovery`, a framework-free controller
package/README.md CHANGED
@@ -202,6 +202,11 @@ Interrupted sync claims expire after one minute so recovery can resume. Records
202
202
  created before snapshot identities were introduced remain readable; they receive
203
203
  a stable compatibility identity and are upgraded when next updated.
204
204
 
205
+ Conditional IndexedDB mutations are cancelled when their storage deadline is
206
+ exceeded, so an operation cannot report failure and then commit later. Ordered
207
+ replay also stops at an active older claim; it never delivers a later record
208
+ whose remote effect could subsequently be overwritten by that predecessor.
209
+
205
210
  Host apps that change their pending-write payload shape can normalize current
206
211
  records while keeping domain logic outside the package:
207
212
 
package/dist/idbDriver.js CHANGED
@@ -1,4 +1,4 @@
1
- import { wrap } from "idb";
1
+ import { wrap, } from "idb";
2
2
  export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
3
3
  if (!indexedDB) {
4
4
  return null;
@@ -44,25 +44,18 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
44
44
  });
45
45
  },
46
46
  async deleteIf(key, predicate) {
47
- return runBrowserStorageOperation({
48
- fallback: false,
47
+ return runConditionalStorageOperation({
48
+ databasePromise,
49
49
  operationName: "conditionally delete key",
50
- operation: async () => {
51
- const database = await databasePromise;
52
- // The ownership check and deletion must share a transaction; splitting
53
- // them can delete a newer offline snapshot written by another trigger.
54
- const transaction = database.transaction(storeName, "readwrite");
55
- const objectStore = transaction.objectStore(storeName);
50
+ storeName,
51
+ timeoutMs: operationTimeoutMs,
52
+ apply: async (objectStore) => {
56
53
  const current = (await objectStore.get(key)) ?? null;
57
- if (!predicate(current)) {
58
- await transaction.done;
54
+ if (!predicate(current))
59
55
  return false;
60
- }
61
56
  await objectStore.delete(key);
62
- await transaction.done;
63
57
  return true;
64
58
  },
65
- timeoutMs: operationTimeoutMs,
66
59
  });
67
60
  },
68
61
  async get(key) {
@@ -123,23 +116,18 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
123
116
  });
124
117
  },
125
118
  async setIf(key, value, predicate) {
126
- return runBrowserStorageOperation({
127
- fallback: false,
119
+ return runConditionalStorageOperation({
120
+ databasePromise,
128
121
  operationName: "conditionally replace key",
129
- operation: async () => {
130
- const database = await databasePromise;
131
- const transaction = database.transaction(storeName, "readwrite");
132
- const objectStore = transaction.objectStore(storeName);
122
+ storeName,
123
+ timeoutMs: operationTimeoutMs,
124
+ apply: async (objectStore) => {
133
125
  const current = (await objectStore.get(key)) ?? null;
134
- if (!predicate(current)) {
135
- await transaction.done;
126
+ if (!predicate(current))
136
127
  return false;
137
- }
138
128
  await objectStore.put(value, key);
139
- await transaction.done;
140
129
  return true;
141
130
  },
142
- timeoutMs: operationTimeoutMs,
143
131
  });
144
132
  },
145
133
  };
@@ -158,7 +146,7 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
158
146
  }
159
147
  return database;
160
148
  }
161
- async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
149
+ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
162
150
  let timeoutId = null;
163
151
  try {
164
152
  return await Promise.race([
@@ -168,7 +156,9 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
168
156
  console.error(`IndexedDB timed out while trying to ${operationName}.`, {
169
157
  timeoutMs,
170
158
  });
171
- resolve(fallback);
159
+ if (!cancelOnTimeout || cancelOnTimeout()) {
160
+ resolve(fallback);
161
+ }
172
162
  }, timeoutMs);
173
163
  }),
174
164
  ]);
@@ -183,3 +173,39 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
183
173
  }
184
174
  }
185
175
  }
176
+ async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
177
+ let transaction;
178
+ let cancelled = false;
179
+ return runBrowserStorageOperation({
180
+ fallback: false,
181
+ operationName,
182
+ timeoutMs,
183
+ cancelOnTimeout() {
184
+ cancelled = true;
185
+ if (!transaction)
186
+ return true;
187
+ try {
188
+ transaction.abort();
189
+ return true;
190
+ }
191
+ catch (error) {
192
+ // Product decision: when commit has already started, report its actual
193
+ // result instead of returning a false failure that can later mutate data.
194
+ console.warn("[Bayon offline] Conditional transaction already settling at timeout.", { error, operationName });
195
+ return false;
196
+ }
197
+ },
198
+ operation: async () => {
199
+ const database = await databasePromise;
200
+ if (cancelled)
201
+ return false;
202
+ transaction = database.transaction(storeName, "readwrite");
203
+ // Aborting can reject both a request and transaction.done. Observe the
204
+ // latter immediately so cancellation never creates an unhandled rejection.
205
+ void transaction.done.catch(() => undefined);
206
+ const result = await apply(transaction.objectStore(storeName));
207
+ await transaction.done;
208
+ return result;
209
+ },
210
+ });
211
+ }
@@ -277,7 +277,10 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
277
277
  });
278
278
  summary.attempted += 1;
279
279
  summary[result.status] += 1;
280
- if (stopOnFailure && result.status === "failed") {
280
+ // Product decision: a superseded result can represent an older live claim.
281
+ // Preserve strict ordering by leaving every later record for a future run.
282
+ if (stopOnFailure &&
283
+ (result.status === "failed" || result.status === "superseded")) {
281
284
  break;
282
285
  }
283
286
  }
@@ -47,25 +47,18 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
47
47
  });
48
48
  },
49
49
  async deleteIf(key, predicate) {
50
- return runBrowserStorageOperation({
51
- fallback: false,
50
+ return runConditionalStorageOperation({
51
+ databasePromise,
52
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);
53
+ storeName,
54
+ timeoutMs: operationTimeoutMs,
55
+ apply: async (objectStore) => {
59
56
  const current = (await objectStore.get(key)) ?? null;
60
- if (!predicate(current)) {
61
- await transaction.done;
57
+ if (!predicate(current))
62
58
  return false;
63
- }
64
59
  await objectStore.delete(key);
65
- await transaction.done;
66
60
  return true;
67
61
  },
68
- timeoutMs: operationTimeoutMs,
69
62
  });
70
63
  },
71
64
  async get(key) {
@@ -126,23 +119,18 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
126
119
  });
127
120
  },
128
121
  async setIf(key, value, predicate) {
129
- return runBrowserStorageOperation({
130
- fallback: false,
122
+ return runConditionalStorageOperation({
123
+ databasePromise,
131
124
  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);
125
+ storeName,
126
+ timeoutMs: operationTimeoutMs,
127
+ apply: async (objectStore) => {
136
128
  const current = (await objectStore.get(key)) ?? null;
137
- if (!predicate(current)) {
138
- await transaction.done;
129
+ if (!predicate(current))
139
130
  return false;
140
- }
141
131
  await objectStore.put(value, key);
142
- await transaction.done;
143
132
  return true;
144
133
  },
145
- timeoutMs: operationTimeoutMs,
146
134
  });
147
135
  },
148
136
  };
@@ -161,7 +149,7 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
161
149
  }
162
150
  return database;
163
151
  }
164
- async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
152
+ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
165
153
  let timeoutId = null;
166
154
  try {
167
155
  return await Promise.race([
@@ -171,7 +159,9 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
171
159
  console.error(`IndexedDB timed out while trying to ${operationName}.`, {
172
160
  timeoutMs,
173
161
  });
174
- resolve(fallback);
162
+ if (!cancelOnTimeout || cancelOnTimeout()) {
163
+ resolve(fallback);
164
+ }
175
165
  }, timeoutMs);
176
166
  }),
177
167
  ]);
@@ -186,3 +176,39 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
186
176
  }
187
177
  }
188
178
  }
179
+ async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
180
+ let transaction;
181
+ let cancelled = false;
182
+ return runBrowserStorageOperation({
183
+ fallback: false,
184
+ operationName,
185
+ timeoutMs,
186
+ cancelOnTimeout() {
187
+ cancelled = true;
188
+ if (!transaction)
189
+ return true;
190
+ try {
191
+ transaction.abort();
192
+ return true;
193
+ }
194
+ catch (error) {
195
+ // Product decision: when commit has already started, report its actual
196
+ // result instead of returning a false failure that can later mutate data.
197
+ console.warn("[Bayon offline] Conditional transaction already settling at timeout.", { error, operationName });
198
+ return false;
199
+ }
200
+ },
201
+ operation: async () => {
202
+ const database = await databasePromise;
203
+ if (cancelled)
204
+ return false;
205
+ transaction = database.transaction(storeName, "readwrite");
206
+ // Aborting can reject both a request and transaction.done. Observe the
207
+ // latter immediately so cancellation never creates an unhandled rejection.
208
+ void transaction.done.catch(() => undefined);
209
+ const result = await apply(transaction.objectStore(storeName));
210
+ await transaction.done;
211
+ return result;
212
+ },
213
+ });
214
+ }
@@ -291,7 +291,10 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
291
291
  });
292
292
  summary.attempted += 1;
293
293
  summary[result.status] += 1;
294
- if (stopOnFailure && result.status === "failed") {
294
+ // Product decision: a superseded result can represent an older live claim.
295
+ // Preserve strict ordering by leaving every later record for a future run.
296
+ if (stopOnFailure &&
297
+ (result.status === "failed" || result.status === "superseded")) {
295
298
  break;
296
299
  }
297
300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",
@@ -55,6 +55,7 @@
55
55
  "idb": "^8.0.3"
56
56
  },
57
57
  "devDependencies": {
58
+ "fake-indexeddb": "^6.2.5",
58
59
  "typescript": "^5",
59
60
  "vitest": "^4.1.6"
60
61
  }