@ultimat3/realtime 21.0.0 → 22.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +302 -1009
- package/README.md +130 -26
- package/package.json +4 -4
- package/src/changefeed.ts +7 -1
- package/src/channel-authz.ts +23 -4
- package/src/channel-decl.ts +16 -5
- package/src/channel-describe.ts +7 -5
- package/src/channel-logs.ts +19 -1
- package/src/channel-records.ts +8 -0
- package/src/client-channels.ts +75 -5
- package/src/client.ts +14 -2
- package/src/cursor.ts +5 -0
- package/src/errors.ts +43 -1
- package/src/idb-fake.ts +24 -4
- package/src/idb-types.ts +7 -0
- package/src/index.ts +1 -1
- package/src/live-definition.ts +5 -1
- package/src/live-fanout.ts +51 -2
- package/src/live-query.ts +11 -0
- package/src/live-replicator.ts +160 -0
- package/src/local-store-idb.ts +89 -15
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +76 -21
- package/src/page-outbox.ts +80 -10
- package/src/page-socket.ts +39 -8
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-identifier.ts +23 -0
- package/src/pg-preflight.ts +32 -45
- package/src/pg-publication.ts +95 -0
- package/src/pg-replication.ts +21 -7
- package/src/pg-socket.ts +139 -53
- package/src/pg-tls.ts +124 -0
- package/src/pg-wire.ts +65 -16
- package/src/policy-fake.ts +14 -0
- package/src/query-window.ts +35 -21
- package/src/replication-errors.ts +29 -16
- package/src/replicator.ts +13 -3
- package/src/server.ts +8 -3
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +15 -3
- package/src/socket-host.ts +103 -4
- package/src/socket-idle.ts +21 -0
- package/src/socket.ts +41 -38
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-node-contract.ts +6 -0
- package/src/sync-node.ts +3 -7
- package/src/sync-origin.ts +33 -0
- package/src/sync-upgrade.ts +33 -9
- package/src/thundering-herd.ts +21 -11
- package/src/transport-env.ts +55 -14
- package/src/use-mutation.ts +13 -0
- package/src/use-query.ts +10 -5
package/src/subscriber-gate.ts
CHANGED
|
@@ -54,6 +54,55 @@ export interface GateTarget {
|
|
|
54
54
|
readonly rows: readonly Row[];
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The shared window, indexed ONCE per fan-out rather than searched per subscriber per patch: a
|
|
59
|
+
* `rows.find` inside the subscriber loop was O(subscribers × window) — 54.6 ms against 0.7 ms at
|
|
60
|
+
* 1000 subscribers over a 500-row window. `windowIndex` builds it; `filterPatches` builds its own
|
|
61
|
+
* when a caller passes none.
|
|
62
|
+
*/
|
|
63
|
+
export interface WindowIndex {
|
|
64
|
+
readonly byId: ReadonlyMap<string, Row>;
|
|
65
|
+
readonly position: ReadonlyMap<string, number>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function windowIndex(rows: readonly Row[]): WindowIndex {
|
|
69
|
+
const byId = new Map<string, Row>();
|
|
70
|
+
const position = new Map<string, number>();
|
|
71
|
+
rows.forEach((row, at) => {
|
|
72
|
+
byId.set(row.id, row);
|
|
73
|
+
position.set(row.id, at);
|
|
74
|
+
});
|
|
75
|
+
return { byId, position };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a subscriber holds as a patch list is folded: the cursor's set, plus what this list has
|
|
80
|
+
* inserted, minus what it has deleted — never a copy of the set per subscriber.
|
|
81
|
+
*/
|
|
82
|
+
class Holding {
|
|
83
|
+
readonly #base: ReadonlySet<string>;
|
|
84
|
+
readonly #added = new Set<string>();
|
|
85
|
+
readonly #removed = new Set<string>();
|
|
86
|
+
|
|
87
|
+
constructor(base: ReadonlySet<string>) {
|
|
88
|
+
this.#base = base;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
has(id: string): boolean {
|
|
92
|
+
return this.#added.has(id) || (this.#base.has(id) && !this.#removed.has(id));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fold(patch: RowPatch): void {
|
|
96
|
+
if (patch.op === 'delete') {
|
|
97
|
+
this.#added.delete(patch.id);
|
|
98
|
+
this.#removed.add(patch.id);
|
|
99
|
+
} else if (patch.op === 'insert') {
|
|
100
|
+
this.#removed.delete(patch.id);
|
|
101
|
+
this.#added.add(patch.id);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
57
106
|
export interface SubscriberGateOptions {
|
|
58
107
|
/**
|
|
59
108
|
* `live.rows_denied`. A row an actor's policy refuses is dropped, never sent and never turned
|
|
@@ -113,11 +162,16 @@ export class SubscriberGate {
|
|
|
113
162
|
who: Subscriber,
|
|
114
163
|
patches: readonly RowPatch[],
|
|
115
164
|
held: ReadonlySet<string>,
|
|
165
|
+
index: WindowIndex = windowIndex(target.rows),
|
|
116
166
|
): Promise<RowPatch[]> {
|
|
117
167
|
const out: RowPatch[] = [];
|
|
168
|
+
const holding = new Holding(held);
|
|
118
169
|
for (const patch of patches) {
|
|
119
|
-
const allowed = await this
|
|
120
|
-
if (allowed
|
|
170
|
+
const allowed = await this.#decide(target, who, patch, holding.has(patch.id), index);
|
|
171
|
+
if (allowed === null) continue;
|
|
172
|
+
const placed = rebase(allowed, holding, target.rows, index);
|
|
173
|
+
holding.fold(placed);
|
|
174
|
+
out.push(placed);
|
|
121
175
|
}
|
|
122
176
|
return out;
|
|
123
177
|
}
|
|
@@ -128,6 +182,16 @@ export class SubscriberGate {
|
|
|
128
182
|
who: Subscriber,
|
|
129
183
|
patch: RowPatch,
|
|
130
184
|
holds: boolean,
|
|
185
|
+
): Promise<RowPatch | null> {
|
|
186
|
+
return await this.#decide(target, who, patch, holds, windowIndex(target.rows));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async #decide(
|
|
190
|
+
target: GateTarget,
|
|
191
|
+
who: Subscriber,
|
|
192
|
+
patch: RowPatch,
|
|
193
|
+
holds: boolean,
|
|
194
|
+
index: WindowIndex,
|
|
131
195
|
): Promise<RowPatch | null> {
|
|
132
196
|
// A delete carries no row, so there is nothing to put in front of the rule — `holds` IS the
|
|
133
197
|
// decision, the same one the two branches below take for a row a rule has just refused.
|
|
@@ -147,7 +211,7 @@ export class SubscriberGate {
|
|
|
147
211
|
this.#denied(target.qid, who, patch.id);
|
|
148
212
|
return null;
|
|
149
213
|
}
|
|
150
|
-
const full =
|
|
214
|
+
const full = index.byId.get(patch.id);
|
|
151
215
|
// No whole row means no decision to take. An update patch carries the changed columns only, so
|
|
152
216
|
// a rule reading `row.ownerId` on one reads `undefined` and answers as if the row had said so —
|
|
153
217
|
// fail-closed for `=== actor.id`, and a leak for every `!row.private`. It is not a gate that
|
|
@@ -218,6 +282,31 @@ const actorIdOf = (who: Subscriber): string | null => (who.actor === null ? null
|
|
|
218
282
|
* window stopped holding it. Written once so the two paths cannot answer differently: a client left
|
|
219
283
|
* holding the row instead renders a revoked grant until something else reconnects it.
|
|
220
284
|
*/
|
|
285
|
+
/**
|
|
286
|
+
* The patch as this subscriber must read it. `index` was a position in the SHARED, pre-policy
|
|
287
|
+
* window, and forwarded unchanged it placed the row out of order for anyone who sees fewer rows —
|
|
288
|
+
* and its size told them how many rows they may not see sit ahead of it. Re-based on the rows ahead
|
|
289
|
+
* of it that this subscriber holds; dropped from a delete, which the client applies by id. Bounded
|
|
290
|
+
* by `CURSOR_ID_LIMIT` like `holds` is: a held row past it is not counted.
|
|
291
|
+
*/
|
|
292
|
+
function rebase(
|
|
293
|
+
patch: RowPatch,
|
|
294
|
+
holding: Holding,
|
|
295
|
+
rows: readonly Row[],
|
|
296
|
+
index: WindowIndex,
|
|
297
|
+
): RowPatch {
|
|
298
|
+
if (patch.index === undefined) return patch;
|
|
299
|
+
const { index: _shared, ...rest } = patch;
|
|
300
|
+
if (patch.op === 'delete' || patch.row === null) return rest;
|
|
301
|
+
const at = index.position.get(patch.id) ?? patch.index;
|
|
302
|
+
let local = 0;
|
|
303
|
+
for (let i = 0; i < at && i < rows.length; i += 1) {
|
|
304
|
+
const id = rows[i]?.id;
|
|
305
|
+
if (id !== undefined && id !== patch.id && holding.has(id)) local += 1;
|
|
306
|
+
}
|
|
307
|
+
return { ...rest, index: local };
|
|
308
|
+
}
|
|
309
|
+
|
|
221
310
|
const withdrawn = (patch: RowPatch): RowPatch => ({
|
|
222
311
|
op: 'delete',
|
|
223
312
|
id: patch.id,
|
|
@@ -53,6 +53,12 @@ export interface SyncNodeOptions {
|
|
|
53
53
|
* is a single-tenant node, and `start()` says so in the log.
|
|
54
54
|
*/
|
|
55
55
|
readonly authenticate?: SyncAuthenticator;
|
|
56
|
+
/**
|
|
57
|
+
* Exact origins a page may open a socket from, besides this node's own host name — the page's
|
|
58
|
+
* origin when it is served on another host (`SYNC_URL` on a separate domain). Anything else is
|
|
59
|
+
* refused `X_SOCKET_ORIGIN_REFUSED` before `authenticate` runs.
|
|
60
|
+
*/
|
|
61
|
+
readonly allowedOrigins?: readonly string[];
|
|
56
62
|
/** How often an expired grant is re-decided. The clock a socket's authority runs on. */
|
|
57
63
|
readonly reauthenticateIntervalMs?: number;
|
|
58
64
|
readonly clock?: Clock;
|
package/src/sync-node.ts
CHANGED
|
@@ -11,13 +11,8 @@ import { evictInChunks } from './drain-evictions';
|
|
|
11
11
|
import { isClientFault } from './errors';
|
|
12
12
|
import type { TransportSubscription } from './fanout';
|
|
13
13
|
import { CHANGE_SUBJECT_ALL, parseEnvelope, SeqGapDetector } from './replicator';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
DEFAULT_MAX_BUFFERED_BYTES,
|
|
17
|
-
idleSweepPeriodMs,
|
|
18
|
-
SocketRegistry,
|
|
19
|
-
SyncSocket,
|
|
20
|
-
} from './socket';
|
|
14
|
+
import { CLOSE, DEFAULT_MAX_BUFFERED_BYTES, SocketRegistry, SyncSocket } from './socket';
|
|
15
|
+
import { idleSweepPeriodMs } from './socket-idle';
|
|
21
16
|
import { GrantBook, sweepGrants } from './sync-auth';
|
|
22
17
|
import { ackRefOf, createFrameRouter } from './sync-frames';
|
|
23
18
|
import { drainGraceMs, socketCeilings, syncNodeBounds } from './sync-node-bounds';
|
|
@@ -274,6 +269,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
274
269
|
socketCount: () => sockets.count,
|
|
275
270
|
newSocketId: () => uuid(),
|
|
276
271
|
authenticate: options.authenticate,
|
|
272
|
+
allowedOrigins: options.allowedOrigins,
|
|
277
273
|
onGranted: (socketId, grant) => grants.set(socketId, grant),
|
|
278
274
|
// The other half of recording the grant before the upgrade: an upgrade that never took
|
|
279
275
|
// gets no `close` callback, so this is the only thing that can free its entry.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Single responsibility: whether a websocket upgrade may carry this app's ambient credential. No
|
|
2
|
+
// CORS applies to a websocket and the session cookie rides it, so without this a page on a sibling
|
|
3
|
+
// host (same-site, so `SameSite=Lax` does not stop it) opened a socket as its visitor. The rule is
|
|
4
|
+
// core's `proveSameOrigin`, the one `@ultimat3/http`'s CSRF check asks too.
|
|
5
|
+
|
|
6
|
+
import { type OriginVerdict, proveSameOrigin } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Admitted, in order:
|
|
10
|
+
* - **no `Origin`** — RFC 6455 has a browser send one on every handshake, so a dial without it is
|
|
11
|
+
* not a browser and no visitor's cookie can be riding it;
|
|
12
|
+
* - **the node's own host name**, whatever the port or scheme — cookies are not port-isolated, so
|
|
13
|
+
* the page on `:3000` dialling the Compose rung's `:3001` is the same credential boundary, and a
|
|
14
|
+
* TLS-terminating ingress means the node cannot know the page's scheme;
|
|
15
|
+
* - otherwise core's rule, with `allowedOrigins` as the exact list (`APP_URL`'s origin, from the
|
|
16
|
+
* CLI, when the page is served on another host than the node).
|
|
17
|
+
*/
|
|
18
|
+
export function upgradeOrigin(
|
|
19
|
+
request: Request,
|
|
20
|
+
url: URL,
|
|
21
|
+
allowedOrigins: readonly string[],
|
|
22
|
+
): OriginVerdict {
|
|
23
|
+
const origin = request.headers.get('origin');
|
|
24
|
+
if (origin === null) return { ok: true };
|
|
25
|
+
if (URL.parse(origin)?.hostname === url.hostname) return { ok: true };
|
|
26
|
+
return proveSameOrigin({
|
|
27
|
+
selfOrigins: allowedOrigins,
|
|
28
|
+
origin,
|
|
29
|
+
secFetchSite: request.headers.get('sec-fetch-site'),
|
|
30
|
+
listed: () => false,
|
|
31
|
+
listName: 'APP_URL or createSyncNode({ allowedOrigins })',
|
|
32
|
+
});
|
|
33
|
+
}
|
package/src/sync-upgrade.ts
CHANGED
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
// from what the socket then does — the same line `sync-frames.ts` and `sync-listen.ts` already draw.
|
|
4
4
|
|
|
5
5
|
import { healthzPayload, readyzPayload, reportError } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
SocketAuthUnavailableError,
|
|
8
|
+
SocketOriginRefusedError,
|
|
9
|
+
SocketUnauthenticatedError,
|
|
10
|
+
} from './errors';
|
|
7
11
|
import type { SyncAuthenticator, SyncGrant } from './sync-auth';
|
|
12
|
+
import { upgradeOrigin } from './sync-origin';
|
|
8
13
|
import { toWireError } from './sync-protocol';
|
|
9
14
|
import type { AcceptBudget, Rng } from './thundering-herd';
|
|
10
15
|
|
|
@@ -43,6 +48,11 @@ export interface UpgradeDeps {
|
|
|
43
48
|
socketCount(): number;
|
|
44
49
|
newSocketId(): string;
|
|
45
50
|
readonly authenticate?: SyncAuthenticator | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Exact origins a page may dial from besides the node's own host name — `APP_URL`'s, when the
|
|
53
|
+
* page is served on another host than the node (`sync-origin.ts`).
|
|
54
|
+
*/
|
|
55
|
+
readonly allowedOrigins?: readonly string[] | undefined;
|
|
46
56
|
/**
|
|
47
57
|
* Recorded BEFORE `server.upgrade`, because Bun runs `websocket.open` synchronously inside it
|
|
48
58
|
* (measured on bun 1.4.0) and `open` is where the node reads this grant to build the socket's
|
|
@@ -79,12 +89,19 @@ export async function handleUpgrade(
|
|
|
79
89
|
return deps.ready() ? json(payload) : json({ status: 503, body: payload.body });
|
|
80
90
|
}
|
|
81
91
|
if (url.pathname !== deps.path) return new Response('not found', { status: 404 });
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
// First, and before anything is spent: a foreign page is refused whatever the node's load.
|
|
93
|
+
const origin = upgradeOrigin(request, url, deps.allowedOrigins ?? []);
|
|
94
|
+
if (!origin.ok)
|
|
95
|
+
return wireErrorResponse(403, new SocketOriginRefusedError({ reason: origin.reason }));
|
|
96
|
+
// The count and readiness. Decided before `authenticate` so a full node costs no token service
|
|
97
|
+
// call.
|
|
98
|
+
if (deps.socketCount() >= deps.maxConnections || !deps.ready()) return shed(deps);
|
|
99
|
+
// The RATE is RESERVED here and REFUNDED on every exit that takes no socket. Reserved first, so a
|
|
100
|
+
// reconnect herd reaches `authenticate` bounded by the burst — the token service is the first
|
|
101
|
+
// thing a herd would otherwise flatten. Refunded, because spent-and-kept, one client dialling
|
|
102
|
+
// with no credential drained the bucket and every signed-in reconnect behind it was shed.
|
|
103
|
+
if (!deps.accept.tryAccept()) return shed(deps);
|
|
104
|
+
const refund = (): void => deps.accept.refund();
|
|
88
105
|
let grant: SyncGrant | null = null;
|
|
89
106
|
if (deps.authenticate) {
|
|
90
107
|
try {
|
|
@@ -93,6 +110,7 @@ export async function handleUpgrade(
|
|
|
93
110
|
// A failure is not a denial. The token service timing out must not read to a client as "you
|
|
94
111
|
// may not connect" — it is told to come back, and this node is the one that pages.
|
|
95
112
|
reportError(error, { source: 'realtime', scope: { operation: 'sync.authenticate' } });
|
|
113
|
+
refund();
|
|
96
114
|
return wireErrorResponse(
|
|
97
115
|
503,
|
|
98
116
|
new SocketAuthUnavailableError({ detail: 'see the node log for the cause' }),
|
|
@@ -101,6 +119,7 @@ export async function handleUpgrade(
|
|
|
101
119
|
// The decision, made before a socket exists: an upgrade is the cheapest thing to refuse and the
|
|
102
120
|
// most expensive thing to take back.
|
|
103
121
|
if (grant === null) {
|
|
122
|
+
refund();
|
|
104
123
|
return wireErrorResponse(
|
|
105
124
|
401,
|
|
106
125
|
new SocketUnauthenticatedError({ reason: 'authenticate() resolved no actor' }),
|
|
@@ -120,8 +139,11 @@ export async function handleUpgrade(
|
|
|
120
139
|
// sockets as there were parked requests, and `maxConnections` bounded nothing that a herd could
|
|
121
140
|
// reach. Sound because there is no await between this line and `server.upgrade`, and the count
|
|
122
141
|
// moves INSIDE it: Bun runs `websocket.open` synchronously there, which is where `sockets.add`
|
|
123
|
-
// runs.
|
|
124
|
-
if (!deps.ready() || deps.socketCount() >= deps.maxConnections)
|
|
142
|
+
// runs.
|
|
143
|
+
if (!deps.ready() || deps.socketCount() >= deps.maxConnections) {
|
|
144
|
+
refund();
|
|
145
|
+
return shed(deps);
|
|
146
|
+
}
|
|
125
147
|
const data: WsData = {
|
|
126
148
|
socketId: deps.newSocketId(),
|
|
127
149
|
// The node's own id is "not skewed until the hello says so", never "current forever".
|
|
@@ -144,10 +166,12 @@ export async function handleUpgrade(
|
|
|
144
166
|
// failing upgrades left 20 grants. Rethrown untouched — the throw is the operator's diagnosis,
|
|
145
167
|
// and this line owes it the release, not a verdict.
|
|
146
168
|
deps.onUngranted(data.socketId);
|
|
169
|
+
refund();
|
|
147
170
|
throw error;
|
|
148
171
|
}
|
|
149
172
|
if (!upgraded) {
|
|
150
173
|
deps.onUngranted(data.socketId);
|
|
174
|
+
refund();
|
|
151
175
|
return new Response('expected websocket', { status: 426 });
|
|
152
176
|
}
|
|
153
177
|
return undefined;
|
package/src/thundering-herd.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Three mechanisms, in the order they fire:
|
|
5
5
|
// 1. drainPlan() — the draining node assigns each client a distinct delay slot before closing
|
|
6
|
-
// 2.
|
|
6
|
+
// 2. policyDelay() — the client's own jittered retry, for failures nobody scheduled
|
|
7
7
|
// 3. AcceptBudget — the receiving node's token bucket, so recovery sheds instead of collapsing
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
backoffDelay,
|
|
10
11
|
type Clock,
|
|
11
|
-
backoffDelay as coreBackoffDelay,
|
|
12
12
|
finiteOption,
|
|
13
13
|
type JitterMode,
|
|
14
14
|
type Random,
|
|
@@ -59,20 +59,21 @@ export const browserBackoff: BackoffPolicy = {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* A {@link BackoffPolicy} mapped onto `@ultimat3/core`'s `backoffDelay` — the one curve. Attempt is
|
|
63
|
+
* 1-BASED, core's count: the wait after the first failure is `attempt: 1` and is `baseMs`.
|
|
63
64
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
65
|
+
* Internal to this package and never re-exported from the barrel. Until 22.0.0 `.` exported a
|
|
66
|
+
* 0-based `backoffDelay` of its own that shifted by one before delegating, which made two counting
|
|
67
|
+
* conventions under one name — and a caller that passed a 1-based count to it (the channel
|
|
68
|
+
* catch-up retry did) waited twice as long as it meant to, with no error anywhere.
|
|
68
69
|
*/
|
|
69
|
-
export function
|
|
70
|
+
export function policyDelay(
|
|
71
|
+
policy: BackoffPolicy,
|
|
70
72
|
attempt: number,
|
|
71
|
-
policy: BackoffPolicy = defaultBackoff,
|
|
72
73
|
rng: Rng = Math.random,
|
|
73
74
|
): number {
|
|
74
|
-
return
|
|
75
|
-
attempt
|
|
75
|
+
return backoffDelay({
|
|
76
|
+
attempt,
|
|
76
77
|
base: policy.baseMs,
|
|
77
78
|
max: policy.maxMs,
|
|
78
79
|
factor: policy.factor,
|
|
@@ -187,6 +188,15 @@ export class AcceptBudget {
|
|
|
187
188
|
return true;
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Hands back a token `tryAccept` reserved for work that took no socket — an upgrade whose
|
|
193
|
+
* credential was refused, or whose authenticator failed. Never past `burst`.
|
|
194
|
+
*/
|
|
195
|
+
refund(): void {
|
|
196
|
+
this.#refill();
|
|
197
|
+
this.#tokens = Math.min(this.#burst, this.#tokens + 1);
|
|
198
|
+
}
|
|
199
|
+
|
|
190
200
|
/** Delay to hand a refused client, jittered so refusals do not re-synchronise the herd. */
|
|
191
201
|
retryAfterMs(rng: Rng = Math.random): number {
|
|
192
202
|
const base = Math.ceil(1000 / this.#perSecond);
|
package/src/transport-env.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
// Single responsibility: environment → fanout transport. The one place a boot
|
|
2
|
-
// process fans changes out inside its own heap or over NATS, so `x dev`, a
|
|
3
|
-
// custom host resolve it identically. The KV bucket and the presence TTL are decided here too:
|
|
1
|
+
// Single responsibility: `realtime.transport` + environment → fanout transport. The one place a boot
|
|
2
|
+
// decides whether this process fans changes out inside its own heap or over NATS, so `x dev`, a
|
|
3
|
+
// `sync` container and any custom host resolve it identically. The KV bucket and the presence TTL are decided here too:
|
|
4
4
|
// the bucket's whole-stream age limit and `PresenceRegistry`'s TTL are the same number seen from
|
|
5
5
|
// two sides, and a caller that had to pass each one separately could quietly set them apart.
|
|
6
6
|
|
|
7
|
-
import type { Clock } from '@ultimat3/core';
|
|
8
|
-
import { finiteOption } from '@ultimat3/core';
|
|
7
|
+
import type { Clock, RealtimeConfig } from '@ultimat3/core';
|
|
8
|
+
import { ConfigInvalidError, finiteOption } from '@ultimat3/core';
|
|
9
9
|
import type { Transport } from './fanout';
|
|
10
10
|
import { InProcessTransport } from './fanout';
|
|
11
11
|
import type { NatsConnect } from './nats-client';
|
|
12
12
|
import { assertBucket } from './nats-jetstream';
|
|
13
13
|
import { NatsTransport } from './nats-transport';
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* The keys read here, and nothing else. Named once so docs and tests cannot drift from the code.
|
|
17
|
+
* `NATS_URL` is the conventional bus variable: read under `transport: 'nats'` when `urlEnv` names
|
|
18
|
+
* it, and under `'memory'` only to refuse it — see `selectTransport`.
|
|
19
|
+
*/
|
|
16
20
|
export const TRANSPORT_ENV_KEYS = ['NATS_URL', 'NATS_KV_BUCKET'] as const;
|
|
17
21
|
|
|
22
|
+
/** The two fields of `app.config.ts`'s `realtime` section that decide the bus. */
|
|
23
|
+
export type RealtimeTopology = Pick<RealtimeConfig, 'transport' | 'urlEnv'>;
|
|
24
|
+
|
|
25
|
+
/** Named in every refusal, so the reader edits the file the decision lives in. */
|
|
26
|
+
const CONFIG_FILE = 'app.config.ts';
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
29
|
* One bucket per deployment, not per cluster: two apps sharing a nats-server would otherwise share
|
|
20
30
|
* one presence namespace, and a room name that collided would list the other app's members.
|
|
@@ -59,13 +69,22 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
59
69
|
value === undefined || value.trim().length === 0 ? undefined : value.trim();
|
|
60
70
|
|
|
61
71
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
72
|
+
* The config decides the transport and the environment supplies its url — never the other way
|
|
73
|
+
* round. Until 22.0.0 `NATS_URL` alone decided and `realtime.transport` / `realtime.urlEnv` were
|
|
74
|
+
* read by nothing, so `transport: 'nats'` with the variable unset booted the in-process bus and
|
|
75
|
+
* reached no other node, with no error on either side. Both mismatches are refused here:
|
|
76
|
+
*
|
|
77
|
+
* - `'nats'` with the variable `urlEnv` names unset or blank.
|
|
78
|
+
* - `'memory'` with a bus url set (`NATS_URL`, or the variable `urlEnv` names). An operator who set
|
|
79
|
+
* one expected fanout across nodes; keeping every change in this heap instead is the same silent
|
|
80
|
+
* failure seen from the other side, so the two are refused rather than reconciled.
|
|
81
|
+
*
|
|
82
|
+
* The bucket name is validated here rather than on first connect: a typo'd bucket is a boot that
|
|
83
|
+
* reports a healthy bus and then fails every presence write.
|
|
66
84
|
*/
|
|
67
85
|
export function selectTransport(
|
|
68
86
|
env: TransportEnvironment,
|
|
87
|
+
topology: RealtimeTopology,
|
|
69
88
|
options: SelectTransportOptions = {},
|
|
70
89
|
): TransportSelection {
|
|
71
90
|
const presenceTtlMs = finiteOption(
|
|
@@ -73,22 +92,32 @@ export function selectTransport(
|
|
|
73
92
|
'presenceTtlMs',
|
|
74
93
|
options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS,
|
|
75
94
|
);
|
|
76
|
-
const url = nonEmpty(env['NATS_URL']);
|
|
77
95
|
|
|
78
|
-
if (
|
|
96
|
+
if (topology.transport === 'memory') {
|
|
97
|
+
refuseStrayBusUrl(env, topology);
|
|
79
98
|
const transport = new InProcessTransport(
|
|
80
99
|
options.clock === undefined ? {} : { clock: options.clock },
|
|
81
100
|
);
|
|
82
101
|
return {
|
|
83
102
|
transport,
|
|
84
103
|
mode: 'embedded',
|
|
85
|
-
detail:
|
|
104
|
+
detail: `in-process fanout — set realtime.transport 'nats' in ${CONFIG_FILE} and NATS_URL to reach the other nodes`,
|
|
86
105
|
bucket: null,
|
|
87
106
|
presenceTtlMs,
|
|
88
107
|
connect: () => Promise.resolve(),
|
|
89
108
|
};
|
|
90
109
|
}
|
|
91
110
|
|
|
111
|
+
const urlEnv = topology.urlEnv ?? 'NATS_URL';
|
|
112
|
+
const url = nonEmpty(env[urlEnv]);
|
|
113
|
+
if (url === undefined) {
|
|
114
|
+
throw new ConfigInvalidError({
|
|
115
|
+
cause: `realtime.transport is 'nats' and realtime.urlEnv names ${urlEnv}, which is unset in this process's environment, so no node would be reachable`,
|
|
116
|
+
fix: `set ${urlEnv} to the nats-server url for every realtime role (web, sync, replicator), or set realtime: { transport: 'memory' } in ${CONFIG_FILE} for a single node`,
|
|
117
|
+
meta: { key: 'realtime.urlEnv', variable: urlEnv },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
const bucket = nonEmpty(env['NATS_KV_BUCKET']) ?? DEFAULT_PRESENCE_BUCKET;
|
|
93
122
|
assertBucket(bucket);
|
|
94
123
|
const transport = new NatsTransport({
|
|
@@ -101,9 +130,21 @@ export function selectTransport(
|
|
|
101
130
|
return {
|
|
102
131
|
transport,
|
|
103
132
|
mode: 'external',
|
|
104
|
-
detail:
|
|
133
|
+
detail: urlEnv,
|
|
105
134
|
bucket,
|
|
106
135
|
presenceTtlMs,
|
|
107
136
|
connect: () => transport.connect(),
|
|
108
137
|
};
|
|
109
138
|
}
|
|
139
|
+
|
|
140
|
+
/** `'memory'` with a bus url in the environment: the conflict `selectTransport` refuses. */
|
|
141
|
+
function refuseStrayBusUrl(env: TransportEnvironment, topology: RealtimeTopology): void {
|
|
142
|
+
const names = topology.urlEnv === undefined ? ['NATS_URL'] : ['NATS_URL', topology.urlEnv];
|
|
143
|
+
const set = names.find((name) => nonEmpty(env[name]) !== undefined);
|
|
144
|
+
if (set === undefined) return;
|
|
145
|
+
throw new ConfigInvalidError({
|
|
146
|
+
cause: `${set} is set but realtime.transport is 'memory', so this process would fan out in its own heap and reach no other node`,
|
|
147
|
+
fix: `set realtime: { transport: 'nats', urlEnv: '${set}' } in ${CONFIG_FILE} to use the bus, or unset ${set} for a single node`,
|
|
148
|
+
meta: { key: 'realtime.transport', variable: set },
|
|
149
|
+
});
|
|
150
|
+
}
|
package/src/use-mutation.ts
CHANGED
|
@@ -134,6 +134,19 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
134
134
|
page.store.push(key, (tx) => mutator.local?.(tx, input), mutator.conflict ?? 'server-wins');
|
|
135
135
|
}
|
|
136
136
|
count(writes, mutator.name, 1);
|
|
137
|
+
// Older writes still wait in the outbox: this one queues BEHIND them rather than overtaking
|
|
138
|
+
// them over HTTP — a like queued offline and the unlike made once the network was back could
|
|
139
|
+
// otherwise land swapped. The replay sends the queue in order, this write last, under its key.
|
|
140
|
+
const queued = peekOutbox();
|
|
141
|
+
if (queued !== undefined && queued.pending().length > 0) {
|
|
142
|
+
try {
|
|
143
|
+
await queued.enqueue({ key, name: mutator.name, input });
|
|
144
|
+
void queued.replay().catch(() => undefined);
|
|
145
|
+
return undefined;
|
|
146
|
+
} finally {
|
|
147
|
+
count(writes, mutator.name, -1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
137
150
|
let output: unknown;
|
|
138
151
|
/** The records the answer carried, `type:key` — what the overlay may be settled against. */
|
|
139
152
|
const carried = new Set<string>();
|
package/src/use-query.ts
CHANGED
|
@@ -161,7 +161,9 @@ function readAccessor<R extends object>(
|
|
|
161
161
|
* read's records in answer order (`records[type]`, which `rowsOf` fills first-seen = data order).
|
|
162
162
|
* The browser never derives a key: an answer with no records envelope holds its rows itself.
|
|
163
163
|
*/
|
|
164
|
-
const fetch = (
|
|
164
|
+
const fetch = (
|
|
165
|
+
append: boolean,
|
|
166
|
+
): Promise<{ rows: readonly Row[]; keys: string[] | null; next?: string | null }> => {
|
|
165
167
|
let keysOf: string[] | null = null;
|
|
166
168
|
const onEnvelope = (envelope: RecordEnvelope): void => {
|
|
167
169
|
const records = type === undefined ? undefined : envelope.records?.[type];
|
|
@@ -173,10 +175,12 @@ function readAccessor<R extends object>(
|
|
|
173
175
|
}
|
|
174
176
|
const controls =
|
|
175
177
|
append && after !== null ? { first: options.first, after } : { first: options.first };
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
// The page's cursor travels WITH its rows and is taken only where the rows are: a `more()`
|
|
179
|
+
// a refetch superseded wrote its cursor here, before the generation check discarded its rows.
|
|
180
|
+
return method.page(input, controls, { onEnvelope }).then((page) => ({
|
|
181
|
+
...answered(page.rows as readonly Row[]),
|
|
182
|
+
next: page.hasNextPage ? page.endCursor : null,
|
|
183
|
+
}));
|
|
180
184
|
};
|
|
181
185
|
|
|
182
186
|
const load = (append = false): void => {
|
|
@@ -186,6 +190,7 @@ function readAccessor<R extends object>(
|
|
|
186
190
|
fetch(append).then(
|
|
187
191
|
(answer) => {
|
|
188
192
|
if (released || mine !== generation) return;
|
|
193
|
+
if (answer.next !== undefined) after = answer.next;
|
|
189
194
|
if (type === undefined || answer.keys === null) {
|
|
190
195
|
// Not records — no type named, or no envelope: the list holds its own rows.
|
|
191
196
|
own = append ? [...own, ...answer.rows] : answer.rows;
|