@ultimat3/realtime 27.6.0 → 27.7.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 CHANGED
@@ -119,12 +119,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
119
119
  ## The node, the bus and presence
120
120
 
121
121
  - `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
122
- - **`selectTransport(env, realtime)` is the one place `realtime.transport` + env become a bus** (KV
123
- bucket and presence TTL come back with it). The config decides, the env supplies: `'nats'` refuses
124
- (`X_CONFIG_INVALID`) when the `urlEnv` variable is unset; `'memory'` refuses a set one.
125
- - **`nats` is imported only by `nats-lib-client.ts`**; its reconnect re-subscribes. Only when it
126
- GIVES UP (`onClosed`) does `NatsTransport` re-dial on backoff and re-bind kept subscriptions
127
- (`nats-subscriptions.ts`); callers meanwhile are refused. KV semantics: `nats-jetstream`/`nats-kv`.
122
+ - **`selectTransport(env, realtime, { use })` is the one place config + env become a bus** (bucket
123
+ and presence TTL come back). `'nats'` refuses (`X_CONFIG_INVALID`) an unset `urlEnv` variable;
124
+ `'memory'` a set one. `use`: `'sockets'`/`'feed'` await a 15 s dial, then
125
+ `X_TRANSPORT_UNAVAILABLE`; `'publish'` dials in background. Never `waitOnFirstConnect`
126
+ (retries forever). A publish with no live connection is refused.
127
+ - **`nats` is imported only by `nats-lib-client.ts`**; its reconnect re-subscribes. When it GIVES
128
+ UP (`onClosed`) `NatsTransport` re-dials on backoff and re-binds.
128
129
  - **Nothing leaves `NatsTransport` uncoded** — `#translating` wraps `publish`/`subscribe` refusals
129
130
  as `X_TRANSPORT_UNAVAILABLE`. `#ensure` reuses a mid-reconnect client on purpose;
130
131
  `Transport.onReconnect(listener)` (required) announces each recovery.
package/README.md CHANGED
@@ -800,7 +800,22 @@ principal.
800
800
  fanout across nodes. Until 22.0.0 `NATS_URL` alone decided and both keys were read by nothing.
801
801
  `presenceTtlMs` comes back with it because the bucket's whole-stream age limit was derived from
802
802
  it — a `PresenceRegistry` given a different number would report members leaving that never left.
803
- Selection is pure; `connect()` is the dial, so an unreachable bus fails at boot.
803
+ Selection is pure; `connect()` is the dial. **What the process does with the bus decides whether
804
+ that dial is waited for** — `selectTransport(env, realtime, { use })`, `BusUse`:
805
+ `'sockets'` (a `sync` node; the default) awaits it and the presence bucket, retried on backoff for
806
+ `connectWithinMs` (default `BUS_CONNECT_WAIT_MS`, 15 s) and then `X_TRANSPORT_UNAVAILABLE`;
807
+ `'feed'` (the replicator) awaits it and asks JetStream for nothing; `'publish'` (`web`, `worker`,
808
+ `scheduler`) starts it in the background and resolves at once. `selection.state()` answers
809
+ `'up' | 'connecting'`.
810
+ - **`NatsTransport` has two ways to open.** `connect({ withinMs, fix })` is for a node that cannot
811
+ work without the bus. `connectInBackground()` is for a process that only publishes: retried on the
812
+ transport's own backoff until it lands or `close()` runs, announced through `onReconnect`, and
813
+ until then every `publish`/`subscribe` rejects at once with `X_TRANSPORT_UNAVAILABLE` — never
814
+ parked, never queued. A publish made while the client is **reconnecting** is refused too: the
815
+ `nats` client would buffer it without bound and replay it late. `presenceBucket: 'first-use'`
816
+ asserts the KV bucket when `shared` is first read instead of at the dial, so a publisher needs no
817
+ JetStream. The first dial is ONE attempt of the library's — `waitOnFirstConnect` is not set,
818
+ because under it nats@2.29.3 retries a failed first dial forever and the promise never settles.
804
819
  - **`NatsTransport` runs on the official `nats` client** — `nats@2.29.3`, pinned exact, admitted at
805
820
  this transport seam and nowhere else
