@ultimat3/realtime 21.0.0 → 22.1.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 (55) hide show
  1. package/CLAUDE.md +302 -1009
  2. package/README.md +130 -26
  3. package/package.json +4 -4
  4. package/src/changefeed.ts +7 -1
  5. package/src/channel-authz.ts +23 -4
  6. package/src/channel-decl.ts +16 -5
  7. package/src/channel-describe.ts +7 -5
  8. package/src/channel-logs.ts +19 -1
  9. package/src/channel-records.ts +8 -0
  10. package/src/client-channels.ts +75 -5
  11. package/src/client.ts +14 -2
  12. package/src/cursor.ts +5 -0
  13. package/src/errors.ts +43 -1
  14. package/src/idb-fake.ts +24 -4
  15. package/src/idb-types.ts +7 -0
  16. package/src/index.ts +1 -1
  17. package/src/live-definition.ts +5 -1
  18. package/src/live-fanout.ts +51 -2
  19. package/src/live-query.ts +11 -0
  20. package/src/live-replicator.ts +160 -0
  21. package/src/local-store-idb.ts +89 -15
  22. package/src/matcher-bridge.ts +5 -0
  23. package/src/nats-fake.ts +10 -1
  24. package/src/nats-jetstream.ts +36 -14
  25. package/src/nats-transport.ts +2 -2
  26. package/src/offline-queue.ts +76 -21
  27. package/src/page-outbox.ts +80 -10
  28. package/src/page-socket.ts +39 -8
  29. package/src/pg-entity-row.ts +37 -184
  30. package/src/pg-identifier.ts +23 -0
  31. package/src/pg-preflight.ts +32 -45
  32. package/src/pg-publication.ts +95 -0
  33. package/src/pg-replication.ts +21 -7
  34. package/src/pg-socket.ts +139 -53
  35. package/src/pg-tls.ts +124 -0
  36. package/src/pg-wire.ts +65 -16
  37. package/src/policy-fake.ts +14 -0
  38. package/src/query-window.ts +35 -21
  39. package/src/replication-errors.ts +29 -16
  40. package/src/replicator.ts +13 -3
  41. package/src/server.ts +8 -3
  42. package/src/socket-drops.ts +30 -0
  43. package/src/socket-engine.ts +15 -3
  44. package/src/socket-host.ts +103 -4
  45. package/src/socket-idle.ts +21 -0
  46. package/src/socket.ts +41 -38
  47. package/src/subscriber-gate.ts +92 -3
  48. package/src/sync-node-contract.ts +6 -0
  49. package/src/sync-node.ts +3 -7
  50. package/src/sync-origin.ts +33 -0
  51. package/src/sync-upgrade.ts +33 -9
  52. package/src/thundering-herd.ts +21 -11
  53. package/src/transport-env.ts +55 -14
  54. package/src/use-mutation.ts +13 -0
  55. package/src/use-query.ts +10 -5
@@ -1,5 +1,5 @@
1
- // The four refusals the Postgres replication half raises: the wire, the connection, the slot, and
2
- // the replica identity it warns about.
1
+ // The five refusals the Postgres replication half raises: the wire, the connection, its TLS, the
2
+ // slot, and the replica identity it warns about.
3
3
  //
4
4
  // Split out of `errors.ts` on the one seam this package already draws — these are the only codes
5
5
  // no browser can reach, thrown by `pg-*.ts` and the replicator and by nothing on the client half.
