@rebasepro/server-postgres 0.10.1-canary.6f89f77 → 0.10.1-canary.7609565

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.
@@ -16,6 +16,8 @@ import { sanitizeErrorForClient } from "../utils/pg-error-utils";
16
16
  import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
17
17
  import { deriveRowAddress, getPrimaryKeys, type PrimaryKeyInfo } from "./collection-helpers";
18
18
  import { ChannelHistoryStore, type ResolvedRetention } from "./channel-history";
19
+ import { ChannelPresenceStore } from "./channel-presence";
20
+ import { ChannelBus, ChannelBusFrame, MemoryChannelBus, frameByteLength } from "./channel-bus";
19
21
  import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
20
22
 
21
23
  /** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
@@ -76,8 +78,38 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
76
78
  * wait on each other.
77
79
  */
78
80
  private channelSendQueues = new Map<string, Promise<void>>();
81
+
82
+ /**
83
+ * Cross-instance transport for channel frames and presence.
84
+ *
85
+ * Defaults to the memory bus, which publishes nowhere — so a single-instance
86
+ * deployment runs the same fan-out it always did, with one resolved promise
87
+ * per broadcast for company. See `channel-bus/ChannelBus.ts`.
88
+ */
89
+ private bus: ChannelBus = new MemoryChannelBus();
90
+
91
+ /**
92
+ * The shared presence roster, present only when a real bus is active.
93
+ *
94
+ * Fan-out alone is not enough for presence: `presence_state` has to answer
95
+ * with everyone in the channel, and per-process maps can only answer for
96
+ * this replica's clients. See `channel-presence.ts`.
97
+ */
98
+ private presenceStore?: ChannelPresenceStore;
99
+
100
+ /** Sweeps roster rows left behind by instances that stopped heartbeating. */
101
+ private presenceSweepInterval?: ReturnType<typeof setInterval>;
102
+
103
+ /**
104
+ * Channels whose oversized ephemeral broadcasts have already been reported,
105
+ * so a hot channel logs the problem once rather than once per message.
106
+ */
107
+ private oversizedBroadcastWarned = new Set<string>();
108
+
79
109
  private presenceInterval?: ReturnType<typeof setInterval>;
80
110
  private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
111
+ /** How often stale roster rows from other instances are reaped. */
112
+ private static readonly PRESENCE_SWEEP_INTERVAL_MS = 10000; // 10s
81
113
  private dataService: DataService;
82
114
  // Enhanced subscriptions storage with full request parameters
83
115
  private _subscriptions = new Map<string, {
@@ -307,15 +339,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
307
339
  for (const [channel, members] of this.channels.entries()) {
308
340
  if (members.has(clientId)) {
309
341
  members.delete(clientId);
310
- this.removePresence(clientId, channel);
342
+ this.removePresence(clientId, channel, { skipStore: true });
311
343
  if (members.size === 0) this.channels.delete(channel);
312
344
  }
313
345
  }
314
346
 
315
347
  // Remove from all presence channels
316
348
  for (const [channel] of this.presence) {
317
- this.removePresence(clientId, channel);
349
+ this.removePresence(clientId, channel, { skipStore: true });
318
350
  }
351
+
352
+ // One statement for every channel the client was in, rather than one
353
+ // per channel above — a disconnect is the common case, not a rare one.
354
+ void this.presenceStoreOp(() => this.presenceStore!.removeClient(clientId), "client removal");
319
355
  }
320
356
 
321
357
  private async handleMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
@@ -1098,6 +1134,11 @@ roles: activeAuth.roles },
1098
1134
  const retention = this.channelHistory?.retentionFor(channel);
1099
1135
  if (!retention) {
1100
1136
  this.fanOutBroadcast(clientId, channel, event, payload);
1137
+ // Other instances get the same frame, but never before the clients
1138
+ // on this one: the local fan-out above is synchronous and the
1139
+ // publish is not, which is also what keeps the ephemeral path free
1140
+ // of any await for a single-instance deployment.
1141
+ this.publishBroadcast(clientId, channel, event, payload);
1101
1142
  return;
1102
1143
  }
