@ultimat3/realtime 20.2.1 → 22.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 (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -4,52 +4,55 @@
4
4
 
5
5
  import { logger } from '@ultimat3/core';
6
6
  import type { ChannelHub } from './channel';
7
- import { topic as makeTopic } from './channel';
7
+ import type { Topic } from './channel-decl';
8
8
  import { FrameRateLimitError } from './errors';
9
9
  import { FrameLanes, laneKeyOf } from './frame-lanes';
10
- import type { JsonValue, Row } from './json';
11
10
  import type { LiveQueryRegistry } from './live-query';
12
11
  import { type PresenceRegistry, presenceFrame } from './presence';
13
- import { CLOSE, type SyncSocket } from './socket';
14
- import { type Frame, PROTOCOL_VERSION, toWireError } from './sync-protocol';
15
-
16
- /** Server-authoritative mutation execution. Injected: `sync` never owns business logic. */
17
- export type MutationHandler = (args: {
18
- socket: SyncSocket;
19
- name: string;
20
- key: string;
21
- seq: number;
22
- input: JsonValue;
23
- }) => Promise<{ lsn?: string | null; entity?: string; row?: Row | null }>;
12
+ import type { SyncSocket } from './socket';
13
+ import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
24
14
 
25
15
  export interface FrameRouterOptions {
26
16
  readonly hub: ChannelHub;
27
17
  readonly registry: LiveQueryRegistry;
28
18
  readonly buildId: string;
29
19
  readonly presence?: PresenceRegistry | undefined;
30
- readonly onMutate?: MutationHandler | undefined;
31
20
  }
32
21
 
33
22
  export type FrameRouter = (socket: SyncSocket, frame: Frame) => Promise<void>;
34
23
 
35
24
  /**
36
- * What a failure ack refers to. `ack.ref` is how a client finds the thing that failed —
37
- * `queue.fail(frame.ref)` looks up a mutation by its idempotency key — so an ack built with the
38
- * SOCKET id names a key no queue can hold and the whole rollback path is inert end to end: the
39
- * optimistic write stays on screen and the mutation stays queued.
40
- *
41
- * The socket id is the answer for a frame nothing could read (a decode failure) and for the kinds
42
- * that carry no reference of their own, because there is nothing else true to say.
25
+ * What a refusal ack refers to. `ack.ref` is how a client finds the thing that failed — the sid
26
+ * of the subscription it refused, so that one window renders `failed` and no other does. The
27
+ * socket id is the answer for a frame nothing could read (a decode failure) and for the kinds that
28
+ * carry no reference of their own, because there is nothing else true to say.
43
29
  */
44
30
  export function ackRefOf(frame: Frame | null, socketId: string): string {
45
31
  if (frame === null) return socketId;
46
- if (frame.type === 'mutate') return frame.key;
47
32
  if (frame.type === 'subscribe') return frame.sid;
48
33
  return socketId;
49
34
  }
50
35
 
51
36
  export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
52
37
  const presence = options.presence;
38
+ /** Per socket: the topic each channel sid was joined under. Dies with the socket. */
39
+ const channelTopics = new WeakMap<SyncSocket, Map<string, Topic>>();
40
+
41
+ /**
42
+ * Subscribing to a channel declared `events: true` IS joining its presence set: presence has no
43
+ * frame of its own (it rides that channel's `events`), so a second round trip saying "and I am
44
+ * here" would be a second way to do one thing. A records-only channel has no roster. Repeating the
45
+ * frame is therefore also the heartbeat — `join` re-`put`s the member. The roster's answer is
46
+ * read though nothing here can repair it: a dropped roster costs one heartbeat of blank room, and
47
+ * the log is the only trace it leaves anywhere.
48
+ */
49
+ const joinPresence = async (socket: SyncSocket, name: Topic): Promise<void> => {
50
+ if (!presence || options.hub.channelOf(name)?.channel.events !== true) return;
51
+ const roster = await presence.join(name, { id: socket.id, actorId: socket.actorId });
52
+ if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
53
+ logger.warn('sync.presence_roster_dropped', { topic: name, socketId: socket.id });
54
+ }
55
+ };
53
56
  // Weakly keyed, so one socket's lanes die with it and no close path has to remember them.
