@bayonai/offline 0.1.1 → 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.
@@ -1,19 +1,54 @@
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, }) {
6
+ const listeners = new Set();
7
+ const notify = (change) => {
8
+ for (const listener of listeners) {
9
+ try {
10
+ listener(change);
11
+ }
12
+ catch (error) {
13
+ console.error("[Bayon offline] Pending-write store listener failed.", {
14
+ error,
15
+ type: change.type,
16
+ });
17
+ }
18
+ }
19
+ };
5
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
+ },
6
35
  async clear(id) {
7
36
  await driver.clear(createPendingWriteKey({ id, prefix: keyPrefix }));
37
+ notify({ id, type: "clear" });
8
38
  },
9
39
  async clearAll() {
10
40
  await driver.clearAll();
41
+ notify({ type: "clearAll" });
11
42
  },
12
43
  async clearIfSnapshotId(id, snapshotId) {
13
- return driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
44
+ const cleared = await driver.clearIf(createPendingWriteKey({ id, prefix: keyPrefix }), (value) => toPendingWriteEnvelope(value, schemaVersion, {
14
45
  normalizeLegacyRecord,
15
46
  normalizePayload,
16
47
  })?.record.snapshotId === snapshotId);
48
+ if (cleared) {
49
+ notify({ id, type: "clear" });
50
+ }
51
+ return cleared;
17
52
  },
18
53
  async close() {
19
54
  await driver.close?.();
@@ -28,13 +63,18 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
28
63
  normalizePayload,
29
64
  })?.record)
30
65
  .filter((record) => Boolean(record))
31
- .sort((left, right) => left.savedAt.localeCompare(right.savedAt));
66
+ .sort((left, right) => left.savedAt.localeCompare(right.savedAt) ||
67
+ left.id.localeCompare(right.id));
32
68
  },
33
69
  async replaceIfSnapshotId(id, snapshotId, record) {
34
- return driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
70
+ const replaced = await driver.setIf(createPendingWriteKey({ id, prefix: keyPrefix }), { record, schemaVersion }, (value) => toPendingWriteEnvelope(value, schemaVersion, {
35
71
  normalizeLegacyRecord,
36
72
  normalizePayload,
37
73
  })?.record.snapshotId === snapshotId);
74
+ if (replaced) {
75
+ notify({ record, type: "replace" });
76
+ }
77
+ return replaced;
38
78
  },
39
79
  async set(record) {
40
80
  const storedRecord = {
@@ -45,8 +85,13 @@ export function createPendingWriteStore(driver, { keyPrefix, normalizeLegacyReco
45
85
  record: storedRecord,
46
86
  schemaVersion,
47
87
  });
88
+ notify({ record: storedRecord, type: "set" });
48
89
  return storedRecord;
49
90
  },
91
+ subscribe(listener) {
92
+ listeners.add(listener);
93
+ return () => listeners.delete(listener);
94
+ },
50
95
  };
51
96
  }
