@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
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
25
+
26
+ - Added `createDurableCommandStore`, a framework-neutral store that projects
27
+ accepted commands immediately while persisting them before remote execution.
28
+ - Added durable `notBefore` deadlines to pending writes, preserving ordered
29
+ deferred commands across navigation and restart.
30
+
31
+ ## 0.6.0 - 2026-09-14
32
+
33
+ - Preserved the original, non-persisted failure value on failed pending-write
34
+ sync results so host applications can classify errors without losing stack
35
+ traces or structured conflict details.
36
+
3
37
  ## 0.5.1 - 2026-09-12
4
38
 
5
39
  - Prevented timed-out conditional IndexedDB replacements and deletions from
package/README.md CHANGED
@@ -98,6 +98,10 @@ await syncPendingWrite({
98
98
  });
99
99
  ```
100
100
 
101
+ Failed sync results include a safe persisted `error` string and the original
102
+ non-persisted `failure` value. Hosts may use `failure` for structured logging or
103
+ domain error classification; it is never written into the pending-write store.
104
+
101
105
  For a browser application that wants durable, silent recovery, mount the
102
106
  headless controller once for the active account or workspace:
103
107
 
@@ -187,6 +191,65 @@ the later write queued until the predecessor is delivered. This protects remote
187
191
  write order; it does not replace idempotent server-side mutation handling for
188
192
  ambiguous request outcomes.
189
193
 
194
+ ## Durable Command Store
195
+
196
+ Use `createDurableCommandStore` when a host needs one client-side owner for
197
+ base data, optimistic projection, durable commands, and remote reconciliation.
198
+ The host supplies its domain projection and authenticated transport; this
199
+ package never imports an application model, database SDK, or framework.
200
+
201
+ ```ts
202
+ const commands = createDurableCommandStore({
203
+ initialState: workspace,
204
+ load: (scope) => workspaceApi.load(scope),
205
+ execute: (command, { operationId }) =>
206
+ workspaceApi.execute(command, { operationId }),
207
+ project: (state, pending) => projectWorkspace(state, pending),
208
+ reconcile: (state, command, result) =>
209
+ reconcileWorkspace(state, command, result),
210
+ shouldRetry: (category) => category === "connectivity",
211
+ store,
212
+ });
213
+
214
+ const { accepted, operationId } = commands.dispatch(command);
215
+ await accepted; // locally durable; remote confirmation continues in background
216
+
217
+ // A delayed command stays ordered and can be cancelled before its deadline.
218
+ const deferred = commands.dispatch(deleteCommand, { notBefore: deadline });
219
+ await commands.cancel(deferred.operationId);
220
+ ```
221
+
222
+ `getSnapshot` and `subscribe` expose projected state and command status to any
223
+ UI framework. `refresh` ignores older read results, `recover` replays durable
224
+ commands in order, and `close` releases the local subscription and delayed
225
+ deadline timer. Local-storage failure rejects `accepted`; hosts must show that
226
+ failure instead of claiming the command will recover.
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
+
190
253
  Use generic record summaries when an app needs to render its own status:
191
254
 
192
255
  ```ts
@@ -250,10 +313,13 @@ pnpm --filter @bayonai/offline pack --dry-run
250
313
  ## Publish Checklist
251
314
 
252
315
  1. Run the package tests and build.
253
- 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
254
318
  includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
255
319
  `CHANGELOG.md`, and `LICENSE`.
256
- 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
257
323
  `publishConfig.access` are correct.
