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