@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,272 @@
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
+ import { object } from "@dbx-tools/shared-core";
27
+ import type { PgPoolLike, PgQueryable } from "./advisory-lock.ts";
28
+ /**
29
+ * `@dbx-tools/shared-core` owns the JSON-round-trip rule; this alias just keeps the
30
+ * generic signatures below readable. Consumers import the type from shared-core.
31
+ */
32
+ type SerializableValue = object.SerializableValue;
33
+ /**
34
+ * Flat-keyed context travelling alongside a message body: who sent it, from
35
+ * where, in which deployment. Values may nest, but the keys are the addressable
36
+ * part - a listener filtering or labelling messages reads `metadata.user`, not a
37
+ * path into the body.
38
+ */
39
+ export type TopicMetadata = Record<string, SerializableValue>;
40
+ /**
41
+ * The wire envelope every subscriber receives, and what {@link
42
+ * PostgresTopicBus.broadcast} returns to the publisher.
43
+ *
44
+ * `id`, `topic`, and `publishedAt` are assigned by the bus; `type`, `metadata`,
45
+ * and `body` come from the caller (with automatic context merged under
46
+ * `metadata`). The shape is stable enough to hand straight to an SSE `data:`
47
+ * frame.
48
+ */
49
+ export interface TopicMessage<TBody extends SerializableValue = SerializableValue> {
50
+ /**
51
+ * Per-message identity generated by the publisher, unique across instances.
52
+ * Suitable for dedupe when a client reconnects and for an SSE `id:` field. Not
53
+ * ordered, and not a database key.
54
+ */
55
+ id: string;
56
+ /** The topic this was broadcast on; listeners on other topics never see it. */
57
+ topic: string;
58
+ /** Caller-chosen event name, e.g. `order.updated`. Never empty. */
59
+ type: string;
60
+ /** Automatic context merged with caller metadata. See {@link TopicMetadata}. */
61
+ metadata: TopicMetadata;
62
+ /** The caller's payload, unchanged. */
63
+ body: TBody;
64
+ /**
65
+ * ISO-8601 publish time from the PUBLISHING process's clock, not the database's.
66
+ * Fine for display; do not order messages from different instances by it.
67
+ */
68
+ publishedAt: string;
69
+ }
70
+ /** What a caller supplies to {@link PostgresTopicBus.broadcast}. */
71
+ export interface TopicPublishInput<TBody extends SerializableValue = SerializableValue> {
72
+ /** Event name, e.g. `chat.message`. Must be non-blank. */
73
+ type: string;
74
+ /**
75
+ * Context to attach. Wins over any automatic key of the same name, so a caller
76
+ * can override a machine default (`project`) or add its own (`traceId`).
77
+ */
78
+ metadata?: TopicMetadata;
79
+ /** The payload. Must satisfy `object.isSerializableValue`. */
80
+ body: TBody;
81
+ }
82
+ /**
83
+ * Subscriber callback. Invoked once per matching message on the notification
84
+ * connection's callback, so it should not block: a returned promise is awaited
85
+ * only to route a rejection to `onError`, and listeners for one message run
86
+ * concurrently rather than in registration order.
87
+ *
88
+ * `TBody` is unchecked at runtime. The bus guarantees the body is serializable,
89
+ * not that it matches the type parameter, so validate anything a listener
90
+ * branches on.
91
+ */
92
+ export type TopicListener<TBody extends SerializableValue = SerializableValue> = (message: TopicMessage<TBody>) => void | PromiseLike<void>;
93
+ /**
94
+ * Resolves metadata at publish time instead of construction time, for context a
95
+ * process learns late or asynchronously - a discovered public IP, an instance id
96
+ * from a control plane. Called on every broadcast, so memoize anything expensive;
97
+ * a rejection fails the broadcast.
98
+ */
99
+ export type TopicMetadataProvider = () => TopicMetadata | PromiseLike<TopicMetadata>;
100
+ /** Construction options for {@link PostgresTopicBus}. */
101
+ export interface PostgresTopicBusOptions {
102
+ /**
103
+ * What identifies this channel - anything, not just an identifier: a name, an
104
+ * id, a `[env, feature]` pair, a config object. The parts are tokenized into a
105
+ * legal Postgres channel name with a short hash of the originals appended, so
106
+ * the derivation is deterministic and no call site has to sanitize. See
107
+ * {@link PostgresTopicBus.channelName} for the resolved name.
108
+ *
109
+ * One value or many: an array is read as multiple parts
110
+ * ({@link object.OneOrMany}), anything else as a single part. So
111
+ * `["billing", "prod"]` and `"billing_prod"` are different channels, since the
112
+ * hash sees different structure.
113
+ *
114
+ * Every participating process must pass EQUIVALENT parts, since a different
115
+ * spelling hashes to a different channel. Defaults to a shared
116
+ * `dbx_tools_topic_bus` channel.
117
+ */
118
+ channel?: unknown;
119
+ /**
120
+ * Extra context added to every message this bus publishes, either a fixed
121
+ * record or a {@link TopicMetadataProvider} called per broadcast. Overrides
122
+ * automatic machine keys; per-call `metadata` overrides this.
123
+ */
124
+ metadata?: TopicMetadata | TopicMetadataProvider;
125
+ /**
126
+ * Sink for failures that have no caller to throw to: a listener that rejected,
127
+ * a dropped notification connection, a failed reconnect attempt. Defaults to
128
+ * swallowing them, so wire this to a logger in anything long-running.
129
+ */
130
+ onError?: (cause: unknown) => void;
131
+ }
132
+ /**
133
+ * Broadcasts structured messages by topic and delivers them to every process
134
+ * listening on the same channel.
135
+ *
136
+ * CONNECTION SHAPE. Publishing borrows a pooled connection per call, like any
137
+ * other query. Listening cannot: `LISTEN` is session state, so the bus holds ONE
138
+ * dedicated client out of the pool for as long as it has subscribers, no matter
139
+ * how many topics or listeners are registered. Size the pool with that one
140
+ * long-lived checkout in mind.
141
+ *
142
+ * LIFECYCLE. Construction is inert - nothing connects until the first
143
+ * {@link listen} (or an explicit {@link start}). {@link close} is required to give
144
+ * the connection back; a closed bus stays closed and throws on further use rather
145
+ * than silently reconnecting. Register it with the host's shutdown hook.
146
+ *
147
+ * FAILURE HANDLING. A lost notification connection reconnects on its own with
148
+ * bounded backoff while subscribers remain, reporting each failed attempt through
149
+ * `onError`. Messages published during the gap are lost - `NOTIFY` has no replay.
150
+ * A throwing or rejecting listener never affects the publisher or the other
151
+ * listeners; its failure goes to `onError`.
152
+ *
153
+ * Not safe to share one instance across unrelated channels - construct one bus
154
+ * per channel. The channel itself is DERIVED from the `channel` option rather
155
+ * than taken literally; see {@link PostgresTopicBusOptions.channel} and
156
+ * {@link PostgresTopicBus.channelName}.
157
+ */
158
+ export declare class PostgresTopicBus {
159
+ private readonly pool;
160
+ /**
161
+ * The resolved Postgres channel this bus listens and publishes on, derived from
162
+ * the `channel` option. Read it to confirm two processes agree, or to log what a
163
+ * set of parts actually resolved to.
164
+ */
165
+ readonly channelName: string;
166
+ private readonly metadata;
167
+ private readonly onError;
168
+ private readonly listeners;
169
+ private client;
170
+ private starting;
171
+ private reconnecting;
172
+ private readonly reconnectAbort;
173
+ private closed;
174
+ constructor(pool: PgPoolLike & PgQueryable, options?: PostgresTopicBusOptions);
175
+ /**
176
+ * Open the dedicated notification connection and `LISTEN`.
177
+ *
178
+ * Idempotent, and safe to call concurrently - overlapping calls await the same
179
+ * in-flight connect. {@link listen} calls this, so it is only needed to surface
180
+ * a connection problem at startup rather than on first subscribe. Throws if the
181
+ * bus is closed, or if the pool cannot hand out a connection.
182
+ */
183
+ start(): Promise<void>;
184
+ /**
185
+ * Publish a message to `topic`, returning the envelope that was sent.
186
+ *
187
+ * Resolves once Postgres has accepted the `NOTIFY`, which says nothing about
188
+ * anyone receiving it: sessions not listening at that moment miss it. Publishing
189
+ * needs no {@link start} and no subscribers.
190
+ *
191
+ * Validation is deliberately front-loaded, since a message that fails on the
192
+ * wire is far harder to diagnose than one rejected at the call: `TypeError` for a
193
+ * blank topic or type, or a body/metadata that would not round-trip through JSON
194
+ * unchanged (`object.isSerializableValue`); `RangeError` when the encoded
195
+ * envelope exceeds the `NOTIFY` payload limit, which the automatic metadata
196
+ * counts against - send a reference and let the receiver fetch the payload.
197
+ * Throws if the bus is closed.
198
+ */
199
+ broadcast<TBody extends SerializableValue>(topic: string, input: TopicPublishInput<TBody>): Promise<TopicMessage<TBody>>;
200
+ /**
201
+ * Subscribe to `topic`, returning the function that unsubscribes.
202
+ *
203
+ * Connects on first use. Several listeners may share a topic; each is called
204
+ * once per message. Only messages published AFTER this resolves arrive, so
205
+ * subscribe before triggering whatever you expect to observe.
206
+ *
207
+ * The returned function removes just this listener and is safe to call twice.
208
+ * The connection stays open once the last listener leaves - {@link close}
209
+ * releases it - so a bus that subscribes and unsubscribes per request does not
210
+ * churn connections.
211
+ */
212
+ listen<TBody extends SerializableValue>(topic: string, listener: TopicListener<TBody>): Promise<() => Promise<void>>;
213
+ /**
214
+ * Build the automatic half of a message's metadata.
215
+ *
216
+ * Precedence, weakest first: machine/process context, then AppKit sender
217
+ * identity, then this bus's configured metadata. The caller's per-message
218
+ * metadata is layered over the result in {@link broadcast}, so the most specific
219
+ * source always wins and nothing here can overwrite an explicit key.
220
+ */
221
+ private resolveMetadata;
222
+ /**
223
+ * Release the notification connection and stop delivering messages. Idempotent.
224
+ *
225
+ * Cancels any pending reconnect, drops all listeners, then `UNLISTEN`s and
226
+ * returns the client to the pool. A failed `UNLISTEN` is reported to the pool as
227
+ * a release error so the connection is DISCARDED rather than handed to the next
228
+ * caller still subscribed to the channel. Does not throw; the bus stays closed.
229
+ */
230
+ close(): Promise<void>;
231
+ /**
232
+ * Check out one client, attach the handlers, and `LISTEN`.
233
+ *
234
+ * The handlers are attached BEFORE the `LISTEN` round trip so a notification or
235
+ * error arriving mid-setup is not missed. Any failure - including the bus being
236
+ * closed while the connect was in flight - detaches the handlers and releases
237
+ * the client as errored, so a half-configured connection never returns to the
238
+ * pool.
239
+ */
240
+ private connect;
241
+ /**
242
+ * Route one inbound notification to the topic's listeners.
243
+ *
244
+ * Ignores other channels and undecodable payloads - the channel is shared, so
245
+ * both are ordinary traffic. Listeners are invoked concurrently and their
246
+ * rejections go to `onError`, keeping one slow or broken subscriber from
247
+ * stalling the notification connection.
248
+ */
249
+ private readonly handleNotification;
250
+ /**
251
+ * Handle the notification connection dying, which `pg` reports as an `error`
252
+ * event rather than a rejected query.
253
+ *
254
+ * The client is unusable at this point, so it is detached and released as
255
+ * errored (which discards it) before anything else. Reconnection only starts
256
+ * when there is still someone to deliver to, so an idle or closing bus does not
257
+ * hold a connection open chasing a channel nobody reads.
258
+ */
259
+ private readonly handleClientError;
260
+ /**
261
+ * Re-establish the notification connection with bounded exponential backoff.
262
+ *
263
+ * The first attempt is immediate, since the common case is a single dropped
264
+ * connection that reconnects at once; subsequent delays double from 250ms up to
265
+ * 5s and stay there. Retries indefinitely rather than giving up, because a Postgres restart
266
+ * or a rotated Lakebase credential is a recoverable outage and a silently dead
267
+ * listener is worse than a noisy one. Stops when the bus closes or the last
268
+ * listener leaves, and reports every failed attempt through `onError`.
269
+ */
270
+ private reconnect;
271
+ }
272
+ export {};