@ultimat3/realtime 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/package.json +36 -0
  4. package/src/change-buffer.ts +69 -0
  5. package/src/changefeed-env.ts +146 -0
  6. package/src/changefeed.ts +191 -0
  7. package/src/channel.ts +181 -0
  8. package/src/client.ts +439 -0
  9. package/src/cursor.ts +188 -0
  10. package/src/errors.ts +253 -0
  11. package/src/fanout.ts +159 -0
  12. package/src/hooks.ts +230 -0
  13. package/src/index.ts +328 -0
  14. package/src/json.ts +76 -0
  15. package/src/live-definition.ts +144 -0
  16. package/src/live-query.ts +449 -0
  17. package/src/local-store.ts +188 -0
  18. package/src/matcher-bridge.ts +169 -0
  19. package/src/nats-commands.ts +97 -0
  20. package/src/nats-connection-fixture.ts +105 -0
  21. package/src/nats-connection.ts +464 -0
  22. package/src/nats-fake.ts +431 -0
  23. package/src/nats-jetstream.ts +226 -0
  24. package/src/nats-kv.ts +157 -0
  25. package/src/nats-protocol.ts +222 -0
  26. package/src/nats-socket.ts +236 -0
  27. package/src/nats-transport.ts +257 -0
  28. package/src/offline-queue.ts +206 -0
  29. package/src/pg-advisory-lock.ts +98 -0
  30. package/src/pg-auth.ts +300 -0
  31. package/src/pg-bytes.ts +185 -0
  32. package/src/pg-connection-fixture.ts +215 -0
  33. package/src/pg-connection.ts +337 -0
  34. package/src/pg-entity-row.ts +130 -0
  35. package/src/pg-replication-fixture.ts +261 -0
  36. package/src/pg-replication.ts +396 -0
  37. package/src/pg-socket.ts +265 -0
  38. package/src/pg-wire.ts +192 -0
  39. package/src/pgoutput.ts +297 -0
  40. package/src/policy-gate.ts +56 -0
  41. package/src/presence.ts +219 -0
  42. package/src/rebase.ts +198 -0
  43. package/src/replicator.ts +185 -0
  44. package/src/socket.ts +208 -0
  45. package/src/sync-node.ts +400 -0
  46. package/src/sync-protocol.ts +376 -0
  47. package/src/thundering-herd.ts +141 -0
  48. package/src/transport-env.ts +104 -0
