@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
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
+
15
+ ## 0.3.0 - 2026-08-31
16
+
17
+ - Added optional `BroadcastChannel` wake-ups and `navigator.locks` replay
18
+ leases so browser tabs can avoid concurrent recovery attempts while retaining
19
+ a safe local fallback where either API is unavailable.
20
+ - Added `summarizePendingWrites` for framework-neutral pending/syncing/failed
21
+ counts without exposing any product UI policy.
22
+ - Added opt-in `pruneReadCache`, which deletes only valid read-cache envelopes
23
+ older than a host-supplied retention age. Pending writes are never pruned.
24
+
25
+ ## 0.2.0 - 2026-08-31
26
+
27
+ - Added forced-fresh, per-key single-flight `readThroughCache` loads so a
28
+ post-mutation read cannot reuse or restore an earlier cached response.
29
+ - Added generic write-or-queue and chronological, scoped batch replay helpers.
30
+ - Added local pending-store subscriptions and a framework-free browser recovery
31
+ controller with silent startup/focus/reconnect/new-write replay plus bounded
32
+ exponential retry.
33
+ - Documented the IndexedDB database/store-version topology constraint for host
34
+ applications that initialize more than one object store.
35
+
3
36
  ## 0.1.1 - 2026-08-10
4
37
 
5
38
  - Added unique pending-write snapshot identities and atomic conditional
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @bayonai/offline
2
2
 
3
- Reusable offline read-cache and pending-write primitives.
3
+ Reusable offline read-cache, pending-write, and silent browser-recovery
4
+ primitives.
4
5
 
5
6
  This package owns generic browser and memory storage behavior. Host apps own
6
7
  their domain records, remote APIs, cache prefixes, database names, and sync
@@ -23,6 +24,7 @@ import {
23
24
  createBrowserReadCache,
24
25
  createReadCacheKey,
25
26
  loadReadCacheResource,
27
+ pruneReadCache,
26
28
  } from "@bayonai/offline";
27
29
 
