@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 CHANGED
@@ -1,6 +1,27 @@
1
1
  # Changelog
2
2
 
3
- ## 0.7.0 - Unreleased
3
+ ## 0.8.0 - Unreleased
4
+
5
+ - Added authoritative-result reconciliation to `createDurableCommandStore`,
6
+ including rebasing remaining optimistic commands without a transient revert.
7
+ - Exposed stable durable operation IDs to remote executors for idempotent
8
+ command handling and prevented older refreshes from replacing confirmations.
9
+ - Propagated injected clocks through ordered replay so deferred-command timing
10
+ stays deterministic in hosts and tests.
11
+ - Added a host-owned retry policy so terminal failures remain inspectable
12
+ without replaying or blocking later commands, while connectivity failures
13
+ retain their optimistic projection and continue through ordered recovery.
14
+ - Made IndexedDB mutation failures truthful: unavailable, rejected, and
15
+ cancelled timed-out writes now reject with `OfflineStorageError` instead of
16
+ reporting false durability.
17
+ - Prevented delayed database opens from mutating after timeout, stale store
18
+ listings from replacing newer command state, and listener failures from
19
+ interrupting command dispatch.
20
+ - Preserved retryable optimistic projections while later commands enter the
21
+ queue, added collision-resistant fallback IDs where Web Crypto UUIDs are
22
+ unavailable, and restored the package's 100% coverage release gate.
23
+
24
+ ## 0.7.0 - 2026-09-16
4
25
 
5
26
  - Added `createDurableCommandStore`, a framework-neutral store that projects
6
27
  accepted commands immediately while persisting them before remote execution.
package/README.md CHANGED
@@ -202,8 +202,12 @@ package never imports an application model, database SDK, or framework.
202
202
  const commands = createDurableCommandStore({
203
203
  initialState: workspace,
204
204
  load: (scope) => workspaceApi.load(scope),
205
- execute: (command) => workspaceApi.execute(command),
205
+ execute: (command, { operationId }) =>
206
+ workspaceApi.execute(command, { operationId }),
206
207
  project: (state, pending) => projectWorkspace(state, pending),
208
+ reconcile: (state, command, result) =>
209
+ reconcileWorkspace(state, command, result),
210
+ shouldRetry: (category) => category === "connectivity",
207
211
  store,
208
212
  });
209
213
 
@@ -221,6 +225,31 @@ commands in order, and `close` releases the local subscription and delayed
221
225
  deadline timer. Local-storage failure rejects `accepted`; hosts must show that
222
226
  failure instead of claiming the command will recover.
223
227
 
228
+ Browser pending-write mutations reject with `OfflineStorageError` when
229
+ IndexedDB is unavailable, rejects an operation, or cancels a timed-out
230
+ transaction. This is intentional: successful queue acceptance always means
231
+ the command was durably stored. Read-cache access remains best-effort, and a
232
+ fresh remote result is still returned when optional cache invalidation or
233
+ persistence fails.
234
+
235
+ The executor receives the durable `operationId` on every attempt so the remote
236
+ handler can use it as an idempotency key. When execution succeeds, `reconcile`
237
+ folds the authoritative result into base state before the command is removed;
238
+ any later optimistic commands are projected over that confirmed state. A read
239
+ that started before this confirmation cannot replace it when it finishes.
240
+ Reconciliation is deliberately a host-owned, synchronous state transformation.
241
+ If it throws, the package logs the local bug and removes the already-confirmed
242
+ command rather than risking a duplicate remote effect.
243
+
244
+ `classifyError` and `shouldRetry` let a host distinguish recoverable failures
245
+ from terminal validation, authentication, permission, or conflict outcomes.
246
+ Retryable failures retain their optimistic projection while awaiting recovery.
247
+ Terminal failures roll back, stay available in `operations` for host feedback
248
+ or dismissal, and are excluded from later recovery lanes, so they neither retry
249
+ nor block a newer command. Because recovery survives restart, classification
250
+ must work from the package's persisted safe error message rather than relying
251
+ only on a non-serializable error object.
252
+
224
253
  Use generic record summaries when an app needs to render its own status:
225
254
 