54
57
  const lanes = new WeakMap<SyncSocket, FrameLanes>();
55
58
 
@@ -94,29 +97,23 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
94
97
  return;
95
98
  }
96
99
  case 'subscribe': {
97
- if (frame.target.kind === 'topic') {
98
- const name = makeTopic(...frame.target.topic.split('.'));
100
+ if (frame.target.kind === 'channel') {
101
+ // The topic is the DECLARATION's to spell (`channel.topic(params)`), so a drop finds it
102
+ // by the sid its add was answered under — never by re-deriving it from client data.
103
+ const topics = channelTopics.get(socket) ?? new Map<string, Topic>();
104
+ channelTopics.set(socket, topics);
99
105
  if (frame.op === 'drop') {
106
+ const name = topics.get(frame.sid);
107
+ if (name === undefined) return;
108
+ topics.delete(frame.sid);
109
+ const events = options.hub.channelOf(name)?.channel.events === true;
100
110
  options.hub.unsubscribe(socket, name);
101
- if (presence) await presence.leave(name, socket.id);
111
+ if (presence && events) await presence.leave(name, socket.id);
102
112
  return;
103
113
  }
104
- await options.hub.subscribe(socket, name);
105
- // Subscribing to a topic IS joining its presence set: presence has no frame of its own,
106
- // so a second round trip saying "and I am here" would be a second way to do one thing,
107
- // and a client that skipped it would be invisible in a room it is receiving from.
108
- // Repeating the frame is therefore also the heartbeat — `join` re-`put`s the member.
109
- if (presence) {
110
- const roster = await presence.join(name, { id: socket.id, actorId: socket.actorId });
111
- // The answer is read even though nothing here can repair it. A roster has no cursor and
112
- // no re-send path of its own: the client renders an empty room until it repeats this
113
- // very frame as its heartbeat, which re-joins and re-rosters. Membership on the shared
114
- // set is already correct, so the drop costs one client one heartbeat of blank room —
115
- // and the log is the only trace it leaves anywhere.
116
- if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
117
- logger.warn('sync.presence_roster_dropped', { topic: name, socketId: socket.id });
118
- }
119
- }
114
+ const name = await options.hub.subscribeChannel(socket, frame.target);
115
+ topics.set(frame.sid, name);
116
+ await joinPresence(socket, name);
120
117
  return;
121
118
  }
122
119
  if (frame.op === 'drop') {
@@ -140,86 +137,16 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
140
137
  if (!socket.send(reply)) socket.markDesynced(frame.sid);
141
138
  return;
142
139
  }
143
- case 'mutate': {
144
- if (!options.onMutate) {
145
- // A failure receipt is still a receipt: dropped, the client's mutation stays `inflight`
146
- // and is neither rolled back nor retried, so this one reads the answer too.
147
- const sent = socket.send({
148
- type: 'ack',
149
- v: PROTOCOL_VERSION,
150
- ref: frame.key,
151
- lsn: null,
152
- error: toWireError({
153
- code: 'X_NOT_IMPLEMENTED',
154
- cause: 'this sync node was started without a mutation handler',
155
- fix: 'pass onMutate to createSyncNode({ onMutate })',
156
- }),
157
- });
158
- if (!sent) undeliverable(socket, frame.key, 'ack');
159
- return;
160
- }
161
- const result = await options.onMutate({
162
- socket,
163
- name: frame.name,
164
- key: frame.key,
165
- seq: frame.seq,
166
- input: frame.input,
167
- });
168
- // The rebase FIRST, and the ack last. The ack is the receipt, and a receipt is what
169
- // retires the client's record of the mutation — its journal row and its rebase-log entry,
170
- // both of which stay forever otherwise. A rebase that lands after that has no entry left
171
- // to read the mutator's conflict strategy off (every merge silently becomes server-wins)
172
- // and no sequence to decide which later optimistic writes to replay over server truth.
173
- // These are two frames on one socket, so the order is the only coordination there is.
174
- if (result.entity !== undefined) {
175
- const sent = socket.send({
176
- type: 'rebase',
177
- v: PROTOCOL_VERSION,
178
- key: frame.key,
179
- entity: result.entity,
180
- strategy: 'server-wins',
181
- row: result.row ?? null,
182
- });
183
- // The ack retires the client's rebase-log entry, so acking a rebase that never left is
184
- // the divergence the ordering above exists to prevent — one frame later instead of one
185
- // frame earlier. Nothing is acked; the mutation stays unsettled and is replayed.
186
- if (!sent) return undeliverable(socket, frame.key, 'rebase');
187
- }
188
- if (
189
- !socket.send({
190
- type: 'ack',
191
- v: PROTOCOL_VERSION,
192
- ref: frame.key,
193
- lsn: result.lsn ?? null,
194
- error: null,
195
- })
196
- ) {
197
- undeliverable(socket, frame.key, 'ack');
198
- }
199
- return;
200
- }
201
140
  // Server-authored frames are never received from a client.
202
141
  case 'snapshot':
203
142
  case 'patch':
204
143
  case 'ack':
205
- case 'rebase':
206
- case 'presence':
144
+ case 'records':
145
+ case 'events':
146
+ case 'replay-gap':
207
147
  case 'reconnect':
208
148
  case 'update-available':
209
149
  return;
210
150
  }
