@pylonsync/sync 0.3.292 → 0.3.294

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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;