@ultimat3/realtime 20.2.1 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -0,0 +1,55 @@
1
+ // The engine ⇄ tab seam: the messages a `MessagePort` carries, the part of a port the engine uses,
2
+ // and what the engine remembers about each attached one. Shared by the engine and its router.
3
+
4
+ import type { SyncTarget } from './page-store';
5
+ import type { SubscribeFrame } from './sync-protocol';
6
+
7
+ /** The engine ⇄ tab messages. `frame.data` is a wire frame, encoded exactly as a socket carries it. */
8
+ export type PortMessage =
9
+ | { readonly t: 'open'; readonly target: SyncTarget }
10
+ | { readonly t: 'frame'; readonly data: string }
11
+ | { readonly t: 'close'; readonly code: number }
12
+ /** The tab is going away (`pagehide`, a principal change): release the port itself. */
13
+ | { readonly t: 'bye' };
14
+
15
+ /** The part of a `MessagePort` the engine uses — so a test hands in a `MessageChannel` port. */
16
+ export interface PortLike {
17
+ postMessage(message: PortMessage): void;
18
+ onmessage: ((event: { readonly data: unknown }) => void) | null;
19
+ close?(): void;
20
+ }
21
+
22
+ /** One tab, as the engine knows it. */
23
+ export interface AttachedPort {
24
+ readonly id: number;
25
+ readonly port: PortLike;
26
+ /** The tab asked for its virtual socket to be open. */
27
+ open: boolean;
28
+ /** It has asked before: a later `open` is the tab's own retry, not a page arriving. */
29
+ asked: boolean;
30
+ lastSeen: number;
31
+ /** Channel topics this port wants. */
32
+ readonly topics: Set<string>;
33
+ /** Engine sid → the add frame, for the live queries this port holds. */
34
+ readonly lives: Map<string, SubscribeFrame>;
35
+ }
36
+
37
+ /**
38
+ * A real `MessagePort` as a `PortLike`. Setting `onmessage` on a `MessagePort` starts it, which is
39
+ * what both hosts rely on; the wrapper exists only because the DOM types a handler over
40
+ * `MessageEvent` and the engine reads nothing of one but `data`.
41
+ */
42
+ export function messagePort(port: MessagePort): PortLike {
43
+ let handler: PortLike['onmessage'] = null;
44
+ return {
45
+ postMessage: (message) => port.postMessage(message),
46
+ get onmessage() {
47
+ return handler;
48
+ },
49
+ set onmessage(next) {
50
+ handler = next;
51
+ port.onmessage = next === null ? null : (event: MessageEvent) => next({ data: event.data });
52
+ },
53
+ close: () => port.close(),
54
+ };
55
+ }
@@ -0,0 +1,170 @@
1
+ // The engine's multiplexing: which port wants which channel topic and which live query, so the
2
+ // one socket carries ONE membership per topic and every server frame is ROUTED only to the ports
3
+ // that want it — never broadcast. It holds no socket; the engine hands it `send` and `post`.
4
+
5
+ import { CHANNEL_SID_PREFIX } from './client-channels';
6
+ import type { AttachedPort, PortMessage } from './socket-port';
7
+ import { encode, type Frame, PROTOCOL_VERSION, type SubscribeFrame } from './sync-protocol';
8
+
9
+ export interface RouterDeps {
10
+ /** To the node, when the socket is up. */
11
+ send(frame: Frame): void;
12
+ post(attached: AttachedPort, message: PortMessage): void;
13
+ port(id: number): AttachedPort | undefined;
14
+ ports(): Iterable<AttachedPort>;
15
+ }
16
+
17
+ /** The frames the router routes; the engine keeps `hello`, `update-available` and `reconnect`. */
18
+ type RoutedFrame = Extract<
19
+ Frame,
20
+ { readonly type: 'snapshot' | 'patch' | 'ack' | 'records' | 'replay-gap' | 'events' }
21
+ >;
22
+
23
+ export class PortRouter {
24
+ readonly #deps: RouterDeps;
25
+ /**
26
+ * Topic → the ports that want it, the add frame that joined it, and the epoch the node last
27
+ * named for it — what a later port is told to re-read in.
28
+ */
29
+ readonly #topics = new Map<
30
+ string,
31
+ { readonly ports: Set<number>; readonly add: SubscribeFrame; epoch: string | null }
32
+ >();
33
+
34
+ constructor(deps: RouterDeps) {
35
+ this.#deps = deps;
36
+ }
37
+
38
+ /** A tab's `subscribe`: a channel membership, or a live query under a port-scoped sid. */
39
+ subscribe(attached: AttachedPort, frame: SubscribeFrame): void {
40
+ if (frame.target.kind === 'channel') {
41
+ this.#channel(attached, frame);
42
+ return;
43
+ }
44
+ if (frame.target.kind !== 'query') return;
45
+ // Every tab mints its own sids; the engine's sid carries the port so two tabs never collide.
46
+ const sid = `${attached.id}|${frame.sid}`;
47
+ const rewritten: SubscribeFrame = { ...frame, sid };
48
+ if (frame.op === 'add') attached.lives.set(sid, rewritten);
49
+ else attached.lives.delete(sid);
50
+ this.#deps.send(rewritten);
51
+ }
52
+
53
+ /** Release what a port held: its channel wants and its live registrations. */
54
+ release(attached: AttachedPort): void {
55
+ for (const topic of attached.topics) {
56
+ const add = this.#topics.get(topic)?.add;
57
+ if (add !== undefined) this.#leave(attached.id, topic, { ...add, op: 'drop' });
58
+ }
59
+ attached.topics.clear();
60
+ for (const add of attached.lives.values()) this.#deps.send({ ...add, op: 'drop' });
61
+ attached.lives.clear();
62
+ }
63
+
64
+ /** One server frame to the ports it belongs to. `data` is the frame as the socket carried it. */
65
+ route(frame: RoutedFrame, data: string): void {
66
+ switch (frame.type) {
67
+ case 'snapshot':
68
+ case 'patch':
69
+ this.#toLive(frame.sid, (sid) => ({ ...frame, sid }));
70
+ return;
71
+ case 'ack': {
72
+ const ref = frame.ref;
73
+ if (ref.includes('|')) this.#toLive(ref, (local) => ({ ...frame, ref: local }));
74
+ else if (this.#topics.has(ref)) this.#toTopic(ref, data);
75
+ else for (const attached of this.#deps.ports()) this.#frame(attached, data);
76
+ return;
77
+ }
78
+ case 'records':
79
+ case 'replay-gap': {
80
+ const held = this.#topics.get(`${CHANNEL_SID_PREFIX}${frame.channel}`);
81
+ if (held !== undefined) held.epoch = frame.epoch;
82
+ this.#toTopic(`${CHANNEL_SID_PREFIX}${frame.channel}`, data);
83
+ return;
84
+ }
85
+ case 'events':
86
+ this.#toTopic(`${CHANNEL_SID_PREFIX}${frame.channel}`, data);
87
+ return;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Re-announcing each channel IS the node's presence heartbeat — with no `since`, so it replays
93
+ * nothing.
94
+ */
95
+ announce(): void {
96
+ for (const { add } of this.#topics.values()) this.#deps.send(withoutSince(add));
97
+ }
98
+
99
+ /** The socket is gone: every membership goes with it; each tab resubscribes from its cursors. */
100
+ clear(): void {
101
+ this.#topics.clear();
102
+ for (const attached of this.#deps.ports()) {
103
+ attached.topics.clear();
104
+ attached.lives.clear();
105
+ }
106
+ }
107
+
108
+ /** One membership per topic across every port; the first `add` joins, the last leave drops. */
109
+ #channel(attached: AttachedPort, frame: SubscribeFrame): void {
110
+ const topic = frame.sid;
111
+ const held = this.#topics.get(topic);
112
+ if (frame.op !== 'add') {
113
+ attached.topics.delete(topic);
114
+ this.#leave(attached.id, topic, frame);
115
+ return;
116
+ }
117
+ attached.topics.add(topic);
118
+ if (held === undefined) {
119
+ this.#topics.set(topic, { ports: new Set([attached.id]), add: frame, epoch: null });
120
+ this.#deps.send(frame);
121
+ return;
122
+ }
123
+ if (held.ports.has(attached.id)) return;
124
+ held.ports.add(attached.id);
125
+ // The node never hears this add, so it cannot answer it with the `replay-gap` a fresh seat
126
+ // gets; the engine does. With no epoch yet, the node's own answer is still on its way and now
127
+ // reaches this port too.
128
+ if (held.epoch === null) return;
129
+ const gap: Frame = {
130
+ type: 'replay-gap',
131
+ v: PROTOCOL_VERSION,
132
+ channel: topic.slice(CHANNEL_SID_PREFIX.length),
133
+ epoch: held.epoch,
134
+ };
135
+ this.#frame(attached, encode(gap));
136
+ }
137
+
138
+ #leave(id: number, topic: string, drop: SubscribeFrame): void {
139
+ const held = this.#topics.get(topic);
140
+ if (held === undefined) return;
141
+ held.ports.delete(id);
142
+ if (held.ports.size > 0) return;
143
+ this.#topics.delete(topic);
144
+ this.#deps.send(drop);
145
+ }
146
+
147
+ #toLive(engineSid: string, rewrite: (localSid: string) => Frame): void {
148
+ const bar = engineSid.indexOf('|');
149
+ const attached = this.#deps.port(Number(engineSid.slice(0, bar)));
150
+ if (attached !== undefined) this.#frame(attached, encode(rewrite(engineSid.slice(bar + 1))));
151
+ }
152
+
153
+ #toTopic(topic: string, data: string): void {
154
+ for (const id of this.#topics.get(topic)?.ports ?? []) {
155
+ const attached = this.#deps.port(id);
156
+ if (attached !== undefined) this.#frame(attached, data);
157
+ }
158
+ }
159
+
160
+ #frame(attached: AttachedPort, data: string): void {
161
+ this.#deps.post(attached, { t: 'frame', data });
162
+ }
163
+ }
164
+
165
+ /** A channel's `add` without its resume point — the beat repeats a membership, it resumes nothing. */
166
+ function withoutSince(add: SubscribeFrame): SubscribeFrame {
167
+ if (add.target.kind !== 'channel') return add;
168
+ const { since: _since, ...target } = add.target;
169
+ return { ...add, target };
170
+ }
package/src/socket.ts CHANGED
@@ -16,7 +16,11 @@ 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';
22
+ import { DropWindow } from './socket-drops';
23
+ import { DEFAULT_IDLE_TIMEOUT_MS } from './socket-idle';
20
24
  import { encode, type Frame } from './sync-protocol';