52
97
  export function createMemoryPendingWriteDriver() {
@@ -111,34 +156,193 @@ export function createPendingWriteKey({ id, prefix }) {
111
156
  export function createPendingWriteSnapshotId() {
112
157
  return globalThis.crypto.randomUUID();
113
158
  }
114
- export async function syncPendingWrite({ id, store, sync, }) {
159
+ export async function syncPendingWrite({ id, now = () => new Date().toISOString(), store, sync, }) {
115
160
  const pending = await store.get(id);
116
161
  if (!pending) {
117
162
  return { status: "idle" };
118
163
  }
119
- await markPendingSnapshot({
120
- id,
121
- nextRecord: { ...pending, error: undefined, syncStatus: "syncing" },
122
- snapshotId: pending.snapshotId,
123
- store,
124
- });
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
+ }
125
175
  try {
126
176
  await sync(pending.payload);
127
177
  }
128
178
  catch (error) {
129
179
  const message = error instanceof Error ? error.message : "Unable to sync pending write.";
130
- await markPendingSnapshot({
180
+ const { syncingAt: _syncingAt, ...pendingWithoutSyncClaim } = pending;
181
+ const markedFailed = await markPendingSnapshot({
131
182
  id,
132
- nextRecord: { ...pending, error: message, syncStatus: "failed" },
133
- snapshotId: pending.snapshotId,
183
+ nextRecord: {
184
+ ...pendingWithoutSyncClaim,
185
+ error: message,
186
+ snapshotId: claimedSnapshotId,
187
+ syncStatus: "failed",
188
+ },
189
+ snapshotId: claimedSnapshotId,
134
190
  store,
135
191
  });
136
- return { error: message, status: "failed" };
192
+ return markedFailed
193
+ ? { error: message, status: "failed" }
194
+ : { status: "superseded" };
137
195
  }
138
- return (await store.clearIfSnapshotId(id, pending.snapshotId))
196
+ return (await store.clearIfSnapshotId(id, claimedSnapshotId))
139
197
  ? { status: "synced" }
140
198
  : { status: "superseded" };
141
199
  }
200
+ /**
201
+ * Replays selected writes oldest first and coalesces overlapping runs for the
202
+ * same store/scope. Callers own the selection predicate and idempotent remote
203
+ * mutation semantics.
204
+ */
205
+ export function syncPendingWrites({ scopeKey = "all", select = () => true, stopOnFailure = false, store, sync, }) {
206
+ let inFlightByScope = pendingWriteSyncs.get(store);
207
+ if (!inFlightByScope) {
208
+ inFlightByScope = new Map();
209
+ pendingWriteSyncs.set(store, inFlightByScope);
210
+ }
211
+ const existing = inFlightByScope.get(scopeKey);
212
+ if (existing) {
213
+ return existing;
214
+ }
215
+ const syncRun = performPendingWriteSync({
216
+ select,
217
+ stopOnFailure,
218
+ store,
219
+ sync,
220
+ });
221
+ inFlightByScope.set(scopeKey, syncRun);
222
+ const clearInFlight = () => {
223
+ inFlightByScope.delete(scopeKey);
224
+ };
225
+ void syncRun.then(clearInFlight, clearInFlight);
226
+ return syncRun;
227
+ }
228
+ /**
229
+ * Attempts a remote write now and persists a caller-created durable fallback
230
+ * only when offline or when the supplied retry classifier accepts the error.
231
+ */
232
+ export async function runPendingWrite({ enqueue, isOnline, shouldQueue, write, }) {
233
+ if (!isOnline()) {
234
+ return { queued: await enqueue(), status: "queued" };
235
+ }
236
+ try {
237
+ return { result: await write(), status: "synced" };
238
+ }
239
+ catch (error) {
240
+ if (!shouldQueue(error)) {
241
+ throw error;
242
+ }
243
+ return { queued: await enqueue(), status: "queued" };
244
+ }
245
+ }
246
+ export function summarizePendingWrites(records) {
247
+ return records.reduce((summary, record) => {
248
+ summary.totalCount += 1;
249
+ if (record.syncStatus === "failed") {
250
+ summary.failedCount += 1;
251
+ }
252
+ else if (record.syncStatus === "syncing") {
253
+ summary.syncingCount += 1;
254
+ }
255
+ else {
256
+ summary.pendingCount += 1;
257
+ }
258
+ return summary;
259
+ }, { failedCount: 0, pendingCount: 0, syncingCount: 0, totalCount: 0 });
260
+ }
261
+ const pendingWriteSyncs = new WeakMap();
262
+ async function performPendingWriteSync({ select, stopOnFailure, store, sync, }) {
263
+ const summary = {
264
+ attempted: 0,
265
+ failed: 0,
266
+ idle: 0,
267
+ remaining: 0,
268
+ superseded: 0,
269
+ synced: 0,
270
+ };
271
+ const records = (await store.list()).filter(select);
272
+ for (const record of records) {
273
+ const result = await syncPendingWrite({
274
+ id: record.id,
275
+ store,
276
+ sync,
277
+ });
278
+ summary.attempted += 1;
279
+ summary[result.status] += 1;
280
+ if (stopOnFailure && result.status === "failed") {
281
+ break;
282
+ }
283
+ }
284
+ summary.remaining = (await store.list()).filter(select).length;
285
+ return summary;
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
+ }
142
346
  function createNoopPendingWriteDriver() {
143
347
  return {
144
348
  async clear() { },
@@ -198,6 +402,9 @@ function normalizePendingWriteRecord(record, normalizePayload) {
198
402
  payload,
199
403
  savedAt: record.savedAt,
200
404
  snapshotId: record.snapshotId ?? createLegacyPendingWriteSnapshotId(record),
405
+ ...(typeof record.syncingAt === "string"
406
+ ? { syncingAt: record.syncingAt }
407
+ : {}),
201
408
  syncStatus: record.syncStatus,
202
409
  };
203
410
  }
@@ -223,6 +430,7 @@ function isPendingWriteRecord(value) {
223
430
  "savedAt" in value &&
224
431
  typeof value.savedAt === "string" &&
225
432
  (!("snapshotId" in value) || typeof value.snapshotId === "string") &&
433
+ (!("syncingAt" in value) || typeof value.syncingAt === "string") &&
226
434
  "syncStatus" in value &&
227
435
  (value.syncStatus === "pending" ||
228
436
  value.syncStatus === "syncing" ||
@@ -14,6 +14,10 @@ export type ReadCacheDriver = {
14
14
  close?: () => Promise<void>;
15
15
  delete: (key: string) => Promise<void>;
16
16
  get: (key: string) => Promise<unknown | null>;
17
+ listEntries?: () => Promise<Array<{
18
+ key: string;
19
+ value: unknown;
20
+ }>>;
17
21
  set: (key: string, value: unknown) => Promise<void>;
18
22
  };
19
23
  export type ReadCache = {
@@ -55,9 +59,23 @@ export type ReadCacheKeyOptions = {
55
59
  export declare function createReadCache(driver: ReadCacheDriver): ReadCache;
56
60
  export declare function createMemoryReadCacheDriver(): ReadCacheDriver;
57
61
  export declare function createBrowserReadCache({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: BrowserReadCacheOptions): ReadCache;
62
+ /**
63
+ * Deletes only valid read-cache envelopes older than the supplied age. It is
64
+ * opt-in and returns zero for custom cache adapters that cannot enumerate keys.
65
+ */
66
+ export declare function pruneReadCache({ cache, maxAgeMs, now, }: {
67
+ cache: ReadCache;
68
+ maxAgeMs: number;
69
+ now?: () => number;
70
+ }): Promise<number>;
58
71
  export declare function createReadCacheKey({ namespace, params, prefix, scope, }: ReadCacheKeyOptions): string;
59
- export declare function readThroughCache<T>({ cache, key, loadFresh, maxAgeMs, }: {
72
+ export declare function readThroughCache<T>({ cache, forceFresh, key, loadFresh, maxAgeMs, }: {
60
73
  cache: ReadCache;
74
+ /**
75
+ * Invalidates the current value before loading. Use after a successful write
76
+ * when a cached or earlier in-flight response must not become authoritative.
77
+ */
78
+ forceFresh?: boolean;
61
79
  key: string;
62
80
  loadFresh: () => Promise<T>;
63
81
  maxAgeMs?: number;
package/dist/readCache.js CHANGED
@@ -5,6 +5,7 @@ export const durableReadCacheFreshnessMaxAgeMs = 60 * 60_000;
5
5
  export const identityReadCacheFreshnessMaxAgeMs = 24 * 60 * 60_000;
6
6
  export const browserReadCacheOperationTimeoutMs = 2_000;
7
7
  const pendingFreshLoads = new WeakMap();
8
+ const cachePruners = new WeakMap();
8
9
  export function createReadCache(driver) {
9
10
  let globalVersion = 0;
10
11
  const keyVersions = new Map();
@@ -69,6 +70,21 @@ export function createReadCache(driver) {
69
70
  },
70
71
  };
71
72
  pendingFreshLoads.set(cache, pendingLoads);
73
+ cachePruners.set(cache, async ({ maxAgeMs, now }) => {
74
+ if (!driver.listEntries || maxAgeMs < 0) {
75
+ return 0;
76
+ }
77
+ const cutoff = now() - maxAgeMs;
78
+ const expiredKeys = (await driver.listEntries())
79
+ .filter(({ value }) => {
80
+ const envelope = toReadCacheEnvelope(value);
81
+ const savedAt = envelope ? Date.parse(envelope.savedAt) : Number.NaN;
82
+ return Number.isFinite(savedAt) && savedAt < cutoff;
83
+ })
84
+ .map(({ key }) => key);
85
+ await Promise.all(expiredKeys.map((key) => cache.delete(key)));
86
+ return expiredKeys.length;
87
+ });
72
88
  return cache;
73
89
  }
74
90
  export function createMemoryReadCacheDriver() {
@@ -84,6 +100,9 @@ export function createMemoryReadCacheDriver() {
84
100
  async get(key) {
85
101
  return records.get(key) ?? null;
86
102
  },
103
+ async listEntries() {
104
+ return [...records.entries()].map(([key, value]) => ({ key, value }));
105
+ },
87
106
  async set(key, value) {
88
107
  records.set(key, value);
89
108
  },
@@ -97,7 +116,25 @@ export function createBrowserReadCache({ databaseName, databaseVersion, indexedD
97
116
  operationTimeoutMs,
98
117
  storeName,
99
118
  });
100
- return createReadCache(driver ?? createNoopReadCacheDriver());
119
+ return createReadCache(driver
120
+ ? {
121
+ clear: driver.clear,
122
+ close: driver.close,
123
+ delete: driver.delete,
124
+ get: driver.get,
125
+ listEntries: driver.listEntries,
126
+ set: driver.set,
127
+ }
128
+ : createNoopReadCacheDriver());
129
+ }
130
+ /**
131
+ * Deletes only valid read-cache envelopes older than the supplied age. It is
132
+ * opt-in and returns zero for custom cache adapters that cannot enumerate keys.
133
+ */
134
+ export function pruneReadCache({ cache, maxAgeMs, now = () => Date.now(), }) {
135
+ const resourceCache = cache.capture?.() ?? cache;
136
+ return cachePruners.get(resourceCache)?.({ maxAgeMs, now }) ??
137
+ Promise.resolve(0);
101
138
  }
102
139
  export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
103
140
  const serializedParams = Object.entries(params)
@@ -114,8 +151,13 @@ export function createReadCacheKey({ namespace, params = {}, prefix, scope, }) {
114
151
  .filter(Boolean)
115
152
  .join(":");
116
153
  }
117
- export async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
154
+ export async function readThroughCache({ cache, forceFresh = false, key, loadFresh, maxAgeMs, }) {
118
155
  const resourceCache = cache.capture?.() ?? cache;
156
+ if (forceFresh) {
157
+ // Deletion advances the cache version, so a request started before this
158
+ // post-write refresh cannot restore stale data after it finishes.
159
+ await resourceCache.delete(key);
160
+ }
119
161
  const version = resourceCache.getVersion(key);
120
162
  const cached = await resourceCache.get(key);
121
163
  if (cached && isCacheFresh(cached.savedAt, maxAgeMs)) {
@@ -127,7 +169,7 @@ export async function readThroughCache({ cache, key, loadFresh, maxAgeMs, }) {
127
169
  };
128
170
  }
129
171
  try {
130
- const fresh = await loadFresh();
172
+ const fresh = await loadSharedFreshResource(resourceCache, key, loadFresh);
131
173
  await resourceCache.setIfVersion(key, fresh, version);
132
174
  return {
133
175
  data: fresh,
@@ -191,6 +233,9 @@ function createNoopReadCacheDriver() {
191
233
  async get() {
192
234
  return null;
193
235
  },
236
+ async listEntries() {
237
+ return [];
238
+ },
194
239
  async set() { },
195
240
  };
196
241
  }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBrowserPendingWriteRecoveryCoordination = createBrowserPendingWriteRecoveryCoordination;
4
+ function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel = createDefaultChannel(channelName), locks = readDefaultLockManager(), }) {
5
+ const listeners = new Set();
6
+ const handleMessage = () => {
7
+ for (const listener of listeners) {
8
+ try {
9
+ listener();
10
+ }
11
+ catch (error) {
12
+ console.error("[Bayon offline] Cross-tab recovery listener failed.", {
13
+ channelName,
14
+ error,
15
+ });
16
+ }
17
+ }
18
+ };
19
+ channel?.addEventListener("message", handleMessage);
20
+ return {
21
+ close() {
22
+ channel?.removeEventListener("message", handleMessage);
23
+ channel?.close();
24
+ listeners.clear();
25
+ },
26
+ notify() {
27
+ try {
28
+ channel?.postMessage("pending-write-recovery");
29
+ }
30
+ catch (error) {
31
+ console.error("[Bayon offline] Unable to wake cross-tab recovery.", {
32
+ channelName,
33
+ error,
34
+ });
35
+ }
36
+ },
37
+ async runExclusive(attempt) {
38
+ if (!locks) {
39
+ return attempt();
40
+ }
41
+ return locks.request(leaseName, { ifAvailable: true }, (lock) => lock ? attempt() : Promise.resolve(null));
42
+ },
43
+ subscribe(listener) {
44
+ listeners.add(listener);
45
+ return () => listeners.delete(listener);
46
+ },
47
+ };
48
+ }
49
+ function createDefaultChannel(name) {
50
+ if (typeof BroadcastChannel === "undefined") {
51
+ return null;
52
+ }
53
+ return new BroadcastChannel(name);
54
+ }
55
+ function readDefaultLockManager() {
56
+ if (typeof navigator === "undefined") {
57
+ return null;
58
+ }
59
+ return navigator.locks ?? null;
60
+ }
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.maxPendingWriteRetryDelayMs = exports.initialPendingWriteRetryDelayMs = void 0;
4
+ exports.createBrowserPendingWriteRecovery = createBrowserPendingWriteRecovery;
5
+ const pendingWrites_1 = require("./pendingWrites");
6
+ exports.initialPendingWriteRetryDelayMs = 2_000;
7
+ exports.maxPendingWriteRetryDelayMs = 60_000;
8
+ function createBrowserPendingWriteRecovery(options) {
9
+ const { environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = exports.initialPendingWriteRetryDelayMs, maxRetryDelayMs = exports.maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, } = options;
10
+ let active = false;
11
+ let retryDelayMs = Math.max(0, initialRetryDelayMs);
12
+ let retryTimer;
13
+ let recoveryPromise;
14
+ let immediateRecoveryRequested = false;
15
+ let unsubscribePeerWakeups;
16
+ let unsubscribe;
17
+ function clearRetryTimer() {
18
+ if (retryTimer) {
19
+ environment.clearTimeout(retryTimer);
20
+ }
21
+ retryTimer = undefined;
22
+ }
23
+ function emitResult(result) {
24
+ try {
25
+ onResult?.(result);
26
+ }
27
+ catch (error) {
28
+ console.error("[Bayon offline] Pending-write recovery result listener failed.", {
29
+ error,
30
+ });
31
+ }
32
+ }
33
+ function emitError(error) {
34
+ console.error("[Bayon offline] Pending-write recovery failed.", {
35
+ error,
36
+ scopeKey,
37
+ });
38
+ try {
39
+ onError?.(error);
40
+ }
41
+ catch (callbackError) {
42
+ console.error("[Bayon offline] Pending-write recovery error listener failed.", {
43
+ callbackError,
44
+ scopeKey,
45
+ });
46
+ }
47
+ }
48
+ function scheduleRetry() {
49
+ if (!active || !environment.isOnline() || retryTimer) {
50
+ return;
51
+ }
52
+ retryTimer = environment.setTimeout(() => {
53
+ retryTimer = undefined;
54
+ void recoverNow();
55
+ }, retryDelayMs);
56
+ retryDelayMs = Math.min(Math.max(initialRetryDelayMs, retryDelayMs * 2), Math.max(initialRetryDelayMs, maxRetryDelayMs));
57
+ }
58
+ function recoverNow() {
59
+ if (!environment.isOnline()) {
60
+ clearRetryTimer();
61
+ return Promise.resolve(null);
62
+ }
63
+ if (recoveryPromise) {
64
+ return recoveryPromise;
65
+ }
66
+ const attempt = (async () => {
67
+ try {
68
+ const run = () => options.recover
69
+ ? options.recover()
70
+ : (0, pendingWrites_1.syncPendingWrites)({
71
+ scopeKey,
72
+ select,
73
+ store,
74
+ sync: options.sync,
75
+ });
76
+ const result = coordination
77
+ ? await coordination.runExclusive(run)
78
+ : await run();
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();
83
+ return null;
84
+ }
85
+ emitResult(result);
86
+ if (result.remaining > 0) {
87
+ scheduleRetry();
88
+ }
89
+ else {
90
+ retryDelayMs = Math.max(0, initialRetryDelayMs);
91
+ }
92
+ return result;
93
+ }
94
+ catch (error) {
95
+ emitError(error);
96
+ scheduleRetry();
97
+ return null;
98
+ }
99
+ })();
100
+ recoveryPromise = attempt.finally(() => {
101
+ recoveryPromise = undefined;
102
+ if (active && immediateRecoveryRequested) {
103
+ immediateRecoveryRequested = false;
104
+ triggerImmediateRecovery();
105
+ }
106
+ });
107
+ return recoveryPromise;
108
+ }
109
+ function triggerImmediateRecovery() {
110
+ if (!active) {
111
+ return;
112
+ }
113
+ immediateRecoveryRequested ||= Boolean(recoveryPromise);
114
+ clearRetryTimer();
115
+ retryDelayMs = Math.max(0, initialRetryDelayMs);
116
+ void recoverNow();
117
+ }
118
+ function handleOffline() {
119
+ clearRetryTimer();
120
+ }
121
+ function handleStoreChange(change) {
122
+ if (change.type === "set") {
123
+ coordination?.notify();
124
+ triggerImmediateRecovery();
125
+ }
126
+ }
127
+ return {
128
+ notifyQueued() {
129
+ coordination?.notify();
130
+ triggerImmediateRecovery();
131
+ },
132
+ recoverNow,
133
+ start() {
134
+ if (active) {
135
+ return;
136
+ }
137
+ active = true;
138
+ unsubscribe = store.subscribe(handleStoreChange);
139
+ unsubscribePeerWakeups = coordination?.subscribe(triggerImmediateRecovery);
140
+ environment.addEventListener("focus", triggerImmediateRecovery);
141
+ environment.addEventListener("online", triggerImmediateRecovery);
142
+ environment.addEventListener("offline", handleOffline);
143
+ if (environment.isOnline()) {
144
+ triggerImmediateRecovery();
145
+ }
146
+ },
147
+ stop() {
148
+ if (!active) {
149
+ return;
150
+ }
151
+ active = false;
152
+ clearRetryTimer();
153
+ unsubscribe?.();
154
+ unsubscribe = undefined;
155
+ unsubscribePeerWakeups?.();
156
+ unsubscribePeerWakeups = undefined;
157
+ environment.removeEventListener("focus", triggerImmediateRecovery);
158
+ environment.removeEventListener("online", triggerImmediateRecovery);
159
+ environment.removeEventListener("offline", handleOffline);
160
+ },
161
+ };
162
+ }
163
+ function createDefaultBrowserRecoveryEnvironment() {
164
+ const target = typeof window === "undefined" ? null : window;
165
+ return {
166
+ addEventListener(type, listener) {
167
+ target?.addEventListener(type, listener);
168
+ },
169
+ clearTimeout(timer) {
170
+ globalThis.clearTimeout(timer);
171
+ },
172
+ isOnline() {
173
+ return typeof navigator !== "undefined" && navigator.onLine;
174
+ },
175
+ removeEventListener(type, listener) {
176
+ target?.removeEventListener(type, listener);
177
+ },
178
+ setTimeout(callback, delayMs) {
179
+ return globalThis.setTimeout(callback, delayMs);
180
+ },
181
+ };
182
+ }