@rebasepro/server-postgres 0.9.1-canary.ff338b5 → 0.10.1-canary.14e53ae

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 (70) hide show
  1. package/README.md +21 -0
  2. package/dist/PostgresBackendDriver.d.ts +18 -0
  3. package/dist/PostgresBootstrapper.d.ts +11 -1
  4. package/dist/auth/services.d.ts +93 -54
  5. package/dist/backup/backup-logic.d.ts +23 -0
  6. package/dist/backup/backup-service.d.ts +44 -2
  7. package/dist/backup/pg-tools.d.ts +41 -1
  8. package/dist/chunk-DSJWtz9O.js +40 -0
  9. package/dist/cli-helpers.d.ts +33 -1
  10. package/dist/ensure-collection-tables-CNlIONzj.js +304 -0
  11. package/dist/ensure-collection-tables-CNlIONzj.js.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.es.js +2090 -4741
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
  16. package/dist/schema/auth-schema.d.ts +194 -24
  17. package/dist/schema/destructive-sql.d.ts +49 -0
  18. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  19. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  20. package/dist/schema/introspect-db-logic.d.ts +0 -5
  21. package/dist/schema/introspect-db-naming.d.ts +10 -0
  22. package/dist/security/policy-drift.d.ts +24 -0
  23. package/dist/security/rls-enforcement.d.ts +2 -2
  24. package/dist/services/cdc/CdcListener.d.ts +7 -14
  25. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  26. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  27. package/dist/services/channel-bus/index.d.ts +55 -0
  28. package/dist/services/channel-history.d.ts +129 -0
  29. package/dist/services/channel-presence.d.ts +66 -0
  30. package/dist/services/pg-notify-listener.d.ts +47 -0
  31. package/dist/services/realtimeService.d.ts +183 -8
  32. package/dist/src-B0v4IKaI.js +329 -0
  33. package/dist/src-B0v4IKaI.js.map +1 -0
  34. package/dist/src-DmsRg8MR.js +4056 -0
  35. package/dist/src-DmsRg8MR.js.map +1 -0
  36. package/package.json +7 -31
  37. package/src/PostgresBackendDriver.ts +56 -5
  38. package/src/PostgresBootstrapper.ts +87 -1
  39. package/src/auth/ensure-tables.ts +187 -19
  40. package/src/auth/services.ts +309 -170
  41. package/src/backup/backup-cli.ts +60 -1
  42. package/src/backup/backup-cron.ts +24 -1
  43. package/src/backup/backup-logic.ts +62 -0
  44. package/src/backup/backup-service.ts +132 -13
  45. package/src/backup/pg-tools.ts +70 -2
  46. package/src/cli-helpers.ts +82 -27
  47. package/src/cli.ts +152 -6
  48. package/src/index.ts +4 -0
  49. package/src/schema/auth-bootstrap-sql.ts +7 -1
  50. package/src/schema/auth-schema.ts +53 -15
  51. package/src/schema/destructive-sql.ts +94 -0
  52. package/src/schema/ensure-collection-tables.test.ts +156 -0
  53. package/src/schema/ensure-collection-tables.ts +297 -0
  54. package/src/schema/generate-postgres-ddl-logic.ts +3 -3
  55. package/src/schema/introspect-db-inference.ts +1 -1
  56. package/src/schema/introspect-db-logic.ts +1 -10
  57. package/src/schema/introspect-db-naming.ts +15 -0
  58. package/src/schema/introspect-runtime.ts +1 -1
  59. package/src/security/policy-drift.test.ts +46 -0
  60. package/src/security/policy-drift.ts +70 -4
  61. package/src/security/rls-enforcement.ts +11 -5
  62. package/src/services/cdc/CdcListener.ts +27 -91
  63. package/src/services/channel-bus/ChannelBus.ts +44 -0
  64. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  65. package/src/services/channel-bus/index.ts +123 -0
  66. package/src/services/channel-history.ts +378 -0
  67. package/src/services/channel-presence.ts +148 -0
  68. package/src/services/pg-notify-listener.ts +137 -0
  69. package/src/services/realtimeService.ts +581 -24
  70. package/src/websocket.ts +30 -11