21
25
  import { AcceptBudget } from './thundering-herd';
22
26
 
@@ -81,10 +85,10 @@ export const DEFAULT_FRAME_BURST = 256;
81
85
  export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
82
86
 
83
87
  /**
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.
88
+ * Channel frames this process dropped under backpressure. A dropped `records` frame is now marked
89
+ * and REPAIRED — the socket is sent `replay-gap` once it drains and re-reads the channel's catch-up
90
+ * query (`channel_replay_gaps_total` counts those) — while a dropped `events` frame is ephemeral
91
+ * by definition and is only counted. The two series side by side are the whole delivery story.
88
92
  *
89
93
  * Declared here rather than in `@ultimat3/core`'s `runtime-metrics.ts` because that file is the
90
94
  * series EVERY Ultimate process emits and the deploy chart scales on; this one exists only where
@@ -94,7 +98,7 @@ export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
94
98
  */
95
99
  const channelFramesDropped: Counter = counter('channel_frames_dropped_total', {
96
100
  unit: '{frame}',
97
- description: 'Channel frames dropped by socket backpressure — unrecoverable, nothing replays one',
101
+ description: 'Channel frames dropped by socket backpressure',
98
102
  });
99
103
 
100
104
  export function actorIdOf(actor: Actor | null): string | null {
@@ -123,6 +127,12 @@ export class SyncSocket {
123
127
  * flush re-snapshots instead of silently diverging.
124
128
  */
125
129
  readonly desynced = new Set<string>();
130
+ /**
131
+ * Channel topics whose `records` stream this socket lost a frame of, with the epoch it was lost
132
+ * in. The channel twin of `desynced`: written on a drop, read on the next delivery or drain, which
133
+ * sends `replay-gap` and clears it. Bounded by `topics`, so it adds nothing a socket did not hold.
134
+ */
135
+ readonly gaps = new Map<string, string>();
126
136
  /**
127
137
  * Inbound frames this socket may still have routed. The accept budget spends one token per
128
138
  * UPGRADE, so nothing bounded what happened after: one authenticated socket reached a DB read,
@@ -148,6 +158,7 @@ export class SyncSocket {
148
158
  readonly #maxDroppedFrames: number;
149
159
  #clientBuildId: string;
150
160
  #closed = false;
161
+ readonly #drops: DropWindow;
151
162
 
152
163
  constructor(options: SyncSocketOptions) {
153
164
  this.#ws = options.ws;
@@ -160,6 +171,7 @@ export class SyncSocket {
160
171
  finiteOption('SyncSocket', 'maxBufferedBytes', this.#maxBufferedBytes);
161
172
  this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
162
173
  finiteOption('SyncSocket', 'maxDroppedFrames', this.#maxDroppedFrames);
174
+ this.#drops = new DropWindow(this.#maxDroppedFrames);
163
175
  this.frameBudget = new AcceptBudget({
164
176
  perSecond: finiteOption(
165
177
  'SyncSocket',
@@ -207,33 +219,34 @@ export class SyncSocket {
207
219
 
208
220
  /** `false` means the frame was dropped by backpressure — the caller must mark state stale. */
209
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 {
210
227
  if (this.#closed) return false;
211
228
  if (this.#ws.getBufferedAmount() > this.#maxBufferedBytes) {
212
- this.droppedFrames += 1;
213
- if (this.droppedFrames > this.#maxDroppedFrames) {
214
- this.close(CLOSE.overloaded, 'backpressure');
215
- }
229
+ this.#dropped();
216
230
  return false;
217
231
  }
218
- // `WsLike.send` is declared `: number` for this line and no other: Bun answers `0` for a
219
- // message it DROPPED — the socket closed between the buffered-amount check above and this
220
- // write — and `-1` under backpressure. Discarded, a dropped frame read as delivered, so
221
- // `live-fanout` advanced the subscriber's cursor past a patch that never left and
222
- // `sync-frames`' desync mark was never taken: permanently stale on a healthy socket, which is
223
- // the exact outcome every other `socket.send` on this node reads its answer to prevent.
224
- if (this.#ws.send(encode(frame)) <= 0) {
225
- this.droppedFrames += 1;
226
- // The same ceiling backpressure takes: a socket the runtime keeps refusing is one to close,
227
- // and the two are one failure — the write went nowhere either way.
228
- if (this.droppedFrames > this.#maxDroppedFrames) {
229
- this.close(CLOSE.overloaded, 'backpressure');
230
- }
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();
231
237
  return false;
232
238
  }
233
239
  this.sentFrames += 1;
234
240
  return true;
235
241
  }
236
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
+
237
250
  /** Record a dropped or invalidated subscription so the next flush re-snapshots it. */
238
251
  markDesynced(sid: string): void {
239
252
  this.desynced.add(sid);
@@ -255,6 +268,7 @@ export class SyncSocket {
255
268
 
256
269
  unsubscribeTopic(topic: string): void {
257
270
  this.topics.delete(topic);
271
+ this.gaps.delete(topic);
258
272
  }
259
273
 
260
274
  touch(): void {
@@ -273,25 +287,6 @@ export class SyncSocket {
273
287
  }
274
288
  }
275
289
 
276
- /**
277
- * How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
278
- * inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
279
- * ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
280
- * its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
281
- * every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
282
- */
283
- export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
284
-
285
- /**
286
- * How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
287
- * of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
288
- * four times per window rather than once a second. Derived rather than configured — a second knob
289
- * is a second number that can disagree with the one it is a fraction of.
290
- */
291
- export function idleSweepPeriodMs(idleTimeoutMs: number): number {
292
- return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
293
- }
294
-
295
290
  export interface SocketRegistryOptions {
296
291
  readonly clock?: Clock;
297
292
  /** Bun's own `idleTimeout` is renewed by its ping/pong; this budget counts routed FRAMES. */
@@ -313,6 +308,8 @@ export class SocketRegistry {
313
308
  readonly #clock: Clock;
314
309
  readonly #idleTimeoutMs: number;
315
310
  #droppedChannelFrames = 0;
311
+ /** The repair side of `SyncSocket.gaps`: `repairAll(socket)` is what Bun's `drain` calls. */
312
+ readonly gapRepairs = new GapRepairs();
316
313
 
317
314
  constructor(options: SocketRegistryOptions = {}) {
318
315
  this.#clock = options.clock ?? systemClock;
@@ -414,6 +411,9 @@ export class SocketRegistry {
414
411
  deliver(topic: string, frame: Frame): number {
415
412
  const members = this.#byTopic.get(topic);
416
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);
417
417
  let sent = 0;
418
418
  let dropped = 0;
419
419
  for (const socket of members) {
@@ -421,21 +421,63 @@ export class SocketRegistry {
421
421
  members.delete(socket);
422
422
  continue;
423
423
  }
424
- if (socket.send(frame)) sent += 1;
424
+ if (socket.sendEncoded(text)) sent += 1;
425
425
  else dropped += 1;
426
426
  }
427
427
  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 });
428
+ if (dropped > 0) this.#countDropped(topic, dropped);
429
+ return sent;
430
+ }
431
+
432
+ /**
433
+ * A `records` delivery. An owed `replay-gap` goes out first; a socket that then drops the frame is
434
+ * marked for the next one. `deliver`'s twin, because only a records stream can be repaired.
435
+ */
436
+ deliverRecords(topic: string, epoch: string, frame: ChannelRecordsFrame): number {
437
+ const members = this.#byTopic.get(topic);
438
+ if (!members) return 0;
439
+ const text = encode(frame);
440
+ let sent = 0;
441
+ let dropped = 0;
442
+ for (const socket of members) {
443
+ if (socket.closed) {
444
+ members.delete(socket);
445
+ continue;
446
+ }
447
+ this.gapRepairs.repair(socket, topic);
448
+ if (socket.sendEncoded(text)) {
449
+ sent += 1;
450
+ continue;
451
+ }
452
+ dropped += 1;
453
+ socket.gaps.set(topic, epoch);
435
454
  }
455
+ if (members.size === 0) this.#byTopic.delete(topic);
456
+ if (dropped > 0) this.#countDropped(topic, dropped);
436
457
  return sent;
437
458
  }
438
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
+
473
+ #countDropped(topic: string, dropped: number): void {
474
+ this.#droppedChannelFrames += dropped;
475
+ // Two readers, one event, one spelling: the series an operator alerts on and the line that
476
+ // says which topic it was.
477
+ channelFramesDropped.add(dropped);
478
+ logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
479
+ }
480
+
439
481
  /**
440
482
  * Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
441
483
  * of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
@@ -54,6 +54,55 @@ export interface GateTarget {
54
54
  readonly rows: readonly Row[];
55
55
  }
56
56
 
57
+ /**
58
+ * The shared window, indexed ONCE per fan-out rather than searched per subscriber per patch: a
59
+ * `rows.find` inside the subscriber loop was O(subscribers × window) — 54.6 ms against 0.7 ms at
60
+ * 1000 subscribers over a 500-row window. `windowIndex` builds it; `filterPatches` builds its own
61
+ * when a caller passes none.
62
+ */
63
+ export interface WindowIndex {
64
+ readonly byId: ReadonlyMap<string, Row>;
65
+ readonly position: ReadonlyMap<string, number>;
66
+ }
67
+
68
+ export function windowIndex(rows: readonly Row[]): WindowIndex {
69
+ const byId = new Map<string, Row>();
70
+ const position = new Map<string, number>();
71
+ rows.forEach((row, at) => {
72
+ byId.set(row.id, row);
73
+ position.set(row.id, at);
74
+ });
75
+ return { byId, position };
76
+ }
77
+
78
+ /**
79
+ * What a subscriber holds as a patch list is folded: the cursor's set, plus what this list has
80
+ * inserted, minus what it has deleted — never a copy of the set per subscriber.
81
+ */
82
+ class Holding {
83
+ readonly #base: ReadonlySet<string>;
84
+ readonly #added = new Set<string>();
85
+ readonly #removed = new Set<string>();
86
+
87
+ constructor(base: ReadonlySet<string>) {
88
+ this.#base = base;
89
+ }
90
+
91
+ has(id: string): boolean {
92
+ return this.#added.has(id) || (this.#base.has(id) && !this.#removed.has(id));
93
+ }
94
+
95
+ fold(patch: RowPatch): void {
96
+ if (patch.op === 'delete') {
97
+ this.#added.delete(patch.id);
98
+ this.#removed.add(patch.id);
99
+ } else if (patch.op === 'insert') {
100
+ this.#removed.delete(patch.id);
101
+ this.#added.add(patch.id);
102
+ }
103
+ }
104
+ }
105
+
57
106
  export interface SubscriberGateOptions {
58
107
  /**
59
108
  * `live.rows_denied`. A row an actor's policy refuses is dropped, never sent and never turned
@@ -113,11 +162,16 @@ export class SubscriberGate {
113
162
  who: Subscriber,
114
163
  patches: readonly RowPatch[],
115
164
  held: ReadonlySet<string>,
165
+ index: WindowIndex = windowIndex(target.rows),
116
166
  ): Promise<RowPatch[]> {
117
167
  const out: RowPatch[] = [];
168
+ const holding = new Holding(held);
118
169
  for (const patch of patches) {
119
- const allowed = await this.patch(target, who, patch, held.has(patch.id));
120
- if (allowed !== null) out.push(allowed);
170
+ const allowed = await this.#decide(target, who, patch, holding.has(patch.id), index);
171
+ if (allowed === null) continue;
172
+ const placed = rebase(allowed, holding, target.rows, index);
173
+ holding.fold(placed);
174
+ out.push(placed);
121
175
  }
122
176
  return out;
123
177
  }
@@ -128,6 +182,16 @@ export class SubscriberGate {
128
182
  who: Subscriber,
129
183
  patch: RowPatch,
130
184
  holds: boolean,
185
+ ): Promise<RowPatch | null> {
186
+ return await this.#decide(target, who, patch, holds, windowIndex(target.rows));
187
+ }
188
+
189
+ async #decide(
190
+ target: GateTarget,
191
+ who: Subscriber,
192
+ patch: RowPatch,
193
+ holds: boolean,
194
+ index: WindowIndex,
131
195
  ): Promise<RowPatch | null> {
132
196
  // A delete carries no row, so there is nothing to put in front of the rule — `holds` IS the
133
197
  // decision, the same one the two branches below take for a row a rule has just refused.
@@ -147,7 +211,7 @@ export class SubscriberGate {
147
211
  this.#denied(target.qid, who, patch.id);
148
212
  return null;
149
213
  }
150
- const full = target.rows.find((row) => row.id === patch.id);
214
+ const full = index.byId.get(patch.id);
151
215
  // No whole row means no decision to take. An update patch carries the changed columns only, so
152
216
  // a rule reading `row.ownerId` on one reads `undefined` and answers as if the row had said so —
153
217
  // fail-closed for `=== actor.id`, and a leak for every `!row.private`. It is not a gate that
@@ -218,6 +282,31 @@ const actorIdOf = (who: Subscriber): string | null => (who.actor === null ? null
218
282
  * window stopped holding it. Written once so the two paths cannot answer differently: a client left
219
283
  * holding the row instead renders a revoked grant until something else reconnects it.
220
284
  */
285
+ /**
286
+ * The patch as this subscriber must read it. `index` was a position in the SHARED, pre-policy
287
+ * window, and forwarded unchanged it placed the row out of order for anyone who sees fewer rows —
288
+ * and its size told them how many rows they may not see sit ahead of it. Re-based on the rows ahead
289
+ * of it that this subscriber holds; dropped from a delete, which the client applies by id. Bounded
290
+ * by `CURSOR_ID_LIMIT` like `holds` is: a held row past it is not counted.
291
+ */
292
+ function rebase(
293
+ patch: RowPatch,
294
+ holding: Holding,
295
+ rows: readonly Row[],
296
+ index: WindowIndex,
297
+ ): RowPatch {
298
+ if (patch.index === undefined) return patch;
299
+ const { index: _shared, ...rest } = patch;
300
+ if (patch.op === 'delete' || patch.row === null) return rest;
301
+ const at = index.position.get(patch.id) ?? patch.index;
302
+ let local = 0;
303
+ for (let i = 0; i < at && i < rows.length; i += 1) {
304
+ const id = rows[i]?.id;
305
+ if (id !== undefined && id !== patch.id && holding.has(id)) local += 1;
306
+ }
307
+ return { ...rest, index: local };
308
+ }
309
+
221
310
  const withdrawn = (patch: RowPatch): RowPatch => ({
222
311
  op: 'delete',
223
312
  id: patch.id,
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