@bayonai/offline 0.5.1 → 0.7.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - Unreleased
4
+
5
+ - Added `createDurableCommandStore`, a framework-neutral store that projects
6
+ accepted commands immediately while persisting them before remote execution.
7
+ - Added durable `notBefore` deadlines to pending writes, preserving ordered
8
+ deferred commands across navigation and restart.
9
+
10
+ ## 0.6.0 - 2026-09-14
11
+
12
+ - Preserved the original, non-persisted failure value on failed pending-write
13
+ sync results so host applications can classify errors without losing stack
14
+ traces or structured conflict details.
15
+
3
16
  ## 0.5.1 - 2026-09-12
4
17
 
5
18
  - 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,36 @@ 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) => workspaceApi.execute(command),
206
+ project: (state, pending) => projectWorkspace(state, pending),
207
+ store,
208
+ });
209
+
210
+ const { accepted, operationId } = commands.dispatch(command);
211
+ await accepted; // locally durable; remote confirmation continues in background
212
+
213
+ // A delayed command stays ordered and can be cancelled before its deadline.
214
+ const deferred = commands.dispatch(deleteCommand, { notBefore: deadline });
215
+ await commands.cancel(deferred.operationId);
216
+ ```
217
+
218
+ `getSnapshot` and `subscribe` expose projected state and command status to any
219
+ UI framework. `refresh` ignores older read results, `recover` replays durable
220
+ commands in order, and `close` releases the local subscription and delayed
221
+ deadline timer. Local-storage failure rejects `accepted`; hosts must show that
222
+ failure instead of claiming the command will recover.
223
+
190
224
  Use generic record summaries when an app needs to render its own status:
191
225
 
192
226
  ```ts
