@ultimat3/realtime 1.2.0 → 3.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 +641 -0
  2. package/README.md +336 -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 +202 -20
  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 +99 -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 +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  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 +324 -248
  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
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,9 +117,22 @@ 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
- lastSeenAt: number;
129
+ /**
130
+ * MONOTONIC milliseconds, not an instant — the idle sweep measures a duration, and a duration
131
+ * read off the wall clock is decided by whatever NTP last wrote. A step forward evicts sockets
132
+ * that are talking; a step backward makes `idleFor` negative and spares sockets that are dead.
133
+ * Named for the units so nobody hands it to `new Date()`; `openedAt` is the wall-clock one.
134
+ */
135
+ lastSeenMonotonicMs: number;
64
136
  droppedFrames = 0;
65
137
  sentFrames = 0;
66
138
 
@@ -77,10 +149,17 @@ export class SyncSocket {
77
149
  this.clientBuildId = options.clientBuildId;
78
150
  this.serverBuildId = options.serverBuildId;
79
151
  this.actor = options.actor ?? null;
80
- this.#maxBufferedBytes = options.maxBufferedBytes ?? 1024 * 1024;
152
+ this.#maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
81
153
  this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
154
+ this.frameBudget = new AcceptBudget({
155
+ perSecond: options.maxFramesPerSecond ?? DEFAULT_MAX_FRAMES_PER_SECOND,
156
+ burst: options.frameBurst ?? DEFAULT_FRAME_BURST,
157
+ clock: this.#clock,
158
+ });
159
+ // Two clocks on purpose: `openedAt` is an instant a human reads, `lastSeenMonotonicMs` is the
160
+ // start of a duration only this process compares.
82
161
  this.openedAt = this.#clock.now().getTime();
83
- this.lastSeenAt = this.openedAt;
162
+ this.lastSeenMonotonicMs = this.#clock.monotonic();
84
163
  }
85
164
 