258
- 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
259
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;
@@ -0,0 +1,49 @@
1
+ import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites";
2
+ export type DurableCommandErrorCategory = "auth" | "conflict" | "connectivity" | "local-storage" | "permission" | "unexpected" | "validation";
3
+ export type DurableCommandOperation<Command> = {
4
+ command: Command;
5
+ error?: DurableCommandErrorCategory;
6
+ id: string;
7
+ status: "accepted" | "failed" | "syncing";
8
+ };
9
+ export type DurableCommandSnapshot<State, Command> = {
10
+ operations: DurableCommandOperation<Command>[];
11
+ state: State;
12
+ };
13
+ export type DurableCommandDispatch = {
14
+ accepted: Promise<void>;
15
+ operationId: string;
16
+ };
17
+ export type DurableCommandExecutionContext = {
18
+ /** Stable across retries so the remote command can be idempotent. */
19
+ operationId: string;
20
+ };
21
+ export type DurableCommandStore<State, Command, Scope> = {
22
+ cancel: (operationId: string) => Promise<boolean>;
23
+ close: () => void;
24
+ dispatch: (command: Command, options?: {
25
+ notBefore?: string;
26
+ }) => DurableCommandDispatch;
27
+ getSnapshot: () => DurableCommandSnapshot<State, Command>;
28
+ recover: () => Promise<void>;
29
+ refresh: (scope: Scope) => Promise<void>;
30
+ subscribe: (listener: () => void) => () => void;
31
+ };
32
+ /**
33
+ * Hosts provide domain projection and transport; this package only owns the
34
+ * durable command lifecycle. A command becomes visible immediately, but is
35
+ * never sent remotely until IndexedDB has accepted it.
36
+ */
37
+ export declare function createDurableCommandStore<State, Command, Scope, Result = unknown>({ canCancel, classifyError, createId, execute, initialState, load, now, project, reconcile, shouldRetry, store, }: {
38
+ canCancel?: (command: Command, now: string) => boolean;
39
+ classifyError?: (error: unknown) => DurableCommandErrorCategory;
40
+ createId?: () => string;
41
+ execute: (command: Command, context: DurableCommandExecutionContext) => Promise<Result>;
42
+ initialState: State;
43
+ load: (scope: Scope) => Promise<State>;
44
+ now?: () => string;
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;
48
+ store: PendingWriteStore<Command>;
49
+ }): DurableCommandStore<State, Command, Scope>;
@@ -0,0 +1,223 @@
1
+ import { createOrderedPendingWriteExecutor, } from "./pendingWrites.js";
2
+ /**
3
+ * Hosts provide domain projection and transport; this package only owns the
4
+ * durable command lifecycle. A command becomes visible immediately, but is
5
+ * never sent remotely until IndexedDB has accepted it.
6
+ */
7
+ export function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, reconcile, shouldRetry = () => true, store, }) {
8
+ let baseState = initialState;
9
+ let baseStateGeneration = 0;
10
+ let savedAtMillis = 0;
11
+ let publishGeneration = 0;
12
+ let storedRecords = [];
13
+ let deferredRecoveryTimer = null;
14
+ const provisionalRecords = new Map();
15
+ const localStorageFailures = new Map();
16
+ let snapshot = {
17
+ operations: [],
18
+ state: initialState,
19
+ };
20
+ const listeners = new Set();
21
+ const executor = createOrderedPendingWriteExecutor({
22
+ now,
23
+ scopeKey: "durable-command-store",
24
+ select: isActiveCommand,
25
+ store,
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
+ },
41
+ });
42
+ function notify() {
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));
60
+ }
61
+ function scheduleDeferredRecovery(records) {
62
+ if (deferredRecoveryTimer) {
63
+ clearTimeout(deferredRecoveryTimer);
64
+ deferredRecoveryTimer = null;
65
+ }
66
+ const currentTime = Date.parse(now());
67
+ const nextDeadline = records.reduce((earliest, record) => {
68
+ const deadline = record.notBefore ? Date.parse(record.notBefore) : NaN;
69
+ if (!Number.isFinite(deadline) || deadline <= currentTime)
70
+ return earliest;
71
+ return earliest === null ? deadline : Math.min(earliest, deadline);
72
+ }, null);
73
+ if (nextDeadline === null)
74
+ return;
75
+ deferredRecoveryTimer = setTimeout(recoverInBackground, Math.max(0, nextDeadline - currentTime));
76
+ }
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) {
86
+ snapshot = {
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)),
108
+ };
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());
118
+ notify();
119
+ }
120
+ async function recover() {
121
+ await executor.recover();
122
+ await publish();
123
+ }
124
+ function recoverInBackground() {
125
+ void recover().catch((error) => {
126
+ console.error("[Bayon offline] Unable to recover durable commands.", {
127
+ error,
128
+ });
129
+ });
130
+ }
131
+ const unsubscribe = store.subscribe(() => {
132
+ void publish().catch((error) => {
133
+ console.error("[Bayon offline] Unable to publish durable commands.", {
134
+ error,
135
+ });
136
+ });
137
+ });
138
+ return {
139
+ async cancel(operationId) {
140
+ if (localStorageFailures.delete(operationId)) {
141
+ applySnapshot(currentRecords());
142
+ notify();
143
+ return true;
144
+ }
145
+ const record = await store.get(operationId);
146
+ if (!record ||
147
+ record.syncStatus === "syncing" ||
148
+ !canCancel(record.payload, now())) {
149
+ return false;
150
+ }
151
+ const cancelled = await store.clearIfSnapshotId(record.id, record.snapshotId);
152
+ if (cancelled)
153
+ await publish();
154
+ return cancelled;
155
+ },
156
+ close() {
157
+ if (deferredRecoveryTimer)
158
+ clearTimeout(deferredRecoveryTimer);
159
+ publishGeneration += 1;
160
+ unsubscribe();
161
+ },
162
+ dispatch(command, options = {}) {
163
+ const operationId = createId();
164
+ const timestamp = Math.max(Date.parse(now()), savedAtMillis + 1);
165
+ savedAtMillis = timestamp;
166
+ const record = {
167
+ id: operationId,
168
+ ...(options.notBefore ? { notBefore: options.notBefore } : {}),
169
+ payload: command,
170
+ savedAt: new Date(timestamp).toISOString(),
171
+ snapshotId: operationId,
172
+ syncStatus: "pending",
173
+ };
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());
179
+ notify();
180
+ const accepted = store.set(record).then(async () => {
181
+ provisionalRecords.delete(operationId);
182
+ await publish();
183
+ recoverInBackground();
184
+ }, async (error) => {
185
+ provisionalRecords.delete(operationId);
186
+ localStorageFailures.set(operationId, command);
187
+ applySnapshot(currentRecords());
188
+ notify();
189
+ throw error;
190
+ });
191
+ return { accepted, operationId };
192
+ },
193
+ getSnapshot: () => snapshot,
194
+ async recover() {
195
+ await recover();
196
+ },
197
+ async refresh(scope) {
198
+ const generation = ++baseStateGeneration;
199
+ const loaded = await load(scope);
200
+ if (generation !== baseStateGeneration)
201
+ return;
202
+ baseState = loaded;
203
+ await publish();
204
+ },
205
+ subscribe(listener) {
206
+ listeners.add(listener);
207
+ return () => listeners.delete(listener);
208
+ },
209
+ };
210
+ }
211
+ function createDurableCommandId() {
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("-");
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,22 +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 runBrowserStorageOperation({
15
- fallback: undefined,
16
- operationName: "clear object store",
17
- operation: async () => {
18
- const database = await databasePromise;
19
- await database.clear(storeName);
20
- },
21
- timeoutMs: operationTimeoutMs,
22
- });
35
+ await runMutation("clear object store", (objectStore) => objectStore.clear());
23
36
  },
