@rebasepro/server-postgres 0.10.0 → 0.10.1-canary.0a881d4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBootstrapper.d.ts +7 -3
- package/dist/auth/services.d.ts +43 -4
- package/dist/backup/backup-logic.d.ts +23 -0
- package/dist/backup/backup-service.d.ts +44 -2
- package/dist/backup/pg-tools.d.ts +41 -1
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-helpers.d.ts +33 -1
- package/dist/ensure-collection-tables-C9gy4STB.js +304 -0
- package/dist/ensure-collection-tables-C9gy4STB.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1480 -4648
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +114 -6
- package/dist/src-CBgtrPhJ.js +336 -0
- package/dist/src-CBgtrPhJ.js.map +1 -0
- package/dist/src-DG6ZsQQ3.js +4026 -0
- package/dist/src-DG6ZsQQ3.js.map +1 -0
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +72 -3
- package/src/auth/ensure-tables.ts +91 -3
- package/src/auth/services.ts +186 -48
- package/src/backup/backup-cli.ts +60 -1
- package/src/backup/backup-cron.ts +24 -1
- package/src/backup/backup-logic.ts +62 -0
- package/src/backup/backup-service.ts +132 -13
- package/src/backup/pg-tools.ts +70 -2
- package/src/cli-helpers.ts +82 -27
- package/src/cli.ts +152 -6
- package/src/index.ts +4 -0
- package/src/schema/auth-schema.ts +41 -3
- package/src/schema/destructive-sql.ts +94 -0
- package/src/schema/doctor.ts +6 -6
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +13 -9
- package/src/schema/generate-postgres-ddl-logic.ts +22 -15
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +6 -6
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +383 -14
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution of the channel bus from config, environment, or a supplied instance.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in, like every other cross-cutting realtime switch here: with nothing
|
|
5
|
+
* configured a deployment gets the memory bus and behaves exactly as it did
|
|
6
|
+
* before this existed. Unlike `REALTIME_CDC=auto`, there is no "try it and see"
|
|
7
|
+
* default — a bus changes where messages go, and quietly turning on a Postgres
|
|
8
|
+
* NOTIFY per broadcast because a direct URL happened to be set is not a
|
|
9
|
+
* decision to make on the user's behalf.
|
|
10
|
+
*
|
|
11
|
+
* Two transports ship, and neither adds a service to a deployment. A third is
|
|
12
|
+
* not a code change here: `realtime.bus` also accepts an already-constructed
|
|
13
|
+
* {@link ChannelBus}, so a transport published as its own package plugs in
|
|
14
|
+
* without this file learning about it. See `@rebasepro/types` →
|
|
15
|
+
* `types/channel_bus.ts` for the contract such a package implements.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
19
|
+
import { isChannelBusInstance, type ChannelBus, type ChannelBusConfig, type ChannelBusSetting } from "@rebasepro/types";
|
|
20
|
+
import { logger } from "@rebasepro/server";
|
|
21
|
+
import { MemoryChannelBus } from "./ChannelBus";
|
|
22
|
+
import { PostgresChannelBus } from "./PostgresChannelBus";
|
|
23
|
+
|
|
24
|
+
export * from "./ChannelBus";
|
|
25
|
+
export {
|
|
26
|
+
PostgresChannelBus,
|
|
27
|
+
CHANNEL_BUS_NOTIFY_CHANNEL,
|
|
28
|
+
PG_NOTIFY_MAX_PAYLOAD_BYTES,
|
|
29
|
+
DEFAULT_BATCH_WINDOW_MS,
|
|
30
|
+
parseChannelBusFrame,
|
|
31
|
+
parseChannelBusPayload
|
|
32
|
+
} from "./PostgresChannelBus";
|
|
33
|
+
|
|
34
|
+
export interface ChannelBusDeps {
|
|
35
|
+
db: NodePgDatabase<Record<string, unknown>>;
|
|
36
|
+
/**
|
|
37
|
+
* Direct (non-pooled) Postgres URL for the LISTEN client. `LISTEN` is
|
|
38
|
+
* session state, so behind PgBouncer in transaction mode this must be the
|
|
39
|
+
* database itself and not the pooler.
|
|
40
|
+
*/
|
|
41
|
+
directUrl?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merge `REALTIME_CHANNEL_BUS` into the configured bus.
|
|
46
|
+
*
|
|
47
|
+
* The environment wins over a *named* built-in, so the transport can be changed
|
|
48
|
+
* per deployment without a rebuild — the same reason `REALTIME_CDC` is an env
|
|
49
|
+
* var. It does **not** win over a supplied instance: the env var can only name
|
|
50
|
+
* transports this package knows how to construct, so honouring it there would
|
|
51
|
+
* mean silently discarding the object the application handed us.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveChannelBusSetting(configured?: ChannelBusSetting): ChannelBusSetting {
|
|
54
|
+
const raw = (process.env.REALTIME_CHANNEL_BUS || "").trim().toLowerCase();
|
|
55
|
+
|
|
56
|
+
if (isChannelBusInstance(configured)) {
|
|
57
|
+
if (raw && raw !== configured.kind) {
|
|
58
|
+
logger.warn(
|
|
59
|
+
`⚠️ [ChannelBus] REALTIME_CHANNEL_BUS="${raw}" is ignored because realtime.bus was given a ` +
|
|
60
|
+
`"${configured.kind}" transport instance directly. Remove one of the two to make the intent clear.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return configured;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!raw) return configured ?? { type: "memory" };
|
|
67
|
+
|
|
68
|
+
if (raw !== "memory" && raw !== "postgres") {
|
|
69
|
+
logger.warn(
|
|
70
|
+
`⚠️ [ChannelBus] Unknown REALTIME_CHANNEL_BUS value "${raw}" — expected memory|postgres, or pass a ` +
|
|
71
|
+
"ChannelBus instance as realtime.bus for a transport that ships separately. Falling back to the " +
|
|
72
|
+
"configured bus."
|
|
73
|
+
);
|
|
74
|
+
return configured ?? { type: "memory" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Keep the configured options (an explicit connection string) when the env
|
|
78
|
+
// var only restates the type it was already set to.
|
|
79
|
+
if (configured?.type === raw) return configured;
|
|
80
|
+
return raw === "memory" ? { type: "memory" } : { type: "postgres" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
|
|
85
|
+
* supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
|
|
86
|
+
* config-only callers keep their exact types.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig {
|
|
89
|
+
return resolveChannelBusSetting(configured) as ChannelBusConfig;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Produce the bus a setting asks for.
|
|
94
|
+
*
|
|
95
|
+
* An instance is handed straight back — constructing it was the application's
|
|
96
|
+
* job, and this function has nothing to add. A named built-in that turns out to
|
|
97
|
+
* be unusable degrades to the memory bus, with the reason logged, rather than
|
|
98
|
+
* throwing: a misconfigured bus should cost a deployment its cross-instance
|
|
99
|
+
* fan-out, not its ability to boot.
|
|
100
|
+
*/
|
|
101
|
+
export function createChannelBus(setting: ChannelBusSetting, deps: ChannelBusDeps): ChannelBus {
|
|
102
|
+
if (isChannelBusInstance(setting)) return setting;
|
|
103
|
+
|
|
104
|
+
switch (setting.type) {
|
|
105
|
+
case "postgres": {
|
|
106
|
+
const connectionString = setting.connectionString || deps.directUrl;
|
|
107
|
+
if (!connectionString) {
|
|
108
|
+
logger.warn(
|
|
109
|
+
"⚠️ [ChannelBus] realtime.bus is \"postgres\" but no direct database URL is available " +
|
|
110
|
+
"(set DATABASE_DIRECT_URL or realtime.bus.connectionString) — channel broadcast and presence " +
|
|
111
|
+
"stay per-instance."
|
|
112
|
+
);
|
|
113
|
+
return new MemoryChannelBus();
|
|
114
|
+
}
|
|
115
|
+
return new PostgresChannelBus(deps.db, connectionString, {
|
|
116
|
+
batchWindowMs: setting.batchWindowMs
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
case "memory":
|
|
120
|
+
default:
|
|
121
|
+
return new MemoryChannelBus();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -290,6 +290,41 @@ export class ChannelHistoryStore {
|
|
|
290
290
|
return { messages, latestSeq };
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* One retained message by its address.
|
|
295
|
+
*
|
|
296
|
+
* This is what makes the cross-instance pointer path work: a broadcast too
|
|
297
|
+
* large to travel inside a `pg_notify` payload is already stored here, so
|
|
298
|
+
* the notification carries `(channel, seq)` and each receiving instance
|
|
299
|
+
* reads the body back. Returns null when the message has since been pruned
|
|
300
|
+
* — a receiver that is that far behind has nothing useful to deliver, and
|
|
301
|
+
* the client's own `channel_history` replay is the repair path.
|
|
302
|
+
*/
|
|
303
|
+
async getBySeq(channel: string, seq: number): Promise<ChannelHistoryEntry | null> {
|
|
304
|
+
const result = await this.db.execute(sql`
|
|
305
|
+
SELECT seq, event, payload, sender_id, created_at
|
|
306
|
+
FROM rebase.channel_messages
|
|
307
|
+
WHERE channel = ${channel} AND seq = ${seq}
|
|
308
|
+
`);
|
|
309
|
+
|
|
310
|
+
const row = result.rows[0] as {
|
|
311
|
+
seq: string | number;
|
|
312
|
+
event: string;
|
|
313
|
+
payload: unknown;
|
|
314
|
+
sender_id: string | null;
|
|
315
|
+
created_at: Date | string;
|
|
316
|
+
} | undefined;
|
|
317
|
+
if (!row) return null;
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
seq: Number(row.seq),
|
|
321
|
+
event: row.event,
|
|
322
|
+
payload: row.payload,
|
|
323
|
+
senderId: row.sender_id ?? undefined,
|
|
324
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
293
328
|
/**
|
|
294
329
|
* Enforce a channel's retention bounds.
|
|
295
330
|
*
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared presence roster.
|
|
3
|
+
*
|
|
4
|
+
* Broadcast only ever needed *fan-out* to work across instances — a frame goes
|
|
5
|
+
* out, whoever is connected receives it. Presence needs more than that, because
|
|
6
|
+
* `presence_state` is a question ("who is in this document?") and a per-process
|
|
7
|
+
* `Map` can only answer for the clients that happen to share a replica with the
|
|
8
|
+
* asker. Two people editing the same scene through different pods would each
|
|
9
|
+
* see an empty room while broadcasting cursors at each other perfectly.
|
|
10
|
+
*
|
|
11
|
+
* So presence gets one row per tracked client, in Postgres, readable by every
|
|
12
|
+
* instance. Three consequences worth stating:
|
|
13
|
+
*
|
|
14
|
+
* - **The table is the roster; the in-process map is a cache of our own
|
|
15
|
+
* clients.** Reads answer from the table when this store is active, so the
|
|
16
|
+
* answer is the same whichever instance is asked.
|
|
17
|
+
*
|
|
18
|
+
* - **`last_seen` is the liveness signal, and it is already there.** The client
|
|
19
|
+
* heartbeats presence every ~20 s against a 30 s window; the sweep that has
|
|
20
|
+
* always reaped local stale entries now also reaps rows belonging to
|
|
21
|
+
* instances that stopped writing — which is exactly what a crashed pod looks
|
|
22
|
+
* like. Crash recovery is a property of the TTL, not a separate mechanism.
|
|
23
|
+
*
|
|
24
|
+
* - **The sweep deletes with `RETURNING`.** Whichever instance wins the delete
|
|
25
|
+
* is the one that announces the departures, so a stale client produces one
|
|
26
|
+
* `presence_diff` for the cluster rather than one per replica.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { sql } from "drizzle-orm";
|
|
30
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
31
|
+
|
|
32
|
+
/** A tracked client, as any instance sees it. */
|
|
33
|
+
export interface PresenceRow {
|
|
34
|
+
channel: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
state: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ChannelPresenceStore {
|
|
40
|
+
private tablesReady = false;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly db: NodePgDatabase<Record<string, unknown>>,
|
|
44
|
+
private readonly instanceId: string
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
/** Create the roster table. Idempotent. */
|
|
48
|
+
async ensureTables(): Promise<void> {
|
|
49
|
+
if (this.tablesReady) return;
|
|
50
|
+
|
|
51
|
+
await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
52
|
+
|
|
53
|
+
// Keyed by (channel, client_id): a client id is globally unique, so the
|
|
54
|
+
// instance is a column rather than part of the identity — a client that
|
|
55
|
+
// reconnects onto another replica replaces its own row instead of
|
|
56
|
+
// appearing twice in the roster.
|
|
57
|
+
await this.db.execute(sql`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_presence (
|
|
59
|
+
channel TEXT NOT NULL,
|
|
60
|
+
client_id TEXT NOT NULL,
|
|
61
|
+
instance_id TEXT NOT NULL,
|
|
62
|
+
state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
63
|
+
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
64
|
+
PRIMARY KEY (channel, client_id)
|
|
65
|
+
)
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
// The sweep's access path; the roster read rides the primary key.
|
|
69
|
+
await this.db.execute(sql`
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
71
|
+
ON rebase.channel_presence (last_seen)
|
|
72
|
+
`);
|
|
73
|
+
|
|
74
|
+
this.tablesReady = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Record (or refresh) a client's presence. */
|
|
78
|
+
async track(channel: string, clientId: string, state: Record<string, unknown>): Promise<void> {
|
|
79
|
+
await this.db.execute(sql`
|
|
80
|
+
INSERT INTO rebase.channel_presence (channel, client_id, instance_id, state, last_seen)
|
|
81
|
+
VALUES (${channel}, ${clientId}, ${this.instanceId}, ${JSON.stringify(state ?? {})}::jsonb, NOW())
|
|
82
|
+
ON CONFLICT (channel, client_id) DO UPDATE
|
|
83
|
+
SET state = EXCLUDED.state,
|
|
84
|
+
instance_id = EXCLUDED.instance_id,
|
|
85
|
+
last_seen = NOW()
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Drop one client's presence in one channel. */
|
|
90
|
+
async remove(channel: string, clientId: string): Promise<void> {
|
|
91
|
+
await this.db.execute(sql`
|
|
92
|
+
DELETE FROM rebase.channel_presence
|
|
93
|
+
WHERE channel = ${channel} AND client_id = ${clientId}
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Drop a client from every channel — used when its socket closes. */
|
|
98
|
+
async removeClient(clientId: string): Promise<void> {
|
|
99
|
+
await this.db.execute(sql`
|
|
100
|
+
DELETE FROM rebase.channel_presence WHERE client_id = ${clientId}
|
|
101
|
+
`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The global roster for a channel. */
|
|
105
|
+
async roster(channel: string): Promise<Record<string, Record<string, unknown>>> {
|
|
106
|
+
const result = await this.db.execute(sql`
|
|
107
|
+
SELECT client_id, state FROM rebase.channel_presence WHERE channel = ${channel}
|
|
108
|
+
`);
|
|
109
|
+
|
|
110
|
+
const presences: Record<string, Record<string, unknown>> = {};
|
|
111
|
+
for (const row of result.rows as Array<{ client_id: string; state: Record<string, unknown> | null }>) {
|
|
112
|
+
presences[row.client_id] = row.state ?? {};
|
|
113
|
+
}
|
|
114
|
+
return presences;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reap rows this instance is not responsible for and that have gone quiet.
|
|
119
|
+
*
|
|
120
|
+
* Own rows are excluded because the in-process sweep already handles them —
|
|
121
|
+
* and handles them better, since it can tell "the socket is gone" from "the
|
|
122
|
+
* heartbeat is late". What is left is precisely the interesting case: rows
|
|
123
|
+
* written by an instance that is no longer writing.
|
|
124
|
+
*
|
|
125
|
+
* Returns what was removed, so the caller can announce it.
|
|
126
|
+
*/
|
|
127
|
+
async sweepStale(ttlMs: number): Promise<PresenceRow[]> {
|
|
128
|
+
const result = await this.db.execute(sql`
|
|
129
|
+
DELETE FROM rebase.channel_presence
|
|
130
|
+
WHERE instance_id <> ${this.instanceId}
|
|
131
|
+
AND last_seen < NOW() - MAKE_INTERVAL(secs => ${ttlMs / 1000})
|
|
132
|
+
RETURNING channel, client_id, state
|
|
133
|
+
`);
|
|
134
|
+
|
|
135
|
+
return (result.rows as Array<{ channel: string; client_id: string; state: Record<string, unknown> | null }>)
|
|
136
|
+
.map(row => ({ channel: row.channel, clientId: row.client_id, state: row.state ?? {} }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Remove every row this instance owns. Called on graceful shutdown so a
|
|
141
|
+
* rolling deploy does not leave a TTL window of ghosts in every roster.
|
|
142
|
+
*/
|
|
143
|
+
async removeInstance(): Promise<void> {
|
|
144
|
+
await this.db.execute(sql`
|
|
145
|
+
DELETE FROM rebase.channel_presence WHERE instance_id = ${this.instanceId}
|
|
146
|
+
`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dedicated, self-healing Postgres `LISTEN` connection.
|
|
3
|
+
*
|
|
4
|
+
* Every cross-instance feature in the backend needs the same thing: one
|
|
5
|
+
* connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
|
|
6
|
+
* comes back on its own after the database or the network drops it. CDC needed
|
|
7
|
+
* it first; the channel bus needs it too. This is that connection, with the one
|
|
8
|
+
* behaviour that matters to callers preserved: the **first** connect is
|
|
9
|
+
* validated and rethrown, so a caller can fall back to a different strategy,
|
|
10
|
+
* while every later drop is repaired quietly in the background.
|
|
11
|
+
*
|
|
12
|
+
* `LISTEN` is session state, so this connection must not go through a
|
|
13
|
+
* transaction-mode pooler (PgBouncer): give it the direct database URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Client as PgClient } from "pg";
|
|
17
|
+
import { logger } from "@rebasepro/server";
|
|
18
|
+
|
|
19
|
+
export interface PgNotifyListenerOptions {
|
|
20
|
+
/** Direct Postgres connection string (must bypass a transaction-mode pooler). */
|
|
21
|
+
connectionString: string;
|
|
22
|
+
/** NOTIFY channel to LISTEN on. Must be a plain identifier — it is interpolated. */
|
|
23
|
+
channel: string;
|
|
24
|
+
/** Called for every notification payload received. */
|
|
25
|
+
onPayload: (payload: string) => void | Promise<void>;
|
|
26
|
+
/** Prefix for log lines, e.g. `"[CDC]"`. */
|
|
27
|
+
logLabel: string;
|
|
28
|
+
/** Delay before a reconnect attempt. */
|
|
29
|
+
reconnectDelayMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_RECONNECT_DELAY_MS = 3000;
|
|
33
|
+
/** Guards the identifier interpolated into `LISTEN`. */
|
|
34
|
+
const SAFE_CHANNEL = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
+
|
|
36
|
+
export class PgNotifyListener {
|
|
37
|
+
private client?: PgClient;
|
|
38
|
+
private running = false;
|
|
39
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
40
|
+
|
|
41
|
+
constructor(private readonly options: PgNotifyListenerOptions) {
|
|
42
|
+
if (!SAFE_CHANNEL.test(options.channel)) {
|
|
43
|
+
throw new Error(`Unsafe NOTIFY channel name "${options.channel}" — expected a plain SQL identifier.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether the listener is meant to be connected right now. */
|
|
48
|
+
get active(): boolean {
|
|
49
|
+
return this.running;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Connect and begin listening. Idempotent.
|
|
54
|
+
*
|
|
55
|
+
* Rejects if the *initial* connection or `LISTEN` fails, leaving the
|
|
56
|
+
* listener stopped — callers use that to degrade deliberately instead of
|
|
57
|
+
* running blind against a channel nothing is delivering.
|
|
58
|
+
*/
|
|
59
|
+
async start(): Promise<void> {
|
|
60
|
+
if (this.running) return;
|
|
61
|
+
this.running = true;
|
|
62
|
+
try {
|
|
63
|
+
await this.connect({ initial: true });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
this.running = false;
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stop listening and release the connection. Idempotent. */
|
|
71
|
+
async stop(): Promise<void> {
|
|
72
|
+
this.running = false;
|
|
73
|
+
if (this.reconnectTimer) {
|
|
74
|
+
clearTimeout(this.reconnectTimer);
|
|
75
|
+
this.reconnectTimer = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (this.client) {
|
|
78
|
+
try {
|
|
79
|
+
await this.client.end();
|
|
80
|
+
} catch { /* ignore close errors */ }
|
|
81
|
+
this.client = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
|
|
86
|
+
const { connectionString, channel, onPayload, logLabel } = this.options;
|
|
87
|
+
try {
|
|
88
|
+
const client = new PgClient({ connectionString });
|
|
89
|
+
|
|
90
|
+
client.on("error", (err) => {
|
|
91
|
+
logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
|
|
92
|
+
this.scheduleReconnect();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
client.on("end", () => {
|
|
96
|
+
if (this.running) {
|
|
97
|
+
logger.warn(`⚠️ ${logLabel} LISTEN client disconnected unexpectedly.`);
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
client.on("notification", (msg) => {
|
|
103
|
+
if (!msg.payload) return;
|
|
104
|
+
// A handler rejection must never surface as an unhandled
|
|
105
|
+
// rejection inside the pg client's event emitter.
|
|
106
|
+
Promise.resolve(onPayload(msg.payload)).catch((err) =>
|
|
107
|
+
logger.error(`❌ ${logLabel} Error handling notification`, { error: err })
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await client.connect();
|
|
112
|
+
await client.query(`LISTEN ${channel}`);
|
|
113
|
+
this.client = client;
|
|
114
|
+
logger.info(`📡 ${logLabel} Listening on channel "${channel}".`);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Surface the initial failure so callers can choose to fall back;
|
|
117
|
+
// for reconnects, keep retrying quietly in the background.
|
|
118
|
+
if (initial) throw err;
|
|
119
|
+
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
120
|
+
this.scheduleReconnect();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private scheduleReconnect(): void {
|
|
125
|
+
if (!this.running || this.reconnectTimer) return;
|
|
126
|
+
|
|
127
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
128
|
+
this.reconnectTimer = undefined;
|
|
129
|
+
if (!this.running) return;
|
|
130
|
+
if (this.client) {
|
|
131
|
+
try { await this.client.end(); } catch { /* ignore */ }
|
|
132
|
+
this.client = undefined;
|
|
133
|
+
}
|
|
134
|
+
await this.connect();
|
|
135
|
+
}, this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS);
|
|
136
|
+
}
|
|
137
|
+
}
|