@ultimat3/realtime 23.0.0 → 25.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 (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -11,11 +11,12 @@ import {
11
11
  type ResumeSource,
12
12
  resumeFrom,
13
13
  } from './cursor';
14
+ import type { RowPatch } from './json';
14
15
  import type { SnapshotResult } from './live-contract';
15
- import { snapshotFrame } from './live-fanout';
16
+ import { type NamedWrite, patchFrame, snapshotFrame } from './live-fanout';
16
17
  import { fillWindow, type QueryEntry } from './query-window';
17
18
  import type { Subscriber, SubscriberGate } from './subscriber-gate';
18
- import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
19
+ import type { Frame } from './sync-protocol';
19
20
 
20
21
  export interface ResumeOntoDeps {
21
22
  readonly source: ResumeSource;
@@ -24,6 +25,8 @@ export interface ResumeOntoDeps {
24
25
  readonly gate: SubscriberGate;
25
26
  /** The window, read once for the entry and filtered for THIS subscriber. */
26
27
  read(): Promise<SnapshotResult>;
28
+ /** Called before the delta path fills an unread entry — a database read the caller pays for. */
29
+ beforeFill?(): Promise<void>;
27
30
  }
28
31
 
29
32
  /**
@@ -45,16 +48,23 @@ export async function resumeOnto(
45
48
  snapshot: () => deps.read(),
46
49
  });
47
50
  if (resumed.kind !== 'delta') {
51
+ // Read after every retained patch, so it holds their writes; `snapshotFrame` names only those
52
+ // behind a row this subscriber was given. Out of the ring's window there is nothing to name,
53
+ // and a write left unnamed settles on its own HTTP answer.
54
+ const named = retainedWrites(deps.source.since(entry.qid, cursor.lsn) ?? []);
48
55
  return {
49
56
  cursor: resumed.cursor,
50
- frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor),
57
+ frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor, named),
51
58
  };
52
59
  }
53
60
  // The gate decides about whole rows out of the shared window, and an entry nothing has read
54
61
  // yet has none — every patch would meet an empty window and be withheld. Filling is
55
62
  // conditional on purpose: a restart storm resumes onto entries that already hold a live
56
63
  // window, and re-reading per resuming subscriber is the cost a delta resume exists to skip.
57
- if (entry.lsn === '') await fillWindow(entry);
64
+ if (entry.lsn === '') {
65
+ await deps.beforeFill?.();
66
+ await fillWindow(entry);
67
+ }
58
68
  // The live entry on purpose: a resume runs outside the lane, so the window under it may
59
69
  // have moved on — always forwards, and a row whose grant was revoked in the meantime is
60
70
  // one this pass must refuse rather than replay from the state it had at the cursor's lsn.
@@ -67,8 +77,34 @@ export async function resumeOnto(
67
77
  // another tenant's row id and the instant it went. The leak that branch closes, re-opened
68
78
  // one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
69
79
  const seated = advance(cursor, patches, resumed.cursor.lsn, now);
70
- return {
71
- cursor: seated,
72
- frame: { type: 'patch', v: PROTOCOL_VERSION, sid: who.sid, patches, lsn: seated.lsn },
73
- };
80
+ const { bare, writes } = namedWrites(patches);
81
+ return { cursor: seated, frame: patchFrame(who.sid, bare, seated.lsn, writes) };
82
+ }
83
+
84
+ /**
85
+ * The writes behind the patches this subscriber is sent, each named once, and the patches as the
86
+ * wire spells them. Read off the FILTERED list: a write behind a row the gate withheld is another
87
+ * actor's, and naming it here would tell this page that write happened.
88
+ */
89
+ function namedWrites(patches: readonly RowPatch[]): {
90
+ readonly bare: readonly RowPatch[];
91
+ readonly writes: readonly string[];
92
+ } {
93
+ const writes = new Set<string>();
94
+ const bare = patches.map((patch) => {
95
+ if (patch.write === undefined) return patch;
96
+ writes.add(patch.write);
97
+ const { write: _named, ...rest } = patch;
98
+ return rest;
99
+ });
100
+ return { bare, writes: [...writes] };
101
+ }
102
+
103
+ /** Each retained write, by the record key of the row its patch made. */
104
+ function retainedWrites(patches: readonly RowPatch[]): readonly NamedWrite[] {
105
+ const named: NamedWrite[] = [];
106
+ for (const patch of patches) {
107
+ if (patch.write !== undefined) named.push({ write: patch.write, key: patch.key ?? patch.id });
108
+ }
109
+ return named;
74
110
  }
package/src/live-rows.ts CHANGED
@@ -70,6 +70,7 @@ export class RowWindows {
70
70
  type: string | null,
71
71
  rows: readonly Row[],
72
72
  keys?: readonly string[],
73
+ writes: readonly string[] = [],
73
74
  ): void {
74
75
  const next = type ?? registration.type;
75
76
  // The server's record key where it sent one; a row's `id` IS its key everywhere else.
@@ -80,12 +81,27 @@ export class RowWindows {
80
81
  keyed.map(([id]) => id),
81
82
  () => {
82
83
  for (const [id, row] of keyed) this.#store.merge(next, id, row);
84
+ // Settled in this batch, as `patch` settles: truth that holds the write, never under it.
85
+ settle(
86
+ this.#store,
87
+ writes,
88
+ keyed.map(([id]) => recordKey(next, id)),
89
+ );
83
90
  },
84
91
  );
85
92
  }
86
93
 
87
- /** A patch list: values merged into the store, membership and order folded over the ids. */
88
- patch(registration: Registration, sent: readonly RowPatch[]): void {
94
+ /**
95
+ * A patch list: values merged into the store, membership and order folded over the ids. `writes`
96
+ * are the writes the frame names (`PatchFrame.writes`): each this page still holds is settled
97
+ * against the rows merged here, INSIDE the same batch — so no holder ever renders the server's
98
+ * row with that write's twin replayed over it (truth plus overlay: one like counted twice).
99
+ */
100
+ patch(
101
+ registration: Registration,
102
+ sent: readonly RowPatch[],
103
+ writes: readonly string[] = [],
104
+ ): void {
89
105
  const type = registration.type;
90
106
  // A window holds RECORD keys: a patch the server keyed is folded under its key, not its id.
91
107
  const patches = sent.map((patch) =>
@@ -93,10 +109,16 @@ export class RowWindows {
93
109
  );
94
110
  // A `delete` is this window losing the row, never the store losing it: another holder keeps it.
95
111
  this.#reseat(registration, type, orderAfterPatches(registration.ids, patches), (held) => {
112
+ // Only a row merged here is truth this frame carried. A `delete` is membership, not the row:
113
+ // a write it names keeps its overlay on that row until the server's next word on it.
114
+ const carried = new Set<RecordKey>();
96
115
  for (const patch of patches) {
97
116
  if (patch.op === 'delete' || patch.row === null) continue;
98
- if (held.has(patch.id)) this.#store.merge(type, patch.id, patch.row);
117
+ if (!held.has(patch.id)) continue;
118
+ this.#store.merge(type, patch.id, patch.row);
119
+ carried.add(recordKey(type, patch.id));
99
120
  }
121
+ settle(this.#store, writes, carried);
100
122
  });
101
123
  }
102
124
 
@@ -144,3 +166,10 @@ function holds(registration: Registration, changed: ReadonlySet<RecordKey>): boo
144
166
  }
145
167
  return false;
146
168
  }
169
+
170
+ /** Each named write this page still holds, settled against the rows the frame merged. */
171
+ function settle(store: RecordStore, writes: readonly string[], merged: Iterable<RecordKey>): void {
172
+ if (writes.length === 0) return;
173
+ const carried = new Set(merged);
174
+ for (const write of writes) store.settleWrite(write, carried);
175
+ }
@@ -0,0 +1,50 @@
1
+ // When a live subscribe spends the read's declared `rateLimit:`, and against whom. Split from
2
+ // `live-query.ts` at its size ceiling: the registry decides WHAT is served, this decides what it
3
+ // costs — once per subscribe that reads, on the node's clock, keyed by the socket's own address.
4
+
5
+ import type { Clock } from '@ultimat3/core';
6
+ import type { LiveCursor } from './cursor';
7
+ import type { JsonValue } from './json';
8
+ import type { LiveQueryDefinition } from './live-contract';
9
+ import type { SyncSocket } from './socket';
10
+
11
+ export interface SubscribeArgs {
12
+ readonly socket: SyncSocket;
13
+ readonly name: string;
14
+ readonly input: JsonValue;
15
+ readonly sid?: string;
16
+ readonly cursor?: LiveCursor | null;
17
+ }
18
+
19
+ /**
20
+ * One subscribe's bill, shared by every pass of it. `subscribe` serves AGAIN when the socket's
21
+ * actor is replaced under it, and that second pass is the same subscription: spending there
22
+ * charged one subscribe twice.
23
+ */
24
+ export interface Charge {
25
+ due: boolean;
26
+ }
27
+
28
+ /**
29
+ * Spend once, then never again for this subscribe — called by the registry immediately before
30
+ * any DATABASE READ this subscriber causes: a cold snapshot, a resume that fell back to one, or a
31
+ * delta resume onto an entry nothing has read yet. Only a real in-window delta served out of the
32
+ * ring is free. Never decided from the cursor: its `qid` is computable from the query, the input
33
+ * and the org, so a cursor is a client's claim to a window, not proof it held one — trusting it
34
+ * made every subscribe+unsubscribe cycle a free read. `due` drops before the await, so a pass
35
+ * racing this one (the re-auth retry) cannot pay as well.
36
+ */
37
+ export async function spendOnce(
38
+ definition: LiveQueryDefinition,
39
+ socket: SyncSocket,
40
+ charge: Charge,
41
+ clock: Clock,
42
+ ): Promise<void> {
43
+ if (!charge.due) return;
44
+ charge.due = false;
45
+ await definition.spend?.({
46
+ actor: socket.actor,
47
+ clientAddress: socket.clientAddress,
48
+ nowMs: clock.now().getTime(),
49
+ });
50
+ }
@@ -115,6 +115,11 @@ export class MemoryLocalStore implements LocalStore {
115
115
  }
116
116
  }
