@ultimat3/realtime 31.4.2 → 31.5.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.
package/CLAUDE.md CHANGED
@@ -107,6 +107,9 @@ A rule marked `†` has its full text in [`docs/history/realtime-rules.md`](../.
107
107
  `live-fanout` tests).
108
108
  - **`startLiveReplicator` SUBSCRIBES to `entity.row`/`entity.bulk`**, never a slot: `stop()`
109
109
  releases its own; two both hear a write (`live-replicator.test.ts`). Its handlers never throw.
110
+ - **A window is keyed by the PARSED input** (`LiveQueryDefinition.queryId`): a stripped junk key
111
+ opens no window. An entry is built from what `prepare` returned (`PreparedWindow`), never a
112
+ re-lookup (`live-definition-input.test.ts`). A cold window is staled only by a table it reads.
110
113
  - **A WINDOW is per TENANT** (`windowId(queryHash, tenant)`, `live-tenant.ts`); the read runs as a
111
114
  service actor carrying ONLY that org, never the subscriber. †
112
115
  - **A `sid` is CLIENT data: a subscription is keyed by `(socket, sid)`** (`subscription-book.ts`);
package/README.md CHANGED
@@ -366,11 +366,17 @@ Two actors on one live query get two different result sets, and a row that leave
366
366
  is delivered to them as a `delete` — never as silence.
367
367
 
368
368
  **The shared read is a TENANT's, never the node's.** One `(query, input)` is one window per org:
369
- its id is `windowId(queryHash(name, input), tenant)`, the tenant being the subscriber's
370
- `actor.orgId`, and its source is read in a context that carries that org and nothing else of any
371
- subscriber's. So a repo that leaves the tenant to the acting actor — what `x g query --live`
372
- generates — reads on a sync node exactly as it reads over HTTP, and no org predicate is written
373
- by hand.
369
+ its id is `windowId(queryHash(name, parsed), tenant)` — `parsed` the input as the query's own
370
+ schema parsed it (`LiveQueryDefinition.queryId`), so a key the schema strips opens no second window
371
+ — the tenant being the subscriber's `actor.orgId`, and its source is read in a context that
372
+ carries that org and nothing else of any subscriber's. So a repo that leaves the tenant to the
373
+ acting actor — what `x g query --live` generates — reads on a sync node exactly as it reads over
374
+ HTTP, and no org predicate is written by hand.
375
+
376
+ A hand-written `LiveQueryDefinition` (`@ultimat3/realtime/server`) that resolves per input in
377
+ `prepare` returns a `PreparedWindow` — `{ matcher, rowEntity, rowKey }` — and the entry is built
378
+ from that answer, never looked up again: a bounded cache another subscribe evicted in between would
379
+ answer nothing, and the entry would be born reading no table.
374
380
 
375
381
  | On a sync node | Answer |
376
382
  |---|---|
@@ -665,8 +671,9 @@ made it — a key already queued collapses as always, and nothing queued is ever
665
671
  `subscribe` is one lane per sid, or per topic name; `hello` and
666
672
  the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
667
673
  lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
668
- - **A window's `qid` is `@ultimat3/query`'s `queryHash(name, input)`, qualified by the tenant it
669
- is read for** (`windowId`) — `<name>:<first 16 hex of SHA-256(canonicalJson(input))>`, 64 bits
674
+ - **A window's `qid` is `@ultimat3/query`'s `queryHash(name, input)` over the PARSED input,
675
+ qualified by the tenant it is read for** (`windowId`; a re-seat derives the new tenant's id from
676
+ the held one, `retenant`, never re-hashing the input as sent) — `<name>:<first 16 hex of SHA-256(canonicalJson(input))>`, 64 bits
670
677
  `As of 2026-08` where it was a 32-bit FNV-1a. It is a *sharing* key: a hit is answered with the existing entry and the seated window, both holding the
671
678
  first subscriber's input and rows, and input is client-chosen, so a collision is one client served
672
679
  out of another's window. This package derives none of its own — `qidOf` was a second spelling of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "31.4.2",
3
+ "version": "31.5.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -52,10 +52,10 @@
52
52
  "test": "bun test"
53
53
  },
54
54
  "dependencies": {
55
- "@ultimat3/core": "31.4.2",
56
- "@ultimat3/entity": "31.4.2",
57
- "@ultimat3/policy": "31.4.2",
58
- "@ultimat3/query": "31.4.2",
55
+ "@ultimat3/core": "31.5.0",
56
+ "@ultimat3/entity": "31.5.0",
57
+ "@ultimat3/policy": "31.5.0",
58
+ "@ultimat3/query": "31.5.0",
59
59
  "nats": "2.29.3"
60
60
  }
61
61
  }
@@ -16,6 +16,13 @@ import type { JsonValue, LiveRow } from './json';
16
16
  import type { IncrementalMatcher } from './matcher-bridge';
17
17
  import type { SyncSocket } from './socket';
18
18
 