1103
1144
 
@@ -1147,6 +1188,7 @@ roles: activeAuth.roles },
1147
1188
  }
1148
1189
 
1149
1190
  this.fanOutBroadcast(clientId, channel, event, payload, seq);
1191
+ this.publishBroadcast(clientId, channel, event, payload, seq);
1150
1192
 
1151
1193
  try {
1152
1194
  await this.channelHistory!.prune(channel, retention);
@@ -1180,6 +1222,186 @@ roles: activeAuth.roles },
1180
1222
  }
1181
1223
  }
1182
1224
 
1225
+ // =============================================================================
1226
+ // Cross-Instance Channel Bus
1227
+ // =============================================================================
1228
+
1229
+ /**
1230
+ * Install the transport that carries channel frames between instances.
1231
+ *
1232
+ * Called once at boot. A bus that cannot start is reported and replaced with
1233
+ * the memory bus: losing cross-instance fan-out degrades collaboration to
1234
+ * what it was before this existed, whereas refusing to boot takes the whole
1235
+ * backend down for it.
1236
+ */
1237
+ async configureChannelBus(bus: ChannelBus): Promise<void> {
1238
+ if (bus.kind === "memory") {
1239
+ this.bus = bus;
1240
+ return;
1241
+ }
1242
+
1243
+ try {
1244
+ await bus.start((frame) => this.handleBusFrame(frame));
1245
+ } catch (error) {
1246
+ logger.warn(
1247
+ `⚠️ [ChannelBus] Could not start the "${bus.kind}" channel bus — channel broadcast and presence ` +
1248
+ "stay per-instance. Clients served by different replicas will not see each other.",
1249
+ { error }
1250
+ );
1251
+ await bus.stop().catch(() => { /* best effort */ });
1252
+ this.bus = new MemoryChannelBus();
1253
+ return;
1254
+ }
1255
+
1256
+ this.bus = bus;
1257
+
1258
+ // Presence needs shared *state*, not just shared fan-out — see
1259
+ // `channel-presence.ts`. It comes up with the bus and only with it.
1260
+ try {
1261
+ const store = new ChannelPresenceStore(this.db, this.instanceId);
1262
+ await store.ensureTables();
1263
+ this.presenceStore = store;
1264
+ this.ensurePresenceSweep();
1265
+ } catch (error) {
1266
+ logger.warn(
1267
+ "⚠️ [ChannelBus] Could not create the shared presence table — presence rosters will only list " +
1268
+ "clients connected to this instance (broadcast is unaffected).",
1269
+ { error }
1270
+ );
1271
+ this.presenceStore = undefined;
1272
+ }
1273
+
1274
+ logger.info(
1275
+ `📡 [ChannelBus] Cross-instance channels active via ${bus.kind} (instanceId: ${this.instanceId}).`
1276
+ );
1277
+ }
1278
+
1279
+ /** Which transport is in use — `"memory"` means per-instance only. */
1280
+ public getChannelBusKind(): ChannelBus["kind"] {
1281
+ return this.bus.kind;
1282
+ }
1283
+
1284
+ /**
1285
+ * Send a broadcast to the other instances.
1286
+ *
1287
+ * Fire-and-forget by design: the clients on this instance have already been
1288
+ * served, and a bus that is briefly unreachable must not turn a broadcast
1289
+ * into an error for the sender.
1290
+ */
1291
+ private publishBroadcast(clientId: string, channel: string, event: string, payload: unknown, seq?: number): void {
1292
+ if (this.bus.kind === "memory") return;
1293
+
1294
+ const frame: ChannelBusFrame = {
1295
+ kind: "broadcast",
1296
+ sid: this.instanceId,
1297
+ channel,
1298
+ event,
1299
+ from: clientId,
1300
+ ...(seq !== undefined ? { seq } : {}),
1301
+ payload
1302
+ };
1303
+
1304
+ // Postgres caps a NOTIFY payload at 8 KB. A retained message is already
1305
+ // durable and addressable, so it travels as a pointer and each receiver
1306
+ // reads the body back — the same shape as the entity path, which
1307
+ // notifies an address and refetches the row.
1308
+ if (frameByteLength(frame) > this.bus.maxFrameBytes) {
1309
+ if (seq === undefined) {
1310
+ this.reportOversizedBroadcast(clientId, channel);
1311
+ return;
1312
+ }
1313
+ void this.publishFrame({
1314
+ kind: "broadcast_ref",
1315
+ sid: this.instanceId,
1316
+ channel,
1317
+ from: clientId,
1318
+ seq
1319
+ });
1320
+ return;
1321
+ }
1322
+
1323
+ void this.publishFrame(frame);
1324
+ }
1325
+
1326
+ private async publishFrame(frame: ChannelBusFrame): Promise<void> {
1327
+ try {
1328
+ await this.bus.publish(frame);
1329
+ } catch (error) {
1330
+ logger.error("❌ [ChannelBus] Failed to publish frame — other instances did not receive it", {
1331
+ detail: `${frame.kind} on "${frame.channel}"`,
1332
+ error
1333
+ });
1334
+ }
1335
+ }
1336
+
1337
+ /**
1338
+ * Tell the sender that a message was delivered locally but nowhere else.
1339
+ *
1340
+ * Staying quiet here would be the worst option available: on one instance
1341
+ * the app works, on two it works for half the users, and nothing in the
1342
+ * logs connects the two. The fix is a one-liner in config — give the
1343
+ * channel a retention rule and the message travels as a pointer instead —
1344
+ * so the message says exactly that.
1345
+ */
1346
+ private reportOversizedBroadcast(clientId: string, channel: string): void {
1347
+ const remedy =
1348
+ `Add a retention rule for "${channel}" (realtime.channels) — retained messages travel by reference ` +
1349
+ "and have no size limit.";
1350
+
1351
+ if (!this.oversizedBroadcastWarned.has(channel)) {
1352
+ this.oversizedBroadcastWarned.add(channel);
1353
+ logger.warn(
1354
+ `⚠️ [ChannelBus] A broadcast on ephemeral channel "${channel}" exceeds the ` +
1355
+ `${this.bus.maxFrameBytes}-byte limit of the ${this.bus.kind} bus and reached only this instance. ` +
1356
+ remedy
1357
+ );
1358
+ }
1359
+ this.sendError(
1360
+ clientId,
1361
+ `Broadcast on "${channel}" was too large to reach other instances. ${remedy}`,
1362
+ undefined,
1363
+ "CHANNEL_BUS_PAYLOAD_TOO_LARGE"
1364
+ );
1365
+ }
1366
+
1367
+ /**
1368
+ * Deliver a frame published by another instance to this one's clients.
1369
+ *
1370
+ * Frames we published ourselves are dropped on arrival — the local fan-out
1371
+ * happened before the publish — exactly as the entity-change handler skips
1372
+ * its own `sid`.
1373
+ */
1374
+ private async handleBusFrame(frame: ChannelBusFrame): Promise<void> {
1375
+ if (frame.sid === this.instanceId) return;
1376
+
1377
+ switch (frame.kind) {
1378
+ case "broadcast":
1379
+ this.fanOutBroadcast(frame.from ?? "", frame.channel, frame.event, frame.payload, frame.seq);
1380
+ return;
1381
+
1382
+ case "broadcast_ref": {
1383
+ // Nothing to read back for: skip the query rather than pay for
1384
+ // a message no client here is waiting for.
1385
+ if (!this.channels.get(frame.channel)?.size) return;
1386
+
1387
+ const entry = await this.channelHistory?.getBySeq(frame.channel, frame.seq);
1388
+ if (!entry) {
1389
+ logger.warn(
1390
+ `⚠️ [ChannelBus] Message ${frame.seq} on "${frame.channel}" is no longer retained — ` +
1391
+ "clients on this instance will need to replay (channel_history) to catch up."
1392
+ );
1393
+ return;
1394
+ }
1395
+ this.fanOutBroadcast(frame.from ?? "", frame.channel, entry.event, entry.payload, entry.seq);
1396
+ return;
1397
+ }
1398
+
1399
+ case "presence_diff":
1400
+ this.deliverPresenceDiff(frame.channel, frame.joins, frame.leaves);
1401
+ return;
1402
+ }
1403
+ }
1404
+
1183
1405
  // =============================================================================