@@ -0,0 +1,43 @@
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 DurableCommandStore<State, Command, Scope> = {
18
+ cancel: (operationId: string) => Promise<boolean>;
19
+ close: () => void;
20
+ dispatch: (command: Command, options?: {
21
+ notBefore?: string;
22
+ }) => DurableCommandDispatch;
23
+ getSnapshot: () => DurableCommandSnapshot<State, Command>;
24
+ recover: () => Promise<void>;
25
+ refresh: (scope: Scope) => Promise<void>;
26
+ subscribe: (listener: () => void) => () => void;
27
+ };
28
+ /**
29
+ * Hosts provide domain projection and transport; this package only owns the
30
+ * durable command lifecycle. A command becomes visible immediately, but is
31
+ * never sent remotely until IndexedDB has accepted it.
32
+ */
33
+ export declare function createDurableCommandStore<State, Command, Scope>({ canCancel, classifyError, createId, execute, initialState, load, now, project, store, }: {
34
+ canCancel?: (command: Command, now: string) => boolean;
35
+ classifyError?: (error: unknown) => DurableCommandErrorCategory;
36
+ createId?: () => string;
37
+ execute: (command: Command) => Promise<unknown>;
38
+ initialState: State;
39
+ load: (scope: Scope) => Promise<State>;
40
+ now?: () => string;
41
+ project: (state: State, commands: readonly PendingWriteRecord<Command>[]) => State;
42
+ store: PendingWriteStore<Command>;
43
+ }): DurableCommandStore<State, Command, Scope>;
@@ -0,0 +1,158 @@
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, store, }) {
8
+ let baseState = initialState;
9
+ let refreshGeneration = 0;
10
+ let savedAtMillis = 0;
11
+ let deferredRecoveryTimer = null;
12
+ let snapshot = {
13
+ operations: [],
14
+ state: initialState,
15
+ };
16
+ const listeners = new Set();
17
+ const executor = createOrderedPendingWriteExecutor({
18
+ scopeKey: "durable-command-store",
19
+ store,
20
+ sync: execute,
21
+ });
22
+ function notify() {
23
+ for (const listener of listeners)
24
+ listener();
25
+ }
26
+ function scheduleDeferredRecovery(records) {
27
+ if (deferredRecoveryTimer) {
28
+ clearTimeout(deferredRecoveryTimer);
29
+ deferredRecoveryTimer = null;
30
+ }
31
+ const currentTime = Date.parse(now());
32
+ const nextDeadline = records.reduce((earliest, record) => {
33
+ const deadline = record.notBefore ? Date.parse(record.notBefore) : NaN;
34
+ if (!Number.isFinite(deadline) || deadline <= currentTime)
35
+ return earliest;
36
+ return earliest === null ? deadline : Math.min(earliest, deadline);
37
+ }, null);
38
+ if (nextDeadline === null)
39
+ return;
40
+ deferredRecoveryTimer = setTimeout(() => void recover(), Math.max(0, nextDeadline - currentTime));
41
+ }
42
+ async function publish() {
43
+ const records = await store.list();
44
+ 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")),
58
+ };
59
+ scheduleDeferredRecovery(records);
60
+ notify();
61
+ }
62
+ async function recover() {
63
+ await executor.recover();
64
+ await publish();
65
+ }
66
+ const unsubscribe = store.subscribe(() => {
67
+ void publish().catch((error) => {
68
+ console.error("[Bayon offline] Unable to publish durable commands.", {
69
+ error,
70
+ });
71
+ });
72
+ });
73
+ return {
74
+ async cancel(operationId) {
75
+ const record = await store.get(operationId);
76
+ if (!record ||
77
+ record.syncStatus === "syncing" ||
78
+ !canCancel(record.payload, now())) {
79
+ return false;
80
+ }
81
+ const cancelled = await store.clearIfSnapshotId(record.id, record.snapshotId);
82
+ if (cancelled)
83
+ await publish();
84
+ return cancelled;
85
+ },
86
+ close() {
87
+ if (deferredRecoveryTimer)
88
+ clearTimeout(deferredRecoveryTimer);
89
+ unsubscribe();
90
+ },
91
+ dispatch(command, options = {}) {
92
+ const operationId = createId();
93
+ const timestamp = Math.max(Date.parse(now()), savedAtMillis + 1);
94
+ savedAtMillis = timestamp;
95
+ const record = {
96
+ id: operationId,
97
+ ...(options.notBefore ? { notBefore: options.notBefore } : {}),
98
+ payload: command,
99
+ savedAt: new Date(timestamp).toISOString(),
100
+ snapshotId: operationId,
101
+ syncStatus: "pending",
102
+ };
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
+ };
122
+ notify();
123
+ const accepted = store.set(record).then(async () => {
124
+ await publish();
125
+ void recover();
126
+ }, 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
+ };
133
+ notify();
134
+ throw error;
135
+ });
136
+ return { accepted, operationId };
137
+ },
138
+ getSnapshot: () => snapshot,
139
+ async recover() {
140
+ await recover();
141
+ },
142
+ async refresh(scope) {
143
+ const generation = ++refreshGeneration;
144
+ const loaded = await load(scope);
145
+ if (generation !== refreshGeneration)
146
+ return;
147
+ baseState = loaded;
148
+ await publish();
149
+ },
150
+ subscribe(listener) {
151
+ listeners.add(listener);
152
+ return () => listeners.delete(listener);
153
+ },
154
+ };
155
+ }
156
+ function createDurableCommandId() {
157
+ return globalThis.crypto?.randomUUID?.() ?? `command-${Date.now()}`;
158
+ }
package/dist/idbDriver.js CHANGED
@@ -11,12 +11,15 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
11
11
  });
