@ultimat3/realtime 1.2.0 → 2.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 +591 -0
- package/README.md +320 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +174 -19
- 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 +96 -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 +151 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +170 -14
- 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 +284 -243
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Who holds which subscription, and the composite identity that makes that answerable. A `sid`
|
|
2
|
+
// is CLIENT data — unique only to the socket that chose it — so every lookup here takes the
|
|
3
|
+
// owner too, and the per-socket and per-tenant caps are answered from this book because it is
|
|
4
|
+
// the only thing that knows what exists. Every question it answers is indexed, never scanned.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import { SubscriptionIdTakenError, SubscriptionLimitError } from './errors';
|
|
8
|
+
import type { LiveSubscription } from './live-contract';
|
|
9
|
+
import type { SyncSocket } from './socket';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A slot taken synchronously at the top of `subscribe` and given back when it has either become a
|
|
13
|
+
* subscription or failed. It exists because every cap here is answered from what the book HOLDS,
|
|
14
|
+
* and a subscribe does not hold anything until three awaits later: one WebSocket write carrying N
|
|
15
|
+
* subscribe frames is dispatched concurrently, so N of them read `size === 0` and every cap is
|
|
16
|
+
* bypassed by batching. Releasing twice is a no-op — the caller's `finally` runs once per path.
|
|
17
|
+
*/
|
|
18
|
+
export interface SubscriptionSlot {
|
|
19
|
+
release(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The identity of one subscription. `\u0000` because a socket id and a sid are both opaque
|
|
24
|
+
* strings and nothing else can appear in one, so no pair of them can collide with another.
|
|
25
|
+
*/
|
|
26
|
+
export function subscriptionKey(socketId: string, sid: string): string {
|
|
27
|
+
return `${socketId}\u0000${sid}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SubscriptionCaps {
|
|
31
|
+
readonly maxPerSocket?: number;
|
|
32
|
+
readonly maxPerTenant?: number;
|
|
33
|
+
readonly tenantOf?: (actor: Actor | null) => string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Sockets may open this many live queries before `X_SUBSCRIPTION_LIMIT`. */
|
|
37
|
+
export const DEFAULT_MAX_PER_SOCKET = 128;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every live subscription on this node, keyed by `(socket, sid)`.
|
|
41
|
+
*
|
|
42
|
+
* Keyed by the sid alone, socket B reusing socket A's sid overwrote A's entry — A's subscription
|
|
43
|
+
* stayed in its query entry's `subscribers` map, unreachable, so `unsubscribeSocket(A)` freed
|
|
44
|
+
* nothing and that entry's matcher and shared window were pinned for the process's life, fanning
|
|
45
|
+
* every change out to a dead socket. A `drop` frame from B likewise ended A's stream with no
|
|
46
|
+
* error either side.
|
|
47
|
+
*
|
|
48
|
+
* **Two secondary indexes, because both of this book's sweeps run once per socket.** `ofSocket`
|
|
49
|
+
* copied the node's whole map and filtered it, so a teardown or a re-auth pass cost
|
|
50
|
+
* `sockets x subscriptions` — 100,000 entries measured at 17.7s of blocking work, with no
|
|
51
|
+
* attacker capability required: a deploy, a network blip or a batch of grants expiring together
|
|
52
|
+
* is the trigger. The per-tenant cap walked the same map on every subscribe FRAME (7.96 ms each
|
|
53
|
+
* at that size), which is one authenticated socket consuming the node. Both are `Map` reads now,
|
|
54
|
+
* maintained in `add`/`delete` — the shape `lru.ts` and `presence.ts` already use.
|
|
55
|
+
*/
|
|
56
|
+
export class SubscriptionBook {
|
|
57
|
+
readonly #bySid = new Map<string, LiveSubscription>();
|
|
58
|
+
/** socket id -> its sids. The drop list on close, the retry list on re-auth. */
|
|
59
|
+
readonly #bySocket = new Map<string, Set<string>>();
|
|
60
|
+
/** tenant -> live subscriptions held by its sockets. The per-tenant cap's whole answer. */
|
|
61
|
+
readonly #perTenant = new Map<string, number>();
|
|
62
|
+
/**
|
|
63
|
+
* The tenant each socket's subscriptions were counted under. Remembered rather than re-derived,
|
|
64
|
+
* because `socket.actor` is replaced by a re-auth: deriving it again at `delete` time would
|
|
65
|
+
* decrement a tenant that was never incremented and leave the old one counting forever.
|
|
66
|
+
*/
|
|
67
|
+
readonly #tenantOfSocket = new Map<string, string>();
|
|
68
|
+
/** sids a socket has claimed but not yet attached. Empty between subscribes, so it never grows. */
|
|
69
|
+
readonly #claimedBySocket = new Map<string, Set<string>>();
|
|
70
|
+
/** The same claims counted per tenant, because that cap spans sockets and a lane cannot see it. */
|
|
71
|
+
readonly #claimedPerTenant = new Map<string, number>();
|
|
72
|
+
readonly #caps: SubscriptionCaps;
|
|
73
|
+
|
|
74
|
+
constructor(caps: SubscriptionCaps = {}) {
|
|
75
|
+
this.#caps = caps;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get(socketId: string, sid: string): LiveSubscription | undefined {
|
|
79
|
+
return this.#bySid.get(subscriptionKey(socketId, sid));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
has(socketId: string, sid: string): boolean {
|
|
83
|
+
return this.#bySid.has(subscriptionKey(socketId, sid));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
add(subscription: LiveSubscription): void {
|
|
87
|
+
const socketId = subscription.socket.id;
|
|
88
|
+
const key = subscriptionKey(socketId, subscription.sid);
|
|
89
|
+
// A re-add is the one thing that could double-count a tenant, so it is refused here rather
|
|
90
|
+
// than relied on not to happen: `subscribe` already answers `X_SUBSCRIPTION_ID_TAKEN`.
|
|
91
|
+
if (this.#bySid.has(key)) return;
|
|
92
|
+
this.#bySid.set(key, subscription);
|
|
93
|
+
const sids = this.#bySocket.get(socketId);
|
|
94
|
+
if (sids) sids.add(subscription.sid);
|
|
95
|
+
else this.#bySocket.set(socketId, new Set([subscription.sid]));
|
|
96
|
+
const tenant = this.#tenantFor(subscription.socket);
|
|
97
|
+
if (tenant === null) return;
|
|
98
|
+
this.#tenantOfSocket.set(socketId, tenant);
|
|
99
|
+
this.#perTenant.set(tenant, (this.#perTenant.get(tenant) ?? 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
delete(socketId: string, sid: string): void {
|
|
103
|
+
if (!this.#bySid.delete(subscriptionKey(socketId, sid))) return;
|
|
104
|
+
const sids = this.#bySocket.get(socketId);
|
|
105
|
+
sids?.delete(sid);
|
|
106
|
+
const empty = sids === undefined || sids.size === 0;
|
|
107
|
+
if (empty) this.#bySocket.delete(socketId);
|
|
108
|
+
const tenant = this.#tenantOfSocket.get(socketId);
|
|
109
|
+
if (tenant === undefined) return;
|
|
110
|
+
this.#bump(tenant, -1);
|
|
111
|
+
if (empty) this.#tenantOfSocket.delete(socketId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A copy, because every caller mutates the book while walking it. */
|
|
115
|
+
all(): readonly LiveSubscription[] {
|
|
116
|
+
return [...this.#bySid.values()];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One socket's subscriptions — the drop list when it closes, the retry list when it reauths. */
|
|
120
|
+
ofSocket(socketId: string): readonly LiveSubscription[] {
|
|
121
|
+
const sids = this.#bySocket.get(socketId);
|
|
122
|
+
if (!sids) return [];
|
|
123
|
+
const out: LiveSubscription[] = [];
|
|
124
|
+
for (const sid of sids) {
|
|
125
|
+
const subscription = this.#bySid.get(subscriptionKey(socketId, sid));
|
|
126
|
+
if (subscription) out.push(subscription);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Live subscriptions counted against one tenant. The metric the cap reads. */
|
|
132
|
+
tenantCount(tenant: string): number {
|
|
133
|
+
return this.#perTenant.get(tenant) ?? 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A re-auth moved this socket to another tenant, so its subscriptions move with it. Without
|
|
138
|
+
* this the count the cap reads drifts from the book for the rest of the process — one tenant
|
|
139
|
+
* refused for subscriptions it does not hold, another admitted past its cap.
|
|
140
|
+
*/
|
|
141
|
+
retenant(socket: SyncSocket): void {
|
|
142
|
+
const held = this.#bySocket.get(socket.id)?.size ?? 0;
|
|
143
|
+
const before = this.#tenantOfSocket.get(socket.id) ?? null;
|
|
144
|
+
const after = this.#caps.tenantOf?.(socket.actor) ?? null;
|
|
145
|
+
if (before === after) return;
|
|
146
|
+
if (before !== null) this.#bump(before, -held);
|
|
147
|
+
if (after === null) this.#tenantOfSocket.delete(socket.id);
|
|
148
|
+
else {
|
|
149
|
+
this.#tenantOfSocket.set(socket.id, after);
|
|
150
|
+
if (held > 0) this.#perTenant.set(after, (this.#perTenant.get(after) ?? 0) + held);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Refuse a subscribe that would exceed a cap. Load shedding, not a crash: both scopes throw
|
|
156
|
+
* `X_SUBSCRIPTION_LIMIT` naming which one refused, so the fix line points at one knob.
|
|
157
|
+
*
|
|
158
|
+
* Claims count, because the thing being bounded is work that starts before it is held: a
|
|
159
|
+
* subscribe that has passed this check and is awaiting its snapshot has already committed this
|
|
160
|
+
* node to an entry, a matcher and a read.
|
|
161
|
+
*/
|
|
162
|
+
assertCapacity(socket: SyncSocket): void {
|
|
163
|
+
const perSocket = this.#caps.maxPerSocket ?? DEFAULT_MAX_PER_SOCKET;
|
|
164
|
+
const claimed = this.#claimedBySocket.get(socket.id)?.size ?? 0;
|
|
165
|
+
if (socket.queries.size + claimed >= perSocket) {
|
|
166
|
+
throw new SubscriptionLimitError({
|
|
167
|
+
scope: 'socket',
|
|
168
|
+
id: socket.id,
|
|
169
|
+
limit: perSocket,
|
|
170
|
+
knob: 'maxPerSocket',
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const perTenant = this.#caps.maxPerTenant;
|
|
174
|
+
const tenant = this.#tenantFor(socket);
|
|
175
|
+
if (perTenant === undefined || tenant === null) return;
|
|
176
|
+
if (this.tenantCount(tenant) + (this.#claimedPerTenant.get(tenant) ?? 0) >= perTenant) {
|
|
177
|
+
throw new SubscriptionLimitError({
|
|
178
|
+
scope: 'tenant',
|
|
179
|
+
id: tenant,
|
|
180
|
+
limit: perTenant,
|
|
181
|
+
knob: 'maxPerTenant',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Take the slot this subscribe is going to fill — the sid and the two caps — before it awaits
|
|
188
|
+
* anything. Every refusal a subscribe can answer with is decided here, in one synchronous step,
|
|
189
|
+
* so N frames arriving in one write are N decisions against a count that already includes the
|
|
190
|
+
* ones still in flight.
|
|
191
|
+
*
|
|
192
|
+
* The sid is claimed here for the same reason: keyed by `(socket, sid)`, two concurrent frames
|
|
193
|
+
* reusing one sid both passed `has()` and the second attach replaced the first, stranding it
|
|
194
|
+
* inside its query entry where nothing can reach it again. The tenant is captured rather than
|
|
195
|
+
* re-derived — a re-auth may `retenant` this socket while the read is in flight, and the release
|
|
196
|
+
* has to give the slot back to the tenant that took it.
|
|
197
|
+
*/
|
|
198
|
+
reserve(socket: SyncSocket, sid: string): SubscriptionSlot {
|
|
199
|
+
const socketId = socket.id;
|
|
200
|
+
if (this.has(socketId, sid) || this.#claimedBySocket.get(socketId)?.has(sid) === true) {
|
|
201
|
+
throw new SubscriptionIdTakenError({ sid, socketId });
|
|
202
|
+
}
|
|
203
|
+
this.assertCapacity(socket);
|
|
204
|
+
const claims = this.#claimedBySocket.get(socketId);
|
|
205
|
+
if (claims) claims.add(sid);
|
|
206
|
+
else this.#claimedBySocket.set(socketId, new Set([sid]));
|
|
207
|
+
const tenant = this.#tenantFor(socket);
|
|
208
|
+
if (tenant !== null) {
|
|
209
|
+
this.#claimedPerTenant.set(tenant, (this.#claimedPerTenant.get(tenant) ?? 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
let released = false;
|
|
212
|
+
return {
|
|
213
|
+
release: (): void => {
|
|
214
|
+
if (released) return;
|
|
215
|
+
released = true;
|
|
216
|
+
const held = this.#claimedBySocket.get(socketId);
|
|
217
|
+
held?.delete(sid);
|
|
218
|
+
if (held !== undefined && held.size === 0) this.#claimedBySocket.delete(socketId);
|
|
219
|
+
if (tenant === null) return;
|
|
220
|
+
const next = (this.#claimedPerTenant.get(tenant) ?? 0) - 1;
|
|
221
|
+
if (next > 0) this.#claimedPerTenant.set(tenant, next);
|
|
222
|
+
else this.#claimedPerTenant.delete(tenant);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The tenant this socket's subscriptions are counted under: the remembered one, or the actor's. */
|
|
228
|
+
#tenantFor(socket: SyncSocket): string | null {
|
|
229
|
+
return this.#tenantOfSocket.get(socket.id) ?? this.#caps.tenantOf?.(socket.actor) ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#bump(tenant: string, by: number): void {
|
|
233
|
+
const next = (this.#perTenant.get(tenant) ?? 0) + by;
|
|
234
|
+
if (next > 0) this.#perTenant.set(tenant, next);
|
|
235
|
+
else this.#perTenant.delete(tenant);
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/sync-auth.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Who a socket is, and for how long. The `sync` node evaluates no credential of its own — an app
|
|
2
|
+
// supplies `authenticate`, exactly as it supplies `onMutate` — so this file owns the shape of that
|
|
3
|
+
// answer, the per-node book that holds it, and the pass that re-decides one whose window has closed.
|
|
4
|
+
|
|
5
|
+
import type { Actor, Clock } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One connection's identity. A grant, not an `Actor`, because a websocket outlives every credential
|
|
9
|
+
* that opened it: a token with a 15-minute TTL on a socket that stays up for hours is a subscription
|
|
10
|
+
* authorized once and served forever, which is the hole `expiresAt` closes.
|
|
11
|
+
*
|
|
12
|
+
* `refresh` is the app's, and the framework retains no credential of its own for it. That is the
|
|
13
|
+
* whole reason the seam is a closure: re-reading the upgrade `Request` would mean holding one per
|
|
14
|
+
* socket for the life of the connection, and an app that closes over a token string holds the two
|
|
15
|
+
* fields it actually needs. Omit it and an expired grant simply closes the socket — the client
|
|
16
|
+
* re-dials with a fresh credential, which is the safe default and costs one reconnect.
|
|
17
|
+
*/
|
|
18
|
+
export interface SyncGrant {
|
|
19
|
+
readonly actor: Actor;
|
|
20
|
+
/** Epoch ms this grant stops being true. Omitted = never re-decided on a clock. */
|
|
21
|
+
readonly expiresAt?: number;
|
|
22
|
+
/** Re-resolve this connection without a reconnect. `null` means the actor is gone. */
|
|
23
|
+
refresh?: () => Promise<SyncGrant | null>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The app's answer to "who is dialling". Called once per upgrade, before `server.upgrade`, so a
|
|
28
|
+
* refused credential never costs a websocket. `null` is a decision (nobody may open this socket);
|
|
29
|
+
* a throw is a failure (nothing was decided) — the node answers those differently, because reading
|
|
30
|
+
* an auth backend timeout as a denial is the same class of bug as reading a dead pool as a row
|
|
31
|
+
* policy refusing a row.
|
|
32
|
+
*/
|
|
33
|
+
export type SyncAuthenticator = (request: Request) => Promise<SyncGrant | null>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The grants of the sockets on this node, keyed by socket id.
|
|
37
|
+
*
|
|
38
|
+
* Off `SyncSocket` on purpose: that object's budget is ~1KB per connection and it is the only
|
|
39
|
+
* per-socket allocation a million-socket node is costed against, so an auth lifetime — a closure,
|
|
40
|
+
* an expiry and an actor — lives beside the socket table instead of inside it. Empty, and costing
|
|
41
|
+
* nothing, on a node with no authenticator.
|
|
42
|
+
*/
|
|
43
|
+
export class GrantBook {
|
|
44
|
+
readonly #grants = new Map<string, SyncGrant>();
|
|
45
|
+
|
|
46
|
+
set(socketId: string, grant: SyncGrant): void {
|
|
47
|
+
this.#grants.set(socketId, grant);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get(socketId: string): SyncGrant | undefined {
|
|
51
|
+
return this.#grants.get(socketId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
delete(socketId: string): void {
|
|
55
|
+
this.#grants.delete(socketId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get size(): number {
|
|
59
|
+
return this.#grants.size;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Grants whose window has closed. One with no `expiresAt` never appears here. */
|
|
63
|
+
expired(now: number): readonly (readonly [string, SyncGrant])[] {
|
|
64
|
+
const out: (readonly [string, SyncGrant])[] = [];
|
|
65
|
+
for (const [socketId, grant] of this.#grants) {
|
|
66
|
+
if (grant.expiresAt !== undefined && grant.expiresAt <= now) out.push([socketId, grant]);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface GrantSweepDeps {
|
|
73
|
+
readonly grants: GrantBook;
|
|
74
|
+
readonly clock: Clock;
|
|
75
|
+
/** The grant was renewed: re-decide every subscription this socket holds, under the new actor. */
|
|
76
|
+
onActor: (socketId: string, actor: Actor) => Promise<void>;
|
|
77
|
+
/** Nobody may hold this socket any longer. The caller closes it. */
|
|
78
|
+
onRevoked: (socketId: string) => void;
|
|
79
|
+
/** `refresh` raised instead of deciding. The grant is kept and retried on the next pass. */
|
|
80
|
+
onRefreshFailed?: (socketId: string, error: unknown) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GrantSweepResult {
|
|
84
|
+
readonly refreshed: number;
|
|
85
|
+
readonly revoked: number;
|
|
86
|
+
readonly failed: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One pass over the expired grants. The clock is injected because a re-auth only provable by
|
|
91
|
+
* sleeping is a re-auth no test proves — the same rule the client's reconnect timer already follows.
|
|
92
|
+
*
|
|
93
|
+
* A `refresh` that raises keeps its grant: a denial and a failure never share an answer here either,
|
|
94
|
+
* and signing every connected user out because the auth backend timed out is a bigger outage than
|
|
95
|
+
* the one it would be responding to. It stays expired, so the next pass retries it — and every
|
|
96
|
+
* failure is reported, because a socket that cannot be re-decided is not a socket anyone should
|
|
97
|
+
* discover from a graph of connection counts.
|
|
98
|
+
*/
|
|
99
|
+
export async function sweepGrants(deps: GrantSweepDeps): Promise<GrantSweepResult> {
|
|
100
|
+
const now = deps.clock.now().getTime();
|
|
101
|
+
let refreshed = 0;
|
|
102
|
+
let revoked = 0;
|
|
103
|
+
let failed = 0;
|
|
104
|
+
for (const [socketId, grant] of deps.grants.expired(now)) {
|
|
105
|
+
let next: SyncGrant | null;
|
|
106
|
+
try {
|
|
107
|
+
next = grant.refresh ? await grant.refresh() : null;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
failed += 1;
|
|
110
|
+
deps.onRefreshFailed?.(socketId, error);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (next === null) {
|
|
114
|
+
deps.grants.delete(socketId);
|
|
115
|
+
deps.onRevoked(socketId);
|
|
116
|
+
revoked += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
deps.grants.set(socketId, next);
|
|
120
|
+
await deps.onActor(socketId, next.actor);
|
|
121
|
+
refreshed += 1;
|
|
122
|
+
}
|
|
123
|
+
return { refreshed, revoked, failed };
|
|
124
|
+
}
|