@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.
Files changed (55) hide show
  1. package/CLAUDE.md +302 -1009
  2. package/README.md +130 -26
  3. package/package.json +4 -4
  4. package/src/changefeed.ts +7 -1
  5. package/src/channel-authz.ts +23 -4
  6. package/src/channel-decl.ts +16 -5
  7. package/src/channel-describe.ts +7 -5
  8. package/src/channel-logs.ts +19 -1
  9. package/src/channel-records.ts +8 -0
  10. package/src/client-channels.ts +75 -5
  11. package/src/client.ts +14 -2
  12. package/src/cursor.ts +5 -0
  13. package/src/errors.ts +43 -1
  14. package/src/idb-fake.ts +24 -4
  15. package/src/idb-types.ts +7 -0
  16. package/src/index.ts +1 -1
  17. package/src/live-definition.ts +5 -1
  18. package/src/live-fanout.ts +51 -2
  19. package/src/live-query.ts +11 -0
  20. package/src/live-replicator.ts +160 -0
  21. package/src/local-store-idb.ts +89 -15
  22. package/src/matcher-bridge.ts +5 -0
  23. package/src/nats-fake.ts +10 -1
  24. package/src/nats-jetstream.ts +36 -14
  25. package/src/nats-transport.ts +2 -2
  26. package/src/offline-queue.ts +76 -21
  27. package/src/page-outbox.ts +80 -10
  28. package/src/page-socket.ts +39 -8
  29. package/src/pg-entity-row.ts +37 -184
  30. package/src/pg-identifier.ts +23 -0
  31. package/src/pg-preflight.ts +32 -45
  32. package/src/pg-publication.ts +95 -0
  33. package/src/pg-replication.ts +21 -7
  34. package/src/pg-socket.ts +139 -53
  35. package/src/pg-tls.ts +124 -0
  36. package/src/pg-wire.ts +65 -16
  37. package/src/policy-fake.ts +14 -0
  38. package/src/query-window.ts +35 -21
  39. package/src/replication-errors.ts +29 -16
  40. package/src/replicator.ts +13 -3
  41. package/src/server.ts +8 -3
  42. package/src/socket-drops.ts +30 -0
  43. package/src/socket-engine.ts +15 -3
  44. package/src/socket-host.ts +103 -4
  45. package/src/socket-idle.ts +21 -0
  46. package/src/socket.ts +41 -38
  47. package/src/subscriber-gate.ts +92 -3
  48. package/src/sync-node-contract.ts +6 -0
  49. package/src/sync-node.ts +3 -7
  50. package/src/sync-origin.ts +33 -0
  51. package/src/sync-upgrade.ts +33 -9
  52. package/src/thundering-herd.ts +21 -11
  53. package/src/transport-env.ts +55 -14
  54. package/src/use-mutation.ts +13 -0
  55. package/src/use-query.ts +10 -5
@@ -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.patch(target, who, patch, held.has(patch.id));
120
- if (allowed !== null) out.push(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 = target.rows.find((row) => row.id === patch.id);
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
- CLOSE,
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
+ }
@@ -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 { SocketAuthUnavailableError, SocketUnauthenticatedError } from './errors';
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
- // The count, not the rate. Shed the same way and with the same delay attached: a client refused
83
- // for a full node and one refused for a fast one have the same next move, and the refusal is
84
- // decided before `authenticate` so a full node costs no token service call.
85
- if (deps.socketCount() >= deps.maxConnections || !deps.ready() || !deps.accept.tryAccept()) {
86
- return shed(deps);
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. No second `tryAccept()`: that budget was spent above.
124
- if (!deps.ready() || deps.socketCount() >= deps.maxConnections) return shed(deps);
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;
@@ -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. backoffDelay() — the client's own jittered retry, for failures nobody scheduled
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
- * Attempt is 0-BASED. Result is always in `[0, maxMs]`.
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
- * The arithmetic is core's, and `attempt + 1` is the WHOLE of the seam: a client counts its first
65
- * reconnect as attempt 0 and core counts the first wait as attempt 1, so dropping the shift would
66
- * double every reconnect delay in the framework — silently, and only under the load this file
67
- * exists to survive. `thundering-herd-core-parity.test.ts` pins the numbers.
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 backoffDelay(
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 coreBackoffDelay({
75
- attempt: attempt + 1,
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);
@@ -1,20 +1,30 @@
1
- // Single responsibility: environment → fanout transport. The one place a boot decides whether this
2
- // process fans changes out inside its own heap or over NATS, so `x dev`, a `sync` container and any
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
- /** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */
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
- * No url means the in-process transport — the same "an unset variable means the embedded default"
63
- * law the db, mail, storage and replication bindings follow. The bucket name is validated here
64
- * rather than on first connect: a typo'd bucket is a boot that reports a healthy bus and then
65
- * fails every presence write, which is the failure this whole selector exists to move earlier.
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 (url === undefined) {
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: 'in-process fanout — set NATS_URL to reach the other nodes',
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: 'NATS_URL',
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
+ }
@@ -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 = (append: boolean): Promise<{ rows: readonly Row[]; keys: string[] | null }> => {
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
- return method.page(input, controls, { onEnvelope }).then((page) => {
177
- after = page.hasNextPage ? page.endCursor : null;
178
- return answered(page.rows as readonly Row[]);
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;