@bayonai/offline 0.5.0 → 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 +20 -0
- package/README.md +39 -0
- package/dist/durableCommandStore.d.ts +43 -0
- package/dist/durableCommandStore.js +158 -0
- package/dist/idbDriver.js +103 -36
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pendingWrites.d.ts +3 -0
- package/dist/pendingWrites.js +17 -2
- package/dist-cjs/durableCommandStore.js +161 -0
- package/dist-cjs/idbDriver.js +102 -35
- package/dist-cjs/index.js +1 -0
- package/dist-cjs/pendingWrites.js +17 -2
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
16
|
+
## 0.5.1 - 2026-09-12
|
|
17
|
+
|
|
18
|
+
- Prevented timed-out conditional IndexedDB replacements and deletions from
|
|
19
|
+
committing after the operation has already reported failure.
|
|
20
|
+
- Preserved strict chronological replay when an older record has an active
|
|
21
|
+
claim by leaving it and all later records for a future ordered run.
|
|
22
|
+
|
|
3
23
|
## 0.5.0 - 2026-09-12
|
|
4
24
|
|
|
5
25
|
- Added `createBrowserOfflineFallbackRecovery`, a framework-free controller
|
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
|
|
@@ -202,6 +236,11 @@ Interrupted sync claims expire after one minute so recovery can resume. Records
|
|
|
202
236
|
created before snapshot identities were introduced remain readable; they receive
|
|
203
237
|
a stable compatibility identity and are upgraded when next updated.
|
|
204
238
|
|
|
239
|
+
Conditional IndexedDB mutations are cancelled when their storage deadline is
|
|
240
|
+
exceeded, so an operation cannot report failure and then commit later. Ordered
|
|
241
|
+
replay also stops at an active older claim; it never delivers a later record
|
|
242
|
+
whose remote effect could subsequently be overwritten by that predecessor.
|
|
243
|
+
|
|
205
244
|
Host apps that change their pending-write payload shape can normalize current
|
|
206
245
|
records while keeping domain logic outside the package:
|
|
207
246
|
|
|
@@ -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
|
@@ -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;
|
|
@@ -11,12 +11,15 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
11
11
|
});
|
|
12
12
|
return {
|
|
13
13
|
async clear() {
|
|
14
|
-
await
|
|
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
|
-
|
|
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,36 +36,32 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
33
36
|
});
|
|
34
37
|
},
|
|
35
38
|
async delete(key) {
|
|
36
|
-
await
|
|
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
|
-
|
|
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
|
});
|
|
45
51
|
},
|
|
46
52
|
async deleteIf(key, predicate) {
|
|
47
|
-
return
|
|
48
|
-
|
|
53
|
+
return runConditionalStorageOperation({
|
|
54
|
+
databasePromise,
|
|
49
55
|
operationName: "conditionally delete key",
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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);
|
|
56
|
+
storeName,
|
|
57
|
+
timeoutMs: operationTimeoutMs,
|
|
58
|
+
apply: async (objectStore) => {
|
|
56
59
|
const current = (await objectStore.get(key)) ?? null;
|
|
57
|
-
if (!predicate(current))
|
|
58
|
-
await transaction.done;
|
|
60
|
+
if (!predicate(current))
|
|
59
61
|
return false;
|
|
60
|
-
}
|
|
61
62
|
await objectStore.delete(key);
|
|
62
|
-
await transaction.done;
|
|
63
63
|
return true;
|
|
64
64
|
},
|
|
65
|
-
timeoutMs: operationTimeoutMs,
|
|
66
65
|
});
|
|
67
66
|
},
|
|
68
67
|
async get(key) {
|
|
@@ -112,34 +111,32 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
112
111
|
});
|
|
113
112
|
},
|
|
114
113
|
async set(key, value) {
|
|
115
|
-
await
|
|
114
|
+
await runTransactionalStorageOperation({
|
|
116
115
|
fallback: undefined,
|
|
117
116
|
operationName: "write key",
|
|
118
|
-
operation: async () => {
|
|
117
|
+
operation: async (setTransaction) => {
|
|
119
118
|
const database = await databasePromise;
|
|
120
|
-
|
|
119
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
120
|
+
setTransaction(transaction);
|
|
121
|
+
await transaction.objectStore(storeName).put(value, key);
|
|
122
|
+
await transaction.done;
|
|
121
123
|
},
|
|
122
124
|
timeoutMs: operationTimeoutMs,
|
|
123
125
|
});
|
|
124
126
|
},
|
|
125
127
|
async setIf(key, value, predicate) {
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
+
return runConditionalStorageOperation({
|
|
129
|
+
databasePromise,
|
|
128
130
|
operationName: "conditionally replace key",
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const objectStore = transaction.objectStore(storeName);
|
|
131
|
+
storeName,
|
|
132
|
+
timeoutMs: operationTimeoutMs,
|
|
133
|
+
apply: async (objectStore) => {
|
|
133
134
|
const current = (await objectStore.get(key)) ?? null;
|
|
134
|
-
if (!predicate(current))
|
|
135
|
-
await transaction.done;
|
|
135
|
+
if (!predicate(current))
|
|
136
136
|
return false;
|
|
137
|
-
}
|
|
138
137
|
await objectStore.put(value, key);
|
|
139
|
-
await transaction.done;
|
|
140
138
|
return true;
|
|
141
139
|
},
|
|
142
|
-
timeoutMs: operationTimeoutMs,
|
|
143
140
|
});
|
|
144
141
|
},
|
|
145
142
|
};
|
|
@@ -158,7 +155,7 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
158
155
|
}
|
|
159
156
|
return database;
|
|
160
157
|
}
|
|
161
|
-
async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
158
|
+
async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
|
|
162
159
|
let timeoutId = null;
|
|
163
160
|
try {
|
|
164
161
|
return await Promise.race([
|
|
@@ -168,7 +165,9 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
168
165
|
console.error(`IndexedDB timed out while trying to ${operationName}.`, {
|
|
169
166
|
timeoutMs,
|
|
170
167
|
});
|
|
171
|
-
|
|
168
|
+
if (!cancelOnTimeout || cancelOnTimeout()) {
|
|
169
|
+
resolve(fallback);
|
|
170
|
+
}
|
|
172
171
|
}, timeoutMs);
|
|
173
172
|
}),
|
|
174
173
|
]);
|
|
@@ -183,3 +182,71 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
183
182
|
}
|
|
184
183
|
}
|
|
185
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
|
+
}
|
|
217
|
+
async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
|
|
218
|
+
let transaction;
|
|
219
|
+
let cancelled = false;
|
|
220
|
+
return runBrowserStorageOperation({
|
|
221
|
+
fallback: false,
|
|
222
|
+
operationName,
|
|
223
|
+
timeoutMs,
|
|
224
|
+
cancelOnTimeout() {
|
|
225
|
+
cancelled = true;
|
|
226
|
+
if (!transaction)
|
|
227
|
+
return true;
|
|
228
|
+
try {
|
|
229
|
+
transaction.abort();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
// Product decision: when commit has already started, report its actual
|
|
234
|
+
// result instead of returning a false failure that can later mutate data.
|
|
235
|
+
console.warn("[Bayon offline] Conditional transaction already settling at timeout.", { error, operationName });
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
operation: async () => {
|
|
240
|
+
const database = await databasePromise;
|
|
241
|
+
if (cancelled)
|
|
242
|
+
return false;
|
|
243
|
+
transaction = database.transaction(storeName, "readwrite");
|
|
244
|
+
// Aborting can reject both a request and transaction.done. Observe the
|
|
245
|
+
// latter immediately so cancellation never creates an unhandled rejection.
|
|
246
|
+
void transaction.done.catch(() => undefined);
|
|
247
|
+
const result = await apply(transaction.objectStore(storeName));
|
|
248
|
+
await transaction.done;
|
|
249
|
+
return result;
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/pendingWrites.d.ts
CHANGED
|
@@ -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 = {
|
package/dist/pendingWrites.js
CHANGED
|
@@ -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))
|
|
@@ -277,7 +283,12 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
277
283
|
});
|
|
278
284
|
summary.attempted += 1;
|
|
279
285
|
summary[result.status] += 1;
|
|
280
|
-
|
|
286
|
+
// Product decision: a superseded result can represent an older live claim.
|
|
287
|
+
// Preserve strict ordering by leaving every later record for a future run.
|
|
288
|
+
if (stopOnFailure &&
|
|
289
|
+
(result.status === "failed" ||
|
|
290
|
+
result.status === "idle" ||
|
|
291
|
+
result.status === "superseded")) {
|
|
281
292
|
break;
|
|
282
293
|
}
|
|
283
294
|
}
|
|
@@ -399,6 +410,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
399
410
|
return {
|
|
400
411
|
...(typeof record.error === "string" ? { error: record.error } : {}),
|
|
401
412
|
id: record.id,
|
|
413
|
+
...(typeof record.notBefore === "string"
|
|
414
|
+
? { notBefore: record.notBefore }
|
|
415
|
+
: {}),
|
|
402
416
|
payload,
|
|
403
417
|
savedAt: record.savedAt,
|
|
404
418
|
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
@@ -427,6 +441,7 @@ function isPendingWriteRecord(value) {
|
|
|
427
441
|
typeof value === "object" &&
|
|
428
442
|
"id" in value &&
|
|
429
443
|
typeof value.id === "string" &&
|
|
444
|
+
(!("notBefore" in value) || typeof value.notBefore === "string") &&
|
|
430
445
|
"savedAt" in value &&
|
|
431
446
|
typeof value.savedAt === "string" &&
|
|
432
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
|
+
}
|
package/dist-cjs/idbDriver.js
CHANGED
|
@@ -14,12 +14,15 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
14
14
|
});
|
|
15
15
|
return {
|
|
16
16
|
async clear() {
|
|
17
|
-
await
|
|
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
|
-
|
|
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,36 +39,32 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
36
39
|
});
|
|
37
40
|
},
|
|
38
41
|
async delete(key) {
|
|
39
|
-
await
|
|
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
|
-
|
|
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
|
});
|
|
48
54
|
},
|
|
49
55
|
async deleteIf(key, predicate) {
|
|
50
|
-
return
|
|
51
|
-
|
|
56
|
+
return runConditionalStorageOperation({
|
|
57
|
+
databasePromise,
|
|
52
58
|
operationName: "conditionally delete key",
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
59
|
+
storeName,
|
|
60
|
+
timeoutMs: operationTimeoutMs,
|
|
61
|
+
apply: async (objectStore) => {
|
|
59
62
|
const current = (await objectStore.get(key)) ?? null;
|
|
60
|
-
if (!predicate(current))
|
|
61
|
-
await transaction.done;
|
|
63
|
+
if (!predicate(current))
|
|
62
64
|
return false;
|
|
63
|
-
}
|
|
64
65
|
await objectStore.delete(key);
|
|
65
|
-
await transaction.done;
|
|
66
66
|
return true;
|
|
67
67
|
},
|
|
68
|
-
timeoutMs: operationTimeoutMs,
|
|
69
68
|
});
|
|
70
69
|
},
|
|
71
70
|
async get(key) {
|
|
@@ -115,34 +114,32 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
115
114
|
});
|
|
116
115
|
},
|
|
117
116
|
async set(key, value) {
|
|
118
|
-
await
|
|
117
|
+
await runTransactionalStorageOperation({
|
|
119
118
|
fallback: undefined,
|
|
120
119
|
operationName: "write key",
|
|
121
|
-
operation: async () => {
|
|
120
|
+
operation: async (setTransaction) => {
|
|
122
121
|
const database = await databasePromise;
|
|
123
|
-
|
|
122
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
123
|
+
setTransaction(transaction);
|
|
124
|
+
await transaction.objectStore(storeName).put(value, key);
|
|
125
|
+
await transaction.done;
|
|
124
126
|
},
|
|
125
127
|
timeoutMs: operationTimeoutMs,
|
|
126
128
|
});
|
|
127
129
|
},
|
|
128
130
|
async setIf(key, value, predicate) {
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
+
return runConditionalStorageOperation({
|
|
132
|
+
databasePromise,
|
|
131
133
|
operationName: "conditionally replace key",
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const objectStore = transaction.objectStore(storeName);
|
|
134
|
+
storeName,
|
|
135
|
+
timeoutMs: operationTimeoutMs,
|
|
136
|
+
apply: async (objectStore) => {
|
|
136
137
|
const current = (await objectStore.get(key)) ?? null;
|
|
137
|
-
if (!predicate(current))
|
|
138
|
-
await transaction.done;
|
|
138
|
+
if (!predicate(current))
|
|
139
139
|
return false;
|
|
140
|
-
}
|
|
141
140
|
await objectStore.put(value, key);
|
|
142
|
-
await transaction.done;
|
|
143
141
|
return true;
|
|
144
142
|
},
|
|
145
|
-
timeoutMs: operationTimeoutMs,
|
|
146
143
|
});
|
|
147
144
|
},
|
|
148
145
|
};
|
|
@@ -161,7 +158,7 @@ async function openKeyValueDatabase({ databaseName, databaseVersion, indexedDB,
|
|
|
161
158
|
}
|
|
162
159
|
return database;
|
|
163
160
|
}
|
|
164
|
-
async function runBrowserStorageOperation({ fallback, operation, operationName, timeoutMs, }) {
|
|
161
|
+
async function runBrowserStorageOperation({ cancelOnTimeout, fallback, operation, operationName, timeoutMs, }) {
|
|
165
162
|
let timeoutId = null;
|
|
166
163
|
try {
|
|
167
164
|
return await Promise.race([
|
|
@@ -171,7 +168,9 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
171
168
|
console.error(`IndexedDB timed out while trying to ${operationName}.`, {
|
|
172
169
|
timeoutMs,
|
|
173
170
|
});
|
|
174
|
-
|
|
171
|
+
if (!cancelOnTimeout || cancelOnTimeout()) {
|
|
172
|
+
resolve(fallback);
|
|
173
|
+
}
|
|
175
174
|
}, timeoutMs);
|
|
176
175
|
}),
|
|
177
176
|
]);
|
|
@@ -186,3 +185,71 @@ async function runBrowserStorageOperation({ fallback, operation, operationName,
|
|
|
186
185
|
}
|
|
187
186
|
}
|
|
188
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
|
+
}
|
|
220
|
+
async function runConditionalStorageOperation({ apply, databasePromise, operationName, storeName, timeoutMs, }) {
|
|
221
|
+
let transaction;
|
|
222
|
+
let cancelled = false;
|
|
223
|
+
return runBrowserStorageOperation({
|
|
224
|
+
fallback: false,
|
|
225
|
+
operationName,
|
|
226
|
+
timeoutMs,
|
|
227
|
+
cancelOnTimeout() {
|
|
228
|
+
cancelled = true;
|
|
229
|
+
if (!transaction)
|
|
230
|
+
return true;
|
|
231
|
+
try {
|
|
232
|
+
transaction.abort();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// Product decision: when commit has already started, report its actual
|
|
237
|
+
// result instead of returning a false failure that can later mutate data.
|
|
238
|
+
console.warn("[Bayon offline] Conditional transaction already settling at timeout.", { error, operationName });
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
operation: async () => {
|
|
243
|
+
const database = await databasePromise;
|
|
244
|
+
if (cancelled)
|
|
245
|
+
return false;
|
|
246
|
+
transaction = database.transaction(storeName, "readwrite");
|
|
247
|
+
// Aborting can reject both a request and transaction.done. Observe the
|
|
248
|
+
// latter immediately so cancellation never creates an unhandled rejection.
|
|
249
|
+
void transaction.done.catch(() => undefined);
|
|
250
|
+
const result = await apply(transaction.objectStore(storeName));
|
|
251
|
+
await transaction.done;
|
|
252
|
+
return result;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
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))
|
|
@@ -291,7 +297,12 @@ async function performPendingWriteSync({ select, stopOnFailure, store, sync, })
|
|
|
291
297
|
});
|
|
292
298
|
summary.attempted += 1;
|
|
293
299
|
summary[result.status] += 1;
|
|
294
|
-
|
|
300
|
+
// Product decision: a superseded result can represent an older live claim.
|
|
301
|
+
// Preserve strict ordering by leaving every later record for a future run.
|
|
302
|
+
if (stopOnFailure &&
|
|
303
|
+
(result.status === "failed" ||
|
|
304
|
+
result.status === "idle" ||
|
|
305
|
+
result.status === "superseded")) {
|
|
295
306
|
break;
|
|
296
307
|
}
|
|
297
308
|
}
|
|
@@ -413,6 +424,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
413
424
|
return {
|
|
414
425
|
...(typeof record.error === "string" ? { error: record.error } : {}),
|
|
415
426
|
id: record.id,
|
|
427
|
+
...(typeof record.notBefore === "string"
|
|
428
|
+
? { notBefore: record.notBefore }
|
|
429
|
+
: {}),
|
|
416
430
|
payload,
|
|
417
431
|
savedAt: record.savedAt,
|
|
418
432
|
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
@@ -441,6 +455,7 @@ function isPendingWriteRecord(value) {
|
|
|
441
455
|
typeof value === "object" &&
|
|
442
456
|
"id" in value &&
|
|
443
457
|
typeof value.id === "string" &&
|
|
458
|
+
(!("notBefore" in value) || typeof value.notBefore === "string") &&
|
|
444
459
|
"savedAt" in value &&
|
|
445
460
|
typeof value.savedAt === "string" &&
|
|
446
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.
|
|
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",
|
|
@@ -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
|
}
|