@ultimat3/realtime 22.15.0 → 23.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.
package/CLAUDE.md CHANGED
@@ -85,8 +85,9 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
85
85
  - **A denial is a decision; everything else is a failure.** `visibleWithPolicy` matches
86
86
  `QueryDeniedError` and rethrows the rest; `subscriber-gate.ts` and `reauthorize` ask
87
87
  `isPolicyDenial(error)`. A failed snapshot raises out of `subscribe`; a failed delivery desyncs that
88
- one subscriber; a failed `reauthorize` keeps the subscription. Failures count as `gateFailures` via
89
- `onGateFailed`, never `onRowDenied`.
88
+ one subscriber; a failed `reauthorize` keeps the subscription, unless its org moved — then it is
89
+ refused under its sid (`refuseSubscription`), as is a failed re-seat. Failures count as
90
+ `gateFailures` via `onGateFailed`, never `onRowDenied`.
90
91
  - **One serial lane per query id (`WindowLock`) is the only thing that orders a fanout.** `deliver`
91
92
  enters every lane before awaiting any; no fanout takes a second lane; a lane that fails desyncs its
92
93
  own subscribers; lanes chain on a settled shadow of each task.
@@ -107,8 +108,14 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
107
108
  - **A change the window already holds is refused** (`change.lsn <= entry.lsn`, counted as
108
109
  `staleChanges`). **A gap is detected**: the replicator stamps `producer` + `seq`; a skipped
109
110
  sequence marks every window stale and every subscriber desynced; `refillWindowInLane` replaces it.
