@ultimat3/realtime 20.2.1 → 21.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 (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  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 +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -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 +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
package/src/socket.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  systemClock,
17
17
  uuid,
18
18
  } from '@ultimat3/core';
19
+ import { GapRepairs } from './channel-gaps';
20
+ import type { ChannelRecordsFrame } from './channel-wire';
19
21
  import { CLOSE } from './close-codes';
20
22
  import { encode, type Frame } from './sync-protocol';
21
23
  import { AcceptBudget } from './thundering-herd';
@@ -81,10 +83,10 @@ export const DEFAULT_FRAME_BURST = 256;
81
83
  export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
82
84
 
83
85
  /**
84
- * Channel frames this process dropped under backpressure. A DATA-LOSS counter, not a saturation
85
- * one: the live-query path repairs a dropped patch (the subscriber is marked desynced and the next
86
- * change re-snapshots it), and a channel has no cursor, no mark and no re-snapshot — so this is the
87
- * only trace a lost channel message leaves anywhere.
86
+ * Channel frames this process dropped under backpressure. A dropped `records` frame is now marked
87
+ * and REPAIRED — the socket is sent `replay-gap` once it drains and re-reads the channel's catch-up
88
+ * query (`channel_replay_gaps_total` counts those) — while a dropped `events` frame is ephemeral
89
+ * by definition and is only counted. The two series side by side are the whole delivery story.
88
90
  *
89
91
  * Declared here rather than in `@ultimat3/core`'s `runtime-metrics.ts` because that file is the
90
92
  * series EVERY Ultimate process emits and the deploy chart scales on; this one exists only where
@@ -94,7 +96,7 @@ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
94
96
  */
95
97
  const channelFramesDropped: Counter = counter('channel_frames_dropped_total', {
96
98
  unit: '{frame}',
97
- description: 'Channel frames dropped by socket backpressure — unrecoverable, nothing replays one',
99
+ description: 'Channel frames dropped by socket backpressure',
98
100
  });
99
101
 
