@ultimat3/realtime 23.0.0 → 25.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- 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 +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- 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 +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- 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 +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- 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-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- 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 +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/live-definition.ts
CHANGED
|
@@ -9,8 +9,14 @@
|
|
|
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 {
|
|
13
|
-
|
|
12
|
+
import {
|
|
13
|
+
anonymousActor,
|
|
14
|
+
finiteOption,
|
|
15
|
+
runWithContext,
|
|
16
|
+
serviceActor,
|
|
17
|
+
withChildContext,
|
|
18
|
+
} from '@ultimat3/core';
|
|
19
|
+
import { type AnyQuery, queryHash, queryName, spendQueryLimit } from '@ultimat3/query';
|
|
14
20
|
import { LiveRowUnidentifiedError } from './errors';
|
|
15
21
|
import { isRow, type JsonValue, type Row } from './json';
|
|
16
22
|
import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
|
|
@@ -27,8 +33,10 @@ export interface LiveDefinitionOptions {
|
|
|
27
33
|
*/
|
|
28
34
|
readonly ctx: Ctx;
|
|
29
35
|
/**
|
|
30
|
-
* Where the shared window sits in the change stream, asked at snapshot time
|
|
31
|
-
*
|
|
36
|
+
* Where the shared window sits in the change stream, asked at snapshot time — the node's newest
|
|
37
|
+
* received change (`registry.lastLsn`). Omitted, or answering `''` before the first change, the
|
|
38
|
+
* snapshot claims no position and the retained ring is floored by its first patch instead: a
|
|
39
|
+
* cursor minted before that patch re-snapshots, one that received it resumes as a delta.
|
|
32
40
|
*/
|
|
33
41
|
readonly lsn?: () => string;
|
|
34
42
|
/** Pins the reconnect epoch in tests; the server derives it from the build. */
|
|
@@ -165,6 +173,9 @@ export function liveQueryDefinition(
|
|
|
165
173
|
// The position is taken BEFORE the rows: the rows are then at least that new, so the claim
|
|
166
174
|
// is true. Taken after, a commit landing mid-read was claimed and missing — and every change
|
|
167
175
|
// up to it is dropped downstream as already folded.
|
|
176
|
+
// `''` when the node holds no position — no `lsn` option, or no change received yet. Never
|
|
177
|
+
// a made-up one: a position the node never held becomes a ring floor every foreign cursor
|
|
178
|
+
// is "inside", and the client that resumed there kept rows this node never patched.
|
|
168
179
|
const lsn = options.lsn?.() ?? '';
|
|
169
180
|
return { rows: await readFor(options.ctx, tenant, () => window.read()), lsn };
|
|
170
181
|
},
|
|
@@ -177,6 +188,10 @@ export function liveQueryDefinition(
|
|
|
177
188
|
// memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
|
|
178
189
|
// delivery, and there is no key here an actor could share with another actor.
|
|
179
190
|
authorize: authorizeWithPolicy(target.policy, { query: name, ctx: options.ctx }),
|
|
191
|
+
// The read's own `rateLimit:` bucket, per subscriber — the one HTTP and MCP spend. A socket
|
|
192
|
+
// with no actor is the anonymous reader, keyed by its address as an unauthenticated HTTP read is.
|
|
193
|
+
spend: ({ actor, clientAddress, nowMs }) =>
|
|
194
|
+
spendQueryLimit(target, { actor: actor ?? anonymousActor(), clientAddress, nowMs }),
|
|
180
195
|
visible: visibleWithPolicy(target.policy, { query: name, ctx: options.ctx }),
|
|
181
196
|
};
|
|
182
197
|
}
|
|
@@ -191,15 +206,25 @@ function evictOldest(windows: Map<string, SharedWindow>, max: number): void {
|
|
|
191
206
|
}
|
|
192
207
|
|
|
193
208
|
/**
|
|
194
|
-
* Rows crossing the wire are addressed by `id` — patches, cursors and the local store all
|
|
195
|
-
* it. A
|
|
196
|
-
*
|
|
209
|
+
* Rows crossing the wire are addressed by a TEXT `id` — patches, cursors and the local store all
|
|
210
|
+
* key on it. A safe-integer id is stringified, the one normalisation the change feed applies to the
|
|
211
|
+
* same row (`pg-replication.ts`'s `toRow`), so a snapshot row and its later patch name one identity.
|
|
212
|
+
* Anything else is refused here rather than delivered as a row nobody can patch.
|
|
197
213
|
*/
|
|
198
214
|
function rowsOf(query: string, rows: readonly object[]): readonly Row[] {
|
|
199
215
|
const out: Row[] = [];
|
|
200
216
|
for (const row of rows) {
|
|
201
|
-
|
|
202
|
-
|
|
217
|
+
const id: unknown = (row as { id?: unknown }).id;
|
|
218
|
+
const candidate =
|
|
219
|
+
typeof id === 'number' && Number.isSafeInteger(id) ? { ...row, id: String(id) } : row;
|
|
220
|
+
if (!isRow(candidate)) {
|
|
221
|
+
throw new LiveRowUnidentifiedError({
|
|
222
|
+
query,
|
|
223
|
+
keys: Object.keys(row),
|
|
224
|
+
...(id === undefined || id === null ? {} : { idType: typeof id }),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
out.push(candidate);
|
|
203
228
|
}
|
|
204
229
|
return out;
|
|
205
230
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// One change, fanned out over every live window on the node. Split from `live-query.ts` at its
|
|
2
|
+
// size ceiling: the registry owns who holds which window, this owns the ordering of one delivery.
|
|
3
|
+
|
|
4
|
+
import type { ChangeEvent } from './changefeed';
|
|
5
|
+
import { type FanoutDeps, fanoutChange } from './live-fanout';
|
|
6
|
+
import type { QueryEntry } from './query-window';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
|
|
10
|
+
* number of frames sent — the metric the reconnect benchmark watches.
|
|
11
|
+
*
|
|
12
|
+
* Each entry's turn is taken in that entry's lane. Nothing upstream orders this: `sync` fires
|
|
13
|
+
* `void registry.deliver(change)` straight off the bus subscription, so two changes arriving back
|
|
14
|
+
* to back would otherwise interleave inside one query id — lsn 2 delivered before lsn 1, the
|
|
15
|
+
* subscriber's cursor rewound to 1, and every gate deciding against whichever window won.
|
|
16
|
+
*
|
|
17
|
+
* Every lane is *entered* before any of them is awaited, and nothing inside a fanout takes a
|
|
18
|
+
* second lane, so holding all of them at once cannot be a cycle. That is what makes the ordering
|
|
19
|
+
* claim true: two deliveries queue onto each query id in call order, serialized per query id and
|
|
20
|
+
* never per node — awaiting one entry before entering the next made one slow policy pass the
|
|
21
|
+
* whole node's pace, and let a lane that threw skip every entry behind it with nobody desynced.
|
|
22
|
+
*/
|
|
23
|
+
export async function deliverChange(
|
|
24
|
+
entries: Iterable<QueryEntry>,
|
|
25
|
+
fanout: FanoutDeps,
|
|
26
|
+
change: ChangeEvent,
|
|
27
|
+
onStale: (count: number) => void,
|
|
28
|
+
): Promise<number> {
|
|
29
|
+
const lanes = [...entries].map(async (entry) => {
|
|
30
|
+
try {
|
|
31
|
+
const result = await entry.lock.run(() => fanoutChange(fanout, entry, change));
|
|
32
|
+
onStale(result.stale);
|
|
33
|
+
return result.sent;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// The window advanced under a fanout that did not finish, so every subscriber of this one
|
|
36
|
+
// query id now holds a cursor below the change and no later flush would correct them:
|
|
37
|
+
// desynced here, re-snapshotted on the next one. Silent divergence is the whole reason
|
|
38
|
+
// `markDesynced` exists, and skipping this is how a failure became one.
|
|
39
|
+
for (const subscription of entry.subscribers.values()) {
|
|
40
|
+
subscription.socket.markDesynced(subscription.sid);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// `allSettled`, so one lane's rejection neither cancels the others nor goes unhandled. The
|
|
46
|
+
// first failure still reaches the caller — `sync` logs it — but it costs one query id.
|
|
47
|
+
let sent = 0;
|
|
48
|
+
let failure: { readonly error: unknown } | null = null;
|
|
49
|
+
for (const lane of await Promise.allSettled(lanes)) {
|
|
50
|
+
if (lane.status === 'fulfilled') sent += lane.value;
|
|
51
|
+
else failure ??= { error: lane.reason };
|
|
52
|
+
}
|
|
53
|
+
if (failure !== null) throw failure.error;
|
|
54
|
+
return sent;
|
|
55
|
+
}
|
package/src/live-fanout.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
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';
|
|
10
10
|
import type { LiveSubscription } from './live-contract';
|
|
11
|
-
import { applyToWindow, bridgeChange } from './matcher-bridge';
|
|
11
|
+
import { applyToWindow, bridgeChange, type Projection, projectionOf } from './matcher-bridge';
|
|
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,9 +48,17 @@ 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);
|
|
60
|
+
// What was retained before this read has a hole exactly where the staleness came from.
|
|
61
|
+
deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
|
|
53
62
|
// Every subscriber was fed from the rows this read just replaced, so each is owed a snapshot —
|
|
54
63
|
// not only the ones `invalidate()` marked: a read that failed or timed out stales the window
|
|
55
64
|
// and marks nobody.
|
|
@@ -60,7 +69,7 @@ export async function fanoutChange(
|
|
|
60
69
|
// there is nothing to patch. Returning before the loop below was the defect: the guard after
|
|
61
70
|
// this refused the change as stale and every desynced subscriber went without the change AND
|
|
62
71
|
// 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 };
|
|
72
|
+
if (change.lsn <= entry.lsn) return { sent: await resnapshotAll(deps, entry, named), stale: 0 };
|
|
64
73
|
}
|
|
65
74
|
// The consume-side twin of the replicator's own duplicate guard, which had none. `entry.lsn =
|
|
66
75
|
// change.lsn` was unconditional, so a change the window already holds — a redelivery, or one
|
|
@@ -68,23 +77,33 @@ export async function fanoutChange(
|
|
|
68
77
|
// to it and asked them to fold state they had already folded over newer rows.
|
|
69
78
|
if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
|
|
70
79
|
const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
|
|
71
|
-
// 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.
|
|
72
82
|
if (!bridged) return { sent: reread ? await resnapshotAll(deps, entry) : 0, stale: 0 };
|
|
73
83
|
// Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
|
|
74
84
|
const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
|
|
85
|
+
// Asked of the window as it stood BEFORE this change: the partial row must not teach it a shape.
|
|
86
|
+
const partial = lacksOmitted(change, result.patches, projectionOf(entry.rows));
|
|
75
87
|
entry.lsn = change.lsn;
|
|
76
88
|
entry.rows = applyToWindow(entry.rows, result.patches);
|
|
77
|
-
// The window lost its tail
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
// The window lost its tail — or adopted a row the change could not carry whole — so what it
|
|
90
|
+
// holds is a guess: the next delivery re-reads it rather than patching a guess, and every
|
|
91
|
+
// subscriber below is re-snapshotted out of what that returns.
|
|
92
|
+
const guessed = result.refill || partial;
|
|
93
|
+
if (guessed) entry.stale = true;
|
|
94
|
+
// The retained window holds the pre-policy patch; resume re-filters it per subscriber. A partial
|
|
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));
|
|
99
|
+
// And the ring says so now, not at the re-read: a resume in between got a delta lacking it.
|
|
100
|
+
else deps.source.floorAt?.(entry.qid, change.lsn, { exclusive: true });
|
|
82
101
|
|
|
83
102
|
let sent = 0;
|
|
84
103
|
// Indexed once for every subscriber below, never searched per subscriber per patch.
|
|
85
104
|
const index = windowIndex(entry.rows);
|
|
86
105
|
for (const subscription of entry.subscribers.values()) {
|
|
87
|
-
if (
|
|
106
|
+
if (guessed) {
|
|
88
107
|
// The window lost its tail: guessing is how a sync engine silently diverges. Checked BEFORE
|
|
89
108
|
// the mark, because a repair reads `entry.rows` — which this fanout has just declared a
|
|
90
109
|
// guess — and then CLEARS the mark. A subscriber already diverged would be recorded as
|
|
@@ -100,7 +119,8 @@ export async function fanoutChange(
|
|
|
100
119
|
// healthy socket. A marked subscriber is re-snapshotted out of the shared window instead, at
|
|
101
120
|
// the cost of one frame and no DB read, and only then is the mark cleared.
|
|
102
121
|
if (subscription.socket.desynced.has(subscription.sid)) {
|
|
103
|
-
|
|
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;
|
|
104
124
|
continue;
|
|
105
125
|
}
|
|
106
126
|
const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
|
|
@@ -122,13 +142,7 @@ export async function fanoutChange(
|
|
|
122
142
|
continue;
|
|
123
143
|
}
|
|
124
144
|
if (allowed.length === 0) continue;
|
|
125
|
-
const frame
|
|
126
|
-
type: 'patch',
|
|
127
|
-
v: PROTOCOL_VERSION,
|
|
128
|
-
sid: subscription.sid,
|
|
129
|
-
patches: allowed,
|
|
130
|
-
lsn: change.lsn,
|
|
131
|
-
};
|
|
145
|
+
const frame = patchFrame(subscription.sid, allowed, change.lsn, writes);
|
|
132
146
|
if (subscription.socket.send(frame)) {
|
|
133
147
|
subscription.cursor = advance(subscription.cursor, allowed, change.lsn, change.at);
|
|
134
148
|
sent += 1;
|
|
@@ -139,6 +153,32 @@ export async function fanoutChange(
|
|
|
139
153
|
return { sent, stale: 0 };
|
|
140
154
|
}
|
|
141
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Whether a row ENTERS the window without a column the change did not carry
|
|
158
|
+
* (`ChangeEvent.omitted`: an unchanged out-of-line value Postgres logged no bytes for). An update
|
|
159
|
+
* patch is safe — it merges onto a window row that already holds the column — but an add adopts
|
|
160
|
+
* the row as the whole row, into the window, every later snapshot and the retained ring. An
|
|
161
|
+
* unread projection (an empty window) cannot rule a column out, so every omitted one counts.
|
|
162
|
+
*/
|
|
163
|
+
function lacksOmitted(
|
|
164
|
+
change: ChangeEvent,
|
|
165
|
+
patches: readonly RowPatch[],
|
|
166
|
+
projection: Projection,
|
|
167
|
+
): boolean {
|
|
168
|
+
const omitted = change.omitted;
|
|
169
|
+
if (omitted === undefined || omitted.length === 0) return false;
|
|
170
|
+
return patches.some(
|
|
171
|
+
(patch) =>
|
|
172
|
+
patch.op === 'insert' &&
|
|
173
|
+
patch.row !== null &&
|
|
174
|
+
omitted.some(
|
|
175
|
+
(column) =>
|
|
176
|
+
(projection === undefined || projection.has(column)) &&
|
|
177
|
+
!Object.hasOwn(patch.row ?? {}, column),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
142
182
|
/**
|
|
143
183
|
* Every row of a relation this window reads is gone. There is nothing to patch from — a truncate
|
|
144
184
|
* names no row — so the window is re-read now, in the lane, and every subscriber is re-snapshotted
|
|
@@ -150,9 +190,10 @@ async function truncated(
|
|
|
150
190
|
entry: QueryEntry,
|
|
151
191
|
change: ChangeEvent,
|
|
152
192
|
): Promise<FanoutResult> {
|
|
153
|
-
if (!entry.shape.entities.includes(change.
|
|
193
|
+
if (!entry.shape.entities.includes(change.table)) return { sent: 0, stale: 0 };
|
|
154
194
|
await refillWindowInLane(entry);
|
|
155
195
|
if (change.lsn > entry.lsn) entry.lsn = change.lsn;
|
|
196
|
+
deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
|
|
156
197
|
for (const subscription of entry.subscribers.values()) {
|
|
157
198
|
subscription.socket.markDesynced(subscription.sid);
|
|
158
199
|
}
|
|
@@ -160,11 +201,15 @@ async function truncated(
|
|
|
160
201
|
}
|
|
161
202
|
|
|
162
203
|
/** Each marked subscriber, re-snapshotted out of the window just read. Frames that left. */
|
|
163
|
-
async function resnapshotAll(
|
|
204
|
+
async function resnapshotAll(
|
|
205
|
+
deps: FanoutDeps,
|
|
206
|
+
entry: QueryEntry,
|
|
207
|
+
named: readonly NamedWrite[] = [],
|
|
208
|
+
): Promise<number> {
|
|
164
209
|
let sent = 0;
|
|
165
210
|
for (const subscription of entry.subscribers.values()) {
|
|
166
211
|
if (!subscription.socket.desynced.has(subscription.sid)) continue;
|
|
167
|
-
if (await resnapshot(deps, entry, subscription)) sent += 1;
|
|
212
|
+
if (await resnapshot(deps, entry, subscription, named)) sent += 1;
|
|
168
213
|
}
|
|
169
214
|
return sent;
|
|
170
215
|
}
|
|
@@ -195,6 +240,7 @@ async function resnapshot(
|
|
|
195
240
|
deps: FanoutDeps,
|
|
196
241
|
entry: QueryEntry,
|
|
197
242
|
subscription: LiveSubscription,
|
|
243
|
+
named: readonly NamedWrite[],
|
|
198
244
|
): Promise<boolean> {
|
|
199
245
|
const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
|
|
200
246
|
let rows: readonly Row[];
|
|
@@ -206,31 +252,58 @@ async function resnapshot(
|
|
|
206
252
|
return false;
|
|
207
253
|
}
|
|
208
254
|
const cursor = makeCursor(entry.qid, entry.lsn, rows, deps.clock.now().getTime());
|
|
209
|
-
|
|
255
|
+
const frame = snapshotFrame(entry, subscription.sid, rows, cursor, named);
|
|
256
|
+
if (!subscription.socket.send(frame)) return false;
|
|
210
257
|
subscription.cursor = cursor;
|
|
211
258
|
subscription.socket.clearDesynced(subscription.sid);
|
|
212
259
|
return true;
|
|
213
260
|
}
|
|
214
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
|
+
|
|
215
268
|
/**
|
|
216
|
-
* The one place a snapshot frame is built, so the identity scope
|
|
217
|
-
* told to one caller only. `keys` rides only when some key differs from its row's `id`
|
|
218
|
-
* 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.
|
|
219
274
|
*/
|
|
220
275
|
export function snapshotFrame(
|
|
221
276
|
entry: QueryEntry,
|
|
222
277
|
sid: string,
|
|
223
278
|
rows: readonly Row[],
|
|
224
279
|
cursor: LiveCursor,
|
|
280
|
+
named: readonly NamedWrite[] = [],
|
|
225
281
|
): Frame {
|
|
226
|
-
const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
|
|
227
|
-
const scoped = entry.rowEntity === null ? base : { ...base, entity: entry.rowEntity };
|
|
228
282
|
const keyOf = entry.rowKey;
|
|
229
|
-
|
|
230
|
-
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 };
|
|
231
293
|
return keys.every((key, index) => key === rows[index]?.id) ? scoped : { ...scoped, keys };
|
|
232
294
|
}
|
|
233
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
|
+
|
|
234
307
|
/**
|
|
235
308
|
* A patch's record key, from the change's WHOLE row — an update patch carries only the changed
|
|
236
309
|
* columns, and a key needs every primary-key column. Only stamped where it differs from `id`.
|
|
@@ -246,3 +319,23 @@ function keyPatches(
|
|
|
246
319
|
const key = keyOf(whole);
|
|
247
320
|
return patches.map((patch) => (key === patch.id ? patch : { ...patch, key }));
|
|
248
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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// What a window read that just returned owes the retained ring, and the position the subscriber it
|
|
2
|
+
// serves is told. The rule itself is `change-buffer.ts`'s (`Ring`); this is its one caller for a
|
|
3
|
+
// read, split from `live-query.ts` at that file's size ceiling.
|
|
4
|
+
|
|
5
|
+
import type { ResumeSource } from './cursor';
|
|
6
|
+
import type { FilledWindow, QueryEntry } from './query-window';
|
|
7
|
+
|
|
8
|
+
export interface NodePosition {
|
|
9
|
+
/** The newest change this node has received; `''` before the first. */
|
|
10
|
+
readonly lastLsn: string;
|
|
11
|
+
/** This node's own mark for a read with no position behind it. Sorts below every real lsn. */
|
|
12
|
+
readonly origin: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Synchronous on purpose: the caller awaits a policy pass next, and the position has to be the
|
|
17
|
+
* one these ROWS were read at — read after that await, a change folded in meanwhile would be
|
|
18
|
+
* claimed by a snapshot that does not hold it.
|
|
19
|
+
*/
|
|
20
|
+
export function floorAfterRead(
|
|
21
|
+
source: ResumeSource,
|
|
22
|
+
entry: QueryEntry,
|
|
23
|
+
window: FilledWindow,
|
|
24
|
+
node: NodePosition,
|
|
25
|
+
): string {
|
|
26
|
+
// Served out of a window that was already filled: its patch history is still true.
|
|
27
|
+
if (window.landed === null) return window.lsn;
|
|
28
|
+
// A read with no position behind it is marked with this node's own origin, so the cursor it
|
|
29
|
+
// mints is one no other node could have issued.
|
|
30
|
+
const lsn = window.lsn === '' ? node.origin : window.lsn;
|
|
31
|
+
if (entry.lsn === '') entry.lsn = lsn;
|
|
32
|
+
if (window.landed === 'forced') source.floorAt?.(entry.qid, lsn, { exclusive: true });
|
|
33
|
+
else source.floorAt?.(entry.qid, lsn, { sole: node.lastLsn === '' || window.lsn === '' });
|
|
34
|
+
return lsn;
|
|
35
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What a `LiveQueryRegistry` is built from, and the one ceiling it defaults. Split from
|
|
2
|
+
// `live-query.ts` at that file's size ceiling: the registry owns behaviour, this owns its knobs.
|
|
3
|
+
|
|
4
|
+
import type { Actor, Clock } from '@ultimat3/core';
|
|
5
|
+
import type { ReconnectBudget, ResumeSource } from './cursor';
|
|
6
|
+
import type { SubscriberGateOptions } from './subscriber-gate';
|
|
7
|
+
import type { Scheduler } from './thundering-herd';
|
|
8
|
+
|
|
9
|
+
export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
|
|
10
|
+
readonly source: ResumeSource;
|
|
11
|
+
readonly budget?: ReconnectBudget;
|
|
12
|
+
readonly clock?: Clock;
|
|
13
|
+
readonly maxPerSocket?: number;
|
|
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;
|
|
21
|
+
readonly tenantOf?: (actor: Actor | null) => string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Distinct `(query, input)` pairs this node will hold at once. A `qid` derives from
|
|
24
|
+
* client-chosen input, so without a ceiling one socket mints entries — a matcher, a row window,
|
|
25
|
+
* a `WindowLock` and a fanout target each — until the process dies.
|
|
26
|
+
*/
|
|
27
|
+
readonly maxEntries?: number;
|
|
28
|
+
/**
|
|
29
|
+
* How long one entry's SHARED snapshot read may hold its slot. Defaults to
|
|
30
|
+
* `DEFAULT_READ_DEADLINE_MS`. Without it a `definition.snapshot` that never settles pinned the
|
|
31
|
+
* slot for the life of the process and every later cold subscriber joined a promise nothing
|
|
32
|
+
* would resolve — one wedged read taking every future subscriber of that query id with it.
|
|
33
|
+
*/
|
|
34
|
+
readonly readDeadlineMs?: number;
|
|
35
|
+
/** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
|
|
36
|
+
readonly schedule?: Scheduler;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Live `(query, input)` pairs one node holds. Reached, the next NEW pair is refused with
|
|
41
|
+
* `X_SUBSCRIPTION_LIMIT`; subscribing to a pair that already exists keeps working, because the
|
|
42
|
+
* cost this bounds is the entry, not the subscriber.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_MAX_ENTRIES = 10_000;
|