806
821
  ([`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md)). The package reaches it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "27.6.0",
3
+ "version": "27.7.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,10 +40,10 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/core": "27.6.0",
44
- "@ultimat3/entity": "27.6.0",
45
- "@ultimat3/policy": "27.6.0",
46
- "@ultimat3/query": "27.6.0",
43
+ "@ultimat3/core": "27.7.0",
44
+ "@ultimat3/entity": "27.7.0",
45
+ "@ultimat3/policy": "27.7.0",
46
+ "@ultimat3/query": "27.7.0",
47
47
  "nats": "2.29.3"
48
48
  }
49
49
  }
@@ -71,6 +71,11 @@ export interface NatsClientOptions {
71
71
  /** Our jitter policy, asked once per attempt — the herd is ours to spread, not the library's. */
72
72
  readonly reconnectDelay?: (() => number) | undefined;
73
73
  readonly requestTimeoutMs?: number | undefined;
74
+ /**
75
+ * How long ONE dial may sit before it is an attempt that failed. The library's own default is
76
+ * 20 s, which a server that accepts the socket and says nothing spends in full.
77
+ */
78
+ readonly connectTimeoutMs?: number | undefined;
74
79
  /** A background failure: a dropped connection, a server error, an exhausted reconnect. */
75
80
  readonly onError?: ((error: unknown) => void) | undefined;
76
81
  /** The library re-established the connection. The cluster behind it may be a different one. */
@@ -0,0 +1,61 @@
1
+ // Single responsibility: how long a dial is waited for — one attempt's connect timeout, and the
2
+ // bounded wait a process that cannot work without the bus puts on the whole retry loop.
3
+
4
+ import { finiteOption } from '@ultimat3/core';
5
+ import { TransportUnavailableError } from './errors';
6
+
7
+ /**
8
+ * One dial's connect timeout. Well under `BUS_CONNECT_WAIT_MS`: a black-holed server must fail
9
+ * several attempts inside a `sync` node's boot wait, so its refusal carries the attempt's own
10
+ * reason rather than an empty one, and a background dial keeps its backoff instead of the
11
+ * library's 20 s.
12
+ */
13
+ export const DIAL_TIMEOUT_MS = 4_000;
14
+
15
+ export interface NatsConnectWait {
16
+ /** Refuse with `X_TRANSPORT_UNAVAILABLE` once this long has passed without a connection. */
17
+ readonly withinMs?: number | undefined;
18
+ /** The `fix:` of that refusal — the caller knows which role is waiting and what it needs. */
19
+ readonly fix?: string | undefined;
20
+ }
21
+
22
+ export interface DialWait {
23
+ readonly transport: string;
24
+ /** `host:port`, never the url: the url carries the credential. */
25
+ readonly server: string;
26
+ readonly withinMs: number;
27
+ readonly fix: string | undefined;
28
+ /** The last attempt's own refusal, read when the wait runs out. */
29
+ readonly lastFailure: () => string | undefined;
30
+ }
31
+
32
+ /**
33
+ * `landing`, or a coded refusal once `withinMs` has passed. A real timer, never an injected clock:
34
+ * the wait bounds a container's boot, which the kubelet counts in real seconds. The loser is not
35
+ * cancelled — a dial cannot be — so the caller's `close()` is what ends it.
36
+ */
37
+ export async function landWithin(
38
+ landing: Promise<void> | undefined,
39
+ wait: DialWait,
40
+ ): Promise<void> {
41
+ const withinMs = finiteOption('createNatsTransport', 'connect.withinMs', wait.withinMs);
42
+ let timer: ReturnType<typeof setTimeout> | undefined;
43
+ const late = new Promise<never>((_, reject) => {
44
+ timer = setTimeout(() => {
45
+ const failure = wait.lastFailure();
46
+ const last = failure === undefined ? '' : ` — last attempt: ${failure}`;
47
+ reject(
48
+ new TransportUnavailableError({
49
+ transport: wait.transport,
50
+ reason: `${wait.server} did not accept a connection within ${withinMs}ms${last}`,
51
+ ...(wait.fix === undefined ? {} : { fix: wait.fix }),
52
+ }),
53
+ );
54
+ }, withinMs);
55
+ });
56
+ try {
57
+ await Promise.race([landing, late]);
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
@@ -92,7 +92,7 @@ class LibNatsClient implements NatsClient {
92
92
  }
93
93
 
94
94
  get connected(): boolean {
95
- return this.#connected && !this.#connection.isClosed();
95
+ return this.#connected && !this.#connection.isClosed() && protocolUp(this.#connection);
96
96
  }
97
97
 
98
98
  publish(subject: string, payload: Uint8Array): void {
@@ -211,6 +211,29 @@ class LibNatsClient implements NatsClient {
211
211
  }
212
212
  }
213
213
 
214
+ /**
215
+ * The library's own flag, read where it is set. `#connected` above follows the status ITERATOR,
216
+ * which delivers a disconnect a tick or more after the socket dropped; in between the library
217
+ * already knows (`protocol.connected` is cleared in the socket's close handler) and already
218
+ * buffers every publish. nats@2.29.3 has no option to forbid that buffer, so the flag is the only
219
+ * way to refuse a publish it would queue. It is not on the typed surface: a library that moves it
220
+ * answers `true` here and the status iterator is the reading again — the behaviour before this.
221
+ */
222
+ const protocolUp = (connection: NatsConnection): boolean => {
223
+ const protocol: unknown = Reflect.get(connection, 'protocol');
224
+ if (typeof protocol !== 'object' || protocol === null) return true;
225
+ return Reflect.get(protocol, 'connected') !== false;
226
+ };
227
+
228
+ /**
229
+ * How often the client pings, and how many may go unanswered. The library's defaults are two
230
+ * minutes and two: a connection whose packets are silently dropped stays "connected" — and keeps
231
+ * buffering publishes — for up to six minutes. 10 s and two is noticed within half a minute, for
232
+ * one 6-byte frame every ten seconds on a connection that is otherwise idle.
233
+ */
234
+ export const PING_INTERVAL_MS = 10_000;
235
+ export const MAX_PINGS_OUT = 2;
236
+
214
237
  /** `QueuedIterator.stop()` is the library's, and missing from the `AsyncIterable` it is typed as. */
215
238
  const stopStream = (stream: unknown): void => {
216
239
  if (typeof stream !== 'object' || stream === null || !('stop' in stream)) return;
@@ -219,9 +242,14 @@ const stopStream = (stream: unknown): void => {
219
242
  };
220
243
 
221
244
  /**
222
- * The production `NatsConnect`. The first dial retries on the same budget as a later loss
223
- * (`waitOnFirstConnect`), so a `sync` container that raced the bus into readiness recovers on its
224
- * own — and a budget that runs out rejects here rather than leaving a half-live connection behind.
245
+ * The production `NatsConnect`: ONE first dial, refused at once when the server is not there.
246
+ *
247
+ * Deliberately not `waitOnFirstConnect`. Until 2026-10 it was set, on the reading that the first
248
+ * dial "retries on the same budget as a later loss" — it does not: in nats@2.29.3 a failed first
249
+ * dial under that option `continue`s past the attempt counter, so it retries FOREVER, and the
250
+ * promise this returns never settles while the server is down. Every role awaited it at boot, so
251
+ * a pod that restarted during a NATS outage hung before it bound a socket. The retry is the
252
+ * transport's (`NatsTransport.#redial`), on our backoff, where `close()` can end it.
225
253
  */
226
254
  export const openNatsClient = async (options: NatsClientOptions): Promise<NatsClient> => {
227
255
  const target = parseNatsUrl(options.url);
@@ -235,11 +263,17 @@ export const openNatsClient = async (options: NatsClientOptions): Promise<NatsCl
235
263
  options.maxReconnectAttempts === undefined
236
264
  ? undefined
237
265
  : finiteOption('the nats client', 'maxReconnectAttempts', options.maxReconnectAttempts);
266
+ const connectTimeoutMs =
267
+ options.connectTimeoutMs === undefined
268
+ ? undefined
269
+ : finiteOption('the nats client', 'connectTimeoutMs', options.connectTimeoutMs);
238
270
  try {
239
271
  const connection = await connect({
240
272
  servers: [`${target.host}:${target.port}`],
241
273
  name: options.name ?? 'ultimate',
242
- waitOnFirstConnect: true,
274
+ pingInterval: PING_INTERVAL_MS,
275
+ maxPingOut: MAX_PINGS_OUT,
276
+ ...(connectTimeoutMs === undefined ? {} : { timeout: connectTimeoutMs }),
243
277
  ...(maxReconnectAttempts === undefined ? {} : { maxReconnectAttempts }),
244
278
  ...(options.reconnectDelay === undefined
245
279
  ? {}
@@ -3,6 +3,11 @@
3
3
  // re-establishing subscriptions, which is what makes a `sync` node stateless. The one loss it does
4
4
  // not own is its own end: a client closed for good once its reconnect budget is spent is replaced
5
5
  // here, on our backoff, and every kept subscription is bound again on the new one.
6
+ //
7
+ // Two ways to open it, because two kinds of process hold one. A node that SERVES from the bus
8
+ // awaits `connect()` and refuses to boot without it; a process that only PUBLISHES calls
9
+ // `connectInBackground()` and boots regardless — its publishes are refused, typed and at once,
10
+ // until the dial lands.
6
11
 
7
12
  import {
8
13
  type Clock,
@@ -16,6 +21,7 @@ import { TransportUnavailableError } from './errors';
16
21
  import type { Transport, TransportHandler, TransportSet, TransportSubscription } from './fanout';
17
22
  import type { NatsClient, NatsConnect } from './nats-client';
18
23
  import { parseNatsUrl } from './nats-client';
24
+ import { DIAL_TIMEOUT_MS, landWithin, type NatsConnectWait } from './nats-dial-wait';
19
25
  import { ensureKvBucket } from './nats-jetstream';
20
26
  import { NatsKvSet } from './nats-kv';
21
27
  import { openNatsClient } from './nats-open';
@@ -39,6 +45,14 @@ export interface NatsTransportOptions {
39
45
  readonly rng?: Rng;
40
46
  /** Injected so the whole transport — reconnect included — runs in a test with no network. */
41
47
  readonly connect?: NatsConnect;
48
+ /**
49
+ * When the presence bucket is asserted. `'dial'` (the default) is a node that serves presence: no
50
+ * JetStream, no connection. `'first-use'` is a process that only publishes — it never reads
51
+ * `shared`, so a server without JetStream, or without the bucket, must not cost it the bus.
52
+ */
53
+ readonly presenceBucket?: 'dial' | 'first-use';
54
+ /** One dial's connect timeout. Default `DIAL_TIMEOUT_MS`. */
55
+ readonly dialTimeoutMs?: number;
42
56
  }
43
57
 
44
58
  const DEFAULT_ATTEMPTS = 10;
@@ -57,8 +71,17 @@ export class NatsTransport implements Transport {
57
71
  #dialing: Promise<NatsClient> | undefined;
58
72
  #retries = 0;
59
73
  #closed = false;
60
- /** Set while a client the library gave up on is being replaced; the attempt in flight. */
74
+ /** False until the first dial lands: what a refusal says while a background dial is in flight. */
75
+ #everUp = false;
76
+ readonly #lazyBucket: boolean;
77
+ readonly #dialTimeoutMs: number;
78
+ /** Under `'first-use'`: the clients whose bucket has been asserted since they last connected. */
79
+ readonly #bucketed = new WeakSet<NatsClient>();
80
+ /** Set while no client is held and one is being dialled in the background; the attempt in flight. */
61
81
  #redialing: number | undefined;
82
+ #redialLoop: Promise<void> | undefined;
83
+ /** The last background dial's own refusal, for the cause of a `connect({ withinMs })` that gave up. */
84
+ #lastFailure: string | undefined;
62
85
  #wakeRedial: (() => void) | undefined;
63
86
  readonly #subscriptions = new NatsSubscriptions();
64
87
  readonly #reconnectListeners = new Set<() => void>();
@@ -76,16 +99,51 @@ export class NatsTransport implements Transport {
76
99
  options.maxReconnectAttempts ?? DEFAULT_ATTEMPTS,
77
100
  );
78
101
  this.#rng = options.rng ?? Math.random;
102
+ this.#dialTimeoutMs = finiteOption(
103
+ 'createNatsTransport',
104
+ 'dialTimeoutMs',
105
+ options.dialTimeoutMs ?? DIAL_TIMEOUT_MS,
106
+ );
107
+ this.#lazyBucket = options.presenceBucket === 'first-use';
79
108
  this.shared = new NatsKvSet({
80
- client: () => this.#ensure(),
109
+ client: () => this.#presenceClient(),
81
110
  bucket: options.bucket,
82
111
  clock: options.clock ?? systemClock,
83
112
  });
84
113
  }
85
114
 
86
- /** Fail fast at boot rather than on the first change: `/readyz` is meant to catch a dead bus. */
87
- async connect(): Promise<void> {
88
- await this.#ensure();
115
+ /**
116
+ * Fail fast at boot rather than on the first change: `/readyz` is meant to catch a dead bus.
117
+ * Bare, it is ONE dial. With `withinMs` the dial is retried on our backoff for that long — a
118
+ * node that raced the bus into readiness recovers on its own — and then refused, naming the
119
+ * server, the wait and the last attempt's own failure. The retry is not stopped by the refusal;
120
+ * `close()` stops it, and that is what a failed boot calls.
121
+ */
122
+ async connect(wait: NatsConnectWait = {}): Promise<void> {
123
+ if (wait.withinMs === undefined) {
124
+ await this.#ensure();
125
+ return;
126
+ }
127
+ const target = parseNatsUrl(this.#options.url);
128
+ await landWithin(this.#client === undefined ? this.#redial() : undefined, {
129
+ transport: this.name,
130
+ server: `${target.host}:${target.port}`,
131
+ withinMs: wait.withinMs,
132
+ fix: wait.fix,
133
+ lastFailure: () => this.#lastFailure,
134
+ });
135
+ // The loop also ends when the transport is closed under it.
136
+ if (this.#client === undefined) await this.#ensure();
137
+ }
138
+
139
+ /**
140
+ * Dial without being waited for: retried on our backoff until it lands or `close()` runs, and
141
+ * announced to the `onReconnect` listeners when it does. Until then every publish and subscribe
142
+ * is refused at once (`#ensure`) — never parked behind the dial, never queued.
143
+ */
144
+ connectInBackground(): void {
145
+ if (this.#closed || this.#client !== undefined) return;
146
+ void this.#redial();
89
147
  }
90
148
 
91
149
  get connected(): boolean {
@@ -94,6 +152,16 @@ export class NatsTransport implements Transport {
94
152
 
95
153
  async publish(subject: string, payload: string): Promise<void> {
96
154
  const client = await this.#ensure();
155
+ // The library BUFFERS a publish made while it reconnects, without bound, and replays the lot
156
+ // when the server is back: a queue nobody sized, delivering events whose only meaning was
157
+ // "re-read now" minutes late. A subscriber is told of the gap (`onReconnect`); the publisher
158
+ // is told here.
159
+ if (!client.connected) {
160
+ throw new TransportUnavailableError({
161
+ transport: this.name,
162
+ reason: `publish to ${subject} was refused: the client is reconnecting to the bus`,
163
+ });
164
+ }
97
165
  // `client.publish` is synchronous and refuses locally: a bad subject, a payload over the
98
166
  // server's `max_payload`, a connection torn down between the `#ensure` and this line. Those
99
167
  // are the LIBRARY's errors — or an app-supplied `connect`'s — so they arrive uncoded, and
@@ -167,7 +235,9 @@ export class NatsTransport implements Transport {
167
235
  return Promise.reject(
168
236
  new TransportUnavailableError({
169
237
  transport: this.name,
170
- reason: `the connection closed once its reconnect budget was spent, and re-dial attempt ${this.#redialing} is in progress`,
238
+ reason: this.#everUp
239
+ ? `the connection closed once its reconnect budget was spent, and re-dial attempt ${this.#redialing} is in progress`
240
+ : `the bus has not connected yet, and dial attempt ${this.#redialing} is in progress`,
171
241
  }),
172
242
  );
173
243
  }
@@ -197,6 +267,7 @@ export class NatsTransport implements Transport {
197
267
  url: this.#options.url,
198
268
  name: 'ultimate',
199
269
  maxReconnectAttempts: this.#attempts,
270
+ connectTimeoutMs: this.#dialTimeoutMs,
200
271
  // The library retries; the spread is ours, so a cluster restart does not bring every node
201
272
  // back on the same millisecond.
202
273
  reconnectDelay: () => policyDelay(this.#backoff, ++this.#retries, this.#rng),
@@ -208,7 +279,7 @@ export class NatsTransport implements Transport {
208
279
  },
209
280
  });
210
281
  try {
211
- await this.#ensureBucket(client);
282
+ if (!this.#lazyBucket) await this.#ensureBucket(client);
212
283
  if (closedEarly) {
213
284
  throw new TransportUnavailableError({
214
285
  transport: this.name,
@@ -229,10 +300,21 @@ export class NatsTransport implements Transport {
229
300
  }
230
301
  opened = client;
231
302
  this.#client = client;
303
+ this.#everUp = true;
232
304
  this.#retries = 0;
233
305
  return client;
234
306
  }
235
307
 
308
+ /** `shared`'s client: under `'first-use'` this is where the bucket is asserted, once per client. */
309
+ async #presenceClient(): Promise<NatsClient> {
310
+ const client = await this.#ensure();
311
+ if (this.#lazyBucket && !this.#bucketed.has(client)) {
312
+ await this.#ensureBucket(client);
313
+ this.#bucketed.add(client);
314
+ }
315
+ return client;
316
+ }
317
+
236
318
  /**
237
319
  * The library spent its reconnect budget and closed the client. Handing it out again is a node
238
320
  * that refuses every publish until somebody restarts it — and the liveness probe never asks the
@@ -253,18 +335,24 @@ export class NatsTransport implements Transport {
253
335
  }
254
336
 
255
337
  /**
256
- * Re-dial until a client is up or the transport is closed. Bounded by our backoff between
257
- * attempts (capped at the policy's `maxMs`), and each attempt is itself a library dial with its
258
- * own budget, so a long outage costs one dial per interval rather than a spin. A dial that lands
259
- * re-binds every kept subscription and is announced: changes published in the gap are gone.
338
+ * Dial until a client is up or the transport is closed — the first dial of a process that does
339
+ * not wait for the bus, and the replacement of a client the library gave up on. One loop however
340
+ * many callers ask. Bounded by our backoff between attempts (capped at the policy's `maxMs`), so
341
+ * a long outage costs one dial per interval rather than a spin. A dial that lands re-binds every
342
+ * kept subscription and is announced: changes published in the gap are gone.
260
343
  */
261
- async #redial(): Promise<void> {
262
- if (this.#redialing !== undefined) return;
344
+ #redial(): Promise<void> {
345
+ this.#redialLoop ??= this.#redialUntilUp();
346
+ return this.#redialLoop;
347
+ }
348
+
349
+ async #redialUntilUp(): Promise<void> {
263
350
  try {
264
351
  for (let attempt = 1; !this.#closed && this.#client === undefined; attempt += 1) {
265
352
  this.#redialing = attempt;
266
353
  try {
267
354
  const client = await this.#dialOnce();
355
+ this.#lastFailure = undefined;
268
356
  // Coded like a first subscribe's refusal; the subscription stays kept for the next client.
269
357
  for (const failure of this.#subscriptions.bindAll(client)) {
270
358
  this.#report(
@@ -275,12 +363,19 @@ export class NatsTransport implements Transport {
275
363
  this.#announce();
276
364
  } catch (error) {
277
365
  if (this.#closed) return;
278
- this.#report(error, this.name);
366
+ this.#lastFailure = renderThrowable(error);
367
+ // Attempts 1, 2, 4, 8, …: an outage is said at once and then ever more rarely, rather
368
+ // than once per dial per pod for as long as it lasts.
369
+ if ((attempt & (attempt - 1)) === 0) this.#report(error, this.name);
279
370
  await this.#pause(policyDelay(this.#backoff, attempt, this.#rng));
280
371
  }
281
372
  }
282
373
  } finally {
374
+ // Both, HERE, in the loop's own last synchronous step — never in a reaction to its promise.
375
+ // A reaction runs a microtask later, and a client lost in that gap (`#lost`) was handed the
376
+ // finished loop back: nothing dialled again and its subscriptions stayed orphaned.
283
377
  this.#redialing = undefined;
378
+ this.#redialLoop = undefined;
284
379
  }
285
380
  }
286
381
 
@@ -319,7 +414,9 @@ export class NatsTransport implements Transport {
319
414
  const client = this.#client;
320
415
  if (client === undefined) return;
321
416
  // Not awaited: the library calls this from its status loop. The failure has one place to go.
322
- void this.#ensureBucket(client).catch((error: unknown) => this.#report(error, this.name));
417
+ // A lazy bucket is forgotten instead, and asserted again by whoever next reads `shared`.
418
+ if (this.#lazyBucket) this.#bucketed.delete(client);
419
+ else void this.#ensureBucket(client).catch((error: unknown) => this.#report(error, this.name));
323
420
  this.#announce();
324
421
  }
325
422
 
@@ -364,8 +461,16 @@ export class NatsTransport implements Transport {
364
461
  #report(error: unknown, subject: string): void {
365
462
  const handler = this.#options.onError;
366
463
  if (handler !== undefined) {
367
- handler(error, subject);
368
- return;
464
+ // Total: the handler is injected, and its throw would land in whatever called this — the
465
+ // dial loop (started with `void`: an unhandled rejection and no more retries), the
466
+ // library's status loop, a subscriber's delivery. A reporter that cannot report is not a
467
+ // reason to stop dialling; the fault falls through to the logger line below.
468
+ try {
469
+ handler(error, subject);
470
+ return;
471
+ } catch (thrown) {
472
+ logger.error('nats transport onError threw', { error: renderThrowable(thrown) });
473
+ }
369
474
  }
370
475
  logger.error('nats transport error', {
371
476
  transport: this.name,
package/src/pg-array.ts CHANGED
@@ -1,10 +1,10 @@
1
- // The Postgres array TEXT literal — `{a,b}`, `{"a,b",NULL}`, `{{1,2},{3,4}}` — and the element
2
- // type behind an array oid. Split from `pg-values.ts` so that file stays the oid switch and this
3
- // one stays the grammar; the element decoder arrives as a parameter, so neither imports the other.
1
+ // The element type behind an array type oid. The WAL names only the ARRAY type and carries the
2
+ // value as its TEXT literal (`{a,b}`), while `@ultimat3/entity`'s repository hands an `arrayOf()`
3
+ // column back as a JS array — and a live row and a repository row have to be the same object.
4
4
  //
5
- // It exists because `@ultimat3/entity`'s repository hands an `arrayOf()` column back as a JS array
6
- // (the driver parses the literal) while the WAL carries the literal itself. A live row and a
7
- // repository row have to be the same object, so the literal is parsed here rather than shipped.
5
+ // The GRAMMAR of that literal is `@ultimat3/entity`'s `parsePgArray`: the repository reads the
6
+ // same text when a driver leaves an array unparsed, and one grammar has one reader. This file
7
+ // keeps the half only the WAL needs — which element decoder an oid selects.
8
8
 
9
9
  /**
10
10
  * `text[]` -> `text`. The wire names only the ARRAY type, so the element type behind it is a
@@ -41,101 +41,3 @@ const ELEMENT_OF = Object.freeze<Record<number, number>>({
41
41
  export function arrayElementOid(typeOid: number): number | undefined {
42
42
  return Object.hasOwn(ELEMENT_OF, typeOid) ? ELEMENT_OF[typeOid] : undefined;
43
43
  }
44
-
45
- /** Where the scan is, so every branch below advances one cursor rather than slicing the text. */
46
- interface Scan {
47
- readonly text: string;
48
- at: number;
49
- }
50
-
51
- /**
52
- * One array literal -> a nested JS array, or `null` when the text is not one this grammar
53
- * describes. `null` is not an error: a dimension prefix (`[0:2]={…}`), a `DateStyle` this decoder
54
- * does not read, or a corrupted literal all mean the same thing to the caller — keep the text it
55
- * arrived as rather than deliver an array that is missing a member.
56
- *
57
- * `decode` is the ELEMENT decoder. It never sees a quoted element's quotes or its backslash
58
- * escapes: an unquoted `NULL` is the null value and a quoted `"NULL"` is the four-character
59
- * string, which is the one distinction the quoting exists to carry.
60
- */
61
- export function parsePgArray<T>(text: string, decode: (raw: string) => T): PgArray<T> | null {
62
- const scan: Scan = { text, at: 0 };
63
- const parsed = readArray(scan, decode);
64
- // Trailing content means the literal was not what this grammar accepted, whatever it parsed.
65
- return parsed === null || scan.at !== text.length ? null : parsed;
66
- }
67
-
68
- /** A member is a decoded value, the null member, or — for a multidimensional array — a row of them. */
69
- export type PgArray<T> = (T | null | PgArray<T>)[];
70
-
71
- function readArray<T>(scan: Scan, decode: (raw: string) => T): PgArray<T> | null {
72
- if (scan.text[scan.at] !== '{') return null;
73
- scan.at += 1;
74
- const out: PgArray<T> = [];
75
- if (scan.text[scan.at] === '}') {
76
- scan.at += 1;
77
- return out;
78
- }
79
- for (;;) {
80
- const member = readMember(scan, decode);
81
- if (member === FAILED) return null;
82
- out.push(member);
83
- const next = scan.text[scan.at];
84
- scan.at += 1;
85
- if (next === '}') return out;
86
- if (next !== ',') return null;
87
- }
88
- }
89
-
90
- /** A sentinel, because `null` is a legal member and `undefined` would be a second spelling of it. */
91
- const FAILED = Symbol('pg-array-failed');
92
-
93
- function readMember<T>(
94
- scan: Scan,
95
- decode: (raw: string) => T,
96
- ): T | null | PgArray<T> | typeof FAILED {
97
- const head = scan.text[scan.at];
98
- if (head === '{') {
99
- const nested = readArray(scan, decode);
100
- return nested === null ? FAILED : nested;
101
- }
102
- if (head === '"') {
103
- const quoted = readQuoted(scan);
104
- return quoted === null ? FAILED : decode(quoted);
105
- }
106
- const start = scan.at;
107
- while (scan.at < scan.text.length) {
108
- const char = scan.text[scan.at];
109
- if (char === ',' || char === '}') break;
110
- // A brace or a quote inside a bare element is a literal this grammar does not describe.
111
- if (char === '{' || char === '"') return FAILED;
112
- scan.at += 1;
113
- }
114
- if (scan.at === scan.text.length) return FAILED;
115
- const raw = scan.text.slice(start, scan.at);
116
- // Unquoted and case-insensitive is the ONLY spelling of the null member; `"NULL"` is a string.
117
- return raw.toUpperCase() === 'NULL' ? null : decode(raw);
118
- }
119
-
120
- /** The text between one pair of quotes, with `\\` and `\"` unescaped. `null` if it never closes. */
121
- function readQuoted(scan: Scan): string | null {
122
- scan.at += 1;
123
- let out = '';
124
- while (scan.at < scan.text.length) {
125
- const char = scan.text[scan.at];
126
- if (char === '"') {
127
- scan.at += 1;
128
- return out;
129
- }
130
- if (char === '\\') {
131
- const escaped = scan.text[scan.at + 1];
132
- if (escaped === undefined) return null;
133
- out += escaped;
134
- scan.at += 2;
135
- continue;
136
- }
137
- out += char ?? '';
138
- scan.at += 1;
139
- }
140
- return null;
141
- }
package/src/pg-values.ts CHANGED
@@ -11,9 +11,10 @@
11
11
  // in the component the first patch reached.
12
12
 
13
13
  import { renderThrowable } from '@ultimat3/core';
14
+ import { parsePgArray } from '@ultimat3/entity';
14
15
  import { ReplicationProtocolError } from './errors';
15
16
  import type { JsonValue } from './json';
16
- import { arrayElementOid, parsePgArray } from './pg-array';
17
+ import { arrayElementOid } from './pg-array';
17
18
 
18
19
  /**
19
20
  * What a row value can be between the WAL and the wire: JSON, plus the two JS shapes a repository
package/src/replicator.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // its feed and reports `/readyz` false. A stream that ENDS — or a lock that is LOST — is this file's
12
12
  // to answer: `running` goes false, everything is let go, and the process asks again on a backoff.
13
13
 
14
- import { logger, renderThrowable, uuidV7, withSpan } from '@ultimat3/core';
14
+ import { isUltimateError, logger, renderThrowable, uuidV7, withSpan } from '@ultimat3/core';
15
15
  import type { AdvisoryLock } from './advisory-lock';
16
16
  import type { ChangeEvent, ChangeFeed } from './changefeed';
17
17
  import { ReplicationFailedError } from './errors';
@@ -100,6 +100,18 @@ interface Refusal {
100
100
  */
101
101
  export const STOP_DEADLINE_MS = 5_000;
102
102
 
103
+ /**
104
+ * How long a publish refused because the bus is AWAY is waited out, in the handler, before the run
105
+ * ends: three more attempts over two seconds. A NATS restart is a second or two, and ending the
106
+ * run for it costs a stream stop, an unlock, a backoff and a re-read from `lastLsn` — with
107
+ * `/readyz` false throughout. Longer than this is an outage, and the takeover loop's to handle.
108
+ */
109
+ export const PUBLISH_RETRY_DELAYS_MS: readonly number[] = [100, 400, 1_500];
110
+
111
+ /** The bus is unreachable — as opposed to a change it will never take (a payload over the limit). */
112
+ const busAway = (thrown: unknown): boolean =>
113
+ isUltimateError(thrown) && thrown.code === 'X_TRANSPORT_UNAVAILABLE';
114
+
103
115
  const fencedOut = (reason: string): ReplicationFailedError =>
104
116
  new ReplicationFailedError({
105
117
  stage: 'stream',
@@ -131,6 +143,8 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
131
143
  /** The run whose stream and lock still speak for this replicator. */
132
144
  let current: Run | undefined;
133
145
  let cancelRetry: (() => void) | undefined;
146
+ /** Ends the publish retry's wait in flight, if any — see `publishThroughBlip`. */
147
+ let endBlipWait: (() => void) | undefined;
134
148
  /** Stops listening for the lock being lost. Held only while this replicator holds the lock. */
135
149
  let unwatchLock: (() => void) | undefined;
136
150
  const unwatch = (): void => {
@@ -180,6 +194,39 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
180
194
  return outcome.how === 'failed' ? outcome.error : undefined;
181
195
  };
182
196
 
197
+ /**
198
+ * One envelope, published — retried on `PUBLISH_RETRY_DELAYS_MS` while the bus is away. The SAME
199
+ * envelope each time, so a consumer sees one `seq`. Fenced after every wait: a run that ended
200
+ * meanwhile publishes nothing, for the reason the handler below gives.
201
+ */
202
+ const publishThroughBlip = async (run: Run, subject: string, envelope: string): Promise<void> => {
203
+ for (let retry = 0; ; retry += 1) {
204
+ try {
205
+ await options.transport.publish(subject, envelope);
206
+ return;
207
+ } catch (thrown) {
208
+ const delay = PUBLISH_RETRY_DELAYS_MS[retry];
209
+ if (delay === undefined || !busAway(thrown)) throw thrown;
210
+ await new Promise<void>((resolve) => {
211
+ const cancel = schedule(() => {
212
+ endBlipWait = undefined;
213
+ resolve();
214
+ }, delay);
215
+ // Held so `stop()` can end the wait: the timer is cleared AND the wait settles, into the
216
+ // fence below. A stop that only waited it out held the feed's handler for up to 1.5 s.
217
+ endBlipWait = (): void => {
218
+ endBlipWait = undefined;
219
+ cancel();
220
+ resolve();
221
+ };
222
+ });
223
+ if (current !== run || run.over !== null) {
224
+ throw fencedOut(run.over ?? 'a newer run started');
225
+ }
226
+ }
227
+ }
228
+ };
229
+
183
230
  const changeHandler =
184
231
  (run: Run) =>
185
232
  async (raw: ChangeEvent): Promise<void> => {
@@ -200,7 +247,7 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
200
247
  await withSpan('realtime.replicate', async () => {
201
248
  seq += 1;
202
249
  try {
203
- await options.transport.publish(subjectOf(change), encodeEnvelope(change, seq, producer));
250
+ await publishThroughBlip(run, subjectOf(change), encodeEnvelope(change, seq, producer));
204
251
  } catch (thrown) {
205
252
  const error = renderThrowable(thrown);
206
253
  const same = refusal !== null && refusal.lsn === change.lsn;
@@ -391,6 +438,8 @@ export function changeFeedReplicator(options: ReplicatorOptions): Replicator {
391
438
  pumping = false;
392
439
  if (current !== undefined) current.over ??= 'the replicator was stopped';
393
440
  current = undefined;
441
+ // After the run is marked over, so the wait it ends lands in the fence and publishes nothing.
442
+ endBlipWait?.();
394
443
  unwatch();
395
444
  // Whatever the feed said: a lock kept because the stream would not close politely is a
396
445
  // slot no standby can take. Its refusal is still the one the caller is owed.
package/src/server.ts CHANGED
@@ -256,6 +256,8 @@ export {
256
256
  reconnectFrame,
257
257
  } from './thundering-herd';
258
258
  export {
259
+ BUS_CONNECT_WAIT_MS,
260
+ type BusUse,
259
261
  DEFAULT_PRESENCE_BUCKET,
260
262
  DEFAULT_PRESENCE_TTL_MS,
261
263
  type SelectTransportOptions,
@@ -11,6 +11,7 @@ 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
+ import type { BackoffPolicy } from './thundering-herd';
14
15
 
15
16
  /**
16
17
  * The keys read here, and nothing else. Named once so docs and tests cannot drift from the code.
@@ -36,6 +37,27 @@ export const DEFAULT_PRESENCE_TTL_MS = 30_000;
36
37
 
37
38
  export type TransportEnvironment = Readonly<Record<string, string | undefined>>;
38
39
 
40
+ /**
41
+ * What this process does with the bus, which decides whether its boot waits for it:
42
+ *
43
+ * - `'sockets'` — it serves sockets from the bus (a `sync` node). The dial and the presence bucket
44
+ * are awaited, and a bus that is not there within `connectWithinMs` refuses the boot.
45
+ * - `'feed'` — it feeds those nodes (the replicator). The dial is awaited; it reads no presence,
46
+ * so it asks JetStream for nothing.
47
+ * - `'publish'` — it only publishes channel events and cache invalidations (`web`, `worker`,
48
+ * `scheduler`). The dial runs in the background and the boot does not wait: both kinds of
49
+ * message mean "re-read", and a page that cannot say it is still a page.
50
+ */
51
+ export type BusUse = 'sockets' | 'feed' | 'publish';
52
+
53
+ /** How long a process that cannot work without the bus waits for it before refusing its boot. */
54
+ export const BUS_CONNECT_WAIT_MS = 15_000;
55
+
56
+ const NEEDS: Record<Exclude<BusUse, 'publish'>, string> = {
57
+ sockets: 'serves sockets from the bus',
58
+ feed: 'feeds the sync nodes over the bus',
59
+ };
60
+
39
61
  export interface TransportSelection {
40
62
  readonly transport: Transport;
41
63
  /** `embedded` fans out in this process only; `external` reaches every node on the bus. */
@@ -50,12 +72,18 @@ export interface TransportSelection {
50
72
  readonly bucket: string | null;
51
73
  /** What `PresenceRegistry` must be given, so its TTL and the bucket's cannot disagree. */
52
74
  readonly presenceTtlMs: number;
75
+ /** What the process does with the bus, as `connect()` and `state()` obey it. */
76
+ readonly use: BusUse;
53
77
  /**
54
78
  * Dial now rather than on the first change nobody receives. Selection itself stays pure — it
55
79
  * parses env and constructs, it does not touch a socket — so a boot can order its dials.
56
- * Embedded resolves immediately: there is nothing to reach.
80
+ * Embedded resolves immediately: there is nothing to reach. Under `use: 'publish'` it STARTS the
81
+ * dial and resolves at once; under the other two it resolves connected or rejects with
82
+ * `X_TRANSPORT_UNAVAILABLE` inside `connectWithinMs`.
57
83
  */
58
84
  connect(): Promise<void>;
85
+ /** `'connecting'` while a NATS bus is not reachable; the in-process bus is always `'up'`. */
86
+ state(): 'up' | 'connecting';
59
87
  }
60
88
 
61
89
  export interface SelectTransportOptions {
@@ -63,6 +91,14 @@ export interface SelectTransportOptions {
63
91
  readonly clock?: Clock | undefined;
64
92
  /** Injected so a boot — reconnect included — can be proven with no network. */
65
93
  readonly connect?: NatsConnect | undefined;
94
+ /** Default `'sockets'`: a caller that does not say is held to the strictest reading. */
95
+ readonly use?: BusUse | undefined;
96
+ /** Default `BUS_CONNECT_WAIT_MS`. Read under `'sockets'` and `'feed'` only. */
97
+ readonly connectWithinMs?: number | undefined;
98
+ /** The dial's retry curve; the transport's own default when unset. */
99
+ readonly backoff?: BackoffPolicy | undefined;
100
+ /** Where the transport's background failures go; its own logger line when unset. */
101
+ readonly onError?: ((error: unknown, subject: string) => void) | undefined;
66
102
  }
67
103
 
68
104
  const nonEmpty = (value: string | undefined): string | undefined =>
@@ -92,6 +128,7 @@ export function selectTransport(
92
128
  'presenceTtlMs',
93
129
  options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS,
94
130
  );
131
+ const use = options.use ?? 'sockets';
95
132
 
96
133
  if (topology.transport === 'memory') {
97
134
  refuseStrayBusUrl(env, topology);
@@ -104,7 +141,9 @@ export function selectTransport(
104
141
  detail: `in-process fanout — set realtime.transport 'nats' in ${CONFIG_FILE} and NATS_URL to reach the other nodes`,
105
142
  bucket: null,
106
143
  presenceTtlMs,
144
+ use,
107
145
  connect: () => Promise.resolve(),
146
+ state: () => 'up',
108
147
  };
109
148
  }
110
149
 
@@ -124,16 +163,37 @@ export function selectTransport(
124
163
  url,
125
164
  bucket,
126
165
  presenceTtlMs,
166
+ // Only a node that serves presence needs the bucket to exist before it can start.
167
+ presenceBucket: use === 'sockets' ? 'dial' : 'first-use',
127
168
  ...(options.clock === undefined ? {} : { clock: options.clock }),
128
169
  ...(options.connect === undefined ? {} : { connect: options.connect }),
170
+ ...(options.backoff === undefined ? {} : { backoff: options.backoff }),
171
+ ...(options.onError === undefined ? {} : { onError: options.onError }),
129
172
  });
173
+ const withinMs = finiteOption(
174
+ 'the transport env',
175
+ 'connectWithinMs',
176
+ options.connectWithinMs ?? BUS_CONNECT_WAIT_MS,
177
+ );
130
178
  return {
131
179
  transport,
132
180
  mode: 'external',
133
181
  detail: urlEnv,
134
182
  bucket,
135
183
  presenceTtlMs,
136
- connect: () => transport.connect(),
184
+ use,
185
+ connect: async () => {
186
+ if (use === 'publish') {
187
+ transport.connectInBackground();
188
+ return;
189
+ }
190
+ await transport.connect({
191
+ withinMs,
192
+ // The variable, never the url behind it. `-js` only where the bucket is asked for.
193
+ fix: `x doctor — then check ${urlEnv} names a reachable nats-server${use === 'sockets' ? ' started with -js (JetStream)' : ''} — this process ${NEEDS[use]} and cannot start without it; the web, worker and scheduler roles boot without one and connect in the background`,
194
+ });
195
+ },
196
+ state: () => (transport.connected ? 'up' : 'connecting'),
137
197
  };
138
198
  }
139
199