211
151
  }
212
152
  }
213
-
214
- /**
215
- * A settlement the socket refused. There is nothing on the node to mark — the mutation is applied
216
- * and the server keeps no per-mutation state — and a client only returns an `inflight` mutation to
217
- * its queue when the connection dies (`requeueInflight`), so a receipt dropped on a socket that
218
- * stays up is a write the client neither retires nor retries, with the server believing it settled.
219
- * Closing IS the repair: the queue hands the mutation back and the reconnect replays it under the
220
- * same idempotency key.
221
- */
222
- function undeliverable(socket: SyncSocket, key: string, kind: 'rebase' | 'ack'): void {
223
- logger.warn('sync.settlement_dropped', { socketId: socket.id, key, kind });
224
- socket.close(CLOSE.overloaded, 'settlement undeliverable');
225
- }
@@ -0,0 +1,42 @@
1
+ // The page's sync target, read off the `<meta>` tags the document shell renders — the default
2
+ // when the island bootstrap passed none. The names are core's (`page-meta.ts`): one literal per
3
+ // fact, written by `@ultimat3/render`, read here.
4
+
5
+ import { CLIENT_BUILD_META, CLIENT_SYNC_META, CLIENT_SYNC_WORKER_META } from '@ultimat3/core/page';
6
+ import type { SyncTarget } from './page-store';
7
+
8
+ /** The minimum of a DOM this reads — so a test can hand one in without a browser. */
9
+ export interface MetaDocument {
10
+ querySelector(selector: string): { getAttribute(name: string): string | null } | null;
11
+ }
12
+
13
+ function meta(doc: MetaDocument, name: string): string | undefined {
14
+ const content = doc.querySelector(`meta[name="${name}"]`)?.getAttribute('content');
15
+ return content === null || content === undefined || content === '' ? undefined : content;
16
+ }
17
+
18
+ /**
19
+ * `ultimate-sync` resolved against the page (`/_x/sync` is same-origin), with the scheme turned
20
+ * to the socket's: `https:` → `wss:`, `http:` → `ws:`; an absolute `ws(s)://` stays as written.
21
+ * `undefined` when the document names no sync node — a page with no realtime.
22
+ */
23
+ export function syncTargetFromMeta(doc: MetaDocument, href: string): SyncTarget | undefined {
24
+ const sync = meta(doc, CLIENT_SYNC_META);
25
+ if (sync === undefined) return undefined;
26
+ const url = new URL(sync, href);
27
+ if (url.protocol === 'https:') url.protocol = 'wss:';
28
+ else if (url.protocol === 'http:') url.protocol = 'ws:';
29
+ return { url: url.toString(), buildId: meta(doc, CLIENT_BUILD_META) ?? '' };
30
+ }
31
+
32
+ /** The worker script URL, or `undefined` when the app built none (the in-page host is used). */
33
+ export function syncWorkerFromMeta(doc: MetaDocument, href: string): string | undefined {
34
+ const worker = meta(doc, CLIENT_SYNC_WORKER_META);
35
+ return worker === undefined ? undefined : new URL(worker, href).toString();
36
+ }
37
+
38
+ /** This page's target, when there is a page to read. */
39
+ export function pageSyncTarget(): SyncTarget | undefined {
40
+ if (typeof document === 'undefined' || typeof location === 'undefined') return undefined;
41
+ return syncTargetFromMeta(document, location.href);
42
+ }
@@ -0,0 +1,100 @@
1
+ // What a `sync` node IS, as types: the options `createSyncNode` takes, the node it returns and the
2
+ // socket Bun hands its handlers. Apart from `sync-node.ts` so the lifecycle there stays under the
3
+ // file ceiling, and so a host can name the shapes without reading the lifecycle.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import type { ChannelHub } from './channel';
7
+ import type { Transport } from './fanout';
8
+ import type { LiveQueryRegistry } from './live-query';
9
+ import type { PresenceRegistry } from './presence';
10
+ import type { SocketRegistry, WsLike } from './socket';
11
+ import type { SyncAuthenticator } from './sync-auth';
12
+ import type { UpgradeTarget, WsData } from './sync-upgrade';
13
+ import type { AcceptBudget, DrainedSocket, Rng } from './thundering-herd';
14
+
15
+ export type SyncWs = WsLike & { readonly data: WsData };
16
+
17
+ export interface SyncNodeOptions {
18
+ readonly hub: ChannelHub;
19
+ readonly registry: LiveQueryRegistry;
20
+ readonly transport: Transport;
21
+ readonly buildId: string;
22
+ readonly presence?: PresenceRegistry;
23
+ readonly sockets?: SocketRegistry;
24
+ readonly accept?: AcceptBudget;
25
+ /** Concurrent sockets this node will hold. The count the accept budget does not bound. */
26
+ readonly maxConnections?: number;
27
+ /** Inbound bytes one frame may carry, handed to whatever server mounts `websocket`. */
28
+ readonly maxFrameBytes?: number;
29
+ /** Sustained inbound frames one socket may have routed per second. */
30
+ readonly maxFramesPerSecond?: number;
31
+ /** Burst allowance on that rate, per socket. */
32
+ readonly frameBurst?: number;
33
+ /**
34
+ * When a socket starts dropping frames, and how many drops close it. On `SyncSocket` too, but
35
+ * this node builds every socket it holds — so unforwarded they were reachable only by abandoning
36
+ * `createSyncNode`, and a dropped channel frame is the one loss nothing replays.
37
+ */
38
+ readonly maxBufferedBytes?: number;
39
+ readonly maxDroppedFrames?: number;
40
+ /**
41
+ * How long a socket may route no frame before this node evicts it. Every ceiling on a socket
42
+ * `sync` builds has to be reachable from here, and this one was not: `SocketRegistry`'s default
43
+ * was only settable by constructing the registry yourself, and nothing swept it either way.
44
+ */
45
+ readonly idleTimeoutMs?: number;
46
+ /**
47
+ * Who is dialling. Injected because `sync` owns no business logic and
48
+ * imports no authenticator, so an app supplies the one function that turns an upgrade request
49
+ * into an actor — from `@ultimat3/auth` or from anywhere else.
50
+ *
51
+ * **Omitted, every socket on this node is anonymous** and every policy downstream — the topic
52
+ * guard, `authorize`, `visible`, the per-tenant subscription cap — decides against `null`. That
53
+ * is a single-tenant node, and `start()` says so in the log.
54
+ */
55
+ readonly authenticate?: SyncAuthenticator;
56
+ /** How often an expired grant is re-decided. The clock a socket's authority runs on. */
57
+ readonly reauthenticateIntervalMs?: number;
58
+ readonly clock?: Clock;
59
+ readonly rng?: Rng;
60
+ /** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
61
+ readonly path?: string;
62
+ readonly drainSpreadMs?: number;
63
+ }
64
+
65
+ export interface SyncNode {
66
+ readonly sockets: SocketRegistry;
67
+ readonly ready: boolean;
68
+ /** The one path it answers an upgrade on: a HOST has to route it, and must not restate it. */
69
+ readonly path: string;
70
+ start(): Promise<void>;
71
+ /**
72
+ * Refuse new connections, keep every one this node holds. The SIGTERM `accept` phase calls it —
73
+ * `/readyz` answers 503 so the load balancer stops routing here, and an upgrade arriving in the
74
+ * meantime is shed with a retry delay instead of landing on a process that is going away. It is
75
+ * NOT `stop()`: a draining node still owes its clients their patches, and `stop()` releases the
76
+ * change subscription that carries them.
77
+ */
78
+ stopAccepting(): void;
79
+ stop(): Promise<void>;
80
+ /**
81
+ * Async because `authenticate` is: the credential is decided *before* `server.upgrade`, so a
82
+ * refused one never costs a websocket. Bun's `fetch` may return a promise, and an upgrade that
83
+ * awaits first is still an upgrade.
84
+ */
85
+ fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined>;
86
+ readonly websocket: {
87
+ idleTimeout: number;
88
+ backpressureLimit: number;
89
+ /** Inbound ceiling. Declared here so every host that mounts this handler inherits it. */
90
+ maxPayloadLength: number;
91
+ sendPings: boolean;
92
+ open(ws: SyncWs): void;
93
+ message(ws: SyncWs, message: string | Uint8Array): void;
94
+ /** Bun calls it when a backed-up socket has drained: owed `replay-gap` frames go out here. */
95
+ drain(ws: SyncWs): void;
96
+ close(ws: SyncWs): void;
97
+ };
98
+ /** Sends every client a distinct reconnect delay, then closes. Returns the plan for tests/logs. */
99
+ drain(options?: { graceMs?: number }): Promise<readonly DrainedSocket[]>;
100
+ }
package/src/sync-node.ts CHANGED
@@ -4,40 +4,22 @@
4
4
  // client may reconnect to any node and resume from its cursor, which is why drain is allowed to