1184
1406
  // Channel History
1185
1407
  // =============================================================================
@@ -1257,32 +1479,59 @@ roles: activeAuth.roles },
1257
1479
  // Presence
1258
1480
  // =============================================================================
1259
1481
 
1260
- /** Track presence in a channel */
1482
+ /**
1483
+ * Track presence in a channel.
1484
+ *
1485
+ * The client re-sends this every ~20s as a heartbeat against the 30s
1486
+ * timeout, so most calls carry the state that is already recorded. Those
1487
+ * refresh `last_seen` and stop there: re-announcing an unchanged state to
1488
+ * every instance would put a bus message per client per heartbeat on the
1489
+ * wire to tell everyone nothing happened.
1490
+ */
1261
1491
  trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void {
1262
1492
  if (!this.presence.has(channel)) {
1263
1493
  this.presence.set(channel, new Map());
1264
1494
  }
1265
1495
 
1266
1496
  const channelPresence = this.presence.get(channel)!;
1497
+ const previous = channelPresence.get(clientId);
1498
+ const changed = !previous || JSON.stringify(previous.state) !== JSON.stringify(state);
1267
1499
  channelPresence.set(clientId, { state,
1268
1500
  lastSeen: Date.now() });
1269
1501
 
1502
+ // Refresh the shared roster on every heartbeat — that timestamp is what
1503
+ // tells other instances this client is still here.
1504
+ void this.presenceStoreOp(() => this.presenceStore!.track(channel, clientId, state), "track");
1505
+
1270
1506
  // Broadcast join / state update to channel
1271
- this.broadcastPresenceDiff(channel, { [clientId]: state }, {});
1507
+ this.deliverPresenceDiff(channel, { [clientId]: state }, {});
1508
+ if (changed) {
1509
+ this.publishPresenceDiff(channel, { [clientId]: state }, {});
1510
+ }
1272
1511
 
1273
1512
  // Start cleanup interval if not running
1274
1513
  this.ensurePresenceCleanup();
1275
1514
  }
