@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 +13 -0
- package/README.md +34 -0
- package/dist/durableCommandStore.d.ts +43 -0
- package/dist/durableCommandStore.js +158 -0
- package/dist/idbDriver.js +50 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pendingWrites.d.ts +3 -0
- package/dist/pendingWrites.js +14 -2
- package/dist-cjs/durableCommandStore.js +161 -0
- package/dist-cjs/idbDriver.js +50 -9
- package/dist-cjs/index.js +1 -0
- package/dist-cjs/pendingWrites.js +14 -2
- package/package.json +1 -1
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
|
|
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,12 +36,15 @@ 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
|
});
|
|
@@ -105,12 +111,15 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
|
|
|
105
111
|
});
|
|
106
112
|
},
|
|
107
113
|
async set(key, value) {
|
|
108
|
-
await
|
|
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
|
-
|
|
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
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))
|
|
@@ -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" ||
|
|
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
|
+
}
|
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,12 +39,15 @@ 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
|
});
|
|
@@ -108,12 +114,15 @@ function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, indexedDB
|
|
|
108
114
|
});
|
|
109
115
|
},
|
|
110
116
|
async set(key, value) {
|
|
111
|
-
await
|
|
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
|
-
|
|
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" ||
|
|
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") &&
|