226
255
  ```ts
@@ -284,10 +313,13 @@ pnpm --filter @bayonai/offline pack --dry-run
284
313
  ## Publish Checklist
285
314
 
286
315
  1. Run the package tests and build.
287
- 2. Run `pnpm --filter @bayonai/offline pack --dry-run` and confirm the tarball
316
+ 2. Run the 100% coverage gate and production dependency audit.
317
+ 3. Run `pnpm --filter @bayonai/offline pack --dry-run` and confirm the tarball
288
318
  includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
289
319
  `CHANGELOG.md`, and `LICENSE`.
290
- 3. Confirm `package.json` metadata, version, repository, license, and
320
+ 4. Install the generated tarball into an isolated consumer and verify ESM,
321
+ CommonJS, and TypeScript declaration imports.
322
+ 5. Confirm `package.json` metadata, version, repository, license, and
291
323
  `publishConfig.access` are correct.
292
- 4. Publish from the package directory or with the package filter after the
324
+ 6. Publish from the package directory or with the package filter after the
293
325
  workspace lockfile is current.
@@ -8,6 +8,7 @@ export function createBrowserOfflineFallbackRecovery({ control, environment = cr
8
8
  let active = false;
9
9
  let retrying = false;
10
10
  let pullStartY = null;
11
+ const touchThreshold = pullThreshold === null ? Number.POSITIVE_INFINITY : pullThreshold;
11
12
  function retry() {
12
13
  if (retrying)
13
14
  return;
@@ -19,7 +20,9 @@ export function createBrowserOfflineFallbackRecovery({ control, environment = cr
19
20
  }
20
21
  catch (error) {
21
22
  // A failed navigation must leave the recovery action visible and usable.
22
- console.error("[Bayon offline] Offline fallback reload failed.", { error });
23
+ console.error("[Bayon offline] Offline fallback reload failed.", {
24
+ error,
25
+ });
23
26
  retrying = false;
24
27
  control.disabled = false;
25
28
  }
@@ -32,7 +35,7 @@ export function createBrowserOfflineFallbackRecovery({ control, environment = cr
32
35
  const touchY = getSingleTouchY(event, "changedTouches");
33
36
  if (pullStartY !== null &&
34
37
  touchY !== null &&
35
- touchY - pullStartY >= (pullThreshold ?? Number.POSITIVE_INFINITY)) {
38
+ touchY - pullStartY >= touchThreshold) {
36
39
  retry();
37
40
  }
38
41
  pullStartY = null;
@@ -14,6 +14,10 @@ export type DurableCommandDispatch = {
14
14
  accepted: Promise<void>;
15
15
  operationId: string;
16
16
  };
17
+ export type DurableCommandExecutionContext = {
18
+ /** Stable across retries so the remote command can be idempotent. */
19
+ operationId: string;
20
+ };
17
21
  export type DurableCommandStore<State, Command, Scope> = {
18
22
  cancel: (operationId: string) => Promise<boolean>;
19
23
  close: () => void;
@@ -30,14 +34,16 @@ export type DurableCommandStore<State, Command, Scope> = {
30
34
  * durable command lifecycle. A command becomes visible immediately, but is
31
35
  * never sent remotely until IndexedDB has accepted it.
32
36
  */
33
- export declare function createDurableCommandStore<State, Command, Scope>({ canCancel, classifyError, createId, execute, initialState, load, now, project, store, }: {
37
+ export declare function createDurableCommandStore<State, Command, Scope, Result = unknown>({ canCancel, classifyError, createId, execute, initialState, load, now, project, reconcile, shouldRetry, store, }: {
34
38
  canCancel?: (command: Command, now: string) => boolean;
35
39
  classifyError?: (error: unknown) => DurableCommandErrorCategory;
36
40
  createId?: () => string;
37
- execute: (command: Command) => Promise<unknown>;
41
+ execute: (command: Command, context: DurableCommandExecutionContext) => Promise<Result>;
38
42
  initialState: State;
39
43
  load: (scope: Scope) => Promise<State>;
40
44
  now?: () => string;
41
45
  project: (state: State, commands: readonly PendingWriteRecord<Command>[]) => State;
46
+ reconcile?: (state: State, command: Command, result: Result) => State;
47
+ shouldRetry?: (category: DurableCommandErrorCategory, command: Command) => boolean;
42
48
  store: PendingWriteStore<Command>;
43
49
  }): DurableCommandStore<State, Command, Scope>;
@@ -4,24 +4,59 @@ import { createOrderedPendingWriteExecutor, } from "./pendingWrites.js";
4
4
  * durable command lifecycle. A command becomes visible immediately, but is
5
5
  * never sent remotely until IndexedDB has accepted it.
6
6
  */
7
- export function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, store, }) {
7
+ export function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, reconcile, shouldRetry = () => true, store, }) {
8
8
  let baseState = initialState;
9
- let refreshGeneration = 0;
9
+ let baseStateGeneration = 0;
10
10
  let savedAtMillis = 0;
11
+ let publishGeneration = 0;
12
+ let storedRecords = [];
11
13
  let deferredRecoveryTimer = null;
14
+ const provisionalRecords = new Map();
15
+ const localStorageFailures = new Map();
12
16
  let snapshot = {
13
17
  operations: [],
14
18
  state: initialState,
15
19
  };
16
20
  const listeners = new Set();
17
21
  const executor = createOrderedPendingWriteExecutor({
22
+ now,
18
23
  scopeKey: "durable-command-store",
24
+ select: isActiveCommand,
19
25
  store,
20
- sync: execute,
26
+ sync: async (command, { recordId }) => {
27
+ const result = await execute(command, { operationId: recordId });
28
+ // A completed remote write is newer than any read already in flight.
29
+ // Reconciliation is local-only: a projector bug must never retry a
30
+ // command whose remote effect may already have committed.
31
+ baseStateGeneration += 1;
32
+ if (reconcile) {
33
+ try {
34
+ baseState = reconcile(baseState, command, result);
35
+ }
36
+ catch (error) {
37
+ console.error("[Bayon offline] Unable to reconcile a confirmed durable command.", { error, operationId: recordId });
38
+ }
39
+ }
40
+ },
21
41
  });
22
42
  function notify() {
23
- for (const listener of listeners)
24
- listener();
43
+ for (const listener of listeners) {
44
+ try {
45
+ listener();
46
+ }
47
+ catch (error) {
48
+ console.error("[Bayon offline] Durable-command listener failed.", {
49
+ error,
50
+ });
51
+ }
52
+ }
53
+ }
54
+ function classifyStoredError(error) {
55
+ return classifyError(new Error(error ?? "Unable to sync durable command."));
56
+ }
57
+ function isActiveCommand(record) {
58
+ return (record.syncStatus !== "failed" ||
59
+ shouldRetry(classifyStoredError(record.error), record.payload));
25
60
  }
26
61
  function scheduleDeferredRecovery(records) {
27
62
  if (deferredRecoveryTimer) {
@@ -37,32 +72,62 @@ export function createDurableCommandStore({ canCancel = () => true, classifyErro
37
72
  }, null);
38
73
  if (nextDeadline === null)
39
74
  return;
40
- deferredRecoveryTimer = setTimeout(() => void recover(), Math.max(0, nextDeadline - currentTime));
75
+ deferredRecoveryTimer = setTimeout(recoverInBackground, Math.max(0, nextDeadline - currentTime));
41
76
  }
42
- async function publish() {
43
- const records = await store.list();
77
+ function currentRecords() {
78
+ const records = new Map(storedRecords.map((record) => [record.id, record]));
79
+ for (const record of provisionalRecords.values()) {
80
+ records.set(record.id, record);
81
+ }
82
+ return [...records.values()].sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
83
+ left.id.localeCompare(right.id));
84
+ }
85
+ function applySnapshot(records) {
44
86
  snapshot = {
45
- operations: records.map((record) => ({
46
- command: record.payload,
47
- ...(record.error
48
- ? { error: classifyError(new Error(record.error)) }
49
- : {}),
50
- id: record.id,
51
- status: record.syncStatus === "failed"
52
- ? "failed"
53
- : record.syncStatus === "syncing"
54
- ? "syncing"
55
- : "accepted",
56
- })),
57
- state: project(baseState, records.filter((record) => record.syncStatus !== "failed")),
87
+ operations: [
88
+ ...records.map((record) => ({
89
+ command: record.payload,
90
+ ...(record.syncStatus === "failed"
91
+ ? { error: classifyStoredError(record.error) }
92
+ : {}),
93
+ id: record.id,
94
+ status: record.syncStatus === "failed"
95
+ ? "failed"
96
+ : record.syncStatus === "syncing"
97
+ ? "syncing"
98
+ : "accepted",
99
+ })),
100
+ ...[...localStorageFailures].map(([id, command]) => ({
101
+ command,
102
+ error: "local-storage",
103
+ id,
104
+ status: "failed",
105
+ })),
106
+ ],
107
+ state: project(baseState, records.filter(isActiveCommand)),
58
108
  };
59
109
  scheduleDeferredRecovery(records);
110
+ }
111
+ async function publish() {
112
+ const generation = ++publishGeneration;
113
+ const records = await store.list();
114
+ if (generation !== publishGeneration)
115
+ return;
116
+ storedRecords = records;
117
+ applySnapshot(currentRecords());
60
118
  notify();
61
119
  }
62
120
  async function recover() {
63
121
  await executor.recover();
64
122
  await publish();
65
123
  }
124
+ function recoverInBackground() {
125
+ void recover().catch((error) => {
126
+ console.error("[Bayon offline] Unable to recover durable commands.", {
127
+ error,
128
+ });
129
+ });
130
+ }
66
131
  const unsubscribe = store.subscribe(() => {
67
132
  void publish().catch((error) => {
68
133
  console.error("[Bayon offline] Unable to publish durable commands.", {
@@ -72,6 +137,11 @@ export function createDurableCommandStore({ canCancel = () => true, classifyErro
72
137
  });
73
138
  return {
74
139
  async cancel(operationId) {
140
+ if (localStorageFailures.delete(operationId)) {
141
+ applySnapshot(currentRecords());
142
+ notify();
143
+ return true;
144
+ }
75
145
  const record = await store.get(operationId);
76
146
  if (!record ||
77
147
  record.syncStatus === "syncing" ||
@@ -86,6 +156,7 @@ export function createDurableCommandStore({ canCancel = () => true, classifyErro
86
156
  close() {
87
157
  if (deferredRecoveryTimer)
88
158
  clearTimeout(deferredRecoveryTimer);
159
+ publishGeneration += 1;
89
160
  unsubscribe();
90
161
  },
91
162
  dispatch(command, options = {}) {
@@ -100,36 +171,20 @@ export function createDurableCommandStore({ canCancel = () => true, classifyErro
100
171
  snapshotId: operationId,
101
172
  syncStatus: "pending",
102
173
  };
103
- // Project before storage settles so controls never wait on storage I/O.
104
- snapshot = {
105
- operations: [
106
- ...snapshot.operations,
107
- { command, id: operationId, status: "accepted" },
108
- ],
109
- state: project(baseState, [
110
- ...snapshot.operations
111
- .filter((operation) => operation.status !== "failed")
112
- .map((operation) => ({
113
- id: operation.id,
114
- payload: operation.command,
115
- savedAt: record.savedAt,
116
- snapshotId: operation.id,
117
- syncStatus: "pending",
118
- })),
119
- record,
120
- ]),
121
- };
174
+ // Product decision: provisional records remain part of the same ordered
175
+ // projection as persisted retryable failures while storage settles.
176
+ provisionalRecords.set(operationId, record);
177
+ localStorageFailures.delete(operationId);
178
+ applySnapshot(currentRecords());
122
179
  notify();
123
180
  const accepted = store.set(record).then(async () => {
181
+ provisionalRecords.delete(operationId);
124
182
  await publish();
125
- void recover();
183
+ recoverInBackground();
126
184
  }, async (error) => {
127
- snapshot = {
128
- operations: snapshot.operations.map((operation) => operation.id === operationId
129
- ? { ...operation, error: "local-storage", status: "failed" }
130
- : operation),
131
- state: baseState,
132
- };
185
+ provisionalRecords.delete(operationId);
186
+ localStorageFailures.set(operationId, command);
187
+ applySnapshot(currentRecords());
133
188
  notify();
134
189
  throw error;
135
190
  });
@@ -140,9 +195,9 @@ export function createDurableCommandStore({ canCancel = () => true, classifyErro
140
195
  await recover();
141
196
  },
142
197
  async refresh(scope) {
143
- const generation = ++refreshGeneration;
198
+ const generation = ++baseStateGeneration;
144
199
  const loaded = await load(scope);
145
- if (generation !== refreshGeneration)
200
+ if (generation !== baseStateGeneration)
146
201
  return;
147
202
  baseState = loaded;
148
203
  await publish();
@@ -154,5 +209,15 @@ export function createDurableCommandStore({ canCancel = () => true, classifyErro
154
209
  };
155
210
  }
156
211
  function createDurableCommandId() {
157
- return globalThis.crypto?.randomUUID?.() ?? `command-${Date.now()}`;
212
+ return globalThis.crypto?.randomUUID?.() ?? createFallbackDurableCommandId();
213
+ }
214
+ let fallbackDurableCommandSequence = 0;
215
+ function createFallbackDurableCommandId() {
216
+ fallbackDurableCommandSequence += 1;
217
+ return [
218
+ "command",
219
+ Date.now().toString(36),
220
+ fallbackDurableCommandSequence.toString(36),
221
+ Math.random().toString(36).slice(2),
222
+ ].join("-");
158
223
  }
@@ -20,4 +20,7 @@ export type BrowserKeyValueDriverOptions = {
20
20
  operationTimeoutMs: number;
21
21
  storeName: string;
22
22
  };
23
+ export declare class OfflineStorageError extends Error {
24
+ name: string;
25
+ }
23
26
  export declare function createIdbKeyValueDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserKeyValueDriverOptions): BrowserKeyValueDriver | null;
package/dist/idbDriver.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { wrap, } from "idb";
2
+ export class OfflineStorageError extends Error {
3
+ name = "OfflineStorageError";
4
+ }
2
5
  export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB = globalThis.indexedDB, operationTimeoutMs, storeName, }) {
3
6
  if (!indexedDB) {
4
7
  return null;
@@ -9,25 +12,33 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
9
12
  indexedDB,
10
13
  storeName,
11
14
  });
15
+ function runMutation(operationName, mutate) {
16
+ return runTransactionalStorageOperation({
17
+ failureMode: "throw",
18
+ fallback: undefined,
19
+ operationName,
20
+ storeName,
21
+ operation: async (setTransaction, isCancelled) => {
22
+ const database = await databasePromise;
23
+ if (isCancelled())
24
+ return;
25
+ const transaction = database.transaction(storeName, "readwrite");
26
+ setTransaction(transaction);
27
+ await mutate(transaction.objectStore(storeName));
28
+ await transaction.done;
29
+ },
30
+ timeoutMs: operationTimeoutMs,
31
+ });
32
+ }
12
33
  return {
13
34
  async clear() {
14
- await runTransactionalStorageOperation({
15
- fallback: undefined,
16
- operationName: "clear object store",
17
- operation: async (setTransaction) => {
18
- const database = await databasePromise;
19
- const transaction = database.transaction(storeName, "readwrite");
20
- setTransaction(transaction);
21
- await transaction.objectStore(storeName).clear();
22
- await transaction.done;
23
- },
24
- timeoutMs: operationTimeoutMs,
25
- });
35
+ await runMutation("clear object store", (objectStore) => objectStore.clear());
26
36
  },
27
37
  async close() {
28
38
  await runBrowserStorageOperation({
29
39
  fallback: undefined,
30
40
  operationName: "close database",
41
+ storeName,
31
42
  operation: async () => {
32
43
  const database = await databasePromise;
33
44
  database.close();
@@ -36,18 +47,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
36
47
  });
37
48
  },
38
49
  async delete(key) {
39
- await runTransactionalStorageOperation({
40
- fallback: undefined,
41
- operationName: "delete key",
42
- operation: async (setTransaction) => {
43
- const database = await databasePromise;
44
- const transaction = database.transaction(storeName, "readwrite");
45
- setTransaction(transaction);
46
- await transaction.objectStore(storeName).delete(key);
47
- await transaction.done;
48
- },
49
- timeoutMs: operationTimeoutMs,
50
- });
50
+ await runMutation("delete key", (objectStore) => objectStore.delete(key));
51
51
  },
52
52
  async deleteIf(key, predicate) {
53
53
  return runConditionalStorageOperation({
@@ -68,6 +68,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
68
68
  return runBrowserStorageOperation({
69
69
  fallback: null,
70
70
  operationName: "read key",
71
+ storeName,
71
72
  operation: async () => {
72
73
  const database = await databasePromise;
73
74
  return (await database.get(storeName, key)) ?? null;
@@ -79,6 +80,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
79
80
  return runBrowserStorageOperation({
80
81
  fallback: [],
81
82
  operationName: "list object store",
83
+ storeName,
82
84
  operation: async () => {
83
85
  const database = await databasePromise;
84
86
  return database.getAll(storeName);
@@ -90,6 +92,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
90
92
  return runBrowserStorageOperation({
91
93
  fallback: [],
92
94
  operationName: "list object-store entries",
95
+ storeName,
93
96
  operation: async () => {
94
97
  const database = await databasePromise;
95
98
  // Keys and values must come from one transaction. Separate database
@@ -111,18 +114,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
111
114
  });
112
115
  },
113
116
  async set(key, value) {
114
- await runTransactionalStorageOperation({
115
- fallback: undefined,
116
- operationName: "write key",
117
- operation: async (setTransaction) => {
118
- const database = await databasePromise;
119
- const transaction = database.transaction(storeName, "readwrite");
120
- setTransaction(transaction);
121
- await transaction.objectStore(storeName).put(value, key);
122
- await transaction.done;
123
- },
124
- timeoutMs: operationTimeoutMs,
125
- });
117
+ await runMutation("write key", (objectStore) => objectStore.put(value, key));
126
118
  },
127
119
  async setIf(key, value, predicate) {
128
120
  return runConditionalStorageOperation({
@@ -155,18 +147,27 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
155
147
  }
156
148
  return database;
157
149
  }
158
- async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
150
+ async function runBrowserStorageOperation({ cancelOnTimeout, failureMode = "fallback", fallback, operation, operationName, storeName, timeoutMs, }) {
159
151
  let timeoutId = null;
152
+ const startedAt = Date.now();
160
153
  try {
161
154
  return await Promise.race([
162
155
  operation(),
163
- new Promise((resolve) => {
156
+ new Promise((resolve, reject) => {
164
157
  timeoutId = setTimeout(() => {
165
- console.error(`IndexedDB timed out while trying to ${operationName}.`, {
158
+ console.warn("[Bayon offline] IndexedDB operation timed out.", {
159
+ elapsedMs: Date.now() - startedAt,
160
+ operationName,
161
+ storeName,
166
162
  timeoutMs,
167
163
  });
168
164
  if (!cancelOnTimeout || cancelOnTimeout()) {
169
- resolve(fallback);
165
+ if (failureMode === "throw") {
166
+ reject(new OfflineStorageError(`IndexedDB timed out while attempting to ${operationName} in ${storeName}.`));
167
+ }
168
+ else {
169
+ resolve(fallback);
170
+ }
170
171
  }
171
172
  }, timeoutMs);
172
173
  }),
@@ -174,6 +175,11 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
174
175
  }
175
176
  catch (error) {
176
177
  console.error(`IndexedDB failed to ${operationName}.`, { error });
178
+ if (failureMode === "throw") {
179
+ if (error instanceof OfflineStorageError)
180
+ throw error;
181
+ throw new OfflineStorageError(`IndexedDB failed to ${operationName} in ${storeName}.`, { cause: error });
182
+ }
177
183
  return fallback;
178
184
  }
179
185
  finally {
@@ -182,12 +188,14 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
182
188
  }
183
189
  }
184
190
  }
185
- async function runTransactionalStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
191
+ async function runTransactionalStorageOperation({ failureMode, fallback, operation, operationName, storeName, timeoutMs, }) {
186
192
  let transaction;
187
193
  let cancelled = false;
188
194
  return runBrowserStorageOperation({
195
+ failureMode,
189
196
  fallback,
190
197
  operationName,
198
+ storeName,
191
199
  timeoutMs,
192
200
  cancelOnTimeout() {
193
201
  cancelled = true;
@@ -203,14 +211,12 @@ async function runTransactionalStorageOperation({ fallback, operation, operation
203
211
  }
204
212
  },
205
213
  operation: async () => {
206
- if (cancelled)
207
- return fallback;
208
214
  return operation((nextTransaction) => {
209
215
  transaction = nextTransaction;
210
216
  // Aborting can reject transaction.done. The operation also awaits it,
211
217
  // but observing it here prevents a race from becoming unhandled.
212
218
  void transaction.done.catch(() => undefined);
213
- });
219
+ }, () => cancelled);
214
220
  },
215
221
  });
216
222
  }
@@ -218,8 +224,10 @@ async function runConditionalStorageOperation({ apply, databasePromise, operatio
218
224
  let transaction;
219
225
  let cancelled = false;
220
226
  return runBrowserStorageOperation({
227
+ failureMode: "throw",
221
228
  fallback: false,
222
229
  operationName,
230
+ storeName,
223
231
  timeoutMs,
224
232
  cancelOnTimeout() {
225
233
  cancelled = true;
package/dist/index.d.ts CHANGED
@@ -4,3 +4,4 @@ export * from "./browserRecovery";
4
4
  export * from "./browserCoordination";
5
5
  export * from "./browserFallbackRecovery";
6
6
  export * from "./durableCommandStore";
7
+ export { OfflineStorageError } from "./idbDriver";
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export * from "./browserRecovery.js";
4
4
  export * from "./browserCoordination.js";
5
5
  export * from "./browserFallbackRecovery.js";
6
6
  export * from "./durableCommandStore.js";
7
+ export { OfflineStorageError } from "./idbDriver.js";
@@ -94,7 +94,11 @@ export type SyncPendingWritesResult = {
94
94
  superseded: number;
95
95
  synced: number;
96
96
  };
97
- export type PendingWriteSync<Payload> = (payload: Payload) => Promise<unknown>;
97
+ export type PendingWriteSyncContext = {
98
+ /** Stable durable-record identity; safe to use as a remote idempotency key. */
99
+ recordId: string;
100
+ };
101
+ export type PendingWriteSync<Payload, Result = unknown> = (payload: Payload, context: PendingWriteSyncContext) => Promise<Result>;
98
102
  export type RunPendingWriteResult<TResult, TQueued> = {
99
103
  result: TResult;
100
104
  status: "synced";
@@ -130,7 +134,7 @@ export declare function createMemoryPendingWriteDriver(): PendingWriteDriver;
130
134
  export declare function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: Omit<BrowserPendingWriteStoreOptions, "keyPrefix" | "normalizePayload" | "schemaVersion">): PendingWriteDriver | null;
131
135
  export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
132
136
  export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
133
- export declare function createPendingWriteSnapshotId(): `${string}-${string}-${string}-${string}-${string}`;
137
+ export declare function createPendingWriteSnapshotId(): string;
134
138
  export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
135
139
  id: string;
136
140
  now?: () => string;
@@ -142,7 +146,8 @@ export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
142
146
  * same store/scope. Callers own the selection predicate and idempotent remote
143
147
  * mutation semantics.
144
148
  */
145
- export declare function syncPendingWrites<Payload>({ scopeKey, select, stopOnFailure, store, sync, }: {
149
+ export declare function syncPendingWrites<Payload>({ now, scopeKey, select, stopOnFailure, store, sync, }: {
150
+ now?: () => string;
146
151
  scopeKey?: string;
147
152
  select?: (record: PendingWriteRecord<Payload>) => boolean;
148
153
  /** Preserve caller ordering by leaving later records for a future retry. */
@@ -161,7 +166,8 @@ export declare function summarizePendingWrites<Payload>(records: Array<PendingWr
161
166
  * workspace. Use its `recover` method for background replay and `run` for new
162
167
  * writes so a newer remote mutation cannot overtake an older queued mutation.
163
168
  */
164
- export declare function createOrderedPendingWriteExecutor<Payload>({ scopeKey, select, store, sync, }: {
169
+ export declare function createOrderedPendingWriteExecutor<Payload>({ now, scopeKey, select, store, sync, }: {
170
+ now?: () => string;
165
171
  scopeKey?: string;
166
172
  select?: (record: PendingWriteRecord<Payload>) => boolean;
167
173
  store: PendingWriteStore<Payload>;