@ultimat3/realtime 1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/package.json +36 -0
  4. package/src/change-buffer.ts +69 -0
  5. package/src/changefeed-env.ts +146 -0
  6. package/src/changefeed.ts +191 -0
  7. package/src/channel.ts +181 -0
  8. package/src/client.ts +439 -0
  9. package/src/cursor.ts +188 -0
  10. package/src/errors.ts +253 -0
  11. package/src/fanout.ts +159 -0
  12. package/src/hooks.ts +230 -0
  13. package/src/index.ts +328 -0
  14. package/src/json.ts +76 -0
  15. package/src/live-definition.ts +144 -0
  16. package/src/live-query.ts +449 -0
  17. package/src/local-store.ts +188 -0
  18. package/src/matcher-bridge.ts +169 -0
  19. package/src/nats-commands.ts +97 -0
  20. package/src/nats-connection-fixture.ts +105 -0
  21. package/src/nats-connection.ts +464 -0
  22. package/src/nats-fake.ts +431 -0
  23. package/src/nats-jetstream.ts +226 -0
  24. package/src/nats-kv.ts +157 -0
  25. package/src/nats-protocol.ts +222 -0
  26. package/src/nats-socket.ts +236 -0
  27. package/src/nats-transport.ts +257 -0
  28. package/src/offline-queue.ts +206 -0
  29. package/src/pg-advisory-lock.ts +98 -0
  30. package/src/pg-auth.ts +300 -0
  31. package/src/pg-bytes.ts +185 -0
  32. package/src/pg-connection-fixture.ts +215 -0
  33. package/src/pg-connection.ts +337 -0
  34. package/src/pg-entity-row.ts +130 -0
  35. package/src/pg-replication-fixture.ts +261 -0
  36. package/src/pg-replication.ts +396 -0
  37. package/src/pg-socket.ts +265 -0
  38. package/src/pg-wire.ts +192 -0
  39. package/src/pgoutput.ts +297 -0
  40. package/src/policy-gate.ts +56 -0
  41. package/src/presence.ts +219 -0
  42. package/src/rebase.ts +198 -0
  43. package/src/replicator.ts +185 -0
  44. package/src/socket.ts +208 -0
  45. package/src/sync-node.ts +400 -0
  46. package/src/sync-protocol.ts +376 -0
  47. package/src/thundering-herd.ts +141 -0
  48. package/src/transport-env.ts +104 -0