110
- - **A qid is `@ultimat3/query`'s `queryHash(name, input)`**; this package owns no hash
111
- (`live-contract.test.ts`). The canonical form is core's and injective (`-0` is wire-reachable).
111
+ - **A bulk write stales only the windows reading its entity** (`invalidate(entity)`, from
112
+ `onBulk`). **A stale window's re-read re-snapshots EVERY subscriber**, before the lsn guard and
113
+ on a non-matching change too (`live-replicator-bulk.test.ts`, `live-fanout.test.ts`).
114
+ - **A WINDOW is per TENANT**: its id is `windowId(queryHash(name, input), tenant)` (`live-tenant.ts`;
115
+ tenant = the subscriber's `actor.orgId`). This package owns no hash. The read runs `readFor`
116
+ (`live-definition.ts`): the node's ctx, a service actor carrying ONLY that org — never the
117
+ subscriber. A cursor naming another window is a cold start; `reauthorize` re-seats a
118
+ subscription whose actor changed org (`live-tenancy.test.ts`; `docs/history/realtime.md`).
112
119
  - **A `sid` is CLIENT data: a subscription is keyed by `(socket, sid)`**
113
120
  (`subscription-book.ts` is the only spelling). Reusing a sid the same socket holds is
114
121
  `X_SUBSCRIPTION_ID_TAKEN`.
@@ -168,8 +175,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
168
175
  `DrainedSocket[]` with `notified` and logs `sync.drain_frames_dropped`.
169
176
  - **Inbound frames run in a lane, never the socket**: `subscribe` is `sub:<sid>` or `topic:<name>`,
170
177
  everything else unlaned (`frame-lanes.ts`); a lane exists only while work is queued.
171
- - **Bun's native pub/sub is deleted**; `SocketRegistry.deliver` is the one fanout path.
172
- `WsLike.subscribe`/`unsubscribe` stay declared for structural implementers.
173
178
  - **A dropped `records` frame is counted AND repaired**: per-node `seq`/`epoch` per channel; a refused
174
179
  frame marks the socket gapped, and the websocket `drain` handler sends `replay-gap`
175
180
  (`channel_replay_gaps_total` beside `channel_frames_dropped_total`). The client re-runs the
@@ -265,20 +270,16 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
265
270
  throws in the timer is reported through `onError` (default `console.error`) and arms the next.
266
271
  - **`connect()` closes the socket it replaces**; `onOpen`, `onMessage` and `onClose` all carry the
267
272
  identity guard.
273
+ - **A refusal `ack` is revived as a branded `UltimateError`** (`refusalError`, `client-frames.ts`).
268
274
  - **A reconnect replays registrations AND topics** — one `hello`, one `subscribe` per registration
269
275
  (with its cursor) and per topic.
270
- - **`hello` carries NO cursors** (`HelloFrame.resume` is deleted): a cursor's qid names a window but
271
- not the input a decision needs.
272
276
  - **The client beats** (`heartbeatMs`, default `DEFAULT_HEARTBEAT_MS` 15 s, `0` disables): a `hello`
273
277
  plus one subscribe per topic (the presence heartbeat). Two silent windows close with `4000`. The
274
278
  node's `socket.skewed` compares the build the `hello` claims against its own. The server beat is
275
279
  derived: `PresenceRegistry.heartbeatMs = max(1000, floor(ttlMs / 3))`.
276
- - **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1.** `policyDelay(policy, attempt,
277
- rng)` (internal, not on the barrel) maps a `BackoffPolicy` onto it; a 0-based counter adds 1 at the
278
- call site (`client.ts`, `socket-engine.ts`, `replicator.ts`'s 0-based `retryDelayMs`).
279
- `client-reconnect.test.ts`, `socket-engine-reconnect.test.ts`, `nats-transport.test.ts` pin the
280
- first wait at the base. `drainPlan()` and `AcceptBudget` keep their own arithmetic.
281
- `bun run flight-copies` refuses a second curve.
280
+ - **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` maps a
281
+ `BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve. Long form:
282
+ `docs/history/realtime.md`, "Moved 2026-10-01".
282
283
 
283
284
  ## Rules for code here
284
285
 
@@ -287,17 +288,9 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
287
288
  `Denied`, `ThirdPartySdkError`): it simulates a value this package did not construct. The rule
288
289
  governs what this package **throws**, never what a test hands it.
289
290
 
290
- ## Browser bytes, per hook (`As of 2026-09-22`)
291
+ ## The page boot
291
292
 
292
- `bun build --target=browser --minify`, one entry importing one hook from the barrel. Re-measure
293
- before quoting.
294
-
295
- | Hook | Current |
296
- |---|---|
297
- | `useRecord` | 13,226 |
298
- | `useMutation` | 21,725 (after the outbox left islands) |
299
- | `useQuery` | 47,696 |
300
- | `useChannel` | 45,585 |
293
+ Browser bytes per hook, as last measured: `docs/history/realtime.md`. Re-measure before quoting.
301
294
 
302
295
  - **The disk boot is ONE page script (`boot.ts`, `./boot`)**, served at `/_x/page-boot/<hash>.js`
303
296
  on a document with the scope tag and a realtime island; it wipes every other principal's stored
@@ -309,7 +302,7 @@ before quoting.
309
302
 
310
303
  `index.ts` / `server.ts` are the two barrels. Server side: `sync-node.ts` (+ `sync-auth`,
311
304
  `sync-frames`, `sync-upgrade`, `sync-listen`, `drain-evictions`), `channel.ts`, `presence.ts`,
312
- `socket.ts`, `live-query.ts` (+ `live-definition`, `query-window`, `live-fanout`, `window-lock`,
305
+ `socket.ts`, `live-query.ts` (+ `live-definition`, `live-refusal`, `live-tenant`, `live-resume`, `query-window`, `live-fanout`, `window-lock`,
313
306
  `subscriber-gate`, `policy-gate` — the only authz seam — and `matcher-bridge`), `replicator.ts` /
314
307
  `live-replicator.ts`, the Postgres client (`pg-*.ts`, `pgoutput.ts`), the bus (`nats-*.ts`,
315
308
  `transport-env.ts`, `fanout.ts`). Client side: `client*.ts`, `socket-engine.ts` / `socket-host.ts` /
package/README.md CHANGED
@@ -232,6 +232,32 @@ One authz system, never two: `policy` is evaluated **once per subscriber**, neve
232
232
  Two actors on one live query get two different result sets, and a row that leaves an actor's policy
233
233
  is delivered to them as a `delete` — never as silence.
234
234
 
235
+ **The shared read is a TENANT's, never the node's.** One `(query, input)` is one window per org:
236
+ its id is `windowId(queryHash(name, input), tenant)`, the tenant being the subscriber's
237
+ `actor.orgId`, and its source is read in a context that carries that org and nothing else of any
238
+ subscriber's. So a repo that leaves the tenant to the acting actor — what `x g query --live`
239
+ generates — reads on a sync node exactly as it reads over HTTP, and no org predicate is written
240
+ by hand.
241
+
242
+ | On a sync node | Answer |
243
+ |---|---|
244
+ | two subscribers of one org, one `(query, input)` | one window, one read, one policy pass each |
245
+ | two subscribers in two orgs, one `(query, input)` | two windows, two reads, two retained patch rings — a row of one is never in the other's, policy or not |
246
+ | the source names no org (`db.posts.orderBy(…)`) | scoped to the window's tenant by `@ultimat3/entity`'s guard |
247
+ | the source names ANOTHER org | `X_TENANCY_ACTOR_MISMATCH`, as over HTTP — it was answered, until 2026-10 |
248
+ | a subscriber with no org reads a tenant-scoped table | `X_TENANCY_ACTOR_ORG_REQUIRED`, as over HTTP |
249
+ | a reconnect presents a cursor minted in another org's window | a cold start: one snapshot, never a replay of that window's patches |
250
+ | the actor changes org mid-connection | `reauthorize` re-seats the subscription on its new tenant's window and sends that snapshot |
251
+
252
+ The read's actor is `serviceActor({ id: 'live-window', orgId })` — the shape a job run gets. Never
253
+ a subscriber's: a window has many, and one subscriber's identity on a shared read is that
254
+ subscriber's entitlements becoming the window's.
255
+
256
+ **A refusal reaches the page as itself.** A subscription the node refuses is an `ack` carrying
257
+ `{ code, cause, fix }`; the client revives it as a branded `UltimateError` (`meta.origin:
258
+ 'remote'`), so `useQuery`'s `failed` state and an `<ErrorState>` render that code and that fix.
259
+ Handed over as the bare wire object it failed `isUltimateError` and rendered as `X_INTERNAL`.
260
+
235
261
  ## What one socket may cost
236
262
 
237
263
  Every ceiling on this node, and the option that moves it. Each one is a default, not a policy: an
@@ -401,6 +427,10 @@ wire twice by a reconnect that raced an ack.
401
427
  re-reads and re-snapshots. Both fields are optional on the bus, so a publisher that does not
402
428
  sequence simply detects nothing. Durable replay (JetStream) is a separate decision — retention,
403
429
  storage and replay window — and is deliberately not this mechanism.
430
+ - **A bulk write re-reads only the windows over its entity.** `updateWhere`/`deleteWhere` name a
431
+ filter, not rows, so the in-process replicator calls `registry.invalidate(entity)`: windows whose
432
+ shape reads that entity go stale and their subscribers desynced. The next change re-reads the
433
+ window and re-snapshots every subscriber, whether or not that change matches the query.
404
434
  - **`desynced` has a reader.** A subscriber recorded as diverged — a dropped patch, a gate that
405
435
  failed, a window that lost its tail — is served a fresh snapshot out of the shared window on the
406
436
  next delivery, and only then is the mark cleared. A snapshot the socket refuses leaves it
@@ -443,9 +473,9 @@ wire twice by a reconnect that raced an ack.
443
473
  `subscribe` is one lane per sid, or per topic name; `hello` and
444
474
  the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
445
475
  lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
446
- - **`qid` is `@ultimat3/query`'s `queryHash(name, input)`** — `<name>:<first 16 hex of
447
- SHA-256(canonicalJson(input))>`, 64 bits `As of 2026-08` where it was a 32-bit FNV-1a. It is a
448
- *sharing* key: a hit is answered with the existing entry and the seated window, both holding the
476
+ - **A window's `qid` is `@ultimat3/query`'s `queryHash(name, input)`, qualified by the tenant it
477
+ is read for** (`windowId`) — `<name>:<first 16 hex of SHA-256(canonicalJson(input))>`, 64 bits
478
+ `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
449
479
  first subscriber's input and rows, and input is client-chosen, so a collision is one client served
450
480
  out of another's window. This package derives none of its own — `qidOf` was a second spelling of
451
481
  `queryHash` while `@ultimat3/query`'s `planResume` compares a cursor's `queryHash` against the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "22.15.0",
3
+ "version": "23.0.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,9 +38,9 @@
38
38
  "test": "bun test"
39
39
  },
40
40
  "dependencies": {
41
- "@ultimat3/core": "22.15.0",
42
- "@ultimat3/entity": "22.15.0",
43
- "@ultimat3/query": "22.15.0",
41
+ "@ultimat3/core": "23.0.0",
42
+ "@ultimat3/entity": "23.0.0",
43
+ "@ultimat3/query": "23.0.0",
44
44
  "nats": "2.29.3"
45
45
  }
46
46
  }