1276
1515
 
1277
- /** Remove presence from a channel */
1278
- removePresence(clientId: string, channel: string): void {
1516
+ /**
1517
+ * Remove presence from a channel.
1518
+ *
1519
+ * `skipStore` is for the socket-close path, which clears every channel at
1520
+ * once and then deletes the client's rows in a single statement instead of
1521
+ * one per channel.
1522
+ */
1523
+ removePresence(clientId: string, channel: string, options?: { skipStore?: boolean }): void {
1279
1524
  const channelPresence = this.presence.get(channel);
1280
1525
  if (!channelPresence) return;
1281
1526
 
1282
1527
  const entry = channelPresence.get(clientId);
1283
1528
  if (entry) {
1284
1529
  channelPresence.delete(clientId);
1285
- this.broadcastPresenceDiff(channel, {}, { [clientId]: entry.state });
1530
+ this.deliverPresenceDiff(channel, {}, { [clientId]: entry.state });
1531
+ this.publishPresenceDiff(channel, {}, { [clientId]: entry.state });
1532
+ if (!options?.skipStore) {
1533
+ void this.presenceStoreOp(() => this.presenceStore!.remove(channel, clientId), "remove");
1534
+ }
1286
1535
  }
1287
1536
 
1288
1537
  if (channelPresence.size === 0) {
@@ -1290,17 +1539,50 @@ lastSeen: Date.now() });
1290
1539
  }