@@ -0,0 +1,144 @@
1
+ // The one bridge from a declared `query({ live: true })` to something `LiveQueryRegistry` can
2
+ // register. It exists so the per-subscriber rule holds for real declarations and not only for the
3
+ // definitions a test writes by hand: without it `policy-gate.ts` — this package's single authz
4
+ // seam — has no caller at all, and a live `subscribe` frame answers "no live query registered".
5
+ //
6
+ // The split it enforces is the whole point. Everything keyed by query id is subject-less: the
7
+ // source, the shape, the matcher, the row window. Everything that decides about an actor —
8
+ // `authorize` at subscribe, `visible` per row per delivery — is keyed by subscriber and evaluated
9
+ // every time. Collapsing the second onto the first is privilege escalation with a cache hit rate.
10
+
11
+ import type { Ctx } from '@ultimat3/core';
12
+ import { type AnyQuery, queryName, sourceFor } from '@ultimat3/query';
13
+ import { LiveRowUnidentifiedError } from './errors';
14
+ import { isRow, type JsonValue, type Row } from './json';
15
+ import { type LiveQueryDefinition, qidOf, type SnapshotResult } from './live-query';
16
+ import { type IncrementalMatcher, matcherFor } from './matcher-bridge';
17
+ import { authorizeWithPolicy, visibleWithPolicy } from './policy-gate';
18
+
19
+ export interface LiveDefinitionOptions {
20
+ /**
21
+ * The node's own context — never a subscriber's. It supplies services, clock and locale to the
22
+ * shared read; it supplies no authority, because that read is built with the query's policy
23
+ * switched off and every row leaving it is gated per subscriber.
24
+ */
25
+ readonly ctx: Ctx;
26
+ /**
27
+ * Where the shared window sits in the change stream, asked at snapshot time. Without a feed
28
+ * position a reconnect can only re-snapshot, so a node with a replicator should pass its lsn.
29
+ */
30
+ readonly lsn?: () => string;
31
+ /** Pins the reconnect epoch in tests; the server derives it from the build. */
32
+ readonly epoch?: string;
33
+ /**
34
+ * How many distinct inputs keep a compiled source. The map is keyed by an argument the client
35
+ * chooses, so it is bounded: an eviction costs one rebuild, and a live entry holds its own
36
+ * matcher, so nothing in flight notices.
37
+ */
38
+ readonly maxWindows?: number;
39
+ }
40
+
41
+ /**
42
+ * A resolved `(query, input)` pair: everything the matcher and the window need, and nothing that
43
+ * knows who is subscribing. One per query id, shared by every subscriber of it.
44
+ */
45
+ interface SharedWindow {
46
+ readonly matcher: IncrementalMatcher;
47
+ read(): Promise<readonly Row[]>;
48
+ }
49
+
50
+ /**
51
+ * A matcher for an input nothing has resolved yet. It refuses to decide rather than reporting "no
52
+ * change": a subscriber told nothing happened diverges silently, and `refill` is the one answer
53
+ * the registry already knows how to handle — mark desynced, re-snapshot.
54
+ */
55
+ const UNRESOLVED: IncrementalMatcher = {
56
+ entities: [],
57
+ match: () => ({ patches: [], refill: true }),
58
+ };
59
+
60
+ /**
61
+ * Registrable definition for one declared query. `register` takes it by name, so what comes back
62
+ * is input-independent: the per-input half is resolved by `prepare`, which the registry awaits
63
+ * before it builds an entry — and only after that subscriber's own `authorize` allowed it.
64
+ */
65
+ export function liveQueryDefinition(
66
+ target: AnyQuery,
67
+ options: LiveDefinitionOptions,
68
+ ): LiveQueryDefinition {
69
+ const name = queryName(target);
70
+ // Keyed by query id, and holding no subscriber's decision — that is what makes it shareable.
71
+ const windows = new Map<string, SharedWindow>();
72
+
73
+ const resolve = async (input: JsonValue): Promise<SharedWindow> => {
74
+ const qid = qidOf(name, input);
75
+ const seated = windows.get(qid);
76
+ if (seated !== undefined) return seated;
77
+ const live = await target.live(input, {
78
+ ctx: options.ctx,
79
+ // No subject: see `ToLiveOptions.enforce`. `authorize` below is the subscribe-time
80
+ // decision, and it runs once per subscriber rather than once per query id.
81
+ enforce: false,
82
+ ...(options.epoch === undefined ? {} : { epoch: options.epoch }),
83
+ });
84
+ // A second, subject-less build of the same source: `LiveQuery` describes the read (shape,
85
+ // reads, SQL text) but deliberately cannot run it, and the window needs rows. Both calls are
86
+ // pure source construction, both are subject-less, and they happen once per query id.
87
+ const source = await sourceFor(target, input, {
88
+ ctx: options.ctx,
89
+ enforce: false,
90
+ surface: 'live',
91
+ });
92
+ const built: SharedWindow = {
93
+ matcher: matcherFor(live),
94
+ read: async () => rowsOf(name, await source.execute()),
95
+ };
96
+ windows.set(qid, built);
97
+ evictOldest(windows, options.maxWindows ?? 256);
98
+ return built;
99
+ };
100
+
101
+ return {
102
+ name,
103
+ // The dependency set is only known once an input has produced a shape, so the registry reads
104
+ // it off the resolved matcher. This is the value for a definition asked before `prepare` ran,
105
+ // and it matches nothing rather than guessing an entity.
106
+ entities: [],
107
+ prepare: async (input) => {
108
+ await resolve(input);
109
+ },
110
+ snapshot: async ({ input }): Promise<SnapshotResult> => {
111
+ const window = await resolve(input);
112
+ return { rows: await window.read(), lsn: options.lsn?.() ?? '' };
113
+ },
114
+ matcher: (input) => windows.get(qidOf(name, input))?.matcher ?? UNRESOLVED,
115
+ // The two per-subscriber gates, both through the package's one authz seam. Neither result is
116
+ // memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
117
+ // delivery, and there is no key here an actor could share with another actor.
118
+ authorize: authorizeWithPolicy(target.policy, { query: name, ctx: options.ctx }),
119
+ visible: visibleWithPolicy(target.policy, { query: name, ctx: options.ctx }),
120
+ };
121
+ }
122
+
123
+ /** Insertion-ordered, so the oldest compiled input is the one that goes. */
124
+ function evictOldest(windows: Map<string, SharedWindow>, max: number): void {
125
+ while (windows.size > max) {
126
+ const oldest = windows.keys().next();
127
+ if (oldest.done === true) return;
128
+ windows.delete(oldest.value);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Rows crossing the wire are addressed by `id` — patches, cursors and the local store all key on
134
+ * it. A projection without one is refused here rather than delivered as a row nobody can patch:
135
+ * the alternative is a subscription that appears to work until the first update.
136
+ */
137
+ function rowsOf(query: string, rows: readonly object[]): readonly Row[] {
138
+ const out: Row[] = [];
139
+ for (const row of rows) {
140
+ if (!isRow(row)) throw new LiveRowUnidentifiedError({ query, keys: Object.keys(row) });
141
+ out.push(row);
142
+ }
143
+ return out;
144
+ }
@@ -0,0 +1,449 @@
1
+ // Tier 2: live queries. Registration, per-subscriber authz, snapshot, patch stream.
2
+ //
3
+ // The rule this file exists to enforce: **policy is evaluated once per subscriber, never once per
4
+ // query**. The DB read is shared across subscribers of the same query id; the authz decision is
5
+ // not. Two actors on one live query see two different result sets, and a row that fails an actor's
6
+ // policy is never sent to that actor — it arrives as a `delete` if they hold it, and is dropped
7
+ // otherwise.
8
+
9
+ import { type Actor, type Clock, systemClock, uuid } from '@ultimat3/core';
10
+ import type { ChangeEvent } from './changefeed';
11
+ import {
12
+ advance,
13
+ type LiveCursor,
14
+ makeCursor,
15
+ type ReconnectBudget,
16
+ type ResumeSource,
17
+ resumeFrom,
18
+ } from './cursor';
19
+ import { ProtocolVersionError, SubscriptionLimitError } from './errors';
20
+ import { canonicalJson, fnv1a, type JsonValue, type Row, type RowPatch } from './json';
21
+ import {
22
+ applyToWindow,
23
+ bridgeChange,
24
+ type IncrementalMatcher,
25
+ type SubscriptionShape,
26
+ } from './matcher-bridge';
27
+ import type { SyncSocket } from './socket';
28
+ import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
29
+
30
+ /** `qid` = hash(query name, input). Fanout subjects and change windows are keyed by it. */
31
+ export function qidOf(name: string, input: JsonValue): string {
32
+ return `${name}:${fnv1a(canonicalJson(input))}`;
33
+ }
34
+
35
+ export interface SnapshotResult<R extends Row = Row> {
36
+ readonly rows: readonly R[];
37
+ readonly lsn: string;
38
+ }
39
+
40
+ export interface LiveQueryDefinition<R extends Row = Row> {
41
+ readonly name: string;
42
+ /** Dependency set for the pre-filter. `x verify` rejects a `live: true` query without one. */
43
+ readonly entities: readonly string[];
44
+ /** Read set. Lets the pre-filter skip updates that touch no column this query reads. */
45
+ readonly columns?: readonly string[];
46
+ /** Bounded read (`orderBy` + `limit`, enforced by `x verify`), unfiltered by policy. */
47
+ snapshot(args: { input: JsonValue }): Promise<SnapshotResult<R>>;
48
+ /** Subscribe-time gate. Throws to deny — the same `policy` used by HTTP, jobs, and MCP. */
49
+ authorize?(args: { actor: Actor | null; input: JsonValue }): void | Promise<void>;
50
+ /** Row-level gate, evaluated per subscriber. The only row filter in the pipeline. */
51
+ visible(args: { actor: Actor | null; row: R; input: JsonValue }): boolean | Promise<boolean>;
52
+ /** Built once per `qid`, since a qid pins both the query and its input. */
53
+ matcher(input: JsonValue): IncrementalMatcher;
54
+ /**
55
+ * Resolve whatever this input needs before an entry is built. `matcher` is synchronous by
56
+ * design — a change event must not await anything — so a definition that has to compile a
57
+ * source or a shape does it here, after `authorize` allowed this subscriber and before the
58
+ * shared window exists.
59
+ */
60
+ prepare?(input: JsonValue): Promise<void>;
61
+ }
62
+
63
+ export interface LiveSubscription {
64
+ readonly sid: string;
65
+ readonly qid: string;
66
+ readonly socket: SyncSocket;
67
+ readonly input: JsonValue;
68
+ readonly definition: LiveQueryDefinition;
69
+ cursor: LiveCursor;
70
+ }
71
+
72
+ export interface LiveQueryRegistryOptions {
73
+ readonly source: ResumeSource;
74
+ readonly budget?: ReconnectBudget;
75
+ readonly clock?: Clock;
76
+ readonly maxPerSocket?: number;
77
+ readonly maxPerTenant?: number;
78
+ readonly tenantOf?: (actor: Actor | null) => string | null;
79
+ /**
80
+ * `live.rows_denied`. A row an actor's policy refuses is dropped, never sent and never turned
81
+ * into an error — telling a client "there is a row you may not see" is itself the leak. Dropped
82
+ * silently it is also invisible, so the drop is a metric instead.
83
+ */
84
+ readonly onRowDenied?: (event: RowDenied) => void;
85
+ }
86
+
87
+ /** One row withheld from one subscriber. Carries no row payload: the ids are the whole point. */
88
+ export interface RowDenied {
89
+ readonly qid: string;
90
+ readonly sid: string;
91
+ readonly actorId: string | null;
92
+ readonly rowId: string;
93
+ }
94
+
95
+ /**
96
+ * Who a decision is being made for. Every policy call in this file takes one, which is the shape
97
+ * of the rule: there is no path through the gate that reads a query id and no actor.
98
+ */
99
+ interface Subscriber {
100
+ readonly sid: string;
101
+ readonly actor: Actor | null;
102
+ }
103
+
104
+ interface QueryEntry {
105
+ readonly qid: string;
106
+ readonly definition: LiveQueryDefinition;
107
+ readonly input: JsonValue;
108
+ readonly shape: SubscriptionShape;
109
+ readonly matcher: IncrementalMatcher;
110
+ readonly subscribers: Map<string, LiveSubscription>;
111
+ /**
112
+ * The shared, *pre-policy* result window. One per query id, bounded by the query's `limit`, and
113
+ * the reason the matcher can run once for N subscribers: the read is shared, the authz is not.
114
+ */
115
+ rows: readonly Row[];
116
+ lsn: string;
117
+ }
118
+
119
+ export class LiveQueryRegistry {
120
+ readonly #definitions = new Map<string, LiveQueryDefinition>();
121
+ readonly #entries = new Map<string, QueryEntry>();
122
+ readonly #bySid = new Map<string, LiveSubscription>();
123
+ readonly #options: LiveQueryRegistryOptions;
124
+ readonly #clock: Clock;
125
+ #rowsDenied = 0;
126
+
127
+ constructor(options: LiveQueryRegistryOptions) {
128
+ this.#options = options;
129
+ this.#clock = options.clock ?? systemClock;
130
+ }
131
+
132
+ /** `live.rows_denied` for this node: rows a subscriber's policy refused since boot. */
133
+ get rowsDenied(): number {
134
+ return this.#rowsDenied;
135
+ }
136
+
137
+ register(definition: LiveQueryDefinition): this {
138
+ this.#definitions.set(definition.name, definition);
139
+ return this;
140
+ }
141
+
142
+ definition(name: string): LiveQueryDefinition | undefined {
143
+ return this.#definitions.get(name);
144
+ }
145
+
146
+ subscriberCount(qid: string): number {
147
+ return this.#entries.get(qid)?.subscribers.size ?? 0;
148
+ }
149
+
150
+ subscription(sid: string): LiveSubscription | undefined {
151
+ return this.#bySid.get(sid);
152
+ }
153
+
154
+ /**
155
+ * Subscribe or resume. Returns the frame this subscriber needs: a `snapshot` on a cold start or a
156
+ * blown reconnect budget, a `patch` when the cursor is inside the retained window.
157
+ */
158
+ async subscribe(args: {
159
+ socket: SyncSocket;
160
+ name: string;
161
+ input: JsonValue;
162
+ sid?: string;
163
+ cursor?: LiveCursor | null;
164
+ }): Promise<{ subscription: LiveSubscription; frame: Frame }> {
165
+ const definition = this.#definitions.get(args.name);
166
+ if (!definition) {
167
+ throw new ProtocolVersionError({
168
+ got: args.name,
169
+ expected: PROTOCOL_VERSION,
170
+ detail: `no live query registered as "${args.name}" — client and server manifests differ`,
171
+ });
172
+ }
173
+ this.#assertLimits(args.socket);
174
+ await definition.authorize?.({ actor: args.socket.actor, input: args.input });
175
+ // After this subscriber's own decision, never before it: resolving a shape for a caller who
176
+ // may not subscribe is work an unauthorized client gets to schedule.
177
+ await definition.prepare?.(args.input);
178
+
179
+ const qid = qidOf(args.name, args.input);
180
+ const entry = this.#entryFor(qid, definition, args.input);
181
+ const sid = args.sid ?? uuid();
182
+ const now = this.#clock.now().getTime();
183
+
184
+ if (args.cursor) {
185
+ const resumed = await resumeFrom(args.cursor, {
186
+ source: this.#options.source,
187
+ ...(this.#options.budget ? { budget: this.#options.budget } : {}),
188
+ clock: this.#clock,
189
+ snapshot: async () => await this.#read(entry, { sid, actor: args.socket.actor }),
190
+ });
191
+ if (resumed.kind === 'delta') {
192
+ const patches = await this.#filterPatches(
193
+ entry,
194
+ { sid, actor: args.socket.actor },
195
+ resumed.patches,
196
+ args.cursor,
197
+ );
198
+ const subscription = this.#attach(entry, args.socket, sid, resumed.cursor);
199
+ return {
200
+ subscription,
201
+ frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn: resumed.cursor.lsn },
202
+ };
203
+ }
204
+ const subscription = this.#attach(entry, args.socket, sid, resumed.cursor);
205
+ return {
206
+ subscription,
207
+ frame: {
208
+ type: 'snapshot',
209
+ v: PROTOCOL_VERSION,
210
+ sid,
211
+ rows: resumed.rows,
212
+ cursor: resumed.cursor,
213
+ },
214
+ };
215
+ }
216
+
217
+ const fresh = await this.#read(entry, { sid, actor: args.socket.actor });
218
+ const cursor = makeCursor(qid, fresh.lsn, fresh.rows, now);
219
+ const subscription = this.#attach(entry, args.socket, sid, cursor);
220
+ return {
221
+ subscription,
222
+ frame: { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows: fresh.rows, cursor },
223
+ };
224
+ }
225
+
226
+ unsubscribe(sid: string): void {
227
+ const subscription = this.#bySid.get(sid);
228
+ if (!subscription) return;
229
+ this.#bySid.delete(sid);
230
+ subscription.socket.queries.delete(sid);
231
+ subscription.socket.clearDesynced(sid);
232
+ const entry = this.#entries.get(subscription.qid);
233
+ if (!entry) return;
234
+ entry.subscribers.delete(sid);
235
+ // An entry with no subscribers stops costing a matcher and a change window.
236
+ if (entry.subscribers.size === 0) this.#entries.delete(subscription.qid);
237
+ }
238
+
239
+ unsubscribeSocket(socketId: string): void {
240
+ for (const subscription of [...this.#bySid.values()]) {
241
+ if (subscription.socket.id === socketId) this.unsubscribe(subscription.sid);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Actor changed mid-connection (login, logout, role change): re-run subscribe-time authz and drop
247
+ * what is no longer allowed. Survivors are marked desynced so the next flush re-snapshots them
248
+ * under the new actor's row policy.
249
+ */
250
+ async reauthorize(socket: SyncSocket): Promise<readonly string[]> {
251
+ const dropped: string[] = [];
252
+ for (const subscription of [...this.#bySid.values()]) {
253
+ if (subscription.socket.id !== socket.id) continue;
254
+ try {
255
+ await subscription.definition.authorize?.({
256
+ actor: socket.actor,
257
+ input: subscription.input,
258
+ });
259
+ socket.markDesynced(subscription.sid);
260
+ } catch {
261
+ this.unsubscribe(subscription.sid);
262
+ dropped.push(subscription.sid);
263
+ }
264
+ }
265
+ return dropped;
266
+ }
267
+
268
+ /**
269
+ * Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
270
+ * number of frames sent — the metric the reconnect benchmark watches.
271
+ */
272
+ async deliver(change: ChangeEvent): Promise<number> {
273
+ let sent = 0;
274
+ for (const entry of this.#entries.values()) {
275
+ const result = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
276
+ if (!result) continue;
277
+ entry.lsn = change.lsn;
278
+ entry.rows = applyToWindow(entry.rows, result.patches);
279
+ // The retained window holds the pre-policy patch; resume re-filters it per subscriber.
280
+ for (const patch of result.patches) this.#options.source.append(entry.qid, patch);
281
+
282
+ for (const subscription of entry.subscribers.values()) {
283
+ if (result.refill) {
284
+ // The window lost its tail: guessing is how a sync engine silently diverges.
285
+ subscription.socket.markDesynced(subscription.sid);
286
+ continue;
287
+ }
288
+ const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
289
+ const allowed: RowPatch[] = [];
290
+ for (const patch of result.patches) {
291
+ const gated = await this.#gate(
292
+ entry,
293
+ who,
294
+ patch,
295
+ subscription.cursor.ids.includes(patch.id),
296
+ );
297
+ if (gated) allowed.push(gated);
298
+ }
299
+ if (allowed.length === 0) continue;
300
+ const frame: Frame = {
301
+ type: 'patch',
302
+ v: PROTOCOL_VERSION,
303
+ sid: subscription.sid,
304
+ patches: allowed,
305
+ lsn: change.lsn,
306
+ };
307
+ if (subscription.socket.send(frame)) {
308
+ subscription.cursor = advance(subscription.cursor, allowed, change.lsn, change.at);
309
+ sent += 1;
310
+ } else {
311
+ subscription.socket.markDesynced(subscription.sid);
312
+ }
313
+ }
314
+ }
315
+ return sent;
316
+ }
317
+
318
+ #attach(
319
+ entry: QueryEntry,
320
+ socket: SyncSocket,
321
+ sid: string,
322
+ cursor: LiveCursor,
323
+ ): LiveSubscription {
324
+ const subscription: LiveSubscription = {
325
+ sid,
326
+ qid: entry.qid,
327
+ socket,
328
+ input: entry.input,
329
+ definition: entry.definition,
330
+ cursor,
331
+ };
332
+ entry.subscribers.set(sid, subscription);
333
+ this.#bySid.set(sid, subscription);
334
+ socket.queries.set(sid, entry.qid);
335
+ socket.clearDesynced(sid);
336
+ return subscription;
337
+ }
338
+
339
+ #entryFor(qid: string, definition: LiveQueryDefinition, input: JsonValue): QueryEntry {
340
+ const existing = this.#entries.get(qid);
341
+ if (existing) return existing;
342
+ const matcher = definition.matcher(input);
343
+ const created: QueryEntry = {
344
+ qid,
345
+ definition,
346
+ input,
347
+ shape: {
348
+ qid,
349
+ // The matcher knows the dependency set this *input* produced; `definition.entities` is
350
+ // the static declaration and can only be a superset of it. Preferring the matcher is what
351
+ // lets a definition built from a real query carry no static list at all.
352
+ entities: matcher.entities.length > 0 ? matcher.entities : definition.entities,
353
+ orgId: orgIdOf(input),
354
+ ...(definition.columns ? { columns: definition.columns } : {}),
355
+ },
356
+ matcher,
357
+ subscribers: new Map(),
358
+ rows: [],
359
+ lsn: '',
360
+ };
361
+ this.#entries.set(qid, created);
362
+ return created;
363
+ }
364
+
365
+ /** One read, then one policy pass per subscriber. Never one read per subscriber. */
366
+ async #read(entry: QueryEntry, who: Subscriber): Promise<SnapshotResult> {
367
+ const result = await entry.definition.snapshot({ input: entry.input });
368
+ entry.rows = result.rows;
369
+ entry.lsn = result.lsn;
370
+ const rows: Row[] = [];
371
+ for (const row of result.rows) {
372
+ if (await entry.definition.visible({ actor: who.actor, row, input: entry.input })) {
373
+ rows.push(row);
374
+ } else {
375
+ this.#denied(entry, who, row.id);
376
+ }
377
+ }
378
+ return { rows, lsn: result.lsn };
379
+ }
380
+
381
+ async #filterPatches(
382
+ entry: QueryEntry,
383
+ who: Subscriber,
384
+ patches: readonly RowPatch[],
385
+ cursor: LiveCursor,
386
+ ): Promise<RowPatch[]> {
387
+ const held = new Set(cursor.ids);
388
+ const out: RowPatch[] = [];
389
+ for (const patch of patches) {
390
+ const allowed = await this.#gate(entry, who, patch, held.has(patch.id));
391
+ if (allowed) out.push(allowed);
392
+ }
393
+ return out;
394
+ }
395
+
396
+ /**
397
+ * Row-level authz. A row that becomes invisible is converted to a `delete` when the subscriber
398
+ * holds it — otherwise a revoked grant would leave a stale row on screen forever.
399
+ */
400
+ async #gate(
401
+ entry: QueryEntry,
402
+ who: Subscriber,
403
+ patch: RowPatch,
404
+ holds: boolean,
405
+ ): Promise<RowPatch | null> {
406
+ if (patch.op === 'delete' || patch.row === null) return patch;
407
+ // The policy always sees the whole row from the shared window — a patch carries changed
408
+ // columns only, and authorizing a partial row is how a row policy silently starts failing.
409
+ const full = entry.rows.find((row) => row.id === patch.id);
410
+ const row: Row = { ...(full ?? {}), ...patch.row, id: patch.id };
411
+ if (await entry.definition.visible({ actor: who.actor, row, input: entry.input })) return patch;
412
+ this.#denied(entry, who, patch.id);
413
+ return holds ? { op: 'delete', id: patch.id, row: null, lsn: patch.lsn } : null;
414
+ }
415
+
416
+ /** `live.rows_denied`. Counted here and nowhere else, so every drop is one increment. */
417
+ #denied(entry: QueryEntry, who: Subscriber, rowId: string): void {
418
+ this.#rowsDenied += 1;
419
+ this.#options.onRowDenied?.({
420
+ qid: entry.qid,
421
+ sid: who.sid,
422
+ actorId: who.actor === null ? null : who.actor.id,
423
+ rowId,
424
+ });
425
+ }
426
+
427
+ #assertLimits(socket: SyncSocket): void {
428
+ const perSocket = this.#options.maxPerSocket ?? 128;
429
+ if (socket.queries.size >= perSocket) {
430
+ throw new SubscriptionLimitError({ scope: 'socket', id: socket.id, limit: perSocket });
431
+ }
432
+ const perTenant = this.#options.maxPerTenant;
433
+ const tenant = this.#options.tenantOf?.(socket.actor) ?? null;
434
+ if (perTenant === undefined || tenant === null) return;
435
+ let count = 0;
436
+ for (const subscription of this.#bySid.values()) {
437
+ if ((this.#options.tenantOf?.(subscription.socket.actor) ?? null) === tenant) count += 1;
438
+ }
439
+ if (count >= perTenant) {
440
+ throw new SubscriptionLimitError({ scope: 'tenant', id: tenant, limit: perTenant });
441
+ }
442
+ }
443
+ }
444
+
445
+ function orgIdOf(input: JsonValue): string | null {
446
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) return null;
447
+ const value = input['orgId'];
448
+ return typeof value === 'string' ? value : null;
449
+ }