@pylonsync/sync 0.3.292 → 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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
import { LocalStore } from "./local-store";
|
|
2
|
+
import { MutationQueue } from "./mutation-queue";
|
|
3
|
+
import { type RoomError, type RoomMember, type RoomSubscriber } from "./room-subscriptions";
|
|
4
|
+
import { SessionResolver } from "./session-resolver";
|
|
5
|
+
export { RoomSubscriptions, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
|
|
6
|
+
export { IndexedDBPersistence, persistChange } from "./persistence";
|
|
7
|
+
export { buildRequest, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
|
|
8
|
+
export type { PylonRequestInit, TransportConfig } from "./transport";
|
|
9
|
+
export { LocalStore } from "./local-store";
|
|
10
|
+
export { MutationQueue, type MutationQueuePersistence, type PendingMutation, } from "./mutation-queue";
|
|
11
|
+
export { generateId } from "./ids";
|
|
12
|
+
export type { ChangeEvent, ClientChange, PullResponse, PushOpResult, PushResponse, ReactiveMessage, ReactiveSpec, ResolvedSession, Row, SyncConnectionStatus, SyncCursor, TransportType, } from "./types";
|
|
13
|
+
export { defaultStorage, createWriteThroughStorage, type Storage, } from "./storage";
|
|
14
|
+
import type { ReactiveMessage, ResolvedSession, Row, SyncConnectionStatus, SyncCursor, TransportType } from "./types";
|
|
15
|
+
export interface SyncEngineConfig {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
/** Transport type. Default: "websocket". Falls back to polling if connection fails. */
|
|
18
|
+
transport?: TransportType;
|
|
19
|
+
/** WebSocket URL. Default: derived from baseUrl (ws://). */
|
|
20
|
+
wsUrl?: string;
|
|
21
|
+
/** Poll interval in ms (only used when transport is "poll"). Default 1000. */
|
|
22
|
+
pollInterval?: number;
|
|
23
|
+
/** Reconnect delay in ms. Default 1000. */
|
|
24
|
+
reconnectDelay?: number;
|
|
25
|
+
/** Auth token for requests. */
|
|
26
|
+
token?: string;
|
|
27
|
+
/** Enable IndexedDB persistence. Data survives page refresh. Default: true in browser. */
|
|
28
|
+
persist?: boolean;
|
|
29
|
+
/** App name for IndexedDB database naming. Default: "default". */
|
|
30
|
+
appName?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Sync key-value adapter for hot-path state (auth token, client_id).
|
|
33
|
+
* Default: localStorage on the web, in-memory no-op elsewhere. Non-browser
|
|
34
|
+
* hosts (RN, Tauri, Workers) inject an adapter to persist these values.
|
|
35
|
+
*/
|
|
36
|
+
storage?: import("./storage").Storage;
|
|
37
|
+
/**
|
|
38
|
+
* Debounce window (ms) between `reconcile()` calls. Reconcile triggers
|
|
39
|
+
* fire on connect, WS reconnect, and visibility-change; the debounce
|
|
40
|
+
* prevents the back-to-back triggers from re-fetching every entity
|
|
41
|
+
* twice within seconds. Default 2000ms.
|
|
42
|
+
*/
|
|
43
|
+
reconcileMinIntervalMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Opt out of the automatic visibility-change reconcile. The reconcile
|
|
46
|
+
* pass runs on connect/reconnect regardless; this only disables the
|
|
47
|
+
* tab-refocus trigger. Default: enabled (reconcile fires when the tab
|
|
48
|
+
* becomes visible).
|
|
49
|
+
*/
|
|
50
|
+
reconcileOnVisibility?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Client → server keepalive interval (ms). The Pylon dev server's
|
|
53
|
+
* dedicated :port+1 WS listener uses a dual-thread design where the
|
|
54
|
+
* writer thread wakes on every broadcast — pings are pure liveness,
|
|
55
|
+
* 25_000 (25s) is the right default. The HTTP-multiplexed
|
|
56
|
+
* `/api/sync/ws` fallback path uses a single-thread design where
|
|
57
|
+
* the reader's mutex unlocks only between reads; on THAT path,
|
|
58
|
+
* broadcast latency is bounded by this interval, so set it lower
|
|
59
|
+
* (e.g. 200) to trade traffic for delivery latency. Most production
|
|
60
|
+
* deployments should leave this at the default and configure their
|
|
61
|
+
* edge proxy to forward to the dual-thread listener instead.
|
|
62
|
+
*/
|
|
63
|
+
pingIntervalMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Multi-tab coordination via BroadcastChannel. When multiple tabs of
|
|
66
|
+
* the same origin run the engine, one is elected leader and owns
|
|
67
|
+
* the WebSocket, pull/push/reconcile, and IndexedDB writes;
|
|
68
|
+
* follower tabs mirror state via cross-tab broadcasts. Default
|
|
69
|
+
* `true` in browsers. Set `false` to force every tab to behave as
|
|
70
|
+
* its own leader (the pre-multi-tab semantics).
|
|
71
|
+
*/
|
|
72
|
+
multiTab?: boolean;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Generate a stable client_id. Prefers a persisted id from `storage`
|
|
76
|
+
* (so a reload keeps the same identifier) and falls back to a fresh UUID.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* Generate a Pylon-shaped row id (40-char lowercase hex).
|
|
80
|
+
*
|
|
81
|
+
* Mirrors the runtime's `generate_id` shape: 32 hex of milliseconds
|
|
82
|
+
* since epoch (extended to nanos so it lex-sorts alongside server-
|
|
83
|
+
* generated ids) + 8 hex of a per-tab counter. Lex-sortable, monotonic
|
|
84
|
+
* within a tab, statistically unique across tabs (the timestamp
|
|
85
|
+
* disambiguates almost every cross-tab collision; the counter handles
|
|
86
|
+
* the rest within a single tick).
|
|
87
|
+
*
|
|
88
|
+
* Used by `useMutation({ optimistic })` to mint client-side ids that
|
|
89
|
+
* the runtime will accept verbatim — so the optimistic ghost and the
|
|
90
|
+
* canonical row share the same `row_id` and the WS broadcast is an
|
|
91
|
+
* idempotent merge instead of a delete-then-replace flash.
|
|
92
|
+
*
|
|
93
|
+
* Apps can call this directly when they need a stable id earlier than
|
|
94
|
+
* the mutation (e.g. to reference the row from another optimistic
|
|
95
|
+
* insert in the same gesture).
|
|
96
|
+
*/
|
|
97
|
+
export declare class SyncEngine {
|
|
98
|
+
private config;
|
|
99
|
+
private cursor;
|
|
100
|
+
private running;
|
|
101
|
+
/** Real-time transport — owns its own socket / timers / backoff.
|
|
102
|
+
* The engine just calls start/stop/send and consumes inbound events
|
|
103
|
+
* via the TransportHost callbacks (set up below in `transportHost`).
|
|
104
|
+
* Constructed in start() because followers don't open a transport
|
|
105
|
+
* at all and SSR-only consumers never reach start(). */
|
|
106
|
+
private transport;
|
|
107
|
+
private _connectionStatus;
|
|
108
|
+
private persistence;
|
|
109
|
+
/**
|
|
110
|
+
* Flips true once `start()` has either:
|
|
111
|
+
* - drained IndexedDB into the in-memory store (data path), OR
|
|
112
|
+
* - decided the engine has no persistence layer (test / SSR / explicit opt-out).
|
|
113
|
+
*
|
|
114
|
+
* `useQuery`'s `loading` flag consumes this so apps don't render a
|
|
115
|
+
* "Loading…" flash on every page refresh when the disk replica
|
|
116
|
+
* already has the data. Without it, even returning visits show the
|
|
117
|
+
* spinner for the 50–200ms gap between component mount and IndexedDB
|
|
118
|
+
* `loadAllEntities()` resolving — visually identical to a true
|
|
119
|
+
* cold-start fetch.
|
|
120
|
+
*/
|
|
121
|
+
private _hydrated;
|
|
122
|
+
isHydrated(): boolean;
|
|
123
|
+
/**
|
|
124
|
+
* True once the engine has a *server-confirmed* initial view: the first
|
|
125
|
+
* pull (snapshot) settled, OR the IndexedDB cache already held rows, OR a
|
|
126
|
+
* fallback deadline elapsed so we never pin forever. This is what
|
|
127
|
+
* `useQuery`'s `loading` waits on — NOT `isHydrated()`. The distinction
|
|
128
|
+
* matters: `isHydrated()` flips true the instant the local cache loads,
|
|
129
|
+
* which on a cold/empty cache (first visit, or right after an org switch
|
|
130
|
+
* wipes the replica) is immediate and EMPTY — so a `loading` gated on it
|
|
131
|
+
* drops to false while the projects/rows are still en route from the
|
|
132
|
+
* server, and the UI flashes its empty state for the ~seconds until the
|
|
133
|
+
* pull lands. Gating on this signal keeps `loading` true through that
|
|
134
|
+
* window so callers can show a skeleton instead.
|
|
135
|
+
*/
|
|
136
|
+
private _initialSyncSettled;
|
|
137
|
+
isInitialSyncSettled(): boolean;
|
|
138
|
+
private _initialSyncFallback;
|
|
139
|
+
/** Flip `_initialSyncSettled` true (idempotent) + notify so `useQuery`
|
|
140
|
+
* re-reads and drops its loading state. */
|
|
141
|
+
private markInitialSyncSettled;
|
|
142
|
+
/** Safety net so `loading` never pins: settle after a deadline even if no
|
|
143
|
+
* pull lands (offline, or a multi-tab follower of an empty entity that
|
|
144
|
+
* never receives a broadcast). The real pull settles it far sooner in the
|
|
145
|
+
* normal case. Re-armable — a replica wipe (org switch / token flip) resets
|
|
146
|
+
* the signal and re-arms this. */
|
|
147
|
+
private armInitialSyncFallback;
|
|
148
|
+
/**
|
|
149
|
+
* True when the engine drained at least one row OR a saved cursor
|
|
150
|
+
* out of IndexedDB during `start()`. Distinguishes a returning user
|
|
151
|
+
* (cached replica may contain rows the server has since deleted) from
|
|
152
|
+
* a true first-time user (cache empty, pull-from-0 IS canonical
|
|
153
|
+
* truth).
|
|
154
|
+
*
|
|
155
|
+
* Used by the WS `onConnected` fast-path: `lastPullStartedFromZero`
|
|
156
|
+
* only fires the reconcile-skip when this flag is ALSO false. A
|
|
157
|
+
* returning user whose IDB cursor somehow rolled back to 0 (rare:
|
|
158
|
+
* partial wipe, corrupt write) must still get the reconcile pass —
|
|
159
|
+
* otherwise rows deleted on the server while the tab was closed
|
|
160
|
+
* survive forever.
|
|
161
|
+
*
|
|
162
|
+
* Read-only after start() observes the IDB load.
|
|
163
|
+
*/
|
|
164
|
+
private _hadCachedReplica;
|
|
165
|
+
/**
|
|
166
|
+
* Sticky flag: a persisted row/cursor write degraded (IDB quota /
|
|
167
|
+
* abort), so the on-disk replica is known to be behind the in-memory
|
|
168
|
+
* cursor. Once set, `enqueueApply` STOPS advancing the persisted
|
|
169
|
+
* cursor — persisting a cursor ahead of the durable rows would make
|
|
170
|
+
* the next cold start skip them forever (cursor-ahead-of-replica). The
|
|
171
|
+
* in-memory replica stays authoritative for the live session; on
|
|
172
|
+
* restart the lagging on-disk cursor simply re-pulls the gap. Resets to
|
|
173
|
+
* false only on `resetReplicaInner` (full wipe + resync, disk is clean
|
|
174
|
+
* again). A storage-pressured tab thus degrades to "re-pull on restart"
|
|
175
|
+
* — like a memory-only client — instead of silently losing rows.
|
|
176
|
+
*/
|
|
177
|
+
private persistDegraded;
|
|
178
|
+
readonly store: LocalStore;
|
|
179
|
+
readonly mutations: MutationQueue;
|
|
180
|
+
/**
|
|
181
|
+
* Stable per-client identifier. Minted on first construction, not
|
|
182
|
+
* necessarily persisted (depends on what the host provides).
|
|
183
|
+
* Included on every PushRequest so the server can correlate retries and
|
|
184
|
+
* track per-client diagnostics. Not auth — do not trust this to identify
|
|
185
|
+
* a user.
|
|
186
|
+
*/
|
|
187
|
+
readonly clientId: string;
|
|
188
|
+
/** Presence state for this client. */
|
|
189
|
+
private presenceData;
|
|
190
|
+
/**
|
|
191
|
+
* Token observed on the last pull. When the token changes (anonymous →
|
|
192
|
+
* signed in, or user A → user B), the set of rows the server will expose
|
|
193
|
+
* changes — so the cursor from the previous identity is meaningless.
|
|
194
|
+
* Compared on every pull; a mismatch triggers an automatic resync.
|
|
195
|
+
*
|
|
196
|
+
* Owns the resolved session, the last-seen token, the last-seen
|
|
197
|
+
* tenant, and the null→X / X→Y / token-flip verdicts that used to
|
|
198
|
+
* be inlined across pull / refresh / reconcile. The engine acts on
|
|
199
|
+
* the verdicts (reset, pull, notify); the resolver decides nothing
|
|
200
|
+
* on its own. See session-resolver.ts.
|
|
201
|
+
*
|
|
202
|
+
* Exposed (read-only) so tests and plugins can inspect or simulate
|
|
203
|
+
* identity transitions without re-implementing the comparison.
|
|
204
|
+
*/
|
|
205
|
+
readonly session: SessionResolver;
|
|
206
|
+
/**
|
|
207
|
+
* Multi-tab orchestrator — owns the cross-tab protocol: broker
|
|
208
|
+
* lifecycle, election, inbound message dispatch, outbound broadcasts.
|
|
209
|
+
* Engine receives inbound events via hooks (see `multiTabHooks()`).
|
|
210
|
+
*
|
|
211
|
+
* Constructed lazily in `start()` because SSR-only consumers that
|
|
212
|
+
* never reach start() shouldn't pay for the broker. */
|
|
213
|
+
private orchestrator;
|
|
214
|
+
/** Mirror of `orchestrator.isLeader()` kept on the engine for the
|
|
215
|
+
* many `if (this.isMultiTabLeader)` gates throughout the codebase.
|
|
216
|
+
* Updated via the orchestrator's onInitialLeader / onLatePromote /
|
|
217
|
+
* onDemote hooks. Defaults to false so a tab joining an existing
|
|
218
|
+
* election stays a passive follower until the orchestrator
|
|
219
|
+
* explicitly promotes us — a `true` default would let a late
|
|
220
|
+
* joiner whose orchestrator never fires onPromote (because it was
|
|
221
|
+
* never leader) silently run as a leader. */
|
|
222
|
+
private isMultiTabLeader;
|
|
223
|
+
/**
|
|
224
|
+
* Serialized apply queue. Every change-event apply — from WS onmessage,
|
|
225
|
+
* pull(), or session-changed catchup — chains onto this promise so
|
|
226
|
+
* applies execute in arrival order. Without this, two WS messages or
|
|
227
|
+
* two concurrent pull()s race: seq 3's persistence can land before
|
|
228
|
+
* seq 2's, leaving the row at the older value AND the cursor briefly
|
|
229
|
+
* regressing if writes complete out of order. The queue also gates
|
|
230
|
+
* the cursor advance so `last_seq` only moves forward.
|
|
231
|
+
*/
|
|
232
|
+
private applyQueue;
|
|
233
|
+
/**
|
|
234
|
+
* Live-event hold buffer, active ONLY while a from-zero snapshot pull is in
|
|
235
|
+
* flight. A snapshot is full state as-of `snapshot_seq` S; its rows arrive
|
|
236
|
+
* tagged `seq = S`. If a live WS frame (or a tab broadcast) at `seq = S+k`
|
|
237
|
+
* applies FIRST — during the snapshot's (possibly multi-page) HTTP fetch — it
|
|
238
|
+
* advances the cursor past S, and then EVERY snapshot row (seq ≤ S) is
|
|
239
|
+
* dropped by the monotonic filter in enqueueApply, leaving a near-empty
|
|
240
|
+
* replica with the cursor persisted ahead (no 410, no heal until a reconcile
|
|
241
|
+
* happens to fire). The store has no per-row seq guard, so we can't just
|
|
242
|
+
* apply the snapshot unconditionally — an older snapshot row would clobber a
|
|
243
|
+
* newer live update. So we instead ORDER them: hold live/broadcast applies
|
|
244
|
+
* here while snapshotting, then replay them (seq-filtered) AFTER the snapshot
|
|
245
|
+
* lands. null = not snapshotting → normal apply.
|
|
246
|
+
*/
|
|
247
|
+
private snapshotHold;
|
|
248
|
+
/**
|
|
249
|
+
* Serialized channel for outbound network ops (pull, push, reconcile,
|
|
250
|
+
* refresh, resetReplica). Replaces the per-op `inFlightX` mutexes +
|
|
251
|
+
* the fire-and-forget `void refreshResolvedSession()` calls that used
|
|
252
|
+
* to race against in-flight pulls and reconciles. Apply stays
|
|
253
|
+
* separate (see `applyQueue` above) so WS events don't block on a
|
|
254
|
+
* pull's HTTP round-trip.
|
|
255
|
+
*/
|
|
256
|
+
private opQueue;
|
|
257
|
+
/**
|
|
258
|
+
* Serialized chain for session-transition application. Multiple
|
|
259
|
+
* concurrent triggers (`refreshResolvedSession` from app code +
|
|
260
|
+
* a `session-changed` envelope landing over WS + a multi-tab
|
|
261
|
+
* `session` broadcast) all enqueue here. Without it, two
|
|
262
|
+
* inspect-then-commit pairs interleave on the microtask queue
|
|
263
|
+
* and the older session can commit AFTER the newer, leaving the
|
|
264
|
+
* engine pinned to a stale tenant.
|
|
265
|
+
*/
|
|
266
|
+
private sessionChain;
|
|
267
|
+
/**
|
|
268
|
+
* Registered consumers for binary WebSocket frames. SyncEngine itself
|
|
269
|
+
* doesn't decode binary — it just owns the WS connection and routes
|
|
270
|
+
* frames to whoever signed up via [`onBinaryFrame`]. The first
|
|
271
|
+
* consumer is `@pylonsync/loro` for CRDT snapshots / updates;
|
|
272
|
+
* future binary use cases (file streaming, etc.) register the same
|
|
273
|
+
* way so this layer stays use-case-agnostic.
|
|
274
|
+
*
|
|
275
|
+
* Set rather than Array so a hot-reload re-registration of the same
|
|
276
|
+
* handler doesn't double-invoke. Caller-provided handler identity
|
|
277
|
+
* is the dedup key.
|
|
278
|
+
*/
|
|
279
|
+
private binaryHandlers;
|
|
280
|
+
/**
|
|
281
|
+
* Server-side ephemeral subscriptions (CRDT row subs, reactive query
|
|
282
|
+
* subs, future kinds). Owns the WS replay bookkeeping — each kind
|
|
283
|
+
* registers the message that re-creates its server-side state, and
|
|
284
|
+
* `ws.onopen` replays the bundle on reconnect. Kind-specific concerns
|
|
285
|
+
* (CRDT refcount, reactive handler routing) stay below as their own
|
|
286
|
+
* maps. See server-subscriptions.ts.
|
|
287
|
+
*/
|
|
288
|
+
private serverSubs;
|
|
289
|
+
/** Coordinator for every "this tab wants live updates" subscription —
|
|
290
|
+
* CRDT row subs + reactive query subs, leader bookkeeping + follower
|
|
291
|
+
* forwarding. The engine delegates `subscribeCrdt` / `unsubscribeCrdt`
|
|
292
|
+
* / `subscribeReactive` / `unsubscribeReactive` to it, and routes
|
|
293
|
+
* inbound multi-tab `sub-register` / `sub-unregister` / `reactive-msg`
|
|
294
|
+
* envelopes through it. Constructed lazily in start() because
|
|
295
|
+
* serverSubs isn't built until then. */
|
|
296
|
+
private subscriptions;
|
|
297
|
+
/** Room presence subscriptions. Replaces the per-component
|
|
298
|
+
* setInterval(GET /api/rooms/<room>, 5s) polling loop the `useRoom`
|
|
299
|
+
* hook used to run for every channel. New server protocol
|
|
300
|
+
* (v0.3.214+): the client sends `room-subscribe` / `room-unsubscribe`
|
|
301
|
+
* over the existing WS, and the server pushes `room-snapshot` /
|
|
302
|
+
* `room-update` whenever membership changes.
|
|
303
|
+
*
|
|
304
|
+
* This engine field is leader-only (followers forward register /
|
|
305
|
+
* unregister calls over the multi-tab channel — the leader's
|
|
306
|
+
* registry is the single source of truth on the wire). Constructed
|
|
307
|
+
* lazily in start() so SSR-only callers don't pay for it. */
|
|
308
|
+
private rooms;
|
|
309
|
+
/** Per-room set of follower tabIds that have forwarded a
|
|
310
|
+
* `room-sub-register`. Leader-only. Mirrors `crdtForwarders` in the
|
|
311
|
+
* SubscriptionCoordinator — the WS room sub stays alive until both
|
|
312
|
+
* the local refcount AND this set are empty. A separate map on the
|
|
313
|
+
* engine (instead of inside RoomSubscriptions) because the registry
|
|
314
|
+
* is leader-local and forwarder bookkeeping is multi-tab specific. */
|
|
315
|
+
private roomForwarders;
|
|
316
|
+
/**
|
|
317
|
+
* Listeners notified when the server signals a per-subscriber row
|
|
318
|
+
* revocation (`row-revoked` envelope). Used by `@pylonsync/loro`
|
|
319
|
+
* to evict the LoroDoc registry entry for a row whose policy was
|
|
320
|
+
* revoked mid-session. Plain Set so identity is the dedup key.
|
|
321
|
+
*/
|
|
322
|
+
private rowEvictionListeners;
|
|
323
|
+
/**
|
|
324
|
+
* Register a binary-frame handler. Returns an unsubscribe fn that
|
|
325
|
+
* pulls the handler back out — call on hook unmount / module
|
|
326
|
+
* teardown so handlers don't leak.
|
|
327
|
+
*
|
|
328
|
+
* Multiple handlers can register concurrently; each gets called for
|
|
329
|
+
* every binary frame the WS receives. Handlers should be cheap and
|
|
330
|
+
* non-throwing — exceptions are caught and logged but the message
|
|
331
|
+
* is otherwise dropped for that handler.
|
|
332
|
+
*/
|
|
333
|
+
onBinaryFrame(handler: (bytes: Uint8Array) => void): () => void;
|
|
334
|
+
/** Read the cached resolved session. Null user = anonymous. */
|
|
335
|
+
resolvedSession(): ResolvedSession;
|
|
336
|
+
/**
|
|
337
|
+
* Coarse connection state (see `SyncConnectionStatus`). Updated as
|
|
338
|
+
* the WS opens/closes and reconnect attempts run; subscribers re-
|
|
339
|
+
* render via the same store notify channel as live queries, so
|
|
340
|
+
* `useSyncStatus` is just a thin reader.
|
|
341
|
+
*/
|
|
342
|
+
connectionStatus(): SyncConnectionStatus;
|
|
343
|
+
/**
|
|
344
|
+
* Mutate connection status + notify subscribers. Idempotent — same-
|
|
345
|
+
* status calls are a no-op so the WS onopen → connected transition
|
|
346
|
+
* doesn't spam re-renders during a stable connection.
|
|
347
|
+
*/
|
|
348
|
+
private setConnectionStatus;
|
|
349
|
+
/** Sync key-value adapter for hot-path state (token, client_id). */
|
|
350
|
+
readonly storage: import("./storage").Storage;
|
|
351
|
+
constructor(config: SyncEngineConfig);
|
|
352
|
+
/**
|
|
353
|
+
* Hydrate the local store with server-rendered data.
|
|
354
|
+
* Call this before start() to avoid a redundant initial pull.
|
|
355
|
+
* Typically used for SSR: server fetches data + cursor, passes to client.
|
|
356
|
+
*/
|
|
357
|
+
hydrate(data: HydrationData): void;
|
|
358
|
+
/** Start the sync engine. Loads persisted data, pulls updates, then connects for real-time. */
|
|
359
|
+
start(): Promise<void>;
|
|
360
|
+
/**
|
|
361
|
+
* Multi-tab election. Brings up the orchestrator and runs the
|
|
362
|
+
* initial election. Sets `isMultiTabLeader` via the orchestrator's
|
|
363
|
+
* hooks (onInitialLeader / onLatePromote / onDemote).
|
|
364
|
+
*
|
|
365
|
+
* On platforms without BroadcastChannel (Node, jsdom, very old
|
|
366
|
+
* Safari) the orchestrator declares self leader and returns
|
|
367
|
+
* immediately.
|
|
368
|
+
*/
|
|
369
|
+
private initMultiTab;
|
|
370
|
+
/** Hooks the orchestrator calls back into for inbound multi-tab
|
|
371
|
+
* events that need engine state. Cases that only touch
|
|
372
|
+
* subscriptions are dispatched directly by the orchestrator. */
|
|
373
|
+
private multiTabHooks;
|
|
374
|
+
/** Leader-side hold tokens for room subs forwarded by followers. The
|
|
375
|
+
* leader subscribes via its own registry with a no-op callback so
|
|
376
|
+
* the registry's first-add gate ships `room-subscribe` exactly
|
|
377
|
+
* once; the release tokens here let us undo that hold when the
|
|
378
|
+
* last follower stops caring. */
|
|
379
|
+
private leaderForwarderHolds;
|
|
380
|
+
/** Seed serverSubs with every subscription this tab currently wants.
|
|
381
|
+
* Called from both the initial-leader path (subscribes that
|
|
382
|
+
* happened before start() took the follower branch and broadcast
|
|
383
|
+
* to a not-yet-running broker, so the registers were lost) and the
|
|
384
|
+
* late-promotion path (the previous leader owned the subs and we
|
|
385
|
+
* need to claim them). */
|
|
386
|
+
private seedServerSubsFromLocalInterest;
|
|
387
|
+
/** Late promotion: the previous leader dropped while we were
|
|
388
|
+
* running as a follower. Take over network ops now AND drain any
|
|
389
|
+
* pending mutations through push() — a mutation we'd forwarded to
|
|
390
|
+
* the previous leader may have died with it before the server
|
|
391
|
+
* acked, and we need to ship it ourselves. op_id makes the
|
|
392
|
+
* server-side retry idempotent: a previously-applied op returns
|
|
393
|
+
* `replayed` and we just mark it applied locally. */
|
|
394
|
+
private onMultiTabPromoted;
|
|
395
|
+
/** Demotion: another tab took over as leader. Tear down our
|
|
396
|
+
* transport (WS socket, ping timer, reconnect timer, poll timer —
|
|
397
|
+
* whichever applies) and stop driving network ops; the new leader
|
|
398
|
+
* will broadcast applied changes that our followers-mirror path
|
|
399
|
+
* picks up. */
|
|
400
|
+
private onMultiTabDemoted;
|
|
401
|
+
/** Broadcast a payload to other tabs in this origin. Delegates to
|
|
402
|
+
* the orchestrator; no-op when the orchestrator isn't running
|
|
403
|
+
* (SSR-only consumers that never reach `start()`). */
|
|
404
|
+
private broadcastToTabs;
|
|
405
|
+
private visibilityHandler;
|
|
406
|
+
private attachVisibilityListener;
|
|
407
|
+
/**
|
|
408
|
+
* Serialize a batch of change applies behind any in-flight applies, and
|
|
409
|
+
* advance the cursor monotonically when the batch lands. Both the WS
|
|
410
|
+
* onmessage path and pull() funnel through here so seq 3's persistence
|
|
411
|
+
* can't race ahead of seq 2's. The returned promise resolves after
|
|
412
|
+
* THIS batch is applied (not after later batches), so a caller awaiting
|
|
413
|
+
* pull() still completes deterministically.
|
|
414
|
+
*
|
|
415
|
+
* Per-event monotonic filter: re-applies of an already-seen seq are
|
|
416
|
+
* skipped before touching the store. Without that, a retransmit
|
|
417
|
+
* (WS + pull window overlap) would have us run applyChange twice
|
|
418
|
+
* against the local store.
|
|
419
|
+
*/
|
|
420
|
+
private enqueueApply;
|
|
421
|
+
/**
|
|
422
|
+
* Reconcile path. Routes through the same applyQueue as WS/pull so
|
|
423
|
+
* a reconcile batch can't interleave with a fresher change event
|
|
424
|
+
* mid-apply — but reconcile carries no real seqs, so the seq filter
|
|
425
|
+
* and cursor advance from `enqueueApply` are deliberately absent.
|
|
426
|
+
* Pending mutations are protected upstream (in applyEntityReconcile)
|
|
427
|
+
* before the batch is ever built.
|
|
428
|
+
*/
|
|
429
|
+
private enqueueReconcile;
|
|
430
|
+
/** Stop the sync engine. */
|
|
431
|
+
stop(): void;
|
|
432
|
+
/**
|
|
433
|
+
* Drop local cursor + store + notify. Safe to call from any state.
|
|
434
|
+
* Used by:
|
|
435
|
+
* - the 410 RESYNC_REQUIRED handler (server says our cursor is stale)
|
|
436
|
+
* - the identity-change detector in pull() (new auth = new visible set)
|
|
437
|
+
* - callers that need to force a clean re-pull (tests, sign-out flows)
|
|
438
|
+
*
|
|
439
|
+
* Does NOT issue the subsequent pull — callers decide when to re-pull.
|
|
440
|
+
* That keeps the lifecycle explicit: a caller can reset, swap config,
|
|
441
|
+
* then pull.
|
|
442
|
+
*
|
|
443
|
+
* Clears IndexedDB too. Without that, locals that should have been
|
|
444
|
+
* deleted server-side (e.g. another client deleted rows while this tab
|
|
445
|
+
* was closed, then this tab's cursor 410'd) survived on disk and got
|
|
446
|
+
* rehydrated on the next page load — phantom rows that no purge of
|
|
447
|
+
* in-memory state could fix.
|
|
448
|
+
*/
|
|
449
|
+
resetReplica(opts?: {
|
|
450
|
+
wipeMutations?: boolean;
|
|
451
|
+
}): Promise<void>;
|
|
452
|
+
/**
|
|
453
|
+
* Drop the local replica and pull fresh. `wipeMutations` decides the
|
|
454
|
+
* fate of the durable offline write queue:
|
|
455
|
+
* - `false` (default, 410 RESYNC, SAME user): KEEP pending writes —
|
|
456
|
+
* they survive the snapshot refresh and re-push under the same
|
|
457
|
+
* session.
|
|
458
|
+
* - `true` (token/tenant flip, DIFFERENT identity): DROP them — the
|
|
459
|
+
* queued writes belong to the outgoing identity and must never be
|
|
460
|
+
* replayed as the incoming one (cross-identity write leak).
|
|
461
|
+
*/
|
|
462
|
+
private resetReplicaInner;
|
|
463
|
+
/**
|
|
464
|
+
* localStorage key for the auth token, namespaced by appName. Matches
|
|
465
|
+
* the key the React package's `configureClient` writes to so the sync
|
|
466
|
+
* engine and the hooks agree on where the token lives.
|
|
467
|
+
*/
|
|
468
|
+
private tokenStorageKey;
|
|
469
|
+
/** Current auth token from config or the storage adapter. Null when neither has one. */
|
|
470
|
+
private currentToken;
|
|
471
|
+
/**
|
|
472
|
+
* Call a Pylon Action / Mutation by name.
|
|
473
|
+
*
|
|
474
|
+
* Wraps `POST /api/fn/<name>` with the engine's bearer/cookie auth
|
|
475
|
+
* AND observes the `X-Pylon-Change-Seq` response header. If the
|
|
476
|
+
* server reports the action generated change events that the local
|
|
477
|
+
* replica hasn't seen yet, the engine immediately fires a one-shot
|
|
478
|
+
* pull — closing the latency window between the HTTP response
|
|
479
|
+
* landing here and the WS broadcast of the same events arriving.
|
|
480
|
+
*
|
|
481
|
+
* App code that uses this method no longer needs the
|
|
482
|
+
* "after-mutation refetch()" workaround pattern (see pylon-cloud's
|
|
483
|
+
* domains/page.tsx, pre-2026-05-17, which called refetch() four
|
|
484
|
+
* times for exactly this reason).
|
|
485
|
+
*
|
|
486
|
+
* Throws (`Error & {status, code?}`) on non-2xx with the server's
|
|
487
|
+
* error envelope. Returns the parsed JSON response.
|
|
488
|
+
*/
|
|
489
|
+
fn<T = unknown>(name: string, args?: unknown): Promise<T>;
|
|
490
|
+
/** Shared by `fn()` and any future entity-mutation wrappers. POSTs
|
|
491
|
+
* through the central transport, observes `X-Pylon-Change-Seq`,
|
|
492
|
+
* and triggers a one-shot pull when the server says it produced
|
|
493
|
+
* events past our local cursor. The pull short-circuits cheaply
|
|
494
|
+
* (`{changes:[]}`) if WS broadcast already caught us up — so the
|
|
495
|
+
* worst case is one extra in-flight pull per mutation, never a
|
|
496
|
+
* stale render. */
|
|
497
|
+
private requestWithChangeSync;
|
|
498
|
+
/** Pull changes from the server. Coalesces concurrent callers via
|
|
499
|
+
* the op queue and serializes against push / reconcile / reset, so
|
|
500
|
+
* the cursor can't be read mid-reset and the change-log delta can't
|
|
501
|
+
* interleave with a sweeping reconcile. The 410 RESYNC retry path
|
|
502
|
+
* recurses into `pullInner` directly to avoid self-deadlock on the
|
|
503
|
+
* queue. */
|
|
504
|
+
pull(): Promise<void>;
|
|
505
|
+
private pullInner;
|
|
506
|
+
/** Consecutive 410 RESYNC_REQUIRED responses since the last successful
|
|
507
|
+
* pull. Used by the circuit breaker in pull() to bound the retry
|
|
508
|
+
* storm against a misconfigured server. Resets to 0 on any pull
|
|
509
|
+
* that doesn't throw a 410. */
|
|
510
|
+
private consecutive_410s;
|
|
511
|
+
/** Consecutive TRANSIENT push failures (offline / 5xx / 429 / 401)
|
|
512
|
+
* since the last server response. Drives the exponential backoff on
|
|
513
|
+
* the retry of a transient-failed push so an offline tab doesn't
|
|
514
|
+
* hot-loop. Reset to 0 the moment the server returns any response. */
|
|
515
|
+
private pushFailureCount;
|
|
516
|
+
/** Set by pullInner whenever the just-completed pull started with
|
|
517
|
+
* `cursor.last_seq === 0` (cold load OR post-reset). The WS
|
|
518
|
+
* onConnected hook reads this to skip the reconcile() that would
|
|
519
|
+
* otherwise fire immediately after the bootstrap pull — the
|
|
520
|
+
* snapshot path of pull already returned every row visible under
|
|
521
|
+
* current policy, so per-entity reconcile fetches right after are
|
|
522
|
+
* pure waste (~300ms on the critical path). One-shot: the flag is
|
|
523
|
+
* cleared on read so a subsequent reconnect-after-disconnect still
|
|
524
|
+
* runs reconcile normally. */
|
|
525
|
+
private lastPullStartedFromZero;
|
|
526
|
+
/** Timestamp of the last `reconcile()` invocation. Used to debounce —
|
|
527
|
+
* reconcile runs on connect, WS reconnect, AND visibility-change, so
|
|
528
|
+
* a quick tab-flick after a normal reconnect shouldn't refetch every
|
|
529
|
+
* entity twice within seconds. Configurable via `reconcileMinIntervalMs`. */
|
|
530
|
+
private lastReconcileAt;
|
|
531
|
+
/** Entities the app has subscribed to via `useQuery` / `useQueryOne`,
|
|
532
|
+
* even ones the local replica has zero rows for. The reconcile
|
|
533
|
+
* safety net defaults to `store.entityNames()` — entities with at
|
|
534
|
+
* least one local row — so a server row in a NEVER-cached entity (a
|
|
535
|
+
* row created on another surface, or a freshly-added entity) stayed
|
|
536
|
+
* invisible until a full snapshot / cache clear: `useQuery` reads
|
|
537
|
+
* the local store and a delta `pull()` can't recover a row created
|
|
538
|
+
* before the cursor. Tracking observed entities lets the no-arg
|
|
539
|
+
* reconcile sweep them too. See `observeEntity`. */
|
|
540
|
+
private observedEntities;
|
|
541
|
+
/** Per-entity count of CONSECUTIVE 403s seen during reconcile, reset on
|
|
542
|
+
* any successful fetch. A single 403 can be transient (a bearer caught
|
|
543
|
+
* mid-refresh, a momentary policy blip) and must NOT wipe the local cache;
|
|
544
|
+
* we only drop an entity's rows after two in a row. See `reconcile`. */
|
|
545
|
+
private reconcile403Streak;
|
|
546
|
+
/**
|
|
547
|
+
* Reconcile the local replica against server truth.
|
|
548
|
+
*
|
|
549
|
+
* For each entity that has at least one local row, fetch the
|
|
550
|
+
* authoritative row set from `/api/entities/<entity>` (already
|
|
551
|
+
* policy-filtered) and apply the diff:
|
|
552
|
+
*
|
|
553
|
+
* - Local rows whose id is missing from the server set → removed.
|
|
554
|
+
* - Server rows whose JSON differs from local → overwritten.
|
|
555
|
+
* - Server rows the local replica doesn't have → inserted.
|
|
556
|
+
*
|
|
557
|
+
* This is the safety net the WS / pull path can't provide on its own:
|
|
558
|
+
*
|
|
559
|
+
* - Deletes made by other surfaces (Mac SDK, server-side actions,
|
|
560
|
+
* admin tools) while this client was offline can fall off the
|
|
561
|
+
* in-memory ChangeLog before this client reconnects. The pull
|
|
562
|
+
* then returns an empty diff and the local phantom rows persist
|
|
563
|
+
* forever. Reconcile catches them.
|
|
564
|
+
*
|
|
565
|
+
* - Mutations broadcast on a sibling Fly machine (multi-instance
|
|
566
|
+
* deploys, autoscaled apps) never reach this WS. Reconcile is
|
|
567
|
+
* the only mechanism that observes them.
|
|
568
|
+
*
|
|
569
|
+
* - Events broadcast in the brief window between a pull completing
|
|
570
|
+
* and the WS opening get dropped because the WS has no replay-
|
|
571
|
+
* on-connect; reconcile makes those eventually-consistent.
|
|
572
|
+
*
|
|
573
|
+
* Debounced via `lastReconcileAt` so a flurry of triggers
|
|
574
|
+
* (reconnect + visibility-change firing back-to-back) coalesces to
|
|
575
|
+
* one network round per entity.
|
|
576
|
+
*
|
|
577
|
+
* Pass an explicit entity list to scope the reconcile (callers like
|
|
578
|
+
* `db.useQueryOne` that know what they care about). When called with
|
|
579
|
+
* no arg, every entity with local rows OR observed via `useQuery`
|
|
580
|
+
* (see `observeEntity`) is checked.
|
|
581
|
+
*/
|
|
582
|
+
/**
|
|
583
|
+
* Register interest in an entity — called by `useQuery` /
|
|
584
|
+
* `useQueryOne` on mount. Two effects:
|
|
585
|
+
*
|
|
586
|
+
* 1. Adds the entity to the reconcile sweep so the safety net
|
|
587
|
+
* covers it even with zero local rows (see `observedEntities`).
|
|
588
|
+
* 2. The FIRST time an entity is observed while the replica is
|
|
589
|
+
* hydrated and that entity is locally empty, fires a one-shot
|
|
590
|
+
* scoped reconcile so a server row this client never cached
|
|
591
|
+
* appears on page-open — instead of waiting for the next
|
|
592
|
+
* reconnect / visibility-change trigger. Bounded: at most once
|
|
593
|
+
* per entity per engine (the `observedEntities` guard).
|
|
594
|
+
*
|
|
595
|
+
* Genuinely-empty entities just pay one cheap policy-filtered fetch;
|
|
596
|
+
* entities where the client missed an insert get the row back.
|
|
597
|
+
*/
|
|
598
|
+
observeEntity(entity: string): void;
|
|
599
|
+
reconcile(entities?: string[]): Promise<void>;
|
|
600
|
+
private reconcileInner;
|
|
601
|
+
/** Fetch every row for an entity. Uses cursor pagination so big tables
|
|
602
|
+
* don't blow past server-side limits; loops until `has_more` is false
|
|
603
|
+
* or a safety cap is hit. */
|
|
604
|
+
private fetchEntityRows;
|
|
605
|
+
private applyEntityReconcile;
|
|
606
|
+
private dropEntity;
|
|
607
|
+
/**
|
|
608
|
+
* Fetch `/api/auth/me` and feed the result into the SessionResolver,
|
|
609
|
+
* acting on the verdict it returns (reset replica + pull on a real
|
|
610
|
+
* tenant flip, notify subscribers if any field changed). Callers:
|
|
611
|
+
* - `start()` — initial load
|
|
612
|
+
* - the token-flip branch in `pull()`
|
|
613
|
+
* - `notifySessionChanged()` — app code invokes this after it mutates
|
|
614
|
+
* server session state (login, logout, `/api/auth/select-org`) so the
|
|
615
|
+
* cached session + React subscribers update immediately instead of
|
|
616
|
+
* waiting for the next pull/reconnect cycle.
|
|
617
|
+
*
|
|
618
|
+
* On tenant flip this also resets the replica — same logic as the
|
|
619
|
+
* token-flip path, for the same reason (visible set changed).
|
|
620
|
+
*/
|
|
621
|
+
refreshResolvedSession(): Promise<void>;
|
|
622
|
+
/**
|
|
623
|
+
* Pure HTTP fetch of /api/auth/me → ResolvedSession. Unlike
|
|
624
|
+
* `refreshResolvedSession`, this does NOT gate on `isMultiTabLeader`
|
|
625
|
+
* — bootstrap callers in `start()` fire this in PARALLEL with the
|
|
626
|
+
* multi-tab election to overlap two independent latency windows
|
|
627
|
+
* (election ~250ms || auth/me ~60ms). At that point no other tabs'
|
|
628
|
+
* messages have been observed yet, so there's no broadcast-policy
|
|
629
|
+
* violation; the caller is responsible for discarding the result
|
|
630
|
+
* if it lost the election.
|
|
631
|
+
*
|
|
632
|
+
* Returns null on HTTP error / network failure / parse error — the
|
|
633
|
+
* caller's next pull cycle (or the WS `session-changed` envelope)
|
|
634
|
+
* will retry. Errors must not abort bootstrap.
|
|
635
|
+
*/
|
|
636
|
+
private fetchSessionBootstrap;
|
|
637
|
+
/**
|
|
638
|
+
* Apply a freshly-observed session through the resolver and act on
|
|
639
|
+
* the verdict. Serialized via `sessionChain` so concurrent triggers
|
|
640
|
+
* (refreshResolvedSession from app code + multi-tab `session`
|
|
641
|
+
* broadcast + WS `session-changed` envelope) run in arrival order
|
|
642
|
+
* and the latest tenant wins — without this, two interleaved
|
|
643
|
+
* inspect-then-commit pairs could commit an older session AFTER
|
|
644
|
+
* the newer one.
|
|
645
|
+
*/
|
|
646
|
+
private applySessionTransition;
|
|
647
|
+
private rawFetch;
|
|
648
|
+
/**
|
|
649
|
+
* Public alias for `refreshResolvedSession`. Almost never needed by
|
|
650
|
+
* app code today — the server pushes a `session-changed` envelope
|
|
651
|
+
* over WS whenever the session is mutated (select-org, clear-org,
|
|
652
|
+
* session revoke, even from other tabs / admin tools / server
|
|
653
|
+
* actions), and the engine's WS handler refreshes automatically.
|
|
654
|
+
*
|
|
655
|
+
* Kept as an escape hatch for the rare case where you mutated the
|
|
656
|
+
* session via a path that doesn't go through the framework's auth
|
|
657
|
+
* surface (e.g. directly writing to the SessionStore from a Rust
|
|
658
|
+
* plugin that bypassed `notify_session_changed`).
|
|
659
|
+
*
|
|
660
|
+
* The `selectOrg` / `clearOrg` / `signOut` helpers below remain as
|
|
661
|
+
* convenience wrappers that combine the HTTP call with an immediate
|
|
662
|
+
* local refresh — useful when the same tab needs the new state
|
|
663
|
+
* before the WS round-trip lands.
|
|
664
|
+
*/
|
|
665
|
+
notifySessionChanged(): Promise<void>;
|
|
666
|
+
/**
|
|
667
|
+
* Drop a row from the local replica because the server signaled
|
|
668
|
+
* that the current subscriber's read policy was revoked for it.
|
|
669
|
+
*
|
|
670
|
+
* `tombstoneSeq` is the server's high-water seq at the time of
|
|
671
|
+
* revocation (from the envelope). Stale in-flight WS frames with
|
|
672
|
+
* `seq <= tombstoneSeq` are filtered locally; legitimate
|
|
673
|
+
* re-grant + re-insert at higher seqs still land. Also fires a
|
|
674
|
+
* catch-up pull on revocation so any frame with `seq >
|
|
675
|
+
* tombstoneSeq` that arrives before the next legitimate event
|
|
676
|
+
* gets reconciled against server truth.
|
|
677
|
+
*
|
|
678
|
+
* Uses `LocalStore.revokeRow` (not `reconcileRemove`) so the
|
|
679
|
+
* tombstone is recorded even for CRDT-only consumers whose row
|
|
680
|
+
* was never materialized into `tables`.
|
|
681
|
+
*
|
|
682
|
+
* Also notifies row-eviction listeners so external row-bound
|
|
683
|
+
* resources (LoroDoc registries, etc.) can unmount.
|
|
684
|
+
*/
|
|
685
|
+
private handleRowRevocation;
|
|
686
|
+
/**
|
|
687
|
+
* Register a listener invoked when the server signals a per-
|
|
688
|
+
* subscriber row revocation. Used by `@pylonsync/loro` to evict
|
|
689
|
+
* the LoroDoc registry entry for the row so collaborative doc
|
|
690
|
+
* handles unmount cleanly. Returns an unsubscribe function.
|
|
691
|
+
*/
|
|
692
|
+
addRowEvictionListener(listener: (entity: string, rowId: string) => void): () => void;
|
|
693
|
+
/**
|
|
694
|
+
* Switch the caller's active tenant (organization) and refresh the
|
|
695
|
+
* resolved session in one shot. Membership is verified server-side
|
|
696
|
+
* (POST /api/auth/select-org throws 403 if the user isn't a member
|
|
697
|
+
* of the target org), and the engine's local replica resets so
|
|
698
|
+
* `db.useQuery` stops returning the previous tenant's rows.
|
|
699
|
+
*
|
|
700
|
+
* Throws on any non-2xx response. The error carries the
|
|
701
|
+
* server-issued JSON error body when available, so callers can
|
|
702
|
+
* branch on `err.code === "NOT_A_MEMBER"` etc.
|
|
703
|
+
*/
|
|
704
|
+
selectOrg(orgId: string): Promise<void>;
|
|
705
|
+
/**
|
|
706
|
+
* Drop the caller's active tenant — back to the "no active org"
|
|
707
|
+
* state typical of a login-lobby route. Refreshes the resolved
|
|
708
|
+
* session so React subscribers re-render with `tenantId: null`.
|
|
709
|
+
*/
|
|
710
|
+
clearOrg(): Promise<void>;
|
|
711
|
+
/**
|
|
712
|
+
* Revoke the current session server-side (DELETE /api/auth/session)
|
|
713
|
+
* and refresh — leaves the caller anonymous. Local sync stops on
|
|
714
|
+
* the next pull cycle; replica content stays in IndexedDB so a
|
|
715
|
+
* subsequent sign-in as the same user is instant.
|
|
716
|
+
*/
|
|
717
|
+
signOut(): Promise<void>;
|
|
718
|
+
/** Shared transport for the auth helpers above. Same bearer/cookie
|
|
719
|
+
* policy as `request()` — keeps the auth flows on the same
|
|
720
|
+
* authentication footing as data sync. */
|
|
721
|
+
private authMutate;
|
|
722
|
+
/** Push pending mutations to the server. Coalesces concurrent callers
|
|
723
|
+
* via the op queue's keyed dedupe — a slow push can't be restarted
|
|
724
|
+
* by the poll timer or a user mutation, which would resend the same
|
|
725
|
+
* batch (the mutation `op_id` keeps that safe at the protocol level,
|
|
726
|
+
* but shipping the same batch twice is still wasted bandwidth). Also
|
|
727
|
+
* serializes against pull / reconcile / resetReplica so a push can't
|
|
728
|
+
* observe a half-reset cursor or a mid-reconcile replica. */
|
|
729
|
+
push(): Promise<void>;
|
|
730
|
+
private pushInner;
|
|
731
|
+
/**
|
|
732
|
+
* Mark a pending mutation as failed AND undo its optimistic ghost
|
|
733
|
+
* in the local replica. Without the rollback step, a server-
|
|
734
|
+
* rejected insert leaves a ghost row that survives indefinitely
|
|
735
|
+
* (reconcile skips rows with pending/failed mutations to avoid
|
|
736
|
+
* sweeping the user's in-flight edit). The exact failure mode is
|
|
737
|
+
* "send a message, the server says no, refresh — the ghost is
|
|
738
|
+
* still there until you find the failed-state UI."
|
|
739
|
+
*
|
|
740
|
+
* Updates can't be rolled back without a pre-update snapshot
|
|
741
|
+
* (not captured today); the user-visible ghost-update sticks
|
|
742
|
+
* until the next reconcile observes the canonical row. Deletes
|
|
743
|
+
* leave a tombstone that should also be cleared, but the current
|
|
744
|
+
* `LocalStore` API doesn't expose the un-tombstone path — flagged
|
|
745
|
+
* for follow-up. Inserts are the dominant case (chat send is an
|
|
746
|
+
* insert; collaborative-edit is an update with a separate CRDT
|
|
747
|
+
* channel) so insert-only rollback is the right shape to ship now.
|
|
748
|
+
*/
|
|
749
|
+
private failPushedMutation;
|
|
750
|
+
/** Insert a row with optimistic local update.
|
|
751
|
+
*
|
|
752
|
+
* Invariant: the optimistic ghost and the canonical server row
|
|
753
|
+
* share a single id. The client mints a Pylon-shaped id, threads
|
|
754
|
+
* it through the data payload, and the server honors it on the
|
|
755
|
+
* canonical insert. Test:
|
|
756
|
+
* `insert_optimistic_ghost_and_server_row_share_id`. */
|
|
757
|
+
insert(entity: string, data: Row): Promise<string>;
|
|
758
|
+
/** Update a row with optimistic local update. */
|
|
759
|
+
update(entity: string, id: string, data: Partial<Row>): Promise<void>;
|
|
760
|
+
/** Delete a row with optimistic local update. */
|
|
761
|
+
delete(entity: string, id: string): Promise<void>;
|
|
762
|
+
/** Load a page of data from an entity with cursor-based pagination. */
|
|
763
|
+
loadPage(entity: string, options?: {
|
|
764
|
+
limit?: number;
|
|
765
|
+
offset?: number;
|
|
766
|
+
order?: Record<string, "asc" | "desc">;
|
|
767
|
+
}): Promise<{
|
|
768
|
+
data: Row[];
|
|
769
|
+
total: number;
|
|
770
|
+
hasMore: boolean;
|
|
771
|
+
}>;
|
|
772
|
+
/**
|
|
773
|
+
* Create an infinite query that appends pages.
|
|
774
|
+
* Returns an object with loadMore() and the current accumulated data.
|
|
775
|
+
*/
|
|
776
|
+
createInfiniteQuery(entity: string, options?: {
|
|
777
|
+
pageSize?: number;
|
|
778
|
+
order?: Record<string, "asc" | "desc">;
|
|
779
|
+
}): {
|
|
780
|
+
/** Load the next page. */
|
|
781
|
+
loadMore: () => Promise<void>;
|
|
782
|
+
/** Get current accumulated rows. */
|
|
783
|
+
readonly data: Row[];
|
|
784
|
+
/** Whether more pages are available. */
|
|
785
|
+
readonly hasMore: boolean;
|
|
786
|
+
/** Whether currently loading. */
|
|
787
|
+
readonly loading: boolean;
|
|
788
|
+
/** Subscribe to changes. */
|
|
789
|
+
subscribe: (fn: () => void) => () => boolean;
|
|
790
|
+
/** Reset and start over. */
|
|
791
|
+
reset: () => void;
|
|
792
|
+
};
|
|
793
|
+
/** Get the current cursor position. */
|
|
794
|
+
getCursor(): SyncCursor;
|
|
795
|
+
/** Whether the real-time transport is currently open. True for an
|
|
796
|
+
* open WebSocket / EventSource and for a running poll loop; false
|
|
797
|
+
* for a follower tab (no transport) or before start() / after stop(). */
|
|
798
|
+
get connected(): boolean;
|
|
799
|
+
/** Set this client's presence data and broadcast it. */
|
|
800
|
+
setPresence(data: Record<string, unknown>): void;
|
|
801
|
+
/** Send a topic message to all connected clients. */
|
|
802
|
+
publishTopic(topic: string, data: unknown): void;
|
|
803
|
+
/**
|
|
804
|
+
* Subscribe this client to binary CRDT updates for one row. Refcounted
|
|
805
|
+
* so two `useLoroDoc` consumers on the same `(entity, rowId)` don't
|
|
806
|
+
* unsubscribe each other on unmount — only the last `unsubscribeCrdt`
|
|
807
|
+
* call ships the unsubscribe message to the server.
|
|
808
|
+
*
|
|
809
|
+
* The first subscriber for a row sends the `crdt-subscribe` over WS,
|
|
810
|
+
* which prompts the server to ship the current snapshot back as a
|
|
811
|
+
* binary frame so the new tab converges to the latest state.
|
|
812
|
+
*
|
|
813
|
+
* Idempotent at the WS level: re-calling for the same row with no
|
|
814
|
+
* intervening unsubscribe just bumps the refcount.
|
|
815
|
+
*/
|
|
816
|
+
subscribeCrdt(entity: string, rowId: string): void;
|
|
817
|
+
/**
|
|
818
|
+
* Decrement the refcount for a row. When it hits zero we ship a
|
|
819
|
+
* `crdt-unsubscribe` to the server and forget the row, so a future
|
|
820
|
+
* reconnect won't try to resubscribe.
|
|
821
|
+
*
|
|
822
|
+
* Calling `unsubscribeCrdt` more times than `subscribeCrdt` is a
|
|
823
|
+
* no-op rather than an error — keeps React's StrictMode double-
|
|
824
|
+
* invocation in dev from over-decrementing past zero.
|
|
825
|
+
*/
|
|
826
|
+
unsubscribeCrdt(entity: string, rowId: string): void;
|
|
827
|
+
/**
|
|
828
|
+
* Register a reactive query subscription. The caller-minted `sub_id`
|
|
829
|
+
* is used by the React hook to dispatch result/error pushes to the
|
|
830
|
+
* right component. Returns nothing — push handling is async via the
|
|
831
|
+
* registered handler.
|
|
832
|
+
*
|
|
833
|
+
* Idempotent: re-calling with the same `sub_id` replaces the prior
|
|
834
|
+
* handler + spec. Useful when args change and the hook re-registers.
|
|
835
|
+
*
|
|
836
|
+
* The actual subscribe message goes over the WS — works only when
|
|
837
|
+
* the socket is open. When called before the WS opens (initial
|
|
838
|
+
* mount during start()), the spec is still recorded and gets sent
|
|
839
|
+
* on `ws.onopen`'s re-registration sweep.
|
|
840
|
+
*/
|
|
841
|
+
subscribeReactive(sub_id: string, fn_name: string, args: unknown, handler: (msg: ReactiveMessage) => void): void;
|
|
842
|
+
/** Tear down a reactive subscription. Sends the unsubscribe to the
|
|
843
|
+
* server and clears local state. No-op for unknown sub_ids — React
|
|
844
|
+
* StrictMode double-unmount won't error. */
|
|
845
|
+
unsubscribeReactive(sub_id: string): void;
|
|
846
|
+
/** Send a JSON message via the active transport. No-op when no
|
|
847
|
+
* transport exists (follower tab) or the transport doesn't support
|
|
848
|
+
* uplink (SSE, polling). Subscribe / presence / topic / ping frames
|
|
849
|
+
* all route here. */
|
|
850
|
+
private sendWs;
|
|
851
|
+
/**
|
|
852
|
+
* Subscribe to a room's membership over WebSocket. The callback fires
|
|
853
|
+
* whenever the room's `members` list OR `error` state changes — read
|
|
854
|
+
* the current snapshot via `getRoomMembers(roomId)` and the latest
|
|
855
|
+
* error via `getRoomError(roomId)` inside the callback.
|
|
856
|
+
*
|
|
857
|
+
* Refcounted: multiple subscribers for the same `roomId` share one
|
|
858
|
+
* `room-subscribe` on the wire. Returns an unsubscribe function;
|
|
859
|
+
* the last unsubscribe ships `room-unsubscribe`.
|
|
860
|
+
*
|
|
861
|
+
* Follower tabs forward the register / unregister over the multi-tab
|
|
862
|
+
* channel so the leader's WS carries one subscribe per room across
|
|
863
|
+
* the whole origin. Inbound snapshot / update / error envelopes land
|
|
864
|
+
* on the leader's WS and the leader fans them out cross-tab so each
|
|
865
|
+
* follower's local registry routes to its own subscribers.
|
|
866
|
+
*
|
|
867
|
+
* Idempotent w.r.t. wire frames: a re-subscribe with no intervening
|
|
868
|
+
* full unsubscribe doesn't re-send `room-subscribe`. ServerSubscriptions-
|
|
869
|
+
* style replay on reconnect is built in — the registry resends
|
|
870
|
+
* `room-subscribe` for every active room on WS reopen.
|
|
871
|
+
*/
|
|
872
|
+
subscribeRoom(roomId: string, callback: RoomSubscriber): () => void;
|
|
873
|
+
/**
|
|
874
|
+
* Subscribe to BROADCAST MESSAGES relayed through a room (the
|
|
875
|
+
* payloads sent via `POST /api/rooms/broadcast` / `useRoom`'s
|
|
876
|
+
* `broadcast()`). Same refcounted wire-subscription and leader /
|
|
877
|
+
* follower routing as `subscribeRoom` — the two channels share one
|
|
878
|
+
* `room-subscribe` per room per origin.
|
|
879
|
+
*
|
|
880
|
+
* The callback receives `{ topic, payload, from }` where `from` is
|
|
881
|
+
* the server-stamped sender user id (own broadcasts echo back —
|
|
882
|
+
* filter on `from` if unwanted). Returns an unsubscribe function.
|
|
883
|
+
*/
|
|
884
|
+
subscribeRoomMessages(roomId: string, callback: (message: import("./room-subscriptions").RoomMessage) => void): () => void;
|
|
885
|
+
/** Force-unsubscribe every local subscriber of a room and ship a
|
|
886
|
+
* `room-unsubscribe`. Used by the `useRoom` hook's manual `leave()`
|
|
887
|
+
* action so a deliberate exit propagates to the server immediately. */
|
|
888
|
+
unsubscribeRoom(roomId: string): void;
|
|
889
|
+
/** Read the current cached members snapshot for `roomId`. Returns
|
|
890
|
+
* `null` when no snapshot has landed yet (distinct from `[]` for
|
|
891
|
+
* an empty room). */
|
|
892
|
+
getRoomMembers(roomId: string): RoomMember[] | null;
|
|
893
|
+
/** Read the latest error for `roomId` (e.g. NOT_IN_ROOM). null when
|
|
894
|
+
* none. */
|
|
895
|
+
getRoomError(roomId: string): RoomError | null;
|
|
896
|
+
/** Active transport kind. Used by the `useRoom` hook to decide
|
|
897
|
+
* between WS push and HTTP polling fallback — only the WS transport
|
|
898
|
+
* supports the room-subscribe push protocol; SSE and polling fall
|
|
899
|
+
* back to the legacy 5s GET /api/rooms/<room>. */
|
|
900
|
+
getActiveTransportType(): TransportType;
|
|
901
|
+
/** True when the active transport is a WebSocket AND the socket is
|
|
902
|
+
* currently open. The `useRoom` hook gates its WS-push path on this
|
|
903
|
+
* — when false, fall back to polling. */
|
|
904
|
+
isWebSocketConnected(): boolean;
|
|
905
|
+
/** Resolved transport kind, with the websocket default applied. */
|
|
906
|
+
private transportKind;
|
|
907
|
+
/** Build the host surface the transport calls back into. One object
|
|
908
|
+
* shared across transport lifetime — the engine fields it reads (
|
|
909
|
+
* config, transport state, callbacks) are stable references. */
|
|
910
|
+
private transportHost;
|
|
911
|
+
/** Dispatch a typed JSON envelope inbound from the transport. The
|
|
912
|
+
* transport already filtered out ChangeEvents (those go through
|
|
913
|
+
* onChangeEvent → enqueueApply). Anything else lands here. */
|
|
914
|
+
private dispatchInboundMessage;
|
|
915
|
+
/** Route a binary frame to local consumers AND, when at least one
|
|
916
|
+
* follower tab forwarded a CRDT sub, mirror over the multi-tab
|
|
917
|
+
* channel so followers see Loro updates too. */
|
|
918
|
+
private dispatchBinaryFrame;
|
|
919
|
+
private request;
|
|
920
|
+
}
|
|
921
|
+
/** Data shape for hydrating the client from server-rendered content. */
|
|
922
|
+
export interface HydrationData {
|
|
923
|
+
/** Map of entity name -> rows fetched on the server. */
|
|
924
|
+
entities: Record<string, Record<string, unknown>[]>;
|
|
925
|
+
/** The sync cursor at the time of server fetch. */
|
|
926
|
+
cursor?: SyncCursor;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Server-side helper: fetch entities from the pylon API and return
|
|
930
|
+
* hydration data that can be passed to the client's SyncEngine.hydrate().
|
|
931
|
+
*
|
|
932
|
+
* Use this in Next.js server components, getServerSideProps, or route handlers.
|
|
933
|
+
*/
|
|
934
|
+
export declare function getServerData(baseUrl: string, entities: string[], options?: {
|
|
935
|
+
token?: string;
|
|
936
|
+
}): Promise<HydrationData>;
|
|
937
|
+
/**
|
|
938
|
+
* Create a sync engine connected to the pylon backend.
|
|
939
|
+
*
|
|
940
|
+
* Default `baseUrl` resolution order:
|
|
941
|
+
* 1. Explicit `baseUrl` argument — wins always.
|
|
942
|
+
* 2. `window.location.origin` when running in a browser — same-origin
|
|
943
|
+
* deployments (Next.js + Vercel rewrites, embedded SPA, etc.) want
|
|
944
|
+
* this and forgetting to pass it should NOT silently leak
|
|
945
|
+
* `localhost:4321` requests in production.
|
|
946
|
+
* 3. `http://localhost:4321` — the `pylon dev` default for SSR /
|
|
947
|
+
* non-browser callers (Node scripts, tests).
|
|
948
|
+
*/
|
|
949
|
+
export declare function createSyncEngine(baseUrl?: string, options?: Partial<SyncEngineConfig>): SyncEngine;
|