@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.
- package/dist/ids.d.ts +14 -0
- package/dist/index.d.ts +949 -0
- package/dist/local-store.d.ts +186 -0
- package/dist/multi-tab-orchestrator.d.ts +141 -0
- package/dist/multi-tab.d.ts +70 -0
- package/dist/mutation-queue.d.ts +88 -0
- package/dist/op-queue.d.ts +18 -0
- package/dist/persistence.d.ts +114 -0
- package/dist/room-subscriptions.d.ts +113 -0
- package/dist/server-subscriptions.d.ts +26 -0
- package/dist/session-resolver.d.ts +68 -0
- package/dist/storage.d.ts +30 -0
- package/dist/subscription-coordinator.d.ts +99 -0
- package/dist/test-harness/env.d.ts +56 -0
- package/dist/test-harness/index.d.ts +5 -0
- package/dist/test-harness/server.d.ts +178 -0
- package/dist/test-harness/transport.d.ts +19 -0
- package/dist/transport.d.ts +89 -0
- package/dist/transports/index.d.ts +19 -0
- package/dist/transports/polling.d.ts +15 -0
- package/dist/transports/reconnect.d.ts +20 -0
- package/dist/transports/sse.d.ts +22 -0
- package/dist/transports/types.d.ts +97 -0
- package/dist/transports/websocket.d.ts +21 -0
- package/dist/types.d.ts +124 -0
- package/package.json +11 -5
- package/tsconfig.json +0 -7
|
@@ -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
|
+
}
|