@@ -3,11 +3,12 @@
3
3
  // every piece of the client a frame may touch, so the blast radius of a new frame kind is a
4
4
  // reviewable list rather than "whatever the router could reach through `this`".
5
5
 
6
+ import { UltimateError } from '@ultimat3/core/page';
6
7
  import type { ChannelBook } from './client-channels';
7
8
  import { CLOSE } from './close-codes';
8
9
  import { advance } from './cursor';
9
10
  import type { Registration, RowWindows } from './live-rows';
10
- import type { Frame } from './sync-protocol';
11
+ import type { Frame, WireError } from './sync-protocol';
11
12
 
12
13
  /** Declared with the window it projects; re-exported here because the router is what writes it. */
13
14
  export type { LiveState, Registration } from './live-rows';
@@ -42,6 +43,28 @@ export interface ClientFrameTarget {
42
43
  report(error: unknown): void;
43
44
  }
44
45
 
46
+ /** An absolute HTTP(S) link, or nothing: a node's `docs` is data and may be `javascript:`. */
47
+ const HTTP_URL = /^https?:\/\/[^\s]+$/;
48
+
49
+ /**
50
+ * A refusal off the wire, as the error the node raised: the same code, cause and fix, BRANDED —
51
+ * the shape `@ultimat3/core`'s `problemError` gives an HTTP failure, marked `origin: 'remote'` the
52
+ * same way. Handed over as the bare `WireError` it failed `isUltimateError`, so every reader that
53
+ * asks that question — `<ErrorState>`, a boundary, a reporter — fell through to "an unknown value
54
+ * was thrown": `X_INTERNAL`, the real error JSON-stringified into its cause, and a fix telling the
55
+ * reader to throw the `UltimateError` it already was.
56
+ */
57
+ export function refusalError(wire: WireError): UltimateError {
58
+ const docs = wire.docs !== undefined && HTTP_URL.test(wire.docs) ? wire.docs : undefined;
59
+ return new UltimateError({
60
+ code: wire.code,
61
+ cause: wire.cause,
62
+ fix: wire.fix,
63
+ meta: { origin: 'remote' },
64
+ ...(docs === undefined ? {} : { docs }),
65
+ });
66
+ }
67
+
45
68
  export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
