@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 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
- runPendingWrite,
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 recovery = createBrowserPendingWriteRecovery({
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 runPendingWrite({
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. Remote write handlers must remain idempotent because
151
- leases do not cover other devices or every browser failure mode.
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`. Sync status updates and
161
- successful cleanup compare that identity inside a single storage transaction,
162
- so a newer local write cannot be overwritten or deleted by an older sync.
163
- Records created before snapshot identities were introduced remain readable;
164
- they receive a stable compatibility identity and are upgraded when next
165
- updated.
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
- export declare function createBrowserPendingWriteRecovery<Payload>({ environment, coordination, initialRetryDelayMs, maxRetryDelayMs, onError, onResult, scopeKey, select, store, sync, }: {
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
- }): BrowserPendingWriteRecovery<Payload>;
40
+ });
41
+ export declare function createBrowserPendingWriteRecovery<Payload>(options: BrowserPendingWriteRecoveryOptions<Payload>): BrowserPendingWriteRecovery<Payload>;
42
+ export {};
@@ -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({ environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = initialPendingWriteRetryDelayMs, maxRetryDelayMs = maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, sync, }) {
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 = () => syncPendingWrites({
64
- scopeKey,
65
- select,
66
- store,
67
- sync,
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);
@@ -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>;
@@ -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
- await markPendingSnapshot({
150
- id,
151
- nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
152
- snapshotId: pending.snapshotId,
153
- store,
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
- await markPendingSnapshot({
180
+ const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
181
+ const markedFailed = await markPendingSnapshot({
161
182
  id,
162
- nextRecord: { ...pending, error: message, syncStatus: "failed" },
163
- snapshotId: pending.snapshotId,
183
+ nextRecord: {
184
+ ...pendingWithoutSyncClaim,
185
+ error: message,
186
+ snapshotId: claimedSnapshotId,
187
+ syncStatus: "failed",
188
+ },
189
+ snapshotId: claimedSnapshotId,
164
190
  store,
165
191
  });
166
- return { error: message, status: "failed" };
192
+ return markedFailed
193
+ ? { error: message, status: "failed" }
194
+ : { status: "superseded" };
167
195
  }
168
- return (await store.clearIfSnapshotId(id, pending.snapshotId))
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({ select, store, sync });
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({ environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = exports.initialPendingWriteRetryDelayMs, maxRetryDelayMs = exports.maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, sync, }) {
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 = () => (0, pendingWrites_1.syncPendingWrites)({
68
- scopeKey,
69
- select,
70
- store,
71
- sync,
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
- await markPendingSnapshot({
163
- id,
164
- nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
165
- snapshotId: pending.snapshotId,
166
- store,
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
- await markPendingSnapshot({
194
+ const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
195
+ const markedFailed = await markPendingSnapshot({
174
196
  id,
175
- nextRecord: { ...pending, error: message, syncStatus: "failed" },
176
- snapshotId: pending.snapshotId,
197
+ nextRecord: {
198
+ ...pendingWithoutSyncClaim,
199
+ error: message,
200
+ snapshotId: claimedSnapshotId,
201
+ syncStatus: "failed",
202
+ },
203
+ snapshotId: claimedSnapshotId,
177
204
  store,
178
205
  });
179
- return { error: message, status: "failed" };
206
+ return markedFailed
207
+ ? { error: message, status: "failed" }
208
+ : { status: "superseded" };
180
209
  }
181
- return (await store.clearIfSnapshotId(id, pending.snapshotId))
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({ select, store, sync });
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" ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bayonai/offline",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Reusable offline read-cache, pending-write, and silent recovery primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Bayon AI",