@@ -40,6 +40,22 @@ export class ReplicationFailedError extends RealtimeError {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * The replication connection failed TLS: the certificate failed the verification the `sslmode`
45
+ * asked for, `sslrootcert` named nothing readable, or the handshake itself failed. Its own code
46
+ * because the fix is a TLS setting, never the network — a certificate refusal used to surface as
47
+ * `X_REPLICATION_FAILED` "the socket refused a 139-byte write", one step after a silent close.
48
+ */
49
+ export class ReplicationTlsError extends RealtimeError {
50
+ constructor(args: { detail: string; fix: string }) {
51
+ super({
52
+ code: 'X_REPLICATION_TLS',
53
+ cause: `postgres replication tls failed: ${args.detail}`,
54
+ fix: args.fix,
55
+ });
56
+ }
57
+ }
58
+
43
59
  /**
44
60
  * A second replicator found the advisory lock held. Distinct from `X_REPLICATION_FAILED` because
45
61
  * nothing is wrong with this process: the database already has its one replicator, and a second
@@ -59,29 +75,26 @@ export class ReplicatorSlotHeldError extends RealtimeError {
59
75
  }
60
76
 
61
77
  /**
62
- * A table in the entity list replicates with a replica identity other than FULL, so its `delete`
63
- * (and any key-changing `update`) carries the KEY COLUMNS ONLY. `toRow` accepts that tuple —
64
- * it only requires a text `id` — so the live matcher decides "did this row leave the result set"
65
- * from a one-column row, and a row policy written against `!row.private` reads `undefined`.
66
- *
67
- * **Raised at preflight and LOGGED, never thrown.** Every app running today on the default
68
- * identity would stop booting, and the replicator refusing to start is a worse outcome than the
69
- * partial rows it is warning about. The runtime half is `ReplicationStreamStats.partialBefore`,
70
- * which counts the changes this actually affects. Refusing it at `x verify` time is the follow-up.
78
+ * A table in the entity list has NO replica identity — no primary key under DEFAULT, or
79
+ * `REPLICA IDENTITY NOTHING`. Once it is in the publication Postgres refuses its UPDATE and DELETE
80
+ * (`cannot update table … because it does not have a replica identity and publishes updates`),
81
+ * and a change the replicator did see could not be keyed. A keyed table under DEFAULT is correct
82
+ * and is never named: the shared window holds the whole row a live query decides on.
71
83
  *
72
- * The tables are named because the fix is per table, and they are the entity list's own names —
73
- * every one has already passed `assertIdentifier`, so the `fix:` is SQL that can be pasted.
84
+ * **Raised at preflight and LOGGED, never thrown**, as it always was: a replicator that will not
85
+ * start is worse than the tables it names. The tables are the entity list's own names — every one
86
+ * has already passed `assertIdentifier`, so the `fix:` is SQL that can be pasted.
74
87
  */
75
88
  export class ReplicaIdentityError extends RealtimeError {
76
89
  constructor(args: { tables: readonly string[] }) {
77
90
  super({
78
91
  code: 'X_LIVE_REPLICA_IDENTITY',
79
92
  cause:
80
- `${args.tables.join(', ')} replicate with a replica identity other than FULL, so a ` +
81
- 'delete carries the key columns only and a live query decides visibility from a partial row',
93
+ `${args.tables.join(', ')} have no replica identity (no primary key, or REPLICA IDENTITY ` +
94
+ 'NOTHING), so once published an UPDATE or DELETE on them fails and a change cannot be keyed',
82
95
  fix:
83
96
  `${args.tables.map((table) => `ALTER TABLE ${table} REPLICA IDENTITY FULL;`).join(' ')}` +
84
- ' -- rows already written to the WAL keep the identity they were written with',
97
+ ' -- or give each a primary key; rows already in the WAL keep the identity they were written with',
85
98
  });
86
99
  }
87
100
  }
package/src/replicator.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  import { isWriteDigest, logger, uuid, withSpan } from '@ultimat3/core';
15
15
  import type { ChangeEvent, ChangeFeed } from './changefeed';
16
16
  import type { Transport } from './fanout';
17
- import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
17
+ import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
18
18
 
19
19
  export const CHANGE_SUBJECT_PREFIX = 'x.change';
20
20
 
@@ -225,13 +225,16 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
225
225
  },
226
226
 
227
227
  retryDelayMs(attempt: number): number {
228
- return backoffDelay(attempt, backoff, options.rng ?? Math.random);
228
+ // This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
229
+ return policyDelay(backoff, attempt + 1, options.rng ?? Math.random);
229
230
  },
230
231
  };
231
232
  }
232
233
 
