@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,151 @@
1
+ // The shared, pre-policy result window one query id is served from: how it is built, how it is
2
+ // read once for N subscribers, and how one that is known to be wrong is replaced. The authz
3
+ // decision is never here — `live-query.ts` owns that, once per subscriber, over what this returns.
4
+
5
+ import type { JsonValue, Row } from './json';
6
+ import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
7
+ import type { IncrementalMatcher, SubscriptionShape } from './matcher-bridge';
8
+ import { WindowLock } from './window-lock';
9
+
10
+ export interface QueryEntry {
11
+ readonly qid: string;
12
+ readonly definition: LiveQueryDefinition;
13
+ readonly input: JsonValue;
14
+ /** Told to the client on every snapshot: the identity scope its rows belong under. */
15
+ readonly rowEntity: string | null;
16
+ readonly shape: SubscriptionShape;
17
+ readonly matcher: IncrementalMatcher;
18
+ readonly subscribers: Map<string, LiveSubscription>;
19
+ /**
20
+ * The shared, *pre-policy* result window. One per query id, bounded by the query's `limit`, and
21
+ * the reason the matcher can run once for N subscribers: the read is shared, the authz is not.
22
+ */
23
+ rows: readonly Row[];
24
+ lsn: string;
25
+ /**
26
+ * This window is known to have missed at least one change, so patching it would compound the
27
+ * error. Set when the change stream skipped a sequence and when a matcher reports it lost the
28
+ * window's tail; cleared by the read that replaces the rows. It is *not* a subscriber's desync —
29
+ * that is per socket, and this is the window every one of them shares.
30
+ */
31
+ stale: boolean;
32
+ /** Serial lane over `rows`/`lsn`. Every fanout and every window assignment takes its turn here. */
33
+ readonly lock: WindowLock;
34
+ /** The read in flight, shared by every subscriber that arrives during it. `null` between reads. */
35
+ reading: Promise<SnapshotResult> | null;
36
+ }
37
+
38
+ export function createEntry(
39
+ qid: string,
40
+ definition: LiveQueryDefinition,
41
+ input: JsonValue,
42
+ matcher: IncrementalMatcher,
43
+ ): QueryEntry {
44
+ return {
45
+ qid,
46
+ definition,
47
+ input,
48
+ // Resolved with the matcher, from the same build: `prepare` has already run, so a definition
49
+ // that compiles its shape per input can answer.
50
+ rowEntity: definition.rowEntity?.(input) ?? null,
51
+ shape: {
52
+ qid,
53
+ // The matcher knows the dependency set this *input* produced; `definition.entities` is the
54
+ // static declaration and can only be a superset of it. Preferring the matcher is what lets a
55
+ // definition built from a real query carry no static list at all.
56
+ entities: matcher.entities.length > 0 ? matcher.entities : definition.entities,
57
+ orgId: orgIdOf(input),
58
+ ...(definition.columns ? { columns: definition.columns } : {}),
59
+ },
60
+ matcher,
61
+ subscribers: new Map(),
62
+ rows: [],
63
+ lsn: '',
64
+ stale: false,
65
+ lock: new WindowLock(),
66
+ reading: null,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * The window this subscriber is served from, read once per entry. A subscriber arriving while
72
+ * another's read is in flight joins that read rather than issuing its own — N cold subscribers on
73
+ * one query id being N reads is the shared window not existing.
74
+ *
75
+ * The result lands in the lane, and never backwards. A snapshot that resolved after a newer change
76
+ * had already been fanned out would rewind every later subscriber to rows the window has moved
77
+ * past, so a stale read is discarded and its caller is served from the newer window instead.
78
+ */
79
+ export async function fillWindow(
80
+ entry: QueryEntry,
81
+ ): Promise<{ rows: readonly Row[]; lsn: string }> {
82
+ // Read before `startRead` clears it: a second caller arriving during the read joins it and is
83
+ // not the one that forced it, which is what keeps one forced read from becoming N.
84
+ const forced = entry.stale;
85
+ const result = await (forced || entry.reading === null ? startRead(entry) : entry.reading);
86
+ return await entry.lock.run(async () => {
87
+ if (forced) {
88
+ // A forced read replaces the window whatever its lsn says: it was issued *because* what is
89
+ // under it is wrong, and a definition with no lsn provider answers `''` — which the
90
+ // never-backwards rule below would read as older than what we hold and discard, leaving
91
+ // every subscriber served from the window the gap already invalidated.
92
+ entry.rows = result.rows;
93
+ if (result.lsn > entry.lsn) entry.lsn = result.lsn;
94
+ } else if (result.lsn >= entry.lsn) {
95
+ entry.rows = result.rows;
96
+ entry.lsn = result.lsn;
97
+ }
98
+ return { rows: entry.rows, lsn: entry.lsn };
99
+ });
100
+ }
101
+
102
+ /**
103
+ * The same replacement, for a caller that is already holding the lane. A fanout cannot call
104
+ * `fillWindow` — that takes the entry's own lane, and a lane is not reentrant — so the one path
105
+ * that repairs a stale window mid-fanout is spelled here rather than deadlocking on the other.
106
+ */
107
+ export async function refillWindowInLane(entry: QueryEntry): Promise<void> {
108
+ const result = await startRead(entry);
109
+ entry.rows = result.rows;
110
+ if (result.lsn > entry.lsn) entry.lsn = result.lsn;
111
+ }
112
+
113
+ /** Publishes the in-flight read, and clears it as it settles — the share is per read, not a cache. */
114
+ function startRead(entry: QueryEntry): Promise<SnapshotResult> {
115
+ // Cleared here rather than when the read lands: the read about to be issued is the one that
116
+ // answers the staleness, so a second caller must join it instead of forcing another.
117
+ entry.stale = false;
118
+ const reading = readSnapshot(entry);
119
+ entry.reading = reading;
120
+ const done = (): void => {
121
+ if (entry.reading === reading) entry.reading = null;
122
+ };
123
+ void reading.then(done, done);
124
+ return reading;
125
+ }
126
+
127
+ /**
128
+ * The definition's read, with the staleness put back when it does not answer.
129
+ *
130
+ * Clearing the mark on the way in and never restoring it was the gap repair happening once and
131
+ * never again: the snapshot that was going to replace an invalidated window rejects — the pool is
132
+ * exhausted by the same incident that caused the gap — and the entry is left unmarked over rows it
133
+ * is known to have missed a change on. Nothing re-reads, `#resnapshot` serves every desynced
134
+ * subscriber out of that divergent window and clears their marks, and the divergence `stale` exists
135
+ * to prevent is now permanent and silent. `async` so a definition that throws synchronously takes
136
+ * the same path as one that rejects.
137
+ */
138
+ async function readSnapshot(entry: QueryEntry): Promise<SnapshotResult> {
139
+ try {
140
+ return await entry.definition.snapshot({ input: entry.input });
141
+ } catch (error) {
142
+ entry.stale = true;
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ export function orgIdOf(input: JsonValue): string | null {
148
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
149
+ const value = input['orgId'];
150
+ return typeof value === 'string' ? value : null;
151
+ }
package/src/rebase.ts CHANGED
@@ -108,26 +108,86 @@ export function reconcile<T extends TableMap = TableMap>(
108
108
  const table = store.table(ack.entity);
109
109
  const local = table.get(ack.id);
110
110
 
111
- // Everything at or after the acked sequence is optimistic and must be undone newest-first.
112
- const affected = log.pending().filter((candidate) => candidate.seq >= (entry?.seq ?? 0));
111
+ const { affected, rolledBack } = undoFrom(store, log, entry?.seq ?? 0);
112
+
113
+ const base = store.table(ack.entity).get(ack.id);
114
+ const winner = land(store, ack, strategy, { local, base }, options);
115
+ log.drop(ack.key);
116
+
117
+ return {
118
+ strategy: strategyName(strategy),
119
+ rolledBack,
120
+ reapplied: replayExcept(store, affected, ack.key),
121
+ winner,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Everything at or after `from` is optimistic, so it is undone newest-first — and `reconcile` and
127
+ * `rollbackMutation` are one rule with different middles, not two. Spelled twice, the next change
128
+ * to the replay order has to be made twice, and the half that is missed diverges silently.
129
+ */
130
+ function undoFrom<T extends TableMap>(
131
+ store: LocalStore<T>,
132
+ log: RebaseLog<T>,
133
+ from: number,
134
+ ): { affected: readonly RebaseEntry<T>[]; rolledBack: string[] } {
135
+ const affected = log.pending().filter((candidate) => candidate.seq >= from);
113
136
  const rolledBack: string[] = [];
114
137
  for (const candidate of [...affected].reverse()) {
115
138
  store.rollback(candidate.key);
116
139
  rolledBack.push(candidate.key);
117
140
  }
141
+ return { affected, rolledBack };
142
+ }
118
143
 
119
- const base = store.table(ack.entity).get(ack.id);
120
- const winner = land(store, ack, strategy, { local, base }, options);
121
- log.drop(ack.key);
122
-
144
+ /**
145
+ * The other half: replay in sequence order, skipping the one the server has now settled. `local` is
146
+ * pure, which is what makes replaying it deterministic and therefore safe to do at all.
147
+ */
148
+ function replayExcept<T extends TableMap>(
149
+ store: LocalStore<T>,
150
+ affected: readonly RebaseEntry<T>[],
151
+ settled: string,
152
+ ): string[] {
123
153
  const reapplied: string[] = [];
124
154
  for (const candidate of affected) {
125
- if (candidate.key === ack.key) continue;
155
+ if (candidate.key === settled) continue;
126
156
  store.apply(candidate.key, (tx) => candidate.apply(tx));
127
157
  reapplied.push(candidate.key);
128
158
  }
159
+ return reapplied;
160
+ }
161
+
162
+ export interface RollbackResult {
163
+ readonly rolledBack: readonly string[];
164
+ readonly reapplied: readonly string[];
165
+ }
166
+
167
+ /**
168
+ * The other half of `reconcile`: the server **refused** a mutation, so there is no server truth to
169
+ * land — only an optimistic write to take back. Same shape as a reconcile, and for the same reason:
170
+ * the writes made after it may depend on it, so everything from its sequence onward is undone
171
+ * newest-first and then replayed without it. Replay is deterministic because `local` is pure.
172
+ *
173
+ * Idempotent for a key the log does not hold: a denial can arrive twice, and tier 2 records nothing
174
+ * to undo in the first place.
175
+ */
176
+ export function rollbackMutation<T extends TableMap = TableMap>(args: {
177
+ store: LocalStore<T>;
178
+ log: RebaseLog<T>;
179
+ key: string;
180
+ }): RollbackResult {
181
+ const { store, log, key } = args;
182
+ const entry = log.get(key);
183
+ if (!entry) return { rolledBack: [], reapplied: [] };
129
184
 
130
- return { strategy: strategyName(strategy), rolledBack, reapplied, winner };
185
+ const { affected, rolledBack } = undoFrom(store, log, entry.seq);
186
+ // The one thing that differs from `reconcile`'s middle: there is no server truth to land. Dropped
187
+ // and never retried — a denial is a decision about this intent, so replaying it on the next
188
+ // reconcile would put the write the server refused back on the screen.
189
+ log.drop(key);
190
+ return { rolledBack, reapplied: replayExcept(store, affected, key) };
131
191
  }
132
192
 
133
193
  function land<T extends TableMap>(
package/src/replicator.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // its feed and reports `/readyz` false; it retries with jittered backoff and takes over the moment
12
12
  // the holder dies. Scaling the replicator is therefore always vertical, and that is by design.
13
13
 
14
- import { logger, withSpan } from '@ultimat3/core';
14
+ import { logger, uuid, withSpan } from '@ultimat3/core';
15
15
  import type { ChangeEvent, ChangeFeed } from './changefeed';
16
16
  import type { Transport } from './fanout';
17
17
  import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
@@ -72,6 +72,26 @@ export interface ReplicatorStats {
72
72
  readonly outOfOrder: number;
73
73
  }
74
74
 
75
+ /**
76
+ * What the bus actually carries: one change, plus who published it and where in that publisher's
77
+ * stream it sits.
78
+ *
79
+ * Fanout is core NATS — `publish`, no ack, at most once — so a consumer that reads only the change
80
+ * cannot tell "nothing happened" from "eleven changes went past while I was reconnecting". An lsn
81
+ * cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
82
+ * arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
83
+ *
84
+ * Both fields are optional on the wire, so a node reading a publisher that predates them simply
85
+ * detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
86
+ */
87
+ export interface ChangeEnvelope {
88
+ readonly change: ChangeEvent;
89
+ /** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
90
+ readonly seq: number | null;
91
+ /** Identifies one replicator *run*. A new one restarts `seq`, and that is not a gap. */
92
+ readonly producer: string | null;
93
+ }
94
+
75
95
  export interface Replicator {
76
96
  /** `false` = another replicator holds the lock; this process must stay `/readyz` false. */
77
97
  start(): Promise<boolean>;
@@ -91,6 +111,11 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
91
111
  let published = 0;
92
112
  let skipped = 0;
93
113
  let outOfOrder = 0;
114
+ // One id per run, not per process: a replicator that took the lock back after a crash publishes
115
+ // from its persisted lsn, and a consumer must read that as a new stream rather than as a gap in
116
+ // the old one.
117
+ let producer = uuid();
118
+ let seq = 0;
94
119
 
95
120
  const onChange = async (raw: ChangeEvent): Promise<void> => {
96
121
  const change = normalize(raw);
@@ -104,7 +129,13 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
104
129
  return;
105
130
  }
106
131
  await withSpan('realtime.replicate', async () => {
107
- await options.transport.publish(subjectOf(change), JSON.stringify(change));
132
+ seq += 1;
133
+ // The envelope is the change plus two fields, flat, so a consumer that only knows about the
134
+ // change reads it unchanged — `parseChange` still answers on the same payload.
135
+ await options.transport.publish(
136
+ subjectOf(change),
137
+ JSON.stringify({ ...change, seq, producer }),
138
+ );
108
139
  lastLsn = change.lsn;
109
140
  published += 1;
110
141
  });
@@ -118,6 +149,8 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
118
149
  return false;
119
150
  }
120
151
  running = true;
152
+ producer = uuid();
153
+ seq = 0;
121
154
  await options.feed.start(
122
155
  options.from === undefined ? { onChange } : { from: options.from, onChange },
123
156
  );
@@ -163,23 +196,63 @@ export function normalize(change: ChangeEvent): ChangeEvent | null {
163
196
 
164
197
  /** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
165
198
  export function parseChange(payload: string): ChangeEvent | null {
199
+ return parseEnvelope(payload)?.change ?? null;
200
+ }
201
+
202
+ /** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
203
+ export function parseEnvelope(payload: string): ChangeEnvelope | null {
166
204
  try {
167
205
  const parsed: unknown = JSON.parse(payload);
168
206
  if (typeof parsed !== 'object' || parsed === null) return null;
169
- const shape = parsed as Partial<ChangeEvent>;
207
+ const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
170
208
  if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
171
209
  if (shape.op !== 'insert' && shape.op !== 'update' && shape.op !== 'delete') return null;
172
210
  return {
173
- entity: shape.entity,
174
- op: shape.op,
175
- before: shape.before ?? null,
176
- after: shape.after ?? null,
177
- lsn: shape.lsn,
178
- txid: typeof shape.txid === 'string' ? shape.txid : '',
179
- orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
180
- at: typeof shape.at === 'number' ? shape.at : 0,
211
+ change: {
212
+ entity: shape.entity,
213
+ op: shape.op,
214
+ before: shape.before ?? null,
215
+ after: shape.after ?? null,
216
+ lsn: shape.lsn,
217
+ txid: typeof shape.txid === 'string' ? shape.txid : '',
218
+ orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
219
+ at: typeof shape.at === 'number' ? shape.at : 0,
220
+ },
221
+ seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
222
+ producer: typeof shape.producer === 'string' ? shape.producer : null,
181
223
  };
182
224
  } catch {
183
225
  return null;
184
226
  }
185
227
  }
228
+
229
+ /**
230
+ * The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
231
+ * say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
232
+ * there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
233
+ * simply never happened as far as every subscriber on this node could tell.
234
+ *
235
+ * Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
236
+ * reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
237
+ * the first message of a stream: a node that joined late has missed everything by definition, and
238
+ * every subscription it holds started after it did.
239
+ */
240
+ export class SeqGapDetector {
241
+ readonly #next = new Map<string, number>();
242
+
243
+ /** `true` when at least one message between the last one and this one was never delivered. */
244
+ observe(envelope: ChangeEnvelope): boolean {
245
+ const { producer, seq } = envelope;
246
+ if (producer === null || seq === null) return false;
247
+ const expected = this.#next.get(producer);
248
+ // Never backwards: a redelivery must not lower the bar and turn the next legitimate message
249
+ // into a gap of its own.
250
+ this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
251
+ return expected !== undefined && seq > expected;
252
+ }
253
+
254
+ /** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
255
+ forget(): void {
256
+ this.#next.clear();
257
+ }
258
+ }
package/src/socket.ts CHANGED
@@ -1,11 +1,22 @@
1
1
  // One WS connection. Bun's WS profile is what makes a million sockets affordable, so this object
2
- // is deliberately lean: ~8 fields plus two small sets. Budget is ~1KB of JS heap per connection on
3
- // top of Bun's own per-socket cost anything richer (row caches, per-socket buffers) belongs in
4
- // the transport or the change buffer, never here. `sync` is stateless: nothing on this object
5
- // survives a restart, and nothing needs to.
2
+ // is deliberately lean: ~8 fields, two small sets and one token bucket (the frame budget, which is
3
+ // five numbers and the only thing standing between one authenticated socket and this node's whole
4
+ // subscribe path). Budget is ~1KB of JS heap per connection on top of Bun's own per-socket cost —
5
+ // anything richer (row caches, per-socket buffers) belongs in the transport or the change buffer,
6
+ // never here. `sync` is stateless: nothing on this object survives a restart, and nothing needs to.
6
7
 
7
- import { type Actor, type Clock, recordConnection, systemClock, uuid } from '@ultimat3/core';
8
+ import {
9
+ type Actor,
10
+ type Clock,
11
+ type Counter,
12
+ counter,
13
+ logger,
14
+ recordConnection,
15
+ systemClock,
16
+ uuid,
17
+ } from '@ultimat3/core';
8
18
  import { encode, type Frame } from './sync-protocol';
19
+ import { AcceptBudget } from './thundering-herd';
9
20
 
10
21
  export const CLOSE = {
11
22
  normal: 1000,
@@ -17,7 +28,16 @@ export const CLOSE = {
17
28
  drain: 4002,
18
29
  } as const;
19
30
 
20
- /** The slice of Bun's `ServerWebSocket` this package uses. Structural, so tests need no server. */
31
+ /**
32
+ * The slice of Bun's `ServerWebSocket` this package uses. Structural, so tests need no server.
33
+ *
34
+ * `subscribe`/`unsubscribe` are Bun's native pub/sub and this package does NOT use them: nothing
35
+ * here publishes to a native topic, and nothing will — a native publish cannot be refused per
36
+ * socket, cannot report the frame it dropped and cannot mark a subscriber desynced, which is
37
+ * exactly what `SocketRegistry.deliver` and `SyncSocket.send` exist to do. They are still declared
38
+ * because `WsLike` is the slice of Bun's own object, and an app already implements it; deleting
39
+ * them is a separate, breaking edit to every implementer.
40
+ */
21
41
  export interface WsLike {
22
42
  send(data: string): number;
23
43
  close(code?: number, reason?: string): void;
@@ -37,8 +57,47 @@ export interface SyncSocketOptions {
37
57
  /** Frames are dropped rather than queued past this. See `desynced`. */
38
58
  readonly maxBufferedBytes?: number;
39
59
  readonly maxDroppedFrames?: number;
60
+ /** Sustained inbound frames this socket may have routed per second. See `frameBudget`. */
61
+ readonly maxFramesPerSecond?: number;
62
+ /** Burst allowance, so a client subscribing its whole cap at connect is never refused. */
63
+ readonly frameBurst?: number;
40
64
  }
41
65
 
66
+ /**
67
+ * What one socket may ask this node to do per second, and how much of it may arrive at once.
68
+ *
69
+ * The burst clears `DEFAULT_MAX_PER_SOCKET` (128) plus a `hello`, because that is exactly what a
70
+ * legitimate client sends on connect; the sustained rate is well under the ~155 frames/s measured
71
+ * to consume a node through the subscribe path's amplifiers.
72
+ */
73
+ export const DEFAULT_MAX_FRAMES_PER_SECOND = 64;
74
+ export const DEFAULT_FRAME_BURST = 256;
75
+ /**
76
+ * Queued-but-unwritten bytes on one server socket before `send` declines and marks the subscriber
77
+ * desynced. `sync-node.ts` hands the same number to Bun as `backpressureLimit` rather than spelling
78
+ * it again: they are one socket's one buffer, and a check the runtime's own limit fires before is a
79
+ * check that never runs. The client half (`client-mutations.ts`) is deliberately its own constant —
80
+ * it is browser code, and importing this file would pull the node's socket registry into the tab.
81
+ */
82
+ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
83
+
84
+ /**
85
+ * Channel frames this process dropped under backpressure. A DATA-LOSS counter, not a saturation
86
+ * one: the live-query path repairs a dropped patch (the subscriber is marked desynced and the next
87
+ * change re-snapshots it), and a channel has no cursor, no mark and no re-snapshot — so this is the
88
+ * only trace a lost channel message leaves anywhere.
89
+ *
90
+ * Declared here rather than in `@ultimat3/core`'s `runtime-metrics.ts` because that file is the
91
+ * series EVERY Ultimate process emits and the deploy chart scales on; this one exists only where
92
+ * channels do. **No attributes**: a topic is client-chosen (`topic()` admits any
93
+ * `[A-Za-z0-9_-]+` segment), so a per-topic label is an unbounded series count one socket can mint
94
+ * — the topic goes in the log line, where cardinality is somebody else's index.
95
+ */
96
+ const channelFramesDropped: Counter = counter('channel_frames_dropped_total', {
97
+ unit: '{frame}',
98
+ description: 'Channel frames dropped by socket backpressure — unrecoverable, nothing replays one',
99
+ });
100
+
42
101
  export function actorIdOf(actor: Actor | null): string | null {
43
102
  return actor === null ? null : actor.id;
44
103
  }
@@ -58,6 +117,13 @@ export class SyncSocket {
58
117
  * flush re-snapshots instead of silently diverging.
59
118
  */
60
119
  readonly desynced = new Set<string>();
120
+ /**
121
+ * Inbound frames this socket may still have routed. The accept budget spends one token per
122
+ * UPGRADE, so nothing bounded what happened after: one authenticated socket reached a DB read,
123
+ * a shared-store presence write and a fleet-wide publish once per frame, unbounded and
124
+ * unawaited. Same token bucket, one per socket — the mechanism already existed, one rung down.
125
+ */
126
+ readonly frameBudget: AcceptBudget;
61
127
 
62
128
  actor: Actor | null;
63
129
  lastSeenAt: number;
@@ -77,8 +143,13 @@ export class SyncSocket {
77
143
  this.clientBuildId = options.clientBuildId;
78
144
  this.serverBuildId = options.serverBuildId;
79
145
  this.actor = options.actor ?? null;
80
- this.#maxBufferedBytes = options.maxBufferedBytes ?? 1024 * 1024;
146
+ this.#maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
81
147
  this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
148
+ this.frameBudget = new AcceptBudget({
149
+ perSecond: options.maxFramesPerSecond ?? DEFAULT_MAX_FRAMES_PER_SECOND,
150
+ burst: options.frameBurst ?? DEFAULT_FRAME_BURST,
151
+ clock: this.#clock,
152
+ });
82
153
  this.openedAt = this.#clock.now().getTime();
83
154
  this.lastSeenAt = this.openedAt;
84
155
  }
@@ -120,14 +191,18 @@ export class SyncSocket {
120
191
  this.desynced.delete(sid);
121
192
  }
122
193
 
194
+ /**
195
+ * This socket's own membership, and nothing else. It used to also call Bun's `ws.subscribe`,
196
+ * which built a second per-topic index nothing ever published to — the fanout is
197
+ * `SocketRegistry.deliver`, one filtered `send` per socket, because that is the only path that
198
+ * can count a dropped frame or close a socket that is drowning in them.
199
+ */
123
200
  subscribeTopic(topic: string): void {
124
201
  this.topics.add(topic);
125
- this.#ws.subscribe(topic);
126
202
  }
127
203
 
128
204
  unsubscribeTopic(topic: string): void {
129
205
  this.topics.delete(topic);
130
- this.#ws.unsubscribe(topic);
131
206
  }
132
207
 
133
208
  touch(): void {
@@ -154,8 +229,18 @@ export interface SocketRegistryOptions {
154
229
  /** Per-node socket table. Intentionally the only in-memory map on a `sync` node. */
155
230
  export class SocketRegistry {
156
231
  readonly #sockets = new Map<string, SyncSocket>();
232
+ /**
233
+ * Who is on each channel topic. `deliver` walked every socket on the node asking each whether
234
+ * it held the topic, so one message with one legitimate subscriber cost as many iterations as
235
+ * this node has connections — 50,000 at the scale this framework benchmarks. It lives here
236
+ * rather than on the hub because this is the only object that sees a socket die: a close, a
237
+ * drain and the idle sweep all pass through `remove`, and an index nobody cleans on those paths
238
+ * retains a dead socket per topic forever.
239
+ */
240
+ readonly #byTopic = new Map<string, Set<SyncSocket>>();
157
241
  readonly #clock: Clock;
158
242
  readonly #idleTimeoutMs: number;
243
+ #droppedChannelFrames = 0;
159
244
 
160
245
  constructor(options: SocketRegistryOptions = {}) {
161
246
  this.#clock = options.clock ?? systemClock;
@@ -178,8 +263,39 @@ export class SocketRegistry {
178
263
  }
179
264
 
180
265
  remove(id: string): void {
266
+ const socket = this.#sockets.get(id);
181
267
  // `Map.delete` answers "was it actually there", so a double close cannot decrement twice.
182
- if (this.#sockets.delete(id)) recordConnection(-1);
268
+ if (!this.#sockets.delete(id)) return;
269
+ recordConnection(-1);
270
+ if (!socket) return;
271
+ // Leaving this table IS the close, whoever noticed first. Bun's `close` callback reports a
272
+ // connection that has already gone, so nothing called `close()` on this object and
273
+ // `socket.closed` stayed false — leaving a subscribe still awaiting its snapshot read with no
274
+ // way to tell that the socket it is about to attach to was torn down while it read.
275
+ socket.close(CLOSE.goingAway, 'connection closed');
276
+ for (const name of socket.topics) this.#dropFrom(name, socket);
277
+ }
278
+
279
+ /**
280
+ * Join a topic: the socket's own set, Bun's native pub/sub and this node's delivery index, in
281
+ * one call. `ChannelHub` is its only caller, and calls nothing else — two call sites for one
282
+ * membership is how an index goes wrong.
283
+ */
284
+ joinTopic(socket: SyncSocket, topic: string): void {
285
+ socket.subscribeTopic(topic);
286
+ const members = this.#byTopic.get(topic);
287
+ if (members) members.add(socket);
288
+ else this.#byTopic.set(topic, new Set([socket]));
289
+ }
290
+
291
+ leaveTopic(socket: SyncSocket, topic: string): void {
292
+ socket.unsubscribeTopic(topic);
293
+ this.#dropFrom(topic, socket);
294
+ }
295
+
296
+ /** Sockets this node would deliver `topic` to. */
297
+ subscriberCount(topic: string): number {
298
+ return this.#byTopic.get(topic)?.size ?? 0;
183
299
  }
184
300
 
185
301
  get(id: string): SyncSocket | undefined {
@@ -209,13 +325,53 @@ export class SocketRegistry {
209
325
  return closed;
210
326
  }
211
327
 
212
- /** Local delivery for a channel topic. Bun's native pub/sub handles the common case; this is
213
- * the fallback used when a frame must be filtered per socket (policy, desync bookkeeping). */
328
+ /**
329
+ * Local delivery for a channel topic every channel message on this node comes through here,
330
+ * so it reads the per-topic index rather than the socket table. A socket that closed without
331
+ * an unsubscribe is dropped as it is met, so the index cannot outlive the connection even on a
332
+ * path that forgot to `remove` it.
333
+ */
214
334
  deliver(topic: string, frame: Frame): number {
335
+ const members = this.#byTopic.get(topic);
336
+ if (!members) return 0;
215
337
  let sent = 0;
216
- for (const socket of this.#sockets.values()) {
217
- if (socket.topics.has(topic) && socket.send(frame)) sent += 1;
338
+ let dropped = 0;
339
+ for (const socket of members) {
340
+ if (socket.closed) {
341
+ members.delete(socket);
342
+ continue;
343
+ }
344
+ if (socket.send(frame)) sent += 1;
345
+ else dropped += 1;
346
+ }
347
+ if (members.size === 0) this.#byTopic.delete(topic);
348
+ if (dropped > 0) {
349
+ this.#droppedChannelFrames += dropped;
350
+ // Two readers, one event, one spelling: the series an operator alerts on and the line that
351
+ // says which topic it was. `deliver` ignored `send`'s answer and so did the hub above it, so
352
+ // until both existed a lost channel message left no trace at all.
353
+ channelFramesDropped.add(dropped);
354
+ logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
218
355
  }
219
356
  return sent;
220
357
  }
358
+
359
+ /**
360
+ * Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
361
+ * of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
362
+ *
363
+ * Node-wide on purpose: a socket past `maxDroppedFrames` is closed and removed, so a per-socket
364
+ * count leaves with the socket exactly when loss is worst. Distinct from `SyncSocket.droppedFrames`,
365
+ * which counts every kind of frame one connection lost, channel and live-query patch alike.
366
+ */
367
+ get droppedChannelFrames(): number {
368
+ return this.#droppedChannelFrames;
369
+ }
370
+
371
+ #dropFrom(topic: string, socket: SyncSocket): void {
372
+ const members = this.#byTopic.get(topic);
373
+ if (!members) return;
374
+ members.delete(socket);
375
+ if (members.size === 0) this.#byTopic.delete(topic);
376
+ }
221
377
  }