@ultimat3/realtime 22.14.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 +18 -25
- package/README.md +33 -3
- package/package.json +4 -4
- package/src/client-frames.ts +27 -3
- package/src/live-contract.ts +6 -2
- package/src/live-definition.ts +24 -4
- package/src/live-fanout.ts +27 -4
- package/src/live-query.ts +85 -56
- package/src/live-refusal.ts +21 -0
- package/src/live-replicator.ts +6 -6
- package/src/live-resume.ts +74 -0
- package/src/live-tenant.ts +25 -0
- package/src/nats-open.ts +9 -0
- package/src/nats-transport.ts +1 -1
- package/src/pg-entity-row.ts +11 -2
- package/src/query-window.ts +12 -3
- package/src/server.ts +1 -1
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
|
|
89
|
-
`
|
|
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
|
|
111
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
##
|
|
291
|
+
## The page boot
|
|
291
292
|
|
|
292
|
-
|
|
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
|
-
-
|
|
447
|
-
SHA-256(canonicalJson(input))>`, 64 bits
|
|
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": "
|
|
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": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/query": "
|
|
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
|
}
|
package/src/client-frames.ts
CHANGED
|
@@ -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,
|
|
107
|
+
if (!target.channels.refused(frame.ref, refusal)) target.report(refusal);
|
|
84
108
|
return;
|
|
85
109
|
}
|
|
86
110
|
registration.state = 'failed';
|
|
87
|
-
registration.error =
|
|
111
|
+
registration.error = refusal;
|
|
88
112
|
registration.notify();
|
|
89
113
|
return;
|
|
90
114
|
}
|
package/src/live-contract.ts
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
|
|
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. */
|
package/src/live-definition.ts
CHANGED
|
@@ -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
|
package/src/live-fanout.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/live-replicator.ts
CHANGED
|
@@ -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
|
-
*
|
|
129
|
-
* node's own answer to "the change stream skipped
|
|
130
|
-
* genuinely does. Silence would be the
|
|
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(
|
|
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
|
+
}
|
package/src/nats-open.ts
ADDED
|
@@ -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);
|
package/src/nats-transport.ts
CHANGED
|
@@ -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-
|
|
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();
|
package/src/pg-entity-row.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/query-window.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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 ------------------------------------------------------------
|