1291
1540
  }
1292
1541
 
1293
- /** Send full presence state to a specific client */
1542
+ /**
1543
+ * Send the full roster for a channel to one client.
1544
+ *
1545
+ * Answered from the shared table when there is one, because "who is in this
1546
+ * document?" has a single answer that must not depend on which replica the
1547
+ * asker happens to be connected to. Without a bus there is nothing to share
1548
+ * and the local map *is* the roster — that path stays synchronous, which is
1549
+ * what it always was.
1550
+ */
1294
1551
  sendPresenceState(clientId: string, channel: string): void {
1552
+ if (!this.presenceStore) {
1553
+ this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
1554
+ return;
1555
+ }
1556
+
1557
+ void this.presenceStore.roster(channel)
1558
+ .then((presences) => {
1559
+ this.sendPresenceStateMessage(clientId, channel, presences);
1560
+ })
1561
+ .catch((error) => {
1562
+ // A roster the asker can act on beats none: fall back to the
1563
+ // clients we can see rather than leaving the request unanswered.
1564
+ logger.warn(`⚠️ [Presence] Could not read the shared roster for "${channel}" — answering with this instance's clients only.`, { error });
1565
+ this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
1566
+ });
1567
+ }
1568
+
1569
+ /** Presence of the clients connected to this instance. */
1570
+ private localPresences(channel: string): Record<string, Record<string, unknown>> {
1295
1571
  const channelPresence = this.presence.get(channel);
1296
1572
  const presences: Record<string, Record<string, unknown>> = {};
1297
-
1298
1573
  if (channelPresence) {
1299
1574
  for (const [id, { state }] of channelPresence) {
1300
1575
  presences[id] = state;
1301
1576
  }
1302
1577
  }
1578
+ return presences;
1579
+ }
1303
1580
 
1581
+ private sendPresenceStateMessage(
1582
+ clientId: string,
1583
+ channel: string,
1584
+ presences: Record<string, Record<string, unknown>>
1585
+ ): void {
1304
1586
  const ws = this.clients.get(clientId);
1305
1587
  if (ws && ws.readyState === WebSocket.OPEN) {
1306
1588
  ws.send(JSON.stringify({
@@ -1311,8 +1593,8 @@ lastSeen: Date.now() });
1311
1593
  }
1312
1594
  }
1313
1595
 
1314
- /** Broadcast presence diff (joins/leaves) to channel */
1315
- private broadcastPresenceDiff(
1596
+ /** Deliver a presence diff to this instance's members of the channel. */
1597
+ private deliverPresenceDiff(
1316
1598
  channel: string,
1317
1599
  joins: Record<string, Record<string, unknown>>,
1318
1600
  leaves: Record<string, Record<string, unknown>>
@@ -1335,6 +1617,26 @@ lastSeen: Date.now() });
1335
1617
  }
1336
1618
  }
1337
1619
 
1620
+ /** Tell the other instances about a presence change. */
1621
+ private publishPresenceDiff(
1622
+ channel: string,
1623
+ joins: Record<string, Record<string, unknown>>,
1624
+ leaves: Record<string, Record<string, unknown>>
1625
+ ): void {
1626
+ if (this.bus.kind === "memory") return;
1627
+ void this.publishFrame({ kind: "presence_diff", sid: this.instanceId, channel, joins, leaves });
1628
+ }
1629
+
1630
+ /** Run a roster write when there is a roster, and never let it throw. */
1631
+ private async presenceStoreOp(op: () => Promise<void>, label: string): Promise<void> {
1632
+ if (!this.presenceStore) return;
1633
+ try {
1634
+ await op();
1635
+ } catch (error) {
1636
+ logger.warn(`⚠️ [Presence] Shared roster ${label} failed`, { error });
1637
+ }
1638
+ }
1639
+
1338
1640
  /** Periodic cleanup for stale presences */