12
12
  return {
13
13
  async clear() {
14
- await runBrowserStorageOperation({
14
+ await runTransactionalStorageOperation({
15
15
  fallback: undefined,
16
16
  operationName: "clear object store",
17
- operation: async () => {
17
+ operation: async (setTransaction) => {
18
18
  const database = await databasePromise;
19
- await database.clear(storeName);
19
+ const transaction = database.transaction(storeName, "readwrite");
20
+ setTransaction(transaction);
21
+ await transaction.objectStore(storeName).clear();
22
+ await transaction.done;
20
23
  },
21
24
  timeoutMs: operationTimeoutMs,
22
25
  });
@@ -33,12 +36,15 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
33
36
  });
34
37
  },
35
38
  async delete(key) {
36
- await runBrowserStorageOperation({
39
+ await runTransactionalStorageOperation({
37
40
  fallback: undefined,
38
41
  operationName: "delete key",
39
- operation: async () => {
42
+ operation: async (setTransaction) => {
40
43
  const database = await databasePromise;
41
- await database.delete(storeName, key);
44
+ const transaction = database.transaction(storeName, "readwrite");
45
+ setTransaction(transaction);
46
+ await transaction.objectStore(storeName).delete(key);
47
+ await transaction.done;
42
48
  },
43
49
  timeoutMs: operationTimeoutMs,
44
50
  });
@@ -105,12 +111,15 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
105
111
  });
106
112
  },
107
113
  async set(key, value) {
108
- await runBrowserStorageOperation({
114
+ await runTransactionalStorageOperation({
109
115
  fallback: undefined,
110
116
  operationName: "write key",
111
- operation: async () => {
117
+ operation: async (setTransaction) => {
112
118
  const database = await databasePromise;
113
- await database.put(storeName, value, key);
119
+ const transaction = database.transaction(storeName, "readwrite");
120
+ setTransaction(transaction);
121
+ await transaction.objectStore(storeName).put(value, key);
122
+ await transaction.done;
114
123
  },
115
124
  timeoutMs: operationTimeoutMs,
116
125
  });
@@ -173,6 +182,38 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
173
182
  }
174
183
  }
175
184
  }
185
+ async function runTransactionalStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
186
+ let transaction;
187
+ let cancelled = false;
188
+ return runBrowserStorageOperation({
189
+ fallback,
190
+ operationName,
191
+ timeoutMs,
192
+ cancelOnTimeout() {
193
+ cancelled = true;
194
+ if (!transaction)
195
+ return true;
196
+ try {
197
+ transaction.abort();
198
+ return true;
199
+ }
200
+ catch (error) {
201
+ console.warn("[Bayon offline] IndexedDB transaction already settling at timeout.", { error, operationName });
202
+ return false;
203
+ }
204
+ },
205
+ operation: async () => {
206
+ if (cancelled)
207
+ return fallback;
208
+ return operation((nextTransaction) => {
209
+ transaction = nextTransaction;
210
+ // Aborting can reject transaction.done. The operation also awaits it,
211
+ // but observing it here prevents a race from becoming unhandled.
212
+ void transaction.done.catch(() => undefined);
213
+ });
214
+ },
215
+ });
216
+ }
176
217
  async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
177
218
  let transaction;
178
219
  let cancelled = false;
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./pendingWrites";
3
3
  export * from "./browserRecovery";
4
4
  export * from "./browserCoordination";
5
5
  export * from "./browserFallbackRecovery";
6
+ export * from "./durableCommandStore";
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ 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";
@@ -6,6 +6,8 @@ export type PendingWriteStatus = "pending" | "syncing" | "failed";
6
6
  export type PendingWriteRecord<Payload = unknown> = {
7
7
  error?: string;
8
8
  id: string;
9
+ /** Do not execute this accepted command before this ISO timestamp. */
10
+ notBefore?: string;
9
11
  payload: Payload;
10
12
  savedAt: string;
11
13
  snapshotId: string;
@@ -80,6 +82,7 @@ export type SyncPendingWriteResult = {
80
82
  status: "superseded";
81
83
  } | {
82
84
  error: string;
85
+ failure: unknown;
83
86
  status: "failed";
84
87
  };
85
88
  export type SyncPendingWritesResult = {
@@ -161,6 +161,12 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
161
161
  if (!pending) {
162
162
  return { status: "idle" };
163
163
  }
164
+ // A delayed command remains in its ordered lane until its grace period has
165
+ // elapsed. This makes cancellation survive UI unmounts and app restarts.
166
+ if (pending.notBefore &&
167
+ Date.parse(pending.notBefore) > Date.parse(now())) {
168
+ return { status: "idle" };
169
+ }
164
170
  const claimedSnapshotId = createPendingWriteSnapshotId();
165
171
  const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
166
172
  ...pending,
@@ -190,7 +196,7 @@ export async function syncPendingWrite({ id, now = () => new Date().toISOString(
190
196
  store,
191
197
  });
192
198
  return markedFailed
193
- ? { error: message, status: "failed" }
199
+ ? { error: message, failure: error, status: "failed" }
194
200
  : { status: "superseded" };
195
201
  }
196
202
  return (await store.clearIfSnapshotId(id, claimedSnapshotId))
@@ -280,7 +286,9 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
280
286
  // Product decision: a superseded result can represent an older live claim.
281
287
  // Preserve strict ordering by leaving every later record for a future run.
282
288
  if (stopOnFailure &&
283
- (result.status === "failed" || result.status === "superseded")) {
289
+ (result.status === "failed" ||
290
+ result.status === "idle" ||
291
+ result.status === "superseded")) {
284
292
  break;
285
293
  }
286
294
  }
@@ -402,6 +410,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
402
410
  return {
403
411
  ...(typeof record.error === "string" ? { error: record.error } : {}),
404
412
  id: record.id,
413
+ ...(typeof record.notBefore === "string"
414
+ ? { notBefore: record.notBefore }
415
+ : {}),
405
416
  payload,
406
417
  savedAt: record.savedAt,
407
418
  snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
@@ -430,6 +441,7 @@ function isPendingWriteRecord(value) {
430
441
  typeof value === "object" &&
431
442
  "id" in value &&
432
443
  typeof value.id === "string" &&
444
+ (!("notBefore" in value) || typeof value.notBefore === "string") &&
433
445
  "savedAt" in value &&
434
446
  typeof value.savedAt === "string" &&
435
447
  (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createDurableCommandStore = createDurableCommandStore;
4
+ const pendingWrites_1 = require("./pendingWrites");
5
+ /**
6
+ * Hosts provide domain projection and transport; this package only owns the
7
+ * durable command lifecycle. A command becomes visible immediately, but is
8
+ * never sent remotely until IndexedDB has accepted it.
9
+ */
10
+ function createDurableCommandStore({ canCancel = () => true, classifyError = () => "unexpected", createId = createDurableCommandId, execute, initialState, load, now = () => new Date().toISOString(), project, store, }) {
11
+ let baseState = initialState;
12
+ let refreshGeneration = 0;
13
+ let savedAtMillis = 0;
14
+ let deferredRecoveryTimer = null;
15
+ let snapshot = {
16
+ operations: [],
17
+ state: initialState,
18
+ };
19
+ const listeners = new Set();
20
+ const executor = (0, pendingWrites_1.createOrderedPendingWriteExecutor)({
21
+ scopeKey: "durable-command-store",
22
+ store,
23
+ sync: execute,
24
+ });
25
+ function notify() {
26
+ for (const listener of listeners)
27
+ listener();
28
+ }
29
+ function scheduleDeferredRecovery(records) {
30
+ if (deferredRecoveryTimer) {
31
+ clearTimeout(deferredRecoveryTimer);
32
+ deferredRecoveryTimer = null;
33
+ }
34
+ const currentTime = Date.parse(now());
35
+ const nextDeadline = records.reduce((earliest, record) => {
36
+ const deadline = record.notBefore ? Date.parse(record.notBefore) : NaN;
37
+ if (!Number.isFinite(deadline) || deadline <= currentTime)
38
+ return earliest;
39
+ return earliest === null ? deadline : Math.min(earliest, deadline);
40
+ }, null);
41
+ if (nextDeadline === null)
42
+ return;
43
+ deferredRecoveryTimer = setTimeout(() => void recover(), Math.max(0, nextDeadline - currentTime));
44
+ }
45
+ async function publish() {
46
+ const records = await store.list();
47
+ 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")),
61
+ };
62
+ scheduleDeferredRecovery(records);
63
+ notify();
64
+ }
65
+ async function recover() {
66
+ await executor.recover();
67
+ await publish();
68
+ }
69
+ const unsubscribe = store.subscribe(() => {
70
+ void publish().catch((error) => {
71
+ console.error("[Bayon offline] Unable to publish durable commands.", {
72
+ error,
73
+ });
74
+ });
75
+ });
76
+ return {
77
+ async cancel(operationId) {
78
+ const record = await store.get(operationId);
79
+ if (!record ||
80
+ record.syncStatus === "syncing" ||
81
+ !canCancel(record.payload, now())) {
82
+ return false;
83
+ }
84
+ const cancelled = await store.clearIfSnapshotId(record.id, record.snapshotId);
85
+ if (cancelled)
86
+ await publish();
87
+ return cancelled;
88
+ },
89
+ close() {
90
+ if (deferredRecoveryTimer)
91
+ clearTimeout(deferredRecoveryTimer);
92
+ unsubscribe();
93
+ },
94
+ dispatch(command, options = {}) {
95
+ const operationId = createId();
96
+ const timestamp = Math.max(Date.parse(now()), savedAtMillis + 1);
97
+ savedAtMillis = timestamp;
98
+ const record = {
99
+ id: operationId,
100
+ ...(options.notBefore ? { notBefore: options.notBefore } : {}),
101
+ payload: command,
102
+ savedAt: new Date(timestamp).toISOString(),
103
+ snapshotId: operationId,
104
+ syncStatus: "pending",
105
+ };
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
+ };
125
+ notify();
126
+ const accepted = store.set(record).then(async () => {
127
+ await publish();
128
+ void recover();
129
+ }, 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
+ };
136
+ notify();
137
+ throw error;
138
+ });
139
+ return { accepted, operationId };
140
+ },
141
+ getSnapshot: () => snapshot,
142
+ async recover() {
143
+ await recover();
144
+ },
145
+ async refresh(scope) {
146
+ const generation = ++refreshGeneration;
147
+ const loaded = await load(scope);
148
+ if (generation !== refreshGeneration)
149
+ return;
150
+ baseState = loaded;
151
+ await publish();
152
+ },
153
+ subscribe(listener) {
154
+ listeners.add(listener);
155
+ return () => listeners.delete(listener);
156
+ },
157
+ };
158
+ }
159
+ function createDurableCommandId() {
160
+ return globalThis.crypto?.randomUUID?.() ?? `command-${Date.now()}`;
161
+ }
@@ -14,12 +14,15 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
14
14
  });
15
15
  return {
16
16
  async clear() {
17
- await runBrowserStorageOperation({
17
+ await runTransactionalStorageOperation({
18
18
  fallback: undefined,
19
19
  operationName: "clear object store",
20
- operation: async () => {
20
+ operation: async (setTransaction) => {
21
21
  const database = await databasePromise;
22
- await database.clear(storeName);
22
+ const transaction = database.transaction(storeName, "readwrite");
23
+ setTransaction(transaction);
24
+ await transaction.objectStore(storeName).clear();
25
+ await transaction.done;
23
26
  },
24
27
  timeoutMs: operationTimeoutMs,
25
28
  });
@@ -36,12 +39,15 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
36
39
  });
37
40
  },
38
41
  async delete(key) {
39
- await runBrowserStorageOperation({
42
+ await runTransactionalStorageOperation({
40
43
  fallback: undefined,
41
44
  operationName: "delete key",
42
- operation: async () => {
45
+ operation: async (setTransaction) => {
43
46
  const database = await databasePromise;
44
- await database.delete(storeName, key);
47
+ const transaction = database.transaction(storeName, "readwrite");
48
+ setTransaction(transaction);
49
+ await transaction.objectStore(storeName).delete(key);
50
+ await transaction.done;
45
51
  },
46
52
  timeoutMs: operationTimeoutMs,
47
53
  });
@@ -108,12 +114,15 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
108
114
  });
109
115
  },
110
116
  async set(key, value) {
111
- await runBrowserStorageOperation({
117
+ await runTransactionalStorageOperation({
112
118
  fallback: undefined,
113
119
  operationName: "write key",
114
- operation: async () => {
120
+ operation: async (setTransaction) => {
115
121
  const database = await databasePromise;
116
- await database.put(storeName, value, key);
122
+ const transaction = database.transaction(storeName, "readwrite");
123
+ setTransaction(transaction);
124
+ await transaction.objectStore(storeName).put(value, key);
125
+ await transaction.done;
117
126
  },
118
127
  timeoutMs: operationTimeoutMs,
119
128
  });
@@ -176,6 +185,38 @@ async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation
176
185
  }
177
186
  }
178
187
  }
188
+ async function runTransactionalStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
189
+ let transaction;
190
+ let cancelled = false;
191
+ return runBrowserStorageOperation({
192
+ fallback,
193
+ operationName,
194
+ timeoutMs,
195
+ cancelOnTimeout() {
196
+ cancelled = true;
197
+ if (!transaction)
198
+ return true;
199
+ try {
200
+ transaction.abort();
201
+ return true;
202
+ }
203
+ catch (error) {
204
+ console.warn("[Bayon offline] IndexedDB transaction already settling at timeout.", { error, operationName });
205
+ return false;
206
+ }
207
+ },
208
+ operation: async () => {
209
+ if (cancelled)
210
+ return fallback;
211
+ return operation((nextTransaction) => {
212
+ transaction = nextTransaction;
213
+ // Aborting can reject transaction.done. The operation also awaits it,
214
+ // but observing it here prevents a race from becoming unhandled.
215
+ void transaction.done.catch(() => undefined);
216
+ });
217
+ },
218
+ });
219
+ }
179
220
  async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
180
221
  let transaction;
181
222
  let cancelled = false;
package/dist-cjs/index.js CHANGED
@@ -19,3 +19,4 @@ __exportStar(require("./pendingWrites"), exports);
19
19
  __exportStar(require("./browserRecovery"), exports);
20
20
  __exportStar(require("./browserCoordination"), exports);
21
21
  __exportStar(require("./browserFallbackRecovery"), exports);
22
+ __exportStar(require("./durableCommandStore"), exports);
@@ -175,6 +175,12 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
175
175
  if (!pending) {
176
176
  return { status: "idle" };
177
177
  }
178
+ // A delayed command remains in its ordered lane until its grace period has
179
+ // elapsed. This makes cancellation survive UI unmounts and app restarts.
180
+ if (pending.notBefore &&
181
+ Date.parse(pending.notBefore) > Date.parse(now())) {
182
+ return { status: "idle" };
183
+ }
178
184
  const claimedSnapshotId = createPendingWriteSnapshotId();
179
185
  const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
180
186
  ...pending,
@@ -204,7 +210,7 @@ async function syncPendingWrite({ id, now = () => new Date().toISOString(), stor
204
210
  store,
205
211
  });
206
212
  return markedFailed
207
- ? { error: message, status: "failed" }
213
+ ? { error: message, failure: error, status: "failed" }
208
214
  : { status: "superseded" };
209
215
  }
210
216
  return (await store.clearIfSnapshotId(id, claimedSnapshotId))
@@ -294,7 +300,9 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
294
300
  // Product decision: a superseded result can represent an older live claim.
295
301
  // Preserve strict ordering by leaving every later record for a future run.
296
302
  if (stopOnFailure &&
297
- (result.status === "failed" || result.status === "superseded")) {
303
+ (result.status === "failed" ||
304
+ result.status === "idle" ||
305
+ result.status === "superseded")) {
298
306
  break;
299
307
  }
300
308
  }
@@ -416,6 +424,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
416
424
  return {
417
425
  ...(typeof record.error === "string" ? { error: record.error } : {}),
418
426
  id: record.id,
427
+ ...(typeof record.notBefore === "string"
428
+ ? { notBefore: record.notBefore }
429
+ : {}),
419
430
  payload,
420
431
  savedAt: record.savedAt,
421
432
  snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
@@ -444,6 +455,7 @@ function isPendingWriteRecord(value) {
444
455
  typeof value === "object" &&
445
456
  "id" in value &&
446
457
  typeof value.id === "string" &&
458
+ (!("notBefore" in value) || typeof value.notBefore === "string") &&
447
459
  "savedAt" in value &&
448
460
  typeof value.savedAt === "string" &&
449
461
  (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",