@@ -29,6 +29,14 @@ export interface PolicyRef {
29
29
  hasUsing: boolean;
30
30
  /** Whether a WITH CHECK clause is present at all (not what it says). */
31
31
  hasWithCheck: boolean;
32
+ /**
33
+ * The live clause text, when read from `pg_policies`. Present only for live
34
+ * policies (the expected side is parsed from DDL and does not carry it).
35
+ * Used solely for the insecure-tautology scan, not for divergence — Postgres
36
+ * rewrites this text, so it is not safe to diff against expected.
37
+ */
38
+ qual?: string | null;
39
+ withCheck?: string | null;
32
40
  }
33
41
 
34
42
  export interface PolicyDrift {
@@ -38,6 +46,19 @@ export interface PolicyDrift {
38
46
  orphaned: PolicyRef[];
39
47
  /** Same policy name, different roles or command. */
40
48
  diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];
49
+ /**
50
+ * A live policy whose expression is the known-permissive tautology
51
+ * `auth.uid() IS NOT NULL` — true for anonymous visitors too, because the
52
+ * user path coerces a blank id to the `'anonymous'` sentinel. This is what
53
+ * `policy.authenticated()` used to compile to, so a database pushed before
54
+ * that fix carries it, and neither the name, roles, command nor clause
55
+ * *presence* differs from the corrected policy — the only thing that changed
56
+ * is the expression text, which this checker otherwise (correctly) ignores.
57
+ * So it is the one drift that hides from every other check here.
58
+ *
59
+ * @see reason a sentence naming the clause and what to do.
60
+ */
61
+ insecure: { policy: PolicyRef; reason: string }[];
41
62
  }
42
63
 
43
64
  export interface Queryable {
@@ -122,10 +143,30 @@ async function readLivePolicies(client: Queryable, schemas: string[]): Promise<P
122
143
  // Presence only. Postgres rewrites the text, but it does not invent or
123
144
  // drop a clause: NULL here means the policy genuinely has none.
124
145
  hasUsing: r.qual != null,
125
- hasWithCheck: r.with_check != null
146
+ hasWithCheck: r.with_check != null,
147
+ qual: r.qual,
148
+ withCheck: r.with_check
126
149
  }));
127
150
  }
128
151
 
152
+ /**
153
+ * The permissive tautology `auth.uid() IS NOT NULL`, without the
154
+ * `<> 'anonymous'` guard that makes it mean "signed in".
155
+ *
156
+ * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The
157
+ * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the
158
+ * corrected policy from the stale one, so its presence clears the text.
159
+ */
160
+ function isPermissiveAuthTautology(clause: string | null | undefined): boolean {
161
+ if (!clause) return false;
162
+ const flat = clause.toLowerCase().replace(/\s+/g, " ");
163
+ if (!/auth\.uid\(\)\s*is not null/.test(flat)) return false;
164
+ // The fix appends `AND auth.uid() <> 'anonymous'`; Postgres may store the
165
+ // literal as `'anonymous'::text`. Either spelling means it is the corrected
166
+ // policy, not the tautology.
167
+ return !/<>\s*'anonymous'/.test(flat) && !/!=\s*'anonymous'/.test(flat);
168
+ }
169
+
129
170
  const keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;
130
171
  const sameRoles = (a: string[], b: string[]) =>
131
172
  a.length === b.length && [...a].sort().join(",") === [...b].sort().join(",");
@@ -154,13 +195,31 @@ export async function checkPolicyDrift(
154
195
  const schemas = [...new Set(expected.map((p) => p.schema))];
155
196
  // Nothing expected means nothing to reconcile against; scanning every
156
197
  // schema would report the whole database as orphaned.
157
- if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [] };
198
+ if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [] };
158
199
 
159
200
  const live = await readLivePolicies(client, schemas);
160
201
  const liveByKey = new Map(live.map((p) => [keyOf(p), p]));
161
202
  const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));
162
203
 