24
37
  async close() {
25
38
  await runBrowserStorageOperation({
26
39
  fallback: undefined,
27
40
  operationName: "close database",
41
+ storeName,
28
42
  operation: async () => {
29
43
  const database = await databasePromise;
30
44
  database.close();
@@ -33,15 +47,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
33
47
  });
34
48
  },
35
49
  async delete(key) {
36
- await runBrowserStorageOperation({
37
- fallback: undefined,
38
- operationName: "delete key",
39
- operation: async () => {
40
- const database = await databasePromise;
41
- await database.delete(storeName, key);
42
- },
43
- timeoutMs: operationTimeoutMs,
44
- });
50
+ await runMutation("delete key", (objectStore) => objectStore.delete(key));
45
51
  },
46
52
  async deleteIf(key, predicate) {
47
53
  return runConditionalStorageOperation({
@@ -62,6 +68,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
62
68
  return runBrowserStorageOperation({
63
69
  fallback: null,
64
70
  operationName: "read key",
71
+ storeName,
65
72
  operation: async () => {
66
73
  const database = await databasePromise;
67
74
  return (await database.get(storeName, key)) ?? null;
@@ -73,6 +80,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
73
80
  return runBrowserStorageOperation({
74
81
  fallback: [],
75
82
  operationName: "list object store",
83
+ storeName,
76
84
  operation: async () => {
77
85
  const database = await databasePromise;
78
86
  return database.getAll(storeName);
@@ -84,6 +92,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
84
92
  return runBrowserStorageOperation({
85
93
  fallback: [],
86
94
  operationName: "list object-store entries",
95
+ storeName,
87
96
  operation: async () => {
88
97
  const database = await databasePromise;
89
98
  // Keys and values must come from one transaction. Separate database
@@ -105,15 +114,7 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
105
114
  });
106
115
  },
107
116
  async set(key, value) {
108
- await runBrowserStorageOperation({
109
- fallback: undefined,
110
- operationName: "write key",
111
- operation: async () => {
112
- const database = await databasePromise;
113
- await database.put(storeName, value, key);
114
- },
115
- timeoutMs: operationTimeoutMs,
116
- });
117
+ await runMutation("write key", (objectStore) => objectStore.put(value, key));
117
118
  },
118
119
  async setIf(key, value, predicate) {
119
120
  return runConditionalStorageOperation({
@@ -146,18 +147,27 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
146
147
  }
147
148
  return database;
148
149
  }
149
- async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
150
+ async function runBrowserStorageOperation({ cancelOnTimeout, failureMode = "fallback", fallback, operation, operationName, storeName, timeoutMs, }) {
150
151
  let timeoutId = null;
152
+ const startedAt = Date.now();
151
153
  try {
152
154
  return await Promise.race([
153
155
  operation(),
154
- new Promise((resolve) => {
156
+ new Promise((resolve, reject) => {
155
157
  timeoutId = setTimeout(() => {
156
- 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,
157
162
  timeoutMs,
158
163
  });
159
164
  if (!cancelOnTimeout || cancelOnTimeout()) {
160
- 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
+ }
161
171
  }
162
172
  }, timeoutMs);
163
173
  }),
@@ -165,6 +175,11 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
165
175
  }
166
176
  catch (error) {
167
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
+ }
168
183
  return fallback;
169
184
  }
170
185
  finally {
@@ -173,12 +188,46 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
173
188
  }
174
189
  }
175
190
  }
191
+ async function runTransactionalStorageOperation({ failureMode, fallback, operation, operationName, storeName, timeoutMs, }) {
192
+ let transaction;
193
+ let cancelled = false;
194
+ return runBrowserStorageOperation({
195
+ failureMode,
196
+ fallback,
197
+ operationName,
198
+ storeName,
199
+ timeoutMs,
200
+ cancelOnTimeout() {
201
+ cancelled = true;
202
+ if (!transaction)
203
+ return true;
204
+ try {
205
+ transaction.abort();
206
+ return true;
207
+ }
208
+ catch (error) {
209
+ console.warn("[Bayon offline] IndexedDB transaction already settling at timeout.", { error, operationName });
210
+ return false;
211
+ }
212
+ },
213
+ operation: async () => {
214
+ return operation((nextTransaction) => {
215
+ transaction = nextTransaction;
216
+ // Aborting can reject transaction.done. The operation also awaits it,
217
+ // but observing it here prevents a race from becoming unhandled.
218
+ void transaction.done.catch(() => undefined);
219
+ }, () => cancelled);
220
+ },
221
+ });
222
+ }
176
223
  async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
177
224
  let transaction;
178
225
  let cancelled = false;
179
226
  return runBrowserStorageOperation({
227
+ failureMode: "throw",
180
228
  fallback: false,
181
229
  operationName,
230
+ storeName,
182
231
  timeoutMs,
183
232
  cancelOnTimeout() {
184
233
  cancelled = true;
package/dist/index.d.ts CHANGED
@@ -3,3 +3,5 @@ export * from "./pendingWrites";
3
3
  export * from "./browserRecovery";
4
4
  export * from "./browserCoordination";
5
5
  export * from "./browserFallbackRecovery";
6
+ export * from "./durableCommandStore";
7
+ export { OfflineStorageError } from "./idbDriver";
package/dist/index.js CHANGED
@@ -3,3 +3,5 @@ export * from "./pendingWrites.js";
3
3
  export * from "./browserRecovery.js";
4
4
  export * from "./browserCoordination.js";
5
5
  export * from "./browserFallbackRecovery.js";
6
+ export * from "./durableCommandStore.js";
7
+ export { OfflineStorageError } from "./idbDriver.js";