@ultimat3/realtime 1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/package.json +36 -0
  4. package/src/change-buffer.ts +69 -0
  5. package/src/changefeed-env.ts +146 -0
  6. package/src/changefeed.ts +191 -0
  7. package/src/channel.ts +181 -0
  8. package/src/client.ts +439 -0
  9. package/src/cursor.ts +188 -0
  10. package/src/errors.ts +253 -0
  11. package/src/fanout.ts +159 -0
  12. package/src/hooks.ts +230 -0
  13. package/src/index.ts +328 -0
  14. package/src/json.ts +76 -0
  15. package/src/live-definition.ts +144 -0
  16. package/src/live-query.ts +449 -0
  17. package/src/local-store.ts +188 -0
  18. package/src/matcher-bridge.ts +169 -0
  19. package/src/nats-commands.ts +97 -0
  20. package/src/nats-connection-fixture.ts +105 -0
  21. package/src/nats-connection.ts +464 -0
  22. package/src/nats-fake.ts +431 -0
  23. package/src/nats-jetstream.ts +226 -0
  24. package/src/nats-kv.ts +157 -0
  25. package/src/nats-protocol.ts +222 -0
  26. package/src/nats-socket.ts +236 -0
  27. package/src/nats-transport.ts +257 -0
  28. package/src/offline-queue.ts +206 -0
  29. package/src/pg-advisory-lock.ts +98 -0
  30. package/src/pg-auth.ts +300 -0
  31. package/src/pg-bytes.ts +185 -0
  32. package/src/pg-connection-fixture.ts +215 -0
  33. package/src/pg-connection.ts +337 -0
  34. package/src/pg-entity-row.ts +130 -0
  35. package/src/pg-replication-fixture.ts +261 -0
  36. package/src/pg-replication.ts +396 -0
  37. package/src/pg-socket.ts +265 -0
  38. package/src/pg-wire.ts +192 -0
  39. package/src/pgoutput.ts +297 -0
  40. package/src/policy-gate.ts +56 -0
  41. package/src/presence.ts +219 -0
  42. package/src/rebase.ts +198 -0
  43. package/src/replicator.ts +185 -0
  44. package/src/socket.ts +208 -0
  45. package/src/sync-node.ts +400 -0
  46. package/src/sync-protocol.ts +376 -0
  47. package/src/thundering-herd.ts +141 -0
  48. package/src/transport-env.ts +104 -0
