@rebasepro/server-postgres 0.10.1-canary.a54c057 → 0.10.1-canary.b1e3dbf

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 (41) hide show
  1. package/dist/PostgresBootstrapper.d.ts +7 -3
  2. package/dist/auth/services.d.ts +43 -4
  3. package/dist/backup/backup-logic.d.ts +23 -0
  4. package/dist/backup/backup-service.d.ts +44 -2
  5. package/dist/backup/pg-tools.d.ts +41 -1
  6. package/dist/cli-helpers.d.ts +33 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.es.js +1314 -159
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/schema/auth-schema.d.ts +170 -0
  11. package/dist/schema/destructive-sql.d.ts +49 -0
  12. package/dist/services/cdc/CdcListener.d.ts +7 -14
  13. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  14. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  15. package/dist/services/channel-bus/index.d.ts +55 -0
  16. package/dist/services/channel-history.d.ts +11 -0
  17. package/dist/services/channel-presence.d.ts +66 -0
  18. package/dist/services/pg-notify-listener.d.ts +47 -0
  19. package/dist/services/realtimeService.d.ts +114 -6
  20. package/package.json +6 -6
  21. package/src/PostgresBootstrapper.ts +32 -3
  22. package/src/auth/ensure-tables.ts +70 -56
  23. package/src/auth/services.ts +186 -48
  24. package/src/backup/backup-cli.ts +60 -1
  25. package/src/backup/backup-cron.ts +24 -1
  26. package/src/backup/backup-logic.ts +62 -0
  27. package/src/backup/backup-service.ts +132 -13
  28. package/src/backup/pg-tools.ts +70 -2
  29. package/src/cli-helpers.ts +82 -27
  30. package/src/cli.ts +152 -6
  31. package/src/index.ts +4 -0
  32. package/src/schema/auth-schema.ts +41 -3
  33. package/src/schema/destructive-sql.ts +94 -0
  34. package/src/services/cdc/CdcListener.ts +27 -91
  35. package/src/services/channel-bus/ChannelBus.ts +44 -0
  36. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  37. package/src/services/channel-bus/index.ts +123 -0
  38. package/src/services/channel-history.ts +35 -0
  39. package/src/services/channel-presence.ts +148 -0
  40. package/src/services/pg-notify-listener.ts +137 -0
  41. package/src/services/realtimeService.ts +383 -14
@@ -1,4 +1,4 @@
1
- import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
@@ -25,24 +25,62 @@ export function createAuthSchema(usersSchemaName = "rebase") {
25
25
  isAnonymous: boolean("is_anonymous").default(false).notNull(),
26
26
  roles: text("roles").array().default([]).notNull(),
27
27
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
28
+ /**
29
+ * Sessions that began before this instant are dead, whatever tokens
30
+ * they still hold. Password resets and admin revocations stamp it.
31
+ *
32
+ * Deleting the user's refresh-token rows (which we also do) is not
33
+ * sufficient on its own: a request already in flight can insert a
34
+ * freshly rotated row microseconds after the delete and survive it.
35
+ * This timestamp cannot be outrun that way — it is checked against
36
+ * `refresh_tokens.session_started_at`, which rotation carries forward.
37
+ */
38
+ tokensValidAfter: timestamp("tokens_valid_after"),
28
39
  createdAt: timestamp("created_at").defaultNow().notNull(),
29
40
  updatedAt: timestamp("updated_at").defaultNow().notNull()
30
41
  });
31
42
 
32
43
 
33
44
  /**
34
- * Refresh tokens for long-lived sessions
45
+ * Refresh tokens for long-lived sessions.
46
+ *
47
+ * A row is one token, not one device. Every token minted from the same
48
+ * sign-in shares a `sessionId`, and rotation ADDS a row rather than
49
+ * replacing one: the superseded token stays on file, flagged `revoked`
50
+ * with a `rotatedAt` stamp. That record is what lets the refresh endpoint
51
+ * tell a client replaying a token it never got an answer for (a response
52
+ * lost to a redeploy, a second tab racing on boot) apart from a stranger
53
+ * presenting a token that was never issued. Deleting the old row on sight
54
+ * — the previous behaviour — made those two cases indistinguishable, and
55
+ * the legitimate one is overwhelmingly the common one.
56
+ *
57
+ * There is deliberately NO unique constraint on (uid, user_agent,
58
+ * ip_address). Keying a session on the IP meant one row per "device",
59
+ * so a second browser profile behind the same NAT silently evicted the
60
+ * first, and a phone changing networks orphaned a row on every hop.
61
+ * User agent and IP are descriptive metadata for the sessions list;
62
+ * `sessionId` is the identity.
35
63
  */
