@pylonsync/sync 0.3.291 → 0.3.293

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.
@@ -0,0 +1,186 @@
1
+ import type { ChangeEvent, Row } from "./types";
2
+ export declare class LocalStore {
3
+ private tables;
4
+ /**
5
+ * `(entity, row_id) → deletedAt seq`. A row in this map has been
6
+ * deleted; any insert/update event older than its tombstone seq is
7
+ * ignored so an out-of-order replay can't resurrect it. Without
8
+ * this, a delete followed by a reconnect-driven replay of the
9
+ * original insert would re-materialize the row.
10
+ *
11
+ * Real server-issued tombstones use the event's own seq. Optimistic
12
+ * client tombstones live in `optimisticTombstones` instead.
13
+ */
14
+ private tombstones;
15
+ /**
16
+ * Pending optimistic deletes — `(entity, row_id)` pairs the client
17
+ * has dropped but the server hasn't yet confirmed. Stored
18
+ * separately from `tombstones` so the real server-issued delete
19
+ * (with its real, smaller seq) can supersede the optimistic entry
20
+ * without being max-merged out.
21
+ *
22
+ * Invariant: a future legitimate re-create of the same id must
23
+ * succeed once the server's authoritative delete arrives. Test:
24
+ * `optimistic_delete_releases_id_when_server_confirms`.
25
+ */
26
+ private optimisticTombstones;
27
+ private listeners;
28
+ /** Get all rows for an entity. */
29
+ list(entity: string): Row[];
30
+ /** Get a row by ID. */
31
+ get(entity: string, id: string): Row | null;
32
+ /** Snapshot of every entity name with at least one local row. Used
33
+ * by `SyncEngine.reconcile` to know which tables to diff against
34
+ * server truth. */
35
+ entityNames(): string[];
36
+ /**
37
+ * Remove a row whose absence was confirmed by the server-truth
38
+ * reconciler. Records a tombstone at `tombstoneSeq` so a stale
39
+ * insert/update replayed afterwards (e.g. a slow WS frame) can't
40
+ * resurrect it. Callers pass the current sync cursor — any future
41
+ * change events have higher seqs and pass the tombstone check,
42
+ * while older replays are filtered.
43
+ *
44
+ * Differs from `optimisticDelete`: this is "the server says this
45
+ * row is gone right now"; that one is "the client wants this row
46
+ * gone before the server has confirmed."
47
+ */
48
+ reconcileRemove(entity: string, id: string, tombstoneSeq: number): boolean;
49
+ /**
50
+ * Per-subscriber row revocation: the server signaled that the
51
+ * current client lost read access to a specific row. ALWAYS
52
+ * records the tombstone (even when the row isn't in memory), so
53
+ * a stale insert/update event that arrives after the revocation
54
+ * can't resurrect the row.
55
+ *
56
+ * `reconcileRemove` returns early when the row is absent — that's
57
+ * the right behavior for "did anything change?" reconcile passes,
58
+ * but wrong here: a CRDT-only consumer using `useLoroDoc` without
59
+ * a JSON row never had the row in `tables` to begin with, and
60
+ * without the tombstone any future server-issued insert
61
+ * (legitimate re-grant or a slow stale frame) would land.
62
+ *
63
+ * Returns true if the row was present + removed; the tombstone
64
+ * is recorded regardless.
65
+ */
66
+ revokeRow(entity: string, id: string, tombstoneSeq: number): boolean;
67
+ private isTombstoned;
68
+ private recordTombstone;
69
+ /** Apply a change event to the local store. */
70
+ applyChange(change: ChangeEvent): void;
71
+ /** Apply multiple changes synchronously. Persistence runs fire-
72
+ * and-forget. Prefer `applyChangesAsync` when you plan to advance
73
+ * a cursor after — otherwise a crash can save the cursor before
74
+ * rows hit disk, causing permanent missed changes on restart. */
75
+ applyChanges(changes: ChangeEvent[]): void;
76
+ /**
77
+ * Apply + persist, awaiting disk writes before returning. Callers
78
+ * that are about to advance a cursor based on `changes` MUST use
79
+ * this path — otherwise cursor durability is broken: a crash
80
+ * between the memory apply and the eventual disk write can persist
81
+ * a cursor that's ahead of the replica, skipping those rows
82
+ * forever on restart.
83
+ *
84
+ * Returns `true` when every persist write reached disk durably,
85
+ * `false` when at least one degraded (quota / abort). The engine
86
+ * uses the result to hold the PERSISTED cursor back: a row that
87
+ * didn't reach disk must not be skipped by an advanced on-disk
88
+ * cursor on the next cold start. The in-memory replica always
89
+ * reflects the change regardless.
90
+ */
91
+ applyChangesAsync(changes: ChangeEvent[]): Promise<boolean>;
92
+ /**
93
+ * Reshape a change event so its `data` field matches the row as it
94
+ * now exists in memory after `applyChange` merged the patch.
95
+ * Persistence callers (IndexedDB) save the full row, which only
96
+ * works if they receive the full row. Deletes pass through
97
+ * untouched.
98
+ */
99
+ private hydrateFromMemory;
100
+ /** Persistence callback for auto-saving changes. Returns
101
+ * `Promise<boolean>` (true = durable, false = degraded) so
102
+ * `applyChangesAsync` can gate the on-disk cursor on durability.
103
+ * Void-returning callbacks are accepted for backwards compatibility
104
+ * (treated as durable / fire-and-forget). */
105
+ _persistFn: ((change: ChangeEvent) => void | Promise<boolean>) | null;
106
+ /** Subscribe to store changes. Returns unsubscribe function. */
107
+ subscribe(listener: () => void): () => void;
108
+ notify(): void;
109
+ /** Apply an optimistic insert. Returns a temporary id. */
110
+ optimisticInsert(entity: string, data: Row): string;
111
+ /**
112
+ * Apply an optimistic insert with a caller-provided id.
113
+ *
114
+ * Used by `useMutation({ optimistic })`: the React hook generates a
115
+ * Pylon-shaped id (40-char hex via `generateId()`), threads it
116
+ * through the mutation args as `_optimisticId`, and the server
117
+ * function honors it on `ctx.db.insert("Entity", { id, ... })`.
118
+ * Because the optimistic ghost and the canonical row share the
119
+ * same row_id, the WS broadcast that follows lands as a field-
120
+ * level merge on top of the optimistic — no delete-then-replace
121
+ * flash, no temp-row swap.
122
+ */
123
+ optimisticInsertWithId(entity: string, id: string, data: Row): void;
124
+ /**
125
+ * Roll back an optimistic insert without leaving a tombstone.
126
+ *
127
+ * Counterpart to `optimisticInsertWithId`. On rejection the ghost
128
+ * row must go away, but we MUST NOT leave a tombstone — a future
129
+ * legitimate insert with the same id (user retries, workflow
130
+ * eventually creates the row) must not be blocked.
131
+ * `optimisticDelete` records a MAX_SAFE_INTEGER tombstone, which is
132
+ * the wrong semantic here; this is a plain remove.
133
+ */
134
+ rollbackOptimisticInsert(entity: string, id: string): void;
135
+ /** Apply an optimistic update. */
136
+ optimisticUpdate(entity: string, id: string, data: Partial<Row>): void;
137
+ /**
138
+ * Undo a rejected optimistic update/delete by restoring the row to its
139
+ * captured pre-mutation value and clearing any optimistic tombstone
140
+ * for it. `failPushedMutation` calls this when the server rejects an
141
+ * update (restore the prior field values) or a delete (bring the row
142
+ * back AND un-fence it so the row — and any future server insert of
143
+ * the id — isn't blocked by the lingering optimistic tombstone).
144
+ *
145
+ * `prev === null` means the row didn't exist before the mutation
146
+ * (e.g. an update on a row that was itself an un-acked insert) — in
147
+ * that case we just remove it + clear the fence.
148
+ *
149
+ * A REAL (server-issued) tombstone wins over the restore: if an
150
+ * authoritative delete/revocation for this id landed on the applyQueue
151
+ * while the rejected push was in flight (the opQueue and applyQueue run
152
+ * independently), resurrecting `prev` here would briefly un-delete a row
153
+ * the server says is gone — healed only at the next reconcile. So when a
154
+ * server tombstone is present we drop the row and let the canonical
155
+ * state stand; the failed mutation's own optimistic fence is cleared
156
+ * regardless so a later legitimate re-create of the id isn't blocked.
157
+ */
158
+ restoreRow(entity: string, id: string, prev: Row | null): void;
159
+ /** Apply an optimistic delete. Block any incoming insert/update
160
+ * for this id until the server's authoritative delete arrives. */
161
+ optimisticDelete(entity: string, id: string): void;
162
+ /**
163
+ * Apply a reconcile result against the local replica. Reconcile is
164
+ * "the server says these are the canonical rows right now" — it does
165
+ * NOT carry per-event seqs. Distinct from `applyChange` (which
166
+ * threads server-issued change events through a monotonic seq
167
+ * guard + cursor advance).
168
+ *
169
+ * `tombstoneSeq` is the cursor at the start of the reconcile fetch.
170
+ * - Upserts skip rows whose tombstone is fresher (would be stale
171
+ * resurrections of a later delete).
172
+ * - Removals tombstone at `tombstoneSeq` so a later legitimate
173
+ * re-create (with a strictly greater seq) flows through.
174
+ *
175
+ * Cursor is NOT advanced — reconcile fabricates no real seqs, so
176
+ * the engine's cursor stays pinned to the change-log position the
177
+ * reconcile started from.
178
+ */
179
+ applyReconcileBatch(entity: string, upserts: Row[], removalIds: string[], tombstoneSeq: number): Promise<void>;
180
+ /**
181
+ * Drop every table + tombstone in-place, then notify. Used by the
182
+ * sync engine's `resetReplica()` on identity flip (token or tenant
183
+ * changed — the old replica reflects a different visible set).
184
+ */
185
+ clearAll(): void;
186
+ }
@@ -0,0 +1,141 @@
1
+ import type { PendingMutation } from "./mutation-queue";
2
+ import type { SubscriptionCoordinator } from "./subscription-coordinator";
3
+ import type { ChangeEvent, ReactiveMessage, ResolvedSession, Row, SyncCursor } from "./types";
4
+ /** Hooks the engine registers to receive inbound multi-tab events.
5
+ * Cases that purely concern subscriptions are NOT routed through
6
+ * these — the orchestrator dispatches them directly to its
7
+ * `SubscriptionCoordinator` reference. */
8
+ export interface MultiTabOrchestratorHooks {
9
+ /** Initial promotion (first election settles with this tab as
10
+ * leader). Fired before the engine's start() proceeds past
11
+ * `initMultiTab()`. */
12
+ onInitialLeader(): void;
13
+ /** Late promotion: the previous leader dropped while we were a
14
+ * follower. Engine performs recovery (replay subs, pull, drain
15
+ * the mutation queue, start the transport). */
16
+ onLatePromote(): void;
17
+ /** Demotion: another tab took over as leader. Engine tears down
18
+ * its transport. */
19
+ onDemote(): void;
20
+ /** Inbound applied-change batch from the current leader. Engine
21
+ * enqueues for apply with `fromBroadcast: true` so the queue
22
+ * doesn't re-broadcast. */
23
+ onAppliedReceived(changes: ChangeEvent[], targetCursor: SyncCursor | undefined): void;
24
+ /** Inbound reconcile batch from the current leader. */
25
+ onReconciledReceived(entity: string, upserts: Row[], removalIds: string[], tombstoneSeq: number): void;
26
+ /** Replica reset broadcast from the leader. `wipeMutations` is true
27
+ * when the reset was an identity flip (drop the outgoing identity's
28
+ * pending offline writes), false for a same-user 410 RESYNC (keep
29
+ * them — they survive the snapshot refresh). */
30
+ onResetReceived(wipeMutations: boolean): void;
31
+ /** Resolved session update from the leader. Engine funnels through
32
+ * its session chain so concurrent triggers commit in order. */
33
+ onSessionReceived(resolved: ResolvedSession): void;
34
+ /** Follower → leader: ops the follower wants pushed. Only fires
35
+ * when this tab is leader. */
36
+ onMutationsForwarded(ops: PendingMutation[]): void;
37
+ /** Leader → follower: op_ids that were successfully pushed. */
38
+ onMutationsAcked(opIds: string[]): void;
39
+ /** Leader → follower: op_ids that failed server-side validation. */
40
+ onMutationsFailed(ops: {
41
+ opId: string;
42
+ error: string;
43
+ }[]): void;
44
+ /** Leader → follower: a binary frame from the WS. Engine routes
45
+ * to its local binary handlers. */
46
+ onBinaryReceived(bytes: Uint8Array): void;
47
+ /** A peer tab disappeared (broker observed `bye`). Engine and
48
+ * SubscriptionCoordinator both clean up state for the departed tab. */
49
+ onPeerLeft(tabId: string): void;
50
+ /** Follower → leader: a follower wants to subscribe to a room.
51
+ * Leader-only. Engine increments the per-room forwarder set and
52
+ * sends `room-subscribe` to the server if no one else owned it. */
53
+ onRoomSubRegister?(roomId: string, fromTabId: string): void;
54
+ /** Follower → leader: a follower's last room subscriber unmounted.
55
+ * Leader-only. Engine decrements the forwarder set and sends
56
+ * `room-unsubscribe` when both the local refcount and the forwarder
57
+ * set are empty. */
58
+ onRoomSubUnregister?(roomId: string, fromTabId: string): void;
59
+ /** Leader → followers: a room-snapshot landed on the WS. Followers
60
+ * apply it to their local room registry so their subscribers fire. */
61
+ onRoomFanoutSnapshot?(roomId: string, members: unknown): void;
62
+ /** Leader → followers: a room-update landed. */
63
+ onRoomFanoutUpdate?(roomId: string, action: "join" | "leave" | "presence" | "broadcast", member: unknown, data: unknown): void;
64
+ /** Leader → followers: the server rejected a room-subscribe. */
65
+ onRoomFanoutError?(roomId: string, error: unknown): void;
66
+ /** New leader → followers: re-forward your locally-wanted room subs.
67
+ * Triggered alongside CRDT/reactive replay after a leader change. */
68
+ onReplayRoomSubs?(): void;
69
+ /** Follower → leader: a follower's `useQuery` observed an entity.
70
+ * Leader-only. The leader adds it to its reconcile sweep and fetches
71
+ * it so a server row the follower never cached is discovered and
72
+ * broadcast back as a `reconciled` batch. Without this, a follower's
73
+ * view on a never-cached entity stays empty forever. */
74
+ onEntityObserve?(entity: string, fromTabId: string): void;
75
+ /** New leader → followers: re-declare your observed entities so the
76
+ * new leader's reconcile sweep covers them. */
77
+ onReplayObservedEntities?(): void;
78
+ /** New leader → followers: re-forward your pending mutation batch. A
79
+ * mutation a follower forwarded to a leader that died before acking is
80
+ * otherwise stranded — the new leader only drains its OWN queue on
81
+ * promotion, and the other replay hooks cover subs/rooms/entities but
82
+ * not mutations. op_id makes the re-forward idempotent. */
83
+ onReplayForwardedMutations?(): void;
84
+ }
85
+ export interface MultiTabOrchestratorConfig {
86
+ /** When false, multi-tab coordination is disabled; this tab acts
87
+ * as a sole leader. */
88
+ enabled?: boolean;
89
+ /** App name used to derive the BroadcastChannel name. Defaults to
90
+ * "default" — apps that share an origin can pin their own name
91
+ * to avoid cross-talk between unrelated installations. */
92
+ appName?: string;
93
+ }
94
+ export declare class MultiTabOrchestrator {
95
+ private readonly config;
96
+ private readonly subscriptions;
97
+ private readonly hooks;
98
+ private broker;
99
+ private _isLeader;
100
+ private settled;
101
+ constructor(config: MultiTabOrchestratorConfig, subscriptions: SubscriptionCoordinator, hooks: MultiTabOrchestratorHooks);
102
+ /** Bring up the broker and run the initial election. Resolves when
103
+ * the election has settled (either onPromote fired, or the
104
+ * settle timer expired without a peer claiming leadership).
105
+ * Returns true if this tab is now the leader. */
106
+ init(): Promise<boolean>;
107
+ stop(): void;
108
+ isLeader(): boolean;
109
+ /** No-op when the broker isn't running. The engine uses this for
110
+ * envelope shapes the orchestrator doesn't have first-class
111
+ * helpers for (currently none — kept as an escape hatch). */
112
+ broadcastRaw(payload: unknown): void;
113
+ broadcastApplied(changes: ChangeEvent[], targetCursor?: SyncCursor): void;
114
+ broadcastReconciled(entity: string, upserts: Row[], removalIds: string[], tombstoneSeq: number): void;
115
+ broadcastReset(wipeMutations: boolean): void;
116
+ broadcastSession(resolved: ResolvedSession): void;
117
+ /** Follower → leader: forward our pending batch. The leader's
118
+ * engine handles it via `onMutationsForwarded`. */
119
+ forwardMutations(ops: PendingMutation[]): void;
120
+ /** Leader → followers: applied op_ids. Followers mark applied + clear. */
121
+ broadcastMutationsAcked(opIds: string[]): void;
122
+ /** Leader → followers: per-op failures with error strings. */
123
+ broadcastMutationsFailed(ops: {
124
+ opId: string;
125
+ error: string;
126
+ }[]): void;
127
+ /** Leader → followers: a reactive-result / reactive-error landed on
128
+ * the WS. Routed by sub_id to whatever tab owns the local handler. */
129
+ broadcastReactiveMessage(sub_id: string, payload: ReactiveMessage): void;
130
+ /** Leader → followers: a binary CRDT frame from the WS. Only
131
+ * meaningful when at least one follower has forwarded a CRDT
132
+ * sub — caller (engine) gates on `subscriptions.hasCrdtForwarders()`. */
133
+ broadcastBinary(bytes: Uint8Array): void;
134
+ /** New leader → followers: re-forward your active sub-registers. */
135
+ requestSubReplay(): void;
136
+ /** Public for tests + future debugging. Drives the same path the
137
+ * BroadcastChannel's onmessage hits, so a test can simulate any
138
+ * inbound envelope without standing up a real channel. The engine
139
+ * itself never calls this directly — it goes through the broker. */
140
+ handleMessage(payload: unknown, fromTabId: string): void;
141
+ }
@@ -0,0 +1,70 @@
1
+ interface TabIdentity {
2
+ tabId: string;
3
+ startTime: number;
4
+ }
5
+ export interface MultiTabHandlers {
6
+ /** Fired when this tab becomes the leader (initial election or
7
+ * promotion after the previous leader dropped). Idempotent —
8
+ * called once per promotion. */
9
+ onPromote: () => void;
10
+ /** Fired when this tab transitions from leader to follower. Only
11
+ * relevant if leader migration is supported (currently no-op
12
+ * because the leader stays leader until it dies). */
13
+ onDemote: () => void;
14
+ /** Fired for every application-level message from another tab. */
15
+ onAppMessage: (payload: unknown, from: TabIdentity) => void;
16
+ /** Fired when another tab leaves the coordination group gracefully
17
+ * (its `bye` was observed). The leader uses this to scrub forwarded
18
+ * subscription state for the departed tab so it stops fanning WS
19
+ * traffic at a dead peer. Does NOT fire for crashed tabs — those
20
+ * have no `bye` and currently leak in the roster until either a
21
+ * new election runs or this tab itself goes away. */
22
+ onLeave?: (tabId: string) => void;
23
+ }
24
+ export declare class MultiTabBroker {
25
+ readonly self: TabIdentity;
26
+ private channel;
27
+ /** Roster of currently-known tabs (including self). Maintained via
28
+ * `hello` / `here` / `bye`. The smallest entry by
29
+ * (startTime, tabId) is the leader. */
30
+ private roster;
31
+ private leaderTabId;
32
+ private lastLeaderHeartbeat;
33
+ private heartbeatTimer;
34
+ private monitorTimer;
35
+ private electionTimer;
36
+ private handlers;
37
+ private started;
38
+ private stopped;
39
+ /** Whether the broker can run on this platform — `BroadcastChannel`
40
+ * is missing in Node/jsdom and old Safari. When false the engine
41
+ * treats this tab as the implicit sole leader. */
42
+ static available(): boolean;
43
+ /** Begin participating in multi-tab coordination. The handlers
44
+ * fire as elections settle. Idempotent — calling twice is a no-op
45
+ * so the engine's `start()` can call freely. */
46
+ start(channelName: string, handlers: MultiTabHandlers): void;
47
+ /** Tear down. Sends a `bye` so other tabs can re-elect right away. */
48
+ stop(): void;
49
+ /** True if this tab is currently the leader. Follower paths use
50
+ * this to decide whether to run their own network ops. */
51
+ isLeader(): boolean;
52
+ /** Send an application-level payload to every other tab. The
53
+ * engine uses this for applied-change / mutation / session
54
+ * broadcasts. */
55
+ broadcastApp(payload: unknown): void;
56
+ private handleBeforeUnload;
57
+ private send;
58
+ private handle;
59
+ private scheduleElection;
60
+ private electLeader;
61
+ /** Smallest (startTime, tabId) wins. Deterministic across every
62
+ * tab that has the same roster, so peers converge without voting.
63
+ * Used both for the initial election and for sanity-checking
64
+ * `lead` claims against our local view. */
65
+ private computeWinner;
66
+ private promote;
67
+ private demote;
68
+ private checkLeaderLiveness;
69
+ }
70
+ export {};
@@ -0,0 +1,88 @@
1
+ import type { ClientChange, Row } from "./types";
2
+ export interface PendingMutation {
3
+ id: string;
4
+ change: ClientChange;
5
+ status: "pending" | "applied" | "failed";
6
+ error?: string;
7
+ /** Pre-mutation snapshot of the affected row, captured at optimistic-
8
+ * apply time for `update`/`delete`. On a server rejection,
9
+ * `failPushedMutation` restores this so the local replica reverts to
10
+ * the value the server actually holds. `null` = the row didn't exist
11
+ * before the mutation; `undefined` = not captured (inserts). */
12
+ prevRow?: Row | null;
13
+ }
14
+ /**
15
+ * Optional persistence backend. The default IndexedDB persistence
16
+ * layer provides `savePending`/`loadPending`. Callers can supply a
17
+ * custom backend for tests or alternative storage.
18
+ */
19
+ export interface MutationQueuePersistence {
20
+ saveAll(mutations: PendingMutation[]): Promise<void>;
21
+ loadAll(): Promise<PendingMutation[]>;
22
+ }
23
+ export declare class MutationQueue {
24
+ private queue;
25
+ private persistence?;
26
+ constructor(persistence?: MutationQueuePersistence);
27
+ /**
28
+ * Attach a persistence backend after construction. The SyncEngine
29
+ * swaps in IndexedDB-backed persistence once the DB has opened.
30
+ * Public so it doesn't need a `// @ts-expect-error` to reach in
31
+ * from the same package.
32
+ */
33
+ attachPersistence(persistence: MutationQueuePersistence): void;
34
+ /** Load persisted queue state. Call once at startup. */
35
+ hydrate(): Promise<void>;
36
+ /** Add a pending mutation. Returns the op_id used for server
37
+ * idempotency. If the change ALREADY carries an op_id (e.g., it
38
+ * was forwarded from another tab via multi-tab broadcast), reuse
39
+ * that id so the leader and follower agree on the identifier the
40
+ * server will dedupe against. Likewise we skip the add when an
41
+ * entry with the same op_id is already queued — a follower
42
+ * retrying its forward of the same op shouldn't double-queue on
43
+ * the leader. */
44
+ add(change: ClientChange, prevRow?: Row | null): string;
45
+ pending(): PendingMutation[];
46
+ /** Look up a queued mutation by op id (any status). Used by the
47
+ * follower's mutations-failed handler to reach the captured
48
+ * `prevRow` for rollback. */
49
+ get(id: string): PendingMutation | undefined;
50
+ /**
51
+ * Set of `${entity}/${row_id}` keys for every mutation currently
52
+ * in Pending or Failed state. Used by reconcile() to skip rows
53
+ * whose canonical state on the server hasn't caught up with the
54
+ * local optimistic ghost yet — otherwise reconcile would tombstone
55
+ * the row (it's not yet on the server) and the still-pending push
56
+ * would later re-apply against the tombstone, fighting the local
57
+ * replica.
58
+ *
59
+ * Failed mutations are included too: a user-visible failure is
60
+ * recoverable, and sweeping the row would discard the local edit
61
+ * the user is still trying to push.
62
+ */
63
+ pendingRowKeys(): Set<string>;
64
+ markApplied(id: string): void;
65
+ markFailed(id: string, error: string): void;
66
+ /**
67
+ * Prune applied mutations. Failed mutations are KEPT so the UI can
68
+ * surface them to the user and so retries are possible.
69
+ */
70
+ clear(): void;
71
+ /** Remove a specific mutation by id. Used by the UI after user
72
+ * ack of failures. */
73
+ remove(id: string): void;
74
+ /**
75
+ * Drop EVERY mutation — pending, failed, and applied — then flush the
76
+ * empty queue to disk. Used by the engine's identity-flip reset
77
+ * (`resetReplica({ wipeMutations: true })`): the queued offline writes
78
+ * belong to the OUTGOING identity, and replaying them under the new
79
+ * session would attribute one user's writes to another (or get
80
+ * policy-rejected on the server). Distinct from `clear()`, which only
81
+ * prunes already-applied entries and deliberately PRESERVES pending +
82
+ * failed writes — the 410-RESYNC same-user path relies on that so
83
+ * offline writes survive a snapshot refresh.
84
+ */
85
+ clearAll(): void;
86
+ /** Fire-and-forget persistence write. */
87
+ private flush;
88
+ }
@@ -0,0 +1,18 @@
1
+ export declare class OpQueue {
2
+ private chain;
3
+ private pending;
4
+ /** Monotonic op counter — incremented as each op begins running.
5
+ * Lets a caller stash a snapshot ("epoch when I scheduled") and
6
+ * detect after `await` whether another op ran in between. */
7
+ private epoch;
8
+ /** Schedule an op behind any currently running/queued ops.
9
+ *
10
+ * Coalescing: if `key` matches a pending op, the existing promise
11
+ * is returned — the new caller does NOT re-enqueue. This preserves
12
+ * the "many concurrent callers share one in-flight op" semantics
13
+ * that pull/push/reconcile had via their respective mutexes. */
14
+ enqueue<T>(key: string, fn: () => Promise<T>): Promise<T>;
15
+ /** Current epoch — read before scheduling, compare after `await` to
16
+ * detect "another op ran while I was waiting." */
17
+ currentEpoch(): number;
18
+ }
@@ -0,0 +1,114 @@
1
+ import type { Row, ChangeEvent, SyncCursor, MutationQueuePersistence, PendingMutation } from "./index";
2
+ /**
3
+ * IndexedDB-backed persistence for the sync store.
4
+ * Saves entity rows and sync cursor so data survives page refresh.
5
+ */
6
+ export declare class IndexedDBPersistence {
7
+ private db;
8
+ private dbName;
9
+ /** Shared connection. Exposed so sibling persistence classes (e.g. the
10
+ * mutation-queue backend) can reuse the same IDBDatabase — IndexedDB only
11
+ * permits one open handle per (origin, db) at a time while upgrades run.
12
+ */
13
+ get connection(): IDBDatabase | null;
14
+ constructor(appName?: string);
15
+ open(): Promise<void>;
16
+ /** Resolve when a write transaction settles, reporting durability:
17
+ * `true` on COMMIT, `false` if it aborts/errors — e.g.
18
+ * QuotaExceededError on a storage-pressured device. NEVER rejects or
19
+ * hangs: registering only `oncomplete` (the original shape) meant an
20
+ * aborted tx never settled, and the engine awaits the persist before
21
+ * advancing the cursor in `enqueueApply` — so ONE hung write wedged
22
+ * the whole applyQueue and live sync silently died for the session.
23
+ *
24
+ * But "resolve unconditionally" (the first fix) was also wrong: on an
25
+ * abort the row never reached disk, yet `enqueueApply` still advanced
26
+ * the PERSISTED cursor past it — so on restart the warm-load skipped
27
+ * those rows forever (cursor ahead of replica). The boolean lets the
28
+ * caller hold the on-disk cursor back when a row write fails, so the
29
+ * next cold start re-pulls the gap. Best-effort: log once, keep the
30
+ * in-memory replica authoritative, but never persist a cursor ahead of
31
+ * what's actually durable. Mirrors the read paths (`getRow`/
32
+ * `loadCursor`) which already degrade via `onerror`. */
33
+ private commit;
34
+ /** Save a row to IndexedDB. Resolves `true` when the write is durable,
35
+ * `false` if it degraded (no DB / aborted) — see `commit`. */
36
+ saveRow(entity: string, id: string, data: Row): Promise<boolean>;
37
+ /** Fetch a row from IndexedDB by key. Used by `persistChange` on update
38
+ * events to merge the patch against what's already on disk. */
39
+ getRow(entity: string, id: string): Promise<Row | null>;
40
+ /** Delete a row from IndexedDB. Resolves `true` when durable, `false`
41
+ * if it degraded — see `commit`. */
42
+ deleteRow(entity: string, id: string): Promise<boolean>;
43
+ /** Load all rows for an entity from IndexedDB. */
44
+ loadAll(entity: string): Promise<Row[]>;
45
+ /** Load all entities and their rows from IndexedDB. */
46
+ loadAllEntities(): Promise<Record<string, Row[]>>;
47
+ /**
48
+ * Atomic warm-load: returns entities + cursor in a single IDB
49
+ * read transaction. Used by `SyncEngine.start()` to hydrate the
50
+ * in-memory replica BEFORE the network pull resolves so React
51
+ * hooks see real data on first render (no empty-then-populated
52
+ * flash on returning visits).
53
+ *
54
+ * `hadCache` is true when at least one row OR a saved cursor
55
+ * was found. The engine uses it to distinguish "true cold start
56
+ * — pull-from-0 IS a full snapshot, skip the post-snapshot
57
+ * reconcile" from "returning user with cached state — pull-from-
58
+ * cursor may miss server-side deletes that happened offline, the
59
+ * onConnected reconcile MUST run". Without that distinction, a
60
+ * returning user whose cursor somehow rolled back to 0 (rare:
61
+ * IDB partial corruption, cleared-by-mistake) would end up with
62
+ * ghost rows that survive forever.
63
+ *
64
+ * Single readonly tx is intentional — two separate reads could
65
+ * race a mid-load saveCursor/saveRow from another tab's apply
66
+ * pipeline and read an inconsistent (cursor C', rows for cursor C)
67
+ * pair. One tx guarantees a consistent snapshot.
68
+ */
69
+ loadSnapshot(): Promise<{
70
+ entities: Record<string, Row[]>;
71
+ cursor: SyncCursor | null;
72
+ hadCache: boolean;
73
+ }>;
74
+ /** Save the sync cursor. Resolves `true` when durable, `false` if it
75
+ * degraded — see `commit`. */
76
+ saveCursor(cursor: SyncCursor): Promise<boolean>;
77
+ /** Load the sync cursor. */
78
+ loadCursor(): Promise<SyncCursor | null>;
79
+ /** Clear stored rows + cursor. Deliberately does NOT touch the
80
+ * durable mutation queue: `resetReplica` calls this on a 410 RESYNC
81
+ * (same user, needs a fresh snapshot) where pending offline writes
82
+ * MUST survive. On an IDENTITY flip the old identity's pending
83
+ * mutations must instead be discarded — that wipe runs through
84
+ * `MutationQueue.clearAll()` (which persists an empty `MUTATIONS_STORE`
85
+ * via its own backend), gated on `resetReplica({ wipeMutations })` at
86
+ * the token/tenant-flip call sites. Splitting the two stores keeps each
87
+ * reset path honest: 410 keeps writes, identity flip drops them. */
88
+ clear(): Promise<boolean>;
89
+ }
90
+ /**
91
+ * Apply a change event to IndexedDB persistence. Returns `true` when the
92
+ * write reached disk durably, `false` when it degraded (so `enqueueApply`
93
+ * can hold the persisted cursor back rather than skip the row on restart).
94
+ * A change with no `data` is a no-op and counts as durable.
95
+ */
96
+ export declare function persistChange(persistence: IndexedDBPersistence, change: ChangeEvent): Promise<boolean>;
97
+ /**
98
+ * IndexedDB-backed implementation of `MutationQueuePersistence`. Wires the
99
+ * `MutationQueue` into the same database as the entity mirror so everything
100
+ * the app needs to resume a session lives in one place.
101
+ *
102
+ * `saveAll` writes the entire queue on every change. That's O(n) per write,
103
+ * but `n` is bounded by "how many mutations the user queued while offline",
104
+ * which is tiny in practice. If that ever becomes a bottleneck, switch to
105
+ * per-id `put`/`delete` — the schema (`keyPath: "id"`) already supports it.
106
+ */
107
+ export declare class IndexedDBMutationPersistence implements MutationQueuePersistence {
108
+ private readonly parent;
109
+ private db;
110
+ constructor(parent: IndexedDBPersistence);
111
+ private handle;
112
+ saveAll(mutations: PendingMutation[]): Promise<void>;
113
+ loadAll(): Promise<PendingMutation[]>;
114
+ }