@ultimat3/realtime 1.2.0 → 3.0.0

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.
Files changed (61) hide show
  1. package/CLAUDE.md +641 -0
  2. package/README.md +336 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +202 -20
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +99 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +324 -248
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
@@ -0,0 +1,237 @@
1
+ // Who holds which subscription, and the composite identity that makes that answerable. A `sid`
2
+ // is CLIENT data — unique only to the socket that chose it — so every lookup here takes the
3
+ // owner too, and the per-socket and per-tenant caps are answered from this book because it is
4
+ // the only thing that knows what exists. Every question it answers is indexed, never scanned.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import { SubscriptionIdTakenError, SubscriptionLimitError } from './errors';
8
+ import type { LiveSubscription } from './live-contract';
9
+ import type { SyncSocket } from './socket';
10
+
11
+ /**
12
+ * A slot taken synchronously at the top of `subscribe` and given back when it has either become a
13
+ * subscription or failed. It exists because every cap here is answered from what the book HOLDS,
14
+ * and a subscribe does not hold anything until three awaits later: one WebSocket write carrying N
15
+ * subscribe frames is dispatched concurrently, so N of them read `size === 0` and every cap is
16
+ * bypassed by batching. Releasing twice is a no-op — the caller's `finally` runs once per path.
17
+ */
18
+ export interface SubscriptionSlot {
19
+ release(): void;
20
+ }
21
+
22
+ /**
23
+ * The identity of one subscription. `\u0000` because a socket id and a sid are both opaque
24
+ * strings and nothing else can appear in one, so no pair of them can collide with another.
25
+ */
26
+ export function subscriptionKey(socketId: string, sid: string): string {
27
+ return `${socketId}\u0000${sid}`;
28
+ }
29
+
30
+ export interface SubscriptionCaps {
31
+ readonly maxPerSocket?: number;
32
+ readonly maxPerTenant?: number;
33
+ readonly tenantOf?: (actor: Actor | null) => string | null;
34
+ }
35
+
36
+ /** Sockets may open this many live queries before `X_SUBSCRIPTION_LIMIT`. */
37
+ export const DEFAULT_MAX_PER_SOCKET = 128;
38
+
39
+ /**
40
+ * Every live subscription on this node, keyed by `(socket, sid)`.
41
+ *
42
+ * Keyed by the sid alone, socket B reusing socket A's sid overwrote A's entry — A's subscription
43
+ * stayed in its query entry's `subscribers` map, unreachable, so `unsubscribeSocket(A)` freed
44
+ * nothing and that entry's matcher and shared window were pinned for the process's life, fanning
45
+ * every change out to a dead socket. A `drop` frame from B likewise ended A's stream with no
46
+ * error either side.
47
+ *
48
+ * **Two secondary indexes, because both of this book's sweeps run once per socket.** `ofSocket`
49
+ * copied the node's whole map and filtered it, so a teardown or a re-auth pass cost
50
+ * `sockets x subscriptions` — 100,000 entries measured at 17.7s of blocking work, with no
51
+ * attacker capability required: a deploy, a network blip or a batch of grants expiring together
52
+ * is the trigger. The per-tenant cap walked the same map on every subscribe FRAME (7.96 ms each
53
+ * at that size), which is one authenticated socket consuming the node. Both are `Map` reads now,
54
+ * maintained in `add`/`delete` — the shape `lru.ts` and `presence.ts` already use.
55
+ */
56
+ export class SubscriptionBook {
57
+ readonly #bySid = new Map<string, LiveSubscription>();
58
+ /** socket id -> its sids. The drop list on close, the retry list on re-auth. */
59
+ readonly #bySocket = new Map<string, Set<string>>();
60
+ /** tenant -> live subscriptions held by its sockets. The per-tenant cap's whole answer. */
61
+ readonly #perTenant = new Map<string, number>();
62
+ /**
63
+ * The tenant each socket's subscriptions were counted under. Remembered rather than re-derived,
64
+ * because `socket.actor` is replaced by a re-auth: deriving it again at `delete` time would
65
+ * decrement a tenant that was never incremented and leave the old one counting forever.
66
+ */
67
+ readonly #tenantOfSocket = new Map<string, string>();
68
+ /** sids a socket has claimed but not yet attached. Empty between subscribes, so it never grows. */
69
+ readonly #claimedBySocket = new Map<string, Set<string>>();
70
+ /** The same claims counted per tenant, because that cap spans sockets and a lane cannot see it. */
71
+ readonly #claimedPerTenant = new Map<string, number>();
72
+ readonly #caps: SubscriptionCaps;
73
+
74
+ constructor(caps: SubscriptionCaps = {}) {
75
+ this.#caps = caps;
76
+ }
77
+
78
+ get(socketId: string, sid: string): LiveSubscription | undefined {
79
+ return this.#bySid.get(subscriptionKey(socketId, sid));
80
+ }
81
+
82
+ has(socketId: string, sid: string): boolean {
83
+ return this.#bySid.has(subscriptionKey(socketId, sid));
84
+ }
85
+
86
+ add(subscription: LiveSubscription): void {
87
+ const socketId = subscription.socket.id;
88
+ const key = subscriptionKey(socketId, subscription.sid);
89
+ // A re-add is the one thing that could double-count a tenant, so it is refused here rather
90
+ // than relied on not to happen: `subscribe` already answers `X_SUBSCRIPTION_ID_TAKEN`.
91
+ if (this.#bySid.has(key)) return;
92
+ this.#bySid.set(key, subscription);
93
+ const sids = this.#bySocket.get(socketId);
94
+ if (sids) sids.add(subscription.sid);
95
+ else this.#bySocket.set(socketId, new Set([subscription.sid]));
96
+ const tenant = this.#tenantFor(subscription.socket);
97
+ if (tenant === null) return;
98
+ this.#tenantOfSocket.set(socketId, tenant);
99
+ this.#perTenant.set(tenant, (this.#perTenant.get(tenant) ?? 0) + 1);
100
+ }
101
+
102
+ delete(socketId: string, sid: string): void {
103
+ if (!this.#bySid.delete(subscriptionKey(socketId, sid))) return;
104
+ const sids = this.#bySocket.get(socketId);
105
+ sids?.delete(sid);
106
+ const empty = sids === undefined || sids.size === 0;
107
+ if (empty) this.#bySocket.delete(socketId);
108
+ const tenant = this.#tenantOfSocket.get(socketId);
109
+ if (tenant === undefined) return;
110
+ this.#bump(tenant, -1);
111
+ if (empty) this.#tenantOfSocket.delete(socketId);
112
+ }
113
+
114
+ /** A copy, because every caller mutates the book while walking it. */
115
+ all(): readonly LiveSubscription[] {
116
+ return [...this.#bySid.values()];
117
+ }
118
+
119
+ /** One socket's subscriptions — the drop list when it closes, the retry list when it reauths. */
120
+ ofSocket(socketId: string): readonly LiveSubscription[] {
121
+ const sids = this.#bySocket.get(socketId);
122
+ if (!sids) return [];
123
+ const out: LiveSubscription[] = [];
124
+ for (const sid of sids) {
125
+ const subscription = this.#bySid.get(subscriptionKey(socketId, sid));
126
+ if (subscription) out.push(subscription);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** Live subscriptions counted against one tenant. The metric the cap reads. */
132
+ tenantCount(tenant: string): number {
133
+ return this.#perTenant.get(tenant) ?? 0;
134
+ }
135
+
136
+ /**
137
+ * A re-auth moved this socket to another tenant, so its subscriptions move with it. Without
138
+ * this the count the cap reads drifts from the book for the rest of the process — one tenant
139
+ * refused for subscriptions it does not hold, another admitted past its cap.
140
+ */
141
+ retenant(socket: SyncSocket): void {
142
+ const held = this.#bySocket.get(socket.id)?.size ?? 0;
143
+ const before = this.#tenantOfSocket.get(socket.id) ?? null;
144
+ const after = this.#caps.tenantOf?.(socket.actor) ?? null;
145
+ if (before === after) return;
146
+ if (before !== null) this.#bump(before, -held);
147
+ if (after === null) this.#tenantOfSocket.delete(socket.id);
148
+ else {
149
+ this.#tenantOfSocket.set(socket.id, after);
150
+ if (held > 0) this.#perTenant.set(after, (this.#perTenant.get(after) ?? 0) + held);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Refuse a subscribe that would exceed a cap. Load shedding, not a crash: both scopes throw
156
+ * `X_SUBSCRIPTION_LIMIT` naming which one refused, so the fix line points at one knob.
157
+ *
158
+ * Claims count, because the thing being bounded is work that starts before it is held: a
159
+ * subscribe that has passed this check and is awaiting its snapshot has already committed this
160
+ * node to an entry, a matcher and a read.
161
+ */
162
+ assertCapacity(socket: SyncSocket): void {
163
+ const perSocket = this.#caps.maxPerSocket ?? DEFAULT_MAX_PER_SOCKET;
164
+ const claimed = this.#claimedBySocket.get(socket.id)?.size ?? 0;
165
+ if (socket.queries.size + claimed >= perSocket) {
166
+ throw new SubscriptionLimitError({
167
+ scope: 'socket',
168
+ id: socket.id,
169
+ limit: perSocket,
170
+ knob: 'maxPerSocket',
171
+ });
172
+ }
173
+ const perTenant = this.#caps.maxPerTenant;
174
+ const tenant = this.#tenantFor(socket);
175
+ if (perTenant === undefined || tenant === null) return;
176
+ if (this.tenantCount(tenant) + (this.#claimedPerTenant.get(tenant) ?? 0) >= perTenant) {
177
+ throw new SubscriptionLimitError({
178
+ scope: 'tenant',
179
+ id: tenant,
180
+ limit: perTenant,
181
+ knob: 'maxPerTenant',
182
+ });
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Take the slot this subscribe is going to fill — the sid and the two caps — before it awaits
188
+ * anything. Every refusal a subscribe can answer with is decided here, in one synchronous step,
189
+ * so N frames arriving in one write are N decisions against a count that already includes the
190
+ * ones still in flight.
191
+ *
192
+ * The sid is claimed here for the same reason: keyed by `(socket, sid)`, two concurrent frames
193
+ * reusing one sid both passed `has()` and the second attach replaced the first, stranding it
194
+ * inside its query entry where nothing can reach it again. The tenant is captured rather than
195
+ * re-derived — a re-auth may `retenant` this socket while the read is in flight, and the release
196
+ * has to give the slot back to the tenant that took it.
197
+ */
198
+ reserve(socket: SyncSocket, sid: string): SubscriptionSlot {
199
+ const socketId = socket.id;
200
+ if (this.has(socketId, sid) || this.#claimedBySocket.get(socketId)?.has(sid) === true) {
201
+ throw new SubscriptionIdTakenError({ sid, socketId });
202
+ }
203
+ this.assertCapacity(socket);
204
+ const claims = this.#claimedBySocket.get(socketId);
205
+ if (claims) claims.add(sid);
206
+ else this.#claimedBySocket.set(socketId, new Set([sid]));
207
+ const tenant = this.#tenantFor(socket);
208
+ if (tenant !== null) {
209
+ this.#claimedPerTenant.set(tenant, (this.#claimedPerTenant.get(tenant) ?? 0) + 1);
210
+ }
211
+ let released = false;
212
+ return {
213
+ release: (): void => {
214
+ if (released) return;
215
+ released = true;
216
+ const held = this.#claimedBySocket.get(socketId);
217
+ held?.delete(sid);
218
+ if (held !== undefined && held.size === 0) this.#claimedBySocket.delete(socketId);
219
+ if (tenant === null) return;
220
+ const next = (this.#claimedPerTenant.get(tenant) ?? 0) - 1;
221
+ if (next > 0) this.#claimedPerTenant.set(tenant, next);
222
+ else this.#claimedPerTenant.delete(tenant);
223
+ },
224
+ };
225
+ }
226
+
227
+ /** The tenant this socket's subscriptions are counted under: the remembered one, or the actor's. */
228
+ #tenantFor(socket: SyncSocket): string | null {
229
+ return this.#tenantOfSocket.get(socket.id) ?? this.#caps.tenantOf?.(socket.actor) ?? null;
230
+ }
231
+
232
+ #bump(tenant: string, by: number): void {
233
+ const next = (this.#perTenant.get(tenant) ?? 0) + by;
234
+ if (next > 0) this.#perTenant.set(tenant, next);
235
+ else this.#perTenant.delete(tenant);
236
+ }
237
+ }
@@ -0,0 +1,124 @@
1
+ // Who a socket is, and for how long. The `sync` node evaluates no credential of its own — an app
2
+ // supplies `authenticate`, exactly as it supplies `onMutate` — so this file owns the shape of that
3
+ // answer, the per-node book that holds it, and the pass that re-decides one whose window has closed.
4
+
5
+ import type { Actor, Clock } from '@ultimat3/core';
6
+
7
+ /**
8
+ * One connection's identity. A grant, not an `Actor`, because a websocket outlives every credential
9
+ * that opened it: a token with a 15-minute TTL on a socket that stays up for hours is a subscription
10
+ * authorized once and served forever, which is the hole `expiresAt` closes.
11
+ *
12
+ * `refresh` is the app's, and the framework retains no credential of its own for it. That is the
13
+ * whole reason the seam is a closure: re-reading the upgrade `Request` would mean holding one per
14
+ * socket for the life of the connection, and an app that closes over a token string holds the two
15
+ * fields it actually needs. Omit it and an expired grant simply closes the socket — the client
16
+ * re-dials with a fresh credential, which is the safe default and costs one reconnect.
17
+ */
18
+ export interface SyncGrant {
19
+ readonly actor: Actor;
20
+ /** Epoch ms this grant stops being true. Omitted = never re-decided on a clock. */
21
+ readonly expiresAt?: number;
22
+ /** Re-resolve this connection without a reconnect. `null` means the actor is gone. */
23
+ refresh?: () => Promise<SyncGrant | null>;
24
+ }
25
+
26
+ /**
27
+ * The app's answer to "who is dialling". Called once per upgrade, before `server.upgrade`, so a
28
+ * refused credential never costs a websocket. `null` is a decision (nobody may open this socket);
29
+ * a throw is a failure (nothing was decided) — the node answers those differently, because reading
30
+ * an auth backend timeout as a denial is the same class of bug as reading a dead pool as a row
31
+ * policy refusing a row.
32
+ */
33
+ export type SyncAuthenticator = (request: Request) => Promise<SyncGrant | null>;
34
+
35
+ /**
36
+ * The grants of the sockets on this node, keyed by socket id.
37
+ *
38
+ * Off `SyncSocket` on purpose: that object's budget is ~1KB per connection and it is the only
39
+ * per-socket allocation a million-socket node is costed against, so an auth lifetime — a closure,
40
+ * an expiry and an actor — lives beside the socket table instead of inside it. Empty, and costing
41
+ * nothing, on a node with no authenticator.
42
+ */
43
+ export class GrantBook {
44
+ readonly #grants = new Map<string, SyncGrant>();
45
+
46
+ set(socketId: string, grant: SyncGrant): void {
47
+ this.#grants.set(socketId, grant);
48
+ }
49
+
50
+ get(socketId: string): SyncGrant | undefined {
51
+ return this.#grants.get(socketId);
52
+ }
53
+
54
+ delete(socketId: string): void {
55
+ this.#grants.delete(socketId);
56
+ }
57
+
58
+ get size(): number {
59
+ return this.#grants.size;
60
+ }
61
+
62
+ /** Grants whose window has closed. One with no `expiresAt` never appears here. */
63
+ expired(now: number): readonly (readonly [string, SyncGrant])[] {
64
+ const out: (readonly [string, SyncGrant])[] = [];
65
+ for (const [socketId, grant] of this.#grants) {
66
+ if (grant.expiresAt !== undefined && grant.expiresAt <= now) out.push([socketId, grant]);
67
+ }
68
+ return out;
69
+ }
70
+ }
71
+
72
+ export interface GrantSweepDeps {
73
+ readonly grants: GrantBook;
74
+ readonly clock: Clock;
75
+ /** The grant was renewed: re-decide every subscription this socket holds, under the new actor. */
76
+ onActor: (socketId: string, actor: Actor) => Promise<void>;
77
+ /** Nobody may hold this socket any longer. The caller closes it. */
78
+ onRevoked: (socketId: string) => void;
79
+ /** `refresh` raised instead of deciding. The grant is kept and retried on the next pass. */
80
+ onRefreshFailed?: (socketId: string, error: unknown) => void;
81
+ }
82
+
83
+ export interface GrantSweepResult {
84
+ readonly refreshed: number;
85
+ readonly revoked: number;
86
+ readonly failed: number;
87
+ }
88
+
89
+ /**
90
+ * One pass over the expired grants. The clock is injected because a re-auth only provable by
91
+ * sleeping is a re-auth no test proves — the same rule the client's reconnect timer already follows.
92
+ *
93
+ * A `refresh` that raises keeps its grant: a denial and a failure never share an answer here either,
94
+ * and signing every connected user out because the auth backend timed out is a bigger outage than
95
+ * the one it would be responding to. It stays expired, so the next pass retries it — and every
96
+ * failure is reported, because a socket that cannot be re-decided is not a socket anyone should
97
+ * discover from a graph of connection counts.
98
+ */
99
+ export async function sweepGrants(deps: GrantSweepDeps): Promise<GrantSweepResult> {
100
+ const now = deps.clock.now().getTime();
101
+ let refreshed = 0;
102
+ let revoked = 0;
103
+ let failed = 0;
104
+ for (const [socketId, grant] of deps.grants.expired(now)) {
105
+ let next: SyncGrant | null;
106
+ try {
107
+ next = grant.refresh ? await grant.refresh() : null;
108
+ } catch (error) {
109
+ failed += 1;
110
+ deps.onRefreshFailed?.(socketId, error);
111
+ continue;
112
+ }
113
+ if (next === null) {
114
+ deps.grants.delete(socketId);
115
+ deps.onRevoked(socketId);
116
+ revoked += 1;
117
+ continue;
118
+ }
119
+ deps.grants.set(socketId, next);
120
+ await deps.onActor(socketId, next.actor);
121
+ refreshed += 1;
122
+ }
123
+ return { refreshed, revoked, failed };
124
+ }
@@ -0,0 +1,185 @@
1
+ // What a RECEIVED frame does to server state — the mirror of `client-frames.ts`, and the only
2
+ // inbound surface the `sync` node exposes. Every dependency is injected, so the router is
3
+ // exercisable without a socket, a bus or a server.
4
+
5
+ import type { ChannelHub } from './channel';
6
+ import { topic as makeTopic } from './channel';
7
+ import { FrameRateLimitError } from './errors';
8
+ import { FrameLanes, laneKeyOf } from './frame-lanes';
9
+ import type { JsonValue, Row } from './json';
10
+ import type { LiveQueryRegistry } from './live-query';
11
+ import { type PresenceRegistry, presenceFrame } from './presence';
12
+ import type { SyncSocket } from './socket';
13
+ import { type Frame, PROTOCOL_VERSION, toWireError } from './sync-protocol';
14
+
15
+ /** Server-authoritative mutation execution. Injected: `sync` never owns business logic. */
16
+ export type MutationHandler = (args: {
17
+ socket: SyncSocket;
18
+ name: string;
19
+ key: string;
20
+ seq: number;
21
+ input: JsonValue;
22
+ }) => Promise<{ lsn?: string | null; entity?: string; row?: Row | null }>;
23
+
24
+ export interface FrameRouterOptions {
25
+ readonly hub: ChannelHub;
26
+ readonly registry: LiveQueryRegistry;
27
+ readonly buildId: string;
28
+ readonly presence?: PresenceRegistry | undefined;
29
+ readonly onMutate?: MutationHandler | undefined;
30
+ }
31
+
32
+ export type FrameRouter = (socket: SyncSocket, frame: Frame) => Promise<void>;
33
+
34
+ /**
35
+ * What a failure ack refers to. `ack.ref` is how a client finds the thing that failed —
36
+ * `queue.fail(frame.ref)` looks up a mutation by its idempotency key — so an ack built with the
37
+ * SOCKET id names a key no queue can hold and the whole rollback path is inert end to end: the
38
+ * optimistic write stays on screen and the mutation stays queued.
39
+ *
40
+ * The socket id is the answer for a frame nothing could read (a decode failure) and for the kinds
41
+ * that carry no reference of their own, because there is nothing else true to say.
42
+ */
43
+ export function ackRefOf(frame: Frame | null, socketId: string): string {
44
+ if (frame === null) return socketId;
45
+ if (frame.type === 'mutate') return frame.key;
46
+ if (frame.type === 'subscribe') return frame.sid;
47
+ return socketId;
48
+ }
49
+
50
+ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
51
+ const presence = options.presence;
52
+ // Weakly keyed, so one socket's lanes die with it and no close path has to remember them.
53
+ const lanes = new WeakMap<SyncSocket, FrameLanes>();
54
+
55
+ const routeFrame: FrameRouter = async (socket, frame) => {
56
+ // Before `touch()` and before every amplifier below it: a frame this node refuses to route
57
+ // must not also renew the idle window that would otherwise close a flooding socket.
58
+ if (!socket.frameBudget.tryAccept()) {
59
+ throw new FrameRateLimitError({
60
+ socketId: socket.id,
61
+ perSecond: socket.frameBudget.perSecond,
62
+ });
63
+ }
64
+ socket.touch();
65
+ const key = laneKeyOf(frame);
66
+ if (key === null) return await apply(socket, frame);
67
+ // Entered synchronously — an `async` body runs to its first await on the call — so the lane
68
+ // order is the order `sync-node.message` was called in, which is the order the bytes arrived.
69
+ const lane = lanes.get(socket) ?? new FrameLanes();
70
+ lanes.set(socket, lane);
71
+ return await lane.run(key, () => apply(socket, frame));
72
+ };
73
+ return routeFrame;
74
+
75
+ async function apply(socket: SyncSocket, frame: Frame): Promise<void> {
76
+ switch (frame.type) {
77
+ case 'hello': {
78
+ socket.send({
79
+ type: 'hello',
80
+ v: PROTOCOL_VERSION,
81
+ buildId: options.buildId,
82
+ sessionId: socket.id,
83
+ // The actor the upgrade resolved, so a client can render who the server thinks it is
84
+ // rather than who it thinks it sent.
85
+ actorId: socket.actorId,
86
+ });
87
+ if (socket.skewed) {
88
+ socket.send({ type: 'update-available', v: PROTOCOL_VERSION, buildId: options.buildId });
89
+ }
90
+ return;
91
+ }
92
+ case 'subscribe': {
93
+ if (frame.target.kind === 'topic') {
94
+ const name = makeTopic(...frame.target.topic.split('.'));
95
+ if (frame.op === 'drop') {
96
+ options.hub.unsubscribe(socket, name);
97
+ if (presence) await presence.leave(name, socket.id);
98
+ return;
99
+ }
100
+ await options.hub.subscribe(socket, name);
101
+ // Subscribing to a topic IS joining its presence set: presence has no frame of its own,
102
+ // so a second round trip saying "and I am here" would be a second way to do one thing,
103
+ // and a client that skipped it would be invisible in a room it is receiving from.
104
+ // Repeating the frame is therefore also the heartbeat — `join` re-`put`s the member.
105
+ if (presence) {
106
+ const roster = await presence.join(name, { id: socket.id, actorId: socket.actorId });
107
+ socket.send(presenceFrame(name, 'sync', roster.members, roster.total));
108
+ }
109
+ return;
110
+ }
111
+ if (frame.op === 'drop') {
112
+ // Scoped to this socket: a sid is client data, and an unscoped drop let one client
113
+ // end another's live stream by guessing — or reusing — its id.
114
+ options.registry.unsubscribe(socket.id, frame.sid);
115
+ return;
116
+ }
117
+ const { frame: reply } = await options.registry.subscribe({
118
+ socket,
119
+ name: frame.target.qid,
120
+ input: frame.target.input,
121
+ sid: frame.sid,
122
+ cursor: frame.target.cursor,
123
+ });
124
+ socket.send(reply);
125
+ return;
126
+ }
127
+ case 'mutate': {
128
+ if (!options.onMutate) {
129
+ socket.send({
130
+ type: 'ack',
131
+ v: PROTOCOL_VERSION,
132
+ ref: frame.key,
133
+ lsn: null,
134
+ error: toWireError({
135
+ code: 'X_NOT_IMPLEMENTED',
136
+ cause: 'this sync node was started without a mutation handler',
137
+ fix: 'pass onMutate to createSyncNode({ onMutate })',
138
+ }),
139
+ });
140
+ return;
141
+ }
142
+ const result = await options.onMutate({
143
+ socket,
144
+ name: frame.name,
145
+ key: frame.key,
146
+ seq: frame.seq,
147
+ input: frame.input,
148
+ });
149
+ // The rebase FIRST, and the ack last. The ack is the receipt, and a receipt is what
150
+ // retires the client's record of the mutation — its journal row and its rebase-log entry,
151
+ // both of which stay forever otherwise. A rebase that lands after that has no entry left
152
+ // to read the mutator's conflict strategy off (every merge silently becomes server-wins)
153
+ // and no sequence to decide which later optimistic writes to replay over server truth.
154
+ // These are two frames on one socket, so the order is the only coordination there is.
155
+ if (result.entity !== undefined) {
156
+ socket.send({
157
+ type: 'rebase',
158
+ v: PROTOCOL_VERSION,
159
+ key: frame.key,
160
+ entity: result.entity,
161
+ strategy: 'server-wins',
162
+ row: result.row ?? null,
163
+ });
164
+ }
165
+ socket.send({
166
+ type: 'ack',
167
+ v: PROTOCOL_VERSION,
168
+ ref: frame.key,
169
+ lsn: result.lsn ?? null,
170
+ error: null,
171
+ });
172
+ return;
173
+ }
174
+ // Server-authored frames are never received from a client.
175
+ case 'snapshot':
176
+ case 'patch':
177
+ case 'ack':
178
+ case 'rebase':
179
+ case 'presence':
180
+ case 'reconnect':
181
+ case 'update-available':
182
+ return;
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,73 @@
1
+ // Binding a `sync` node to a real socket, and to the process lifecycle. Kept out of `sync-node.ts`
2
+ // so the node itself stays testable with no server: this file is the only place realtime calls
3
+ // `Bun.serve`, and the only thing in the package that knows a port exists.
4
+
5
+ import { markListening, onShutdown } from '@ultimat3/core';
6
+ import type { SyncNode } from './sync-node';
7
+
8
+ export interface ListenOptions {
9
+ readonly port?: number;
10
+ }
11
+
12
+ export interface SyncListener {
13
+ /** The bound websocket origin, e.g. `ws://localhost:3001`. With `port: 0` only the OS knows it. */
14
+ readonly url: string;
15
+ stop(): void;
16
+ }
17
+
18
+ /**
19
+ * Binds the node to `Bun.serve` and wires SIGTERM to `drain()`. Kept tiny so the node itself stays
20
+ * testable without a server.
21
+ */
22
+ export function listenSyncNode(node: SyncNode, options: ListenOptions = {}): SyncListener {
23
+ const server = Bun.serve({
24
+ port: options.port ?? 3001,
25
+ fetch: node.fetch,
26
+ websocket: node.websocket,
27
+ });
28
+ // Same rule as @ultimat3/http: every socket the framework opens announces itself, so a request
29
+ // back to it is recognisably this process calling itself rather than egress.
30
+ const stopListening = markListening(server.url.origin);
31
+ // Two phases, because they answer two different questions. `accept` runs first on SIGTERM: the
32
+ // node stops taking new sockets and `/readyz` flips to 503, so the load balancer routes away
33
+ // while every socket this node already holds keeps its patch stream — registered with no phase,
34
+ // this landed in `close`, and until that last phase ran `fetch` went on upgrading new websockets
35
+ // onto a process that was going away. The same split `@ultimat3/http`, the worker and the
36
+ // scheduler already have.
37
+ //
38
+ // Both are unregistered by `stop()`: a hook left behind after the listener is gone drains a node
39
+ // that is already stopped, and the next process-wide shutdown hangs on it.
40
+ const stopAccepting = onShutdown(
41
+ 'realtime:sync:accept',
42
+ () => {
43
+ node.stopAccepting();
44
+ },
45
+ { phase: 'accept' },
46
+ );
47
+ const unregister = onShutdown('realtime:sync', async () => {
48
+ await node.drain();
49
+ await node.stop();
50
+ server.stop();
51
+ stopListening();
52
+ });
53
+ return {
54
+ url: websocketOrigin(server.url),
55
+ stop: () => {
56
+ stopAccepting();
57
+ unregister();
58
+ server.stop();
59
+ stopListening();
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * The listener reports where it actually landed: a caller asking for `port: 0` cannot guess the
66
+ * port, and a guessed URL is a client that connects to someone else. Swapped on the URL's
67
+ * protocol, never on the string — a hostname is allowed to contain "http".
68
+ */
69
+ function websocketOrigin(url: URL): string {
70
+ const ws = new URL(url);
71
+ ws.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
72
+ return ws.origin;
73
+ }