@@ -0,0 +1,376 @@
1
+ // The wire. One protocol for all three tiers: a channel subscribe, a live-query subscribe, and an
2
+ // offline mutation drain are frames in the same union. Moving a route from tier 2 to tier 3 is a
3
+ // config flag (`persist: true`), never a new protocol — that promise is enforced here.
4
+
5
+ import type { LiveCursor } from './cursor';
6
+ import { ProtocolVersionError } from './errors';
7
+ import {
8
+ isJsonObject,
9
+ isRow,
10
+ type JsonObject,
11
+ type JsonValue,
12
+ type Row,
13
+ type RowPatch,
14
+ } from './json';
15
+
16
+ export const PROTOCOL_VERSION = 1;
17
+
18
+ export type ConflictStrategyName = 'server-wins' | 'last-write-wins' | 'custom';
19
+
20
+ export interface WireError {
21
+ readonly code: string;
22
+ readonly cause: string;
23
+ readonly fix: string;
24
+ readonly docs?: string;
25
+ }
26
+
27
+ export interface PresenceMember {
28
+ readonly id: string;
29
+ readonly actorId: string | null;
30
+ readonly meta: JsonObject;
31
+ /** Client-supplied logical time; last write wins per member on ties-free comparison. */
32
+ readonly updatedAt: number;
33
+ }
34
+
35
+ export type SubscribeTarget =
36
+ | { readonly kind: 'topic'; readonly topic: string }
37
+ | {
38
+ /**
39
+ * Client -> server, `qid` carries the *query name*; the server derives the real qid from
40
+ * (name, input) so a client can never pick its own fanout key. Server -> client it is the
41
+ * derived qid, which is also what the cursor is keyed by.
42
+ */
43
+ readonly kind: 'query';
44
+ readonly qid: string;
45
+ readonly input: JsonValue;
46
+ readonly cursor: LiveCursor | null;
47
+ };
48
+
49
+ export interface HelloFrame {
50
+ readonly type: 'hello';
51
+ readonly v: number;
52
+ readonly buildId: string;
53
+ /** Server-assigned on the reply, `null` on the client's opening frame. */
54
+ readonly sessionId: string | null;
55
+ readonly actorId: string | null;
56
+ readonly resume: readonly LiveCursor[];
57
+ }
58
+
59
+ export interface SubscribeFrame {
60
+ readonly type: 'subscribe';
61
+ readonly v: number;
62
+ readonly op: 'add' | 'drop';
63
+ readonly sid: string;
64
+ readonly target: SubscribeTarget;
65
+ }
66
+
67
+ export interface SnapshotFrame {
68
+ readonly type: 'snapshot';
69
+ readonly v: number;
70
+ readonly sid: string;
71
+ readonly rows: readonly Row[];
72
+ readonly cursor: LiveCursor;
73
+ }
74
+
75
+ export interface PatchFrame {
76
+ readonly type: 'patch';
77
+ readonly v: number;
78
+ readonly sid: string;
79
+ readonly patches: readonly RowPatch[];
80
+ readonly lsn: string;
81
+ }
82
+
83
+ export interface MutateFrame {
84
+ readonly type: 'mutate';
85
+ readonly v: number;
86
+ /** Idempotency key. The server collapses repeats; the client never renumbers. */
87
+ readonly key: string;
88
+ readonly seq: number;
89
+ readonly name: string;
90
+ readonly input: JsonValue;
91
+ }
92
+
93
+ export interface AckFrame {
94
+ readonly type: 'ack';
95
+ readonly v: number;
96
+ /** Mutation key or subscription id being acknowledged. */
97
+ readonly ref: string;
98
+ readonly lsn: string | null;
99
+ readonly error: WireError | null;
100
+ }
101
+
102
+ export interface RebaseFrame {
103
+ readonly type: 'rebase';
104
+ readonly v: number;
105
+ readonly key: string;
106
+ readonly entity: string;
107
+ readonly strategy: ConflictStrategyName;
108
+ /** Server truth for the row the mutation touched; `null` when the server deleted it. */
109
+ readonly row: Row | null;
110
+ }
111
+
112
+ export interface PresenceFrame {
113
+ readonly type: 'presence';
114
+ readonly v: number;
115
+ readonly topic: string;
116
+ readonly op: 'join' | 'leave' | 'update' | 'sync';
117
+ readonly members: readonly PresenceMember[];
118
+ }
119
+
120
+ export interface ReconnectFrame {
121
+ readonly type: 'reconnect';
122
+ readonly v: number;
123
+ /** Server-assigned delay. Clients must honour it so a drain redistributes instead of stampeding. */
124
+ readonly afterMs: number;
125
+ readonly reason: 'drain' | 'overload' | 'rebalance';
126
+ }
127
+
128
+ export interface UpdateAvailableFrame {
129
+ readonly type: 'update-available';
130
+ readonly v: number;
131
+ readonly buildId: string;
132
+ }
133
+
134
+ export type Frame =
135
+ | HelloFrame
136
+ | SubscribeFrame
137
+ | SnapshotFrame
138
+ | PatchFrame
139
+ | MutateFrame
140
+ | AckFrame
141
+ | RebaseFrame
142
+ | PresenceFrame
143
+ | ReconnectFrame
144
+ | UpdateAvailableFrame;
145
+
146
+ export type FrameKind = Frame['type'];
147
+
148
+ export const FRAME_KINDS: readonly FrameKind[] = [
149
+ 'hello',
150
+ 'subscribe',
151
+ 'snapshot',
152
+ 'patch',
153
+ 'mutate',
154
+ 'ack',
155
+ 'rebase',
156
+ 'presence',
157
+ 'reconnect',
158
+ 'update-available',
159
+ ];
160
+
161
+ export function encode(frame: Frame): string {
162
+ return JSON.stringify(frame);
163
+ }
164
+
165
+ /** Narrow `unknown` to a `Frame` or throw `X_PROTOCOL_VERSION`. No frame is trusted unvalidated. */
166
+ export function decode(raw: string | Uint8Array): Frame {
167
+ const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
168
+ let parsed: unknown;
169
+ try {
170
+ parsed = JSON.parse(text);
171
+ } catch {
172
+ throw fail('frame is not JSON');
173
+ }
174
+ if (!isJsonObject(parsed)) throw fail('frame is not an object');
175
+ const version = parsed['v'];
176
+ if (version !== PROTOCOL_VERSION) {
177
+ throw new ProtocolVersionError({ got: version, expected: PROTOCOL_VERSION });
178
+ }
179
+ const kind = parsed['type'];
180
+ switch (kind) {
181
+ case 'hello':
182
+ return {
183
+ type: 'hello',
184
+ v: PROTOCOL_VERSION,
185
+ buildId: str(parsed, 'buildId'),
186
+ sessionId: nullableStr(parsed, 'sessionId'),
187
+ actorId: nullableStr(parsed, 'actorId'),
188
+ resume: list(parsed, 'resume').map(cursor),
189
+ };
190
+ case 'subscribe':
191
+ return {
192
+ type: 'subscribe',
193
+ v: PROTOCOL_VERSION,
194
+ op: pick(parsed, 'op', ['add', 'drop'] as const),
195
+ sid: str(parsed, 'sid'),
196
+ target: target(parsed['target']),
197
+ };
198
+ case 'snapshot':
199
+ return {
200
+ type: 'snapshot',
201
+ v: PROTOCOL_VERSION,
202
+ sid: str(parsed, 'sid'),
203
+ rows: list(parsed, 'rows').map(row),
204
+ cursor: cursor(parsed['cursor']),
205
+ };
206
+ case 'patch':
207
+ return {
208
+ type: 'patch',
209
+ v: PROTOCOL_VERSION,
210
+ sid: str(parsed, 'sid'),
211
+ patches: list(parsed, 'patches').map(patch),
212
+ lsn: str(parsed, 'lsn'),
213
+ };
214
+ case 'mutate':
215
+ return {
216
+ type: 'mutate',
217
+ v: PROTOCOL_VERSION,
218
+ key: str(parsed, 'key'),
219
+ seq: num(parsed, 'seq'),
220
+ name: str(parsed, 'name'),
221
+ input: parsed['input'] ?? null,
222
+ };
223
+ case 'ack':
224
+ return {
225
+ type: 'ack',
226
+ v: PROTOCOL_VERSION,
227
+ ref: str(parsed, 'ref'),
228
+ lsn: nullableStr(parsed, 'lsn'),
229
+ error: wireError(parsed['error']),
230
+ };
231
+ case 'rebase':
232
+ return {
233
+ type: 'rebase',
234
+ v: PROTOCOL_VERSION,
235
+ key: str(parsed, 'key'),
236
+ entity: str(parsed, 'entity'),
237
+ strategy: pick(parsed, 'strategy', ['server-wins', 'last-write-wins', 'custom'] as const),
238
+ row: parsed['row'] === null ? null : row(parsed['row']),
239
+ };
240
+ case 'presence':
241
+ return {
242
+ type: 'presence',
243
+ v: PROTOCOL_VERSION,
244
+ topic: str(parsed, 'topic'),
245
+ op: pick(parsed, 'op', ['join', 'leave', 'update', 'sync'] as const),
246
+ members: list(parsed, 'members').map(member),
247
+ };
248
+ case 'reconnect':
249
+ return {
250
+ type: 'reconnect',
251
+ v: PROTOCOL_VERSION,
252
+ afterMs: num(parsed, 'afterMs'),
253
+ reason: pick(parsed, 'reason', ['drain', 'overload', 'rebalance'] as const),
254
+ };
255
+ case 'update-available':
256
+ return { type: 'update-available', v: PROTOCOL_VERSION, buildId: str(parsed, 'buildId') };
257
+ default:
258
+ throw fail(`unknown frame type ${JSON.stringify(kind)}`);
259
+ }
260
+ }
261
+
262
+ /** Project any thrown value onto the wire without losing the error contract's three fields. */
263
+ export function toWireError(error: unknown): WireError {
264
+ const shape = error as { code?: unknown; cause?: unknown; fix?: unknown; docs?: unknown } | null;
265
+ const code = typeof shape?.code === 'string' ? shape.code : 'X_PROTOCOL_VERSION';
266
+ const cause = typeof shape?.cause === 'string' ? shape.cause : String(error);
267
+ const fix = typeof shape?.fix === 'string' ? shape.fix : 'x doctor realtime';
268
+ return typeof shape?.docs === 'string'
269
+ ? { code, cause, fix, docs: shape.docs }
270
+ : { code, cause, fix };
271
+ }
272
+
273
+ function fail(detail: string): ProtocolVersionError {
274
+ return new ProtocolVersionError({ got: detail, expected: PROTOCOL_VERSION, detail });
275
+ }
276
+
277
+ function str(obj: JsonObject, key: string): string {
278
+ const value = obj[key];
279
+ if (typeof value !== 'string') throw fail(`field "${key}" must be a string`);
280
+ return value;
281
+ }
282
+
283
+ function nullableStr(obj: JsonObject, key: string): string | null {
284
+ const value = obj[key];
285
+ if (value === null || value === undefined) return null;
286
+ if (typeof value !== 'string') throw fail(`field "${key}" must be a string or null`);
287
+ return value;
288
+ }
289
+
290
+ function num(obj: JsonObject, key: string): number {
291
+ const value = obj[key];
292
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
293
+ throw fail(`field "${key}" must be a finite number`);
294
+ }
295
+ return value;
296
+ }
297
+
298
+ function pick<T extends string>(obj: JsonObject, key: string, allowed: readonly T[]): T {
299
+ const value = str(obj, key);
300
+ const found = allowed.find((candidate) => candidate === value);
301
+ if (found === undefined) throw fail(`field "${key}" must be one of ${allowed.join('|')}`);
302
+ return found;
303
+ }
304
+
305
+ function list(obj: JsonObject, key: string): JsonValue[] {
306
+ const value = obj[key];
307
+ if (value === undefined || value === null) return [];
308
+ if (!Array.isArray(value)) throw fail(`field "${key}" must be an array`);
309
+ return value;
310
+ }
311
+
312
+ function row(value: unknown): Row {
313
+ if (!isRow(value)) throw fail('row must be an object with a string "id"');
314
+ return value;
315
+ }
316
+
317
+ function cursor(value: unknown): LiveCursor {
318
+ if (!isJsonObject(value)) throw fail('cursor must be an object');
319
+ return {
320
+ qid: str(value, 'qid'),
321
+ lsn: str(value, 'lsn'),
322
+ digest: str(value, 'digest'),
323
+ ids: list(value, 'ids').map((id) => {
324
+ if (typeof id !== 'string') throw fail('cursor.ids must be strings');
325
+ return id;
326
+ }),
327
+ count: num(value, 'count'),
328
+ at: num(value, 'at'),
329
+ };
330
+ }
331
+
332
+ function patch(value: unknown): RowPatch {
333
+ if (!isJsonObject(value)) throw fail('patch must be an object');
334
+ const base = {
335
+ op: pick(value, 'op', ['insert', 'update', 'delete'] as const),
336
+ id: str(value, 'id'),
337
+ row: value['row'] === null || value['row'] === undefined ? null : object(value['row']),
338
+ lsn: str(value, 'lsn'),
339
+ };
340
+ return value['index'] === undefined ? base : { ...base, index: num(value, 'index') };
341
+ }
342
+
343
+ function member(value: unknown): PresenceMember {
344
+ if (!isJsonObject(value)) throw fail('presence member must be an object');
345
+ return {
346
+ id: str(value, 'id'),
347
+ actorId: nullableStr(value, 'actorId'),
348
+ meta: object(value['meta'] ?? {}),
349
+ updatedAt: num(value, 'updatedAt'),
350
+ };
351
+ }
352
+
353
+ function target(value: unknown): SubscribeTarget {
354
+ if (!isJsonObject(value)) throw fail('subscribe.target must be an object');
355
+ const kind = pick(value, 'kind', ['topic', 'query'] as const);
356
+ if (kind === 'topic') return { kind, topic: str(value, 'topic') };
357
+ return {
358
+ kind,
359
+ qid: str(value, 'qid'),
360
+ input: value['input'] ?? null,
361
+ cursor:
362
+ value['cursor'] === null || value['cursor'] === undefined ? null : cursor(value['cursor']),
363
+ };
364
+ }
365
+
366
+ function object(value: unknown): JsonObject {
367
+ if (!isJsonObject(value)) throw fail('expected a JSON object');
368
+ return value;
369
+ }
370
+
371
+ function wireError(value: unknown): WireError | null {
372
+ if (value === null || value === undefined) return null;
373
+ if (!isJsonObject(value)) throw fail('ack.error must be an object or null');
374
+ const base = { code: str(value, 'code'), cause: str(value, 'cause'), fix: str(value, 'fix') };
375
+ return value['docs'] === undefined ? base : { ...base, docs: str(value, 'docs') };
376
+ }
@@ -0,0 +1,141 @@
1
+ // The named risk, mitigated in code. A deploy drops N sockets at once; if every client reconnects
2
+ // immediately the rolling restart becomes a self-inflicted outage that outlasts the deploy.
3
+ //
4
+ // Three mechanisms, in the order they fire:
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
7
+ // 3. AcceptBudget — the receiving node's token bucket, so recovery sheds instead of collapsing
8
+
9
+ import { type Clock, systemClock } from '@ultimat3/core';
10
+ import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
11
+
12
+ /** Injected so tests are deterministic and `local` mutators stay replayable. */
13
+ export type Rng = () => number;
14
+
15
+ export type JitterMode = 'full' | 'equal' | 'none';
16
+
17
+ export interface BackoffPolicy {
18
+ readonly baseMs: number;
19
+ readonly maxMs: number;
20
+ readonly factor: number;
21
+ readonly jitter: JitterMode;
22
+ }
23
+
24
+ /**
25
+ * `full` jitter by default: it is the only mode that actually decorrelates a herd. `equal` keeps a
26
+ * floor for latency-sensitive clients; `none` exists for tests and is never a production choice.
27
+ */
28
+ export const defaultBackoff: BackoffPolicy = {
29
+ baseMs: 500,
30
+ maxMs: 30_000,
31
+ factor: 2,
32
+ jitter: 'full',
33
+ };
34
+
35
+ /** Attempt is 0-based. Result is always in `[0, maxMs]`. */
36
+ export function backoffDelay(
37
+ attempt: number,
38
+ policy: BackoffPolicy = defaultBackoff,
39
+ rng: Rng = Math.random,
40
+ ): number {
41
+ const ceiling = Math.min(policy.maxMs, policy.baseMs * policy.factor ** Math.max(0, attempt));
42
+ switch (policy.jitter) {
43
+ case 'none':
44
+ return Math.round(ceiling);
45
+ case 'equal':
46
+ return Math.round(ceiling / 2 + (rng() * ceiling) / 2);
47
+ case 'full':
48
+ return Math.round(rng() * ceiling);
49
+ }
50
+ }
51
+
52
+ export type ReconnectReason = 'drain' | 'overload' | 'rebalance';
53
+
54
+ export interface DrainPlanEntry {
55
+ readonly socketId: string;
56
+ readonly afterMs: number;
57
+ }
58
+
59
+ export interface DrainPlanOptions {
60
+ /** Window across which reconnects are spread. Must exceed the node's own drain grace period. */
61
+ readonly spreadMs?: number;
62
+ readonly rng?: Rng;
63
+ }
64
+
65
+ /**
66
+ * Slot assignment, not pure randomness: socket *i* of *n* is placed in its own `spreadMs/n` slot and
67
+ * jittered inside it. Pure randomness clusters; slots guarantee a uniform spread even for small n,
68
+ * which is what makes clients redistribute across the surviving nodes instead of all landing on one.
69
+ */
70
+ export function drainPlan(
71
+ socketIds: readonly string[],
72
+ options: DrainPlanOptions = {},
73
+ ): DrainPlanEntry[] {
74
+ const spreadMs = options.spreadMs ?? 30_000;
75
+ const rng = options.rng ?? Math.random;
76
+ const total = socketIds.length;
77
+ if (total === 0) return [];
78
+ const slot = spreadMs / total;
79
+ return socketIds.map((socketId, index) => ({
80
+ socketId,
81
+ afterMs: Math.round(index * slot + rng() * slot),
82
+ }));
83
+ }
84
+
85
+ export function reconnectFrame(afterMs: number, reason: ReconnectReason): Frame {
86
+ return { type: 'reconnect', v: PROTOCOL_VERSION, afterMs, reason };
87
+ }
88
+
89
+ export interface AcceptBudgetOptions {
90
+ /** Sustained accepts per second per node during recovery. */
91
+ readonly perSecond: number;
92
+ /** Burst allowance, so a normal reconnect trickle is never delayed. */
93
+ readonly burst?: number;
94
+ readonly clock?: Clock;
95
+ }
96
+
97
+ /**
98
+ * Token bucket on the accept path. A node that cannot afford a new socket must say so with a
99
+ * `reconnect` frame carrying a delay — refusing without a delay just moves the herd next door.
100
+ */
101
+ export class AcceptBudget {
102
+ readonly #perSecond: number;
103
+ readonly #burst: number;
104
+ readonly #clock: Clock;
105
+ #tokens: number;
106
+ #lastRefill: number;
107
+
108
+ constructor(options: AcceptBudgetOptions) {
109
+ this.#perSecond = Math.max(1, options.perSecond);
110
+ this.#burst = Math.max(1, options.burst ?? options.perSecond);
111
+ this.#clock = options.clock ?? systemClock;
112
+ this.#tokens = this.#burst;
113
+ this.#lastRefill = this.#clock.monotonic();
114
+ }
115
+
116
+ tryAccept(): boolean {
117
+ this.#refill();
118
+ if (this.#tokens < 1) return false;
119
+ this.#tokens -= 1;
120
+ return true;
121
+ }
122
+
123
+ /** Delay to hand a refused client, jittered so refusals do not re-synchronise the herd. */
124
+ retryAfterMs(rng: Rng = Math.random): number {
125
+ const base = Math.ceil(1000 / this.#perSecond);
126
+ return Math.round(base + rng() * base * 4);
127
+ }
128
+
129
+ get tokens(): number {
130
+ this.#refill();
131
+ return Math.floor(this.#tokens);
132
+ }
133
+
134
+ #refill(): void {
135
+ const now = this.#clock.monotonic();
136
+ const elapsed = now - this.#lastRefill;
137
+ if (elapsed <= 0) return;
138
+ this.#lastRefill = now;
139
+ this.#tokens = Math.min(this.#burst, this.#tokens + (elapsed / 1000) * this.#perSecond);
140
+ }
141
+ }
@@ -0,0 +1,104 @@
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:
4
+ // the bucket's whole-stream age limit and `PresenceRegistry`'s TTL are the same number seen from
5
+ // two sides, and a caller that had to pass each one separately could quietly set them apart.
6
+
7
+ import type { Clock } from '@ultimat3/core';
8
+ import type { Transport } from './fanout';
9
+ import { InProcessTransport } from './fanout';
10
+ import { assertBucket } from './nats-jetstream';
11
+ import type { NatsStream, NatsTarget } from './nats-socket';
12
+ import { NatsTransport } from './nats-transport';
13
+
14
+ /** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */
15
+ export const TRANSPORT_ENV_KEYS = ['NATS_URL', 'NATS_KV_BUCKET'] as const;
16
+
17
+ /**
18
+ * One bucket per deployment, not per cluster: two apps sharing a nats-server would otherwise share
19
+ * one presence namespace, and a room name that collided would list the other app's members.
20
+ */
21
+ export const DEFAULT_PRESENCE_BUCKET = 'x_presence';
22
+
23
+ /** Member TTL. A client heartbeats at a third of it, so one lost beat is never a false leave. */
24
+ export const DEFAULT_PRESENCE_TTL_MS = 30_000;
25
+
26
+ export type TransportEnvironment = Readonly<Record<string, string | undefined>>;
27
+
28
+ export interface TransportSelection {
29
+ readonly transport: Transport;
30
+ /** `embedded` fans out in this process only; `external` reaches every node on the bus. */
31
+ readonly mode: 'embedded' | 'external';
32
+ /**
33
+ * Why this transport, in one line: the env key that selected it, or what to set to change it.
34
+ * A boot prints it, so "does this process reach the other nodes" is never a guess — and it is
35
+ * the env key rather than the URL, because a NATS url carries credentials.
36
+ */
37
+ readonly detail: string;
38
+ /** Null in embedded mode: nothing is stored on a bus, so there is no bucket to name. */
39
+ readonly bucket: string | null;
40
+ /** What `PresenceRegistry` must be given, so its TTL and the bucket's cannot disagree. */
41
+ readonly presenceTtlMs: number;
42
+ /**
43
+ * Dial now rather than on the first change nobody receives. Selection itself stays pure — it
44
+ * parses env and constructs, it does not touch a socket — so a boot can order its dials.
45
+ * Embedded resolves immediately: there is nothing to reach.
46
+ */
47
+ connect(): Promise<void>;
48
+ }
49
+
50
+ export interface SelectTransportOptions {
51
+ readonly presenceTtlMs?: number | undefined;
52
+ readonly clock?: Clock | undefined;
53
+ /** Injected so a boot — reconnect included — can be proven with no network. */
54
+ readonly open?: ((target: NatsTarget) => Promise<NatsStream>) | undefined;
55
+ }
56
+
57
+ const nonEmpty = (value: string | undefined): string | undefined =>
58
+ value === undefined || value.trim().length === 0 ? undefined : value.trim();
59
+
60
+ /**
61
+ * No url means the in-process transport — the same "an unset variable means the embedded default"
62
+ * law the db, mail, storage and replication bindings follow. The bucket name is validated here
63
+ * rather than on first connect: a typo'd bucket is a boot that reports a healthy bus and then
64
+ * fails every presence write, which is the failure this whole selector exists to move earlier.
65
+ */
66
+ export function selectTransport(
67
+ env: TransportEnvironment,
68
+ options: SelectTransportOptions = {},
69
+ ): TransportSelection {
70
+ const presenceTtlMs = options.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS;
71
+ const url = nonEmpty(env['NATS_URL']);
72
+
73
+ if (url === undefined) {
74
+ const transport = new InProcessTransport(
75
+ options.clock === undefined ? {} : { clock: options.clock },
76
+ );
77
+ return {
78
+ transport,
79
+ mode: 'embedded',
80
+ detail: 'in-process fanout — set NATS_URL to reach the other nodes',
81
+ bucket: null,
82
+ presenceTtlMs,
83
+ connect: () => Promise.resolve(),
84
+ };
85
+ }
86
+
87
+ const bucket = nonEmpty(env['NATS_KV_BUCKET']) ?? DEFAULT_PRESENCE_BUCKET;
88
+ assertBucket(bucket);
89
+ const transport = new NatsTransport({
90
+ url,
91
+ bucket,
92
+ presenceTtlMs,
93
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
94
+ ...(options.open === undefined ? {} : { open: options.open }),
95
+ });
96
+ return {
97
+ transport,
98
+ mode: 'external',
99
+ detail: 'NATS_URL',
100
+ bucket,
101
+ presenceTtlMs,
102
+ connect: () => transport.connect(),
103
+ };
104
+ }