1339
1641
  private ensurePresenceCleanup(): void {
1340
1642
  if (this.presenceInterval) return;
@@ -1355,6 +1657,43 @@ lastSeen: Date.now() });
1355
1657
  }, 10000); // Check every 10s
1356
1658
  }
1357
1659
 
1660
+ /**
1661
+ * Reap roster rows whose owning instance stopped heartbeating.
1662
+ *
1663
+ * This is the cross-instance half of the sweep above, and it doubles as
1664
+ * crash recovery: a pod that dies takes its clients with it but leaves
1665
+ * their rows behind, and after one TTL window they look exactly like any
1666
+ * other client that went quiet. The delete returns what it removed, so
1667
+ * whichever instance wins the race is the one that announces the
1668
+ * departures — once for the cluster, not once per replica.
1669
+ */
1670
+ private ensurePresenceSweep(): void {
1671
+ if (this.presenceSweepInterval || !this.presenceStore) return;
1672
+
1673
+ this.presenceSweepInterval = setInterval(
1674
+ () => void this.sweepStalePresence(),
1675
+ RealtimeService.PRESENCE_SWEEP_INTERVAL_MS
1676
+ );
1677
+
1678
+ // Never hold the process open for housekeeping.
1679
+ (this.presenceSweepInterval as unknown as { unref?: () => void }).unref?.();
1680
+ }
1681
+
1682
+ /** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
1683
+ private async sweepStalePresence(): Promise<void> {
1684
+ if (!this.presenceStore) return;
1685
+ try {
1686
+ const removed = await this.presenceStore.sweepStale(RealtimeService.PRESENCE_TIMEOUT_MS);
1687
+ for (const row of removed) {
1688
+ this.debugLog(`👻 [Presence] Reaped stale presence ${row.clientId} on "${row.channel}"`);
1689
+ this.deliverPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
1690
+ this.publishPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
1691
+ }
1692
+ } catch (error) {
1693
+ logger.warn("⚠️ [Presence] Stale-roster sweep failed", { error });
1694
+ }
1695
+ }
1696
+
1358
1697
  // =============================================================================
1359
1698
  // Lifecycle / Cleanup
1360
1699
  // =============================================================================
@@ -1393,10 +1732,30 @@ lastSeen: Date.now() });
1393
1732
  clearInterval(this.presenceInterval);
1394
1733
  this.presenceInterval = undefined;
1395
1734
  }
1735
+ if (this.presenceSweepInterval) {
1736
+ clearInterval(this.presenceSweepInterval);
1737
+ this.presenceSweepInterval = undefined;
1738
+ }
1739
+ this.oversizedBroadcastWarned.clear();
1740
+
1741
+ // Drop this instance's roster rows now rather than leaving every other
1742
+ // replica to wait out a TTL window on ghosts — a rolling deploy would
1743
+ // otherwise show 30s of departed users on every restart.
1744
+ if (this.presenceStore) {
1745
+ try {
1746
+ await this.presenceStore.removeInstance();
1747
+ } catch (error) {
1748
+ logger.warn("⚠️ [Presence] Could not clear this instance's roster rows on shutdown", { error });
1749
+ }
1750
+ this.presenceStore = undefined;
1751
+ }
1396
1752
 
1397
1753
  // 4. Disconnect the dedicated LISTEN client(s)
1398
1754
  await this.stopListening();
1399
1755
  await this.stopCdc();
1756
+ await this.bus.stop().catch((error) =>
1757
+ logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
1758
+ this.bus = new MemoryChannelBus();
1400
1759
 
1401
1760
  // 5. Drop client references (don't close — server.close drains them)
1402
1761
  this.clients.clear();