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