86
165
  get actorId(): string | null {
@@ -120,22 +199,27 @@ export class SyncSocket {
120
199
  this.desynced.delete(sid);
121
200
  }
122
201
 
202
+ /**
203
+ * This socket's own membership, and nothing else. It used to also call Bun's `ws.subscribe`,
204
+ * which built a second per-topic index nothing ever published to — the fanout is
205
+ * `SocketRegistry.deliver`, one filtered `send` per socket, because that is the only path that
206
+ * can count a dropped frame or close a socket that is drowning in them.
207
+ */
123
208
  subscribeTopic(topic: string): void {
124
209
  this.topics.add(topic);
125
- this.#ws.subscribe(topic);
126
210
  }
127
211
 
128
212
  unsubscribeTopic(topic: string): void {
129
213
  this.topics.delete(topic);
130
- this.#ws.unsubscribe(topic);
131
214
  }
132
215
 
133
216
  touch(): void {
134
- this.lastSeenAt = this.#clock.now().getTime();
217
+ this.lastSeenMonotonicMs = this.#clock.monotonic();
135
218
  }
136
219
 
137
- idleFor(now: number): number {
138
- return now - this.lastSeenAt;
220
+ /** `nowMonotonicMs` comes from the SAME clock — `Clock.monotonic()`, never `now().getTime()`. */
221
+ idleFor(nowMonotonicMs: number): number {
222
+ return nowMonotonicMs - this.lastSeenMonotonicMs;
139
223
  }
140
224
 
141
225
  close(code: number = CLOSE.normal, reason = ''): void {
@@ -145,21 +229,50 @@ export class SyncSocket {
145
229
  }
146
230
  }
147
231
 
232
+ /**
233
+ * How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
234
+ * inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
235
+ * ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
236
+ * its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
237
+ * every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
238
+ */
239
+ export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
240
+
241
+ /**
242
+ * How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
243
+ * of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
244
+ * four times per window rather than once a second. Derived rather than configured — a second knob
245
+ * is a second number that can disagree with the one it is a fraction of.
246
+ */
247
+ export function idleSweepPeriodMs(idleTimeoutMs: number): number {
248
+ return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
249
+ }
250
+
148
251
  export interface SocketRegistryOptions {
149
252
  readonly clock?: Clock;
150
- /** Bun also enforces its own `idleTimeout`; this sweep catches half-open connections. */
253
+ /** Bun's own `idleTimeout` is renewed by its ping/pong; this budget counts routed FRAMES. */
151
254
  readonly idleTimeoutMs?: number;
152
255
  }
153
256
 
154
257
  /** Per-node socket table. Intentionally the only in-memory map on a `sync` node. */
155
258
  export class SocketRegistry {
156
259
  readonly #sockets = new Map<string, SyncSocket>();
260
+ /**
261
+ * Who is on each channel topic. `deliver` walked every socket on the node asking each whether
262
+ * it held the topic, so one message with one legitimate subscriber cost as many iterations as
263
+ * this node has connections — 50,000 at the scale this framework benchmarks. It lives here
264
+ * rather than on the hub because this is the only object that sees a socket die: a close, a
265
+ * drain and the idle sweep all pass through `remove`, and an index nobody cleans on those paths
266
+ * retains a dead socket per topic forever.
267
+ */
268
+ readonly #byTopic = new Map<string, Set<SyncSocket>>();
157
269
  readonly #clock: Clock;
158
270
  readonly #idleTimeoutMs: number;
271
+ #droppedChannelFrames = 0;
159
272
 
160
273
  constructor(options: SocketRegistryOptions = {}) {
161
274
  this.#clock = options.clock ?? systemClock;
162
- this.#idleTimeoutMs = options.idleTimeoutMs ?? 120_000;
275
+ this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
163
276
  }
164
277
 
165
278
  /**
@@ -178,8 +291,39 @@ export class SocketRegistry {
178
291
  }
179
292
 
180
293
  remove(id: string): void {
294
+ const socket = this.#sockets.get(id);
181
295
  // `Map.delete` answers "was it actually there", so a double close cannot decrement twice.
182
- if (this.#sockets.delete(id)) recordConnection(-1);
296
+ if (!this.#sockets.delete(id)) return;
297
+ recordConnection(-1);
298
+ if (!socket) return;
299
+ // Leaving this table IS the close, whoever noticed first. Bun's `close` callback reports a
300
+ // connection that has already gone, so nothing called `close()` on this object and
301
+ // `socket.closed` stayed false — leaving a subscribe still awaiting its snapshot read with no
302
+ // way to tell that the socket it is about to attach to was torn down while it read.
303
+ socket.close(CLOSE.goingAway, 'connection closed');
304
+ for (const name of socket.topics) this.#dropFrom(name, socket);
305
+ }
306
+
307
+ /**
308
+ * Join a topic: the socket's own set, Bun's native pub/sub and this node's delivery index, in
309
+ * one call. `ChannelHub` is its only caller, and calls nothing else — two call sites for one
310
+ * membership is how an index goes wrong.
311
+ */
312
+ joinTopic(socket: SyncSocket, topic: string): void {
313
+ socket.subscribeTopic(topic);
314
+ const members = this.#byTopic.get(topic);
315
+ if (members) members.add(socket);
316
+ else this.#byTopic.set(topic, new Set([socket]));
317
+ }
318
+
319
+ leaveTopic(socket: SyncSocket, topic: string): void {
320
+ socket.unsubscribeTopic(topic);
321
+ this.#dropFrom(topic, socket);
322
+ }
323
+
324
+ /** Sockets this node would deliver `topic` to. */
325
+ subscriberCount(topic: string): number {
326
+ return this.#byTopic.get(topic)?.size ?? 0;
183
327
  }
184
328
 
185
329
  get(id: string): SyncSocket | undefined {
@@ -194,28 +338,75 @@ export class SocketRegistry {
194
338
  return this.#sockets.size;
195
339
  }
196
340
 
197
- /** Closes and returns everything past the idle budget. Called on an interval by `sync-node`. */
198
- sweepIdle(): SyncSocket[] {
199
- const now = this.#clock.now().getTime();
200
- const closed: SyncSocket[] = [];
201
- for (const socket of this.#sockets.values()) {
202
- if (socket.idleFor(now) > this.#idleTimeoutMs) {
203
- socket.close(CLOSE.idle, 'idle timeout');
204
- // Through `remove`, not the map: the sweep is exactly the abnormal close a gauge leaks on.
205
- this.remove(socket.id);
206
- closed.push(socket);
207
- }
208
- }
209
- return closed;
341
+ /** The budget `idle()` answers against, so a caller can size its own sweep from one number. */
342
+ get idleTimeoutMs(): number {
343
+ return this.#idleTimeoutMs;
210
344
  }
211
345
 
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). */
346
+ /**
347
+ * Everything past the idle budget. A QUERY, and deliberately not an eviction: this table is three
348
+ * of the five things a socket holds, and the other two — its live subscriptions and its presence
349
+ * membership on the SHARED set — are only reachable from `sync-node`'s `teardown`. A sweep that
350
+ * closed and `remove`d here left a member every other node renders until its TTL and a
351
+ * `QueryEntry` whose `subscribers` map never empties. `sync-node` is the one caller and it
352
+ * releases each one the way the close callback does.
353
+ */
354
+ idle(): SyncSocket[] {
355
+ // Monotonic, because this is the one comparison an operator's clock could otherwise decide:
356
+ // `sync-node` hands this registry and every socket it builds the same `Clock`.
357
+ const now = this.#clock.monotonic();
358
+ return [...this.#sockets.values()].filter(
359
+ (socket) => socket.idleFor(now) > this.#idleTimeoutMs,
360
+ );
361
+ }
362
+
363
+ /**
364
+ * Local delivery for a channel topic — every channel message on this node comes through here,
365
+ * so it reads the per-topic index rather than the socket table. A socket that closed without
366
+ * an unsubscribe is dropped as it is met, so the index cannot outlive the connection even on a
367
+ * path that forgot to `remove` it.
368
+ */
214
369
  deliver(topic: string, frame: Frame): number {
370
+ const members = this.#byTopic.get(topic);
371
+ if (!members) return 0;
215
372
  let sent = 0;
216
- for (const socket of this.#sockets.values()) {
217
- if (socket.topics.has(topic) && socket.send(frame)) sent += 1;
373
+ let dropped = 0;
374
+ for (const socket of members) {
375
+ if (socket.closed) {
376
+ members.delete(socket);
377
+ continue;
378
+ }
379
+ if (socket.send(frame)) sent += 1;
380
+ else dropped += 1;
381
+ }
382
+ if (members.size === 0) this.#byTopic.delete(topic);
383
+ if (dropped > 0) {
384
+ this.#droppedChannelFrames += dropped;
385
+ // Two readers, one event, one spelling: the series an operator alerts on and the line that
386
+ // says which topic it was. `deliver` ignored `send`'s answer and so did the hub above it, so
387
+ // until both existed a lost channel message left no trace at all.
388
+ channelFramesDropped.add(dropped);
389
+ logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
218
390
  }
219
391
  return sent;
220
392
  }
393
+
394
+ /**
395
+ * Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
396
+ * of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
397
+ *
398
+ * Node-wide on purpose: a socket past `maxDroppedFrames` is closed and removed, so a per-socket
399
+ * count leaves with the socket exactly when loss is worst. Distinct from `SyncSocket.droppedFrames`,
400
+ * which counts every kind of frame one connection lost, channel and live-query patch alike.
401
+ */
402
+ get droppedChannelFrames(): number {
403
+ return this.#droppedChannelFrames;
404
+ }
405
+
406
+ #dropFrom(topic: string, socket: SyncSocket): void {
407
+ const members = this.#byTopic.get(topic);
408
+ if (!members) return;
409
+ members.delete(socket);
410
+ if (members.size === 0) this.#byTopic.delete(topic);
411
+ }
221
412
  }
@@ -0,0 +1,209 @@
1
+ // The per-subscriber pass of a definition's row policy over the shared window, and the two numbers
2
+ // it produces: rows denied, and gates that could not decide. It evaluates no policy of its own —
3
+ // `policy-gate.ts` is this package's only authz seam — it calls `LiveQueryDefinition.visible` and
4
+ // classifies what comes back, so a denial and a failure never arrive as the same event.
5
+
6
+ import type { Actor } from '@ultimat3/core';
7
+ import { isPolicyDenial } from './errors';
8
+ import type { JsonValue, Row, RowPatch } from './json';
9
+ import type { LiveQueryDefinition } from './live-contract';
10
+
11
+ /**
12
+ * Who a decision is being made for. Every policy call in the live pipeline takes one, which is the
13
+ * shape of the rule: there is no path through the gate that reads a query id and no actor.
14
+ */
15
+ export interface Subscriber {
16
+ readonly sid: string;
17
+ readonly actor: Actor | null;
18
+ }
19
+
20
+ /** One row withheld from one subscriber. Carries no row payload: the ids are the whole point. */
21
+ export interface RowDenied {
22
+ readonly qid: string;
23
+ readonly sid: string;
24
+ readonly actorId: string | null;
25
+ readonly rowId: string;
26
+ }
27
+
28
+ /** Where a gate was standing when it failed. `authorize` is subscribe-time, the rest are rows. */
29
+ export type GateStage = 'authorize' | 'snapshot' | 'patch';
30
+
31
+ /**
32
+ * One gate that raised something other than a denial. `rowId` is absent for `authorize`, which
33
+ * decides about a subscription rather than a row, and `error` is passed through unwrapped so the
34
+ * node logs the driver's own message instead of a summary of it.
35
+ */
36
+ export interface GateFailed {
37
+ readonly qid: string;
38
+ readonly sid: string;
39
+ readonly actorId: string | null;
40
+ readonly stage: GateStage;
41
+ readonly rowId?: string;
42
+ readonly error: unknown;
43
+ }
44
+
45
+ /**
46
+ * What the gate needs from a query entry and nothing more: the shared pre-policy window, the input
47
+ * the rules read, and the definition that owns `visible`. `QueryEntry` satisfies it structurally,
48
+ * so the registry passes its entry straight through and this file never learns what else is on it.
49
+ */
50
+ export interface GateTarget {
51
+ readonly qid: string;
52
+ readonly input: JsonValue;
53
+ readonly definition: LiveQueryDefinition;
54
+ readonly rows: readonly Row[];
55
+ }
56
+
57
+ export interface SubscriberGateOptions {
58
+ /**
59
+ * `live.rows_denied`. A row an actor's policy refuses is dropped, never sent and never turned
60
+ * into an error — telling a client "there is a row you may not see" is itself the leak. Dropped
61
+ * silently it is also invisible, so the drop is a metric instead.
62
+ */
63
+ readonly onRowDenied?: (event: RowDenied) => void;
64
+ /**
65
+ * `live.gate_failed`. The gate raised something that is not a decision, so this subscriber's
66
+ * result set is unknown rather than empty. Separate from `onRowDenied` on purpose: an alert
67
+ * fires on this one, and a dashboard that summed them would show a permission change.
68
+ */
69
+ readonly onGateFailed?: (event: GateFailed) => void;
70
+ }
71
+
72
+ /** Both counters and the one call that classifies a throw. Owned per registry, never per query. */
73
+ export class SubscriberGate {
74
+ readonly #options: SubscriberGateOptions;
75
+ #rowsDenied = 0;
76
+ #gateFailures = 0;
77
+
78
+ constructor(options: SubscriberGateOptions) {
79
+ this.#options = options;
80
+ }
81
+
82
+ /** Rows a subscriber's policy refused since boot. */
83
+ get rowsDenied(): number {
84
+ return this.#rowsDenied;
85
+ }
86
+
87
+ /** Gates that raised instead of deciding since boot. */
88
+ get gateFailures(): number {
89
+ return this.#gateFailures;
90
+ }
91
+
92
+ /**
93
+ * One snapshot, filtered for one subscriber. A failure raises rather than returning the rows it
94
+ * managed to admit: a short result set is indistinguishable from a correct one, and handing it
95
+ * over is the read silently losing rows.
96
+ */
97
+ async filterRows(target: GateTarget, who: Subscriber, rows: readonly Row[]): Promise<Row[]> {
98
+ const out: Row[] = [];
99
+ for (const row of rows) {
100
+ if (await this.#visible(target, who, row, 'snapshot')) out.push(row);
101
+ else this.#denied(target.qid, who, row.id);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Row-level authz over a patch list. A row that becomes invisible is converted to a `delete`
108
+ * when the subscriber holds it — otherwise a revoked grant would leave a stale row on screen
109
+ * forever.
110
+ */
111
+ async filterPatches(
112
+ target: GateTarget,
113
+ who: Subscriber,
114
+ patches: readonly RowPatch[],
115
+ held: ReadonlySet<string>,
116
+ ): Promise<RowPatch[]> {
117
+ const out: RowPatch[] = [];
118
+ 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);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** One patch, one decision. `holds` is whether this subscriber already has the row on screen. */
126
+ async patch(
127
+ target: GateTarget,
128
+ who: Subscriber,
129
+ patch: RowPatch,
130
+ holds: boolean,
131
+ ): Promise<RowPatch | null> {
132
+ if (patch.op === 'delete' || patch.row === null) return patch;
133
+ const full = target.rows.find((row) => row.id === patch.id);
134
+ // No whole row means no decision to take. An update patch carries the changed columns only, so
135
+ // a rule reading `row.ownerId` on one reads `undefined` and answers as if the row had said so —
136
+ // fail-closed for `=== actor.id`, and a leak for every `!row.private`. It is not a gate that
137
+ // failed either: the shared window *is* the result set, so a row it does not hold is a row this
138
+ // subscriber is not entitled to keep, and one that holds it is told so.
139
+ if (full === undefined) return holds ? withdrawn(patch) : null;
140
+ const row: Row = { ...full, ...patch.row, id: patch.id };
141
+ if (await this.#visible(target, who, row, 'patch')) return patch;
142
+ this.#denied(target.qid, who, patch.id);
143
+ return holds ? withdrawn(patch) : null;
144
+ }
145
+
146
+ /**
147
+ * `authorize` failed for a subscription that is being re-decided. Counted and reported here so
148
+ * every gate failure in the pipeline goes through one counter, whatever the caller then does
149
+ * with the subscription.
150
+ */
151
+ failedAuthorize(qid: string, who: Subscriber, error: unknown): void {
152
+ this.#failed(qid, who, 'authorize', undefined, error);
153
+ }
154
+
155
+ /** The definition's own predicate. A denial answers `false`; anything else is counted and raised. */
156
+ async #visible(
157
+ target: GateTarget,
158
+ who: Subscriber,
159
+ row: Row,
160
+ stage: GateStage,
161
+ ): Promise<boolean> {
162
+ try {
163
+ return await target.definition.visible({ actor: who.actor, row, input: target.input });
164
+ } catch (error) {
165
+ if (isPolicyDenial(error)) return false;
166
+ this.#failed(target.qid, who, stage, row.id, error);
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ /** `live.rows_denied`. Counted here and nowhere else, so every drop is one increment. */
172
+ #denied(qid: string, who: Subscriber, rowId: string): void {
173
+ this.#rowsDenied += 1;
174
+ this.#options.onRowDenied?.({ qid, sid: who.sid, actorId: actorIdOf(who), rowId });
175
+ }
176
+
177
+ /** `live.gate_failed`. Same rule: one place counts, so the number and the events agree. */
178
+ #failed(
179
+ qid: string,
180
+ who: Subscriber,
181
+ stage: GateStage,
182
+ rowId: string | undefined,
183
+ error: unknown,
184
+ ): void {
185
+ this.#gateFailures += 1;
186
+ this.#options.onGateFailed?.({
187
+ qid,
188
+ sid: who.sid,
189
+ actorId: actorIdOf(who),
190
+ stage,
191
+ ...(rowId === undefined ? {} : { rowId }),
192
+ error,
193
+ });
194
+ }
195
+ }
196
+
197
+ const actorIdOf = (who: Subscriber): string | null => (who.actor === null ? null : who.actor.id);
198
+
199
+ /**
200
+ * The one frame a subscriber gets for a row it may no longer keep, whether a rule refused it or the
201
+ * window stopped holding it. Written once so the two paths cannot answer differently: a client left
202
+ * holding the row instead renders a revoked grant until something else reconnects it.
203
+ */
204
+ const withdrawn = (patch: RowPatch): RowPatch => ({
205
+ op: 'delete',
206
+ id: patch.id,
207
+ row: null,
208
+ lsn: patch.lsn,
209
+ });