28
30
  const cache = createBrowserReadCache({
@@ -48,6 +50,21 @@ const projects = await loadReadCacheResource({
48
50
  });
49
51
  ```
50
52
 
53
+ After a successful write, use `readThroughCache({ forceFresh: true })` when
54
+ the next read must be server-confirmed rather than a pre-write cached or
55
+ in-flight response. Concurrent ordinary reads for the same cache/key share one
56
+ fresh load; a forced-fresh read starts a new load after invalidating the key.
57
+
58
+ Read-cache retention is deliberately opt-in. It removes only valid cache
59
+ envelopes older than the chosen lifetime; it never affects pending writes.
60
+
61
+ ```ts
62
+ const removed = await pruneReadCache({
63
+ cache,
64
+ maxAgeMs: 7 * 24 * 60 * 60_000,
65
+ });
66
+ ```
67
+
51
68
  ## Pending Writes
52
69
 
53
70
  ```ts
@@ -81,12 +98,85 @@ await syncPendingWrite({
81
98
  });
82
99
  ```
83
100
 
84
- Each call to `set` assigns a unique `snapshotId`. Sync status updates and
85
- successful cleanup compare that identity inside a single storage transaction,
86
- so a newer local write cannot be overwritten or deleted by an older sync.
87
- Records created before snapshot identities were introduced remain readable;
88
- they receive a stable compatibility identity and are upgraded when next
89
- updated.
101
+ For a browser application that wants durable, silent recovery, mount the
102
+ headless controller once for the active account or workspace:
103
+
104
+ ```ts
105
+ import {
106
+ createBrowserPendingWriteRecovery,
107
+ createBrowserPendingWriteRecoveryCoordination,
108
+ createOrderedPendingWriteExecutor,
109
+ summarizePendingWrites,
110
+ } from "@bayonai/offline";
111
+
112
+ const coordination = createBrowserPendingWriteRecoveryCoordination({
113
+ // Scope names must be stable per user/workspace and unique to this app.
114
+ channelName: `my-app:pending-recovery:${currentUser.id}`,
115
+ leaseName: `my-app:pending-recovery:${currentUser.id}`,
116
+ });
117
+
118
+ const writes = createOrderedPendingWriteExecutor({
119
+ scopeKey: currentUser.id,
120
+ select: (record) => record.payload.userId === currentUser.id,
121
+ store,
122
+ sync: (mutation) => replayMutation(mutation),
123
+ });
124
+
125
+ const recovery = createBrowserPendingWriteRecovery({
126
+ coordination,
127
+ recover: writes.recover,
128
+ store,
129
+ onError: (error) => console.error("Pending replay failed", { error }),
130
+ });
131
+
132
+ recovery.start(); // silent initial, focus, online, and local-set recovery
133
+
134
+ const result = await writes.run({
135
+ isOnline: () => navigator.onLine,
136
+ write: () => saveRemotely(),
137
+ shouldQueue: isRetryableNetworkError,
138
+ enqueue: () => store.set(createPendingMutation()),
139
+ });
140
+
141
+ // On unmount or account change:
142
+ recovery.stop();
143
+ coordination.close();
144
+ ```
145
+
146
+ The controller never creates product UI. It preserves failed writes locally,
147
+ retries remaining selected writes with bounded exponential backoff, and exposes
148
+ only `onResult`/`onError` callbacks for host diagnostics or telemetry. Stores
149
+ also expose a local-instance `subscribe` API; it is not a cross-tab delivery
150
+ mechanism.
151
+
152
+ When coordination is supplied, a queued write wakes peer tabs through
153
+ `BroadcastChannel`, and each recovery attempt acquires a non-blocking
154
+ `navigator.locks` lease when supported. Browsers without either API recover in
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.
165
+
166
+ Use generic record summaries when an app needs to render its own status:
167
+
168
+ ```ts
169
+ const summary = summarizePendingWrites(await store.list());
170
+ // { pendingCount, syncingCount, failedCount, totalCount }
171
+ ```
172
+
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.
90
180
 
91
181
  Host apps that change their pending-write payload shape can normalize current
92
182
  records while keeping domain logic outside the package:
@@ -115,6 +205,11 @@ Host apps are also responsible for:
115
205
  - Deciding when to clear browser storage, such as on sign-out.
116
206
  - Providing remote read and write functions.
117
207
 
208
+ Use a separate IndexedDB database for independently initialized stores unless
209
+ the host coordinates a database-version upgrade that creates every required
210
+ object store. IndexedDB cannot add a missing store when an existing database is
211
+ opened at the same version.
212
+
118
213
  ## Package Scripts
119
214
 
120
215
  ```bash
@@ -0,0 +1,23 @@
1
+ export type BrowserRecoveryMessageChannel = {
2
+ addEventListener: (type: "message", listener: () => void) => void;
3
+ close: () => void;
4
+ postMessage: (message: "pending-write-recovery") => void;
5
+ removeEventListener: (type: "message", listener: () => void) => void;
6
+ };
7
+ export type BrowserRecoveryLockManager = {
8
+ request: <T>(name: string, options: {
9
+ ifAvailable: true;
10
+ }, callback: (lock: unknown | null) => Promise<T | null>) => Promise<T | null>;
11
+ };
12
+ export type BrowserPendingWriteRecoveryCoordination = {
13
+ close: () => void;
14
+ notify: () => void;
15
+ runExclusive: <T>(attempt: () => Promise<T>) => Promise<T | null>;
16
+ subscribe: (listener: () => void) => () => void;
17
+ };
18
+ export declare function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel, locks, }: {
19
+ channel?: BrowserRecoveryMessageChannel | null;
20
+ channelName: string;
21
+ leaseName: string;
22
+ locks?: BrowserRecoveryLockManager | null;
23
+ }): BrowserPendingWriteRecoveryCoordination;
@@ -0,0 +1,57 @@
1
+ export function createBrowserPendingWriteRecoveryCoordination({ channelName, leaseName, channel = createDefaultChannel(channelName), locks = readDefaultLockManager(), }) {
2
+ const listeners = new Set();
3
+ const handleMessage = () => {
4
+ for (const listener of listeners) {
5
+ try {
6
+ listener();
7
+ }
8
+ catch (error) {
9
+ console.error("[Bayon offline] Cross-tab recovery listener failed.", {
10
+ channelName,
11
+ error,
12
+ });
13
+ }
14
+ }
15
+ };
16
+ channel?.addEventListener("message", handleMessage);
17
+ return {
18
+ close() {
19
+ channel?.removeEventListener("message", handleMessage);
20
+ channel?.close();
21
+ listeners.clear();
22
+ },
23
+ notify() {
24
+ try {
25
+ channel?.postMessage("pending-write-recovery");
26
+ }
27
+ catch (error) {
28
+ console.error("[Bayon offline] Unable to wake cross-tab recovery.", {
29
+ channelName,
30
+ error,
31
+ });
32
+ }
33
+ },
34
+ async runExclusive(attempt) {
35
+ if (!locks) {
36
+ return attempt();
37
+ }
38
+ return locks.request(leaseName, { ifAvailable: true }, (lock) => lock ? attempt() : Promise.resolve(null));
39
+ },
40
+ subscribe(listener) {
41
+ listeners.add(listener);
42
+ return () => listeners.delete(listener);
43
+ },
44
+ };
45
+ }
46
+ function createDefaultChannel(name) {
47
+ if (typeof BroadcastChannel === "undefined") {
48
+ return null;
49
+ }
50
+ return new BroadcastChannel(name);
51
+ }
52
+ function readDefaultLockManager() {
53
+ if (typeof navigator === "undefined") {
54
+ return null;
55
+ }
56
+ return navigator.locks ?? null;
57
+ }
@@ -0,0 +1,42 @@
1
+ import { type PendingWriteRecord, type PendingWriteStore, type PendingWriteSync, type SyncPendingWritesResult } from "./pendingWrites";
2
+ import type { BrowserPendingWriteRecoveryCoordination } from "./browserCoordination";
3
+ export declare const initialPendingWriteRetryDelayMs = 2000;
4
+ export declare const maxPendingWriteRetryDelayMs = 60000;
5
+ export type BrowserPendingWriteRecoveryEnvironment = {
6
+ addEventListener: (type: "focus" | "offline" | "online", listener: () => void) => void;
7
+ clearTimeout: (timer: ReturnType<typeof setTimeout>) => void;
8
+ isOnline: () => boolean;
9
+ removeEventListener: (type: "focus" | "offline" | "online", listener: () => void) => void;
10
+ setTimeout: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
11
+ };
12
+ export type BrowserPendingWriteRecovery<Payload> = {
13
+ /** Signals that a caller has persisted a record outside the store subscription. */
14
+ notifyQueued: () => void;
15
+ /** Runs one immediate, silent recovery attempt when the browser is online. */
16
+ recoverNow: () => Promise<SyncPendingWritesResult | null>;
17
+ start: () => void;
18
+ stop: () => void;
19
+ };
20
+ type BrowserPendingWriteRecoveryOptions<Payload> = {
21
+ /** Optional BroadcastChannel/Web Locks coordination supplied by the package factory. */
22
+ coordination?: BrowserPendingWriteRecoveryCoordination;
23
+ environment?: BrowserPendingWriteRecoveryEnvironment;
24
+ initialRetryDelayMs?: number;
25
+ maxRetryDelayMs?: number;
26
+ /** Diagnostic callback only. Recovery never renders or presents product UI. */
27
+ onError?: (error: unknown) => void;
28
+ /** Diagnostic callback only. Recovery never renders or presents product UI. */
29
+ onResult?: (result: SyncPendingWritesResult) => void;
30
+ scopeKey?: string;
31
+ select?: (record: PendingWriteRecord<Payload>) => boolean;
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;
39
+ sync: PendingWriteSync<Payload>;
40
+ });
41
+ export declare function createBrowserPendingWriteRecovery<Payload>(options: BrowserPendingWriteRecoveryOptions<Payload>): BrowserPendingWriteRecovery<Payload>;
42
+ export {};
@@ -0,0 +1,178 @@
1
+ import { syncPendingWrites, } from "./pendingWrites.js";
2
+ export const initialPendingWriteRetryDelayMs = 2_000;
3
+ export const maxPendingWriteRetryDelayMs = 60_000;
4
+ export function createBrowserPendingWriteRecovery(options) {
5
+ const { environment = createDefaultBrowserRecoveryEnvironment(), coordination, initialRetryDelayMs = initialPendingWriteRetryDelayMs, maxRetryDelayMs = maxPendingWriteRetryDelayMs, onError, onResult, scopeKey = "all", select = () => true, store, } = options;
6
+ let active = false;
7
+ let retryDelayMs = Math.max(0, initialRetryDelayMs);
8
+ let retryTimer;
9
+ let recoveryPromise;
10
+ let immediateRecoveryRequested = false;
11
+ let unsubscribePeerWakeups;
12
+ let unsubscribe;
13
+ function clearRetryTimer() {
14
+ if (retryTimer) {
15
+ environment.clearTimeout(retryTimer);
16
+ }
17
+ retryTimer = undefined;
18
+ }
19
+ function emitResult(result) {
20
+ try {
21
+ onResult?.(result);
22
+ }
23
+ catch (error) {
24
+ console.error("[Bayon offline] Pending-write recovery result listener failed.", {
25
+ error,
26
+ });
27
+ }
28
+ }
29
+ function emitError(error) {
30
+ console.error("[Bayon offline] Pending-write recovery failed.", {
31
+ error,
32
+ scopeKey,
33
+ });
34
+ try {
35
+ onError?.(error);
36
+ }
37
+ catch (callbackError) {
38
+ console.error("[Bayon offline] Pending-write recovery error listener failed.", {
39
+ callbackError,
40
+ scopeKey,
41
+ });
42
+ }
43
+ }
44
+ function scheduleRetry() {
45
+ if (!active || !environment.isOnline() || retryTimer) {
46
+ return;
47
+ }
48
+ retryTimer = environment.setTimeout(() => {
49
+ retryTimer = undefined;
50
+ void recoverNow();
51
+ }, retryDelayMs);
52
+ retryDelayMs = Math.min(Math.max(initialRetryDelayMs, retryDelayMs * 2), Math.max(initialRetryDelayMs, maxRetryDelayMs));
53
+ }
54
+ function recoverNow() {
55
+ if (!environment.isOnline()) {
56
+ clearRetryTimer();
57
+ return Promise.resolve(null);
58
+ }
59
+ if (recoveryPromise) {
60
+ return recoveryPromise;
61
+ }
62
+ const attempt = (async () => {
63
+ try {
64
+ const run = () => options.recover
65
+ ? options.recover()
66
+ : syncPendingWrites({
67
+ scopeKey,
68
+ select,
69
+ store,
70
+ sync: options.sync,
71
+ });
72
+ const result = coordination
73
+ ? await coordination.runExclusive(run)
74
+ : await run();
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();
79
+ return null;
80
+ }
81
+ emitResult(result);
82
+ if (result.remaining > 0) {
83
+ scheduleRetry();
84
+ }
85
+ else {
86
+ retryDelayMs = Math.max(0, initialRetryDelayMs);
87
+ }
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ emitError(error);
92
+ scheduleRetry();
93
+ return null;
94
+ }
95
+ })();
96
+ recoveryPromise = attempt.finally(() => {
97
+ recoveryPromise = undefined;
98
+ if (active && immediateRecoveryRequested) {
99
+ immediateRecoveryRequested = false;
100
+ triggerImmediateRecovery();
101
+ }
102
+ });
103
+ return recoveryPromise;
104
+ }
105
+ function triggerImmediateRecovery() {
106
+ if (!active) {
107
+ return;
108
+ }
109
+ immediateRecoveryRequested ||= Boolean(recoveryPromise);
110
+ clearRetryTimer();
111
+ retryDelayMs = Math.max(0, initialRetryDelayMs);
112
+ void recoverNow();
113
+ }
114
+ function handleOffline() {
115
+ clearRetryTimer();
116
+ }
117
+ function handleStoreChange(change) {
118
+ if (change.type === "set") {
119
+ coordination?.notify();
120
+ triggerImmediateRecovery();
121
+ }
122
+ }
123
+ return {
124
+ notifyQueued() {
125
+ coordination?.notify();
126
+ triggerImmediateRecovery();
127
+ },
128
+ recoverNow,
129
+ start() {
130
+ if (active) {
131
+ return;
132
+ }
133
+ active = true;
134
+ unsubscribe = store.subscribe(handleStoreChange);
135
+ unsubscribePeerWakeups = coordination?.subscribe(triggerImmediateRecovery);
136
+ environment.addEventListener("focus", triggerImmediateRecovery);
137
+ environment.addEventListener("online", triggerImmediateRecovery);
138
+ environment.addEventListener("offline", handleOffline);
139
+ if (environment.isOnline()) {
140
+ triggerImmediateRecovery();
141
+ }
142
+ },
143
+ stop() {
144
+ if (!active) {
145
+ return;
146
+ }
147
+ active = false;
148
+ clearRetryTimer();
149
+ unsubscribe?.();
150
+ unsubscribe = undefined;
151
+ unsubscribePeerWakeups?.();
152
+ unsubscribePeerWakeups = undefined;
153
+ environment.removeEventListener("focus", triggerImmediateRecovery);
154
+ environment.removeEventListener("online", triggerImmediateRecovery);
155
+ environment.removeEventListener("offline", handleOffline);
156
+ },
157
+ };
158
+ }
159
+ function createDefaultBrowserRecoveryEnvironment() {
160
+ const target = typeof window === "undefined" ? null : window;
161
+ return {
162
+ addEventListener(type, listener) {
163
+ target?.addEventListener(type, listener);
164
+ },
165
+ clearTimeout(timer) {
166
+ globalThis.clearTimeout(timer);
167
+ },
168
+ isOnline() {
169
+ return typeof navigator !== "undefined" && navigator.onLine;
170
+ },
171
+ removeEventListener(type, listener) {
172
+ target?.removeEventListener(type, listener);
173
+ },
174
+ setTimeout(callback, delayMs) {
175
+ return globalThis.setTimeout(callback, delayMs);
176
+ },
177
+ };
178
+ }
@@ -5,6 +5,10 @@ export type BrowserKeyValueDriver = {
5
5
  deleteIf: (key: string, predicate: KeyValuePredicate) => Promise<boolean>;
6
6
  get: (key: string) => Promise<unknown | null>;
7
7
  list: () => Promise<unknown[]>;
8
+ listEntries: () => Promise<Array<{
9
+ key: string;
10
+ value: unknown;
11
+ }>>;
8
12
  set: (key: string, value: unknown) => Promise<void>;
9
13
  setIf: (key: string, value: unknown, predicate: KeyValuePredicate) => Promise<boolean>;
10
14
  };