117
117
 
118
+ /** The one way to build the in-process store (tests, a browser with no IndexedDB); the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
119
+ export function memoryLocalStore(): MemoryLocalStore {
120
+ return new MemoryLocalStore();
121
+ }
122
+
118
123
  class IdbLocalStore implements LocalStore {
119
124
  readonly kind = 'indexeddb';
120
125
  constructor(private readonly db: IdbDatabaseLike) {}
@@ -6,7 +6,13 @@
6
6
 
7
7
  import { type LiveQuery, match, type Patch } from '@ultimat3/query';
8
8
  import type { ChangeEvent } from './changefeed';
9
- import { changedColumns, isJsonObject, type JsonObject, type Row, type RowPatch } from './json';
9
+ import {
10
+ changedColumns,
11
+ isParsedJsonObject,
12
+ type JsonObject,
13
+ type Row,
14
+ type RowPatch,
15
+ } from './json';
10
16
 
11
17
  export interface SubscriptionShape {
12
18
  readonly qid: string;
@@ -37,7 +43,7 @@ export interface IncrementalMatcher {
37
43
 
38
44
  /** The cheap pre-filter. Pure, and the only thing that runs for a change nobody subscribed to. */
39
45
  export function canAffect(shape: SubscriptionShape, change: ChangeEvent): boolean {
40
- if (!shape.entities.includes(change.entity)) return false;
46
+ if (!shape.entities.includes(change.table)) return false;
41
47
  if (shape.orgId !== null && change.orgId !== null && shape.orgId !== change.orgId) return false;
42
48
  if (shape.columns && change.op === 'update' && change.after !== null) {
43
49
  const touched = Object.keys(changedColumns(change.before, change.after));
@@ -89,7 +95,7 @@ export function matcherFor(live: LiveQuery, projection?: () => Projection): Incr
89
95
  const row = change.after ?? change.before;
90
96
  if (!row) return NO_CHANGE;
91
97
  const patches = match<Row>(live.name, live.shape, rows, {
92
- entity: change.entity,
98
+ entity: change.table,
93
99
  op: change.op,
94
100
  row,
95
101
  ...(change.before === null ? {} : { before: change.before }),
@@ -200,7 +206,7 @@ export function applyToWindow(rows: readonly Row[], patches: readonly RowPatch[]
200
206
  export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatch | null {
201
207
  if (candidate === null || candidate === undefined || candidate === false) return null;
202
208
  if (candidate === true) return patchFromChange(change);
203
- if (!isJsonObject(candidate)) return null;
209
+ if (!isParsedJsonObject(candidate)) return null;
204
210
  const op = candidate['op'];
205
211
  if (op !== 'insert' && op !== 'update' && op !== 'delete') return patchFromChange(change);
206
212
  const id = candidate['id'];
@@ -209,7 +215,7 @@ export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatc
209
215
  const base: RowPatch = {
210
216
  op,
211
217
  id: typeof id === 'string' ? id : (change.after?.id ?? change.before?.id ?? ''),
212
- row: op === 'delete' ? null : isJsonObject(rowValue) ? rowValue : (change.after ?? null),
218
+ row: op === 'delete' ? null : isParsedJsonObject(rowValue) ? rowValue : (change.after ?? null),
213
219
  lsn: typeof candidate['lsn'] === 'string' ? candidate['lsn'] : change.lsn,
214
220
  };
215
221
  if (base.id === '') return null;
@@ -33,8 +33,8 @@ export interface NatsRequestManyOptions {
33
33
 
34
34
  /**
35
35
  * What the bus needs from a NATS connection, and nothing more. A subscription outlives a reconnect —
36
- * the client re-establishes it underneath the caller — which is why nothing above this port keeps
37
- * subscription bookkeeping of its own.
36
+ * the client re-establishes it underneath the caller. It does not outlive the client: once the
37
+ * reconnect budget is spent (`onClosed`), the caller dials a new client and binds again.
38
38
  */
39
39
  export interface NatsClient {
40
40
  /** The connected server's version, as `assertServerVersion` reads it. */
@@ -75,14 +75,38 @@ export interface NatsClientOptions {
75
75
  readonly onError?: ((error: unknown) => void) | undefined;
76
76
  /** The library re-established the connection. The cluster behind it may be a different one. */
77
77
  readonly onReconnect?: (() => void) | undefined;
78
+ /**
79
+ * The library gave up: its reconnect budget is spent and this connection is closed for good,
80
+ * subscriptions included. Never called for the caller's own `close()`. The caller re-dials.
81
+ */
82
+ readonly onClosed?: (() => void) | undefined;
78
83
  }
79
84
 
80
85
  /** The one seam a test replaces: a client that never touches a socket. */
81
86
  export type NatsConnect = (options: NatsClientOptions) => Promise<NatsClient>;
82
87
 
83
88
  export const DEFAULT_NATS_PORT = 4222;
89
+
90
+ const NATS_URL_FIX = 'export NATS_URL=nats://user:password@host:4222';
84
91
  export const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
85
92
 
93
+ /**
94
+ * One URL credential, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a stray
95
+ * `%` (`50%off`), so a secret pasted unencoded failed boot with no code and no fix. Refused under
96
+ * the part's NAME and the host — never the value: it is the credential, and a cause reaches a log.
97
+ */
98
+ function decodeCredential(raw: string, part: 'user' | 'password', host: string): string {
99
+ try {
100
+ return decodeURIComponent(raw);
101
+ } catch {
102
+ throw new TransportUnavailableError({
103
+ transport: 'nats',
104
+ reason: `the connection URL for ${host} has a malformed percent-escape in its ${part}`,
105
+ fix: `bun -e 'console.log(encodeURIComponent(process.argv[1]))' '<the raw value>' # then put the result in the ${part} of NATS_URL`,
106
+ });
107
+ }
108
+ }
109
+
86
110
  /**
87
111
  * `nats://user:pass@host:4222`. The one place a bus URL is read — the library takes a bare
88
112
  * `host:port` plus credentials as options, and never looks at a URL's userinfo.
@@ -92,9 +116,12 @@ export function parseNatsUrl(url: string): NatsTarget {
92
116
  try {
93
117
  parsed = new URL(url);
94
118
  } catch {
119
+ // The variable, never its value: a bus URL carries the credential, and an error is the one
120
+ // thing that reaches a log, `--json`, an agent transcript and a ticket. `parsePgUrl`'s rule.
95
121
  throw new TransportUnavailableError({
96
122
  transport: 'nats',
97
- reason: `"${url}" is not a connection URL`,
123
+ reason: 'NATS_URL is not a connection URL',
124
+ fix: NATS_URL_FIX,
98
125
  });
99
126
  }
100
127
  if (parsed.protocol !== 'nats:' && parsed.protocol !== 'tls:') {
@@ -104,7 +131,11 @@ export function parseNatsUrl(url: string): NatsTarget {
104
131
  });
105
132
  }
106
133
  if (parsed.hostname === '') {
107
- throw new TransportUnavailableError({ transport: 'nats', reason: `"${url}" has no host` });
134
+ throw new TransportUnavailableError({
135
+ transport: 'nats',
136
+ reason: 'NATS_URL has no host',
137
+ fix: NATS_URL_FIX,
138
+ });
108
139
  }
109
140
  const hasUser = parsed.username !== '';
110
141
  const hasPass = parsed.password !== '';
@@ -118,8 +149,8 @@ export function parseNatsUrl(url: string): NatsTarget {
118
149
  fix: 'set the URL to nats://<user>:<pass>@host:4222, or the bare-token form nats://<token>@host:4222',
119
150
  });
120
151
  }
121
- const user = hasUser ? decodeURIComponent(parsed.username) : undefined;
122
- const pass = hasPass ? decodeURIComponent(parsed.password) : undefined;
152
+ const user = hasUser ? decodeCredential(parsed.username, 'user', parsed.hostname) : undefined;
153
+ const pass = hasPass ? decodeCredential(parsed.password, 'password', parsed.hostname) : undefined;
123
154
  return {
124
155
  host: parsed.hostname,
125
156
  port: parsed.port === '' ? DEFAULT_NATS_PORT : Number.parseInt(parsed.port, 10),
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // WHERE FAILURES ARE TRANSLATED, and it is deliberately not all here. This file coded the calls
6
6
  // that have no synchronous caller frame to catch them: `request`, `requestMany`, the dial, and the
7
- // background `#watch` that is the only place a lost connection is announced. `publish` and
7
+ // background `#watch`/`#watchClosed`, the only places a lost connection is announced. `publish` and
8
8
  // `subscribe` are synchronous and stay raw — `NatsTransport.#translating` codes them, because
9
9
  // `NatsTransportOptions.connect` is a PUBLIC injection seam: translating in this class would cover
10
10
  // the one client the repo ships and leave every app-supplied one uncovered, and translating in both
@@ -72,6 +72,8 @@ class LibNatsClient implements NatsClient {
72
72
  readonly #timeoutMs: number;
73
73
  readonly #report: (error: unknown) => void;
74
74
  #connected = true;
75
+ /** Our own `close()` ends the status stream too, and that end is not the library giving up. */
76
+ #closing = false;
75
77
 
76
78
  constructor(connection: NatsConnection, target: NatsTarget, options: NatsClientOptions) {
77
79
  this.#connection = connection;
@@ -155,19 +157,22 @@ class LibNatsClient implements NatsClient {
155
157
  }
156
158
 
157
159
  async close(): Promise<void> {
160
+ this.#closing = true;
158
161
  this.#connected = false;
159
162
  await this.#connection.close();
160
163
  }
161
164
 
162
165
  /**
163
- * The library's own status stream is the only place a background loss is announced. Nothing
166
+ * The library's own status stream is the only place a drop or a recovery is announced. Nothing
164
167
  * awaits it, so it can neither throw nor end the process: a drop reports and flips `connected`,
165
168
  * a reconnect flips it back and tells the transport its cluster may be a new one.
166
169
  */
167
170
  async #watch(options: NatsClientOptions): Promise<void> {
168
171
  const report = options.onError ?? ((): void => undefined);
172
+ const stream = this.#connection.status();
173
+ void this.#watchClosed(options, stream);
169
174
  try {
170
- for await (const status of this.#connection.status()) {
175
+ for await (const status of stream) {
171
176
  if (status.type === Events.Disconnect) {
172
177
  this.#connected = false;
173
178
  report(unavailable(this.#target, 'the connection dropped'));
@@ -178,17 +183,41 @@ class LibNatsClient implements NatsClient {
178
183
  report(unavailable(this.#target, `the server reported ${String(status.data)}`));
179
184
  }
180
185
  }
181
- // The iterator ends when the connection is done: either `close()` or a reconnect budget spent.
182
- this.#connected = false;
183
- const failure = await this.#connection.closed();
184
- if (failure !== undefined) report(unavailable(this.#target, describe(failure)));
185
186
  } catch (error) {
186
187
  this.#connected = false;
187
188
  report(unavailable(this.#target, describe(error)));
188
189
  }
189
190
  }
191
+
192
+ /**
193
+ * The end of the connection, read off `closed()` — never off the status stream, which in
194
+ * nats@2.29.3 does NOT end when the connection closes (the library stops its protocol's
195
+ * listeners, not the connection's). A close we did not ask for is the library giving up on its
196
+ * reconnect budget: `onClosed`, so the transport replaces this client rather than handing a dead
197
+ * one out forever. The stream is stopped here, or every replaced client leaks a pending loop.
198
+ */
199
+ async #watchClosed(options: NatsClientOptions, stream: unknown): Promise<void> {
200
+ let failure: unknown;
201
+ try {
202
+ failure = await this.#connection.closed();
203
+ } catch (error) {
204
+ failure = error;
205
+ }
206
+ this.#connected = false;
207
+ stopStream(stream);
208
+ if (this.#closing) return;
209
+ if (failure !== undefined) options.onError?.(unavailable(this.#target, describe(failure)));
210
+ options.onClosed?.();
211
+ }
190
212
  }
191
213
 
214
+ /** `QueuedIterator.stop()` is the library's, and missing from the `AsyncIterable` it is typed as. */
215
+ const stopStream = (stream: unknown): void => {
216
+ if (typeof stream !== 'object' || stream === null || !('stop' in stream)) return;
217
+ const stop: unknown = stream.stop;
218
+ if (typeof stop === 'function') stop.call(stream);
219
+ };
220
+
192
221
  /**
193
222
  * The production `NatsConnect`. The first dial retries on the same budget as a later loss
194
223
  * (`waitOnFirstConnect`), so a `sync` container that raced the bus into readiness recovers on its
@@ -0,0 +1,63 @@
1
+ // Single responsibility: the transport's kept list of live subscriptions, so a client the library
2
+ // closed for good can be replaced and every subscription bound again on the new one. The library's
3
+ // own reconnect never reaches this list — it re-establishes the SAME client's subscriptions — so a
4
+ // subscription is bound once per client and a re-bind can never double a delivery.
5
+
6
+ import type { NatsClient, NatsMessageHandler, NatsSubscription } from './nats-client';
7
+
8
+ interface Kept {
9
+ readonly subject: string;
10
+ readonly deliver: NatsMessageHandler;
11
+ /** The binding on the current client; `undefined` once that client is gone or the bind failed. */
12
+ live: NatsSubscription | undefined;
13
+ }
14
+
15
+ /** A refused bind, handed back rather than thrown: one bad subject must not strand the others. */
16
+ export interface BindFailure {
17
+ readonly subject: string;
18
+ readonly error: unknown;
19
+ }
20
+
21
+ export class NatsSubscriptions {
22
+ readonly #kept = new Set<Kept>();
23
+
24
+ /**
25
+ * Binds on `client` first and keeps it only once bound: a subscribe the client refused is the
26
+ * caller's error, and keeping it would re-try a refusal on every re-dial. Returns the release.
27
+ */
28
+ add(
29
+ client: NatsClient,
30
+ subject: string,
31
+ deliver: NatsMessageHandler,
32
+ bind: (call: () => NatsSubscription) => NatsSubscription,
33
+ ): () => void {
34
+ const kept: Kept = { subject, deliver, live: undefined };
35
+ kept.live = bind(() => client.subscribe(subject, deliver));
36
+ this.#kept.add(kept);
37
+ return () => {
38
+ if (!this.#kept.delete(kept)) return;
39
+ const live = kept.live;
40
+ kept.live = undefined;
41
+ live?.unsubscribe();
42
+ };
43
+ }
44
+
45
+ /** The client is gone, and every binding with it. Nothing is unsubscribed: there is no wire. */
46
+ orphan(): void {
47
+ for (const kept of this.#kept) kept.live = undefined;
48
+ }
49
+
50
+ /** Every kept subscription, bound on a NEW client. Returns the ones it refused. */
51
+ bindAll(client: NatsClient): readonly BindFailure[] {
52
+ const failures: BindFailure[] = [];
53
+ for (const kept of this.#kept) {
54
+ if (kept.live !== undefined) continue;
55
+ try {
56
+ kept.live = client.subscribe(kept.subject, kept.deliver);
57
+ } catch (error) {
58
+ failures.push({ subject: kept.subject, error });
59
+ }
60
+ }
61
+ return failures;
62
+ }
63
+ }