@bayonai/offline 0.3.0 → 0.4.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 +12 -0
- package/README.md +26 -12
- package/dist/browserRecovery.d.ts +10 -2
- package/dist/browserRecovery.js +13 -7
- package/dist/pendingWrites.d.ts +38 -8
- package/dist/pendingWrites.js +114 -15
- package/dist-cjs/browserRecovery.js +13 -7
- package/dist-cjs/pendingWrites.js +116 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-09-01
|
|
4
|
+
|
|
5
|
+
- Fixed cross-context replay claiming: a remote sync now runs only after an
|
|
6
|
+
atomic claim, while interrupted claims become recoverable after a bounded
|
|
7
|
+
lease timeout.
|
|
8
|
+
- Fixed silent recovery after a non-blocking Web Lock is unavailable. Active
|
|
9
|
+
online controllers now retry contested leases with bounded backoff.
|
|
10
|
+
- Added `createOrderedPendingWriteExecutor`, a framework-neutral per-scope lane
|
|
11
|
+
that serializes background replay and live writes so later writes cannot
|
|
12
|
+
overtake unresolved queued predecessors.
|
|
13
|
+
- Documented the remaining at-least-once delivery contract for remote sinks.
|
|
14
|
+
|
|
3
15
|
## 0.3.0 - 2026-08-31
|
|
4
16
|
|
|
5
17
|
- Added optional `BroadcastChannel` wake-ups and `navigator.locks` replay
|
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ headless controller once for the active account or workspace:
|
|
|
105
105
|
import {
|
|
106
106
|
createBrowserPendingWriteRecovery,
|
|
107
107
|
createBrowserPendingWriteRecoveryCoordination,
|
|
108
|
-
|
|
108
|
+
createOrderedPendingWriteExecutor,
|
|
109
109
|
summarizePendingWrites,
|
|
110
110
|
} from "@bayonai/offline";
|
|
111
111
|
|
|
@@ -115,18 +115,23 @@ const coordination = createBrowserPendingWriteRecoveryCoordination({
|
|
|
115
115
|
leaseName: `my-app:pending-recovery:${currentUser.id}`,
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
const
|
|
119
|
-
coordination,
|
|
118
|
+
const writes = createOrderedPendingWriteExecutor({
|
|
120
119
|
scopeKey: currentUser.id,
|
|
121
120
|
select: (record) => record.payload.userId === currentUser.id,
|
|
122
121
|
store,
|
|
123
122
|
sync: (mutation) => replayMutation(mutation),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const recovery = createBrowserPendingWriteRecovery({
|
|
126
|
+
coordination,
|
|
127
|
+
recover: writes.recover,
|
|
128
|
+
store,
|
|
124
129
|
onError: (error) => console.error("Pending replay failed", { error }),
|
|
125
130
|
});
|
|
126
131
|
|
|
127
132
|
recovery.start(); // silent initial, focus, online, and local-set recovery
|
|
128
133
|
|
|
129
|
-
const result = await
|
|
134
|
+
const result = await writes.run({
|
|
130
135
|
isOnline: () => navigator.onLine,
|
|
131
136
|
write: () => saveRemotely(),
|
|
132
137
|
shouldQueue: isRetryableNetworkError,
|
|
@@ -147,8 +152,16 @@ mechanism.
|
|
|
147
152
|
When coordination is supplied, a queued write wakes peer tabs through
|
|
148
153
|
`BroadcastChannel`, and each recovery attempt acquires a non-blocking
|
|
149
154
|
`navigator.locks` lease when supported. Browsers without either API recover in
|
|
150
|
-
the current tab as before.
|
|
151
|
-
|
|
155
|
+
the current tab as before. A contested lease retries silently with bounded
|
|
156
|
+
backoff. Remote write handlers must remain idempotent because leases do not
|
|
157
|
+
cover other devices or every browser failure mode.
|
|
158
|
+
|
|
159
|
+
Use `createOrderedPendingWriteExecutor` when a new write must never overtake a
|
|
160
|
+
previously queued write in the same user, workspace, or other durable scope.
|
|
161
|
+
Its `recover` and `run` methods share one lane: an unresolved predecessor keeps
|
|
162
|
+
the later write queued until the predecessor is delivered. This protects remote
|
|
163
|
+
write order; it does not replace idempotent server-side mutation handling for
|
|
164
|
+
ambiguous request outcomes.
|
|
152
165
|
|
|
153
166
|
Use generic record summaries when an app needs to render its own status:
|
|
154
167
|
|
|
@@ -157,12 +170,13 @@ const summary = summarizePendingWrites(await store.list());
|
|
|
157
170
|
// { pendingCount, syncingCount, failedCount, totalCount }
|
|
158
171
|
```
|
|
159
172
|
|
|
160
|
-
Each call to `set` assigns a unique `snapshotId`.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
173
|
+
Each call to `set` assigns a unique `snapshotId`. A replay first atomically
|
|
174
|
+
claims a fresh snapshot identity before calling the remote sink, then compares
|
|
175
|
+
that identity during status updates and cleanup. This prevents concurrent
|
|
176
|
+
workers or an older sync from overwriting or deleting a newer local write.
|
|
177
|
+
Interrupted sync claims expire after one minute so recovery can resume. Records
|
|
178
|
+
created before snapshot identities were introduced remain readable; they receive
|
|
179
|
+
a stable compatibility identity and are upgraded when next updated.
|
|
166
180
|
|
|
167
181
|
Host apps that change their pending-write payload shape can normalize current
|
|
168
182
|
records while keeping domain logic outside the package:
|
|
@@ -17,7 +17,7 @@ export type BrowserPendingWriteRecovery<Payload> = {
|
|
|
17
17
|
start: () => void;
|
|
18
18
|
stop: () => void;
|
|
19
19
|
};
|
|
20
|
-
|
|
20
|
+
type BrowserPendingWriteRecoveryOptions<Payload> = {
|
|
21
21
|
/** Optional BroadcastChannel/Web Locks coordination supplied by the package factory. */
|
|
22
22
|
coordination?: BrowserPendingWriteRecoveryCoordination;
|
|
23
23
|
environment?: BrowserPendingWriteRecoveryEnvironment;
|
|
@@ -30,5 +30,13 @@ export declare function createBrowserPendingWriteRecovery<Payload>({ environment
|
|
|
30
30
|
scopeKey?: string;
|
|
31
31
|
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
32
32
|
store: PendingWriteStore<Payload>;
|
|
33
|
+
} & ({
|
|
34
|
+
/** Runs one complete host-owned recovery pass, such as through an ordered executor. */
|
|
35
|
+
recover: () => Promise<SyncPendingWritesResult>;
|
|
36
|
+
sync?: never;
|
|
37
|
+
} | {
|
|
38
|
+
recover?: never;
|
|
33
39
|
sync: PendingWriteSync<Payload>;
|
|
34
|
-
})
|
|
40
|
+
});
|
|
41
|
+
export declare function createBrowserPendingWriteRecovery<Payload>(options: BrowserPendingWriteRecoveryOptions<Payload>): BrowserPendingWriteRecovery<Payload>;
|
|
42
|
+
export {};
|
package/dist/browserRecovery.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { syncPendingWrites, } from "./pendingWrites.js";
|
|
2
2
|
export const initialPendingWriteRetryDelayMs = 2_000;
|
|
3
3
|
export const maxPendingWriteRetryDelayMs = 60_000;
|
|
4
|
-
export function createBrowserPendingWriteRecovery(
|
|
4
|
+
export function createBrowserPendingWriteRecovery(options) {
|
|
5
|
+
const { environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = initialPendingWriteRetryDelayMs, maxRetryDelayMs = maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, } = options;
|
|
5
6
|
let active = false;
|
|
6
7
|
let retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
7
8
|
let retryTimer;
|
|
@@ -60,16 +61,21 @@ export function createBrowserPendingWriteRecovery({ environment = createDefaultB
|
|
|
60
61
|
}
|
|
61
62
|
const attempt = (async () => {
|
|
62
63
|
try {
|
|
63
|
-
const run = () =>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
const run = () => options.recover
|
|
65
|
+
? options.recover()
|
|
66
|
+
: syncPendingWrites({
|
|
67
|
+
scopeKey,
|
|
68
|
+
select,
|
|
69
|
+
store,
|
|
70
|
+
sync: options.sync,
|
|
71
|
+
});
|
|
69
72
|
const result = coordination
|
|
70
73
|
? await coordination.runExclusive(run)
|
|
71
74
|
: await run();
|
|
72
75
|
if (!result) {
|
|
76
|
+
// Another tab currently owns the lease. It may disappear before
|
|
77
|
+
// completion, so active recovery must retry rather than go dormant.
|
|
78
|
+
scheduleRetry();
|
|
73
79
|
return null;
|
|
74
80
|
}
|
|
75
81
|
emitResult(result);
|
package/dist/pendingWrites.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type BrowserKeyValueDriverOptions } from "./idbDriver";
|
|
2
2
|
export declare const pendingWriteSchemaVersion = 1;
|
|
3
3
|
export declare const pendingWriteOperationTimeoutMs = 2000;
|
|
4
|
+
export declare const pendingWriteSyncLeaseTimeoutMs = 60000;
|
|
4
5
|
export type PendingWriteStatus = "pending" | "syncing" | "failed";
|
|
5
6
|
export type PendingWriteRecord<Payload = unknown> = {
|
|
6
7
|
error?: string;
|
|
@@ -8,6 +9,8 @@ export type PendingWriteRecord<Payload = unknown> = {
|
|
|
8
9
|
payload: Payload;
|
|
9
10
|
savedAt: string;
|
|
10
11
|
snapshotId: string;
|
|
12
|
+
/** Timestamp of the active remote-sync claim, absent when not owned. */
|
|
13
|
+
syncingAt?: string;
|
|
11
14
|
syncStatus: PendingWriteStatus;
|
|
12
15
|
};
|
|
13
16
|
export type PendingWriteRecordInput<Payload = unknown> = Omit<PendingWriteRecord<Payload>, "snapshotId"> & {
|
|
@@ -39,6 +42,7 @@ export type PendingWriteStoreChange<Payload = unknown> = {
|
|
|
39
42
|
};
|
|
40
43
|
export type PendingWriteStoreChangeListener<Payload = unknown> = (change: PendingWriteStoreChange<Payload>) => void;
|
|
41
44
|
export type PendingWriteStore<Payload = unknown> = {
|
|
45
|
+
claimIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>, now: string) => Promise<boolean>;
|
|
42
46
|
clear: (id: string) => Promise<void>;
|
|
43
47
|
clearAll: () => Promise<void>;
|
|
44
48
|
clearIfSnapshotId: (id: string, snapshotId: string) => Promise<boolean>;
|
|
@@ -95,6 +99,23 @@ export type RunPendingWriteResult<TResult, TQueued> = {
|
|
|
95
99
|
queued: TQueued;
|
|
96
100
|
status: "queued";
|
|
97
101
|
};
|
|
102
|
+
export type PendingWriteRunOptions<TResult, TQueued> = {
|
|
103
|
+
enqueue: () => Promise<TQueued>;
|
|
104
|
+
isOnline: () => boolean;
|
|
105
|
+
shouldQueue: (error: unknown) => boolean;
|
|
106
|
+
write: () => Promise<TResult>;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Serializes recovery and new writes for one caller-defined scope.
|
|
110
|
+
*
|
|
111
|
+
* A queued write that cannot be replayed stays ahead of later writes. This
|
|
112
|
+
* preserves remote write order; callers retain ownership of scope selection
|
|
113
|
+
* and idempotent remote mutation semantics.
|
|
114
|
+
*/
|
|
115
|
+
export type OrderedPendingWriteExecutor<Payload> = {
|
|
116
|
+
recover: () => Promise<SyncPendingWritesResult>;
|
|
117
|
+
run: <TResult, TQueued>(options: PendingWriteRunOptions<TResult, TQueued>) => Promise<RunPendingWriteResult<TResult, TQueued>>;
|
|
118
|
+
};
|
|
98
119
|
export type PendingWriteSummary = {
|
|
99
120
|
failedCount: number;
|
|
100
121
|
pendingCount: number;
|
|
@@ -107,8 +128,9 @@ export declare function createBrowserPendingWriteDriver({ databaseName, database
|
|
|
107
128
|
export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
|
|
108
129
|
export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
|
|
109
130
|
export declare function createPendingWriteSnapshotId(): `${string}-${string}-${string}-${string}-${string}`;
|
|
110
|
-
export declare function syncPendingWrite<Payload>({ id, store, sync, }: {
|
|
131
|
+
export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
|
|
111
132
|
id: string;
|
|
133
|
+
now?: () => string;
|
|
112
134
|
store: PendingWriteStore<Payload>;
|
|
113
135
|
sync: PendingWriteSync<Payload>;
|
|
114
136
|
}): Promise<SyncPendingWriteResult>;
|
|
@@ -117,9 +139,11 @@ export declare function syncPendingWrite<Payload>({ id, store, sync, }: {
|
|
|
117
139
|
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
118
140
|
* mutation semantics.
|
|
119
141
|
*/
|
|
120
|
-
export declare function syncPendingWrites<Payload>({ scopeKey, select, store, sync, }: {
|
|
142
|
+
export declare function syncPendingWrites<Payload>({ scopeKey, select, stopOnFailure, store, sync, }: {
|
|
121
143
|
scopeKey?: string;
|
|
122
144
|
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
145
|
+
/** Preserve caller ordering by leaving later records for a future retry. */
|
|
146
|
+
stopOnFailure?: boolean;
|
|
123
147
|
store: PendingWriteStore<Payload>;
|
|
124
148
|
sync: PendingWriteSync<Payload>;
|
|
125
149
|
}): Promise<SyncPendingWritesResult>;
|
|
@@ -127,10 +151,16 @@ export declare function syncPendingWrites<Payload>({ scopeKey, select, store, sy
|
|
|
127
151
|
* Attempts a remote write now and persists a caller-created durable fallback
|
|
128
152
|
* only when offline or when the supplied retry classifier accepts the error.
|
|
129
153
|
*/
|
|
130
|
-
export declare function runPendingWrite<TResult, TQueued>({ enqueue, isOnline, shouldQueue, write, }:
|
|
131
|
-
enqueue: () => Promise<TQueued>;
|
|
132
|
-
isOnline: () => boolean;
|
|
133
|
-
shouldQueue: (error: unknown) => boolean;
|
|
134
|
-
write: () => Promise<TResult>;
|
|
135
|
-
}): Promise<RunPendingWriteResult<TResult, TQueued>>;
|
|
154
|
+
export declare function runPendingWrite<TResult, TQueued>({ enqueue, isOnline, shouldQueue, write, }: PendingWriteRunOptions<TResult, TQueued>): Promise<RunPendingWriteResult<TResult, TQueued>>;
|
|
136
155
|
export declare function summarizePendingWrites<Payload>(records: Array<PendingWriteRecord<Payload>>): PendingWriteSummary;
|
|
156
|
+
/**
|
|
157
|
+
* Creates a serial execution lane for a durable scope such as one user or
|
|
158
|
+
* workspace. Use its `recover` method for background replay and `run` for new
|
|
159
|
+
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
160
|
+
*/
|
|
161
|
+
export declare function createOrderedPendingWriteExecutor<Payload>({ scopeKey, select, store, sync, }: {
|
|
162
|
+
scopeKey?: string;
|
|
163
|
+
select?: (record: PendingWriteRecord<Payload>) => boolean;
|
|
164
|
+
store: PendingWriteStore<Payload>;
|
|
165
|
+
sync: PendingWriteSync<Payload>;
|
|
166
|
+
}): OrderedPendingWriteExecutor<Payload>;
|
package/dist/pendingWrites.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createIdbKeyValueDriver, } from "./idbDriver.js";
|
|
2
2
|
export const pendingWriteSchemaVersion = 1;
|
|
3
3
|
export const pendingWriteOperationTimeoutMs = 2_000;
|
|
4
|
+
export const pendingWriteSyncLeaseTimeoutMs = 60_000;
|
|
4
5
|
export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = pendingWriteSchemaVersion, }) {
|
|
5
6
|
const listeners = new Set();
|
|
6
7
|
const notify = (change) => {
|
|
@@ -17,6 +18,20 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
|
|
|
17
18
|
}
|
|
18
19
|
};
|
|
19
20
|
return {
|
|
21
|
+
async claimIfSnapshotId(id, snapshotId, record, now) {
|
|
22
|
+
const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
|
|
23
|
+
const current = toPendingWriteEnvelope(value, schemaVersion, {
|
|
24
|
+
normalizeLegacyRecord,
|
|
25
|
+
normalizePayload,
|
|
26
|
+
})?.record;
|
|
27
|
+
return (current?.snapshotId === snapshotId &&
|
|
28
|
+
isPendingWriteSyncClaimAvailable(current, now));
|
|
29
|
+
});
|
|
30
|
+
if (claimed) {
|
|
31
|
+
notify({ record, type: "replace" });
|
|
32
|
+
}
|
|
33
|
+
return claimed;
|
|
34
|
+
},
|
|
20
35
|
async clear(id) {
|
|
21
36
|
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
22
37
|
notify({ id, type: "clear" });
|
|
@@ -141,31 +156,44 @@ export function createPendingWriteKey({ id, prefix }) {
|
|
|
141
156
|
export function createPendingWriteSnapshotId() {
|
|
142
157
|
return globalThis.crypto.randomUUID();
|
|
143
158
|
}
|
|
144
|
-
export async function syncPendingWrite({ id, store, sync, }) {
|
|
159
|
+
export async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
|
|
145
160
|
const pending = await store.get(id);
|
|
146
161
|
if (!pending) {
|
|
147
162
|
return { status: "idle" };
|
|
148
163
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
const claimedSnapshotId = createPendingWriteSnapshotId();
|
|
165
|
+
const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
|
|
166
|
+
...pending,
|
|
167
|
+
error: undefined,
|
|
168
|
+
snapshotId: claimedSnapshotId,
|
|
169
|
+
syncingAt: now(),
|
|
170
|
+
syncStatus: "syncing",
|
|
171
|
+
}, now());
|
|
172
|
+
if (!claimed) {
|
|
173
|
+
return { status: "superseded" };
|
|
174
|
+
}
|
|
155
175
|
try {
|
|
156
176
|
await sync(pending.payload);
|
|
157
177
|
}
|
|
158
178
|
catch (error) {
|
|
159
179
|
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
160
|
-
|
|
180
|
+
const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
|
|
181
|
+
const markedFailed = await markPendingSnapshot({
|
|
161
182
|
id,
|
|
162
|
-
nextRecord: {
|
|
163
|
-
|
|
183
|
+
nextRecord: {
|
|
184
|
+
...pendingWithoutSyncClaim,
|
|
185
|
+
error: message,
|
|
186
|
+
snapshotId: claimedSnapshotId,
|
|
187
|
+
syncStatus: "failed",
|
|
188
|
+
},
|
|
189
|
+
snapshotId: claimedSnapshotId,
|
|
164
190
|
store,
|
|
165
191
|
});
|
|
166
|
-
return
|
|
192
|
+
return markedFailed
|
|
193
|
+
? { error: message, status: "failed" }
|
|
194
|
+
: { status: "superseded" };
|
|
167
195
|
}
|
|
168
|
-
return (await store.clearIfSnapshotId(id,
|
|
196
|
+
return (await store.clearIfSnapshotId(id, claimedSnapshotId))
|
|
169
197
|
? { status: "synced" }
|
|
170
198
|
: { status: "superseded" };
|
|
171
199
|
}
|
|
@@ -174,7 +202,7 @@ export async function syncPendingWrite({ id, store, sync, }) {
|
|
|
174
202
|
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
175
203
|
* mutation semantics.
|
|
176
204
|
*/
|
|
177
|
-
export function syncPendingWrites({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
205
|
+
export function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
178
206
|
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
179
207
|
if (!inFlightByScope) {
|
|
180
208
|
inFlightByScope = new Map();
|
|
@@ -184,7 +212,12 @@ export function syncPendingWrites({ scopeKey = "all", select = () => true, store
|
|
|
184
212
|
if (existing) {
|
|
185
213
|
return existing;
|
|
186
214
|
}
|
|
187
|
-
const syncRun = performPendingWriteSync({
|
|
215
|
+
const syncRun = performPendingWriteSync({
|
|
216
|
+
select,
|
|
217
|
+
stopOnFailure,
|
|
218
|
+
store,
|
|
219
|
+
sync,
|
|
220
|
+
});
|
|
188
221
|
inFlightByScope.set(scopeKey, syncRun);
|
|
189
222
|
const clearInFlight = () => {
|
|
190
223
|
inFlightByScope.delete(scopeKey);
|
|
@@ -226,7 +259,7 @@ export function summarizePendingWrites(records) {
|
|
|
226
259
|
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
227
260
|
}
|
|
228
261
|
const pendingWriteSyncs = new WeakMap();
|
|
229
|
-
async function performPendingWriteSync({ select, store, sync, }) {
|
|
262
|
+
async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
|
|
230
263
|
const summary = {
|
|
231
264
|
attempted: 0,
|
|
232
265
|
failed: 0,
|
|
@@ -244,10 +277,72 @@ async function performPendingWriteSync({ select, store, sync, }) {
|
|
|
244
277
|
});
|
|
245
278
|
summary.attempted += 1;
|
|
246
279
|
summary[result.status] += 1;
|
|
280
|
+
if (stopOnFailure && result.status === "failed") {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
247
283
|
}
|
|
248
284
|
summary.remaining = (await store.list()).filter(select).length;
|
|
249
285
|
return summary;
|
|
250
286
|
}
|
|
287
|
+
function isPendingWriteSyncClaimAvailable(record, now) {
|
|
288
|
+
if (record.syncStatus !== "syncing") {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
if (!record.syncingAt) {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
const startedAt = Date.parse(record.syncingAt);
|
|
295
|
+
const currentTime = Date.parse(now);
|
|
296
|
+
return (!Number.isFinite(startedAt) ||
|
|
297
|
+
!Number.isFinite(currentTime) ||
|
|
298
|
+
currentTime - startedAt >= pendingWriteSyncLeaseTimeoutMs);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Creates a serial execution lane for a durable scope such as one user or
|
|
302
|
+
* workspace. Use its `recover` method for background replay and `run` for new
|
|
303
|
+
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
304
|
+
*/
|
|
305
|
+
export function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
306
|
+
let execution = Promise.resolve();
|
|
307
|
+
function inOrder(operation) {
|
|
308
|
+
const result = execution.then(operation, operation);
|
|
309
|
+
execution = result.then(() => undefined, () => undefined);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
function recover() {
|
|
313
|
+
return inOrder(() => syncPendingWrites({
|
|
314
|
+
scopeKey,
|
|
315
|
+
select,
|
|
316
|
+
stopOnFailure: true,
|
|
317
|
+
store,
|
|
318
|
+
sync,
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
recover,
|
|
323
|
+
run(options) {
|
|
324
|
+
return inOrder(async () => {
|
|
325
|
+
if (!options.isOnline()) {
|
|
326
|
+
return runPendingWrite(options);
|
|
327
|
+
}
|
|
328
|
+
const replay = await syncPendingWrites({
|
|
329
|
+
scopeKey,
|
|
330
|
+
select,
|
|
331
|
+
stopOnFailure: true,
|
|
332
|
+
store,
|
|
333
|
+
sync,
|
|
334
|
+
});
|
|
335
|
+
// Product decision: unresolved older records retain their position in
|
|
336
|
+
// this scope. Queue the later write rather than letting it overtake a
|
|
337
|
+
// replay whose remote effect may be ambiguous.
|
|
338
|
+
if (replay.remaining > 0) {
|
|
339
|
+
return { queued: await options.enqueue(), status: "queued" };
|
|
340
|
+
}
|
|
341
|
+
return runPendingWrite(options);
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
251
346
|
function createNoopPendingWriteDriver() {
|
|
252
347
|
return {
|
|
253
348
|
async clear() { },
|
|
@@ -307,6 +402,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
307
402
|
payload,
|
|
308
403
|
savedAt: record.savedAt,
|
|
309
404
|
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
405
|
+
...(typeof record.syncingAt === "string"
|
|
406
|
+
? { syncingAt: record.syncingAt }
|
|
407
|
+
: {}),
|
|
310
408
|
syncStatus: record.syncStatus,
|
|
311
409
|
};
|
|
312
410
|
}
|
|
@@ -332,6 +430,7 @@ function isPendingWriteRecord(value) {
|
|
|
332
430
|
"savedAt" in value &&
|
|
333
431
|
typeof value.savedAt === "string" &&
|
|
334
432
|
(!("snapshotId" in value) || typeof value.snapshotId === "string") &&
|
|
433
|
+
(!("syncingAt" in value) || typeof value.syncingAt === "string") &&
|
|
335
434
|
"syncStatus" in value &&
|
|
336
435
|
(value.syncStatus === "pending" ||
|
|
337
436
|
value.syncStatus === "syncing" ||
|
|
@@ -5,7 +5,8 @@ exports.createBrowserPendingWriteRecovery = createBrowserPendingWriteRecovery;
|
|
|
5
5
|
const pendingWrites_1 = require("./pendingWrites");
|
|
6
6
|
exports.initialPendingWriteRetryDelayMs = 2_000;
|
|
7
7
|
exports.maxPendingWriteRetryDelayMs = 60_000;
|
|
8
|
-
function createBrowserPendingWriteRecovery(
|
|
8
|
+
function createBrowserPendingWriteRecovery(options) {
|
|
9
|
+
const { environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = exports.initialPendingWriteRetryDelayMs, maxRetryDelayMs = exports.maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, } = options;
|
|
9
10
|
let active = false;
|
|
10
11
|
let retryDelayMs = Math.max(0, initialRetryDelayMs);
|
|
11
12
|
let retryTimer;
|
|
@@ -64,16 +65,21 @@ function createBrowserPendingWriteRecovery({ environment = createDefaultBrowserR
|
|
|
64
65
|
}
|
|
65
66
|
const attempt = (async () => {
|
|
66
67
|
try {
|
|
67
|
-
const run = () =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const run = () => options.recover
|
|
69
|
+
? options.recover()
|
|
70
|
+
: (0, pendingWrites_1.syncPendingWrites)({
|
|
71
|
+
scopeKey,
|
|
72
|
+
select,
|
|
73
|
+
store,
|
|
74
|
+
sync: options.sync,
|
|
75
|
+
});
|
|
73
76
|
const result = coordination
|
|
74
77
|
? await coordination.runExclusive(run)
|
|
75
78
|
: await run();
|
|
76
79
|
if (!result) {
|
|
80
|
+
// Another tab currently owns the lease. It may disappear before
|
|
81
|
+
// completion, so active recovery must retry rather than go dormant.
|
|
82
|
+
scheduleRetry();
|
|
77
83
|
return null;
|
|
78
84
|
}
|
|
79
85
|
emitResult(result);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.pendingWriteOperationTimeoutMs = exports.pendingWriteSchemaVersion = void 0;
|
|
3
|
+
exports.pendingWriteSyncLeaseTimeoutMs = exports.pendingWriteOperationTimeoutMs = exports.pendingWriteSchemaVersion = void 0;
|
|
4
4
|
exports.createPendingWriteStore = createPendingWriteStore;
|
|
5
5
|
exports.createMemoryPendingWriteDriver = createMemoryPendingWriteDriver;
|
|
6
6
|
exports.createBrowserPendingWriteDriver = createBrowserPendingWriteDriver;
|
|
@@ -11,9 +11,11 @@ exports.syncPendingWrite = syncPendingWrite;
|
|
|
11
11
|
exports.syncPendingWrites = syncPendingWrites;
|
|
12
12
|
exports.runPendingWrite = runPendingWrite;
|
|
13
13
|
exports.summarizePendingWrites = summarizePendingWrites;
|
|
14
|
+
exports.createOrderedPendingWriteExecutor = createOrderedPendingWriteExecutor;
|
|
14
15
|
const idbDriver_1 = require("./idbDriver");
|
|
15
16
|
exports.pendingWriteSchemaVersion = 1;
|
|
16
17
|
exports.pendingWriteOperationTimeoutMs = 2_000;
|
|
18
|
+
exports.pendingWriteSyncLeaseTimeoutMs = 60_000;
|
|
17
19
|
function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion = exports.pendingWriteSchemaVersion, }) {
|
|
18
20
|
const listeners = new Set();
|
|
19
21
|
const notify = (change) => {
|
|
@@ -30,6 +32,20 @@ function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyRecord, nor
|
|
|
30
32
|
}
|
|
31
33
|
};
|
|
32
34
|
return {
|
|
35
|
+
async claimIfSnapshotId(id, snapshotId, record, now) {
|
|
36
|
+
const claimed = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => {
|
|
37
|
+
const current = toPendingWriteEnvelope(value, schemaVersion, {
|
|
38
|
+
normalizeLegacyRecord,
|
|
39
|
+
normalizePayload,
|
|
40
|
+
})?.record;
|
|
41
|
+
return (current?.snapshotId === snapshotId &&
|
|
42
|
+
isPendingWriteSyncClaimAvailable(current, now));
|
|
43
|
+
});
|
|
44
|
+
if (claimed) {
|
|
45
|
+
notify({ record, type: "replace" });
|
|
46
|
+
}
|
|
47
|
+
return claimed;
|
|
48
|
+
},
|
|
33
49
|
async clear(id) {
|
|
34
50
|
await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
|
|
35
51
|
notify({ id, type: "clear" });
|
|
@@ -154,31 +170,44 @@ function createPendingWriteKey({ id, prefix }) {
|
|
|
154
170
|
function createPendingWriteSnapshotId() {
|
|
155
171
|
return globalThis.crypto.randomUUID();
|
|
156
172
|
}
|
|
157
|
-
async function syncPendingWrite({ id, store, sync, }) {
|
|
173
|
+
async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
|
|
158
174
|
const pending = await store.get(id);
|
|
159
175
|
if (!pending) {
|
|
160
176
|
return { status: "idle" };
|
|
161
177
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
178
|
+
const claimedSnapshotId = createPendingWriteSnapshotId();
|
|
179
|
+
const claimed = await store.claimIfSnapshotId(id, pending.snapshotId, {
|
|
180
|
+
...pending,
|
|
181
|
+
error: undefined,
|
|
182
|
+
snapshotId: claimedSnapshotId,
|
|
183
|
+
syncingAt: now(),
|
|
184
|
+
syncStatus: "syncing",
|
|
185
|
+
}, now());
|
|
186
|
+
if (!claimed) {
|
|
187
|
+
return { status: "superseded" };
|
|
188
|
+
}
|
|
168
189
|
try {
|
|
169
190
|
await sync(pending.payload);
|
|
170
191
|
}
|
|
171
192
|
catch (error) {
|
|
172
193
|
const message = error instanceof Error ? error.message : "Unable to sync pending write.";
|
|
173
|
-
|
|
194
|
+
const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
|
|
195
|
+
const markedFailed = await markPendingSnapshot({
|
|
174
196
|
id,
|
|
175
|
-
nextRecord: {
|
|
176
|
-
|
|
197
|
+
nextRecord: {
|
|
198
|
+
...pendingWithoutSyncClaim,
|
|
199
|
+
error: message,
|
|
200
|
+
snapshotId: claimedSnapshotId,
|
|
201
|
+
syncStatus: "failed",
|
|
202
|
+
},
|
|
203
|
+
snapshotId: claimedSnapshotId,
|
|
177
204
|
store,
|
|
178
205
|
});
|
|
179
|
-
return
|
|
206
|
+
return markedFailed
|
|
207
|
+
? { error: message, status: "failed" }
|
|
208
|
+
: { status: "superseded" };
|
|
180
209
|
}
|
|
181
|
-
return (await store.clearIfSnapshotId(id,
|
|
210
|
+
return (await store.clearIfSnapshotId(id, claimedSnapshotId))
|
|
182
211
|
? { status: "synced" }
|
|
183
212
|
: { status: "superseded" };
|
|
184
213
|
}
|
|
@@ -187,7 +216,7 @@ async function syncPendingWrite({ id, store, sync, }) {
|
|
|
187
216
|
* same store/scope. Callers own the selection predicate and idempotent remote
|
|
188
217
|
* mutation semantics.
|
|
189
218
|
*/
|
|
190
|
-
function syncPendingWrites({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
219
|
+
function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
|
|
191
220
|
let inFlightByScope = pendingWriteSyncs.get(store);
|
|
192
221
|
if (!inFlightByScope) {
|
|
193
222
|
inFlightByScope = new Map();
|
|
@@ -197,7 +226,12 @@ function syncPendingWrites({ scopeKey = "all", select = () => true, store, sync,
|
|
|
197
226
|
if (existing) {
|
|
198
227
|
return existing;
|
|
199
228
|
}
|
|
200
|
-
const syncRun = performPendingWriteSync({
|
|
229
|
+
const syncRun = performPendingWriteSync({
|
|
230
|
+
select,
|
|
231
|
+
stopOnFailure,
|
|
232
|
+
store,
|
|
233
|
+
sync,
|
|
234
|
+
});
|
|
201
235
|
inFlightByScope.set(scopeKey, syncRun);
|
|
202
236
|
const clearInFlight = () => {
|
|
203
237
|
inFlightByScope.delete(scopeKey);
|
|
@@ -239,7 +273,7 @@ function summarizePendingWrites(records) {
|
|
|
239
273
|
}, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
|
|
240
274
|
}
|
|
241
275
|
const pendingWriteSyncs = new WeakMap();
|
|
242
|
-
async function performPendingWriteSync({ select, store, sync, }) {
|
|
276
|
+
async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
|
|
243
277
|
const summary = {
|
|
244
278
|
attempted: 0,
|
|
245
279
|
failed: 0,
|
|
@@ -257,10 +291,72 @@ async function performPendingWriteSync({ select, store, sync, }) {
|
|
|
257
291
|
});
|
|
258
292
|
summary.attempted += 1;
|
|
259
293
|
summary[result.status] += 1;
|
|
294
|
+
if (stopOnFailure && result.status === "failed") {
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
260
297
|
}
|
|
261
298
|
summary.remaining = (await store.list()).filter(select).length;
|
|
262
299
|
return summary;
|
|
263
300
|
}
|
|
301
|
+
function isPendingWriteSyncClaimAvailable(record, now) {
|
|
302
|
+
if (record.syncStatus !== "syncing") {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (!record.syncingAt) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const startedAt = Date.parse(record.syncingAt);
|
|
309
|
+
const currentTime = Date.parse(now);
|
|
310
|
+
return (!Number.isFinite(startedAt) ||
|
|
311
|
+
!Number.isFinite(currentTime) ||
|
|
312
|
+
currentTime - startedAt >= exports.pendingWriteSyncLeaseTimeoutMs);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Creates a serial execution lane for a durable scope such as one user or
|
|
316
|
+
* workspace. Use its `recover` method for background replay and `run` for new
|
|
317
|
+
* writes so a newer remote mutation cannot overtake an older queued mutation.
|
|
318
|
+
*/
|
|
319
|
+
function createOrderedPendingWriteExecutor({ scopeKey = "all", select = () => true, store, sync, }) {
|
|
320
|
+
let execution = Promise.resolve();
|
|
321
|
+
function inOrder(operation) {
|
|
322
|
+
const result = execution.then(operation, operation);
|
|
323
|
+
execution = result.then(() => undefined, () => undefined);
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
function recover() {
|
|
327
|
+
return inOrder(() => syncPendingWrites({
|
|
328
|
+
scopeKey,
|
|
329
|
+
select,
|
|
330
|
+
stopOnFailure: true,
|
|
331
|
+
store,
|
|
332
|
+
sync,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
recover,
|
|
337
|
+
run(options) {
|
|
338
|
+
return inOrder(async () => {
|
|
339
|
+
if (!options.isOnline()) {
|
|
340
|
+
return runPendingWrite(options);
|
|
341
|
+
}
|
|
342
|
+
const replay = await syncPendingWrites({
|
|
343
|
+
scopeKey,
|
|
344
|
+
select,
|
|
345
|
+
stopOnFailure: true,
|
|
346
|
+
store,
|
|
347
|
+
sync,
|
|
348
|
+
});
|
|
349
|
+
// Product decision: unresolved older records retain their position in
|
|
350
|
+
// this scope. Queue the later write rather than letting it overtake a
|
|
351
|
+
// replay whose remote effect may be ambiguous.
|
|
352
|
+
if (replay.remaining > 0) {
|
|
353
|
+
return { queued: await options.enqueue(), status: "queued" };
|
|
354
|
+
}
|
|
355
|
+
return runPendingWrite(options);
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
264
360
|
function createNoopPendingWriteDriver() {
|
|
265
361
|
return {
|
|
266
362
|
async clear() { },
|
|
@@ -320,6 +416,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
|
|
|
320
416
|
payload,
|
|
321
417
|
savedAt: record.savedAt,
|
|
322
418
|
snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
|
|
419
|
+
...(typeof record.syncingAt === "string"
|
|
420
|
+
? { syncingAt: record.syncingAt }
|
|
421
|
+
: {}),
|
|
323
422
|
syncStatus: record.syncStatus,
|
|
324
423
|
};
|
|
325
424
|
}
|
|
@@ -345,6 +444,7 @@ function isPendingWriteRecord(value) {
|
|
|
345
444
|
"savedAt" in value &&
|
|
346
445
|
typeof value.savedAt === "string" &&
|
|
347
446
|
(!("snapshotId" in value) || typeof value.snapshotId === "string") &&
|
|
447
|
+
(!("syncingAt" in value) || typeof value.syncingAt === "string") &&
|
|
348
448
|
"syncStatus" in value &&
|
|
349
449
|
(value.syncStatus === "pending" ||
|
|
350
450
|
value.syncStatus === "syncing" ||
|