@dbx-tools/postgres 0.6.62

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.
@@ -0,0 +1,655 @@
1
+ /**
2
+ * Topic fan-out over PostgreSQL `LISTEN`/`NOTIFY`, for telling every running
3
+ * instance of an app that something happened.
4
+ *
5
+ * The sibling `advisory-lock` module is about making sure only ONE connection
6
+ * does a thing; this one is the opposite - EVERY listening session gets every
7
+ * notification. That makes it the right primitive for live UI updates
8
+ * (an SSE stream per browser tab, a cache invalidation, a presence ping) and the
9
+ * wrong one for work distribution: there are no competing consumers, no acks, and
10
+ * no replay.
11
+ *
12
+ * Delivery is best-effort and live. `NOTIFY` reaches sessions that are listening
13
+ * at the moment it commits, so a listener that connects a second later never sees
14
+ * it, and a listener whose connection drops misses everything until the bus
15
+ * reconnects. Use a table or a queue when a subscriber needs durability.
16
+ *
17
+ * ONE CHANNEL, MANY TOPICS. Every bus instance listens on a single Postgres
18
+ * channel (`channel`, default `dbx_tools_topic_bus`) and filters by the
19
+ * envelope's `topic` in-process, so adding a topic costs no connection and no
20
+ * `LISTEN`. The tradeoff is that every listening session decodes every message on
21
+ * the channel; give a genuinely high-volume, unrelated stream its own `channel`
22
+ * rather than a topic.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import { hostname } from "node:os";
28
+ import { async as asyncUtil, error, hash, json, object, string } from "@dbx-tools/shared-core";
29
+ import type { Notification, PoolClient } from "pg";
30
+
31
+ import type { PgPoolLike, PgQueryable } from "./advisory-lock.ts";
32
+
33
+ /**
34
+ * `@dbx-tools/shared-core` owns the JSON-round-trip rule; this alias just keeps the
35
+ * generic signatures below readable. Consumers import the type from shared-core.
36
+ */
37
+ type SerializableValue = object.SerializableValue;
38
+
39
+ /** Channel parts used when a caller names no channel of its own. */
40
+ const DEFAULT_CHANNEL = "dbx_tools_topic_bus";
41
+ /**
42
+ * `NAMEDATALEN - 1`. Postgres TRUNCATES a longer channel name rather than
43
+ * rejecting it, which would split publishers and listeners onto different
44
+ * channels while every call looked like it succeeded.
45
+ */
46
+ const MAX_CHANNEL_LENGTH = 63;
47
+ /** Base-32 chars of the channel's identity suffix. Max 7 - the digest is 32 bits. */
48
+ const CHANNEL_HASH_LENGTH = 6;
49
+ /** Body used when the parts tokenize to nothing, or lead with a digit. */
50
+ const CHANNEL_FALLBACK = "bus";
51
+ /**
52
+ * Encoded-envelope ceiling. PostgreSQL caps a `NOTIFY` payload at 8000 bytes and
53
+ * fails the statement past that, so the bus rejects the message first with a size
54
+ * it can name. The margin below 8000 covers the server's own accounting.
55
+ */
56
+ const MAX_NOTIFY_BYTES = 7_900;
57
+ const MIN_RECONNECT_DELAY_MS = 250;
58
+ const MAX_RECONNECT_DELAY_MS = 5_000;
59
+
60
+ /**
61
+ * The subset of AppKit's per-request execution context the bus reads for sender
62
+ * identity. Fields are `unknown` because this is a structural view of an OPTIONAL
63
+ * peer: the values are trimmed and validated rather than trusted.
64
+ */
65
+ type AppKitExecutionContext = {
66
+ userEmail?: unknown;
67
+ userId?: unknown;
68
+ userName?: unknown;
69
+ };
70
+
71
+ /** Structural view of `@databricks/appkit`, imported lazily and never required. */
72
+ type AppKitModule = {
73
+ getExecutionContext(): AppKitExecutionContext;
74
+ };
75
+
76
+ /**
77
+ * Cached result of the one optional-peer import, resolved to `undefined` when
78
+ * AppKit is not installed. Memoized so a bus in a plain Postgres process pays for
79
+ * the failed resolution once instead of per broadcast.
80
+ */
81
+ let appKitModule: Promise<AppKitModule | undefined> | undefined;
82
+
83
+ /**
84
+ * Flat-keyed context travelling alongside a message body: who sent it, from
85
+ * where, in which deployment. Values may nest, but the keys are the addressable
86
+ * part - a listener filtering or labelling messages reads `metadata.user`, not a
87
+ * path into the body.
88
+ */
89
+ export type TopicMetadata = Record<string, SerializableValue>;
90
+
91
+ /**
92
+ * The wire envelope every subscriber receives, and what {@link
93
+ * PostgresTopicBus.broadcast} returns to the publisher.
94
+ *
95
+ * `id`, `topic`, and `publishedAt` are assigned by the bus; `type`, `metadata`,
96
+ * and `body` come from the caller (with automatic context merged under
97
+ * `metadata`). The shape is stable enough to hand straight to an SSE `data:`
98
+ * frame.
99
+ */
100
+ export interface TopicMessage<TBody extends SerializableValue = SerializableValue> {
101
+ /**
102
+ * Per-message identity generated by the publisher, unique across instances.
103
+ * Suitable for dedupe when a client reconnects and for an SSE `id:` field. Not
104
+ * ordered, and not a database key.
105
+ */
106
+ id: string;
107
+ /** The topic this was broadcast on; listeners on other topics never see it. */
108
+ topic: string;
109
+ /** Caller-chosen event name, e.g. `order.updated`. Never empty. */
110
+ type: string;
111
+ /** Automatic context merged with caller metadata. See {@link TopicMetadata}. */
112
+ metadata: TopicMetadata;
113
+ /** The caller's payload, unchanged. */
114
+ body: TBody;
115
+ /**
116
+ * ISO-8601 publish time from the PUBLISHING process's clock, not the database's.
117
+ * Fine for display; do not order messages from different instances by it.
118
+ */
119
+ publishedAt: string;
120
+ }
121
+
122
+ /** What a caller supplies to {@link PostgresTopicBus.broadcast}. */
123
+ export interface TopicPublishInput<TBody extends SerializableValue = SerializableValue> {
124
+ /** Event name, e.g. `chat.message`. Must be non-blank. */
125
+ type: string;
126
+ /**
127
+ * Context to attach. Wins over any automatic key of the same name, so a caller
128
+ * can override a machine default (`project`) or add its own (`traceId`).
129
+ */
130
+ metadata?: TopicMetadata;
131
+ /** The payload. Must satisfy `object.isSerializableValue`. */
132
+ body: TBody;
133
+ }
134
+
135
+ /**
136
+ * Subscriber callback. Invoked once per matching message on the notification
137
+ * connection's callback, so it should not block: a returned promise is awaited
138
+ * only to route a rejection to `onError`, and listeners for one message run
139
+ * concurrently rather than in registration order.
140
+ *
141
+ * `TBody` is unchecked at runtime. The bus guarantees the body is serializable,
142
+ * not that it matches the type parameter, so validate anything a listener
143
+ * branches on.
144
+ */
145
+ export type TopicListener<TBody extends SerializableValue = SerializableValue> = (
146
+ message: TopicMessage<TBody>,
147
+ ) => void | PromiseLike<void>;
148
+
149
+ /**
150
+ * Resolves metadata at publish time instead of construction time, for context a
151
+ * process learns late or asynchronously - a discovered public IP, an instance id
152
+ * from a control plane. Called on every broadcast, so memoize anything expensive;
153
+ * a rejection fails the broadcast.
154
+ */
155
+ export type TopicMetadataProvider = () => TopicMetadata | PromiseLike<TopicMetadata>;
156
+
157
+ /** Construction options for {@link PostgresTopicBus}. */
158
+ export interface PostgresTopicBusOptions {
159
+ /**
160
+ * What identifies this channel - anything, not just an identifier: a name, an
161
+ * id, a `[env, feature]` pair, a config object. The parts are tokenized into a
162
+ * legal Postgres channel name with a short hash of the originals appended, so
163
+ * the derivation is deterministic and no call site has to sanitize. See
164
+ * {@link PostgresTopicBus.channelName} for the resolved name.
165
+ *
166
+ * One value or many: an array is read as multiple parts
167
+ * ({@link object.OneOrMany}), anything else as a single part. So
168
+ * `["billing", "prod"]` and `"billing_prod"` are different channels, since the
169
+ * hash sees different structure.
170
+ *
171
+ * Every participating process must pass EQUIVALENT parts, since a different
172
+ * spelling hashes to a different channel. Defaults to a shared
173
+ * `dbx_tools_topic_bus` channel.
174
+ */
175
+ channel?: unknown;
176
+ /**
177
+ * Extra context added to every message this bus publishes, either a fixed
178
+ * record or a {@link TopicMetadataProvider} called per broadcast. Overrides
179
+ * automatic machine keys; per-call `metadata` overrides this.
180
+ */
181
+ metadata?: TopicMetadata | TopicMetadataProvider;
182
+ /**
183
+ * Sink for failures that have no caller to throw to: a listener that rejected,
184
+ * a dropped notification connection, a failed reconnect attempt. Defaults to
185
+ * swallowing them, so wire this to a logger in anything long-running.
186
+ */
187
+ onError?: (cause: unknown) => void;
188
+ }
189
+
190
+ /**
191
+ * Drop keys whose value is `undefined`, so an unset environment variable leaves
192
+ * the key ABSENT rather than present-and-null. Absence is what lets caller
193
+ * metadata and later merge layers supply the key instead.
194
+ */
195
+ function definedMetadata(values: Record<string, SerializableValue | undefined>): TopicMetadata {
196
+ return Object.fromEntries(
197
+ Object.entries(values).filter((entry): entry is [string, SerializableValue] => {
198
+ return entry[1] !== undefined;
199
+ }),
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Context every message gets for free: which project, host, process, and
205
+ * deployment published it.
206
+ *
207
+ * Read fresh per broadcast rather than cached, because a long-lived process can
208
+ * be re-parented (a deployment id appearing after boot). Keys that resolve to
209
+ * nothing are omitted. Public IP is only read from the environment here - the bus
210
+ * never makes a network call to discover it; a process that wants a discovered IP
211
+ * passes a {@link TopicMetadataProvider}.
212
+ *
213
+ * Deliberately NOT included: CPU architecture, runtime name, and runtime version.
214
+ * They are constant per deployment and cost payload bytes against the `NOTIFY`
215
+ * limit on every single message.
216
+ */
217
+ function machineMetadata(): TopicMetadata {
218
+ return definedMetadata({
219
+ project:
220
+ string.firstNonEmpty([
221
+ process.env.DATABRICKS_APP_NAME,
222
+ process.env.DATABRICKS_BUNDLE_NAME,
223
+ process.env.PROJECT_NAME,
224
+ process.env.npm_package_name,
225
+ ]) ?? undefined,
226
+ publicIp:
227
+ string.firstNonEmpty([
228
+ process.env.PUBLIC_IP,
229
+ process.env.DATABRICKS_PUBLIC_IP,
230
+ process.env.HOST_IP,
231
+ ]) ?? undefined,
232
+ hostname: hostname(),
233
+ cwd: process.cwd(),
234
+ platform: process.platform,
235
+ pid: process.pid,
236
+ environment: string.trimToNull(process.env.NODE_ENV) ?? undefined,
237
+ appName: string.trimToNull(process.env.DATABRICKS_APP_NAME) ?? undefined,
238
+ deploymentId: string.trimToNull(process.env.DATABRICKS_APP_DEPLOYMENT_ID) ?? undefined,
239
+ databricksHost: string.trimToNull(process.env.DATABRICKS_HOST) ?? undefined,
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Identity of the signed-in user on whose behalf this broadcast happens, when the
245
+ * process can tell.
246
+ *
247
+ * `@databricks/appkit` is an OPTIONAL peer, so the import is lazy and a missing
248
+ * module is not an error - this is a plain Postgres package that adds sender
249
+ * identity when it happens to run inside an AppKit app. Outside an active
250
+ * per-request execution context AppKit throws, which means "no user here", so the
251
+ * result is empty metadata rather than a failed broadcast.
252
+ */
253
+ async function senderMetadata(): Promise<TopicMetadata> {
254
+ appKitModule ??= import("@databricks/appkit")
255
+ .then((module) => module as AppKitModule)
256
+ .catch(() => undefined);
257
+ const appkit = await appKitModule;
258
+ if (!appkit) return {};
259
+ try {
260
+ const context = appkit.getExecutionContext();
261
+ return definedMetadata({
262
+ senderId: string.trimToNull(context.userId) ?? undefined,
263
+ senderName: string.trimToNull(context.userName) ?? undefined,
264
+ senderEmail: string.trimToNull(context.userEmail) ?? undefined,
265
+ });
266
+ } catch {
267
+ return {};
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Derive a legal Postgres channel name from whatever a caller used to identify
273
+ * the channel.
274
+ *
275
+ * Callers think in terms of what the channel IS - an app name, a tenant id, a
276
+ * `[env, feature]` pair, a config object - and none of those are identifiers.
277
+ * Rejecting them pushed the sanitizing onto every call site, which is how two
278
+ * services end up disagreeing about whether the channel is `my-app`, `my_app`,
279
+ * or `myApp` and silently never hear each other.
280
+ *
281
+ * The parts are tokenized and joined into an identifier, then a short hash of the
282
+ * ORIGINAL parts is appended. The hash is what makes the mapping trustworthy:
283
+ * tokenizing alone is lossy, so `my-app` and `my_app` and `myApp` would collapse
284
+ * onto one channel, and a name long enough to hit `NAMEDATALEN` would collide
285
+ * with anything sharing its leading tokens. With the suffix, the readable part
286
+ * stays readable and distinct inputs stay distinct.
287
+ *
288
+ * Deterministic across processes and runs, which is the whole point - two
289
+ * services given the same parts must land on the same channel without
290
+ * coordinating. Hash inputs are structure-aware, so `["a", "b"]` and `["a_b"]`
291
+ * differ, and object key order does not.
292
+ */
293
+ function channelName(parts: object.OneOrMany<unknown>): string {
294
+ // Hash the CANONICAL form, the same rule `advisoryLockId` uses, so structure and
295
+ // types decide identity: `["a","b"]` differs from `["ab"]`, `1` from `"1"`, and
296
+ // object key order does not matter. Hashing the raw parts would instead lean on
297
+ // the hash module's canonicalizer, which folds every `Date` onto one token.
298
+ const suffix = hash.fnvHashWithOptions(
299
+ { length: CHANNEL_HASH_LENGTH },
300
+ parts.map((part) => object.toStableKey(part)).join("\u0000"),
301
+ );
302
+ // Only parts that stringify to something a reader recognizes contribute to the
303
+ // readable half. An object would tokenize from `String(value)` as
304
+ // `object_object`, which is noise - it still shapes the hash, so identity is
305
+ // unaffected.
306
+ const labelled = parts.filter((part) => {
307
+ const type = typeof part;
308
+ return type === "string" || type === "number" || type === "boolean" || type === "bigint";
309
+ });
310
+ // Truncate rather than cap through the tokenizer: `trim` would DROP a single
311
+ // token longer than the budget, turning one long name into the bare fallback and
312
+ // making every long name look alike. The hash still separates them, but the
313
+ // channel is unreadable in a log.
314
+ const body = string
315
+ .toIdentifierWithOptions({ delimiter: "_" }, ...labelled)
316
+ .slice(0, MAX_CHANNEL_LENGTH - suffix.length - 1)
317
+ .replace(/_+$/, "");
318
+ // An identifier cannot start with a digit and the hash alphabet is
319
+ // digit-leading, so a numeric or empty body needs a letter in front.
320
+ const prefix = /^[A-Za-z_]/.test(body) ? body : `${CHANNEL_FALLBACK}_${body}`;
321
+ return `${prefix}_${suffix}`.replace(/_+/g, "_");
322
+ }
323
+
324
+ /** Quote a validated channel for `LISTEN`/`UNLISTEN`, which take no parameters. */
325
+ function quoteIdentifier(value: string): string {
326
+ return `"${value.replaceAll('"', '""')}"`;
327
+ }
328
+
329
+ /**
330
+ * Parse a `NOTIFY` payload into an envelope, returning `undefined` for anything
331
+ * that is not one.
332
+ *
333
+ * The channel is shared and any Postgres session can `pg_notify` on it, so an
334
+ * unrecognized payload is expected input, not an exception. Publishers run this
335
+ * over their own encoded message too, which is how a body that stringifies but
336
+ * does not round-trip is caught before it ships.
337
+ */
338
+ function decode(value: string | undefined): TopicMessage | undefined {
339
+ if (!value) return undefined;
340
+ const record = json.parseRecord(value);
341
+ if (
342
+ !record ||
343
+ typeof record.id !== "string" ||
344
+ typeof record.topic !== "string" ||
345
+ typeof record.type !== "string" ||
346
+ typeof record.publishedAt !== "string" ||
347
+ !record.metadata ||
348
+ typeof record.metadata !== "object" ||
349
+ Array.isArray(record.metadata) ||
350
+ !("body" in record) ||
351
+ !object.isSerializableValue(record.metadata) ||
352
+ !object.isSerializableValue(record.body)
353
+ ) {
354
+ return undefined;
355
+ }
356
+ return {
357
+ id: record.id,
358
+ topic: record.topic,
359
+ type: record.type,
360
+ metadata: record.metadata as TopicMetadata,
361
+ body: record.body as SerializableValue,
362
+ publishedAt: record.publishedAt,
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Broadcasts structured messages by topic and delivers them to every process
368
+ * listening on the same channel.
369
+ *
370
+ * CONNECTION SHAPE. Publishing borrows a pooled connection per call, like any
371
+ * other query. Listening cannot: `LISTEN` is session state, so the bus holds ONE
372
+ * dedicated client out of the pool for as long as it has subscribers, no matter
373
+ * how many topics or listeners are registered. Size the pool with that one
374
+ * long-lived checkout in mind.
375
+ *
376
+ * LIFECYCLE. Construction is inert - nothing connects until the first
377
+ * {@link listen} (or an explicit {@link start}). {@link close} is required to give
378
+ * the connection back; a closed bus stays closed and throws on further use rather
379
+ * than silently reconnecting. Register it with the host's shutdown hook.
380
+ *
381
+ * FAILURE HANDLING. A lost notification connection reconnects on its own with
382
+ * bounded backoff while subscribers remain, reporting each failed attempt through
383
+ * `onError`. Messages published during the gap are lost - `NOTIFY` has no replay.
384
+ * A throwing or rejecting listener never affects the publisher or the other
385
+ * listeners; its failure goes to `onError`.
386
+ *
387
+ * Not safe to share one instance across unrelated channels - construct one bus
388
+ * per channel. The channel itself is DERIVED from the `channel` option rather
389
+ * than taken literally; see {@link PostgresTopicBusOptions.channel} and
390
+ * {@link PostgresTopicBus.channelName}.
391
+ */
392
+ export class PostgresTopicBus {
393
+ /**
394
+ * The resolved Postgres channel this bus listens and publishes on, derived from
395
+ * the `channel` option. Read it to confirm two processes agree, or to log what a
396
+ * set of parts actually resolved to.
397
+ */
398
+ readonly channelName: string;
399
+ private readonly metadata: TopicMetadata | TopicMetadataProvider | undefined;
400
+ private readonly onError: (cause: unknown) => void;
401
+ private readonly listeners = new Map<string, Set<TopicListener>>();
402
+ private client: PoolClient | undefined;
403
+ private starting: Promise<void> | undefined;
404
+ private reconnecting: Promise<void> | undefined;
405
+ private readonly reconnectAbort = new AbortController();
406
+ private closed = false;
407
+
408
+ constructor(
409
+ private readonly pool: PgPoolLike & PgQueryable,
410
+ options: PostgresTopicBusOptions = {},
411
+ ) {
412
+ this.channelName = channelName(
413
+ object.toOneOrMany(options.channel === undefined ? DEFAULT_CHANNEL : options.channel),
414
+ );
415
+ this.metadata = options.metadata;
416
+ this.onError = options.onError ?? (() => undefined);
417
+ }
418
+
419
+ /**
420
+ * Open the dedicated notification connection and `LISTEN`.
421
+ *
422
+ * Idempotent, and safe to call concurrently - overlapping calls await the same
423
+ * in-flight connect. {@link listen} calls this, so it is only needed to surface
424
+ * a connection problem at startup rather than on first subscribe. Throws if the
425
+ * bus is closed, or if the pool cannot hand out a connection.
426
+ */
427
+ async start(): Promise<void> {
428
+ if (this.client) return;
429
+ if (this.closed) throw new Error("Postgres topic bus is closed");
430
+ this.starting ??= this.connect().finally(() => {
431
+ this.starting = undefined;
432
+ });
433
+ await this.starting;
434
+ }
435
+
436
+ /**
437
+ * Publish a message to `topic`, returning the envelope that was sent.
438
+ *
439
+ * Resolves once Postgres has accepted the `NOTIFY`, which says nothing about
440
+ * anyone receiving it: sessions not listening at that moment miss it. Publishing
441
+ * needs no {@link start} and no subscribers.
442
+ *
443
+ * Validation is deliberately front-loaded, since a message that fails on the
444
+ * wire is far harder to diagnose than one rejected at the call: `TypeError` for a
445
+ * blank topic or type, or a body/metadata that would not round-trip through JSON
446
+ * unchanged (`object.isSerializableValue`); `RangeError` when the encoded
447
+ * envelope exceeds the `NOTIFY` payload limit, which the automatic metadata
448
+ * counts against - send a reference and let the receiver fetch the payload.
449
+ * Throws if the bus is closed.
450
+ */
451
+ async broadcast<TBody extends SerializableValue>(
452
+ topic: string,
453
+ input: TopicPublishInput<TBody>,
454
+ ): Promise<TopicMessage<TBody>> {
455
+ if (!topic.trim()) throw new TypeError("Topic must not be empty");
456
+ if (!input.type.trim()) throw new TypeError("Message type must not be empty");
457
+ if (
458
+ !object.isSerializableValue(input.metadata ?? {}) ||
459
+ !object.isSerializableValue(input.body)
460
+ ) {
461
+ throw new TypeError("Message metadata and body must be JSON serializable without coercion");
462
+ }
463
+ if (this.closed) throw new Error("Postgres topic bus is closed");
464
+ const automatic = await this.resolveMetadata();
465
+ const message: TopicMessage<TBody> = {
466
+ id: hash.id(),
467
+ topic,
468
+ type: input.type,
469
+ metadata: { ...automatic, ...input.metadata },
470
+ body: input.body,
471
+ publishedAt: new Date().toISOString(),
472
+ };
473
+ let encoded: string;
474
+ try {
475
+ encoded = JSON.stringify(message);
476
+ } catch (cause) {
477
+ throw new TypeError(`Message must be JSON serializable: ${error.errorMessage(cause)}`);
478
+ }
479
+ if (!decode(encoded)) {
480
+ throw new TypeError("Message type, metadata, and body must be JSON serializable");
481
+ }
482
+ if (Buffer.byteLength(encoded, "utf8") > MAX_NOTIFY_BYTES) {
483
+ throw new RangeError(`Postgres notification exceeds ${MAX_NOTIFY_BYTES} bytes`);
484
+ }
485
+ await this.pool.query("SELECT pg_notify($1, $2)", [this.channelName, encoded]);
486
+ return message;
487
+ }
488
+
489
+ /**
490
+ * Subscribe to `topic`, returning the function that unsubscribes.
491
+ *
492
+ * Connects on first use. Several listeners may share a topic; each is called
493
+ * once per message. Only messages published AFTER this resolves arrive, so
494
+ * subscribe before triggering whatever you expect to observe.
495
+ *
496
+ * The returned function removes just this listener and is safe to call twice.
497
+ * The connection stays open once the last listener leaves - {@link close}
498
+ * releases it - so a bus that subscribes and unsubscribes per request does not
499
+ * churn connections.
500
+ */
501
+ async listen<TBody extends SerializableValue>(
502
+ topic: string,
503
+ listener: TopicListener<TBody>,
504
+ ): Promise<() => Promise<void>> {
505
+ if (!topic.trim()) throw new TypeError("Topic must not be empty");
506
+ await this.start();
507
+ const listeners = this.listeners.get(topic) ?? new Set<TopicListener>();
508
+ listeners.add(listener as TopicListener);
509
+ this.listeners.set(topic, listeners);
510
+ return async () => {
511
+ listeners.delete(listener as TopicListener);
512
+ if (listeners.size === 0) this.listeners.delete(topic);
513
+ };
514
+ }
515
+
516
+ /**
517
+ * Build the automatic half of a message's metadata.
518
+ *
519
+ * Precedence, weakest first: machine/process context, then AppKit sender
520
+ * identity, then this bus's configured metadata. The caller's per-message
521
+ * metadata is layered over the result in {@link broadcast}, so the most specific
522
+ * source always wins and nothing here can overwrite an explicit key.
523
+ */
524
+ private async resolveMetadata(): Promise<TopicMetadata> {
525
+ const sender = await senderMetadata();
526
+ const configured =
527
+ typeof this.metadata === "function" ? await this.metadata() : (this.metadata ?? {});
528
+ return { ...machineMetadata(), ...sender, ...configured };
529
+ }
530
+
531
+ /**
532
+ * Release the notification connection and stop delivering messages. Idempotent.
533
+ *
534
+ * Cancels any pending reconnect, drops all listeners, then `UNLISTEN`s and
535
+ * returns the client to the pool. A failed `UNLISTEN` is reported to the pool as
536
+ * a release error so the connection is DISCARDED rather than handed to the next
537
+ * caller still subscribed to the channel. Does not throw; the bus stays closed.
538
+ */
539
+ async close(): Promise<void> {
540
+ if (this.closed) return;
541
+ this.closed = true;
542
+ this.reconnectAbort.abort(new Error("Postgres topic bus is closed"));
543
+ await this.starting?.catch(() => undefined);
544
+ await this.reconnecting?.catch(() => undefined);
545
+ const client = this.client;
546
+ this.client = undefined;
547
+ this.listeners.clear();
548
+ if (!client) return;
549
+ client.removeListener("notification", this.handleNotification);
550
+ client.removeListener("error", this.handleClientError);
551
+ let releaseError: Error | undefined;
552
+ try {
553
+ await client.query(`UNLISTEN ${quoteIdentifier(this.channelName)}`);
554
+ } catch (cause) {
555
+ releaseError = error.toError(cause);
556
+ }
557
+ client.release(releaseError);
558
+ }
559
+
560
+ /**
561
+ * Check out one client, attach the handlers, and `LISTEN`.
562
+ *
563
+ * The handlers are attached BEFORE the `LISTEN` round trip so a notification or
564
+ * error arriving mid-setup is not missed. Any failure - including the bus being
565
+ * closed while the connect was in flight - detaches the handlers and releases
566
+ * the client as errored, so a half-configured connection never returns to the
567
+ * pool.
568
+ */
569
+ private async connect(): Promise<void> {
570
+ const client = await this.pool.connect();
571
+ try {
572
+ client.on("notification", this.handleNotification);
573
+ client.on("error", this.handleClientError);
574
+ await client.query(`LISTEN ${quoteIdentifier(this.channelName)}`);
575
+ if (this.closed) throw new Error("Postgres topic bus is closed");
576
+ this.client = client;
577
+ } catch (cause) {
578
+ client.removeListener("notification", this.handleNotification);
579
+ client.removeListener("error", this.handleClientError);
580
+ client.release(error.toError(cause));
581
+ throw cause;
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Route one inbound notification to the topic's listeners.
587
+ *
588
+ * Ignores other channels and undecodable payloads - the channel is shared, so
589
+ * both are ordinary traffic. Listeners are invoked concurrently and their
590
+ * rejections go to `onError`, keeping one slow or broken subscriber from
591
+ * stalling the notification connection.
592
+ */
593
+ private readonly handleNotification = (notification: Notification): void => {
594
+ if (notification.channel !== this.channelName) return;
595
+ const message = decode(notification.payload);
596
+ if (!message) return;
597
+ for (const listener of this.listeners.get(message.topic) ?? []) {
598
+ Promise.resolve(listener(message)).catch(this.onError);
599
+ }
600
+ };
601
+
602
+ /**
603
+ * Handle the notification connection dying, which `pg` reports as an `error`
604
+ * event rather than a rejected query.
605
+ *
606
+ * The client is unusable at this point, so it is detached and released as
607
+ * errored (which discards it) before anything else. Reconnection only starts
608
+ * when there is still someone to deliver to, so an idle or closing bus does not
609
+ * hold a connection open chasing a channel nobody reads.
610
+ */
611
+ private readonly handleClientError = (cause: Error): void => {
612
+ this.onError(cause);
613
+ const client = this.client;
614
+ if (!client) return;
615
+ this.client = undefined;
616
+ client.removeListener("notification", this.handleNotification);
617
+ client.removeListener("error", this.handleClientError);
618
+ client.release(cause);
619
+ if (this.closed || this.listeners.size === 0) return;
620
+ this.reconnecting ??= this.reconnect().finally(() => {
621
+ this.reconnecting = undefined;
622
+ });
623
+ };
624
+
625
+ /**
626
+ * Re-establish the notification connection with bounded exponential backoff.
627
+ *
628
+ * The first attempt is immediate, since the common case is a single dropped
629
+ * connection that reconnects at once; subsequent delays double from 250ms up to
630
+ * 5s and stay there. Retries indefinitely rather than giving up, because a Postgres restart
631
+ * or a rotated Lakebase credential is a recoverable outage and a silently dead
632
+ * listener is worse than a noisy one. Stops when the bus closes or the last
633
+ * listener leaves, and reports every failed attempt through `onError`.
634
+ */
635
+ private async reconnect(): Promise<void> {
636
+ let delay = 0;
637
+ while (!this.closed && this.listeners.size > 0) {
638
+ if (delay > 0) {
639
+ try {
640
+ await asyncUtil.sleep(delay, this.reconnectAbort.signal);
641
+ } catch {
642
+ return;
643
+ }
644
+ }
645
+ try {
646
+ await this.start();
647
+ return;
648
+ } catch (cause) {
649
+ if (this.closed) return;
650
+ this.onError(cause);
651
+ delay = delay === 0 ? MIN_RECONNECT_DELAY_MS : Math.min(delay * 2, MAX_RECONNECT_DELAY_MS);
652
+ }
653
+ }
654
+ }
655
+ }