100
102
  export function actorIdOf(actor: Actor | null): string | null {
@@ -123,6 +125,12 @@ export class SyncSocket {
123
125
  * flush re-snapshots instead of silently diverging.
124
126
  */
125
127
  readonly desynced = new Set<string>();
128
+ /**
129
+ * Channel topics whose `records` stream this socket lost a frame of, with the epoch it was lost
130
+ * in. The channel twin of `desynced`: written on a drop, read on the next delivery or drain, which
131
+ * sends `replay-gap` and clears it. Bounded by `topics`, so it adds nothing a socket did not hold.
132
+ */
133
+ readonly gaps = new Map<string, string>();
126
134
  /**
127
135
  * Inbound frames this socket may still have routed. The accept budget spends one token per
128
136
  * UPGRADE, so nothing bounded what happened after: one authenticated socket reached a DB read,
@@ -255,6 +263,7 @@ export class SyncSocket {
255
263
 
256
264
  unsubscribeTopic(topic: string): void {
257
265
  this.topics.delete(topic);
266
+ this.gaps.delete(topic);
258
267
  }
259
268
 
260
269
  touch(): void {
@@ -313,6 +322,8 @@ export class SocketRegistry {
313
322
  readonly #clock: Clock;
314
323
  readonly #idleTimeoutMs: number;
315
324
  #droppedChannelFrames = 0;
325
+ /** The repair side of `SyncSocket.gaps`: `repairAll(socket)` is what Bun's `drain` calls. */
326
+ readonly gapRepairs = new GapRepairs();
316
327
 
317
328
  constructor(options: SocketRegistryOptions = {}) {
318
329
  this.#clock = options.clock ?? systemClock;
@@ -425,17 +436,45 @@ export class SocketRegistry {
425
436
  else dropped += 1;
426
437
  }
427
438
  if (members.size === 0) this.#byTopic.delete(topic);
428
- if (dropped > 0) {
429
- this.#droppedChannelFrames += dropped;
430
- // Two readers, one event, one spelling: the series an operator alerts on and the line that
431
- // says which topic it was. `deliver` ignored `send`'s answer and so did the hub above it, so
432
- // until both existed a lost channel message left no trace at all.
433
- channelFramesDropped.add(dropped);
434
- logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
439
+ if (dropped > 0) this.#countDropped(topic, dropped);
440
+ return sent;
441
+ }
442
+
443
+ /**
444
+ * A `records` delivery. An owed `replay-gap` goes out first; a socket that then drops the frame is
445
+ * marked for the next one. `deliver`'s twin, because only a records stream can be repaired.
446
+ */
447
+ deliverRecords(topic: string, epoch: string, frame: ChannelRecordsFrame): number {
448
+ const members = this.#byTopic.get(topic);
449
+ if (!members) return 0;
450
+ let sent = 0;
451
+ let dropped = 0;
452
+ for (const socket of members) {
453
+ if (socket.closed) {
454
+ members.delete(socket);
455
+ continue;
456
+ }
457
+ this.gapRepairs.repair(socket, topic);
458
+ if (socket.send(frame)) {
459
+ sent += 1;
460
+ continue;
461
+ }
462
+ dropped += 1;
463
+ socket.gaps.set(topic, epoch);
435
464
  }
465
+ if (members.size === 0) this.#byTopic.delete(topic);
466
+ if (dropped > 0) this.#countDropped(topic, dropped);
436
467
  return sent;
437
468
  }
438
469
 
470
+ #countDropped(topic: string, dropped: number): void {
471
+ this.#droppedChannelFrames += dropped;
472
+ // Two readers, one event, one spelling: the series an operator alerts on and the line that
473
+ // says which topic it was.
474
+ channelFramesDropped.add(dropped);
475
+ logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
476
+ }
477
+
439
478
  /**
440
479
  * Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
441
480
  * of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
package/src/sync-auth.ts CHANGED
@@ -1,6 +1,6 @@
1
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.
2
+ // supplies `authenticate` — so this file owns the shape of that answer, the per-node book that
3
+ // holds it, and the pass that re-decides one whose window has closed.
4
4
 
5
5
  import type { Actor, Clock } from '@ultimat3/core';
6
6
 
@@ -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,27 @@
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';
12
+ import type { TransportSubscription } from './fanout';
13
+ import { CHANGE_SUBJECT_ALL, parseEnvelope, SeqGapDetector } from './replicator';
16
14
  import {
17
15
  CLOSE,
18
16
  DEFAULT_MAX_BUFFERED_BYTES,
19
17
  idleSweepPeriodMs,
20
18
  SocketRegistry,
21
19
  SyncSocket,
22
- type WsLike,
23
20
  } from './socket';
24
- import { GrantBook, type SyncAuthenticator, sweepGrants } from './sync-auth';
25
- import { ackRefOf, createFrameRouter, type MutationHandler } from './sync-frames';
21
+ import { GrantBook, sweepGrants } from './sync-auth';
22
+ import { ackRefOf, createFrameRouter } from './sync-frames';
26
23
  import { drainGraceMs, socketCeilings, syncNodeBounds } from './sync-node-bounds';
24
+ import type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
27
25
  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 };
26
+ import { handleUpgrade, type UpgradeTarget } from './sync-upgrade';
27
+ import { AcceptBudget, type DrainedSocket, drainPlan, reconnectFrame } from './thundering-herd';
41
28
 
42
29
  // Moved to `sync-node-bounds.ts` with the refusals that read them, and re-exported here because
43
30
  // `server.ts` publishes all three and a moved constant must not become a moved import path.
@@ -47,89 +34,10 @@ export {
47
34
  DEFAULT_REAUTH_INTERVAL_MS,
48
35
  } from './sync-node-bounds';
49
36
 
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
- }
37
+ /** The node's shapes — options, the node itself, its socket — live beside it, in their own file. */
38
+ export type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
39
+ /** Declared with the upgrade that builds it — this file only ever reads one. */
40
+ export type { UpgradeTarget, WsData } from './sync-upgrade';
133
41
 
134
42
  export function createSyncNode(options: SyncNodeOptions): SyncNode {
135
43
  const sockets =
@@ -277,7 +185,6 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
277
185
  registry: options.registry,
278
186
  buildId: options.buildId,
279
187
  presence,
280
- onMutate: options.onMutate,
281
188
  });
282
189
 
283
190
  return {
@@ -289,7 +196,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
289
196
  },
290
197
 
291
198
  async start(): Promise<void> {
292
- changes = await options.transport.subscribe(`${CHANGE_SUBJECT_PREFIX}.>`, (payload) => {
199
+ changes = await options.transport.subscribe(CHANGE_SUBJECT_ALL, (payload) => {
293
200
  const envelope = parseEnvelope(payload);
294
201
  if (!envelope) return;
295
202
  // Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
@@ -304,6 +211,9 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
304
211
  // registry's — one serial lane per query id. What this call site owes is the failure. An
305
212
  // unhandled rejection here is a fanout that reached nobody, reported as a dead process.
306
213
  detach(options.registry.deliver(envelope.change), 'live.deliver', envelope.change.entity);
214
+ // The same stream feeds the declared channels: a write names no channel (axiom 2), and the
215
+ // hub turns this change into `records` frames on every channel it touches.
216
+ options.hub.deliverChange(envelope.change);
307
217
  });
308
218
  // One pass per heartbeat window: a member is swept only once it has actually missed its
309
219
  // window, and the interval never holds the process open — shutdown is the drain's job.
@@ -434,6 +344,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
434
344
  })();
435
345
  },
436
346
 
347
+ drain(ws: SyncWs): void {
348
+ // A `records` frame backpressure refused left this socket gapped on that channel; the
349
+ // repair is the node's verdict, sent the moment the socket can take a frame again.
350
+ const socket = sockets.get(ws.data.socketId);
351
+ if (socket) sockets.gapRepairs.repairAll(socket);
352
+ },
353
+
437
354
  close(ws: SyncWs): void {
438
355
  const socket = sockets.get(ws.data.socketId);
439
356
  if (!socket) {