@ultimat3/realtime 24.0.0 → 25.1.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/live-fanout.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// registry owns the lanes and the entry table while this owns what happens inside one of them —
|
|
4
4
|
// and because one file runs one job. The lane itself is never taken here: the caller is holding it.
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { type Clock, isWriteDigest } from '@ultimat3/core';
|
|
7
7
|
import type { ChangeEvent } from './changefeed';
|
|
8
8
|
import { advance, type LiveCursor, makeCursor, type ResumeSource } from './cursor';
|
|
9
9
|
import type { Row, RowPatch } from './json';
|
|
@@ -12,6 +12,7 @@ import { applyToWindow, bridgeChange, type Projection, projectionOf } from './ma
|
|
|
12
12
|
import { type QueryEntry, refillWindowInLane } from './query-window';
|
|
13
13
|
import { type Subscriber, type SubscriberGate, windowIndex } from './subscriber-gate';
|
|
14
14
|
import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
15
|
+
import { FRAME_LIMITS } from './wire-version';
|
|
15
16
|
|
|
16
17
|
export interface FanoutDeps {
|
|
17
18
|
readonly gate: SubscriberGate;
|
|
@@ -47,6 +48,12 @@ export async function fanoutChange(
|
|
|
47
48
|
return { sent: 0, stale: 0 };
|
|
48
49
|
}
|
|
49
50
|
if (change.op === 'truncate') return await truncated(deps, entry, change);
|
|
51
|
+
// Screened, not trusted: a frame carrying anything but a digest is refused WHOLE by the decoder,
|
|
52
|
+
// so a malformed label would cost every subscriber this change rather than one page its echo.
|
|
53
|
+
const write = isWriteDigest(change.write) ? change.write : null;
|
|
54
|
+
const writes = write === null ? [] : [write];
|
|
55
|
+
// What a re-snapshot out of rows that hold this change may name: the write, by the row it made.
|
|
56
|
+
const named = namedBy(entry, change, write);
|
|
50
57
|
const reread = entry.stale;
|
|
51
58
|
if (reread) {
|
|
52
59
|
await refillWindowInLane(entry);
|
|
@@ -62,7 +69,7 @@ export async function fanoutChange(
|
|
|
62
69
|
// there is nothing to patch. Returning before the loop below was the defect: the guard after
|
|
63
70
|
// this refused the change as stale and every desynced subscriber went without the change AND
|
|
64
71
|
// without its snapshot — a run console frozen on its first event after one `updateWhere`.
|
|
65
|
-
if (change.lsn <= entry.lsn) return { sent: await resnapshotAll(deps, entry), stale: 0 };
|
|
72
|
+
if (change.lsn <= entry.lsn) return { sent: await resnapshotAll(deps, entry, named), stale: 0 };
|
|
66
73
|
}
|
|
67
74
|
// The consume-side twin of the replicator's own duplicate guard, which had none. `entry.lsn =
|
|
68
75
|
// change.lsn` was unconditional, so a change the window already holds — a redelivery, or one
|
|
@@ -70,7 +77,8 @@ export async function fanoutChange(
|
|
|
70
77
|
// to it and asked them to fold state they had already folded over newer rows.
|
|
71
78
|
if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
|
|
72
79
|
const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
|
|
73
|
-
// Matching nothing ends the fanout, but never before a re-read's marks are answered.
|
|
80
|
+
// Matching nothing ends the fanout, but never before a re-read's marks are answered. Named
|
|
81
|
+
// nothing: that read began before this change, so it is not truth that holds its write.
|
|
74
82
|
if (!bridged) return { sent: reread ? await resnapshotAll(deps, entry) : 0, stale: 0 };
|
|
75
83
|
// Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
|
|
76
84
|
const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
|
|
@@ -84,8 +92,10 @@ export async function fanoutChange(
|
|
|
84
92
|
const guessed = result.refill || partial;
|
|
85
93
|
if (guessed) entry.stale = true;
|
|
86
94
|
// The retained window holds the pre-policy patch; resume re-filters it per subscriber. A partial
|
|
87
|
-
// row is never retained: replayed, it is the same missing column on a resuming client.
|
|
88
|
-
|
|
95
|
+
// row is never retained: replayed, it is the same missing column on a resuming client. Each
|
|
96
|
+
// retained patch keeps the write that made it, so a delta resume can name it too.
|
|
97
|
+
if (!partial)
|
|
98
|
+
for (const patch of result.patches) deps.source.append(entry.qid, retain(patch, write));
|
|
89
99
|
// And the ring says so now, not at the re-read: a resume in between got a delta lacking it.
|
|
90
100
|
else deps.source.floorAt?.(entry.qid, change.lsn, { exclusive: true });
|
|
91
101
|
|
|
@@ -109,7 +119,8 @@ export async function fanoutChange(
|
|
|
109
119
|
// healthy socket. A marked subscriber is re-snapshotted out of the shared window instead, at
|
|
110
120
|
// the cost of one frame and no DB read, and only then is the mark cleared.
|
|
111
121
|
if (subscription.socket.desynced.has(subscription.sid)) {
|
|
112
|
-
|
|
122
|
+
// Out of the window this change was just folded into, so it holds the change's write.
|
|
123
|
+
if (await resnapshot(deps, entry, subscription, named)) sent += 1;
|
|
113
124
|
continue;
|
|
114
125
|
}
|
|
115
126
|
const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
|
|
@@ -131,13 +142,7 @@ export async function fanoutChange(
|
|
|
131
142
|
continue;
|
|
132
143
|
}
|
|
133
144
|
if (allowed.length === 0) continue;
|
|
134
|
-
const frame
|
|
135
|
-
type: 'patch',
|
|
136
|
-
v: PROTOCOL_VERSION,
|
|
137
|
-
sid: subscription.sid,
|
|
138
|
-
patches: allowed,
|
|
139
|
-
lsn: change.lsn,
|
|
140
|
-
};
|
|
145
|
+
const frame = patchFrame(subscription.sid, allowed, change.lsn, writes);
|
|
141
146
|
if (subscription.socket.send(frame)) {
|
|
142
147
|
subscription.cursor = advance(subscription.cursor, allowed, change.lsn, change.at);
|
|
143
148
|
sent += 1;
|
|
@@ -185,7 +190,7 @@ async function truncated(
|
|
|
185
190
|
entry: QueryEntry,
|
|
186
191
|
change: ChangeEvent,
|
|
187
192
|
): Promise<FanoutResult> {
|
|
188
|
-
if (!entry.shape.entities.includes(change.
|
|
193
|
+
if (!entry.shape.entities.includes(change.table)) return { sent: 0, stale: 0 };
|
|
189
194
|
await refillWindowInLane(entry);
|
|
190
195
|
if (change.lsn > entry.lsn) entry.lsn = change.lsn;
|
|
191
196
|
deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
|
|
@@ -196,11 +201,15 @@ async function truncated(
|
|
|
196
201
|
}
|
|
197
202
|
|
|
198
203
|
/** Each marked subscriber, re-snapshotted out of the window just read. Frames that left. */
|
|
199
|
-
async function resnapshotAll(
|
|
204
|
+
async function resnapshotAll(
|
|
205
|
+
deps: FanoutDeps,
|
|
206
|
+
entry: QueryEntry,
|
|
207
|
+
named: readonly NamedWrite[] = [],
|
|
208
|
+
): Promise<number> {
|
|
200
209
|
let sent = 0;
|
|
201
210
|
for (const subscription of entry.subscribers.values()) {
|
|
202
211
|
if (!subscription.socket.desynced.has(subscription.sid)) continue;
|
|
203
|
-
if (await resnapshot(deps, entry, subscription)) sent += 1;
|
|
212
|
+
if (await resnapshot(deps, entry, subscription, named)) sent += 1;
|
|
204
213
|
}
|
|
205
214
|
return sent;
|
|
206
215
|
}
|
|
@@ -231,6 +240,7 @@ async function resnapshot(
|
|
|
231
240
|
deps: FanoutDeps,
|
|
232
241
|
entry: QueryEntry,
|
|
233
242
|
subscription: LiveSubscription,
|
|
243
|
+
named: readonly NamedWrite[],
|
|
234
244
|
): Promise<boolean> {
|
|
235
245
|
const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
|
|
236
246
|
let rows: readonly Row[];
|
|
@@ -242,31 +252,58 @@ async function resnapshot(
|
|
|
242
252
|
return false;
|
|
243
253
|
}
|
|
244
254
|
const cursor = makeCursor(entry.qid, entry.lsn, rows, deps.clock.now().getTime());
|
|
245
|
-
|
|
255
|
+
const frame = snapshotFrame(entry, subscription.sid, rows, cursor, named);
|
|
256
|
+
if (!subscription.socket.send(frame)) return false;
|
|
246
257
|
subscription.cursor = cursor;
|
|
247
258
|
subscription.socket.clearDesynced(subscription.sid);
|
|
248
259
|
return true;
|
|
249
260
|
}
|
|
250
261
|
|
|
262
|
+
/** A write this truth may hold, and the RECORD key of the row it made — what it is shown by. */
|
|
263
|
+
export interface NamedWrite {
|
|
264
|
+
readonly write: string;
|
|
265
|
+
readonly key: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
251
268
|
/**
|
|
252
|
-
* The one place a snapshot frame is built, so the identity scope
|
|
253
|
-
* told to one caller only. `keys` rides only when some key differs from its row's `id`
|
|
254
|
-
* keyed by `id` sends the frame it always sent.
|
|
269
|
+
* The one place a snapshot frame is built, so the identity scope, the record keys and the writes
|
|
270
|
+
* cannot be told to one caller only. `keys` rides only when some key differs from its row's `id`
|
|
271
|
+
* — an entity keyed by `id` sends the frame it always sent. A write in `named` is sent only when
|
|
272
|
+
* the row it made is among `rows`, which are already filtered for THIS subscriber: a write behind
|
|
273
|
+
* a row it may not see is another actor's, never named to it.
|
|
255
274
|
*/
|
|
256
275
|
export function snapshotFrame(
|
|
257
276
|
entry: QueryEntry,
|
|
258
277
|
sid: string,
|
|
259
278
|
rows: readonly Row[],
|
|
260
279
|
cursor: LiveCursor,
|
|
280
|
+
named: readonly NamedWrite[] = [],
|
|
261
281
|
): Frame {
|
|
262
|
-
const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
|
|
263
|
-
const scoped = entry.rowEntity === null ? base : { ...base, entity: entry.rowEntity };
|
|
264
282
|
const keyOf = entry.rowKey;
|
|
265
|
-
|
|
266
|
-
const
|
|
283
|
+
const keys = rows.map((row) => (keyOf === null ? row.id : keyOf(row)));
|
|
284
|
+
const shown = new Set(keys);
|
|
285
|
+
const writes = new Set<string>();
|
|
286
|
+
for (const { write, key } of named) if (shown.has(key)) writes.add(write);
|
|
287
|
+
const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
|
|
288
|
+
// Capped where the decoder caps it: a frame over the ceiling is refused whole, and a write left
|
|
289
|
+
// unnamed only settles on its own HTTP answer.
|
|
290
|
+
const listed = [...writes].slice(0, FRAME_LIMITS.patches);
|
|
291
|
+
const withWrites = listed.length === 0 ? base : { ...base, writes: listed };
|
|
292
|
+
const scoped = entry.rowEntity === null ? withWrites : { ...withWrites, entity: entry.rowEntity };
|
|
267
293
|
return keys.every((key, index) => key === rows[index]?.id) ? scoped : { ...scoped, keys };
|
|
268
294
|
}
|
|
269
295
|
|
|
296
|
+
/** The change's write, by the record key of the row it made; nothing for an unkeyed change. */
|
|
297
|
+
function namedBy(
|
|
298
|
+
entry: QueryEntry,
|
|
299
|
+
change: ChangeEvent,
|
|
300
|
+
write: string | null,
|
|
301
|
+
): readonly NamedWrite[] {
|
|
302
|
+
const whole = change.after ?? change.before;
|
|
303
|
+
if (write === null || whole === null) return [];
|
|
304
|
+
return [{ write, key: entry.rowKey === null ? whole.id : entry.rowKey(whole) }];
|
|
305
|
+
}
|
|
306
|
+
|
|
270
307
|
/**
|
|
271
308
|
* A patch's record key, from the change's WHOLE row — an update patch carries only the changed
|
|
272
309
|
* columns, and a key needs every primary-key column. Only stamped where it differs from `id`.
|
|
@@ -282,3 +319,23 @@ function keyPatches(
|
|
|
282
319
|
const key = keyOf(whole);
|
|
283
320
|
return patches.map((patch) => (key === patch.id ? patch : { ...patch, key }));
|
|
284
321
|
}
|
|
322
|
+
|
|
323
|
+
/** The patch the ring retains: the pre-policy patch plus the write that made its change. */
|
|
324
|
+
function retain(patch: RowPatch, write: string | null): RowPatch {
|
|
325
|
+
return write === null ? patch : { ...patch, write };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The one place a patch frame is built, so a live fanout and a delta resume cannot spell it two
|
|
330
|
+
* ways. `writes` rides only when some keyed write made a patch here: an unkeyed change sends the
|
|
331
|
+
* frame it always sent.
|
|
332
|
+
*/
|
|
333
|
+
export function patchFrame(
|
|
334
|
+
sid: string,
|
|
335
|
+
patches: readonly RowPatch[],
|
|
336
|
+
lsn: string,
|
|
337
|
+
writes: readonly string[],
|
|
338
|
+
): Frame {
|
|
339
|
+
const base = { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn } as const;
|
|
340
|
+
return writes.length === 0 ? base : { ...base, writes };
|
|
341
|
+
}
|
|
@@ -12,6 +12,12 @@ export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
|
|
|
12
12
|
readonly clock?: Clock;
|
|
13
13
|
readonly maxPerSocket?: number;
|
|
14
14
|
readonly maxPerTenant?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Live subscriptions one actor — or one anonymous client address — may hold on this node across
|
|
17
|
+
* all its sockets. Defaults to `DEFAULT_MAX_PER_ACTOR` (1,000); the boot passes the app's
|
|
18
|
+
* `realtime.maxSubscriptionsPerActor`.
|
|
19
|
+
*/
|
|
20
|
+
readonly maxPerActor?: number;
|
|
15
21
|
readonly tenantOf?: (actor: Actor | null) => string | null;
|
|
16
22
|
/**
|
|
17
23
|
* Distinct `(query, input)` pairs this node will hold at once. A `qid` derives from
|
package/src/live-query.ts
CHANGED
|
@@ -6,20 +6,22 @@
|
|
|
6
6
|
// policy is never sent to that actor — it arrives as a `delete` if they hold it, and is dropped
|
|
7
7
|
// otherwise.
|
|
8
8
|
|
|
9
|
-
import { type Clock, finiteOption, systemClock,
|
|
9
|
+
import { type Clock, finiteOption, 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 {
|
|
13
|
+
import { LiveQueryUnknownError, SubscriptionLimitError } from './errors';
|
|
14
14
|
import type { JsonValue } from './json';
|
|
15
15
|
import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
|
|
16
|
-
import {
|
|
16
|
+
import { deliverChange } from './live-deliver';
|
|
17
|
+
import { type FanoutDeps, snapshotFrame } from './live-fanout';
|
|
17
18
|
import { floorAfterRead } from './live-floor';
|
|
18
19
|
import { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
|
|
19
|
-
import {
|
|
20
|
+
import { reauthorizeSocket } from './live-reauth';
|
|
20
21
|
import { resumeOnto } from './live-resume';
|
|
22
|
+
import { type Charge, type SubscribeArgs, spendOnce } from './live-spend';
|
|
21
23
|
import { liveTenantOf, windowId } from './live-tenant';
|
|
22
|
-
import {
|
|
24
|
+
import { fillWindow, type QueryEntry, queryEntry } from './query-window';
|
|
23
25
|
import type { SyncSocket } from './socket';
|
|
24
26
|
import { type Subscriber, SubscriberGate } from './subscriber-gate';
|
|
25
27
|
import { SubscriptionBook, subscriptionKey } from './subscription-book';
|
|
@@ -43,7 +45,7 @@ export class LiveQueryRegistry {
|
|
|
43
45
|
* This node's mark for a window read before it held any position: unique to the process, and
|
|
44
46
|
* `!` sorts below every hex digit, so every real change is above it.
|
|
45
47
|
*/
|
|
46
|
-
readonly #origin = `!${
|
|
48
|
+
readonly #origin = `!${uuidV7()}`;
|
|
47
49
|
#staleChanges = 0;
|
|
48
50
|
|
|
49
51
|
constructor(options: LiveQueryRegistryOptions) {
|
|
@@ -85,9 +87,9 @@ export class LiveQueryRegistry {
|
|
|
85
87
|
* ever ask for a re-snapshot. The repair lands on the next change to each query — which is the
|
|
86
88
|
* event that proves the query is moving at all.
|
|
87
89
|
*
|
|
88
|
-
* `entity` narrows it to the windows that read
|
|
89
|
-
* (`updateWhere`/`deleteWhere`) names its
|
|
90
|
-
* for it re-read every live query in the process on each bulk write
|
|
90
|
+
* `entity` — the RELATION, as on `ChangeEvent.table` — narrows it to the windows that read it:
|
|
91
|
+
* a filtered write (`updateWhere`/`deleteWhere`) names its table but no row, and staling every
|
|
92
|
+
* window on the node for it re-read every live query in the process on each bulk write.
|
|
91
93
|
*/
|
|
92
94
|
invalidate(entity?: string): number {
|
|
93
95
|
let marked = 0;
|
|
@@ -124,19 +126,21 @@ export class LiveQueryRegistry {
|
|
|
124
126
|
* Subscribe or resume. Returns the frame this subscriber needs: a `snapshot` on a cold start or a
|
|
125
127
|
* blown reconnect budget, a `patch` when the cursor is inside the retained window.
|
|
126
128
|
*/
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
subscribe(args: SubscribeArgs): Promise<{ subscription: LiveSubscription; frame: Frame }> {
|
|
130
|
+
return this.#subscribe(args, { due: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `charge.due` false: a re-seat, whose subscription was paid for when it was first made. */
|
|
134
|
+
async #subscribe(
|
|
135
|
+
args: SubscribeArgs,
|
|
136
|
+
charge: Charge,
|
|
137
|
+
): Promise<{ subscription: LiveSubscription; frame: Frame }> {
|
|
134
138
|
const definition = this.#definitions.get(args.name);
|
|
135
139
|
// A name this node never registered, and not a protocol skew: the frame parsed, the version
|
|
136
140
|
// matched, and one string in it names nothing. Reporting it as `X_PROTOCOL_VERSION` handed the
|
|
137
141
|
// client "x build && redeploy the client" for a typo no rebuild changes.
|
|
138
142
|
if (!definition) throw new LiveQueryUnknownError({ name: args.name });
|
|
139
|
-
const sid = args.sid ??
|
|
143
|
+
const sid = args.sid ?? uuidV7();
|
|
140
144
|
// Everything this subscribe can be refused for, decided in one synchronous step BEFORE the
|
|
141
145
|
// first await — the caps and the sid both. Read at the top and acted on three awaits later,
|
|
142
146
|
// they were bypassed by the ordinary case: one WebSocket write carrying N subscribe frames,
|
|
@@ -148,7 +152,7 @@ export class LiveQueryRegistry {
|
|
|
148
152
|
// re-decides only what is ATTACHED. So a subscription seated for an actor the socket no
|
|
149
153
|
// longer carries is taken back and served again under the one it does.
|
|
150
154
|
const actor = args.socket.actor;
|
|
151
|
-
const served = await this.#subscribeReserved(definition, sid, args);
|
|
155
|
+
const served = await this.#subscribeReserved(definition, sid, args, charge);
|
|
152
156
|
if (args.socket.actor === actor) return served;
|
|
153
157
|
this.unsubscribe(args.socket.id, sid);
|
|
154
158
|
}
|
|
@@ -168,19 +172,22 @@ export class LiveQueryRegistry {
|
|
|
168
172
|
input: JsonValue;
|
|
169
173
|
cursor?: LiveCursor | null;
|
|
170
174
|
},
|
|
175
|
+
charge: Charge,
|
|
171
176
|
): Promise<{ subscription: LiveSubscription; frame: Frame }> {
|
|
172
177
|
await definition.authorize?.({ actor: args.socket.actor, input: args.input });
|
|
173
|
-
// After this subscriber's own decision, never before it: resolving a shape for a caller who
|
|
174
|
-
// may not subscribe is work an unauthorized client gets to schedule.
|
|
175
|
-
await definition.prepare?.(args.input);
|
|
176
|
-
|
|
177
178
|
// The window is the subscriber's TENANT's: two orgs on one `(query, input)` are two entries,
|
|
178
179
|
// two reads and two retained rings, so no shared row ever stands between them and a policy.
|
|
179
180
|
const tenant = liveTenantOf(args.socket.actor);
|
|
180
181
|
const qid = windowId(queryHash(args.name, args.input), tenant);
|
|
182
|
+
// After this subscriber's own decision, never before it: resolving a shape for a caller who
|
|
183
|
+
// may not subscribe is work an unauthorized client gets to schedule.
|
|
184
|
+
await definition.prepare?.(args.input);
|
|
185
|
+
|
|
181
186
|
const entry = this.#entryFor(qid, definition, args.input, tenant);
|
|
182
187
|
try {
|
|
183
|
-
|
|
188
|
+
// Charged where a read happens, never from the cursor's say-so (`live-spend.ts`).
|
|
189
|
+
const pay = () => spendOnce(definition, args.socket, charge, this.#clock);
|
|
190
|
+
return await this.#serve(entry, sid, args, pay);
|
|
184
191
|
} catch (error) {
|
|
185
192
|
// The entry was born above, before anything could fill it. Everything below can throw — the
|
|
186
193
|
// snapshot, the resume, the window's own read deadline — and `unsubscribe` is the only other
|
|
@@ -200,6 +207,7 @@ export class LiveQueryRegistry {
|
|
|
200
207
|
input: JsonValue;
|
|
201
208
|
cursor?: LiveCursor | null;
|
|
202
209
|
},
|
|
210
|
+
pay: () => Promise<void>,
|
|
203
211
|
): Promise<{ subscription: LiveSubscription; frame: Frame }> {
|
|
204
212
|
const qid = entry.qid;
|
|
205
213
|
const now = this.#clock.now().getTime();
|
|
@@ -219,7 +227,11 @@ export class LiveQueryRegistry {
|
|
|
219
227
|
budget: this.#options.budget,
|
|
220
228
|
clock: this.#clock,
|
|
221
229
|
gate: this.#gate,
|
|
222
|
-
read: () =>
|
|
230
|
+
read: async () => {
|
|
231
|
+
await pay();
|
|
232
|
+
return await this.#read(entry, who);
|
|
233
|
+
},
|
|
234
|
+
beforeFill: pay,
|
|
223
235
|
},
|
|
224
236
|
entry,
|
|
225
237
|
who,
|
|
@@ -230,6 +242,7 @@ export class LiveQueryRegistry {
|
|
|
230
242
|
return { subscription, frame: resumed.frame };
|
|
231
243
|
}
|
|
232
244
|
|
|
245
|
+
await pay();
|
|
233
246
|
const fresh = await this.#read(entry, { sid, actor: args.socket.actor });
|
|
234
247
|
const cursor = makeCursor(qid, fresh.lsn, fresh.rows, now);
|
|
235
248
|
const subscription = this.#attachUnlessGone(entry, args.socket, sid, cursor);
|
|
@@ -278,106 +291,34 @@ export class LiveQueryRegistry {
|
|
|
278
291
|
for (const subscription of this.#book.ofSocket(socketId)) {
|
|
279
292
|
this.unsubscribe(socketId, subscription.sid);
|
|
280
293
|
}
|
|
294
|
+
// Held or not: a socket re-authed while holding nothing is remembered by nothing else.
|
|
295
|
+
this.#book.forgetSocket(socketId);
|
|
281
296
|
}
|
|
282
297
|
|
|
283
|
-
/**
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
* under the new actor's row policy. Returns the sids that were dropped — a denial and nothing
|
|
287
|
-
* else, so a caller may tell the client "you may no longer see this" and be right.
|
|
288
|
-
*
|
|
289
|
-
* A survivor whose actor now belongs to ANOTHER tenant cannot stay where it is: its window is the
|
|
290
|
-
* old org's. It is re-seated — dropped, subscribed again under the new tenant, and sent that
|
|
291
|
-
* window's snapshot under the same sid — or, when that cannot complete, refused on the socket
|
|
292
|
-
* under the sid (`refuseSubscription`), never kept on the old org's window.
|
|
293
|
-
*/
|
|
294
|
-
async reauthorize(socket: SyncSocket): Promise<readonly string[]> {
|
|
295
|
-
const dropped: string[] = [];
|
|
296
|
-
// The actor changed, so what tenant this socket's subscriptions count against may have too.
|
|
297
|
-
// Told here rather than derived per lookup: the per-tenant cap is an index now, and an index
|
|
298
|
-
// nobody updates is a count that drifts from the book for the rest of the process.
|
|
299
|
-
this.#book.retenant(socket);
|
|
300
|
-
for (const subscription of this.#book.ofSocket(socket.id)) {
|
|
301
|
-
let failed: { readonly error: unknown } | undefined;
|
|
302
|
-
try {
|
|
303
|
-
await subscription.definition.authorize?.({
|
|
304
|
-
actor: socket.actor,
|
|
305
|
-
input: subscription.input,
|
|
306
|
-
});
|
|
307
|
-
} catch (error) {
|
|
308
|
-
if (isPolicyDenial(error)) {
|
|
309
|
-
this.unsubscribe(socket.id, subscription.sid);
|
|
310
|
-
// Said under the sid, as a refused subscribe is: unsaid, the client kept the rows on
|
|
311
|
-
// screen in state `live` for a subscription this node no longer serves.
|
|
312
|
-
refuseSubscription(socket, subscription.sid, error);
|
|
313
|
-
dropped.push(subscription.sid);
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
// Not a decision — the gate never reached one. Destroying the subscription would report a
|
|
317
|
-
// database timeout as a revoked grant, and a client does not resubscribe to a denial. It
|
|
318
|
-
// survives, desynced: nothing is delivered from the window built under the old actor, and
|
|
319
|
-
// the row gate still decides every row under the new one, from the same policy `authorize`
|
|
320
|
-
// consults. The failure is counted and reported rather than silently absorbed.
|
|
321
|
-
this.#gate.failedAuthorize(
|
|
322
|
-
subscription.qid,
|
|
323
|
-
{ sid: subscription.sid, actor: socket.actor },
|
|
324
|
-
error,
|
|
325
|
-
);
|
|
326
|
-
failed = { error };
|
|
327
|
-
}
|
|
328
|
-
if (await this.#reseat(socket, subscription, failed)) continue;
|
|
329
|
-
socket.markDesynced(subscription.sid);
|
|
330
|
-
}
|
|
331
|
-
return dropped;
|
|
298
|
+
/** Sockets the spanning caps still remember a key for. A leak is this number not falling. */
|
|
299
|
+
get trackedSockets(): number {
|
|
300
|
+
return this.#book.trackedSockets;
|
|
332
301
|
}
|
|
333
302
|
|
|
334
|
-
/**
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
this.unsubscribe(socket.id, sid);
|
|
352
|
-
if (failed !== undefined) {
|
|
353
|
-
refuseSubscription(socket, sid, failed.error);
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
|
-
try {
|
|
357
|
-
const { frame } = await this.subscribe({ socket, name: definition.name, input, sid });
|
|
358
|
-
if (!socket.send(frame)) socket.markDesynced(sid);
|
|
359
|
-
} catch (error) {
|
|
360
|
-
this.#gate.failedAuthorize(wanted, { sid, actor: socket.actor }, error);
|
|
361
|
-
refuseSubscription(socket, sid, error);
|
|
362
|
-
}
|
|
363
|
-
return true;
|
|
303
|
+
/** Live subscriptions one principal (`actor:<id>` / `address:<ip>`) holds on this node. */
|
|
304
|
+
actorCount(principal: string): number {
|
|
305
|
+
return this.#book.actorCount(principal);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Actor changed mid-connection — re-decided per subscription; `live-reauth.ts` owns how. */
|
|
309
|
+
reauthorize(socket: SyncSocket): Promise<readonly string[]> {
|
|
310
|
+
return reauthorizeSocket(
|
|
311
|
+
{
|
|
312
|
+
book: this.#book,
|
|
313
|
+
gate: this.#gate,
|
|
314
|
+
unsubscribe: (socketId, sid) => this.unsubscribe(socketId, sid),
|
|
315
|
+
// A re-seat is the subscription it already was, so it is never charged again.
|
|
316
|
+
resubscribe: (args) => this.#subscribe(args, { due: false }),
|
|
317
|
+
},
|
|
318
|
+
socket,
|
|
319
|
+
);
|
|
364
320
|
}
|
|
365
321
|
|
|
366
|
-
/**
|
|
367
|
-
* Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
|
|
368
|
-
* number of frames sent — the metric the reconnect benchmark watches.
|
|
369
|
-
*
|
|
370
|
-
* Each entry's turn is taken in that entry's lane. Nothing upstream orders this: `sync` fires
|
|
371
|
-
* `void registry.deliver(change)` straight off the bus subscription, so two changes arriving back
|
|
372
|
-
* to back would otherwise interleave inside one query id — lsn 2 delivered before lsn 1, the
|
|
373
|
-
* subscriber's cursor rewound to 1, and every gate deciding against whichever window won.
|
|
374
|
-
*
|
|
375
|
-
* Every lane is *entered* before any of them is awaited, and nothing inside a fanout takes a
|
|
376
|
-
* second lane, so holding all of them at once cannot be a cycle. That is what makes the ordering
|
|
377
|
-
* claim true: two deliveries queue onto each query id in call order, serialized per query id and
|
|
378
|
-
* never per node — awaiting one entry before entering the next made one slow policy pass the
|
|
379
|
-
* whole node's pace, and let a lane that threw skip every entry behind it with nobody desynced.
|
|
380
|
-
*/
|
|
381
322
|
/**
|
|
382
323
|
* The newest change position this registry has been handed. What a node's snapshot may claim
|
|
383
324
|
* (`liveQueryDefinition`'s `lsn`): a read begun after it holds at least that change, and every
|
|
@@ -387,34 +328,12 @@ export class LiveQueryRegistry {
|
|
|
387
328
|
return this.#lastLsn;
|
|
388
329
|
}
|
|
389
330
|
|
|
331
|
+
/** Fan one change out, every query id in its own lane — `live-deliver.ts` owns why. */
|
|
390
332
|
async deliver(change: ChangeEvent): Promise<number> {
|
|
391
333
|
if (change.lsn > this.#lastLsn) this.#lastLsn = change.lsn;
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const result = await entry.lock.run(() => fanoutChange(this.#fanout, entry, change));
|
|
395
|
-
this.#staleChanges += result.stale;
|
|
396
|
-
return result.sent;
|
|
397
|
-
} catch (error) {
|
|
398
|
-
// The window advanced under a fanout that did not finish, so every subscriber of this one
|
|
399
|
-
// query id now holds a cursor below the change and no later flush would correct them:
|
|
400
|
-
// desynced here, re-snapshotted on the next one. Silent divergence is the whole reason
|
|
401
|
-
// `markDesynced` exists, and skipping this is how a failure became one.
|
|
402
|
-
for (const subscription of entry.subscribers.values()) {
|
|
403
|
-
subscription.socket.markDesynced(subscription.sid);
|
|
404
|
-
}
|
|
405
|
-
throw error;
|
|
406
|
-
}
|
|
334
|
+
return await deliverChange(this.#entries.values(), this.#fanout, change, (stale) => {
|
|
335
|
+
this.#staleChanges += stale;
|
|
407
336
|
});
|
|
408
|
-
// `allSettled`, so one lane's rejection neither cancels the others nor goes unhandled. The
|
|
409
|
-
// first failure still reaches the caller — `sync` logs it — but it costs one query id.
|
|
410
|
-
let sent = 0;
|
|
411
|
-
let failure: { readonly error: unknown } | null = null;
|
|
412
|
-
for (const lane of await Promise.allSettled(lanes)) {
|
|
413
|
-
if (lane.status === 'fulfilled') sent += lane.value;
|
|
414
|
-
else failure ??= { error: lane.reason };
|
|
415
|
-
}
|
|
416
|
-
if (failure !== null) throw failure.error;
|
|
417
|
-
return sent;
|
|
418
337
|
}
|
|
419
338
|
|
|
420
339
|
#attachUnlessGone(
|
|
@@ -438,6 +357,9 @@ export class LiveQueryRegistry {
|
|
|
438
357
|
sid: string,
|
|
439
358
|
cursor: LiveCursor,
|
|
440
359
|
): LiveSubscription {
|
|
360
|
+
// Before anything is written: a re-auth during the read may have moved this socket to a
|
|
361
|
+
// principal that never had room for it.
|
|
362
|
+
this.#book.assertAttachable(socket, sid);
|
|
441
363
|
const subscription: LiveSubscription = {
|
|
442
364
|
sid,
|
|
443
365
|
qid: entry.qid,
|
|
@@ -474,7 +396,7 @@ export class LiveQueryRegistry {
|
|
|
474
396
|
knob: 'maxEntries',
|
|
475
397
|
});
|
|
476
398
|
}
|
|
477
|
-
const created =
|
|
399
|
+
const created = queryEntry(qid, definition, input, definition.matcher(input), {
|
|
478
400
|
readDeadlineMs: this.#options.readDeadlineMs,
|
|
479
401
|
schedule: this.#options.schedule,
|
|
480
402
|
tenant,
|