@ultimat3/realtime 1.2.0 → 2.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 +591 -0
  2. package/README.md +320 -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 +174 -19
  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 +96 -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 +151 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +170 -14
  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 +284 -243
  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,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
+ }