@ultimat3/realtime 1.2.0 → 3.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 +641 -0
- package/README.md +336 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +202 -20
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +99 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +187 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +225 -34
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +324 -248
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
package/src/socket.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
// One WS connection. Bun's WS profile is what makes a million sockets affordable, so this object
|
|
2
|
-
// is deliberately lean: ~8 fields
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// is deliberately lean: ~8 fields, two small sets and one token bucket (the frame budget, which is
|
|
3
|
+
// five numbers and the only thing standing between one authenticated socket and this node's whole
|
|
4
|
+
// subscribe path). Budget is ~1KB of JS heap per connection on top of Bun's own per-socket cost —
|
|
5
|
+
// anything richer (row caches, per-socket buffers) belongs in the transport or the change buffer,
|
|
6
|
+
// never here. `sync` is stateless: nothing on this object survives a restart, and nothing needs to.
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type Actor,
|
|
10
|
+
type Clock,
|
|
11
|
+
type Counter,
|
|
12
|
+
counter,
|
|
13
|
+
logger,
|
|
14
|
+
recordConnection,
|
|
15
|
+
systemClock,
|
|
16
|
+
uuid,
|
|
17
|
+
} from '@ultimat3/core';
|
|
8
18
|
import { encode, type Frame } from './sync-protocol';
|
|
19
|
+
import { AcceptBudget } from './thundering-herd';
|
|
9
20
|
|
|
10
21
|
export const CLOSE = {
|
|
11
22
|
normal: 1000,
|
|
@@ -17,7 +28,16 @@ export const CLOSE = {
|
|
|
17
28
|
drain: 4002,
|
|
18
29
|
} as const;
|
|
19
30
|
|
|
20
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* The slice of Bun's `ServerWebSocket` this package uses. Structural, so tests need no server.
|
|
33
|
+
*
|
|
34
|
+
* `subscribe`/`unsubscribe` are Bun's native pub/sub and this package does NOT use them: nothing
|
|
35
|
+
* here publishes to a native topic, and nothing will — a native publish cannot be refused per
|
|
36
|
+
* socket, cannot report the frame it dropped and cannot mark a subscriber desynced, which is
|
|
37
|
+
* exactly what `SocketRegistry.deliver` and `SyncSocket.send` exist to do. They are still declared
|
|
38
|
+
* because `WsLike` is the slice of Bun's own object, and an app already implements it; deleting
|
|
39
|
+
* them is a separate, breaking edit to every implementer.
|
|
40
|
+
*/
|
|
21
41
|
export interface WsLike {
|
|
22
42
|
send(data: string): number;
|
|
23
43
|
close(code?: number, reason?: string): void;
|
|
@@ -37,8 +57,47 @@ export interface SyncSocketOptions {
|
|
|
37
57
|
/** Frames are dropped rather than queued past this. See `desynced`. */
|
|
38
58
|
readonly maxBufferedBytes?: number;
|
|
39
59
|
readonly maxDroppedFrames?: number;
|
|
60
|
+
/** Sustained inbound frames this socket may have routed per second. See `frameBudget`. */
|
|
61
|
+
readonly maxFramesPerSecond?: number;
|
|
62
|
+
/** Burst allowance, so a client subscribing its whole cap at connect is never refused. */
|
|
63
|
+
readonly frameBurst?: number;
|
|
40
64
|
}
|
|
41
65
|
|
|
66
|
+
/**
|
|
67
|
+
* What one socket may ask this node to do per second, and how much of it may arrive at once.
|
|
68
|
+
*
|
|
69
|
+
* The burst clears `DEFAULT_MAX_PER_SOCKET` (128) plus a `hello`, because that is exactly what a
|
|
70
|
+
* legitimate client sends on connect; the sustained rate is well under the ~155 frames/s measured
|
|
71
|
+
* to consume a node through the subscribe path's amplifiers.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_MAX_FRAMES_PER_SECOND = 64;
|
|
74
|
+
export const DEFAULT_FRAME_BURST = 256;
|
|
75
|
+
/**
|
|
76
|
+
* Queued-but-unwritten bytes on one server socket before `send` declines and marks the subscriber
|
|
77
|
+
* desynced. `sync-node.ts` hands the same number to Bun as `backpressureLimit` rather than spelling
|
|
78
|
+
* it again: they are one socket's one buffer, and a check the runtime's own limit fires before is a
|
|
79
|
+
* check that never runs. The client half (`client-mutations.ts`) is deliberately its own constant —
|
|
80
|
+
* it is browser code, and importing this file would pull the node's socket registry into the tab.
|
|
81
|
+
*/
|
|
82
|
+
export const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Channel frames this process dropped under backpressure. A DATA-LOSS counter, not a saturation
|
|
86
|
+
* one: the live-query path repairs a dropped patch (the subscriber is marked desynced and the next
|
|
87
|
+
* change re-snapshots it), and a channel has no cursor, no mark and no re-snapshot — so this is the
|
|
88
|
+
* only trace a lost channel message leaves anywhere.
|
|
89
|
+
*
|
|
90
|
+
* Declared here rather than in `@ultimat3/core`'s `runtime-metrics.ts` because that file is the
|
|
91
|
+
* series EVERY Ultimate process emits and the deploy chart scales on; this one exists only where
|
|
92
|
+
* channels do. **No attributes**: a topic is client-chosen (`topic()` admits any
|
|
93
|
+
* `[A-Za-z0-9_-]+` segment), so a per-topic label is an unbounded series count one socket can mint
|
|
94
|
+
* — the topic goes in the log line, where cardinality is somebody else's index.
|
|
95
|
+
*/
|
|
96
|
+
const channelFramesDropped: Counter = counter('channel_frames_dropped_total', {
|
|
97
|
+
unit: '{frame}',
|
|
98
|
+
description: 'Channel frames dropped by socket backpressure — unrecoverable, nothing replays one',
|
|
99
|
+
});
|
|
100
|
+
|
|
42
101
|
export function actorIdOf(actor: Actor | null): string | null {
|
|
43
102
|
return actor === null ? null : actor.id;
|
|
44
103
|
}
|
|
@@ -58,9 +117,22 @@ export class SyncSocket {
|
|
|
58
117
|
* flush re-snapshots instead of silently diverging.
|
|
59
118
|
*/
|
|
60
119
|
readonly desynced = new Set<string>();
|
|
120
|
+
/**
|
|
121
|
+
* Inbound frames this socket may still have routed. The accept budget spends one token per
|
|
122
|
+
* UPGRADE, so nothing bounded what happened after: one authenticated socket reached a DB read,
|
|
123
|
+
* a shared-store presence write and a fleet-wide publish once per frame, unbounded and
|
|
124
|
+
* unawaited. Same token bucket, one per socket — the mechanism already existed, one rung down.
|
|
125
|
+
*/
|
|
126
|
+
readonly frameBudget: AcceptBudget;
|
|
61
127
|
|
|
62
128
|
actor: Actor | null;
|
|
63
|
-
|
|
129
|
+
/**
|
|
130
|
+
* MONOTONIC milliseconds, not an instant — the idle sweep measures a duration, and a duration
|
|
131
|
+
* read off the wall clock is decided by whatever NTP last wrote. A step forward evicts sockets
|
|
132
|
+
* that are talking; a step backward makes `idleFor` negative and spares sockets that are dead.
|
|
133
|
+
* Named for the units so nobody hands it to `new Date()`; `openedAt` is the wall-clock one.
|
|
134
|
+
*/
|
|
135
|
+
lastSeenMonotonicMs: number;
|
|
64
136
|
droppedFrames = 0;
|
|
65
137
|
sentFrames = 0;
|
|
66
138
|
|
|
@@ -77,10 +149,17 @@ export class SyncSocket {
|
|
|
77
149
|
this.clientBuildId = options.clientBuildId;
|
|
78
150
|
this.serverBuildId = options.serverBuildId;
|
|
79
151
|
this.actor = options.actor ?? null;
|
|
80
|
-
this.#maxBufferedBytes = options.maxBufferedBytes ??
|
|
152
|
+
this.#maxBufferedBytes = options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
81
153
|
this.#maxDroppedFrames = options.maxDroppedFrames ?? 32;
|
|
154
|
+
this.frameBudget = new AcceptBudget({
|
|
155
|
+
perSecond: options.maxFramesPerSecond ?? DEFAULT_MAX_FRAMES_PER_SECOND,
|
|
156
|
+
burst: options.frameBurst ?? DEFAULT_FRAME_BURST,
|
|
157
|
+
clock: this.#clock,
|
|
158
|
+
});
|
|
159
|
+
// Two clocks on purpose: `openedAt` is an instant a human reads, `lastSeenMonotonicMs` is the
|
|
160
|
+
// start of a duration only this process compares.
|
|
82
161
|
this.openedAt = this.#clock.now().getTime();
|
|
83
|
-
this.
|
|
162
|
+
this.lastSeenMonotonicMs = this.#clock.monotonic();
|
|
84
163
|
}
|
|
85
164
|
|
|
86
165
|
get actorId(): string | null {
|
|
@@ -120,22 +199,27 @@ export class SyncSocket {
|
|
|
120
199
|
this.desynced.delete(sid);
|
|
121
200
|
}
|
|
122
201
|
|
|
202
|
+
/**
|
|
203
|
+
* This socket's own membership, and nothing else. It used to also call Bun's `ws.subscribe`,
|
|
204
|
+
* which built a second per-topic index nothing ever published to — the fanout is
|
|
205
|
+
* `SocketRegistry.deliver`, one filtered `send` per socket, because that is the only path that
|
|
206
|
+
* can count a dropped frame or close a socket that is drowning in them.
|
|
207
|
+
*/
|
|
123
208
|
subscribeTopic(topic: string): void {
|
|
124
209
|
this.topics.add(topic);
|
|
125
|
-
this.#ws.subscribe(topic);
|
|
126
210
|
}
|
|
127
211
|
|
|
128
212
|
unsubscribeTopic(topic: string): void {
|
|
129
213
|
this.topics.delete(topic);
|
|
130
|
-
this.#ws.unsubscribe(topic);
|
|
131
214
|
}
|
|
132
215
|
|
|
133
216
|
touch(): void {
|
|
134
|
-
this.
|
|
217
|
+
this.lastSeenMonotonicMs = this.#clock.monotonic();
|
|
135
218
|
}
|
|
136
219
|
|
|
137
|
-
|
|
138
|
-
|
|
220
|
+
/** `nowMonotonicMs` comes from the SAME clock — `Clock.monotonic()`, never `now().getTime()`. */
|
|
221
|
+
idleFor(nowMonotonicMs: number): number {
|
|
222
|
+
return nowMonotonicMs - this.lastSeenMonotonicMs;
|
|
139
223
|
}
|
|
140
224
|
|
|
141
225
|
close(code: number = CLOSE.normal, reason = ''): void {
|
|
@@ -145,21 +229,50 @@ export class SyncSocket {
|
|
|
145
229
|
}
|
|
146
230
|
}
|
|
147
231
|
|
|
232
|
+
/**
|
|
233
|
+
* How long a socket may route no frame before `sync-node` evicts it. It is an APPLICATION
|
|
234
|
+
* inactivity budget and not Bun's transport one: Bun's `idleTimeout` is renewed by its own
|
|
235
|
+
* ping/pong, so a client whose TCP stack still answers pings while its frame loop is wedged holds
|
|
236
|
+
* its grant, its subscriptions and its topic membership forever. A beating client sends a `hello`
|
|
237
|
+
* every `DEFAULT_HEARTBEAT_MS` (15s), so this is eight missed beats.
|
|
238
|
+
*/
|
|
239
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* How often to ask. A quarter of the budget, floored at a second: a socket is evicted within 25%
|
|
243
|
+
* of its window of going quiet, and a node holding 50,000 of them pays one pass over the table
|
|
244
|
+
* four times per window rather than once a second. Derived rather than configured — a second knob
|
|
245
|
+
* is a second number that can disagree with the one it is a fraction of.
|
|
246
|
+
*/
|
|
247
|
+
export function idleSweepPeriodMs(idleTimeoutMs: number): number {
|
|
248
|
+
return Math.max(1_000, Math.floor(idleTimeoutMs / 4));
|
|
249
|
+
}
|
|
250
|
+
|
|
148
251
|
export interface SocketRegistryOptions {
|
|
149
252
|
readonly clock?: Clock;
|
|
150
|
-
/** Bun
|
|
253
|
+
/** Bun's own `idleTimeout` is renewed by its ping/pong; this budget counts routed FRAMES. */
|
|
151
254
|
readonly idleTimeoutMs?: number;
|
|
152
255
|
}
|
|
153
256
|
|
|
154
257
|
/** Per-node socket table. Intentionally the only in-memory map on a `sync` node. */
|
|
155
258
|
export class SocketRegistry {
|
|
156
259
|
readonly #sockets = new Map<string, SyncSocket>();
|
|
260
|
+
/**
|
|
261
|
+
* Who is on each channel topic. `deliver` walked every socket on the node asking each whether
|
|
262
|
+
* it held the topic, so one message with one legitimate subscriber cost as many iterations as
|
|
263
|
+
* this node has connections — 50,000 at the scale this framework benchmarks. It lives here
|
|
264
|
+
* rather than on the hub because this is the only object that sees a socket die: a close, a
|
|
265
|
+
* drain and the idle sweep all pass through `remove`, and an index nobody cleans on those paths
|
|
266
|
+
* retains a dead socket per topic forever.
|
|
267
|
+
*/
|
|
268
|
+
readonly #byTopic = new Map<string, Set<SyncSocket>>();
|
|
157
269
|
readonly #clock: Clock;
|
|
158
270
|
readonly #idleTimeoutMs: number;
|
|
271
|
+
#droppedChannelFrames = 0;
|
|
159
272
|
|
|
160
273
|
constructor(options: SocketRegistryOptions = {}) {
|
|
161
274
|
this.#clock = options.clock ?? systemClock;
|
|
162
|
-
this.#idleTimeoutMs = options.idleTimeoutMs ??
|
|
275
|
+
this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
163
276
|
}
|
|
164
277
|
|
|
165
278
|
/**
|
|
@@ -178,8 +291,39 @@ export class SocketRegistry {
|
|
|
178
291
|
}
|
|
179
292
|
|
|
180
293
|
remove(id: string): void {
|
|
294
|
+
const socket = this.#sockets.get(id);
|
|
181
295
|
// `Map.delete` answers "was it actually there", so a double close cannot decrement twice.
|
|
182
|
-
if (this.#sockets.delete(id))
|
|
296
|
+
if (!this.#sockets.delete(id)) return;
|
|
297
|
+
recordConnection(-1);
|
|
298
|
+
if (!socket) return;
|
|
299
|
+
// Leaving this table IS the close, whoever noticed first. Bun's `close` callback reports a
|
|
300
|
+
// connection that has already gone, so nothing called `close()` on this object and
|
|
301
|
+
// `socket.closed` stayed false — leaving a subscribe still awaiting its snapshot read with no
|
|
302
|
+
// way to tell that the socket it is about to attach to was torn down while it read.
|
|
303
|
+
socket.close(CLOSE.goingAway, 'connection closed');
|
|
304
|
+
for (const name of socket.topics) this.#dropFrom(name, socket);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Join a topic: the socket's own set, Bun's native pub/sub and this node's delivery index, in
|
|
309
|
+
* one call. `ChannelHub` is its only caller, and calls nothing else — two call sites for one
|
|
310
|
+
* membership is how an index goes wrong.
|
|
311
|
+
*/
|
|
312
|
+
joinTopic(socket: SyncSocket, topic: string): void {
|
|
313
|
+
socket.subscribeTopic(topic);
|
|
314
|
+
const members = this.#byTopic.get(topic);
|
|
315
|
+
if (members) members.add(socket);
|
|
316
|
+
else this.#byTopic.set(topic, new Set([socket]));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
leaveTopic(socket: SyncSocket, topic: string): void {
|
|
320
|
+
socket.unsubscribeTopic(topic);
|
|
321
|
+
this.#dropFrom(topic, socket);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Sockets this node would deliver `topic` to. */
|
|
325
|
+
subscriberCount(topic: string): number {
|
|
326
|
+
return this.#byTopic.get(topic)?.size ?? 0;
|
|
183
327
|
}
|
|
184
328
|
|
|
185
329
|
get(id: string): SyncSocket | undefined {
|
|
@@ -194,28 +338,75 @@ export class SocketRegistry {
|
|
|
194
338
|
return this.#sockets.size;
|
|
195
339
|
}
|
|
196
340
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const closed: SyncSocket[] = [];
|
|
201
|
-
for (const socket of this.#sockets.values()) {
|
|
202
|
-
if (socket.idleFor(now) > this.#idleTimeoutMs) {
|
|
203
|
-
socket.close(CLOSE.idle, 'idle timeout');
|
|
204
|
-
// Through `remove`, not the map: the sweep is exactly the abnormal close a gauge leaks on.
|
|
205
|
-
this.remove(socket.id);
|
|
206
|
-
closed.push(socket);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return closed;
|
|
341
|
+
/** The budget `idle()` answers against, so a caller can size its own sweep from one number. */
|
|
342
|
+
get idleTimeoutMs(): number {
|
|
343
|
+
return this.#idleTimeoutMs;
|
|
210
344
|
}
|
|
211
345
|
|
|
212
|
-
/**
|
|
213
|
-
*
|
|
346
|
+
/**
|
|
347
|
+
* Everything past the idle budget. A QUERY, and deliberately not an eviction: this table is three
|
|
348
|
+
* of the five things a socket holds, and the other two — its live subscriptions and its presence
|
|
349
|
+
* membership on the SHARED set — are only reachable from `sync-node`'s `teardown`. A sweep that
|
|
350
|
+
* closed and `remove`d here left a member every other node renders until its TTL and a
|
|
351
|
+
* `QueryEntry` whose `subscribers` map never empties. `sync-node` is the one caller and it
|
|
352
|
+
* releases each one the way the close callback does.
|
|
353
|
+
*/
|
|
354
|
+
idle(): SyncSocket[] {
|
|
355
|
+
// Monotonic, because this is the one comparison an operator's clock could otherwise decide:
|
|
356
|
+
// `sync-node` hands this registry and every socket it builds the same `Clock`.
|
|
357
|
+
const now = this.#clock.monotonic();
|
|
358
|
+
return [...this.#sockets.values()].filter(
|
|
359
|
+
(socket) => socket.idleFor(now) > this.#idleTimeoutMs,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Local delivery for a channel topic — every channel message on this node comes through here,
|
|
365
|
+
* so it reads the per-topic index rather than the socket table. A socket that closed without
|
|
366
|
+
* an unsubscribe is dropped as it is met, so the index cannot outlive the connection even on a
|
|
367
|
+
* path that forgot to `remove` it.
|
|
368
|
+
*/
|
|
214
369
|
deliver(topic: string, frame: Frame): number {
|
|
370
|
+
const members = this.#byTopic.get(topic);
|
|
371
|
+
if (!members) return 0;
|
|
215
372
|
let sent = 0;
|
|
216
|
-
|
|
217
|
-
|
|
373
|
+
let dropped = 0;
|
|
374
|
+
for (const socket of members) {
|
|
375
|
+
if (socket.closed) {
|
|
376
|
+
members.delete(socket);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (socket.send(frame)) sent += 1;
|
|
380
|
+
else dropped += 1;
|
|
381
|
+
}
|
|
382
|
+
if (members.size === 0) this.#byTopic.delete(topic);
|
|
383
|
+
if (dropped > 0) {
|
|
384
|
+
this.#droppedChannelFrames += dropped;
|
|
385
|
+
// Two readers, one event, one spelling: the series an operator alerts on and the line that
|
|
386
|
+
// says which topic it was. `deliver` ignored `send`'s answer and so did the hub above it, so
|
|
387
|
+
// until both existed a lost channel message left no trace at all.
|
|
388
|
+
channelFramesDropped.add(dropped);
|
|
389
|
+
logger.warn('channel.frames_dropped', { topic, dropped, total: this.#droppedChannelFrames });
|
|
218
390
|
}
|
|
219
391
|
return sent;
|
|
220
392
|
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Channel frames backpressure refused since boot, node-wide and cumulative — the in-process read
|
|
396
|
+
* of `channel_frames_dropped_total`, for a test or a benchmark that cannot scrape.
|
|
397
|
+
*
|
|
398
|
+
* Node-wide on purpose: a socket past `maxDroppedFrames` is closed and removed, so a per-socket
|
|
399
|
+
* count leaves with the socket exactly when loss is worst. Distinct from `SyncSocket.droppedFrames`,
|
|
400
|
+
* which counts every kind of frame one connection lost, channel and live-query patch alike.
|
|
401
|
+
*/
|
|
402
|
+
get droppedChannelFrames(): number {
|
|
403
|
+
return this.#droppedChannelFrames;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#dropFrom(topic: string, socket: SyncSocket): void {
|
|
407
|
+
const members = this.#byTopic.get(topic);
|
|
408
|
+
if (!members) return;
|
|
409
|
+
members.delete(socket);
|
|
410
|
+
if (members.size === 0) this.#byTopic.delete(topic);
|
|
411
|
+
}
|
|
221
412
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The per-subscriber pass of a definition's row policy over the shared window, and the two numbers
|
|
2
|
+
// it produces: rows denied, and gates that could not decide. It evaluates no policy of its own —
|
|
3
|
+
// `policy-gate.ts` is this package's only authz seam — it calls `LiveQueryDefinition.visible` and
|
|
4
|
+
// classifies what comes back, so a denial and a failure never arrive as the same event.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import { isPolicyDenial } from './errors';
|
|
8
|
+
import type { JsonValue, Row, RowPatch } from './json';
|
|
9
|
+
import type { LiveQueryDefinition } from './live-contract';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Who a decision is being made for. Every policy call in the live pipeline takes one, which is the
|
|
13
|
+
* shape of the rule: there is no path through the gate that reads a query id and no actor.
|
|
14
|
+
*/
|
|
15
|
+
export interface Subscriber {
|
|
16
|
+
readonly sid: string;
|
|
17
|
+
readonly actor: Actor | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One row withheld from one subscriber. Carries no row payload: the ids are the whole point. */
|
|
21
|
+
export interface RowDenied {
|
|
22
|
+
readonly qid: string;
|
|
23
|
+
readonly sid: string;
|
|
24
|
+
readonly actorId: string | null;
|
|
25
|
+
readonly rowId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Where a gate was standing when it failed. `authorize` is subscribe-time, the rest are rows. */
|
|
29
|
+
export type GateStage = 'authorize' | 'snapshot' | 'patch';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One gate that raised something other than a denial. `rowId` is absent for `authorize`, which
|
|
33
|
+
* decides about a subscription rather than a row, and `error` is passed through unwrapped so the
|
|
34
|
+
* node logs the driver's own message instead of a summary of it.
|
|
35
|
+
*/
|
|
36
|
+
export interface GateFailed {
|
|
37
|
+
readonly qid: string;
|
|
38
|
+
readonly sid: string;
|
|
39
|
+
readonly actorId: string | null;
|
|
40
|
+
readonly stage: GateStage;
|
|
41
|
+
readonly rowId?: string;
|
|
42
|
+
readonly error: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the gate needs from a query entry and nothing more: the shared pre-policy window, the input
|
|
47
|
+
* the rules read, and the definition that owns `visible`. `QueryEntry` satisfies it structurally,
|
|
48
|
+
* so the registry passes its entry straight through and this file never learns what else is on it.
|
|
49
|
+
*/
|
|
50
|
+
export interface GateTarget {
|
|
51
|
+
readonly qid: string;
|
|
52
|
+
readonly input: JsonValue;
|
|
53
|
+
readonly definition: LiveQueryDefinition;
|
|
54
|
+
readonly rows: readonly Row[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SubscriberGateOptions {
|
|
58
|
+
/**
|
|
59
|
+
* `live.rows_denied`. A row an actor's policy refuses is dropped, never sent and never turned
|
|
60
|
+
* into an error — telling a client "there is a row you may not see" is itself the leak. Dropped
|
|
61
|
+
* silently it is also invisible, so the drop is a metric instead.
|
|
62
|
+
*/
|
|
63
|
+
readonly onRowDenied?: (event: RowDenied) => void;
|
|
64
|
+
/**
|
|
65
|
+
* `live.gate_failed`. The gate raised something that is not a decision, so this subscriber's
|
|
66
|
+
* result set is unknown rather than empty. Separate from `onRowDenied` on purpose: an alert
|
|
67
|
+
* fires on this one, and a dashboard that summed them would show a permission change.
|
|
68
|
+
*/
|
|
69
|
+
readonly onGateFailed?: (event: GateFailed) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Both counters and the one call that classifies a throw. Owned per registry, never per query. */
|
|
73
|
+
export class SubscriberGate {
|
|
74
|
+
readonly #options: SubscriberGateOptions;
|
|
75
|
+
#rowsDenied = 0;
|
|
76
|
+
#gateFailures = 0;
|
|
77
|
+
|
|
78
|
+
constructor(options: SubscriberGateOptions) {
|
|
79
|
+
this.#options = options;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Rows a subscriber's policy refused since boot. */
|
|
83
|
+
get rowsDenied(): number {
|
|
84
|
+
return this.#rowsDenied;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Gates that raised instead of deciding since boot. */
|
|
88
|
+
get gateFailures(): number {
|
|
89
|
+
return this.#gateFailures;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One snapshot, filtered for one subscriber. A failure raises rather than returning the rows it
|
|
94
|
+
* managed to admit: a short result set is indistinguishable from a correct one, and handing it
|
|
95
|
+
* over is the read silently losing rows.
|
|
96
|
+
*/
|
|
97
|
+
async filterRows(target: GateTarget, who: Subscriber, rows: readonly Row[]): Promise<Row[]> {
|
|
98
|
+
const out: Row[] = [];
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
if (await this.#visible(target, who, row, 'snapshot')) out.push(row);
|
|
101
|
+
else this.#denied(target.qid, who, row.id);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Row-level authz over a patch list. A row that becomes invisible is converted to a `delete`
|
|
108
|
+
* when the subscriber holds it — otherwise a revoked grant would leave a stale row on screen
|
|
109
|
+
* forever.
|
|
110
|
+
*/
|
|
111
|
+
async filterPatches(
|
|
112
|
+
target: GateTarget,
|
|
113
|
+
who: Subscriber,
|
|
114
|
+
patches: readonly RowPatch[],
|
|
115
|
+
held: ReadonlySet<string>,
|
|
116
|
+
): Promise<RowPatch[]> {
|
|
117
|
+
const out: RowPatch[] = [];
|
|
118
|
+
for (const patch of patches) {
|
|
119
|
+
const allowed = await this.patch(target, who, patch, held.has(patch.id));
|
|
120
|
+
if (allowed !== null) out.push(allowed);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One patch, one decision. `holds` is whether this subscriber already has the row on screen. */
|
|
126
|
+
async patch(
|
|
127
|
+
target: GateTarget,
|
|
128
|
+
who: Subscriber,
|
|
129
|
+
patch: RowPatch,
|
|
130
|
+
holds: boolean,
|
|
131
|
+
): Promise<RowPatch | null> {
|
|
132
|
+
if (patch.op === 'delete' || patch.row === null) return patch;
|
|
133
|
+
const full = target.rows.find((row) => row.id === patch.id);
|
|
134
|
+
// No whole row means no decision to take. An update patch carries the changed columns only, so
|
|
135
|
+
// a rule reading `row.ownerId` on one reads `undefined` and answers as if the row had said so —
|
|
136
|
+
// fail-closed for `=== actor.id`, and a leak for every `!row.private`. It is not a gate that
|
|
137
|
+
// failed either: the shared window *is* the result set, so a row it does not hold is a row this
|
|
138
|
+
// subscriber is not entitled to keep, and one that holds it is told so.
|
|
139
|
+
if (full === undefined) return holds ? withdrawn(patch) : null;
|
|
140
|
+
const row: Row = { ...full, ...patch.row, id: patch.id };
|
|
141
|
+
if (await this.#visible(target, who, row, 'patch')) return patch;
|
|
142
|
+
this.#denied(target.qid, who, patch.id);
|
|
143
|
+
return holds ? withdrawn(patch) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `authorize` failed for a subscription that is being re-decided. Counted and reported here so
|
|
148
|
+
* every gate failure in the pipeline goes through one counter, whatever the caller then does
|
|
149
|
+
* with the subscription.
|
|
150
|
+
*/
|
|
151
|
+
failedAuthorize(qid: string, who: Subscriber, error: unknown): void {
|
|
152
|
+
this.#failed(qid, who, 'authorize', undefined, error);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The definition's own predicate. A denial answers `false`; anything else is counted and raised. */
|
|
156
|
+
async #visible(
|
|
157
|
+
target: GateTarget,
|
|
158
|
+
who: Subscriber,
|
|
159
|
+
row: Row,
|
|
160
|
+
stage: GateStage,
|
|
161
|
+
): Promise<boolean> {
|
|
162
|
+
try {
|
|
163
|
+
return await target.definition.visible({ actor: who.actor, row, input: target.input });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (isPolicyDenial(error)) return false;
|
|
166
|
+
this.#failed(target.qid, who, stage, row.id, error);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `live.rows_denied`. Counted here and nowhere else, so every drop is one increment. */
|
|
172
|
+
#denied(qid: string, who: Subscriber, rowId: string): void {
|
|
173
|
+
this.#rowsDenied += 1;
|
|
174
|
+
this.#options.onRowDenied?.({ qid, sid: who.sid, actorId: actorIdOf(who), rowId });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `live.gate_failed`. Same rule: one place counts, so the number and the events agree. */
|
|
178
|
+
#failed(
|
|
179
|
+
qid: string,
|
|
180
|
+
who: Subscriber,
|
|
181
|
+
stage: GateStage,
|
|
182
|
+
rowId: string | undefined,
|
|
183
|
+
error: unknown,
|
|
184
|
+
): void {
|
|
185
|
+
this.#gateFailures += 1;
|
|
186
|
+
this.#options.onGateFailed?.({
|
|
187
|
+
qid,
|
|
188
|
+
sid: who.sid,
|
|
189
|
+
actorId: actorIdOf(who),
|
|
190
|
+
stage,
|
|
191
|
+
...(rowId === undefined ? {} : { rowId }),
|
|
192
|
+
error,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const actorIdOf = (who: Subscriber): string | null => (who.actor === null ? null : who.actor.id);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The one frame a subscriber gets for a row it may no longer keep, whether a rule refused it or the
|
|
201
|
+
* window stopped holding it. Written once so the two paths cannot answer differently: a client left
|
|
202
|
+
* holding the row instead renders a revoked grant until something else reconnects it.
|
|
203
|
+
*/
|
|
204
|
+
const withdrawn = (patch: RowPatch): RowPatch => ({
|
|
205
|
+
op: 'delete',
|
|
206
|
+
id: patch.id,
|
|
207
|
+
row: null,
|
|
208
|
+
lsn: patch.lsn,
|
|
209
|
+
});
|