5
5
  // redistribute connections at all.
6
6
 
7
- import { type Clock, logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
8
- import type { ChannelHub, Topic } from './channel';
7
+ import { logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
8
+ import type { Topic } from './channel';
9
9
  import { detach } from './detach';
10
10
  import { evictInChunks } from './drain-evictions';
11
11
  import { isClientFault } from './errors';
12
- import type { Transport, TransportSubscription } from './fanout';
13
- import type { LiveQueryRegistry } from './live-query';
14
- import type { PresenceRegistry } from './presence';
15
- import { CHANGE_SUBJECT_PREFIX, parseEnvelope, SeqGapDetector } from './replicator';
16
- import {
17
- CLOSE,
18
- DEFAULT_MAX_BUFFERED_BYTES,
19
- idleSweepPeriodMs,
20
- SocketRegistry,
21
- SyncSocket,
22
- type WsLike,
23
- } from './socket';
24
- import { GrantBook, type SyncAuthenticator, sweepGrants } from './sync-auth';
25
- import { ackRefOf, createFrameRouter, type MutationHandler } from './sync-frames';
12
+ import type { TransportSubscription } from './fanout';
13
+ import { CHANGE_SUBJECT_ALL, parseEnvelope, SeqGapDetector } from './replicator';
14
+ import { CLOSE, DEFAULT_MAX_BUFFERED_BYTES, SocketRegistry, SyncSocket } from './socket';
15
+ import { idleSweepPeriodMs } from './socket-idle';
16
+ import { GrantBook, sweepGrants } from './sync-auth';
17
+ import { ackRefOf, createFrameRouter } from './sync-frames';
26
18
  import { drainGraceMs, socketCeilings, syncNodeBounds } from './sync-node-bounds';
19
+ import type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
27
20
  import { decode, type Frame, PROTOCOL_VERSION, toWireError } from './sync-protocol';
28
- import { handleUpgrade, type UpgradeTarget, type WsData } from './sync-upgrade';
29
- import {
30
- AcceptBudget,
31
- type DrainedSocket,
32
- drainPlan,
33
- type Rng,
34
- reconnectFrame,
35
- } from './thundering-herd';
36
-
37
- /** Declared with the upgrade that builds it — this file only ever reads one. */
38
- export type { UpgradeTarget, WsData } from './sync-upgrade';
39
-
40
- export type SyncWs = WsLike & { readonly data: WsData };
21
+ import { handleUpgrade, type UpgradeTarget } from './sync-upgrade';
22
+ import { AcceptBudget, type DrainedSocket, drainPlan, reconnectFrame } from './thundering-herd';
41
23
 
42
24
  // Moved to `sync-node-bounds.ts` with the refusals that read them, and re-exported here because
43
25
  // `server.ts` publishes all three and a moved constant must not become a moved import path.
@@ -47,89 +29,10 @@ export {
47
29
  DEFAULT_REAUTH_INTERVAL_MS,
48
30
  } from './sync-node-bounds';
49
31
 
50
- export interface SyncNodeOptions {
51
- readonly hub: ChannelHub;
52
- readonly registry: LiveQueryRegistry;
53
- readonly transport: Transport;
54
- readonly buildId: string;
55
- readonly presence?: PresenceRegistry;
56
- readonly sockets?: SocketRegistry;
57
- readonly accept?: AcceptBudget;
58
- /** Concurrent sockets this node will hold. The count the accept budget does not bound. */
59
- readonly maxConnections?: number;
60
- /** Inbound bytes one frame may carry, handed to whatever server mounts `websocket`. */
61
- readonly maxFrameBytes?: number;
62
- /** Sustained inbound frames one socket may have routed per second. */
63
- readonly maxFramesPerSecond?: number;
64
- /** Burst allowance on that rate, per socket. */
65
- readonly frameBurst?: number;
66
- /**
67
- * When a socket starts dropping frames, and how many drops close it. On `SyncSocket` too, but
68
- * this node builds every socket it holds — so unforwarded they were reachable only by abandoning
69
- * `createSyncNode`, and a dropped channel frame is the one loss nothing replays.
70
- */
71
- readonly maxBufferedBytes?: number;
72
- readonly maxDroppedFrames?: number;
73
- /**
74
- * How long a socket may route no frame before this node evicts it. Every ceiling on a socket
75
- * `sync` builds has to be reachable from here, and this one was not: `SocketRegistry`'s default
76
- * was only settable by constructing the registry yourself, and nothing swept it either way.
77
- */
78
- readonly idleTimeoutMs?: number;
79
- readonly onMutate?: MutationHandler;
80
- /**
81
- * Who is dialling. Injected for the same reason `onMutate` is: `sync` owns no business logic and
82
- * imports no authenticator, so an app supplies the one function that turns an upgrade request
83
- * into an actor — from `@ultimat3/auth` or from anywhere else.
84
- *
85
- * **Omitted, every socket on this node is anonymous** and every policy downstream — the topic
86
- * guard, `authorize`, `visible`, the per-tenant subscription cap — decides against `null`. That
87
- * is a single-tenant node, and `start()` says so in the log.
88
- */
89
- readonly authenticate?: SyncAuthenticator;
90
- /** How often an expired grant is re-decided. The clock a socket's authority runs on. */
91
- readonly reauthenticateIntervalMs?: number;
92
- readonly clock?: Clock;
93
- readonly rng?: Rng;
94
- /** WS endpoint. One path, no negotiation — the protocol version lives in the frames. */
95
- readonly path?: string;
96
- readonly drainSpreadMs?: number;
97
- }
98
-
99
- export interface SyncNode {
100
- readonly sockets: SocketRegistry;
101
- readonly ready: boolean;
102
- /** The one path it answers an upgrade on: a HOST has to route it, and must not restate it. */
103
- readonly path: string;
104
- start(): Promise<void>;
105
- /**
106
- * Refuse new connections, keep every one this node holds. The SIGTERM `accept` phase calls it —
107
- * `/readyz` answers 503 so the load balancer stops routing here, and an upgrade arriving in the
108
- * meantime is shed with a retry delay instead of landing on a process that is going away. It is
109
- * NOT `stop()`: a draining node still owes its clients their patches, and `stop()` releases the
110
- * change subscription that carries them.
111
- */
112
- stopAccepting(): void;
113
- stop(): Promise<void>;
114
- /**
115
- * Async because `authenticate` is: the credential is decided *before* `server.upgrade`, so a
116
- * refused one never costs a websocket. Bun's `fetch` may return a promise, and an upgrade that
117
- * awaits first is still an upgrade.
118
- */
119
- fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined>;
120
- readonly websocket: {
121
- idleTimeout: number;
122
- backpressureLimit: number;
123
- /** Inbound ceiling. Declared here so every host that mounts this handler inherits it. */
124
- maxPayloadLength: number;
125
- sendPings: boolean;
126
- open(ws: SyncWs): void;
127
- message(ws: SyncWs, message: string | Uint8Array): void;
128
- close(ws: SyncWs): void;
129
- };
130
- /** Sends every client a distinct reconnect delay, then closes. Returns the plan for tests/logs. */
131
- drain(options?: { graceMs?: number }): Promise<readonly DrainedSocket[]>;
132
- }
32
+ /** The node's shapes — options, the node itself, its socket — live beside it, in their own file. */
33
+ export type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
34
+ /** Declared with the upgrade that builds it — this file only ever reads one. */
35
+ export type { UpgradeTarget, WsData } from './sync-upgrade';
133
36
 
134
37
  export function createSyncNode(options: SyncNodeOptions): SyncNode {
135
38
  const sockets =
@@ -277,7 +180,6 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
277
180
  registry: options.registry,
278
181
  buildId: options.buildId,
279
182
  presence,
280
- onMutate: options.onMutate,
281
183
  });
282
184
 
283
185
  return {
@@ -289,7 +191,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
289
191
  },
290
192
 
291
193
  async start(): Promise<void> {
292
- changes = await options.transport.subscribe(`${CHANGE_SUBJECT_PREFIX}.>`, (payload) => {
194
+ changes = await options.transport.subscribe(CHANGE_SUBJECT_ALL, (payload) => {
293
195
  const envelope = parseEnvelope(payload);
294
196
  if (!envelope) return;
295
197
  // Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
@@ -304,6 +206,9 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
304
206
  // registry's — one serial lane per query id. What this call site owes is the failure. An
305
207
  // unhandled rejection here is a fanout that reached nobody, reported as a dead process.
306
208
  detach(options.registry.deliver(envelope.change), 'live.deliver', envelope.change.entity);
209
+ // The same stream feeds the declared channels: a write names no channel (axiom 2), and the
210
+ // hub turns this change into `records` frames on every channel it touches.
211
+ options.hub.deliverChange(envelope.change);
307
212
  });
308
213
  // One pass per heartbeat window: a member is swept only once it has actually missed its
309
214
  // window, and the interval never holds the process open — shutdown is the drain's job.
@@ -434,6 +339,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
434
339
  })();
435
340
  },
436
341
 
342
+ drain(ws: SyncWs): void {
343
+ // A `records` frame backpressure refused left this socket gapped on that channel; the
344
+ // repair is the node's verdict, sent the moment the socket can take a frame again.
345
+ const socket = sockets.get(ws.data.socketId);
346
+ if (socket) sockets.gapRepairs.repairAll(socket);
347
+ },
348
+
437
349
  close(ws: SyncWs): void {
438
350
  const socket = sockets.get(ws.data.socketId);
439
351
  if (!socket) {