163
- const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [] };
204
+ const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [] };
205
+
206
+ // Scan every live policy for the permissive tautology. This is deliberately
207
+ // independent of the name-keyed diff below: a database pushed before the
208
+ // `authenticated()` fix matches its expected policy on name, roles, command
209
+ // and clause presence, so nothing else here would flag it.
210
+ for (const p of live) {
211
+ const clause = isPermissiveAuthTautology(p.qual)
212
+ ? "USING"
213
+ : isPermissiveAuthTautology(p.withCheck) ? "WITH CHECK" : null;
214
+ if (clause) {
215
+ drift.insecure.push({
216
+ policy: p,
217
+ reason: `${clause} is \`auth.uid() IS NOT NULL\`, which is true for anonymous ` +
218
+ `visitors too — this grants access to signed-out requests. It predates the ` +
219
+ `\`policy.authenticated()\` fix; re-run \`rebase db push\` to tighten it.`
220
+ });
221
+ }
222
+ }
164
223
 
165
224
  for (const [key, want] of expectedByKey) {
166
225
  const got = liveByKey.get(key);
@@ -249,7 +308,7 @@ export async function dropOrphanedPolicies(
249
308
  }
250
309
 
251
310
  export const hasDrift = (d: PolicyDrift): boolean =>
252
- d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0;
311
+ d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0;
253
312
 
254
313
  /** Human-readable report; empty string when the database matches the config. */
255
314
  export function formatPolicyDrift(drift: PolicyDrift): string {
@@ -273,5 +332,12 @@ export function formatPolicyDrift(drift: PolicyDrift): string {
273
332
  for (const diff of d.differences) lines.push(` ${diff}`);
274
333
  }
275
334
  }
335
+ if (drift.insecure.length > 0) {
336
+ lines.push(" Insecure — a live policy grants access it should not:");
337
+ for (const i of drift.insecure) {
338
+ lines.push(` • ${i.policy.schema}.${i.policy.table} → "${i.policy.name}"`);
339
+ lines.push(` ${i.reason}`);
340
+ }
341
+ }
276
342
  return lines.join("\n");
277
343
  }
@@ -59,7 +59,7 @@ export interface ConnectionPosture {
59
59
  }
60
60
 
61
61
  export interface AuthContext {
62
- userId: string;
62
+ uid: string;
63
63
  /** Raw roles as carried on the user (strings or `{ id }` objects). */
64
64
  roles: unknown[];
65
65
  }
@@ -200,7 +200,7 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
200
200
  * SECURITY: this function is only ever called on the **user** path (the server
201
201
  * context uses the base/owner driver and never calls it). The default policies
202
202
  * treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
203
- * is `NULLIF(current_setting('app.user_id'), '')` — so an EMPTY user id would
203
+ * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
204
204
  * be read as NULL and silently escalate a user request to server privileges.
205
205
  * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
206
206
  * rather than trusting every caller (e.g. realtime subscription auth) to do it.
@@ -208,15 +208,21 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
208
208
  * semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
209
209
  */