19
+ /** One input's resolved, subject-less half — what `prepare` hands the entry it is about to build. */
20
+ export interface PreparedWindow {
21
+ readonly matcher: IncrementalMatcher;
22
+ readonly rowEntity: string | null;
23
+ readonly rowKey: ((row: LiveRow) => string) | null;
24
+ }
25
+
19
26
  export interface SnapshotResult<R extends LiveRow = LiveRow> {
20
27
  readonly rows: readonly R[];
21
28
  readonly lsn: string;
@@ -76,8 +83,18 @@ export interface LiveQueryDefinition<R extends LiveRow = LiveRow> {
76
83
  * design — a change event must not await anything — so a definition that has to compile a
77
84
  * source or a shape does it here, after `authorize` allowed this subscriber and before the
78
85
  * shared window exists.
86
+ *
87
+ * What it resolved comes BACK, and the entry is built from that: looked up again by input, a
88
+ * bounded cache another subscribe evicted in between answered nothing, and the entry was born
89
+ * reading no table. `undefined` = resolve through `matcher` / `rowEntity` / `rowKey`.
90
+ */
91
+ prepare?(input: JsonValue): Promise<PreparedWindow | undefined>;
92
+ /**
93
+ * The query id this input names: `queryHash(name, parsed)` over the input the read PARSED, so
94
+ * a key the schema strips opens no second window. Asked after `authorize`, before `prepare`.
95
+ * Absent, the registry keys the window by the input as sent.
79
96
  */
80
- prepare?(input: JsonValue): Promise<void>;
97
+ queryId?(input: JsonValue): Promise<string>;
81
98
  }
82
99
 
83
100
  /** Who a live subscribe is charged to: the socket's actor, else its address, on the node's clock. */
@@ -18,6 +18,8 @@ import {
18
18
  } from '@ultimat3/core';
19
19
  import {
20
20
  type AnyQuery,
21
+ parsedQueryInput,
22
+ QueryInputInvalidError,
21
23
  queryHash,
22
24
  queryName,
23
25
  spendQueryLimit,
@@ -135,9 +137,39 @@ export function liveQueryDefinition(
135
137
  const name = queryName(target);
136
138
  // Keyed by query id, and holding no subscriber's decision — that is what makes it shareable.
137
139
  const windows = new Map<string, SharedWindow>();
140
+ const maxWindows = finiteOption('live()', 'maxWindows', options.maxWindows ?? 256);
141
+ // Input as sent → query id of the input as parsed. Bounded like `windows`, for the same reason:
142
+ // its key is client-chosen. An eviction costs one parse.
143
+ const ids = new Map<string, string>();
144
+
145
+ const queryId = async (input: JsonValue): Promise<string> => {
146
+ const sent = queryHash(name, input);
147
+ const known = ids.get(sent);
148
+ if (known !== undefined) return known;
149
+ let value: unknown;
150
+ try {
151
+ // The query's own capped parse (`MAX_ISSUES`), never the schema called raw: this is client
152
+ // input, and the read path's bound on what one refusal costs holds here too.
153
+ value = await parsedQueryInput(target, input);
154
+ } catch (error) {
155
+ // A refused input keeps the id of what was sent and is not remembered: `target.live` raises
156
+ // the query's own `X_INPUT_INVALID` for it, exactly as before.
157
+ if (error instanceof QueryInputInvalidError) return sent;
158
+ throw error;
159
+ }
160
+ const parsed = queryHash(name, value);
161
+ ids.set(sent, parsed);
162
+ evictOldest(ids, maxWindows);
163
+ return parsed;
164
+ };
165
+ /** Synchronous, for a caller that did not take `prepare`'s answer: may miss after an eviction. */
166
+ const seatedFor = (input: JsonValue): SharedWindow | undefined => {
167
+ const sent = queryHash(name, input);
168
+ return windows.get(ids.get(sent) ?? sent);
169
+ };
138
170
 
139
171
  const resolve = async (input: JsonValue): Promise<SharedWindow> => {
140
- const qid = queryHash(name, input);
172
+ const qid = await queryId(input);
141
173
  const seated = windows.get(qid);
142
174
  if (seated !== undefined) return seated;
143
175
  const live = await target.live(input, {
@@ -168,7 +200,7 @@ export function liveQueryDefinition(
168
200
  },
169
201
  };
170
202
  windows.set(qid, built);
171
- evictOldest(windows, finiteOption('live()', 'maxWindows', options.maxWindows ?? 256));
203
+ evictOldest(windows, maxWindows);
172
204
  return built;
173
205
  };
174
206
 
@@ -178,8 +210,10 @@ export function liveQueryDefinition(
178
210
  // it off the resolved matcher. This is the value for a definition asked before `prepare` ran,
179
211
  // and it matches nothing rather than guessing an entity.
180
212
  entities: [],
213
+ queryId,
181
214
  prepare: async (input) => {
182
- await resolve(input);
215
+ const { matcher, rowEntity, rowKey } = await resolve(input);
216
+ return { matcher, rowEntity, rowKey };
183
217
  },
184
218
  snapshot: async ({ input, tenant, traceparent, phase }): Promise<SnapshotResult> => {
185
219
  const window = await resolve(input);
@@ -198,11 +232,11 @@ export function liveQueryDefinition(
198
232
  );
199
233
  return { rows, lsn };
200
234
  },
201
- matcher: (input) => windows.get(queryHash(name, input))?.matcher ?? UNRESOLVED,
235
+ matcher: (input) => seatedFor(input)?.matcher ?? UNRESOLVED,
202
236
  // Read off the same resolved window as the matcher, so the scope the client keys rows under and
203
237
  // the entity the matcher patches them from can never be two different names.
204
- rowEntity: (input) => windows.get(queryHash(name, input))?.rowEntity ?? null,
205
- rowKey: (input) => windows.get(queryHash(name, input))?.rowKey ?? null,
238
+ rowEntity: (input) => seatedFor(input)?.rowEntity ?? null,
239
+ rowKey: (input) => seatedFor(input)?.rowKey ?? null,
206
240
  // The two per-subscriber gates, both through the package's one authz seam. Neither result is
207
241
  // memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
208
242
  // delivery, and there is no key here an actor could share with another actor.
@@ -216,7 +250,7 @@ export function liveQueryDefinition(
216
250
  }
217
251
 
218
252
  /** Insertion-ordered, so the oldest compiled input is the one that goes. */
219
- function evictOldest(windows: Map<string, SharedWindow>, max: number): void {
253
+ function evictOldest(windows: Map<string, unknown>, max: number): void {
220
254
  while (windows.size > max) {
221
255
  const oldest = windows.keys().next();
222
256
  if (oldest.done === true) return;
@@ -1,13 +1,98 @@
1
- // The registry's windows, indexed by the relations each one reads. One owner for both, so an entry
2
- // added or forgotten can never be in the table and missing from the index (or the reverse): a
3
- // change then reaches only the windows that read its table, never every window on the node.
1
+ // The registry's windows: born under the node's ceiling, indexed by the relations each one reads,
2
+ // dropped with their retained patches. One owner for all three, so an entry added or forgotten can
3
+ // never be in the table and missing from the index (or the reverse), and a change reaches only the
4
+ // windows that read its table, never every window on the node.
4
5
 
5
- import type { QueryEntry } from './query-window';
6
+ import { finiteOption } from '@ultimat3/core';
7
+ import { SubscriptionLimitError } from './errors';
8
+ import type { JsonValue } from './json';
9
+ import type { LiveQueryDefinition, PreparedWindow } from './live-contract';
10
+ import { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
11
+ import { type QueryEntry, queryEntry } from './query-window';
12
+
13
+ /** What the entry table reads off the registry's options. */
14
+ export type LiveEntriesOptions = Pick<
15
+ LiveQueryRegistryOptions,
16
+ 'source' | 'maxEntries' | 'readDeadlineMs' | 'schedule'
17
+ >;
6
18
 
7
19
  export class LiveEntries {
8
20
  readonly #byQid = new Map<string, QueryEntry>();
9
21
  /** `shape.entities` → the entries reading it. A shape is fixed at birth, so this never moves. */
10
22
  readonly #byTable = new Map<string, Set<QueryEntry>>();
23
+ readonly #options: LiveEntriesOptions;
24
+ readonly #maxEntries: number;
25
+
26
+ constructor(options: LiveEntriesOptions) {
27
+ this.#options = options;
28
+ this.#maxEntries = finiteOption(
29
+ 'the live-query registry',
30
+ 'maxEntries',
31
+ options.maxEntries ?? DEFAULT_MAX_ENTRIES,
32
+ );
33
+ }
34
+
35
+ /**
36
+ * The entry for `qid`, born now if none holds it — from `prepare`'s answer when there is one,
37
+ * never a second lookup by input (`QueryEntry`'s `prepared`).
38
+ */
39
+ seat(
40
+ qid: string,
41
+ definition: LiveQueryDefinition,
42
+ input: JsonValue,
43
+ tenant: string | null,
44
+ prepared: PreparedWindow | undefined,
45
+ ): QueryEntry {
46
+ const existing = this.#byQid.get(qid);
47
+ if (existing) return existing;
48
+ // The node-wide ceiling, refused where the entry would be born. `qid` derives from
49
+ // client-chosen input, so one socket varying it mints a matcher, a row window and a
50
+ // `WindowLock` per value, and every change then fans out over all of them.
51
+ if (this.#byQid.size >= this.#maxEntries) {
52
+ throw new SubscriptionLimitError({
53
+ scope: 'node',
54
+ id: definition.name,
55
+ limit: this.#maxEntries,
56
+ knob: 'maxEntries',
57
+ });
58
+ }
59
+ const matcher = prepared?.matcher ?? definition.matcher(input);
60
+ const created = queryEntry(qid, definition, input, matcher, {
61
+ readDeadlineMs: this.#options.readDeadlineMs,
62
+ schedule: this.#options.schedule,
63
+ tenant,
64
+ prepared,
65
+ });
66
+ this.add(created);
67
+ return created;
68
+ }
69
+
70
+ /**
71
+ * An entry with no subscribers left, gone with its retained patches. `forget` had no caller once:
72
+ * the entry was dropped and the `ResumeSource` never told, so its ring sat at full capacity until
73
+ * the LRU happened to evict it — a client-chosen input's memory outliving the last subscriber.
74
+ */
75
+ release(qid: string): void {
76
+ this.delete(qid);
77
+ this.#options.source.forget?.(qid);
78
+ }
79
+
80
+ /**
81
+ * An entry nothing is holding, released — for the case where a subscriber never arrived. Five
82
+ * cold failures against `maxEntries: 5` used to refuse every later subscribe on the node with
83
+ * `X_SUBSCRIPTION_LIMIT`, forever, after the database recovered.
84
+ */
85
+ dropIfUnheld(qid: string, entry: QueryEntry): void {
86
+ // Identity, never presence: an entry this subscribe did not create may already have been
87
+ // dropped and re-created under the same qid.
88
+ if (this.#byQid.get(qid) !== entry) return;
89
+ if (entry.subscribers.size !== 0) return;
90
+ // A read published on the entry belongs to a subscriber that has not attached yet — it owns
91
+ // the entry's fate, and dropping it here would leave that subscriber holding a window no
92
+ // change is ever fanned out to.
93
+ if (entry.reading !== null) return;
94
+ this.release(qid);
95
+ }
11
96
 
12
97
  get size(): number {
13
98
  return this.#byQid.size;
@@ -53,18 +138,19 @@ export class LiveEntries {
53
138
  }
54
139
 
55
140
  /**
56
- * Every window one change is work for: its table's readers, plus every window that owes a
57
- * read whatever it reads — a `stale` one is repaired by the next change to reach it, and one no
58
- * read has landed in yet marks itself stale so its in-flight read is followed by a fresh one
59
- * (`live-fanout.ts`). Those two are a boolean pair per entry, read without a lane; the expensive
60
- * part — a lane entered, a closure queued, a fanout awaited — is paid by these entries only.
141
+ * Every window one change is work for: its table's readers, plus every `stale` one whatever it
142
+ * reads — the next change to reach it repairs it. A window no read has landed in yet is work
143
+ * only as a reader (`live-fanout.ts` stales it so its in-flight read is followed by a fresh
144
+ * one): a change to a table it does not read is no hole in that read. `stale` is a boolean per
145
+ * entry, read without a lane; the expensive part — a lane entered, a closure queued, a fanout
146
+ * awaited — is paid by these entries only.
61
147
  * Measured: 10k windows on other tables, 34.6 ms → 0.23 ms per change (29 → 4,430/s).
62
148
  */
63
149
  targets(table: string): QueryEntry[] {
64
150
  const readers = this.readersOf(table);
65
151
  const out = [...readers];
66
152
  for (const entry of this.#byQid.values()) {
67
- if ((entry.stale || entry.applied === 0) && !readers.has(entry)) out.push(entry);
153
+ if (entry.stale && !readers.has(entry)) out.push(entry);
68
154
  }
69
155
  return out;
70
156
  }
@@ -49,9 +49,11 @@ export async function fanoutChange(
49
49
  // No read has landed in this window yet: there is nothing to patch, and a patch folded into the
50
50
  // empty rows would move `entry.lsn` past the read in flight and get that read discarded as older
51
51
  // than the window — every row but the patched one lost, permanently. Marked stale instead, so the
52
- // read that lands is applied and followed by one that includes this change.
52
+ // read that lands is applied and followed by one that includes this change. Only a change to a
53
+ // relation it READS (the guard `truncated` uses): any other is no hole in that read, and staling
54
+ // on it made every new window on a busy node read twice and re-snapshot every subscriber.
53
55
  if (entry.applied === 0) {
54
- entry.stale = true;
56
+ if (entry.shape.entities.includes(change.table)) entry.stale = true;
55
57
  return { sent: 0, stale: 0 };
56
58
  }
57
59
  if (change.op === 'truncate') return await truncated(deps, entry, change);
package/src/live-query.ts CHANGED
@@ -6,23 +6,24 @@
6
6
  // policy is never sent to that actor — it arrives as a `delete` if they hold it, and is dropped
7
7
  // otherwise.
8
8
 
9
- import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core';
9
+ import { type Clock, systemClock, uuidV7 } from '@ultimat3/core';
10
10
  import { queryHash } from '@ultimat3/query';
11
11
  import type { ChangeEvent } from './changefeed';
12
12
  import { type LiveCursor, makeCursor } from './cursor';
13
- import { LiveQueryUnknownError, SubscriptionLimitError } from './errors';
13
+ import { LiveQueryUnknownError } from './errors';
14
14
  import type { JsonValue } from './json';
15
15
  import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
16
16
  import { DeliveryCounts, deliverChange } from './live-deliver';
17
17
  import { LiveEntries } from './live-entries';
18
- import { type FanoutDeps, repairSubscriber, repairWindow, snapshotFrame } from './live-fanout';
18
+ import { type FanoutDeps, repairWindow, snapshotFrame } from './live-fanout';
19
19
  import { floorAfterRead } from './live-floor';
20
- import { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
20
+ import type { LiveQueryRegistryOptions } from './live-query-options';
21
21
  import { reauthorizeSocket } from './live-reauth';
22
+ import { repairEveryWindow, repairSocket } from './live-repair';
22
23
  import { resumeOnto } from './live-resume';
23
24
  import { type Charge, type SubscribeArgs, spendOnce } from './live-spend';
24
25
  import { liveTenantOf, windowId } from './live-tenant';
25
- import { fillWindow, noteTraceparent, type QueryEntry, queryEntry } from './query-window';
26
+ import { fillWindow, noteTraceparent, type QueryEntry } from './query-window';
26
27
  import type { SyncSocket } from './socket';
27
28
  import { type Subscriber, SubscriberGate } from './subscriber-gate';
28
29
  import { SubscriptionBook, subscriptionKey } from './subscription-book';
@@ -37,13 +38,12 @@ export {
37
38
  export class LiveQueryRegistry {
38
39
  readonly #definitions = new Map<string, LiveQueryDefinition>();
39
40
  /** Indexed by the relations each window reads (`live-entries.ts`): a change is work for few. */
40
- readonly #entries = new LiveEntries();
41
+ readonly #entries: LiveEntries;
41
42
  /** Keyed by `(socket, sid)`, never by `sid` alone — `subscription-book.ts` owns why. */
42
43
  readonly #book: SubscriptionBook;
43
44
  readonly #options: LiveQueryRegistryOptions;
44
45
  readonly #clock: Clock;
45
46
  readonly #gate: SubscriberGate;
46
- readonly #maxEntries: number;
47
47
  /** What one lane needs, and nothing this class holds beyond it. */
48
48
  readonly #fanout: FanoutDeps;
49
49
  #lastLsn = '';
@@ -58,11 +58,7 @@ export class LiveQueryRegistry {
58
58
  this.#options = options;
59
59
  this.#clock = options.clock ?? systemClock;
60
60
  this.#gate = new SubscriberGate(options);
61
- this.#maxEntries = finiteOption(
62
- 'the live-query registry',
63
- 'maxEntries',
64
- options.maxEntries ?? DEFAULT_MAX_ENTRIES,
65
- );
61
+ this.#entries = new LiveEntries(options);
66
62
  // The book owns the caps because it is the only thing that can answer them in O(1).
67
63
  this.#book = new SubscriptionBook(options);
68
64
  this.#fanout = { gate: this.#gate, source: options.source, clock: this.#clock };
@@ -191,12 +187,14 @@ export class LiveQueryRegistry {
191
187
  // The window is the subscriber's TENANT's: two orgs on one `(query, input)` are two entries,
192
188
  // two reads and two retained rings, so no shared row ever stands between them and a policy.
193
189
  const tenant = liveTenantOf(args.socket.actor);
194
- const qid = windowId(queryHash(args.name, args.input), tenant);
190
+ // The PARSED input's id when the definition can name it: a key the schema strips is no window.
191
+ const queryId = (await definition.queryId?.(args.input)) ?? queryHash(args.name, args.input);
192
+ const qid = windowId(queryId, tenant);
195
193
  // After this subscriber's own decision, never before it: resolving a shape for a caller who
196
194
  // may not subscribe is work an unauthorized client gets to schedule.
197
- await definition.prepare?.(args.input);
195
+ const prepared = await definition.prepare?.(args.input);
198
196
 
199
- const entry = this.#entryFor(qid, definition, args.input, tenant);
197
+ const entry = this.#entries.seat(qid, definition, args.input, tenant, prepared);
200
198
  noteTraceparent(entry, args.traceparent);
201
199
  try {
202
200
  // Charged where a read happens, never from the cursor's say-so (`live-spend.ts`).
@@ -206,7 +204,7 @@ export class LiveQueryRegistry {
206
204
  // The entry was born above, before anything could fill it. Everything below can throw — the
207
205
  // snapshot, the resume, the window's own read deadline — and `unsubscribe` is the only other
208
206
  // removal path, reachable only through a subscription that in this case was never attached.
209
- this.#dropIfUnheld(qid, entry);
207
+ this.#entries.dropIfUnheld(qid, entry);
210
208
  throw error;
211
209
  }
212
210
  }
@@ -275,30 +273,7 @@ export class LiveQueryRegistry {
275
273
  entry.subscribers.delete(subscriptionKey(socketId, sid));
276
274
  // An entry with no subscribers stops costing a matcher and a change window.
277
275
  if (entry.subscribers.size !== 0) return;
278
- this.#entries.delete(subscription.qid);
279
- // And the retained patches go with it. `forget` had no caller: the entry was dropped here and
280
- // the `ResumeSource` was never told, so its ring for that qid sat at full capacity until the
281
- // LRU happened to evict it — a client-chosen input's memory outliving the last subscriber.
282
- this.#options.source.forget?.(subscription.qid);
283
- }
284
-
285
- /**
286
- * An entry nothing is holding, dropped with the retained patches that were kept for it — the
287
- * same three steps `unsubscribe` takes when the last subscriber leaves, for the case where a
288
- * subscriber never arrived. Five cold failures against `maxEntries: 5` used to refuse every
289
- * later subscribe on the node with `X_SUBSCRIPTION_LIMIT`, forever, after the database recovered.
290
- */
291
- #dropIfUnheld(qid: string, entry: QueryEntry): void {
292
- // Identity, never presence: an entry this subscribe did not create may already have been
293
- // dropped and re-created under the same qid.
294
- if (this.#entries.get(qid) !== entry) return;
295
- if (entry.subscribers.size !== 0) return;
296
- // A read published on the entry belongs to a subscriber that has not attached yet — it owns
297
- // the entry's fate, and dropping it here would leave that subscriber holding a window no
298
- // change is ever fanned out to.
299
- if (entry.reading !== null) return;
300
- this.#entries.delete(qid);
301
- this.#options.source.forget?.(qid);
276
+ this.#entries.release(subscription.qid);
302
277
  }
303
278
 
304
279
  unsubscribeSocket(socketId: string): void {
@@ -326,6 +301,7 @@ export class LiveQueryRegistry {
326
301
  book: this.#book,
327
302
  gate: this.#gate,
328
303
  unsubscribe: (socketId, sid) => this.unsubscribe(socketId, sid),
304
+ tenantOf: (qid) => this.#entries.get(qid)?.tenant,
329
305
  // A re-seat is the subscription it already was, so it is never charged again.
330
306
  resubscribe: (args) => this.#subscribe(args, { due: false }),
331
307
  },
@@ -353,37 +329,14 @@ export class LiveQueryRegistry {
353
329
  );
354
330
  }
355
331
 
356
- /**
357
- * This socket's marked subscriptions, repaired now it can take a frame: Bun's `drain`. Frames that
358
- * left. Every lane is entered before any is awaited, as `deliverChange` does, so one slow policy
359
- * pass holds up its own query id and nothing else.
360
- */
361
- async repair(socket: SyncSocket): Promise<number> {
362
- const repairs = [...socket.desynced].map(async (sid) => {
363
- const subscription = this.#book.get(socket.id, sid);
364
- const entry = subscription && this.#entries.get(subscription.qid);
365
- if (!subscription || !entry) return false;
366
- // Its rows are about to be replaced, so wait: this drain is the only one coming. Bounded by
367
- // the read's own deadline, and a read that failed staled the window, which refuses below.
368
- await entry.reading?.result.catch(() => undefined);
369
- return await entry.lock.run(() => repairSubscriber(this.#fanout, entry, subscription));
370
- });
371
- return (await Promise.all(repairs)).filter(Boolean).length;
332
+ /** This socket's marked subscriptions, repaired now it can take a frame: Bun's `drain`. */
333
+ repair(socket: SyncSocket): Promise<number> {
334
+ return repairSocket(this.#fanout, this.#book, this.#entries, socket);
372
335
  }
373
336
 
374
337
  /** The node's sweep: every window owing a mark, each in its lane, one read a window at most. */
375
- async repairMarked(): Promise<number> {
376
- const lanes = [...this.#entries.values()].map((entry) =>
377
- entry.lock.run(() => repairWindow(this.#fanout, entry)),
378
- );
379
- let sent = 0;
380
- let failure: { readonly error: unknown } | null = null;
381
- for (const lane of await Promise.allSettled(lanes)) {
382
- if (lane.status === 'fulfilled') sent += lane.value;
383
- else failure ??= { error: lane.reason };
384
- }
385
- if (failure !== null) throw failure.error;
386
- return sent;
338
+ repairMarked(): Promise<number> {
339
+ return repairEveryWindow(this.#fanout, this.#entries);
387
340
  }
388
341
 
389
342
  #attachUnlessGone(
@@ -427,34 +380,6 @@ export class LiveQueryRegistry {
427
380
  return subscription;
428
381
  }
429
382
 
430
- #entryFor(
431
- qid: string,
432
- definition: LiveQueryDefinition,
433
- input: JsonValue,
434
- tenant: string | null,
435
- ): QueryEntry {
436
- const existing = this.#entries.get(qid);
437
- if (existing) return existing;
438
- // The node-wide ceiling, refused where the entry would be born. `qid` derives from
439
- // client-chosen input, so one socket varying it mints a matcher, a row window and a
440
- // `WindowLock` per value, and every change then fans out over all of them.
441
- if (this.#entries.size >= this.#maxEntries) {
442
- throw new SubscriptionLimitError({
443
- scope: 'node',
444
- id: definition.name,
445
- limit: this.#maxEntries,
446
- knob: 'maxEntries',
447
- });
448
- }
449
- const created = queryEntry(qid, definition, input, definition.matcher(input), {
450
- readDeadlineMs: this.#options.readDeadlineMs,
451
- schedule: this.#options.schedule,
452
- tenant,
453
- });
454
- this.#entries.add(created);
455
- return created;
456
- }
457
-
458
383
  /**
459
384
  * One read, then one policy pass per subscriber. Never one read per subscriber — and never a
460
385
  * partial pass: a gate that fails raises out of `subscribe`, because a snapshot missing the rows
@@ -3,12 +3,11 @@
3
3
  // rather than carried past a ceiling. Split from `live-query.ts` at its size ceiling: the registry
4
4
  // owns the entry table, this owns what a re-authorization does to one socket's subscriptions.
5
5
 
6
- import { queryHash } from '@ultimat3/query';
7
6
  import { isPolicyDenial } from './errors';
8
7
  import type { LiveSubscription } from './live-contract';
9
8
  import { refuseSubscription } from './live-refusal';
10
9
  import type { SubscribeArgs } from './live-spend';
11
- import { liveTenantOf, windowId } from './live-tenant';
10
+ import { liveTenantOf, retenant } from './live-tenant';
12
11
  import type { SyncSocket } from './socket';
13
12
  import type { SubscriberGate } from './subscriber-gate';
14
13
  import type { SubscriptionBook } from './subscription-book';
@@ -19,6 +18,8 @@ export interface ReauthDeps {
19
18
  readonly book: SubscriptionBook;
20
19
  readonly gate: SubscriberGate;
21
20
  unsubscribe(socketId: string, sid: string): void;
21
+ /** The tenant a window is read for; `undefined` when no entry holds that id. */
22
+ tenantOf(qid: string): string | null | undefined;
22
23
  /** Subscribe again under the same sid, uncharged — a re-seat is not a new subscription. */
23
24
  resubscribe(args: SubscribeArgs): Promise<{ readonly frame: Frame }>;
24
25
  }
@@ -94,8 +95,14 @@ async function reseat(
94
95
  failed: { readonly error: unknown } | undefined,
95
96
  ): Promise<boolean> {
96
97
  const { sid, input, definition } = subscription;
97
- const wanted = windowId(queryHash(definition.name, input), liveTenantOf(socket.actor));
98
- if (wanted === subscription.qid) return false;
98
+ const tenant = liveTenantOf(socket.actor);
99
+ const seated = deps.tenantOf(subscription.qid);
100
+ // Derived from the window held, never re-hashed from the input: a window is keyed by the PARSED
101
+ // input (`LiveQueryDefinition.queryId`), so a hash of the input as sent names another id whenever
102
+ // the schema stripped a key, and every re-auth re-seated that subscription for nothing.
103
+ if (seated === tenant) return false;
104
+ const wanted =
105
+ seated === undefined ? subscription.qid : retenant(subscription.qid, seated, tenant);
99
106
  deps.unsubscribe(socket.id, sid);
100
107
  if (failed !== undefined) {
101
108
  refuseSubscription(socket, sid, failed.error);
@@ -0,0 +1,47 @@
1
+ // The registry's two repair paths, each window in its own lane: one socket's marked subscriptions
2
+ // when it can take a frame again (Bun's `drain`), and the node's sweep over every window owing a
3
+ // mark. Split from `live-query.ts` at its size ceiling: the registry owns the tables, this owns
4
+ // what a repair does with them.
5
+
6
+ import type { LiveEntries } from './live-entries';
7
+ import { type FanoutDeps, repairSubscriber, repairWindow } from './live-fanout';
8
+ import type { SyncSocket } from './socket';
9
+ import type { SubscriptionBook } from './subscription-book';
10
+
11
+ /**
12
+ * This socket's marked subscriptions, repaired now. Frames that left. Every lane is entered before
13
+ * any is awaited, as `deliverChange` does, so one slow policy pass holds up its own query id and
14
+ * nothing else.
15
+ */
16
+ export async function repairSocket(
17
+ fanout: FanoutDeps,
18
+ book: SubscriptionBook,
19
+ entries: LiveEntries,
20
+ socket: SyncSocket,
21
+ ): Promise<number> {
22
+ const repairs = [...socket.desynced].map(async (sid) => {
23
+ const subscription = book.get(socket.id, sid);
24
+ const entry = subscription && entries.get(subscription.qid);
25
+ if (!subscription || !entry) return false;
26
+ // Its rows are about to be replaced, so wait: this drain is the only one coming. Bounded by
27
+ // the read's own deadline, and a read that failed staled the window, which refuses below.
28
+ await entry.reading?.result.catch(() => undefined);
29
+ return await entry.lock.run(() => repairSubscriber(fanout, entry, subscription));
30
+ });
31
+ return (await Promise.all(repairs)).filter(Boolean).length;
32
+ }
33
+
34
+ /** The node's sweep: every window owing a mark, each in its lane, one read a window at most. */
35
+ export async function repairEveryWindow(fanout: FanoutDeps, entries: LiveEntries): Promise<number> {
36
+ const lanes = [...entries.values()].map((entry) =>
37
+ entry.lock.run(() => repairWindow(fanout, entry)),
38
+ );
39
+ let sent = 0;
40
+ let failure: { readonly error: unknown } | null = null;
41
+ for (const lane of await Promise.allSettled(lanes)) {
42
+ if (lane.status === 'fulfilled') sent += lane.value;
43
+ else failure ??= { error: lane.reason };
44
+ }
45
+ if (failure !== null) throw failure.error;
46
+ return sent;
47
+ }
@@ -23,3 +23,15 @@ export function liveTenantOf(actor: Actor | null): string | null {
23
23
  export function windowId(queryId: string, tenant: string | null): string {
24
24
  return tenant === null ? queryId : JSON.stringify([queryId, tenant]);
25
25
  }
26
+
27
+ /**
28
+ * The same query id, read for another tenant: `windowId`'s inverse, then `windowId` again. A
29
+ * re-seat asks this of the window it holds rather than re-hashing the input, because the id is
30
+ * taken over the PARSED input (`LiveQueryDefinition.queryId`), which the input as sent is not.
31
+ */
32
+ export function retenant(qid: string, from: string | null, to: string | null): string {
33
+ if (from === null) return windowId(qid, to);
34
+ const pair: unknown = JSON.parse(qid);
35
+ const queryId = Array.isArray(pair) && typeof pair[0] === 'string' ? pair[0] : qid;
36
+ return windowId(queryId, to);
37
+ }
@@ -5,7 +5,12 @@
5
5
  import type { LiveReadPhase } from '@ultimat3/query';
6
6
  import { WindowReadTimeoutError } from './errors';
7
7
  import type { JsonValue, LiveRow } from './json';
8
- import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
8
+ import type {
9
+ LiveQueryDefinition,
10
+ LiveSubscription,
11
+ PreparedWindow,
12
+ SnapshotResult,
13
+ } from './live-contract';
9
14
  import type { IncrementalMatcher, SubscriptionShape } from './matcher-bridge';
10
15
  import type { Scheduler } from './thundering-herd';
11
16
  import { timeoutScheduler } from './thundering-herd';
@@ -92,7 +97,14 @@ export function queryEntry(
92
97
  definition: LiveQueryDefinition,
93
98
  input: JsonValue,
94
99
  matcher: IncrementalMatcher,
95
- options?: EntryOptions & { readonly tenant?: string | null },
100
+ options?: EntryOptions & {
101
+ readonly tenant?: string | null;
102
+ /**
103
+ * What `prepare` resolved for this input. Its row scope is taken as given, never looked up
104
+ * again by input: a bounded cache another subscribe evicted in between answers nothing.
105
+ */
106
+ readonly prepared?: PreparedWindow | undefined;
107
+ },
96
108
  ): QueryEntry {
97
109
  const tenant = options?.tenant ?? null;
98
110
  return {
@@ -102,8 +114,7 @@ export function queryEntry(
102
114
  tenant,
103
115
  // Resolved with the matcher, from the same build: `prepare` has already run, so a definition
104
116
  // that compiles its shape per input can answer.
105
- rowEntity: definition.rowEntity?.(input) ?? null,
106
- rowKey: definition.rowKey?.(input) ?? null,
117
+ ...rowScope(definition, input, options?.prepared),
107
118
  shape: {
108
119
  qid,
109
120
  // The matcher knows the dependency set this *input* produced; `definition.entities` is the
@@ -130,6 +141,25 @@ export function queryEntry(
130
141
  };
131
142
  }
132
143
 
144
+ /**
145
+ * The identity scope an entry's rows travel under: what `prepare` resolved when it answered, else
146
+ * asked of the definition by input. Only a scope the definition states — one with no `rowEntity`
147
+ * sends none, whatever it resolved.
148
+ */
149
+ function rowScope(
150
+ definition: LiveQueryDefinition,
151
+ input: JsonValue,
152
+ prepared: PreparedWindow | undefined,
153
+ ): Pick<QueryEntry, 'rowEntity' | 'rowKey'> {
154
+ const entity = definition.rowEntity;
155
+ const key = definition.rowKey;
156
+ return {
157
+ rowEntity:
158
+ entity === undefined ? null : prepared ? prepared.rowEntity : (entity(input) ?? null),
159
+ rowKey: key === undefined ? null : prepared ? prepared.rowKey : (key(input) ?? null),
160
+ };
161
+ }
162
+
133
163
  /**
134
164
  * Remember the subscriber's `traceparent` for the window's next read. Bounded and never parsed
135
165
  * here: a value is either a string a W3C header could be or it is dropped, and `traceLiveRead`
package/src/server.ts CHANGED
@@ -108,6 +108,7 @@ export {
108
108
  export type {
109
109
  LiveQueryDefinition,
110
110
  LiveSubscription,
111
+ PreparedWindow,
111
112
  SnapshotResult,
112
113
  } from './live-contract';
113
114
  export { type LiveDefinitionOptions, liveQueryDefinition } from './live-definition';