@pylonsync/sync 0.3.291 → 0.3.293
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ids.d.ts +14 -0
- package/dist/index.d.ts +949 -0
- package/dist/local-store.d.ts +186 -0
- package/dist/multi-tab-orchestrator.d.ts +141 -0
- package/dist/multi-tab.d.ts +70 -0
- package/dist/mutation-queue.d.ts +88 -0
- package/dist/op-queue.d.ts +18 -0
- package/dist/persistence.d.ts +114 -0
- package/dist/room-subscriptions.d.ts +113 -0
- package/dist/server-subscriptions.d.ts +26 -0
- package/dist/session-resolver.d.ts +68 -0
- package/dist/storage.d.ts +30 -0
- package/dist/subscription-coordinator.d.ts +99 -0
- package/dist/test-harness/env.d.ts +56 -0
- package/dist/test-harness/index.d.ts +5 -0
- package/dist/test-harness/server.d.ts +178 -0
- package/dist/test-harness/transport.d.ts +19 -0
- package/dist/transport.d.ts +89 -0
- package/dist/transports/index.d.ts +19 -0
- package/dist/transports/polling.d.ts +15 -0
- package/dist/transports/reconnect.d.ts +20 -0
- package/dist/transports/sse.d.ts +22 -0
- package/dist/transports/types.d.ts +97 -0
- package/dist/transports/websocket.d.ts +21 -0
- package/dist/types.d.ts +124 -0
- package/package.json +11 -5
- package/tsconfig.json +0 -7
|
@@ -0,0 +1,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;
|