210
210
  export async function applyAuthContext(tx: SqlTx, auth: AuthContext, userRole?: string): Promise<void> {
211
- const userId = typeof auth.userId === "string" && auth.userId.trim() !== "" ? auth.userId : ANONYMOUS_USER_ID;
211
+ const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
212
212
  const normalizedRoles = auth.roles.map((r: unknown) =>
213
213
  typeof r === "string" ? r : (r as Record<string, unknown>)?.id ?? String(r)
214
214
  );
215
+ // `app.user_id` is the pre-rename spelling, still written because policies
216
+ // are data: a database provisioned before the rename holds rules compiled
217
+ // to `current_setting('app.user_id')`, and those predicates would evaluate
218
+ // to NULL — failing open or locking out — if we stopped setting it. Drop
219
+ // the alias only once no live database carries a legacy policy.
215
220
  await tx.execute(drizzleSql`
216
221
  SELECT
217
- set_config('app.user_id', ${userId}, true),
222
+ set_config('app.uid', ${uid}, true),
223
+ set_config('app.user_id', ${uid}, true),
218
224
  set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
219
- set_config('app.jwt', ${JSON.stringify({ sub: userId, roles: auth.roles })}, true)
225
+ set_config('app.jwt', ${JSON.stringify({ sub: uid, roles: auth.roles })}, true)
220
226
  `);
221
227
  if (userRole) {
222
228
  await tx.execute(drizzleSql.raw(`SET LOCAL ROLE ${quoteIdent(userRole)}`));
@@ -1,6 +1,6 @@
1
- import { Client as PgClient } from "pg";
2
1
  import { logger } from "@rebasepro/server";
3
2
  import { CDC_CHANNEL } from "./trigger-cdc";
3
+ import { PgNotifyListener } from "../pg-notify-listener";
4
4
 
5
5
  /**
6
6
  * A single database change captured by the CDC triggers and delivered over the
@@ -54,22 +54,30 @@ export function parseCdcPayload(payload: string): CdcChangeEvent | null {
54
54
  /**
55
55
  * Dedicated Postgres LISTEN client for database-level CDC.
56
56
  *
57
- * Mirrors the resilience of the RealtimeService cross-instance LISTEN client: a
58
- * standalone `pg.Client` (outside the Drizzle pool) that stays connected, and
59
- * transparently reconnects on error/disconnect. Each backend instance runs one,
60
- * so every instance observes every committed change regardless of which
61
- * instance (or external process) made the write.
57
+ * A {@link PgNotifyListener} — a connection outside the Drizzle pool that stays
58
+ * open and repairs itself — plus the parsing that turns a `rebase_cdc` payload
59
+ * into a change event. Each backend instance runs one, so every instance
60
+ * observes every committed change regardless of which instance (or external
61
+ * process) made the write.
62
62
  */
63
63
  export class CdcListener {
64
- private client?: PgClient;
65
- private running = false;
66
- private reconnectTimer?: ReturnType<typeof setTimeout>;
67
- private static readonly RECONNECT_DELAY_MS = 3000;
68
-
69
- constructor(
70
- private readonly connectionString: string,
71
- private readonly onEvent: (event: CdcChangeEvent) => void | Promise<void>
72
- ) {}
64
+ private readonly listener: PgNotifyListener;
65
+
66
+ constructor(connectionString: string, onEvent: (event: CdcChangeEvent) => void | Promise<void>) {
67
+ this.listener = new PgNotifyListener({
68
+ connectionString,
69
+ channel: CDC_CHANNEL,
70
+ logLabel: "[CDC]",
71
+ onPayload: (payload) => {
72
+ const event = parseCdcPayload(payload);
73
+ if (!event) {
74
+ logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
75
+ return;
76
+ }
77
+ return onEvent(event);
78
+ }
79
+ });
80
+ }
73
81
 
74
82
  /**
75
83
  * Connect and begin listening. Idempotent.
@@ -78,90 +86,18 @@ export class CdcListener {
78
86
  * established (or `LISTEN` is refused), this rejects so callers — notably
79
87
  * `REALTIME_CDC=auto` — can detect an unusable connection and fall back to
80
88
  * app-level realtime. Once the initial connection succeeds, later drops
81
- * self-heal via {@link scheduleReconnect}.
89
+ * self-heal in the background.
82
90
  */
83
91
  async start(): Promise<void> {
84
- if (this.running) {
92
+ if (this.listener.active) {
85
93
  logger.warn("⚠️ [CDC] CdcListener.start() called but already running. Ignoring.");
86
94
  return;
87
95
  }
88
- this.running = true;
89
- try {
90
- await this.connect({ initial: true });
91
- } catch (err) {
92
- this.running = false;
93
- throw err;
94
- }
96
+ await this.listener.start();
95
97
  }
96
98
 
97
99
  /** Stop listening and release the connection. */
98
100
  async stop(): Promise<void> {
99
- this.running = false;
100
- if (this.reconnectTimer) {
101
- clearTimeout(this.reconnectTimer);
102
- this.reconnectTimer = undefined;
103
- }
104
- if (this.client) {
105
- try {
106
- await this.client.end();
107
- } catch { /* ignore close errors */ }
108
- this.client = undefined;
109
- }
110
- }
111
-
112
- private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
113
- try {
114
- const client = new PgClient({ connectionString: this.connectionString });
115
-
116
- client.on("error", (err) => {
117
- logger.error("❌ [CDC] LISTEN client error", { detail: err.message });
118
- this.scheduleReconnect();
119
- });
120
-
121
- client.on("end", () => {
122
- if (this.running) {
123
- logger.warn("⚠️ [CDC] LISTEN client disconnected unexpectedly.");
124
- this.scheduleReconnect();
125
- }
126
- });
127
-
128
- client.on("notification", (msg) => {
129
- if (!msg.payload) return;
130
- const event = parseCdcPayload(msg.payload);
131
- if (!event) {
132
- logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
133
- return;
134
- }
135
- // Never let a handler rejection escape into the pg client.
136
- Promise.resolve(this.onEvent(event)).catch((err) =>
137
- logger.error("❌ [CDC] Error handling change event", { error: err })
138
- );
139
- });
140
-
141
- await client.connect();
142
- await client.query(`LISTEN ${CDC_CHANNEL}`);
143
- this.client = client;
144
- logger.info(`📡 [CDC] Listening for database changes on channel "${CDC_CHANNEL}".`);
145
- } catch (err) {
146
- // Surface the initial failure so callers can choose to fall back;
147
- // for reconnects, keep retrying quietly in the background.
148
- if (initial) throw err;
149
- logger.error("❌ [CDC] Failed to connect LISTEN client", { error: err });
150
- this.scheduleReconnect();
151
- }
152
- }
153
-
154
- private scheduleReconnect(): void {
155
- if (!this.running || this.reconnectTimer) return;
156
-
157
- this.reconnectTimer = setTimeout(async () => {
158
- this.reconnectTimer = undefined;
159
- if (!this.running) return;
160
- if (this.client) {
161
- try { await this.client.end(); } catch { /* ignore */ }
162
- this.client = undefined;
163
- }
164
- await this.connect();
165
- }, CdcListener.RECONNECT_DELAY_MS);
101
+ await this.listener.stop();
166
102
  }
167
103
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Runtime pieces of the channel bus that are not the contract itself.
3
+ *
4
+ * The interface, the frame shape and the implementer's contract live in
5
+ * `@rebasepro/types` (`types/channel_bus.ts`), so a transport shipped as its own
6
+ * package — a Redis one, say — depends on the contract and not on this database
7
+ * adapter. They are re-exported here for convenience: code already importing
8
+ * from the adapter should not have to know where the types are declared.
9
+ */
10
+
11
+ import type { ChannelBusFrame } from "@rebasepro/types";
12
+
13
+ export type {
14
+ ChannelBus,
15
+ ChannelBusFrame,
16
+ ChannelBusHandler,
17
+ ChannelBusConfig,
18
+ ChannelBusSetting
19
+ } from "@rebasepro/types";
20
+ export { isChannelBusInstance } from "@rebasepro/types";
21
+
22
+ /**
23
+ * The default: no cross-instance delivery at all.
24
+ *
25
+ * This is what every deployment ran before the bus existed, and what a
26
+ * single-instance deployment should keep running — `publish` resolves without
27
+ * touching the network, so the broadcast path is the same handful of `ws.send`
28
+ * calls it always was.
29
+ */
30
+ export class MemoryChannelBus {
31
+ readonly kind = "memory" as const;
32
+ readonly maxFrameBytes = Infinity;
33
+
34
+ async start(): Promise<void> { /* nothing to connect */ }
35
+
36
+ async publish(): Promise<void> { /* nowhere to publish to */ }
37
+
38
+ async stop(): Promise<void> { /* nothing to release */ }
39
+ }
40
+
41
+ /** Encoded size of a frame, for a transport's size check. */
42
+ export function frameByteLength(frame: ChannelBusFrame): number {
43
+ return Buffer.byteLength(JSON.stringify(frame), "utf8");
44
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Channel bus over Postgres LISTEN/NOTIFY.
3
+ *
4
+ * Chosen because it needs nothing that a Rebase deployment does not already
5
+ * have — the same database, the same direct URL the CDC listener uses. Three
6
+ * properties of `NOTIFY` shape everything below:
7
+ *
8
+ * - **8000 bytes per payload.** Presence and cursors fit with room to spare; a
9
+ * scene snapshot does not. Rather than truncate or drop, an oversized frame
10
+ * on a *retained* channel is published as a pointer — the body is already in
11
+ * `rebase.channel_messages` with a sequence number, so the receiver reads it
12
+ * back. That is the same trick the entity path uses (notify an address,
13
+ * refetch the row), applied to a different table. On an ephemeral channel
14
+ * there is nothing to point at, so the publish is refused loudly instead of
15
+ * reaching some instances and not others.
16
+ *
17
+ * - **A notify is a query on the primary database.** Not a slow one, but it
18
+ * competes with the application's real queries, and that — not throughput —
19
+ * is what actually limits this transport. Measured, it carried ~10k
20
+ * cross-instance messages/second and stayed flat out to eight instances; what
21
+ * it should not do is spend 10k queries/second of the database's budget on
22
+ * cursor movement. Hence the batching below.
23
+ *
24
+ * - **Delivery is best-effort.** Retained channels repair themselves through
25
+ * the client's history replay, so a lost frame costs a live update rather
26
+ * than correctness. That is what makes coalescing safe.
27
+ */
28
+
29
+ import { sql } from "drizzle-orm";
30
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
31
+ import { logger } from "@rebasepro/server";
32
+ import { PgNotifyListener } from "../pg-notify-listener";
33
+ import { ChannelBus, ChannelBusFrame, ChannelBusHandler, frameByteLength } from "./ChannelBus";
34
+
35
+ /** NOTIFY channel carrying channel-bus frames. */
36
+ export const CHANNEL_BUS_NOTIFY_CHANNEL = "rebase_channel_bus";
37
+
38
+ /**
39
+ * Postgres refuses a NOTIFY payload of 8000 bytes or more. The margin below it
40
+ * is for nothing in particular — it is there so that a payload which passes this
41
+ * check cannot fail at the server for being a few bytes over.
42
+ */
43
+ export const PG_NOTIFY_MAX_PAYLOAD_BYTES = 7500;
44
+
45
+ /**
46
+ * How long a batching window stays open.
47
+ *
48
+ * Ten milliseconds is below the threshold where a human notices a cursor lag,
49
+ * and it is the difference between one query per message and one query per
50
+ * window under load. Set to 0 to disable coalescing entirely.
51
+ */
52
+ export const DEFAULT_BATCH_WINDOW_MS = 10;
53
+
54
+ /** JSON overhead per frame inside a batch: the wrapping array's comma. */
55
+ const BATCH_SEPARATOR_BYTES = 1;
56
+ /** JSON overhead of the batch envelope itself: `{"batch":[]}`. */
57
+ const BATCH_ENVELOPE_BYTES = 12;
58
+
59
+ interface PendingFrame {
60
+ frame: ChannelBusFrame;
61
+ bytes: number;
62
+ resolve: () => void;
63
+ reject: (error: unknown) => void;
64
+ }
65
+
66
+ export class PostgresChannelBus implements ChannelBus {
67
+ readonly kind = "postgres" as const;
68
+ readonly maxFrameBytes = PG_NOTIFY_MAX_PAYLOAD_BYTES;
69
+
70
+ private listener?: PgNotifyListener;
71
+ private readonly batchWindowMs: number;
72
+
73
+ /**
74
+ * Frames waiting for the current window to close.
75
+ *
76
+ * The window is opened by a publish that found none open, and that publish
77
+ * is sent *immediately* rather than joining a batch — see {@link publish}.
78
+ */
79
+ private pending: PendingFrame[] = [];
80
+ private pendingBytes = BATCH_ENVELOPE_BYTES;
81
+ private windowTimer?: ReturnType<typeof setTimeout>;
82
+ private stopped = false;
83
+
84
+ constructor(
85
+ private readonly db: NodePgDatabase<Record<string, unknown>>,
86
+ private readonly connectionString: string,
87
+ options: { batchWindowMs?: number } = {}
88
+ ) {
89
+ const configured = options.batchWindowMs;
90
+ this.batchWindowMs = typeof configured === "number" && configured >= 0
91
+ ? configured
92
+ : DEFAULT_BATCH_WINDOW_MS;
93
+ }
94
+
95
+ async start(handler: ChannelBusHandler): Promise<void> {
96
+ this.stopped = false;
97
+ this.listener = new PgNotifyListener({
98
+ connectionString: this.connectionString,
99
+ channel: CHANNEL_BUS_NOTIFY_CHANNEL,
100
+ logLabel: "[ChannelBus]",
101
+ onPayload: async (payload) => {
102
+ const frames = parseChannelBusPayload(payload);
103
+ if (!frames.length) {
104
+ logger.warn("⚠️ [ChannelBus] Dropping unparseable payload.");
105
+ return;
106
+ }
107
+ // In order: a batch preserves the sender's publish order, and a
108
+ // retained channel's consumers rely on it.
109
+ for (const frame of frames) await handler(frame);
110
+ }
111
+ });
112
+ await this.listener.start();
113
+ }
114
+
115
+ /**
116
+ * Publish, coalescing under load.
117
+ *
118
+ * The window is *leading edge*: a publish arriving when no window is open is
119
+ * sent straight away and opens one, so an idle channel pays no added latency
120
+ * at all. Frames arriving while it is open are collected and leave together
121
+ * when it closes. The effect is that cost tracks elapsed time rather than
122
+ * message count — one query per window instead of one per message — which is
123
+ * the same shape as the retention pruning throttle, for the same reason.
124
+ *
125
+ * The returned promise settles when the frame has actually left, not when it
126
+ * was queued, so the contract ("reaches the other instances, or rejects")
127
+ * still holds.
128
+ */
129
+ async publish(frame: ChannelBusFrame): Promise<void> {
130
+ if (this.batchWindowMs === 0 || this.stopped) {
131
+ await this.send([frame]);
132
+ return;
133
+ }
134
+
135
+ if (!this.windowTimer) {
136
+ this.openWindow();
137
+ await this.send([frame]);
138
+ return;
139
+ }
140
+
141
+ const bytes = frameByteLength(frame) + BATCH_SEPARATOR_BYTES;
142
+
143
+ // A batch is one NOTIFY payload, so the 8 KB ceiling applies to the
144
+ // whole batch. Send what we have rather than let the frame push it over.
145
+ if (this.pending.length && this.pendingBytes + bytes > this.maxFrameBytes) {
146
+ this.flush();
147
+ }
148
+
149
+ return new Promise<void>((resolve, reject) => {
150
+ this.pending.push({ frame, bytes, resolve, reject });
151
+ this.pendingBytes += bytes;
152
+ });
153
+ }
154
+
155
+ async stop(): Promise<void> {
156
+ this.stopped = true;
157
+ if (this.windowTimer) {
158
+ clearTimeout(this.windowTimer);
159
+ this.windowTimer = undefined;
160
+ }
161
+ // Anything still queued belongs to clients that are already waiting on
162
+ // it; dropping it on shutdown would be a silent loss where a flush costs
163
+ // one more query.
164
+ this.flush();
165
+ await this.listener?.stop();
166
+ this.listener = undefined;
167
+ }
168
+
169
+ private openWindow(): void {
170
+ this.windowTimer = setTimeout(() => {
171
+ this.windowTimer = undefined;
172
+ if (this.pending.length) {
173
+ // Still busy: send this window's frames and open the next one,
174
+ // so a sustained stream keeps costing one query per window.
175
+ this.flush();
176
+ this.openWindow();
177
+ }
178
+ // Otherwise leave it closed, so the next publish after a quiet
179
+ // moment goes out immediately.
180
+ }, this.batchWindowMs);
181
+
182
+ // Housekeeping must never hold the process open.
183
+ (this.windowTimer as unknown as { unref?: () => void }).unref?.();
184
+ }
185
+
186
+ /** Send everything queued and settle the promises waiting on it. */
187
+ private flush(): void {
188
+ if (!this.pending.length) return;
189
+
190
+ const batch = this.pending;
191
+ this.pending = [];
192
+ this.pendingBytes = BATCH_ENVELOPE_BYTES;
193
+
194
+ this.send(batch.map(p => p.frame))
195
+ .then(() => { for (const p of batch) p.resolve(); })
196
+ .catch((error) => { for (const p of batch) p.reject(error); });
197
+ }
198
+
199
+ /**
200
+ * One NOTIFY.
201
+ *
202
+ * A single frame goes out in the plain, unwrapped shape. That is not just
203
+ * economy: during a rolling deploy an instance running the previous build
204
+ * understands only that shape, and low-rate traffic — presence, the tail of
205
+ * a session — is exactly what is flowing while pods restart. Batching only
206
+ * appears under load, which shrinks the mixed-version window to almost
207
+ * nothing.
208
+ */
209
+ private async send(frames: ChannelBusFrame[]): Promise<void> {
210
+ if (!frames.length) return;
211
+ const payload = frames.length === 1
212
+ ? JSON.stringify(frames[0])
213
+ : JSON.stringify({ batch: frames });
214
+
215
+ await this.db.execute(sql`SELECT pg_notify(${CHANNEL_BUS_NOTIFY_CHANNEL}, ${payload})`);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Parse a bus payload into the frames it carries.
221
+ *
222
+ * Accepts both wire shapes — a bare frame and a `{ batch: [...] }` envelope —
223
+ * so an instance on the new build understands one on the old. Returns an empty
224
+ * array for anything unrecognisable: a malformed or future-versioned message
225
+ * must never take the listener down.
226
+ */
227
+ export function parseChannelBusPayload(payload: string): ChannelBusFrame[] {
228
+ let parsed: unknown;
229
+ try {
230
+ parsed = JSON.parse(payload);
231
+ } catch {
232
+ return [];
233
+ }
234
+ if (!parsed || typeof parsed !== "object") return [];
235
+
236
+ const batch = (parsed as { batch?: unknown }).batch;
237
+ if (Array.isArray(batch)) {
238
+ return batch
239
+ .map(entry => coerceFrame(entry))
240
+ .filter((frame): frame is ChannelBusFrame => frame !== null);
241
+ }
242
+
243
+ const single = coerceFrame(parsed);
244
+ return single ? [single] : [];
245
+ }
246
+
247
+ /**
248
+ * Parse a single bus frame, returning null for anything that is not a frame we
249
+ * understand.
250
+ */
251
+ export function parseChannelBusFrame(payload: string): ChannelBusFrame | null {
252
+ try {
253
+ return coerceFrame(JSON.parse(payload));
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ function coerceFrame(value: unknown): ChannelBusFrame | null {
260
+ if (!value || typeof value !== "object") return null;
261
+
262
+ const obj = value as Record<string, unknown>;
263
+ const sid = typeof obj.sid === "string" ? obj.sid : undefined;
264
+ const channel = typeof obj.channel === "string" ? obj.channel : undefined;
265
+ if (!sid || !channel) return null;
266
+
267
+ switch (obj.kind) {
268
+ case "broadcast":
269
+ if (typeof obj.event !== "string") return null;
270
+ return {
271
+ kind: "broadcast",
272
+ sid,
273
+ channel,
274
+ event: obj.event,
275
+ from: typeof obj.from === "string" ? obj.from : undefined,
276
+ seq: typeof obj.seq === "number" ? obj.seq : undefined,
277
+ payload: obj.payload
278
+ };
279
+ case "broadcast_ref":
280
+ if (typeof obj.seq !== "number") return null;
281
+ return {
282
+ kind: "broadcast_ref",
283
+ sid,
284
+ channel,
285
+ from: typeof obj.from === "string" ? obj.from : undefined,
286
+ seq: obj.seq
287
+ };
288
+ case "presence_diff":
289
+ return {
290
+ kind: "presence_diff",
291
+ sid,
292
+ channel,
293
+ joins: (obj.joins ?? {}) as Record<string, Record<string, unknown>>,
294
+ leaves: (obj.leaves ?? {}) as Record<string, Record<string, unknown>>
295
+ };
296
+ default:
297
+ return null;
298
+ }
299
+ }