@@ -0,0 +1,400 @@
1
+ // The `sync` role. Accepts WS connections, routes frames, drains gracefully.
2
+ //
3
+ // Stateless by construction: the only per-node memory is the socket table. No sticky sessions — a
4
+ // client may reconnect to any node and resume from its cursor, which is why drain is allowed to
5
+ // redistribute connections at all.
6
+
7
+ import {
8
+ type Clock,
9
+ healthzPayload,
10
+ logger,
11
+ markListening,
12
+ markReady,
13
+ onShutdown,
14
+ readyzPayload,
15
+ systemClock,
16
+ uuid,
17
+ } from '@ultimat3/core';
18
+ import type { ChannelHub, Topic } from './channel';
19
+ import { topic as makeTopic } from './channel';
20
+ import type { Transport, TransportSubscription } from './fanout';
21
+ import type { JsonValue, Row } from './json';
22
+ import type { LiveQueryRegistry } from './live-query';
23
+ import { type PresenceRegistry, presenceFrame } from './presence';
24
+ import { CHANGE_SUBJECT_PREFIX, parseChange } from './replicator';
25
+ import { CLOSE, SocketRegistry, SyncSocket, type WsLike } from './socket';
26
+ import { decode, type Frame, PROTOCOL_VERSION, toWireError } from './sync-protocol';
27
+ import { AcceptBudget, drainPlan, type Rng, reconnectFrame } from './thundering-herd';
28
+
29
+ export interface WsData {
30
+ readonly socketId: string;
31
+ readonly clientBuildId: string;
32
+ readonly actorId: string | null;
33
+ }
34
+
35
+ export type SyncWs = WsLike & { readonly data: WsData };
36
+
37
+ /** Server-authoritative mutation execution. Injected: `sync` never owns business logic. */
38
+ export type MutationHandler = (args: {
39
+ socket: SyncSocket;
40
+ name: string;
41
+ key: string;
42
+ seq: number;
43
+ input: JsonValue;
44
+ }) => Promise<{ lsn?: string | null; entity?: string; row?: Row | null }>;
45
+
46
+ export interface SyncNodeOptions {
47
+ readonly hub: ChannelHub;
48
+ readonly registry: LiveQueryRegistry;
49
+ readonly transport: Transport;
50
+ readonly buildId: string;
51
+ readonly presence?: PresenceRegistry;
52
+ readonly sockets?: SocketRegistry;
53
+ readonly accept?: AcceptBudget;
54
+ readonly onMutate?: MutationHandler;
55
+ readonly clock?: Clock;
56
+ readonly rng?: Rng;
57
+ /** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
58
+ readonly path?: string;
59
+ readonly drainSpreadMs?: number;
60
+ }
61
+
62
+ /** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
63
+ export interface UpgradeTarget {
64
+ upgrade(request: Request, options: { data: WsData }): boolean;
65
+ }
66
+
67
+ export interface SyncNode {
68
+ readonly sockets: SocketRegistry;
69
+ readonly ready: boolean;
70
+ start(): Promise<void>;
71
+ stop(): Promise<void>;
72
+ fetch(request: Request, server: UpgradeTarget): Response | undefined;
73
+ readonly websocket: {
74
+ idleTimeout: number;
75
+ backpressureLimit: number;
76
+ publishToSelf: boolean;
77
+ sendPings: boolean;
78
+ open(ws: SyncWs): void;
79
+ message(ws: SyncWs, message: string | Uint8Array): void;
80
+ close(ws: SyncWs): void;
81
+ };
82
+ /** Sends every client a distinct reconnect delay, then closes. Returns the plan for tests/logs. */
83
+ drain(options?: { graceMs?: number }): Promise<readonly { socketId: string; afterMs: number }[]>;
84
+ }
85
+
86
+ export function createSyncNode(options: SyncNodeOptions): SyncNode {
87
+ const sockets =
88
+ options.sockets ?? new SocketRegistry({ ...(options.clock ? { clock: options.clock } : {}) });
89
+ const clock = options.clock ?? systemClock;
90
+ const accept = options.accept ?? new AcceptBudget({ perSecond: 500, burst: 2000, clock });
91
+ const path = options.path ?? '/_x/sync';
92
+ const presence = options.presence;
93
+ let ready = false;
94
+ let changes: TransportSubscription | null = null;
95
+ let sweeping: ReturnType<typeof setInterval> | null = null;
96
+
97
+ /**
98
+ * Presence work nobody is waiting on — a leave from a synchronous close, a sweep on a timer.
99
+ * It reaches the bus, so it can fail; failing must not take a socket or the process with it,
100
+ * and must not be silent either, or "the room still shows someone who left" has nothing to read.
101
+ */
102
+ const detach = (work: Promise<unknown>, at: string): void => {
103
+ void work.catch((error: unknown) => {
104
+ logger.error('presence failed', {
105
+ at,
106
+ error: error instanceof Error ? error.message : String(error),
107
+ });
108
+ });
109
+ };
110
+
111
+ const routeFrame = async (socket: SyncSocket, frame: Frame): Promise<void> => {
112
+ socket.touch();
113
+ switch (frame.type) {
114
+ case 'hello': {
115
+ socket.send({
116
+ type: 'hello',
117
+ v: PROTOCOL_VERSION,
118
+ buildId: options.buildId,
119
+ sessionId: socket.id,
120
+ actorId: socket.actorId,
121
+ resume: [],
122
+ });
123
+ if (socket.skewed) {
124
+ socket.send({ type: 'update-available', v: PROTOCOL_VERSION, buildId: options.buildId });
125
+ }
126
+ return;
127
+ }
128
+ case 'subscribe': {
129
+ if (frame.target.kind === 'topic') {
130
+ const name = makeTopic(...frame.target.topic.split('.'));
131
+ if (frame.op === 'drop') {
132
+ options.hub.unsubscribe(socket, name);
133
+ if (presence) await presence.leave(name, socket.id);
134
+ return;
135
+ }
136
+ await options.hub.subscribe(socket, name);
137
+ // Subscribing to a topic IS joining its presence set: presence has no frame of its own,
138
+ // so a second round trip saying "and I am here" would be a second way to do one thing,
139
+ // and a client that skipped it would be invisible in a room it is receiving from.
140
+ // Repeating the frame is therefore also the heartbeat — `join` re-`put`s the member.
141
+ if (presence) {
142
+ const members = await presence.join(name, { id: socket.id, actorId: socket.actorId });
143
+ socket.send(presenceFrame(name, 'sync', members));
144
+ }
145
+ return;
146
+ }
147
+ if (frame.op === 'drop') {
148
+ options.registry.unsubscribe(frame.sid);
149
+ return;
150
+ }
151
+ const { frame: reply } = await options.registry.subscribe({
152
+ socket,
153
+ name: frame.target.qid,
154
+ input: frame.target.input,
155
+ sid: frame.sid,
156
+ cursor: frame.target.cursor,
157
+ });
158
+ socket.send(reply);
159
+ return;
160
+ }
161
+ case 'mutate': {
162
+ if (!options.onMutate) {
163
+ socket.send({
164
+ type: 'ack',
165
+ v: PROTOCOL_VERSION,
166
+ ref: frame.key,
167
+ lsn: null,
168
+ error: toWireError({
169
+ code: 'X_NOT_IMPLEMENTED',
170
+ cause: 'this sync node was started without a mutation handler',
171
+ fix: 'pass onMutate to createSyncNode({ onMutate })',
172
+ }),
173
+ });
174
+ return;
175
+ }
176
+ const result = await options.onMutate({
177
+ socket,
178
+ name: frame.name,
179
+ key: frame.key,
180
+ seq: frame.seq,
181
+ input: frame.input,
182
+ });
183
+ socket.send({
184
+ type: 'ack',
185
+ v: PROTOCOL_VERSION,
186
+ ref: frame.key,
187
+ lsn: result.lsn ?? null,
188
+ error: null,
189
+ });
190
+ if (result.entity !== undefined) {
191
+ socket.send({
192
+ type: 'rebase',
193
+ v: PROTOCOL_VERSION,
194
+ key: frame.key,
195
+ entity: result.entity,
196
+ strategy: 'server-wins',
197
+ row: result.row ?? null,
198
+ });
199
+ }
200
+ return;
201
+ }
202
+ // Server-authored frames are never received from a client.
203
+ case 'snapshot':
204
+ case 'patch':
205
+ case 'ack':
206
+ case 'rebase':
207
+ case 'presence':
208
+ case 'reconnect':
209
+ case 'update-available':
210
+ return;
211
+ }
212
+ };
213
+
214
+ return {
215
+ sockets,
216
+
217
+ get ready(): boolean {
218
+ return ready;
219
+ },
220
+
221
+ async start(): Promise<void> {
222
+ changes = await options.transport.subscribe(`${CHANGE_SUBJECT_PREFIX}.>`, (payload) => {
223
+ const change = parseChange(payload);
224
+ if (change) void options.registry.deliver(change);
225
+ });
226
+ // One pass per heartbeat window: a member is swept only once it has actually missed its
227
+ // window, and the interval never holds the process open — shutdown is the drain's job.
228
+ if (presence) {
229
+ sweeping = setInterval(() => detach(presence.sweepAll(), 'sweep'), presence.heartbeatMs);
230
+ sweeping.unref();
231
+ }
232
+ ready = true;
233
+ markReady();
234
+ logger.info('sync node ready', { buildId: options.buildId, path });
235
+ },
236
+
237
+ async stop(): Promise<void> {
238
+ ready = false;
239
+ changes?.unsubscribe();
240
+ changes = null;
241
+ if (sweeping !== null) clearInterval(sweeping);
242
+ sweeping = null;
243
+ },
244
+
245
+ fetch(request: Request, server: UpgradeTarget): Response | undefined {
246
+ const url = new URL(request.url);
247
+ // Health is the process's, readiness is this node's: a draining node stays healthy while it
248
+ // hands its sockets to the rest of the fleet.
249
+ if (url.pathname === '/healthz') return json(healthzPayload());
250
+ if (url.pathname === '/readyz') {
251
+ const payload = readyzPayload();
252
+ return ready ? json(payload) : json({ status: 503, body: payload.body });
253
+ }
254
+ if (url.pathname !== path) return new Response('not found', { status: 404 });
255
+ if (!ready || !accept.tryAccept()) {
256
+ // Load shedding with a delay attached: refusing without one just moves the herd next door.
257
+ return new Response('retry', {
258
+ status: 503,
259
+ headers: { 'retry-after-ms': String(accept.retryAfterMs(options.rng ?? Math.random)) },
260
+ });
261
+ }
262
+ const data: WsData = {
263
+ socketId: uuid(),
264
+ clientBuildId: url.searchParams.get('build') ?? options.buildId,
265
+ actorId: null,
266
+ };
267
+ return server.upgrade(request, { data })
268
+ ? undefined
269
+ : new Response('expected websocket', { status: 426 });
270
+ },
271
+
272
+ websocket: {
273
+ idleTimeout: 120,
274
+ backpressureLimit: 1024 * 1024,
275
+ publishToSelf: false,
276
+ sendPings: true,
277
+
278
+ open(ws: SyncWs): void {
279
+ const socket = new SyncSocket({
280
+ ws,
281
+ id: ws.data.socketId,
282
+ clientBuildId: ws.data.clientBuildId,
283
+ serverBuildId: options.buildId,
284
+ clock,
285
+ });
286
+ sockets.add(socket);
287
+ },
288
+
289
+ message(ws: SyncWs, message: string | Uint8Array): void {
290
+ const socket = sockets.get(ws.data.socketId);
291
+ if (!socket) return;
292
+ void (async () => {
293
+ try {
294
+ await routeFrame(socket, decode(message));
295
+ } catch (error) {
296
+ socket.send({
297
+ type: 'ack',
298
+ v: PROTOCOL_VERSION,
299
+ ref: ws.data.socketId,
300
+ lsn: null,
301
+ error: toWireError(error),
302
+ });
303
+ }
304
+ })();
305
+ },
306
+
307
+ close(ws: SyncWs): void {
308
+ const socket = sockets.get(ws.data.socketId);
309
+ if (!socket) return;
310
+ options.registry.unsubscribeSocket(socket.id);
311
+ const topics = [...socket.topics] as Topic[];
312
+ for (const name of topics) options.hub.unsubscribe(socket, name);
313
+ sockets.remove(socket.id);
314
+ // A closed socket is a leave, said now rather than left to TTL: everyone else would
315
+ // otherwise keep rendering a member who is provably gone for the rest of its window. The
316
+ // write is on the bus, and this callback is synchronous, so it cannot be awaited here.
317
+ if (presence) for (const name of topics) detach(presence.leave(name, socket.id), name);
318
+ },
319
+ },
320
+
321
+ async drain(drainOptions = {}): Promise<readonly { socketId: string; afterMs: number }[]> {
322
+ ready = false;
323
+ const ids = [...sockets.all()].map((socket) => socket.id);
324
+ const plan = drainPlan(ids, {
325
+ spreadMs: options.drainSpreadMs ?? 30_000,
326
+ ...(options.rng ? { rng: options.rng } : {}),
327
+ });
328
+ for (const entry of plan) {
329
+ sockets.get(entry.socketId)?.send(reconnectFrame(entry.afterMs, 'drain'));
330
+ }
331
+ const graceMs = drainOptions.graceMs ?? 5_000;
332
+ if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
333
+ for (const socket of [...sockets.all()]) {
334
+ socket.close(CLOSE.goingAway, 'drain');
335
+ sockets.remove(socket.id);
336
+ }
337
+ await options.hub.close();
338
+ return plan;
339
+ },
340
+ };
341
+ }
342
+
343
+ export interface ListenOptions {
344
+ readonly port?: number;
345
+ }
346
+
347
+ export interface SyncListener {
348
+ /** The bound websocket origin, e.g. `ws://localhost:3001`. With `port: 0` only the OS knows it. */
349
+ readonly url: string;
350
+ stop(): void;
351
+ }
352
+
353
+ /**
354
+ * Binds the node to `Bun.serve` and wires SIGTERM to `drain()`. Kept tiny so the node itself stays
355
+ * testable without a server.
356
+ */
357
+ export function listenSyncNode(node: SyncNode, options: ListenOptions = {}): SyncListener {
358
+ const server = Bun.serve({
359
+ port: options.port ?? 3001,
360
+ fetch: node.fetch,
361
+ websocket: node.websocket,
362
+ });
363
+ // Same rule as @ultimat3/http: every socket the framework opens announces itself, so a request
364
+ // back to it is recognisably this process calling itself rather than egress.
365
+ const stopListening = markListening(server.url.origin);
366
+ // Unregistered by `stop()`: a hook left behind after the listener is gone drains a node that is
367
+ // already stopped, and the next process-wide shutdown hangs on it.
368
+ const unregister = onShutdown('realtime:sync', async () => {
369
+ await node.drain();
370
+ await node.stop();
371
+ server.stop();
372
+ stopListening();
373
+ });
374
+ return {
375
+ url: websocketOrigin(server.url),
376
+ stop: () => {
377
+ unregister();
378
+ server.stop();
379
+ stopListening();
380
+ },
381
+ };
382
+ }
383
+
384
+ /**
385
+ * The listener reports where it actually landed: a caller asking for `port: 0` cannot guess the
386
+ * port, and a guessed URL is a client that connects to someone else. Swapped on the URL's
387
+ * protocol, never on the string — a hostname is allowed to contain "http".
388
+ */
389
+ function websocketOrigin(url: URL): string {
390
+ const ws = new URL(url);
391
+ ws.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
392
+ return ws.origin;
393
+ }
394
+
395
+ function json(payload: { status: number; body: unknown }): Response {
396
+ return new Response(JSON.stringify(payload.body), {
397
+ status: payload.status,
398
+ headers: { 'content-type': 'application/json' },
399
+ });
400
+ }