package/dist/idbDriver.js CHANGED
@@ -87,6 +87,30 @@ export function createIdbKeyValueDriver({ databaseName, databaseVersion = 1, ind
87
87
  timeoutMs: operationTimeoutMs,
88
88
  });
89
89
  },
90
+ async listEntries() {
91
+ return runBrowserStorageOperation({
92
+ fallback: [],
93
+ operationName: "list object-store entries",
94
+ operation: async () => {
95
+ const database = await databasePromise;
96
+ // Keys and values must come from one transaction. Separate database
97
+ // reads could pair a key from one cross-tab storage state with a
98
+ // value from another, causing retention code to delete the wrong key.
99
+ const transaction = database.transaction(storeName, "readonly");
100
+ const objectStore = transaction.objectStore(storeName);
101
+ const [keys, values] = await Promise.all([
102
+ objectStore.getAllKeys(),
103
+ objectStore.getAll(),
104
+ ]);
105
+ await transaction.done;
106
+ return keys.map((key, index) => ({
107
+ key: String(key),
108
+ value: values[index],
109
+ }));
110
+ },
111
+ timeoutMs: operationTimeoutMs,
112
+ });
113
+ },
90
114
  async set(key, value) {
91
115
  await runBrowserStorageOperation({
92
116
  fallback: undefined,
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from "./readCache";
2
2
  export * from "./pendingWrites";
3
+ export * from "./browserRecovery";
4
+ export * from "./browserCoordination";
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from "./readCache.js";
2
2
  export * from "./pendingWrites.js";
3
+ export * from "./browserRecovery.js";
4
+ export * from "./browserCoordination.js";
@@ -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"> & {
@@ -25,7 +28,21 @@ export type PendingWriteDriver = {
25
28
  setIf: (key: string, value: unknown, predicate: PendingWritePredicate) => Promise<boolean>;
26
29
  };
27
30
  export type PendingWritePredicate = (value: unknown | null) => boolean;
31
+ export type PendingWriteStoreChange<Payload = unknown> = {
32
+ record: PendingWriteRecord<Payload>;
33
+ type: "set";
34
+ } | {
35
+ id: string;
36
+ type: "clear";
37
+ } | {
38
+ type: "clearAll";
39
+ } | {
40
+ record: PendingWriteRecord<Payload>;
41
+ type: "replace";
42
+ };
43
+ export type PendingWriteStoreChangeListener<Payload = unknown> = (change: PendingWriteStoreChange<Payload>) => void;
28
44
  export type PendingWriteStore<Payload = unknown> = {
45
+ claimIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>, now: string) => Promise<boolean>;
29
46
  clear: (id: string) => Promise<void>;
30
47
  clearAll: () => Promise<void>;
31
48
  clearIfSnapshotId: (id: string, snapshotId: string) => Promise<boolean>;
@@ -34,6 +51,11 @@ export type PendingWriteStore<Payload = unknown> = {
34
51
  list: () => Promise<Array<PendingWriteRecord<Payload>>>;
35
52
  replaceIfSnapshotId: (id: string, snapshotId: string, record: PendingWriteRecord<Payload>) => Promise<boolean>;
36
53
  set: (record: PendingWriteRecordInput<Payload>) => Promise<PendingWriteRecord<Payload>>;
54
+ /**
55
+ * Observes changes made through this in-memory store instance. It deliberately
56
+ * does not claim cross-tab/process delivery.
57
+ */
58
+ subscribe: (listener: PendingWriteStoreChangeListener<Payload>) => () => void;
37
59
  };
38
60
  export type PendingWriteStoreOptions<Payload = unknown> = {
39
61
  keyPrefix: string;
@@ -60,14 +82,85 @@ export type SyncPendingWriteResult = {
60
82
  error: string;
61
83
  status: "failed";
62
84
  };
85
+ export type SyncPendingWritesResult = {
86
+ attempted: number;
87
+ failed: number;
88
+ idle: number;
89
+ /** Records still selected after this attempt, including newer superseding writes. */
90
+ remaining: number;
91
+ superseded: number;
92
+ synced: number;
93
+ };
94
+ export type PendingWriteSync<Payload> = (payload: Payload) => Promise<unknown>;
95
+ export type RunPendingWriteResult<TResult, TQueued> = {
96
+ result: TResult;
97
+ status: "synced";
98
+ } | {
99
+ queued: TQueued;
100
+ status: "queued";
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
+ };
119
+ export type PendingWriteSummary = {
120
+ failedCount: number;
121
+ pendingCount: number;
122
+ syncingCount: number;
123
+ totalCount: number;
124
+ };
63
125
  export declare function createPendingWriteStore<Payload = unknown>(driver: PendingWriteDriver, { keyPrefix, normalizeLegacyRecord, normalizePayload, schemaVersion, }: PendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
64
126
  export declare function createMemoryPendingWriteDriver(): PendingWriteDriver;
65
127
  export declare function createBrowserPendingWriteDriver({ databaseName, databaseVersion, indexedDB, operationTimeoutMs, storeName, }: Omit<BrowserPendingWriteStoreOptions, "keyPrefix" | "normalizePayload" | "schemaVersion">): PendingWriteDriver | null;
66
128
  export declare function createBrowserPendingWriteStore<Payload = unknown>({ databaseName, databaseVersion, indexedDB, keyPrefix, normalizeLegacyRecord, normalizePayload, operationTimeoutMs, schemaVersion, storeName, }: BrowserPendingWriteStoreOptions<Payload>): PendingWriteStore<Payload>;
67
129
  export declare function createPendingWriteKey({ id, prefix }: PendingWriteKeyOptions): string;
68
130
  export declare function createPendingWriteSnapshotId(): `${string}-${string}-${string}-${string}-${string}`;
69
- export declare function syncPendingWrite<Payload>({ id, store, sync, }: {
131
+ export declare function syncPendingWrite<Payload>({ id, now, store, sync, }: {
70
132
  id: string;
133
+ now?: () => string;
71
134
  store: PendingWriteStore<Payload>;
72
- sync: (payload: Payload) => Promise<unknown>;
135
+ sync: PendingWriteSync<Payload>;
73
136
  }): Promise<SyncPendingWriteResult>;
137
+ /**
138
+ * Replays selected writes oldest first and coalesces overlapping runs for the
139
+ * same store/scope. Callers own the selection predicate and idempotent remote
140
+ * mutation semantics.
141
+ */
142
+ export declare function syncPendingWrites<Payload>({ scopeKey, select, stopOnFailure, store, sync, }: {
143
+ scopeKey?: string;
144
+ select?: (record: PendingWriteRecord<Payload>) => boolean;
145
+ /** Preserve caller ordering by leaving later records for a future retry. */
146
+ stopOnFailure?: boolean;
147
+ store: PendingWriteStore<Payload>;
148
+ sync: PendingWriteSync<Payload>;
149
+ }): Promise<SyncPendingWritesResult>;
150
+ /**
151
+ * Attempts a remote write now and persists a caller-created durable fallback
152
+ * only when offline or when the supplied retry classifier accepts the error.
153
+ */
154
+ export declare function runPendingWrite<TResult, TQueued>({ enqueue, isOnline, shouldQueue, write, }: PendingWriteRunOptions<TResult, TQueued>): Promise<RunPendingWriteResult<TResult, TQueued>>;
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>;