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