46
69
  switch (frame.type) {
47
70
  case 'snapshot': {
@@ -78,13 +101,14 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
78
101
  // The socket carries no writes, so an `ack` is only ever a refusal: of a subscription (its
79
102
  // `ref` is the sid) or of a frame the node could not read at all (its `ref` is the socket).
80
103
  if (frame.error === null) return;
104
+ const refusal = refusalError(frame.error);
81
105
  const registration = target.registration(frame.ref);
82
106
  if (registration === undefined) {
83
- if (!target.channels.refused(frame.ref, frame.error)) target.report(frame.error);
107
+ if (!target.channels.refused(frame.ref, refusal)) target.report(refusal);
84
108
  return;
85
109
  }
86
110
  registration.state = 'failed';
87
- registration.error = frame.error;
111
+ registration.error = refusal;
88
112
  registration.notify();
89
113
  return;
90
114
  }
@@ -26,8 +26,12 @@ export interface LiveQueryDefinition<R extends Row = Row> {
26
26
  readonly entities: readonly string[];
27
27
  /** Read set. Lets the pre-filter skip updates that touch no column this query reads. */
28
28
  readonly columns?: readonly string[];
29
- /** Bounded read (`orderBy` + `limit`, enforced by `x verify`), unfiltered by policy. */
30
- snapshot(args: { input: JsonValue }): Promise<SnapshotResult<R>>;
29
+ /**
30
+ * Bounded read (`orderBy` + `limit`, enforced by `x verify`), unfiltered by policy — and read
31
+ * FOR `tenant`: the org of the subscribers this window serves, `null` for subscribers that carry
32
+ * none. A window is keyed by it (`live-tenant.ts`), so one read is never two orgs' rows.
33
+ */
34
+ snapshot(args: { input: JsonValue; tenant: string | null }): Promise<SnapshotResult<R>>;
31
35
  /** Subscribe-time gate. Throws to deny — the same `policy` used by HTTP, jobs, and MCP. */
32
36
  authorize?(args: { actor: Actor | null; input: JsonValue }): void | Promise<void>;
33
37
  /** Row-level gate, evaluated per subscriber. The only row filter in the pipeline. */
@@ -9,7 +9,7 @@
9
9
  // every time. Collapsing the second onto the first is privilege escalation with a cache hit rate.
10
10
 
11
11
  import type { Ctx } from '@ultimat3/core';
12
- import { finiteOption } from '@ultimat3/core';
12
+ import { finiteOption, runWithContext, serviceActor, withChildContext } from '@ultimat3/core';
13
13
  import { type AnyQuery, queryHash, queryName } from '@ultimat3/query';
14
14
  import { LiveRowUnidentifiedError } from './errors';
15
15
  import { isRow, type JsonValue, type Row } from './json';
@@ -22,7 +22,8 @@ export interface LiveDefinitionOptions {
22
22
  /**
23
23
  * The node's own context — never a subscriber's. It supplies services, clock and locale to the
24
24
  * shared read; it supplies no authority, because that read is built with the query's policy
25
- * switched off and every row leaving it is gated per subscriber.
25
+ * switched off and every row leaving it is gated per subscriber. The read itself runs in a CHILD
26
+ * of it that carries the window's tenant and nothing else of any subscriber's (`readFor`).
26
27
  */
27
28
  readonly ctx: Ctx;
28
29
  /**
@@ -82,6 +83,25 @@ const UNRESOLVED: IncrementalMatcher = {
82
83
  match: () => ({ patches: [], refill: true }),
83
84
  };
84
85
 
86
+ /** Who a shared read runs as: the node, in one org. Never a subscriber — a window has many. */
87
+ const LIVE_READER_ID = 'live-window';
88
+
89
+ /**
90
+ * Run one shared read FOR a tenant: inside the node's context, as a service actor that carries the
91
+ * window's org and no subscriber's identity, roles or grants. `@ultimat3/entity`'s tenant guard
92
+ * derives from the ambient context, so this is what makes a repo that leaves the tenant to the
93
+ * acting actor read on a sync node exactly as it reads over HTTP — and what refuses a read that
94
+ * names another org (`X_TENANCY_ACTOR_MISMATCH`), which with NO context was simply answered.
95
+ *
96
+ * `tenant === null` runs under the node's own actor, which has no org: a tenant-scoped read is
97
+ * then `X_TENANCY_ACTOR_ORG_REQUIRED`, the verdict the same anonymous read gets over HTTP.
98
+ */
99
+ function readFor<T>(ctx: Ctx, tenant: string | null, read: () => Promise<T>): Promise<T> {
100
+ if (tenant === null) return runWithContext(ctx, read);
101
+ const actor = serviceActor({ id: LIVE_READER_ID, orgId: tenant });
102
+ return runWithContext(ctx, () => withChildContext({ actor }, read));
103
+ }
104
+
85
105
  /**
86
106
  * Registrable definition for one declared query. `register` takes it by name, so what comes back
87
107
  * is input-independent: the per-input half is resolved by `prepare`, which the registry awaits
@@ -140,13 +160,13 @@ export function liveQueryDefinition(
140
160
  prepare: async (input) => {
141
161
  await resolve(input);
142
162
  },
143
- snapshot: async ({ input }): Promise<SnapshotResult> => {
163
+ snapshot: async ({ input, tenant }): Promise<SnapshotResult> => {
144
164
  const window = await resolve(input);
145
165
  // The position is taken BEFORE the rows: the rows are then at least that new, so the claim
146
166
  // is true. Taken after, a commit landing mid-read was claimed and missing — and every change
147
167
  // up to it is dropped downstream as already folded.
148
168
  const lsn = options.lsn?.() ?? '';
149
- return { rows: await window.read(), lsn };
169
+ return { rows: await readFor(options.ctx, tenant, () => window.read()), lsn };
150
170
  },
151
171
  matcher: (input) => windows.get(queryHash(name, input))?.matcher ?? UNRESOLVED,
152
172
  // Read off the same resolved window as the matcher, so the scope the client keys rows under and
@@ -47,14 +47,29 @@ export async function fanoutChange(
47
47
  return { sent: 0, stale: 0 };
48
48
  }
49
49
  if (change.op === 'truncate') return await truncated(deps, entry, change);
50
- if (entry.stale) await refillWindowInLane(entry);
50
+ const reread = entry.stale;
51
+ if (reread) {
52
+ await refillWindowInLane(entry);
53
+ // Every subscriber was fed from the rows this read just replaced, so each is owed a snapshot —
54
+ // not only the ones `invalidate()` marked: a read that failed or timed out stales the window
55
+ // and marks nobody.
56
+ for (const subscription of entry.subscribers.values()) {
57
+ subscription.socket.markDesynced(subscription.sid);
58
+ }
59
+ // The read claims to hold this change already (`lsn` is the node's newest when it began), so
60
+ // there is nothing to patch. Returning before the loop below was the defect: the guard after
61
+ // this refused the change as stale and every desynced subscriber went without the change AND
62
+ // without its snapshot — a run console frozen on its first event after one `updateWhere`.
63
+ if (change.lsn <= entry.lsn) return { sent: await resnapshotAll(deps, entry), stale: 0 };
64
+ }
51
65
  // The consume-side twin of the replicator's own duplicate guard, which had none. `entry.lsn =
52
66
  // change.lsn` was unconditional, so a change the window already holds — a redelivery, or one
53
67
  // that arrived behind the snapshot that already included it — rewound every subscriber's cursor
54
68
  // to it and asked them to fold state they had already folded over newer rows.
55
69
  if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
56
70
  const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
57
- if (!bridged) return { sent: 0, stale: 0 };
71
+ // Matching nothing ends the fanout, but never before a re-read's marks are answered.
72
+ if (!bridged) return { sent: reread ? await resnapshotAll(deps, entry) : 0, stale: 0 };
58
73
  // Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
59
74
  const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
60
75
  entry.lsn = change.lsn;
@@ -138,12 +153,20 @@ async function truncated(
138
153
  if (!entry.shape.entities.includes(change.entity)) return { sent: 0, stale: 0 };
139
154
  await refillWindowInLane(entry);
140
155
  if (change.lsn > entry.lsn) entry.lsn = change.lsn;
141
- let sent = 0;
142
156
  for (const subscription of entry.subscribers.values()) {
143
157
  subscription.socket.markDesynced(subscription.sid);
158
+ }
159
+ return { sent: await resnapshotAll(deps, entry), stale: 0 };
160
+ }
161
+
162
+ /** Each marked subscriber, re-snapshotted out of the window just read. Frames that left. */
163
+ async function resnapshotAll(deps: FanoutDeps, entry: QueryEntry): Promise<number> {
164
+ let sent = 0;
165
+ for (const subscription of entry.subscribers.values()) {
166
+ if (!subscription.socket.desynced.has(subscription.sid)) continue;
144
167
  if (await resnapshot(deps, entry, subscription)) sent += 1;
145
168
  }
146
- return { sent, stale: 0 };
169
+ return sent;
147
170
  }
148
171
 
149
172
  /**
package/src/live-query.ts CHANGED
@@ -1,31 +1,27 @@
1
1
  // Tier 2: live queries. Registration, per-subscriber authz, snapshot, patch stream.
2
2
  //
3
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
4
+ // query**. The DB read is shared across subscribers of the same query id IN ONE TENANT
5
+ // (`live-tenant.ts`); the authz decision is not shared at all. Two actors on one live query see two different result sets, and a row that fails an actor's
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
9
  import { type Actor, type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core';
10
10
  import { queryHash } from '@ultimat3/query';
11
11
  import type { ChangeEvent } from './changefeed';
12
- import {
13
- advance,
14
- type LiveCursor,
15
- makeCursor,
16
- type ReconnectBudget,
17
- type ResumeSource,
18
- resumeFrom,
19
- } from './cursor';
12
+ import { type LiveCursor, makeCursor, type ReconnectBudget, type ResumeSource } from './cursor';
20
13
  import { isPolicyDenial, LiveQueryUnknownError, SubscriptionLimitError } from './errors';
21
14
  import type { JsonValue } from './json';
22
15
  import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
23
16
  import { type FanoutDeps, fanoutChange, snapshotFrame } from './live-fanout';
17
+ import { refuseSubscription } from './live-refusal';
18
+ import { resumeOnto } from './live-resume';
19
+ import { liveTenantOf, windowId } from './live-tenant';
24
20
  import { createEntry, fillWindow, type QueryEntry } from './query-window';
25
21
  import type { SyncSocket } from './socket';
26
22
  import { type Subscriber, SubscriberGate, type SubscriberGateOptions } from './subscriber-gate';
27
23
  import { SubscriptionBook, subscriptionKey } from './subscription-book';
28
- import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
24
+ import type { Frame } from './sync-protocol';
29
25
  import type { Scheduler } from './thundering-herd';
30
26
 
31
27
  export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
@@ -111,10 +107,15 @@ export class LiveQueryRegistry {
111
107
  * whose lsn never moved, subscribers whose cursors never moved, and therefore nothing that would
112
108
  * ever ask for a re-snapshot. The repair lands on the next change to each query — which is the
113
109
  * event that proves the query is moving at all.
110
+ *
111
+ * `entity` narrows it to the windows that read that entity: a filtered write
112
+ * (`updateWhere`/`deleteWhere`) names its entity but no row, and staling every window on the node
113
+ * for it re-read every live query in the process on each bulk write to any table.
114
114
  */
115
- invalidate(): number {
115
+ invalidate(entity?: string): number {
116
116
  let marked = 0;
117
117
  for (const entry of this.#entries.values()) {
118
+ if (entity !== undefined && !entry.shape.entities.includes(entity)) continue;
118
119
  entry.stale = true;
119
120
  for (const subscription of entry.subscribers.values()) {
120
121
  subscription.socket.markDesynced(subscription.sid);
@@ -188,8 +189,11 @@ export class LiveQueryRegistry {
188
189
  // may not subscribe is work an unauthorized client gets to schedule.
189
190
  await definition.prepare?.(args.input);
190
191
 
191
- const qid = queryHash(args.name, args.input);
192
- const entry = this.#entryFor(qid, definition, args.input);
192
+ // The window is the subscriber's TENANT's: two orgs on one `(query, input)` are two entries,
193
+ // two reads and two retained rings, so no shared row ever stands between them and a policy.
194
+ const tenant = liveTenantOf(args.socket.actor);
195
+ const qid = windowId(queryHash(args.name, args.input), tenant);
196
+ const entry = this.#entryFor(qid, definition, args.input, tenant);
193
197
  try {
194
198
  return await this.#serve(entry, sid, args);
195
199
  } catch (error) {
@@ -215,48 +219,27 @@ export class LiveQueryRegistry {
215
219
  const qid = entry.qid;
216
220
  const now = this.#clock.now().getTime();
217
221
 
218
- if (args.cursor) {
219
- const cursor = args.cursor;
220
- const resumed = await resumeFrom(cursor, {
221
- source: this.#options.source,
222
- ...(this.#options.budget ? { budget: this.#options.budget } : {}),
223
- clock: this.#clock,
224
- snapshot: async () => await this.#read(entry, { sid, actor: args.socket.actor }),
225
- });
226
- if (resumed.kind === 'delta') {
227
- // The gate decides about whole rows out of the shared window, and an entry nothing has read
228
- // yet has none — every patch would meet an empty window and be withheld. Filling is
229
- // conditional on purpose: a restart storm resumes onto entries that already hold a live
230
- // window, and re-reading per resuming subscriber is the cost a delta resume exists to skip.
231
- if (entry.lsn === '') await fillWindow(entry);
232
- // The live entry on purpose: a resume runs outside the lane, so the window under it may
233
- // have moved on — always forwards, and a row whose grant was revoked in the meantime is
234
- // one this pass must refuse rather than replay from the state it had at the cursor's lsn.
235
- const patches = await this.#gate.filterPatches(
236
- entry,
237
- { sid, actor: args.socket.actor },
238
- resumed.patches,
239
- new Set(cursor.ids),
240
- );
241
- // Advanced over the FILTERED list, never over `resumed.cursor` — which `resumeFrom` built
242
- // by advancing across the retained window, and that window is PRE-POLICY. Seated as it
243
- // came back, this subscriber's `cursor.ids` gained the id of every row inserted for every
244
- // OTHER actor while it was away; `subscriber-gate` then reads `held.has(patch.id)` off it,
245
- // takes the "the subscriber holds this row" branch, and delivers a `delete` frame carrying
246
- // another tenant's row id and the instant it went. The leak that branch closes, re-opened
247
- // one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
248
- const seated = advance(cursor, patches, resumed.cursor.lsn, now);
249
- const subscription = this.#attachUnlessGone(entry, args.socket, sid, seated);
250
- return {
251
- subscription,
252
- frame: { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn: seated.lsn },
253
- };
254
- }
222
+ // A cursor is CLIENT data, and its `qid` names the window whose retained patches a resume
223
+ // replays. One that names another window — the same browser, signed in to another org, or a
224
+ // forged frame — is not a position in this one: it is a cold start, never a replay of a ring
225
+ // this subscriber's window does not own.
226
+ if (args.cursor && args.cursor.qid === qid) {
227
+ const who = { sid, actor: args.socket.actor };
228
+ const resumed = await resumeOnto(
229
+ {
230
+ source: this.#options.source,
231
+ budget: this.#options.budget,
232
+ clock: this.#clock,
233
+ gate: this.#gate,
234
+ read: () => this.#read(entry, who),
235
+ },
236
+ entry,
237
+ who,
238
+ args.cursor,
239
+ now,
240
+ );
255
241
  const subscription = this.#attachUnlessGone(entry, args.socket, sid, resumed.cursor);
256
- return {
257
- subscription,
258
- frame: snapshotFrame(entry, sid, resumed.rows, resumed.cursor),
259
- };
242
+ return { subscription, frame: resumed.frame };
260
243
  }
261
244
 
262
245
  const fresh = await this.#read(entry, { sid, actor: args.socket.actor });
@@ -314,6 +297,11 @@ export class LiveQueryRegistry {
314
297
  * what is no longer allowed. Survivors are marked desynced so the next flush re-snapshots them
315
298
  * under the new actor's row policy. Returns the sids that were dropped — a denial and nothing
316
299
  * else, so a caller may tell the client "you may no longer see this" and be right.
300
+ *
301
+ * A survivor whose actor now belongs to ANOTHER tenant cannot stay where it is: its window is the
302
+ * old org's. It is re-seated — dropped, subscribed again under the new tenant, and sent that
303
+ * window's snapshot under the same sid — or, when that cannot complete, refused on the socket
304
+ * under the sid (`refuseSubscription`), never kept on the old org's window.
317
305
  */
318
306
  async reauthorize(socket: SyncSocket): Promise<readonly string[]> {
319
307
  const dropped: string[] = [];
@@ -322,6 +310,7 @@ export class LiveQueryRegistry {
322
310
  // nobody updates is a count that drifts from the book for the rest of the process.
323
311
  this.#book.retenant(socket);
324
312
  for (const subscription of this.#book.ofSocket(socket.id)) {
313
+ let failed: { readonly error: unknown } | undefined;
325
314
  try {
326
315
  await subscription.definition.authorize?.({
327
316
  actor: socket.actor,
@@ -343,12 +332,46 @@ export class LiveQueryRegistry {
343
332
  { sid: subscription.sid, actor: socket.actor },
344
333
  error,
345
334
  );
335
+ failed = { error };
346
336
  }
337
+ if (await this.#reseat(socket, subscription, failed)) continue;
347
338
  socket.markDesynced(subscription.sid);
348
339
  }
349
340
  return dropped;
350
341
  }
351
342
 
343
+ /**
344
+ * Move one subscription onto its actor's CURRENT tenant's window. `false` when it is already
345
+ * there. A re-seat that cannot complete leaves the subscription dropped — never attached to the
346
+ * window of an org its actor has left — and REFUSED on the socket under its sid, the frame a
347
+ * refused subscribe gets, so the client's window renders `failed` instead of going quiet.
348
+ *
349
+ * `failed` is the `authorize` this pass already saw fail: it is not asked again — the store that
350
+ * just timed out is the one `subscribe` would ask — and it is not counted twice.
351
+ */
352
+ async #reseat(
353
+ socket: SyncSocket,
354
+ subscription: LiveSubscription,
355
+ failed: { readonly error: unknown } | undefined,
356
+ ): Promise<boolean> {
357
+ const { sid, input, definition } = subscription;
358
+ const wanted = windowId(queryHash(definition.name, input), liveTenantOf(socket.actor));
359
+ if (wanted === subscription.qid) return false;
360
+ this.unsubscribe(socket.id, sid);
361
+ if (failed !== undefined) {
362
+ refuseSubscription(socket, sid, failed.error);
363
+ return true;
364
+ }
365
+ try {
366
+ const { frame } = await this.subscribe({ socket, name: definition.name, input, sid });
367
+ if (!socket.send(frame)) socket.markDesynced(sid);
368
+ } catch (error) {
369
+ this.#gate.failedAuthorize(wanted, { sid, actor: socket.actor }, error);
370
+ refuseSubscription(socket, sid, error);
371
+ }
372
+ return true;
373
+ }
374
+
352
375
  /**
353
376
  * Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
354
377
  * number of frames sent — the metric the reconnect benchmark watches.
@@ -437,7 +460,12 @@ export class LiveQueryRegistry {
437
460
  return subscription;
438
461
  }
439
462
 
440
- #entryFor(qid: string, definition: LiveQueryDefinition, input: JsonValue): QueryEntry {
463
+ #entryFor(
464
+ qid: string,
465
+ definition: LiveQueryDefinition,
466
+ input: JsonValue,
467
+ tenant: string | null,
468
+ ): QueryEntry {
441
469
  const existing = this.#entries.get(qid);
442
470
  if (existing) return existing;
443
471
  // The node-wide ceiling, refused where the entry would be born. `qid` derives from
@@ -454,6 +482,7 @@ export class LiveQueryRegistry {
454
482
  const created = createEntry(qid, definition, input, definition.matcher(input), {
455
483
  readDeadlineMs: this.#options.readDeadlineMs,
456
484
  schedule: this.#options.schedule,
485
+ tenant,
457
486
  });
458
487
  this.#entries.set(qid, created);
459
488
  return created;
@@ -0,0 +1,21 @@
1
+ // A live subscription refused AFTER it was served: the frame a refused `subscribe` gets — an `ack`
2
+ // whose `ref` is the sid — sent by the node itself, so the client's window renders `failed` with
3
+ // the code, cause and fix instead of going quiet. `sync-node.ts` sends the same frame when a
4
+ // subscribe frame throws.
5
+
6
+ import { logger } from '@ultimat3/core';
7
+ import type { SyncSocket } from './socket';
8
+ import { PROTOCOL_VERSION, toWireError } from './sync-protocol';
9
+
10
+ export function refuseSubscription(socket: SyncSocket, sid: string, error: unknown): void {
11
+ const sent = socket.send({
12
+ type: 'ack',
13
+ v: PROTOCOL_VERSION,
14
+ ref: sid,
15
+ lsn: null,
16
+ error: toWireError(error),
17
+ });
18
+ // Read, never assumed: the subscription is already gone, so a dropped refusal leaves a client
19
+ // waiting on a window nothing serves — the log is the only trace it would leave.
20
+ if (!sent) logger.warn('sync.refusal_dropped', { socketId: socket.id, sid });
21
+ }
@@ -125,14 +125,14 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
125
125
 
126
126
  /**
127
127
  * A filtered write names rows this seam never saw, so there is no event to shape. Every window
128
- * on the node is marked stale instead and re-read on the next change — `invalidate()` is the
129
- * node's own answer to "the change stream skipped something", used here for the one write that
130
- * genuinely does. Silence would be the alternative, and a subscriber told nothing happened
131
- * diverges with nobody ever asking again.
128
+ * over THAT entity is marked stale instead and re-read, its subscribers re-snapshotted, on the
129
+ * next change — `invalidate()` is the node's own answer to "the change stream skipped
130
+ * something", used here for the one write that genuinely does. Silence would be the
131
+ * alternative, and a subscriber told nothing happened diverges with nobody ever asking again.
132
132
  */
133
- onBulk(_change: RowBulkChange): void {
133
+ onBulk(change: RowBulkChange): void {
134
134
  if (stopped) return;
135
- registry.invalidate();
135
+ registry.invalidate(change.entity);
136
136
  },
137
137
  };
138
138
 
@@ -0,0 +1,74 @@
1
+ // One subscriber resuming onto its window from a cursor: replay the retained patches when the
2
+ // cursor is inside them, serve one bounded snapshot when it is not. Split from `live-query.ts` at
3
+ // that file's size ceiling — the registry owns the entry table and who is attached to what; this
4
+ // owns what a returning subscriber is SENT. It attaches nothing: the caller seats the cursor.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import {
8
+ advance,
9
+ type LiveCursor,
10
+ type ReconnectBudget,
11
+ type ResumeSource,
12
+ resumeFrom,
13
+ } from './cursor';
14
+ import type { SnapshotResult } from './live-contract';
15
+ import { snapshotFrame } from './live-fanout';
16
+ import { fillWindow, type QueryEntry } from './query-window';
17
+ import type { Subscriber, SubscriberGate } from './subscriber-gate';
18
+ import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
19
+
20
+ export interface ResumeOntoDeps {
21
+ readonly source: ResumeSource;
22
+ readonly budget?: ReconnectBudget | undefined;
23
+ readonly clock: Clock;
24
+ readonly gate: SubscriberGate;
25
+ /** The window, read once for the entry and filtered for THIS subscriber. */
26
+ read(): Promise<SnapshotResult>;
27
+ }
28
+
29
+ /**
30
+ * The frame a resuming subscriber needs and the cursor it is seated under. The caller has already
31
+ * established that `cursor` names THIS window: a cursor is client data, and one minted for another
32
+ * window is a cold start, never a replay of a ring this window does not own.
33
+ */
34
+ export async function resumeOnto(
35
+ deps: ResumeOntoDeps,
36
+ entry: QueryEntry,
37
+ who: Subscriber,
38
+ cursor: LiveCursor,
39
+ now: number,
40
+ ): Promise<{ readonly cursor: LiveCursor; readonly frame: Frame }> {
41
+ const resumed = await resumeFrom(cursor, {
42
+ source: deps.source,
43
+ ...(deps.budget ? { budget: deps.budget } : {}),
44
+ clock: deps.clock,
45
+ snapshot: () => deps.read(),
46
+ });
47
+ if (resumed.kind !== 'delta') {
48
+ return {
49
+ cursor: resumed.cursor,
50
+ frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor),
51
+ };
52
+ }
53
+ // The gate decides about whole rows out of the shared window, and an entry nothing has read
54
+ // yet has none — every patch would meet an empty window and be withheld. Filling is
55
+ // conditional on purpose: a restart storm resumes onto entries that already hold a live
56
+ // window, and re-reading per resuming subscriber is the cost a delta resume exists to skip.
57
+ if (entry.lsn === '') await fillWindow(entry);
58
+ // The live entry on purpose: a resume runs outside the lane, so the window under it may
59
+ // have moved on — always forwards, and a row whose grant was revoked in the meantime is
60
+ // one this pass must refuse rather than replay from the state it had at the cursor's lsn.
61
+ const patches = await deps.gate.filterPatches(entry, who, resumed.patches, new Set(cursor.ids));
62
+ // Advanced over the FILTERED list, never over `resumed.cursor` — which `resumeFrom` built
63
+ // by advancing across the retained window, and that window is PRE-POLICY. Seated as it
64
+ // came back, this subscriber's `cursor.ids` gained the id of every row inserted for every
65
+ // OTHER actor while it was away; `subscriber-gate` then reads `held.has(patch.id)` off it,
66
+ // takes the "the subscriber holds this row" branch, and delivers a `delete` frame carrying
67
+ // another tenant's row id and the instant it went. The leak that branch closes, re-opened
68
+ // one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
69
+ const seated = advance(cursor, patches, resumed.cursor.lsn, now);
70
+ return {
71
+ cursor: seated,
72
+ frame: { type: 'patch', v: PROTOCOL_VERSION, sid: who.sid, patches, lsn: seated.lsn },
73
+ };
74
+ }
@@ -0,0 +1,25 @@
1
+ // Which tenant a live window belongs to, and the id that keeps two tenants' windows apart.
2
+ //
3
+ // A window is the SHARED, pre-policy read of one `(query, input)`. Shared across every subscriber
4
+ // of one org it is the fan-out the pipeline is built on; shared across two orgs it is one org's
5
+ // rows in a window the other is served from, with a row policy as the only thing in between. So
6
+ // the tenant is part of the window's identity — the same place `@ultimat3/query`'s read cache puts
7
+ // its authority — and the read under it runs in a context that carries that tenant
8
+ // (`live-definition.ts`), which is where `@ultimat3/entity`'s guard takes it from on every surface.
9
+
10
+ import type { Actor } from '@ultimat3/core';
11
+
12
+ /** The org a subscriber's windows are read for. `null`: anonymous, or an actor in no org. */
13
+ export function liveTenantOf(actor: Actor | null): string | null {
14
+ const orgId = actor?.orgId;
15
+ return typeof orgId === 'string' && orgId !== '' ? orgId : null;
16
+ }
17
+
18
+ /**
19
+ * One window's id: the query id, qualified by the tenant it is read for. JSON for the reason
20
+ * `readAuthority` gives — an org id is app data and may carry any separator, and a value that can
21
+ * spell a boundary can spell someone else's. An org-less window keeps the bare query id.
22
+ */
23
+ export function windowId(queryId: string, tenant: string | null): string {
24
+ return tenant === null ? queryId : JSON.stringify([queryId, tenant]);
25
+ }
@@ -0,0 +1,9 @@
1
+ // The production `NatsConnect`, as a door: the adapter and the `nats` library behind it — 65
2
+ // vendor modules — are imported when a client is OPENED, never when this package is. Every role
3
+ // of every app imports `@ultimat3/realtime/server`; only one configured for the NATS transport
4
+ // ever opens a client.
5
+
6
+ import type { NatsClient, NatsClientOptions } from './nats-client';
7
+
8
+ export const openNatsClient = async (options: NatsClientOptions): Promise<NatsClient> =>
9
+ (await import('./nats-lib-client')).openNatsClient(options);
@@ -17,7 +17,7 @@ import type { NatsClient, NatsConnect } from './nats-client';
17
17
  import { parseNatsUrl } from './nats-client';
18
18
  import { ensureKvBucket } from './nats-jetstream';
19
19
  import { NatsKvSet } from './nats-kv';
20
- import { openNatsClient } from './nats-lib-client';
20
+ import { openNatsClient } from './nats-open';
21
21
  import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
22
22
 
23
23
  const encoder = new TextEncoder();
@@ -6,8 +6,12 @@
6
6
  // pair folded into money by name. So a nullable money column diverged from the repository shape,
7
7
  // two plain columns that happened to be called `x_minor`/`x_currency` were folded into a `Money`,
8
8
  // and a `.column()` rename arrived under its physical name. One decoder, the entity's, now.
9
+ //
10
+ // A SEALED column is the one place a live row is not the repository's: the log carries the stored
11
+ // string, and nothing here opens it. It is dropped, so a change — on the bus, in the ring, in a
12
+ // patch frame — names no sealed column at all.
9
13
 
10
- import { decodeRow, entityForTable } from '@ultimat3/entity';
14
+ import { decodeRow, entityForTable, sealedFields } from '@ultimat3/entity';
11
15
  import { ReplicationProtocolError } from './errors';
12
16
  import type { PhysicalRow } from './pg-values';
13
17
  import type { PgRelation } from './pgoutput';
@@ -47,5 +51,10 @@ export function entityRow(
47
51
  });
48
52
  }
49
53
  const source = image === 'before' ? replicatedOnly(relation, physical) : physical;
50
- return decodeRow(entity, source) as PhysicalRow;
54
+ const row = decodeRow(entity, source) as PhysicalRow;
55
+ // Not opened and not forwarded. A client never receives a sealed column on any other path, so
56
+ // a patch carrying one would be the only frame that does — and a `lookup` column's stored string
57
+ // is the same for equal plaintexts, which tells every subscriber which rows share a value.
58
+ for (const field of sealedFields(entity)) delete row[field.property];
59
+ return row;
51
60
  }
@@ -30,6 +30,11 @@ export interface QueryEntry {
30
30
  readonly qid: string;
31
31
  readonly definition: LiveQueryDefinition;
32
32
  readonly input: JsonValue;
33
+ /**
34
+ * The org this window is read for and patched for — its subscribers' own, `null` for subscribers
35
+ * that carry none. Part of `qid`, so two orgs on one `(query, input)` are two entries.
36
+ */
37
+ readonly tenant: string | null;
33
38
  /** Told to the client on every snapshot: the identity scope its rows belong under. */
34
39
  readonly rowEntity: string | null;
35
40
  /** The record key a row travels under; `null` = its `id` (a plain table). */
@@ -80,12 +85,14 @@ export function createEntry(
80
85
  definition: LiveQueryDefinition,
81
86
  input: JsonValue,
82
87
  matcher: IncrementalMatcher,
83
- options?: EntryOptions,
88
+ options?: EntryOptions & { readonly tenant?: string | null },
84
89
  ): QueryEntry {
90
+ const tenant = options?.tenant ?? null;
85
91
  return {
86
92
  qid,
87
93
  definition,
88
94
  input,
95
+ tenant,
89
96
  // Resolved with the matcher, from the same build: `prepare` has already run, so a definition
90
97
  // that compiles its shape per input can answer.
91
98
  rowEntity: definition.rowEntity?.(input) ?? null,
@@ -96,7 +103,9 @@ export function createEntry(
96
103
  // static declaration and can only be a superset of it. Preferring the matcher is what lets a
97
104
  // definition built from a real query carry no static list at all.
98
105
  entities: matcher.entities.length > 0 ? matcher.entities : definition.entities,
99
- orgId: orgIdOf(input),
106
+ // The window's own tenant first: a change in another org is dropped before any predicate
107
+ // runs, whether or not the query's `where` happens to name one.
108
+ orgId: tenant ?? orgIdOf(input),
100
109
  ...(definition.columns ? { columns: definition.columns } : {}),
101
110
  },
102
111
  matcher,
@@ -253,7 +262,7 @@ function startRead(entry: QueryEntry): PendingRead {
253
262
  */
254
263
  async function readSnapshot(entry: QueryEntry): Promise<SnapshotResult> {
255
264
  try {
256
- return await entry.definition.snapshot({ input: entry.input });
265
+ return await entry.definition.snapshot({ input: entry.input, tenant: entry.tenant });
257
266
  } catch (error) {
258
267
  entry.stale = true;
259
268
  throw error;
package/src/server.ts CHANGED
@@ -111,7 +111,7 @@ export {
111
111
  kvWrite,
112
112
  } from './nats-jetstream';
113
113
  export { decodeToken, encodeToken, NatsKvSet, type NatsKvSetOptions } from './nats-kv';
114
- export { openNatsClient } from './nats-lib-client';
114
+ export { openNatsClient } from './nats-open';
115
115
  export { NatsTransport, type NatsTransportOptions } from './nats-transport';
116
116
  export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
117
117
  // ---- the postgres replication path ------------------------------------------------------------