@bayonai/offline 0.7.0 → 0.9.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 +38 -1
- package/README.md +81 -8
- package/dist/browserFallbackRecovery.js +7 -2
- package/dist/browserRecovery.d.ts +2 -2
- package/dist/durableCommandStore.d.ts +9 -3
- package/dist/durableCommandStore.js +115 -50
- package/dist/idbDriver.d.ts +3 -0
- package/dist/idbDriver.js +52 -44
- package/dist/index.d.ts +7 -6
- package/dist/index.js +1 -0
- package/dist/pendingWrites.d.ts +16 -6
- package/dist/pendingWrites.js +70 -21
- package/dist/readCache.d.ts +27 -6
- package/dist/readCache.js +134 -23
- package/dist-cjs/browserCoordination.d.ts +23 -0
- package/dist-cjs/browserFallbackRecovery.d.ts +32 -0
- package/dist-cjs/browserFallbackRecovery.js +7 -2
- package/dist-cjs/browserRecovery.d.ts +42 -0
- package/dist-cjs/durableCommandStore.d.ts +49 -0
- package/dist-cjs/durableCommandStore.js +115 -50
- package/dist-cjs/idbDriver.d.ts +26 -0
- package/dist-cjs/idbDriver.js +54 -44
- package/dist-cjs/index.d.ts +7 -0
- package/dist-cjs/index.js +3 -0
- package/dist-cjs/pendingWrites.d.ts +179 -0
- package/dist-cjs/pendingWrites.js +69 -20
- package/dist-cjs/readCache.d.ts +112 -0
- package/dist-cjs/readCache.js +134 -23
- package/package.json +15 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.9.0 - 2026-09-26
|
|
4
|
+
|
|
5
|
+
- Added independent read-cache freshness and retention windows with explicit
|
|
6
|
+
`fresh`, `stale`, and `miss` outcomes and selectable refresh-failure behavior.
|
|
7
|
+
- Added retained fallback and stale in-flight response protection to forced
|
|
8
|
+
`loadReadCacheResource` refreshes.
|
|
9
|
+
- Added payload-free read-cache outcome callbacks for host-owned telemetry.
|
|
10
|
+
- Preserved existing `maxAgeMs` behavior for callers that do not opt into the
|
|
11
|
+
new policy. Forced refreshes retain fallback data while invalidating older
|
|
12
|
+
in-flight cache writes.
|
|
13
|
+
- Isolated pending-write listing and clearing by `keyPrefix` when logical
|
|
14
|
+
queues share one physical object store.
|
|
15
|
+
- Fixed NodeNext declaration resolution and supplied matching declarations for
|
|
16
|
+
both ESM and CommonJS consumers.
|
|
17
|
+
- Restored offline retry-control text when a reload attempt throws.
|
|
18
|
+
|
|
19
|
+
## 0.8.0 - 2026-09-25
|
|
20
|
+
|
|
21
|
+
- Added authoritative-result reconciliation to `createDurableCommandStore`,
|
|
22
|
+
including rebasing remaining optimistic commands without a transient revert.
|
|
23
|
+
- Exposed stable durable operation IDs to remote executors for idempotent
|
|
24
|
+
command handling and prevented older refreshes from replacing confirmations.
|
|
25
|
+
- Propagated injected clocks through ordered replay so deferred-command timing
|
|
26
|
+
stays deterministic in hosts and tests.
|
|
27
|
+
- Added a host-owned retry policy so terminal failures remain inspectable
|
|
28
|
+
without replaying or blocking later commands, while connectivity failures
|
|
29
|
+
retain their optimistic projection and continue through ordered recovery.
|
|
30
|
+
- Made IndexedDB mutation failures truthful: unavailable, rejected, and
|
|
31
|
+
cancelled timed-out writes now reject with `OfflineStorageError` instead of
|
|
32
|
+
reporting false durability.
|
|
33
|
+
- Prevented delayed database opens from mutating after timeout, stale store
|
|
34
|
+
listings from replacing newer command state, and listener failures from
|
|
35
|
+
interrupting command dispatch.
|
|
36
|
+
- Preserved retryable optimistic projections while later commands enter the
|
|
37
|
+
queue, added collision-resistant fallback IDs where Web Crypto UUIDs are
|
|
38
|
+
unavailable, and restored the package's 100% coverage release gate.
|
|
39
|
+
|
|
40
|
+
## 0.7.0 - 2026-09-16
|
|
4
41
|
|
|
5
42
|
- Added `createDurableCommandStore`, a framework-neutral store that projects
|
|
6
43
|
accepted commands immediately while persisting them before remote execution.
|
package/README.md
CHANGED
|
@@ -25,8 +25,15 @@ import {
|
|
|
25
25
|
createReadCacheKey,
|
|
26
26
|
loadReadCacheResource,
|
|
27
27
|
pruneReadCache,
|
|
28
|
+
type ReadCachePolicy,
|
|
28
29
|
} from "@bayonai/offline";
|
|
29
30
|
|
|
31
|
+
const policy: ReadCachePolicy = {
|
|
32
|
+
freshForMs: 30_000,
|
|
33
|
+
retainForMs: 7 * 24 * 60 * 60_000,
|
|
34
|
+
refreshFailure: "use-retained",
|
|
35
|
+
};
|
|
36
|
+
|
|
30
37
|
const cache = createBrowserReadCache({
|
|
31
38
|
databaseName: "my-app:v1",
|
|
32
39
|
storeName: "read-cache",
|
|
@@ -43,13 +50,41 @@ const projects = await loadReadCacheResource({
|
|
|
43
50
|
cache,
|
|
44
51
|
key,
|
|
45
52
|
loadFresh: () => fetchProjects(),
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
onOutcome: (outcome) => telemetry.record("offline-read", { outcome }),
|
|
54
|
+
policy,
|
|
55
|
+
onUpdate: ({ data, status, fromCache }) => {
|
|
56
|
+
renderProjects(data, { fromCache, status });
|
|
49
57
|
},
|
|
50
58
|
});
|
|
51
59
|
```
|
|
52
60
|
|
|
61
|
+
`freshForMs` controls how long a cached value can avoid a remote read.
|
|
62
|
+
`retainForMs` independently controls how long it can be shown as an offline
|
|
63
|
+
fallback. A stale-but-retained value is emitted immediately and revalidated;
|
|
64
|
+
an expired value is not emitted and cannot mask a loader failure. The
|
|
65
|
+
`status` is `fresh`, `stale`, or `miss`: a stale update is retained data while
|
|
66
|
+
refreshing or after a failed refresh, and a miss is a successful loader result
|
|
67
|
+
with no retained cache. Successful remote results are `fresh` when replacing
|
|
68
|
+
stale data and `miss` when there was no usable retained data.
|
|
69
|
+
|
|
70
|
+
`onOutcome` emits only a fixed event name (`fresh-hit`, `stale-hit`,
|
|
71
|
+
`cache-miss`, `forced-refresh`, `loader-success`, or `loader-failure`). It never
|
|
72
|
+
includes cache keys, user identifiers, payloads, or error messages. Callback
|
|
73
|
+
exceptions are logged and isolated from the read.
|
|
74
|
+
|
|
75
|
+
When refresh fails, `refreshFailure: "use-retained"` returns retained data
|
|
76
|
+
with `status: "stale"` and the error; `"throw"` propagates the loader error.
|
|
77
|
+
This also applies to forced refresh. Set `forceFresh: true` on
|
|
78
|
+
`loadReadCacheResource` or `readThroughCache` to bypass freshness, retain
|
|
79
|
+
eligible data as the failure fallback, and advance the cache generation so an
|
|
80
|
+
older in-flight load cannot overwrite the refresh. `readThroughCache` returns
|
|
81
|
+
the `status` field alongside its existing `fromCache`, `stale`, and `error`
|
|
82
|
+
fields.
|
|
83
|
+
|
|
84
|
+
For compatibility, callers that omit `policy` retain the previous `maxAgeMs`
|
|
85
|
+
freshness behavior and have no age-based fallback expiry. New callers should
|
|
86
|
+
use `policy`; when supplied it takes precedence over the legacy `maxAgeMs`.
|
|
87
|
+
|
|
53
88
|
After a successful write, use `readThroughCache({ forceFresh: true })` when
|
|
54
89
|
the next read must be server-confirmed rather than a pre-write cached or
|
|
55
90
|
in-flight response. Concurrent ordinary reads for the same cache/key share one
|
|
@@ -102,6 +137,11 @@ Failed sync results include a safe persisted `error` string and the original
|
|
|
102
137
|
non-persisted `failure` value. Hosts may use `failure` for structured logging or
|
|
103
138
|
domain error classification; it is never written into the pending-write store.
|
|
104
139
|
|
|
140
|
+
`keyPrefix` is an isolation boundary inside a shared object store. Listing,
|
|
141
|
+
recovery, and `clearAll()` affect only that prefix, so independent logical
|
|
142
|
+
queues may safely share the same IndexedDB database and store when their
|
|
143
|
+
prefixes are distinct.
|
|
144
|
+
|
|
105
145
|
For a browser application that wants durable, silent recovery, mount the
|
|
106
146
|
headless controller once for the active account or workspace:
|
|
107
147
|
|
|
@@ -202,8 +242,12 @@ package never imports an application model, database SDK, or framework.
|
|
|
202
242
|
const commands = createDurableCommandStore({
|
|
203
243
|
initialState: workspace,
|
|
204
244
|
load: (scope) => workspaceApi.load(scope),
|
|
205
|
-
execute: (command) =>
|
|
245
|
+
execute: (command, { operationId }) =>
|
|
246
|
+
workspaceApi.execute(command, { operationId }),
|
|
206
247
|
project: (state, pending) => projectWorkspace(state, pending),
|
|
248
|
+
reconcile: (state, command, result) =>
|
|
249
|
+
reconcileWorkspace(state, command, result),
|
|
250
|
+
shouldRetry: (category) => category === "connectivity",
|
|
207
251
|
store,
|
|
208
252
|
});
|
|
209
253
|
|
|
@@ -221,6 +265,31 @@ commands in order, and `close` releases the local subscription and delayed
|
|
|
221
265
|
deadline timer. Local-storage failure rejects `accepted`; hosts must show that
|
|
222
266
|
failure instead of claiming the command will recover.
|
|
223
267
|
|
|
268
|
+
Browser pending-write mutations reject with `OfflineStorageError` when
|
|
269
|
+
IndexedDB is unavailable, rejects an operation, or cancels a timed-out
|
|
270
|
+
transaction. This is intentional: successful queue acceptance always means
|
|
271
|
+
the command was durably stored. Read-cache access remains best-effort, and a
|
|
272
|
+
fresh remote result is still returned when optional cache invalidation or
|
|
273
|
+
persistence fails.
|
|
274
|
+
|
|
275
|
+
The executor receives the durable `operationId` on every attempt so the remote
|
|
276
|
+
handler can use it as an idempotency key. When execution succeeds, `reconcile`
|
|
277
|
+
folds the authoritative result into base state before the command is removed;
|
|
278
|
+
any later optimistic commands are projected over that confirmed state. A read
|
|
279
|
+
that started before this confirmation cannot replace it when it finishes.
|
|
280
|
+
Reconciliation is deliberately a host-owned, synchronous state transformation.
|
|
281
|
+
If it throws, the package logs the local bug and removes the already-confirmed
|
|
282
|
+
command rather than risking a duplicate remote effect.
|
|
283
|
+
|
|
284
|
+
`classifyError` and `shouldRetry` let a host distinguish recoverable failures
|
|
285
|
+
from terminal validation, authentication, permission, or conflict outcomes.
|
|
286
|
+
Retryable failures retain their optimistic projection while awaiting recovery.
|
|
287
|
+
Terminal failures roll back, stay available in `operations` for host feedback
|
|
288
|
+
or dismissal, and are excluded from later recovery lanes, so they neither retry
|
|
289
|
+
nor block a newer command. Because recovery survives restart, classification
|
|
290
|
+
must work from the package's persisted safe error message rather than relying
|
|
291
|
+
only on a non-serializable error object.
|
|
292
|
+
|
|
224
293
|
Use generic record summaries when an app needs to render its own status:
|
|
225
294
|
|
|
226
295
|
```ts
|
|
@@ -277,17 +346,21 @@ opened at the same version.
|
|
|
277
346
|
|
|
278
347
|
```bash
|
|
279
348
|
pnpm --filter @bayonai/offline test
|
|
349
|
+
pnpm --filter @bayonai/offline test:coverage
|
|
280
350
|
pnpm --filter @bayonai/offline run build
|
|
281
|
-
pnpm --filter @bayonai/offline pack
|
|
351
|
+
pnpm --filter @bayonai/offline pack:smoke
|
|
282
352
|
```
|
|
283
353
|
|
|
284
354
|
## Publish Checklist
|
|
285
355
|
|
|
286
356
|
1. Run the package tests and build.
|
|
287
|
-
2. Run
|
|
357
|
+
2. Run the 100% coverage gate and production dependency audit.
|
|
358
|
+
3. Run `pnpm --filter @bayonai/offline pack:smoke` and confirm the tarball
|
|
288
359
|
includes only `package.json`, `dist`, `dist-cjs`, `README.md`,
|
|
289
360
|
`CHANGELOG.md`, and `LICENSE`.
|
|
290
|
-
|
|
361
|
+
4. Install the generated tarball into an isolated consumer and verify ESM,
|
|
362
|
+
CommonJS, and TypeScript declaration imports.
|
|
363
|
+
5. Confirm `package.json` metadata, version, repository, license, and
|
|
291
364
|
`publishConfig.access` are correct.
|
|
292
|
-
|
|
365
|
+
6. Publish from the package directory or with the package filter after the
|
|
293
366
|
workspace lockfile is current.
|
|
@@ -8,9 +8,11 @@ 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;
|
|
15
|
+
const previousText = control.textContent;
|
|
14
16
|
retrying = true;
|
|
15
17
|
control.disabled = true;
|
|
16
18
|
control.textContent = retryingText;
|
|
@@ -19,9 +21,12 @@ export function createBrowserOfflineFallbackRecovery({ control, environment = cr
|
|
|
19
21
|
}
|
|
20
22
|
catch (error) {
|
|
21
23
|
// A failed navigation must leave the recovery action visible and usable.
|
|
22
|
-
console.error("[Bayon offline] Offline fallback reload failed.", {
|
|
24
|
+
console.error("[Bayon offline] Offline fallback reload failed.", {
|
|
25
|
+
error,
|
|
26
|
+
});
|
|
23
27
|
retrying = false;
|
|
24
28
|
control.disabled = false;
|
|
29
|
+
control.textContent = previousText;
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
function handleTouchStart(event) {
|
|
@@ -32,7 +37,7 @@ export function createBrowserOfflineFallbackRecovery({ control, environment = cr
|
|
|
32
37
|
const touchY = getSingleTouchY(event, "changedTouches");
|
|
33
38
|
if (pullStartY !== null &&
|
|
34
39
|
touchY !== null &&
|
|
35
|
-
touchY - pullStartY >=
|
|
40
|
+
touchY - pullStartY >= touchThreshold) {
|
|
36
41
|
retry();
|
|
37
42
|
}
|
|
38
43
|
pullStartY = null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites";
|
|
2
|
-
import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination";
|
|
1
|
+
import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites.js";
|
|
2
|
+
import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination.js";
|
|
3
3
|
export declare const initialPendingWriteRetryDelayMs = 2000;
|
|
4
4
|
export declare const maxPendingWriteRetryDelayMs = 60000;
|
|
5
5
|
export type BrowserPendingWriteRecoveryEnvironment = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites";
|
|
1
|
+
import { type PendingWriteRecord, type PendingWriteStore } from "./pendingWrites.js";
|
|
2
2
|
export type DurableCommandErrorCategory = "auth" | "conflict" | "connectivity" | "local-storage" | "permission" | "unexpected" | "validation";
|
|
3
3
|
export type DurableCommandOperation<Command> = {
|
|
4
4
|
command: Command;
|
|
@@ -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;
|