36
64
  const refreshTokens = tableCreator("refresh_tokens", {
37
65
  id: uuid("id").defaultRandom().primaryKey(),
38
66
  uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
67
+ sessionId: uuid("session_id").defaultRandom().notNull(),
39
68
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
40
69
  expiresAt: timestamp("expires_at").notNull(),
70
+ revoked: boolean("revoked").default(false).notNull(),
71
+ rotatedAt: timestamp("rotated_at"),
72
+ /**
73
+ * When the sign-in this token descends from happened — carried across
74
+ * every rotation, unlike `createdAt`. `users.tokensValidAfter` is
75
+ * compared against this, so a revocation cannot be outrun by a token
76
+ * that rotates immediately after it.
77
+ */
78
+ sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
41
79
  userAgent: varchar("user_agent", { length: 500 }),
42
80
  ipAddress: varchar("ip_address", { length: 45 }),
43
81
  createdAt: timestamp("created_at").defaultNow().notNull()
44
82
  }, (table) => ({
45
- uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress)
83
+ sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
46
84
  }));
47
85
 
48
86
  /**
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure helpers that classify an Atlas declarative-apply plan as destructive
3
+ * or not, and decide what `rebase db push` should do about it.
4
+ *
5
+ * `db push` runs `atlas schema apply` to make the live database match the
6
+ * generated `schema.sql`. Removing a collection field compiles to
7
+ * `DROP COLUMN`; renaming compiles to drop-then-add — either destroys data.
8
+ * We first run the apply with `--dry-run` to obtain the planned SQL, scan it
9
+ * here, and refuse to auto-approve anything destructive without an explicit
10
+ * opt-in.
11
+ *
12
+ * Everything in this file is side-effect free so it can be unit-tested
13
+ * without Atlas or a database.
14
+ */
15
+
16
+ /**
17
+ * SQL fragments that destroy data or data-bearing objects. Matched
18
+ * case-insensitively against each statement of the plan. `IF EXISTS` /
19
+ * whitespace variations are tolerated by the regexes below.
20
+ */
21
+ const DESTRUCTIVE_PATTERNS: { label: string; re: RegExp }[] = [
22
+ { label: "DROP TABLE", re: /\bDROP\s+TABLE\b/i },
23
+ { label: "DROP COLUMN", re: /\bDROP\s+COLUMN\b/i },
24
+ { label: "DROP SCHEMA", re: /\bDROP\s+SCHEMA\b/i },
25
+ { label: "DROP VIEW", re: /\bDROP\s+(MATERIALIZED\s+)?VIEW\b/i },
26
+ { label: "DROP TYPE", re: /\bDROP\s+TYPE\b/i },
27
+ { label: "TRUNCATE", re: /\bTRUNCATE\b/i }
28
+ ];
29
+
30
+ /**
31
+ * Split a SQL script into individual statements, dropping blank lines and
32
+ * `--` comment lines. Deliberately simple: Atlas emits one plain statement
33
+ * per `;`, without string literals that contain semicolons in a schema DDL
34
+ * plan, so a naive split is safe and keeps this dependency-free.
35
+ */
36
+ export function splitSqlStatements(sql: string): string[] {
37
+ // Strip full-line SQL comments so a commented-out DROP never trips the
38
+ // detector, then split on semicolons.
39
+ const withoutComments = sql
40
+ .split("\n")
41
+ .filter((line) => !line.trim().startsWith("--"))
42
+ .join("\n");
43
+ return withoutComments
44
+ .split(";")
45
+ .map((s) => s.trim())
46
+ .filter((s) => s.length > 0);
47
+ }
48
+
49
+ export interface DestructiveStatement {
50
+ /** The offending statement (trimmed, without the trailing `;`). */
51
+ statement: string;
52
+ /** Which destructive operation it was flagged for, e.g. "DROP COLUMN". */
53
+ kind: string;
54
+ }
55
+
56
+ /**
57
+ * Scan an Atlas plan (the SQL printed by `schema apply --dry-run`) and return
58
+ * the statements that would destroy data. An empty array means the plan is
59
+ * safe to auto-approve.
60
+ */
61
+ export function detectDestructiveStatements(planSql: string): DestructiveStatement[] {
62
+ const found: DestructiveStatement[] = [];
63
+ for (const statement of splitSqlStatements(planSql)) {
64
+ for (const { label, re } of DESTRUCTIVE_PATTERNS) {
65
+ if (re.test(statement)) {
66
+ found.push({ statement, kind: label });
67
+ break; // one label per statement is enough to flag it
68
+ }
69
+ }
70
+ }
71
+ return found;
72
+ }
73
+
74
+ export type PushDecision = "apply" | "confirm" | "refuse";
75
+
76
+ /**
77
+ * Decide how `db push` should proceed given the plan's destructiveness and
78
+ * the invocation context.
79
+ *
80
+ * - No destructive statements → `apply` (safe to auto-approve).
81
+ * - Destructive + `--allow-destructive` → `apply` (operator opted in).
82
+ * - Destructive + interactive TTY → `confirm` (prompt before applying).
83
+ * - Destructive + non-interactive → `refuse` (never silently drop data in
84
+ * CI / scripts / agents).
85
+ */
86
+ export function decidePushSafety(opts: {
87
+ destructiveCount: number;
88
+ allowDestructive: boolean;
89
+ interactive: boolean;
90
+ }): PushDecision {
91
+ if (opts.destructiveCount === 0) return "apply";
92
+ if (opts.allowDestructive) return "apply";
93
+ return opts.interactive ? "confirm" : "refuse";
94
+ }
@@ -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
+ }