@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.
@@ -0,0 +1,113 @@
1
+ export interface RoomMember {
2
+ user_id: string;
3
+ joined_at: string;
4
+ data?: Record<string, unknown>;
5
+ }
6
+ /** Reason codes the SDK exposes on a room subscription error. */
7
+ export type RoomErrorCode = "NOT_IN_ROOM" | "UNKNOWN";
8
+ export interface RoomError {
9
+ code: RoomErrorCode;
10
+ message?: string;
11
+ }
12
+ /** Callback fired when a room's membership snapshot OR error state
13
+ * changes. The same callback receives every transition for the room —
14
+ * snapshots overwrite state, updates mutate it, and errors set the
15
+ * `error` slot. Subscribers read the latest values via the getters on
16
+ * the entry; this fires purely as a "something changed" pulse so
17
+ * React hooks can re-render. */
18
+ export type RoomSubscriber = () => void;
19
+ /** A broadcast message relayed through a room. `from` is the sender's
20
+ * user id (the server stamps it — clients can't spoof each other). */
21
+ export interface RoomMessage {
22
+ topic: string;
23
+ payload: unknown;
24
+ from: string;
25
+ }
26
+ export type RoomMessageSubscriber = (message: RoomMessage) => void;
27
+ export declare class RoomSubscriptions {
28
+ private readonly sendWs;
29
+ private readonly rooms;
30
+ /** Caller-supplied uplink to the WS. The engine routes through its
31
+ * active transport; this module stays transport-agnostic.
32
+ * Returns true when the message reached the wire (transport is open),
33
+ * false otherwise — RoomSubscriptions uses this to decide whether
34
+ * to surface "not connected yet" to the registry's callers.
35
+ * No-op transports (followers, no WS yet) return false; the engine
36
+ * hides the broadcast/leader split behind this hook. */
37
+ constructor(sendWs: (msg: unknown) => boolean);
38
+ /** Register a subscriber for `roomId`. First add ships the WS
39
+ * `room-subscribe`; subsequent adds just bump the refcount and
40
+ * deliver the cached snapshot to the new subscriber's callback.
41
+ *
42
+ * Returns an unsubscribe function that decrements the refcount on
43
+ * call. The last unsubscribe ships `room-unsubscribe`, clears the
44
+ * entry, and the next register() for the same room is a fresh
45
+ * start.
46
+ *
47
+ * Idempotent w.r.t. wire frames: a re-subscribe with no intervening
48
+ * full unsubscribe doesn't re-send `room-subscribe` to the server. */
49
+ register(roomId: string, subscriber: RoomSubscriber): () => void;
50
+ /** Get-or-create the entry for a room. `isFirst` means this call
51
+ * created it, i.e. the caller must ship the wire `room-subscribe`. */
52
+ private ensureEntry;
53
+ /**
54
+ * Register a BROADCAST-MESSAGE listener for `roomId`. Counts toward
55
+ * the same refcount as membership subscribers (a tab that only
56
+ * listens for broadcasts still needs the wire `room-subscribe`).
57
+ * The callback receives every `action: "broadcast"` relay for the
58
+ * room — including the caller's own broadcasts echoed back; filter
59
+ * on `message.from` if self-echo is unwanted.
60
+ */
61
+ registerMessages(roomId: string, subscriber: RoomMessageSubscriber): () => void;
62
+ /** Decrement the refcount for one subscriber. Internal — the
63
+ * `register()` returned unsubscribe routes here. Last out ships
64
+ * `room-unsubscribe`. */
65
+ private unregisterOne;
66
+ /** Force a full teardown of one room regardless of refcount. Used by
67
+ * the engine's leader-handoff and the hook's manual `leave()` path.
68
+ * Notifies every remaining subscriber via the standard pulse (so
69
+ * they observe `members === null` and re-render as disconnected). */
70
+ unregisterRoom(roomId: string): void;
71
+ /** Snapshot push from the server: full membership for the room.
72
+ * Overwrites any cached state, clears any prior error, and pulses
73
+ * every subscriber callback. */
74
+ applySnapshot(roomId: string, members: RoomMember[]): void;
75
+ /** Incremental update from the server. `action` mirrors the server's
76
+ * RoomEvent variants — join / leave / presence / broadcast.
77
+ * Membership actions mutate the cached `members` snapshot in-place
78
+ * and pulse the membership subscribers; `broadcast` actions route
79
+ * the relayed payload to the message subscribers instead (and do
80
+ * NOT pulse membership — fire-rate broadcasts would otherwise
81
+ * re-render every useRoom consumer per message). */
82
+ applyUpdate(roomId: string, action: "join" | "leave" | "presence" | "broadcast", member: RoomMember | undefined, _data: unknown): void;
83
+ /** Server pushed `{ type: "error", code, room }` after a subscribe.
84
+ * Record the error on the room and pulse subscribers — the React
85
+ * hook surfaces it via the `error` return slot. We do NOT
86
+ * unregister automatically: the user genuinely isn't in the room
87
+ * and the SDK shouldn't retry, but the registry entry stays so
88
+ * React unmount still ships a `room-unsubscribe` (server is
89
+ * idempotent for unknown subs). */
90
+ applyError(roomId: string, error: RoomError): void;
91
+ /** Read the cached snapshot for a room without subscribing. The
92
+ * React hook uses this in its initial-state effect so a re-mount
93
+ * inside the same registry lifecycle picks up the current members
94
+ * on tick zero. Returns `null` when the snapshot hasn't landed
95
+ * yet — DISTINCT from `[]` ("empty room"). */
96
+ members(roomId: string): RoomMember[] | null;
97
+ /** Latest error for a room (null if none). */
98
+ error(roomId: string): RoomError | null;
99
+ /** Is there at least one local subscriber for `roomId`. Used by the
100
+ * hook's polling-fallback path to decide whether to keep polling. */
101
+ has(roomId: string): boolean;
102
+ /** Every room currently tracked. Used by `replay()` and by the
103
+ * multi-tab seed-on-promotion. */
104
+ roomIds(): string[];
105
+ /** Resend `room-subscribe` for every active room. Called by the
106
+ * engine's `onConnected` hook after the WS reopens — the server
107
+ * forgets per-client subs across disconnects, so without this
108
+ * resync the first push would never arrive. */
109
+ replay(): void;
110
+ /** Test/diagnostics: total active rooms. */
111
+ size(): number;
112
+ private notify;
113
+ }
@@ -0,0 +1,26 @@
1
+ export declare class ServerSubscriptions {
2
+ private readonly sendWs;
3
+ private specs;
4
+ constructor(sendWs: (msg: unknown) => void);
5
+ /** Register a subscription. Sends `subscribeMessage` over WS and
6
+ * remembers it so the next reconnect re-sends it.
7
+ *
8
+ * Re-registering the same key with the SAME payload is a no-op
9
+ * (the prior subscribe is still live on the server). But a
10
+ * re-register with a DIFFERENT payload re-sends — that's the
11
+ * intended behavior of `useReactiveQuery(name, args)` when args
12
+ * change: same sub_id, new args, server must observe the change
13
+ * or the handler keeps running against stale arguments. */
14
+ register(key: string, subscribeMessage: unknown): void;
15
+ /** Unregister. Sends `unsubscribeMessage` over WS and forgets the
16
+ * replay entry. No-op for unknown keys (matches React's
17
+ * StrictMode-friendly double-unmount semantics). */
18
+ unregister(key: string, unsubscribeMessage: unknown): void;
19
+ /** Whether `key` is currently registered. */
20
+ has(key: string): boolean;
21
+ /** Re-send every registered subscribe message. Called from
22
+ * `ws.onopen` after the socket reconnects — the server purges
23
+ * per-client subscription state on disconnect, so without this
24
+ * resync the subscriber's first event would never arrive. */
25
+ replay(): void;
26
+ }
@@ -0,0 +1,68 @@
1
+ import type { ResolvedSession } from "./types";
2
+ /** Verdict returned from `observeSession`. The engine acts on these
3
+ * three booleans; the resolver makes no decisions about side effects
4
+ * (replica reset, store notify, pull) — those belong upstream. */
5
+ export interface SessionTransition {
6
+ /** Resolved session differs in any field (userId / tenantId / isAdmin /
7
+ * roles). When true, the engine should notify subscribers. */
8
+ identityChanged: boolean;
9
+ /** The tenant moved AND it isn't the null→X first-resolution case —
10
+ * cached rows belong to a different tenant and the replica should
11
+ * be wiped before the next pull. */
12
+ replicaInvalidated: boolean;
13
+ /** First time tenant flipped from null to a real value (the engine
14
+ * started before /api/auth/select-org landed). The cached rows in
15
+ * this case ARE for the new tenant; reset would tombstone valid
16
+ * state. Engine should pull but NOT reset. */
17
+ isFirstResolution: boolean;
18
+ /** Tenant value moved at all (covers both null→X and X→Y). When
19
+ * true, the engine should pull regardless of reset semantics —
20
+ * cached rows under the old tenant won't include rows added under
21
+ * the new one. */
22
+ tenantChanged: boolean;
23
+ }
24
+ export interface TokenTransition {
25
+ /** Token flipped since the last observation. Engine should reset
26
+ * the replica because the visible set changed under a new identity. */
27
+ tokenChanged: boolean;
28
+ }
29
+ export declare class SessionResolver {
30
+ private _resolved;
31
+ /** `undefined` until the first observation — distinguishes "we've
32
+ * never seen a token" from "the token is null." Same for tenant. */
33
+ private lastSeenToken;
34
+ private lastSeenTenant;
35
+ /** Current resolved session — what `useSession` consumers should see. */
36
+ resolved(): ResolvedSession;
37
+ /** Stable string signature of the current session. Captured before
38
+ * long-running ops (reconcile, in particular) so the op can detect
39
+ * if anything changed during its in-flight period and bail. */
40
+ signature(): string;
41
+ /** Compute the verdict for a freshly resolved session WITHOUT
42
+ * mutating internal state. The engine inspects the verdict to
43
+ * decide whether to reset the replica + pull, then calls
44
+ * `commitObservation(next)` once it's safe for `useSession`
45
+ * subscribers to see the new tenant.
46
+ *
47
+ * Splitting compute from commit prevents the "useSession reports
48
+ * new tenant + useQuery shows old tenant's rows" inconsistency
49
+ * window that existed when the resolved session was mutated
50
+ * before `resetReplica()` finished. */
51
+ inspectSession(next: ResolvedSession): SessionTransition;
52
+ /** Commit a previously-inspected session as the current truth.
53
+ * Engine calls this AFTER acting on the verdict (replica reset,
54
+ * pull) so subscribers never observe a half-applied transition. */
55
+ commitObservation(next: ResolvedSession): void;
56
+ /** Convenience for tests / migration: inspect + commit in one call.
57
+ * Production callers should use `inspectSession` + `commitObservation`
58
+ * to control the timing of state mutation. */
59
+ observeSession(next: ResolvedSession): SessionTransition;
60
+ /** Feed in the current bearer token. Returns whether it flipped
61
+ * since the previous observation. The engine uses this in pull()
62
+ * to decide whether to reset before reading the cursor. */
63
+ observeToken(token: string | null): TokenTransition;
64
+ }
65
+ /** Stable signature of a resolved session. Used by reconcile to detect
66
+ * mid-fetch session flips. Roles array is sorted+joined so insertion
67
+ * order doesn't trip the equality check. */
68
+ export declare function sessionSignature(s: ResolvedSession): string;
@@ -0,0 +1,30 @@
1
+ export interface Storage {
2
+ get(key: string): string | null;
3
+ set(key: string, value: string): void;
4
+ remove(key: string): void;
5
+ }
6
+ /**
7
+ * Pick a default storage adapter for the current host. Returns a real
8
+ * localStorage wrapper in browsers, an in-memory map elsewhere. Apps that
9
+ * need persistence on non-browser hosts (RN, Tauri, Electron) should pass
10
+ * their own adapter via `SyncEngineConfig.storage` and skip this default.
11
+ */
12
+ export declare function defaultStorage(): Storage;
13
+ /**
14
+ * Build a `Storage` wrapper around an async backend (AsyncStorage,
15
+ * Tauri-store, etc). The host is responsible for hydrating `seed` from
16
+ * the async backend at startup and for persisting writes on its own
17
+ * schedule. The wrapper itself stays synchronous so the engine doesn't
18
+ * change shape per platform.
19
+ *
20
+ * Typical RN wiring:
21
+ * ```ts
22
+ * const seed = await AsyncStorage.multiGet(KEYS).then(toRecord);
23
+ * const storage = createWriteThroughStorage(seed, async (k, v) => {
24
+ * if (v === null) await AsyncStorage.removeItem(k);
25
+ * else await AsyncStorage.setItem(k, v);
26
+ * });
27
+ * init({ baseUrl, storage });
28
+ * ```
29
+ */
30
+ export declare function createWriteThroughStorage(seed: Record<string, string> | null, onWrite: (key: string, value: string | null) => void): Storage;
@@ -0,0 +1,99 @@
1
+ import type { ServerSubscriptions } from "./server-subscriptions";
2
+ import type { ReactiveMessage } from "./types";
3
+ /** Engine-side surface the coordinator depends on. Kept narrow so the
4
+ * coordinator's contract with the engine is explicit. */
5
+ export interface SubscriptionCoordinatorContext {
6
+ isLeader(): boolean;
7
+ broadcastToTabs(payload: unknown): void;
8
+ }
9
+ export declare class SubscriptionCoordinator {
10
+ private readonly serverSubs;
11
+ private readonly ctx;
12
+ /** Per-row local refcount for CRDT subscriptions — N `useLoroDoc`
13
+ * consumers on the same `(entity, rowId)` in THIS tab share a
14
+ * single server subscription. Distinct from `reactiveSubOwners`:
15
+ * CRDT keys are per-row (many consumers per tab) while reactive
16
+ * sub_ids are per-consumer-instance (typically one). StrictMode
17
+ * double-subscribe still bumps the count by one each call; the
18
+ * matching double-unsubscribe early-returns when the count is
19
+ * already zero, so the math balances. */
20
+ private crdtSubscribers;
21
+ /** Per-row set of FOLLOWER tabIds that have forwarded a
22
+ * `sub-register` for this key. Leader-only. Used to:
23
+ * (a) skip the WS unsubscribe until both `crdtSubscribers`
24
+ * and this set are empty, and
25
+ * (b) skip the cross-tab binary broadcast when no follower
26
+ * cares about CRDT (saves bandwidth when the leader is
27
+ * the only CRDT consumer). */
28
+ private crdtForwarders;
29
+ /** Per-sub_id ownership set for reactive subscriptions on the
30
+ * leader: which tabs (self + forwarders) want this sub alive.
31
+ * A SET, not a count, so a follower crash + late `sub-unregister`
32
+ * storm can't underflow the count, and a remount/StrictMode
33
+ * double-subscribe from one tab still counts as one owner. */
34
+ private reactiveSubOwners;
35
+ /** Inbound message routing for reactive subscriptions — server pushes
36
+ * `reactive-result` / `reactive-error` envelopes keyed by sub_id and
37
+ * the hook's handler lives here. */
38
+ private reactiveHandlers;
39
+ /** Specs (fn_name + args) for every reactive subscription this tab
40
+ * has registered, regardless of role. On promotion the new leader
41
+ * uses these to register with serverSubs; on a leader change while
42
+ * we stay a follower we use them to re-forward `sub-register` to
43
+ * the new leader. */
44
+ private wantedReactiveSpecs;
45
+ constructor(serverSubs: ServerSubscriptions, ctx: SubscriptionCoordinatorContext);
46
+ /** Refcounted CRDT row subscribe. First subscriber for the row sends
47
+ * the WS frame (leader) or forwards to the leader (follower).
48
+ * Idempotent at the WS level: re-calling with no intervening
49
+ * unsubscribe just bumps the count. */
50
+ subscribeCrdt(entity: string, rowId: string): void;
51
+ /** Refcount-aware CRDT row unsubscribe. Last unsubscribe ships the
52
+ * WS frame; intermediate calls just decrement. Calling more times
53
+ * than `subscribeCrdt` is a no-op (StrictMode-safe). */
54
+ unsubscribeCrdt(entity: string, rowId: string): void;
55
+ /** Register a reactive query subscription. The caller-minted `sub_id`
56
+ * is used by the React hook to dispatch result/error pushes to the
57
+ * right component.
58
+ *
59
+ * Idempotent: re-calling with the same `sub_id` replaces the prior
60
+ * handler + spec. Useful when args change and the hook re-registers
61
+ * — ServerSubscriptions re-sends on payload change, so the WS sees
62
+ * the new args. */
63
+ subscribeReactive(sub_id: string, fn_name: string, args: unknown, handler: (msg: ReactiveMessage) => void): void;
64
+ /** Tear down a reactive subscription. No-op for unknown sub_ids —
65
+ * React StrictMode double-unmount doesn't error. */
66
+ unsubscribeReactive(sub_id: string): void;
67
+ /** Follower → leader: register a WS subscription on the follower's
68
+ * behalf. Caller (engine) has already gated on `isLeader()`. */
69
+ handleForwardedRegister(msg: Record<string, unknown>, fromTabId: string): void;
70
+ /** Follower → leader: unregister a WS subscription on the follower's
71
+ * behalf. */
72
+ handleForwardedUnregister(msg: Record<string, unknown>, fromTabId: string): void;
73
+ /** Leader → follower: a `reactive-result` / `reactive-error` envelope
74
+ * arrived on the leader's WS. Route to the local handler if we
75
+ * registered the sub. */
76
+ handleReactiveMessage(sub_id: string, payload: ReactiveMessage): void;
77
+ /** Seed serverSubs with every subscription this tab currently wants.
78
+ * Called when this tab settles as leader (either at start() or via
79
+ * late promotion) so the next WS connect replays the bundle.
80
+ * Idempotent: serverSubs.register dedupes by payload equality. */
81
+ seedFromLocalInterest(): void;
82
+ /** Re-broadcast every locally-wanted sub to the (new) leader.
83
+ * Triggered by `request-sub-replay` after a leader change while we
84
+ * stayed a follower. */
85
+ replayForwardedSubs(): void;
86
+ /** A peer tab disappeared (broker `bye` fired). Drop it from every
87
+ * forwarder/owner set; if a key drops to zero remaining owners
88
+ * (no local consumer, no forwarder) we unregister the WS sub so
89
+ * the server stops fanning at a tab that no longer exists. */
90
+ scrubPeer(tabId: string): void;
91
+ /** True when at least one follower tab has forwarded a CRDT sub.
92
+ * The engine gates its binary-broadcast on this so we don't fan
93
+ * binary frames to every tab when no follower cares about CRDT. */
94
+ hasCrdtForwarders(): boolean;
95
+ }
96
+ /** Internal CRDT key format: `${entity}\x00${rowId}`. Centralized so
97
+ * the engine's binary-frame parser (and any future routing logic)
98
+ * agrees with the coordinator on the wire shape. */
99
+ export declare function crdtKey(entity: string, rowId: string): string;
@@ -0,0 +1,56 @@
1
+ import { SyncEngine } from "../index";
2
+ import { TestServer, type VisibilityFilter } from "./server";
3
+ import { type TransportHandle } from "./transport";
4
+ export interface CreateTestEnvOptions {
5
+ /** Override visibility per-entity (tenant scoping, RLS, etc.). */
6
+ visible?: VisibilityFilter;
7
+ /** Override the appName the engine uses for storage keys. */
8
+ appName?: string;
9
+ /** Mid-fetch hook fired before /api/entities/<E>/cursor responds.
10
+ * Lets a scenario flip server state to drive the reconcile
11
+ * session-guard race. */
12
+ beforeListEntityRows?: import("./server").BeforeListEntityHook;
13
+ /** Mid-fetch hook fired before /api/sync/pull responds. */
14
+ beforePull?: import("./server").BeforePullHook;
15
+ /** Field projector — strips serverOnly-style fields before the
16
+ * row leaves the server. */
17
+ projectRow?: import("./server").FieldProjector;
18
+ /** Transport mode passed to the engine. Default "websocket". Use
19
+ * "poll" to disable the WS-onopen reconcile race in scenarios
20
+ * that only want to pin the in-start pipeline. */
21
+ transport?: "websocket" | "poll" | "sse";
22
+ /** Base delay (ms) for WS reconnect backoff. The default value used
23
+ * inside the harness (`undefined` → 1000) introduces seconds of
24
+ * wall-clock waiting on every reconnect scenario; pass a small
25
+ * value when the test specifically exercises the reconnect path. */
26
+ reconnectDelay?: number;
27
+ }
28
+ export interface TestEnv {
29
+ server: TestServer;
30
+ engine: SyncEngine;
31
+ transport: TransportHandle;
32
+ /** Token of the most recent signIn() call. */
33
+ token: string | undefined;
34
+ /** Mint a session on the server AND tell the transport to send it
35
+ * as Authorization: Bearer on future requests. Returns the token. */
36
+ signIn(input: {
37
+ userId: string | null;
38
+ tenantId?: string | null;
39
+ isAdmin?: boolean;
40
+ roles?: string[];
41
+ }): string;
42
+ /** Re-stamp tenant on the active token (mirrors select-org). */
43
+ selectOrg(tenantId: string | null): void;
44
+ /** Sign out: drops the active token. The engine still has its
45
+ * cached resolved session until something forces a refresh. */
46
+ signOut(): void;
47
+ /** Boot the engine and wait for the initial pull + hydration. */
48
+ start(): Promise<void>;
49
+ /** Drain pending microtasks + give the engine a moment to react
50
+ * to recent events (WS messages, session-changed envelopes, etc.).
51
+ * Most scenarios call this between mutations + assertions. */
52
+ flush(ms?: number): Promise<void>;
53
+ /** Tear down: stops the engine, restores global fetch/WebSocket. */
54
+ dispose(): Promise<void>;
55
+ }
56
+ export declare function createTestEnv(opts?: CreateTestEnvOptions): TestEnv;
@@ -0,0 +1,5 @@
1
+ export { createTestEnv } from "./env";
2
+ export type { CreateTestEnvOptions, TestEnv } from "./env";
3
+ export { TestServer, defaultVisibilityFilter } from "./server";
4
+ export type { AuthContext, ServerSession, TestServerOptions, VisibilityFilter, } from "./server";
5
+ export type { TransportHandle } from "./transport";
@@ -0,0 +1,178 @@
1
+ import type { ChangeEvent, Row, SyncCursor } from "../types";
2
+ export interface ServerSession {
3
+ token: string;
4
+ userId: string | null;
5
+ tenantId: string | null;
6
+ isAdmin: boolean;
7
+ roles: string[];
8
+ }
9
+ export interface AuthContext {
10
+ userId: string | null;
11
+ tenantId: string | null;
12
+ isAdmin: boolean;
13
+ roles: string[];
14
+ }
15
+ export interface VisibilityFilter {
16
+ /** Return the subset of `rows` the given auth context can see for
17
+ * this entity. Default: every row, so tests that don't care about
18
+ * policy just pass. Tests that DO care can pass a custom filter to
19
+ * exercise the "session changed mid-fetch" path. */
20
+ (entity: string, rows: Row[], auth: AuthContext): Row[];
21
+ }
22
+ export declare const defaultVisibilityFilter: VisibilityFilter;
23
+ /** Hook fired right before `/api/entities/<E>/cursor` serializes its
24
+ * response. Lets a scenario flip server state (e.g., `setTenant`) so
25
+ * the engine sees a different session signature on apply than on
26
+ * fetch — the canonical race the reconcile session-guard pins.
27
+ * Receives the same auth context the visibility filter uses.
28
+ * Returning a Promise makes the fetch await — useful for landing a
29
+ * session refresh in flight before the response serializes. */
30
+ export type BeforeListEntityHook = (entity: string, auth: AuthContext) => void | Promise<void>;
31
+ /** Hook fired right before `/api/sync/pull` serializes its response.
32
+ * Same role as `beforeListEntityRows` but on the pull path. */
33
+ export type BeforePullHook = (auth: AuthContext, since: number) => void | Promise<void>;
34
+ /** Field projector — strips fields from a row before it leaves the
35
+ * server. Models the `serverOnly` projection in production: the
36
+ * field exists in the canonical row but is never wired to a client. */
37
+ export type FieldProjector = (entity: string, row: Row) => Row;
38
+ export declare const identityProjector: FieldProjector;
39
+ export interface TestServerOptions {
40
+ /** Override visibility per-entity (tenant scoping, RLS, etc.). */
41
+ visible?: VisibilityFilter;
42
+ /** Mid-fetch hook for the entity-cursor path. */
43
+ beforeListEntityRows?: BeforeListEntityHook;
44
+ /** Mid-fetch hook for the sync-pull path. */
45
+ beforePull?: BeforePullHook;
46
+ /** Strip fields from rows on the way to the wire (mimics serverOnly). */
47
+ projectRow?: FieldProjector;
48
+ }
49
+ /** Subscribers attached to WS connections — receive every change
50
+ * event we append to the log, plus session-changed envelopes. */
51
+ export type ServerSubscriber = (msg: Record<string, unknown>) => void;
52
+ export declare class TestServer {
53
+ private sessions;
54
+ private rows;
55
+ private log;
56
+ private subscribers;
57
+ private visible;
58
+ private beforeListEntityHook?;
59
+ private beforePullHook?;
60
+ private project;
61
+ private nextSeq;
62
+ /** When set, the next pull() returns this status instead of normal.
63
+ * Used to simulate 410 RESYNC_REQUIRED and similar transient errors. */
64
+ private nextPullStatus;
65
+ /** When true, every DELTA pull (since > 0) 410s, but a snapshot pull
66
+ * (since = 0) succeeds. Simulates a horizontally-scaled deployment
67
+ * where a client bounces between instances whose in-memory change
68
+ * logs diverge (no shared persistent log) — every cursor is "stale"
69
+ * on the instance it lands on. This is the condition that drove the
70
+ * 280GB egress storm; the test asserts the client backs off instead
71
+ * of re-snapshotting forever. */
72
+ force410OnDelta: boolean;
73
+ /** Count of snapshot pulls served (since = 0). The egress storm was a
74
+ * runaway count here; the regression test bounds it. */
75
+ snapshotPullCount: number;
76
+ /** Count of /api/sync/push requests received. Lets a test assert the
77
+ * engine actually shipped a batch (e.g. hydrated offline writes that
78
+ * must drain once leader-elected), independent of the no-op push
79
+ * response the harness returns. */
80
+ pushRequestCount: number;
81
+ /** `${entity}/${row_id}` of every op the engine pushed, across all
82
+ * push requests. Lets a test assert a SPECIFIC mutation reached the
83
+ * server — robust against a stray retry from an unrelated engine
84
+ * whose pending timer fires against the globally-installed fetch
85
+ * mock (that pushes ITS ops, never this test's row). */
86
+ readonly receivedPushKeys: string[];
87
+ /** Captured outbound WS messages from clients — tests assert against
88
+ * this to verify `reactive-subscribe`, `crdt-subscribe`, etc., were
89
+ * actually sent over the wire. */
90
+ readonly receivedWsMessages: Array<{
91
+ userId: string | null;
92
+ msg: unknown;
93
+ }>;
94
+ constructor(opts?: TestServerOptions);
95
+ /** Mint a session and return its bearer token. */
96
+ signIn(input: {
97
+ userId: string | null;
98
+ tenantId?: string | null;
99
+ isAdmin?: boolean;
100
+ roles?: string[];
101
+ }): string;
102
+ /** Re-stamp the tenant on an existing token (analogue of
103
+ * /api/auth/select-org). Fires session-changed to subscribers so
104
+ * the client can refresh its resolved session. */
105
+ setTenant(token: string, tenantId: string | null): void;
106
+ /** Mutate fields on an existing session in place. Used by scenarios
107
+ * that need to change the sessionSignature without triggering the
108
+ * engine's tenant-flip resetReplica (e.g., role changes). */
109
+ mutateSession(token: string, patch: Partial<Omit<ServerSession, "token">>): void;
110
+ /** Revoke a session (drop the token from the map). Used by signOut
111
+ * scenarios — subsequent /api/auth/me returns anonymous. */
112
+ revoke(token: string): void;
113
+ /** What /api/auth/me returns for a given token. */
114
+ authContextFor(token: string | undefined): AuthContext;
115
+ /** Inject a 410 into the next pull. Engine should resetReplica and
116
+ * re-pull from seq=0; the second pull responds normally. */
117
+ primeNextPullStatus(status: number): void;
118
+ consumeNextPullStatus(): number | null;
119
+ /** Inject a one-shot outcome for the NEXT push: a `network` failure
120
+ * (the fetch rejects with no status — simulating offline / connection
121
+ * reset) or an HTTP `status` (e.g. 403 permanent rejection, 503
122
+ * transient). Lets tests exercise the transient-vs-permanent
123
+ * classification in pushInner. */
124
+ private nextPushOutcome;
125
+ primeNextPushOutcome(o: {
126
+ kind: "network";
127
+ } | {
128
+ kind: "status";
129
+ status: number;
130
+ }): void;
131
+ consumeNextPushOutcome(): {
132
+ kind: "network";
133
+ } | {
134
+ kind: "status";
135
+ status: number;
136
+ } | null;
137
+ /** Make the NEXT delta pull report `has_more: true` once, exercising
138
+ * the change-log tail-pull recursion in pullInner (the path that
139
+ * self-deadlocked before the pullInner() fix). */
140
+ private nextPullHasMore;
141
+ primeNextPullHasMore(): void;
142
+ consumeNextPullHasMore(): boolean;
143
+ /** Bulk-seed rows for an entity AND emit insert events into the
144
+ * change log so the engine discovers them via /api/sync/pull at
145
+ * since=0 — same shape production clients see when they boot
146
+ * against a populated server. Without logging, the rows would only
147
+ * be discoverable via reconcile, which doesn't fire on start. */
148
+ seed(entity: string, rows: Row[]): void;
149
+ insert(entity: string, row: Row): void;
150
+ update(entity: string, id: string, patch: Partial<Row>): void;
151
+ delete(entity: string, id: string): void;
152
+ /** Raw seq bump for tests that want to inject events directly. */
153
+ nextSeqValue(): number;
154
+ /** /api/entities/<entity>/cursor — policy-filtered list.
155
+ * Async so the `beforeListEntityRows` hook can await state changes
156
+ * (e.g., land a session refresh mid-fetch). Auth is re-read AFTER
157
+ * the hook so a hook that flips roles / tenant takes effect on
158
+ * the response the engine sees, matching what a real server does
159
+ * when the session mutates mid-request. */
160
+ listEntityRows(entity: string, token: string | undefined): Promise<Row[]>;
161
+ /** /api/sync/pull — every visible change since `since`. */
162
+ pull(token: string | undefined, since: number): Promise<{
163
+ changes: ChangeEvent[];
164
+ cursor: SyncCursor;
165
+ has_more: boolean;
166
+ }>;
167
+ subscribe(userId: string, sub: ServerSubscriber): () => void;
168
+ /** Push an arbitrary envelope to every subscriber for a user — used
169
+ * for `reactive-result`, `row-revoked`, `session-changed` etc. */
170
+ pushToUser(userId: string, msg: Record<string, unknown>): void;
171
+ /** Record an outbound WS message from a client (subscribe / unsub /
172
+ * ping). Tests can assert against `receivedWsMessages` to verify
173
+ * the engine actually sent something over the wire. */
174
+ recordClientWsMessage(token: string | undefined, msg: unknown): void;
175
+ private broadcastToUser;
176
+ private bumpSeq;
177
+ private appendLog;
178
+ }
@@ -0,0 +1,19 @@
1
+ import type { TestServer } from "./server";
2
+ export interface TransportHandle {
3
+ /** Token the engine is currently sending as Authorization: Bearer */
4
+ currentToken: () => string | undefined;
5
+ /** Set / clear the active token (mirrors localStorage flips). */
6
+ setToken: (token: string | undefined) => void;
7
+ /** Number of `fetch` calls observed — assert against this in
8
+ * tests that need to confirm "nothing more was requested." */
9
+ fetchCount: () => number;
10
+ /** Number of WS connections opened so far. */
11
+ wsConnectCount: () => number;
12
+ /** Close the most-recently-opened mock WS so the engine sees a
13
+ * disconnect and schedules reconnect via its backoff. Returns
14
+ * true if a socket was actually closed, false otherwise. */
15
+ closeLatestWs: () => boolean;
16
+ /** Tear down the global stubs. */
17
+ restore: () => void;
18
+ }
19
+ export declare function installTransport(server: TestServer): TransportHandle;