233
234
  /** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
234
235
  export function normalize(change: ChangeEvent): ChangeEvent | null {
236
+ // The one change that carries no row by design; nothing to hoist a tenant out of.
237
+ if (change.op === 'truncate') return change;
235
238
  const row = change.after ?? change.before;
236
239
  if (!row) return null;
237
240
  if (change.op === 'insert' && change.after === null) return null;
@@ -253,7 +256,14 @@ export function parseEnvelope(payload: string): ChangeEnvelope | null {
253
256
  if (typeof parsed !== 'object' || parsed === null) return null;
254
257
  const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
255
258
  if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
256
- if (shape.op !== 'insert' && shape.op !== 'update' && shape.op !== 'delete') return null;
259
+ if (
260
+ shape.op !== 'insert' &&
261
+ shape.op !== 'update' &&
262
+ shape.op !== 'delete' &&
263
+ shape.op !== 'truncate'
264
+ ) {
265
+ return null;
266
+ }
257
267
  return {
258
268
  change: {
259
269
  entity: shape.entity,
package/src/server.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  DEFAULT_MAX_TOPICS_PER_NODE,
42
42
  } from './channel';
43
43
  export { type ChannelDescription, describeChannels } from './channel-describe';
44
+ export { RealtimeTopologyError } from './errors';
44
45
  export {
45
46
  InProcessTransport,
46
47
  type InProcessTransportOptions,
@@ -62,6 +63,11 @@ export {
62
63
  LiveQueryRegistry,
63
64
  type LiveQueryRegistryOptions,
64
65
  } from './live-query';
66
+ export {
67
+ type LiveReplicator,
68
+ type LiveReplicatorOptions,
69
+ startLiveReplicator,
70
+ } from './live-replicator';
65
71
  export {
66
72
  applyToWindow,
67
73
  type BridgeResult,
@@ -109,7 +115,7 @@ export { openNatsClient } from './nats-lib-client';
109
115
  export { NatsTransport, type NatsTransportOptions } from './nats-transport';
110
116
  export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
111
117
  // ---- the postgres replication path ------------------------------------------------------------
112
- export { camel, entityRow } from './pg-entity-row';
118
+ export { entityRow } from './pg-entity-row';
113
119
  export {
114
120
  changeLsn,
115
121
  commitPositionOf,
@@ -166,16 +172,15 @@ export {
166
172
  actorIdOf,
167
173
  CLOSE,
168
174
  DEFAULT_FRAME_BURST,
169
- DEFAULT_IDLE_TIMEOUT_MS,
170
175
  DEFAULT_MAX_BUFFERED_BYTES,
171
176
  DEFAULT_MAX_FRAMES_PER_SECOND,
172
- idleSweepPeriodMs,
173
177
  SocketRegistry,
174
178
  type SocketRegistryOptions,
175
179
  SyncSocket,
176
180
  type SyncSocketOptions,
177
181
  type WsLike,
178
182
  } from './socket';
183
+ export { DEFAULT_IDLE_TIMEOUT_MS, idleSweepPeriodMs } from './socket-idle';
179
184
  export type {
180
185
  GateFailed,
181
186
  GateStage,
@@ -0,0 +1,30 @@
1
+ // How many dropped frames close a socket: more than `maxDroppedFrames` inside one window, never a
2
+ // lifetime count. Split from `socket.ts`, which counts the drops; this decides what they mean.
3
+
4
+ /**
5
+ * The window `maxDroppedFrames` is counted over. Ten seconds: long enough that a burst of
6
+ * backpressure drops closes a socket that cannot keep up, short enough that a connection open for
7
+ * hours is never closed for a lifetime's worth of isolated drops.
8
+ */
9
+ export const DROP_WINDOW_MS = 10_000;
10
+
11
+ /**
12
+ * The instants of the drops inside the current window. A burst of backpressure drops closes a
13
+ * socket that cannot keep up; the same number spread over a connection open for hours is a flaky
14
+ * link the cursor repairs, and closing for it was a healthy socket lost after 33 lifetime drops.
15
+ */
16
+ export class DropWindow {
17
+ readonly #max: number;
18
+ readonly #recent: number[] = [];
19
+
20
+ constructor(max: number) {
21
+ this.#max = max;
22
+ }
23
+
24
+ /** Records one drop at `now` (monotonic ms) and answers whether the window is over its ceiling. */
25
+ overflowed(now: number): boolean {
26
+ this.#recent.push(now);
27
+ while ((this.#recent[0] ?? now) <= now - DROP_WINDOW_MS) this.#recent.shift();
28
+ return this.#recent.length > this.#max;
29
+ }
30
+ }
@@ -13,8 +13,8 @@ import { PortRouter } from './socket-routes';
13
13
  import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
14
14
  import {
15
15
  type BackoffPolicy,
16
- backoffDelay,
17
16
  browserBackoff,
17
+ policyDelay,
18
18
  type Rng,
19
19
  type Scheduler,
20
20
  timeoutScheduler,
@@ -250,7 +250,8 @@ export class SocketEngine {
250
250
  if (this.#reconnect !== null) return;
251
251
  const rng = this.#options.rng ?? Math.random;
252
252
  const delay =
253
- afterMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? browserBackoff, rng);
253
+ // `#attempt` counts reconnects already scheduled, from 0; the wait being armed is the next one.
254
+ afterMs ?? policyDelay(this.#options.backoff ?? browserBackoff, this.#attempt + 1, rng);
254
255
  this.#attempt += 1;
255
256
  this.#reconnect = this.#schedule(() => {
256
257
  this.#reconnect = null;
@@ -284,6 +285,17 @@ export class SocketEngine {
284
285
  socket?.close(1000, 'no tab left');
285
286
  }
286
287
 
288
+ /**
289
+ * A silent port, released — and TOLD first. Silent is usually a closed tab, but a hidden tab the
290
+ * browser throttled is silent too, and it was released without a word: its virtual socket stayed
291
+ * "open" over a port nobody read, and that tab's realtime was dead until a reload. Told, its
292
+ * client goes offline and redials, and the page re-hosts (`socket-host.ts`'s `rehosting`).
293
+ */
294
+ #reap(attached: AttachedPort): void {
295
+ if (attached.open) this.#post(attached, { t: 'close', code: 1006 });
296
+ this.#detach(attached);
297
+ }
298
+
287
299
  /** A `MessagePort` has no close event: a port silent for three beats is a closed tab. */
288
300
  #armReaper(): void {
289
301
  if (this.#reaper !== null) return;
@@ -291,7 +303,7 @@ export class SocketEngine {
291
303
  this.#reaper = null;
292
304
  const cutoff = this.#now() - REAP_AFTER_BEATS * this.#beatMs;
293
305
  for (const attached of [...this.#ports.values()]) {
294
- if (attached.lastSeen < cutoff) this.#detach(attached);
306
+ if (attached.lastSeen < cutoff) this.#reap(attached);
295
307
  }
296
308
  if (this.#ports.size > 0) this.#armReaper();
297
309
  }, this.#beatMs);
@@ -7,12 +7,15 @@ import { browserSocket, dialUrl } from './browser-socket';
7
7
  import type { ClientSocket } from './client-contract';
8
8
  import type { SyncTarget } from './page-store';
9
9
  import { messagePort, type PortLike, SocketEngine } from './socket-engine';
10
+ import { type Scheduler, timeoutScheduler } from './thundering-herd';
10
11
 
11
12
  export interface SocketHostOptions {
12
13
  /** The built worker script (`<meta name="ultimate-sync-worker">`); absent = in-page host. */
13
14
  readonly workerUrl?: string | undefined;
14
15
  /** The principal the page acts for: one worker per principal, never a shared socket across two. */
15
16
  readonly scope: string | null;
17
+ /** The build the page was rendered by: one worker per build, too. See `workerName`. */
18
+ readonly buildId?: string | undefined;
16
19
  /** Injected for tests; production reads the globals. */
17
20
  readonly sharedWorker?: SharedWorkerLike | undefined;
18
21
  readonly inPageEngine?: () => SocketEngine;
@@ -32,9 +35,13 @@ export interface SocketHost {
32
35
  bye(): void;
33
36
  }
34
37
 
35
- /** The worker's name carries the scope, so two principals in two tabs get two workers. */
36
- export function workerName(scope: string | null): string {
37
- return `ultimate-sync:${encodeURIComponent(scope ?? '')}`;
38
+ /**
39
+ * The worker's name carries the scope, so two principals in two tabs get two workers — and the
40
+ * BUILD, so two builds do too. A SharedWorker keeps the first tab's build id for its whole life, so
41
+ * a tab of the new build joined the old engine and was told "update available" about itself.
42
+ */
43
+ export function workerName(scope: string | null, buildId: string | undefined): string {
44
+ return `ultimate-sync:${encodeURIComponent(scope ?? '')}:${encodeURIComponent(buildId ?? '')}`;
38
45
  }
39
46
 
40
47
  export function openHost(options: SocketHostOptions): SocketHost {
@@ -55,7 +62,9 @@ function workerPort(options: SocketHostOptions): PortLike | undefined {
55
62
  (typeof SharedWorker === 'function' ? (SharedWorker as SharedWorkerLike) : undefined);
56
63
  if (Worker === undefined || options.workerUrl === undefined) return undefined;
57
64
  try {
58
- return messagePort(new Worker(options.workerUrl, { name: workerName(options.scope) }).port);
65
+ return messagePort(
66
+ new Worker(options.workerUrl, { name: workerName(options.scope, options.buildId) }).port,
67
+ );
59
68
  } catch {
60
69
  return undefined;
61
70
  }
@@ -124,3 +133,93 @@ class VirtualSocket implements ClientSocket {
124
133
  }
125
134
  }
126
135
  }
136
+
137
+ /** A host that can be replaced under the page's one client. */
138
+ export interface RehostingHost extends SocketHost {
139
+ /** Say bye to the current host and build a new one — the next `socket()` dials through it. */
140
+ rehost(): void;
141
+ }
142
+
143
+ export interface RehostingOptions {
144
+ /** How long a virtual socket may wait for `open` before its host is presumed dead. */
145
+ readonly openTimeoutMs: number;
146
+ readonly schedule?: Scheduler | undefined;
147
+ }
148
+
149
+ /**
150
+ * The page's host, replaceable. A port the engine reaped (a throttled hidden tab), or one the tab
151
+ * itself said `bye` on at `pagehide` before a bfcache restore, is a port nobody reads: the virtual
152
+ * socket over it waited for `open` forever and the tab's realtime was dead until a reload. So an
153
+ * `open` unanswered by `openTimeoutMs` closes that socket (1006, which the client redials on) and
154
+ * re-hosts on a fresh port; `page-socket.ts` also re-hosts on a bfcache `pageshow`.
155
+ */
156
+ export function rehosting(make: () => SocketHost, options: RehostingOptions): RehostingHost {
157
+ const schedule = options.schedule ?? timeoutScheduler;
158
+ let current = make();
159
+ const self: RehostingHost = {
160
+ get kind(): 'worker' | 'in-page' {
161
+ return current.kind;
162
+ },
163
+ socket: (target) => {
164
+ const inner = current.socket(target);
165
+ return watchOpen(inner, schedule, options.openTimeoutMs, () => self.rehost());
166
+ },
167
+ bye: () => current.bye(),
168
+ rehost: () => {
169
+ current.bye();
170
+ current = make();
171
+ },
172
+ };
173
+ return self;
174
+ }
175
+
176
+ /** `inner`, with a deadline on its `open`: missed, the host is re-made and the socket closes. */
177
+ function watchOpen(
178
+ inner: ClientSocket,
179
+ schedule: Scheduler,
180
+ ms: number,
181
+ rehost: () => void,
182
+ ): ClientSocket {
183
+ let opened: (() => void) | null = null;
184
+ let closed: ((code: number) => void) | null = null;
185
+ let settled = false;
186
+ const disarm = schedule(() => {
187
+ if (settled) return;
188
+ settled = true;
189
+ rehost();
190
+ closed?.(1006);
191
+ }, ms);
192
+ inner.onOpen(() => {
193
+ if (settled) return;
194
+ settled = true;
195
+ disarm();
196
+ opened?.();
197
+ });
198
+ inner.onClose((code) => {
199
+ if (!settled) {
200
+ settled = true;
201
+ disarm();
202
+ }
203
+ closed?.(code);
204
+ });
205
+ return {
206
+ get bufferedAmount(): number {
207
+ return inner.bufferedAmount ?? 0;
208
+ },
209
+ send: (data) => inner.send(data),
210
+ close: (code, reason) => {
211
+ if (!settled) {
212
+ settled = true;
213
+ disarm();
214
+ }
215
+ inner.close(code, reason);
216
+ },
217
+ onOpen: (handler) => {
218
+ opened = handler;
219
+ },
220
+ onMessage: (handler) => inner.onMessage(handler),
221
+ onClose: (handler) => {
222
+ closed = handler;
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,21 @@
1
+ // The application idle budget a sync node evicts a silent socket on, and how often it asks. Split
2
+ // from `socket.ts` at its line ceiling.
3
+
4
+ /**
5
+ * How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
6
+ * inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
7
+ * ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
8
+ * its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
9
+ * every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
10
+ */
11
+ export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
12
+
13
+ /**
14
+ * How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
15
+ * of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
16
+ * four times per window rather than once a second. Derived rather than configured — a second knob
17
+ * is a second number that can disagree with the one it is a fraction of.
18
+ */
19
+ export function idleSweepPeriodMs(idleTimeoutMs: number): number {
20
+ return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
21
+ }
package/src/socket.ts CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  import { GapRepairs } from './channel-gaps';
20
20
  import type { ChannelRecordsFrame } from './channel-wire';
21
21
  import { CLOSE } from './close-codes';
22
+ import { DropWindow } from './socket-drops';
23
+ import { DEFAULT_IDLE_TIMEOUT_MS } from './socket-idle';
22
24
  import { encode, type Frame } from './sync-protocol';
23
25
  import { AcceptBudget } from './thundering-herd';
24
26
 
@@ -156,6 +158,7 @@ export class SyncSocket {
156
158
  readonly #maxDroppedFrames: number;
157
159
  #clientBuildId: string;
158
160
  #closed = false;
161
+ readonly #drops: DropWindow;
159
162
 
160
163
  constructor(options: SyncSocketOptions) {
161
164
  this.#ws = options.ws;
@@ -168,6 +171,7 @@ export class SyncSocket {
168
171
  finiteOption('SyncSocket', 'maxBufferedBytes', this.#maxBufferedBytes);
169
172
  this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
170
173
  finiteOption('SyncSocket', 'maxDroppedFrames', this.#maxDroppedFrames);
174
+ this.#drops = new DropWindow(this.#maxDroppedFrames);
171
175
  this.frameBudget = new AcceptBudget({
172
176
  perSecond: finiteOption(
173
177
  'SyncSocket',
@@ -215,33 +219,34 @@ export class SyncSocket {
215
219
 
216
220
  /** `false` means the frame was dropped by backpressure — the caller must mark state stale. */
217
221
  send(frame: Frame): boolean {
222
+ return this.sendEncoded(encode(frame));
223
+ }
224
+
225
+ /** An `encode()`d frame — the fan-outs encode once for every socket. Adds no validation. */
226
+ sendEncoded(text: string): boolean {
218
227
  if (this.#closed) return false;
219
228
  if (this.#ws.getBufferedAmount() > this.#maxBufferedBytes) {
220
- this.droppedFrames += 1;
221
- if (this.droppedFrames > this.#maxDroppedFrames) {
222
- this.close(CLOSE.overloaded, 'backpressure');
223
- }
229
+ this.#dropped();
224
230
  return false;
225
231
  }
226
- // `WsLike.send` is declared `: number` for this line and no other: Bun answers `0` for a
227
- // message it DROPPED — the socket closed between the buffered-amount check above and this
228
- // write — and `-1` under backpressure. Discarded, a dropped frame read as delivered, so
229
- // `live-fanout` advanced the subscriber's cursor past a patch that never left and
230
- // `sync-frames`' desync mark was never taken: permanently stale on a healthy socket, which is
231
- // the exact outcome every other `socket.send` on this node reads its answer to prevent.
232
- if (this.#ws.send(encode(frame)) <= 0) {
233
- this.droppedFrames += 1;
234
- // The same ceiling backpressure takes: a socket the runtime keeps refusing is one to close,
235
- // and the two are one failure — the write went nowhere either way.
236
- if (this.droppedFrames > this.#maxDroppedFrames) {
237
- this.close(CLOSE.overloaded, 'backpressure');
238
- }
232
+ // Bun answers `0` for a message it DROPPED (the socket closed after the check above) — the one
233
+ // drop. `-1` means QUEUED under backpressure, and it is delivered: counted as a drop it sent a
234
+ // spurious `replay-gap`, desynced a healthy subscriber, and closed the socket after 33.
235
+ if (this.#ws.send(text) === 0) {
236
+ this.#dropped();
239
237
  return false;
240
238
  }
241
239
  this.sentFrames += 1;
242
240
  return true;
243
241
  }
244
242
 
243
+ /** One frame that never left: counted for life, judged per `DROP_WINDOW_MS` (`socket-drops.ts`). */
244
+ #dropped(): void {
245
+ this.droppedFrames += 1;
246
+ if (this.#drops.overflowed(this.#clock.monotonic()))
247
+ this.close(CLOSE.overloaded, 'backpressure');
248
+ }
249
+
245
250
  /** Record a dropped or invalidated subscription so the next flush re-snapshots it. */
246
251
  markDesynced(sid: string): void {
247
252
  this.desynced.add(sid);
@@ -282,25 +287,6 @@ export class SyncSocket {
282
287
  }
283
288
  }
284
289
 
285
- /**
286
- * How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
287
- * inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
288
- * ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
289
- * its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
290
- * every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
291
- */
292
- export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
293
-
294
- /**
295
- * How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
296
- * of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
297
- * four times per window rather than once a second. Derived rather than configured — a second knob
298
- * is a second number that can disagree with the one it is a fraction of.
299
- */
300
- export function idleSweepPeriodMs(idleTimeoutMs: number): number {
301
- return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
302
- }
303
-
304
290
  export interface SocketRegistryOptions {
305
291
  readonly clock?: Clock;
306
292
  /** Bun's own `idleTimeout` is renewed by its ping/pong; this budget counts routed FRAMES. */
@@ -425,6 +411,9 @@ export class SocketRegistry {
425
411
  deliver(topic: string, frame: Frame): number {
426
412
  const members = this.#byTopic.get(topic);
427
413
  if (!members) return 0;
414
+ // Encoded ONCE for every member: per socket it was 16.9 ms against 0.6 ms for a 2 kB frame to
415
+ // 10,000 sockets, measured.
416
+ const text = encode(frame);
428
417
  let sent = 0;
429
418
  let dropped = 0;
430
419
  for (const socket of members) {
@@ -432,7 +421,7 @@ export class SocketRegistry {
432
421
  members.delete(socket);
433
422
  continue;
434
423
  }
435
- if (socket.send(frame)) sent += 1;
424
+ if (socket.sendEncoded(text)) sent += 1;
436
425
  else dropped += 1;
437
426
  }
438
427
  if (members.size === 0) this.#byTopic.delete(topic);
@@ -447,6 +436,7 @@ export class SocketRegistry {
447
436
  deliverRecords(topic: string, epoch: string, frame: ChannelRecordsFrame): number {
448
437
  const members = this.#byTopic.get(topic);
449
438
  if (!members) return 0;
439
+ const text = encode(frame);
450
440
  let sent = 0;
451
441
  let dropped = 0;
452
442
  for (const socket of members) {
@@ -455,7 +445,7 @@ export class SocketRegistry {
455
445
  continue;
456
446
  }
457
447
  this.gapRepairs.repair(socket, topic);
458
- if (socket.send(frame)) {
448
+ if (socket.sendEncoded(text)) {
459
449
  sent += 1;
460
450
  continue;
461
451
  }
@@ -467,6 +457,19 @@ export class SocketRegistry {
467
457
  return sent;
468
458
  }
469
459
 
460
+ /** Every member owes a re-read (a TRUNCATE): one `replay-gap` each now, or kept as a mark. */
461
+ announceGap(topic: string, epoch: string): number {
462
+ const members = this.#byTopic.get(topic);
463
+ if (!members) return 0;
464
+ let announced = 0;
465
+ for (const socket of members) {
466
+ if (socket.closed) continue;
467
+ socket.gaps.set(topic, epoch);
468
+ if (this.gapRepairs.repair(socket, topic)) announced += 1;
469
+ }
470
+ return announced;
471
+ }
472
+
470
473
  #countDropped(topic: string, dropped: number): void {
471
474
  this.#droppedChannelFrames += dropped;
472
475
  // Two readers, one event, one spelling: the series an operator alerts on and the line that