@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.
package/dist/index.es.js CHANGED
@@ -439,6 +439,19 @@ function isSQLAdmin(admin) {
439
439
  function isSchemaAdmin(admin) {
440
440
  return !!admin && (typeof admin.fetchUnmappedTables === "function" || typeof admin.fetchTableMetadata === "function");
441
441
  }
442
+ //#endregion
443
+ //#region ../types/src/types/channel_bus.ts
444
+ /**
445
+ * Whether `setting` is an already-constructed transport rather than a request
446
+ * for a built-in one.
447
+ *
448
+ * Structural rather than nominal so that an instance from a *different copy* of
449
+ * `@rebasepro/types` — an entirely normal outcome of a separately versioned
450
+ * transport package — is still recognised.
451
+ */
452
+ function isChannelBusInstance(setting) {
453
+ return typeof setting?.publish === "function";
454
+ }
442
455
  /** @group Models */
443
456
  var POSTGRES_CAPABILITIES = {
444
457
  key: "postgres",
@@ -10247,67 +10260,46 @@ async function provisionTriggerCdc(run, tables) {
10247
10260
  };
10248
10261
  }
10249
10262
  //#endregion
10250
- //#region src/services/cdc/CdcListener.ts
10263
+ //#region src/services/pg-notify-listener.ts
10251
10264
  /**
10252
- * Parse a `rebase_cdc` NOTIFY payload. Returns `null` for anything malformed so
10253
- * a single bad message can never crash the listener.
10254
- */
10255
- function parseCdcPayload(payload) {
10256
- let parsed;
10257
- try {
10258
- parsed = JSON.parse(payload);
10259
- } catch {
10260
- return null;
10261
- }
10262
- if (!parsed || typeof parsed !== "object") return null;
10263
- const obj = parsed;
10264
- const schema = typeof obj.schema === "string" ? obj.schema : void 0;
10265
- const table = typeof obj.table === "string" ? obj.table : void 0;
10266
- const op = obj.op;
10267
- if (!schema || !table) return null;
10268
- if (op !== "INSERT" && op !== "UPDATE" && op !== "DELETE") return null;
10269
- return {
10270
- schema,
10271
- table,
10272
- op,
10273
- row: obj.row && typeof obj.row === "object" ? obj.row : {},
10274
- truncated: obj.truncated === true
10275
- };
10276
- }
10277
- /**
10278
- * Dedicated Postgres LISTEN client for database-level CDC.
10265
+ * A dedicated, self-healing Postgres `LISTEN` connection.
10279
10266
  *
10280
- * Mirrors the resilience of the RealtimeService cross-instance LISTEN client: a
10281
- * standalone `pg.Client` (outside the Drizzle pool) that stays connected, and
10282
- * transparently reconnects on error/disconnect. Each backend instance runs one,
10283
- * so every instance observes every committed change regardless of which
10284
- * instance (or external process) made the write.
10267
+ * Every cross-instance feature in the backend needs the same thing: one
10268
+ * connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
10269
+ * comes back on its own after the database or the network drops it. CDC needed
10270
+ * it first; the channel bus needs it too. This is that connection, with the one
10271
+ * behaviour that matters to callers preserved: the **first** connect is
10272
+ * validated and rethrown, so a caller can fall back to a different strategy,
10273
+ * while every later drop is repaired quietly in the background.
10274
+ *
10275
+ * `LISTEN` is session state, so this connection must not go through a
10276
+ * transaction-mode pooler (PgBouncer): give it the direct database URL.
10285
10277
  */
10286
- var CdcListener = class CdcListener {
10287
- connectionString;
10288
- onEvent;
10278
+ var DEFAULT_RECONNECT_DELAY_MS = 3e3;
10279
+ /** Guards the identifier interpolated into `LISTEN`. */
10280
+ var SAFE_CHANNEL = /^[A-Za-z_][A-Za-z0-9_]*$/;
10281
+ var PgNotifyListener = class {
10282
+ options;
10289
10283
  client;
10290
10284
  running = false;
10291
10285
  reconnectTimer;
10292
- static RECONNECT_DELAY_MS = 3e3;
10293
- constructor(connectionString, onEvent) {
10294
- this.connectionString = connectionString;
10295
- this.onEvent = onEvent;
10286
+ constructor(options) {
10287
+ this.options = options;
10288
+ if (!SAFE_CHANNEL.test(options.channel)) throw new Error(`Unsafe NOTIFY channel name "${options.channel}" — expected a plain SQL identifier.`);
10289
+ }
10290
+ /** Whether the listener is meant to be connected right now. */
10291
+ get active() {
10292
+ return this.running;
10296
10293
  }
10297
10294
  /**
10298
10295
  * Connect and begin listening. Idempotent.
10299
10296
  *
10300
- * The **initial** connection is validated synchronously: if it cannot be
10301
- * established (or `LISTEN` is refused), this rejects so callers — notably
10302
- * `REALTIME_CDC=auto` — can detect an unusable connection and fall back to
10303
- * app-level realtime. Once the initial connection succeeds, later drops
10304
- * self-heal via {@link scheduleReconnect}.
10297
+ * Rejects if the *initial* connection or `LISTEN` fails, leaving the
10298
+ * listener stopped — callers use that to degrade deliberately instead of
10299
+ * running blind against a channel nothing is delivering.
10305
10300
  */
10306
10301
  async start() {
10307
- if (this.running) {
10308
- logger.warn("⚠️ [CDC] CdcListener.start() called but already running. Ignoring.");
10309
- return;
10310
- }
10302
+ if (this.running) return;
10311
10303
  this.running = true;
10312
10304
  try {
10313
10305
  await this.connect({ initial: true });
@@ -10316,7 +10308,7 @@ var CdcListener = class CdcListener {
10316
10308
  throw err;
10317
10309
  }
10318
10310
  }
10319
- /** Stop listening and release the connection. */
10311
+ /** Stop listening and release the connection. Idempotent. */
10320
10312
  async stop() {
10321
10313
  this.running = false;
10322
10314
  if (this.reconnectTimer) {
@@ -10331,34 +10323,30 @@ var CdcListener = class CdcListener {
10331
10323
  }
10332
10324
  }
10333
10325
  async connect({ initial = false } = {}) {
10326
+ const { connectionString, channel, onPayload, logLabel } = this.options;
10334
10327
  try {
10335
- const client = new Client({ connectionString: this.connectionString });
10328
+ const client = new Client({ connectionString });
10336
10329
  client.on("error", (err) => {
10337
- logger.error("❌ [CDC] LISTEN client error", { detail: err.message });
10330
+ logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
10338
10331
  this.scheduleReconnect();
10339
10332
  });
10340
10333
  client.on("end", () => {
10341
10334
  if (this.running) {
10342
- logger.warn("⚠️ [CDC] LISTEN client disconnected unexpectedly.");
10335
+ logger.warn(`⚠️ ${logLabel} LISTEN client disconnected unexpectedly.`);
10343
10336
  this.scheduleReconnect();
10344
10337
  }
10345
10338
  });
10346
10339
  client.on("notification", (msg) => {
10347
10340
  if (!msg.payload) return;
10348
- const event = parseCdcPayload(msg.payload);
10349
- if (!event) {
10350
- logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
10351
- return;
10352
- }
10353
- Promise.resolve(this.onEvent(event)).catch((err) => logger.error("❌ [CDC] Error handling change event", { error: err }));
10341
+ Promise.resolve(onPayload(msg.payload)).catch((err) => logger.error(`❌ ${logLabel} Error handling notification`, { error: err }));
10354
10342
  });
10355
10343
  await client.connect();
10356
- await client.query(`LISTEN ${CDC_CHANNEL}`);
10344
+ await client.query(`LISTEN ${channel}`);
10357
10345
  this.client = client;
10358
- logger.info(`📡 [CDC] Listening for database changes on channel "${CDC_CHANNEL}".`);
10346
+ logger.info(`📡 ${logLabel} Listening on channel "${channel}".`);
10359
10347
  } catch (err) {
10360
10348
  if (initial) throw err;
10361
- logger.error("❌ [CDC] Failed to connect LISTEN client", { error: err });
10349
+ logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
10362
10350
  this.scheduleReconnect();
10363
10351
  }
10364
10352
  }
@@ -10374,7 +10362,82 @@ var CdcListener = class CdcListener {
10374
10362
  this.client = void 0;
10375
10363
  }
10376
10364
  await this.connect();
10377
- }, CdcListener.RECONNECT_DELAY_MS);
10365
+ }, this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS);
10366
+ }
10367
+ };
10368
+ //#endregion
10369
+ //#region src/services/cdc/CdcListener.ts
10370
+ /**
10371
+ * Parse a `rebase_cdc` NOTIFY payload. Returns `null` for anything malformed so
10372
+ * a single bad message can never crash the listener.
10373
+ */
10374
+ function parseCdcPayload(payload) {
10375
+ let parsed;
10376
+ try {
10377
+ parsed = JSON.parse(payload);
10378
+ } catch {
10379
+ return null;
10380
+ }
10381
+ if (!parsed || typeof parsed !== "object") return null;
10382
+ const obj = parsed;
10383
+ const schema = typeof obj.schema === "string" ? obj.schema : void 0;
10384
+ const table = typeof obj.table === "string" ? obj.table : void 0;
10385
+ const op = obj.op;
10386
+ if (!schema || !table) return null;
10387
+ if (op !== "INSERT" && op !== "UPDATE" && op !== "DELETE") return null;
10388
+ return {
10389
+ schema,
10390
+ table,
10391
+ op,
10392
+ row: obj.row && typeof obj.row === "object" ? obj.row : {},
10393
+ truncated: obj.truncated === true
10394
+ };
10395
+ }
10396
+ /**
10397
+ * Dedicated Postgres LISTEN client for database-level CDC.
10398
+ *
10399
+ * A {@link PgNotifyListener} — a connection outside the Drizzle pool that stays
10400
+ * open and repairs itself — plus the parsing that turns a `rebase_cdc` payload
10401
+ * into a change event. Each backend instance runs one, so every instance
10402
+ * observes every committed change regardless of which instance (or external
10403
+ * process) made the write.
10404
+ */
10405
+ var CdcListener = class {
10406
+ listener;
10407
+ constructor(connectionString, onEvent) {
10408
+ this.listener = new PgNotifyListener({
10409
+ connectionString,
10410
+ channel: CDC_CHANNEL,
10411
+ logLabel: "[CDC]",
10412
+ onPayload: (payload) => {
10413
+ const event = parseCdcPayload(payload);
10414
+ if (!event) {
10415
+ logger.warn("⚠️ [CDC] Dropping unparseable change notification.");
10416
+ return;
10417
+ }
10418
+ return onEvent(event);
10419
+ }
10420
+ });
10421
+ }
10422
+ /**
10423
+ * Connect and begin listening. Idempotent.
10424
+ *
10425
+ * The **initial** connection is validated synchronously: if it cannot be
10426
+ * established (or `LISTEN` is refused), this rejects so callers — notably
10427
+ * `REALTIME_CDC=auto` — can detect an unusable connection and fall back to
10428
+ * app-level realtime. Once the initial connection succeeds, later drops
10429
+ * self-heal in the background.
10430
+ */
10431
+ async start() {
10432
+ if (this.listener.active) {
10433
+ logger.warn("⚠️ [CDC] CdcListener.start() called but already running. Ignoring.");
10434
+ return;
10435
+ }
10436
+ await this.listener.start();
10437
+ }
10438
+ /** Stop listening and release the connection. */
10439
+ async stop() {
10440
+ await this.listener.stop();
10378
10441
  }
10379
10442
  };
10380
10443
  //#endregion
@@ -10598,6 +10661,31 @@ var ChannelHistoryStore = class {
10598
10661
  };
10599
10662
  }
10600
10663
  /**
10664
+ * One retained message by its address.
10665
+ *
10666
+ * This is what makes the cross-instance pointer path work: a broadcast too
10667
+ * large to travel inside a `pg_notify` payload is already stored here, so
10668
+ * the notification carries `(channel, seq)` and each receiving instance
10669
+ * reads the body back. Returns null when the message has since been pruned
10670
+ * — a receiver that is that far behind has nothing useful to deliver, and
10671
+ * the client's own `channel_history` replay is the repair path.
10672
+ */
10673
+ async getBySeq(channel, seq) {
10674
+ const row = (await this.db.execute(sql`
10675
+ SELECT seq, event, payload, sender_id, created_at
10676
+ FROM rebase.channel_messages
10677
+ WHERE channel = ${channel} AND seq = ${seq}
10678
+ `)).rows[0];
10679
+ if (!row) return null;
10680
+ return {
10681
+ seq: Number(row.seq),
10682
+ event: row.event,
10683
+ payload: row.payload,
10684
+ senderId: row.sender_id ?? void 0,
10685
+ at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
10686
+ };
10687
+ }
10688
+ /**
10601
10689
  * Enforce a channel's retention bounds.
10602
10690
  *
10603
10691
  * Throttled per channel, so a burst of operations prunes once rather than
@@ -10639,6 +10727,447 @@ var ChannelHistoryStore = class {
10639
10727
  }
10640
10728
  };
10641
10729
  //#endregion
10730
+ //#region src/services/channel-presence.ts
10731
+ /**
10732
+ * The shared presence roster.
10733
+ *
10734
+ * Broadcast only ever needed *fan-out* to work across instances — a frame goes
10735
+ * out, whoever is connected receives it. Presence needs more than that, because
10736
+ * `presence_state` is a question ("who is in this document?") and a per-process
10737
+ * `Map` can only answer for the clients that happen to share a replica with the
10738
+ * asker. Two people editing the same scene through different pods would each
10739
+ * see an empty room while broadcasting cursors at each other perfectly.
10740
+ *
10741
+ * So presence gets one row per tracked client, in Postgres, readable by every
10742
+ * instance. Three consequences worth stating:
10743
+ *
10744
+ * - **The table is the roster; the in-process map is a cache of our own
10745
+ * clients.** Reads answer from the table when this store is active, so the
10746
+ * answer is the same whichever instance is asked.
10747
+ *
10748
+ * - **`last_seen` is the liveness signal, and it is already there.** The client
10749
+ * heartbeats presence every ~20 s against a 30 s window; the sweep that has
10750
+ * always reaped local stale entries now also reaps rows belonging to
10751
+ * instances that stopped writing — which is exactly what a crashed pod looks
10752
+ * like. Crash recovery is a property of the TTL, not a separate mechanism.
10753
+ *
10754
+ * - **The sweep deletes with `RETURNING`.** Whichever instance wins the delete
10755
+ * is the one that announces the departures, so a stale client produces one
10756
+ * `presence_diff` for the cluster rather than one per replica.
10757
+ */
10758
+ var ChannelPresenceStore = class {
10759
+ db;
10760
+ instanceId;
10761
+ tablesReady = false;
10762
+ constructor(db, instanceId) {
10763
+ this.db = db;
10764
+ this.instanceId = instanceId;
10765
+ }
10766
+ /** Create the roster table. Idempotent. */
10767
+ async ensureTables() {
10768
+ if (this.tablesReady) return;
10769
+ await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
10770
+ await this.db.execute(sql`
10771
+ CREATE TABLE IF NOT EXISTS rebase.channel_presence (
10772
+ channel TEXT NOT NULL,
10773
+ client_id TEXT NOT NULL,
10774
+ instance_id TEXT NOT NULL,
10775
+ state JSONB NOT NULL DEFAULT '{}'::jsonb,
10776
+ last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10777
+ PRIMARY KEY (channel, client_id)
10778
+ )
10779
+ `);
10780
+ await this.db.execute(sql`
10781
+ CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
10782
+ ON rebase.channel_presence (last_seen)
10783
+ `);
10784
+ this.tablesReady = true;
10785
+ }
10786
+ /** Record (or refresh) a client's presence. */
10787
+ async track(channel, clientId, state) {
10788
+ await this.db.execute(sql`
10789
+ INSERT INTO rebase.channel_presence (channel, client_id, instance_id, state, last_seen)
10790
+ VALUES (${channel}, ${clientId}, ${this.instanceId}, ${JSON.stringify(state ?? {})}::jsonb, NOW())
10791
+ ON CONFLICT (channel, client_id) DO UPDATE
10792
+ SET state = EXCLUDED.state,
10793
+ instance_id = EXCLUDED.instance_id,
10794
+ last_seen = NOW()
10795
+ `);
10796
+ }
10797
+ /** Drop one client's presence in one channel. */
10798
+ async remove(channel, clientId) {
10799
+ await this.db.execute(sql`
10800
+ DELETE FROM rebase.channel_presence
10801
+ WHERE channel = ${channel} AND client_id = ${clientId}
10802
+ `);
10803
+ }
10804
+ /** Drop a client from every channel — used when its socket closes. */
10805
+ async removeClient(clientId) {
10806
+ await this.db.execute(sql`
10807
+ DELETE FROM rebase.channel_presence WHERE client_id = ${clientId}
10808
+ `);
10809
+ }
10810
+ /** The global roster for a channel. */
10811
+ async roster(channel) {
10812
+ const result = await this.db.execute(sql`
10813
+ SELECT client_id, state FROM rebase.channel_presence WHERE channel = ${channel}
10814
+ `);
10815
+ const presences = {};
10816
+ for (const row of result.rows) presences[row.client_id] = row.state ?? {};
10817
+ return presences;
10818
+ }
10819
+ /**
10820
+ * Reap rows this instance is not responsible for and that have gone quiet.
10821
+ *
10822
+ * Own rows are excluded because the in-process sweep already handles them —
10823
+ * and handles them better, since it can tell "the socket is gone" from "the
10824
+ * heartbeat is late". What is left is precisely the interesting case: rows
10825
+ * written by an instance that is no longer writing.
10826
+ *
10827
+ * Returns what was removed, so the caller can announce it.
10828
+ */
10829
+ async sweepStale(ttlMs) {
10830
+ return (await this.db.execute(sql`
10831
+ DELETE FROM rebase.channel_presence
10832
+ WHERE instance_id <> ${this.instanceId}
10833
+ AND last_seen < NOW() - MAKE_INTERVAL(secs => ${ttlMs / 1e3})
10834
+ RETURNING channel, client_id, state
10835
+ `)).rows.map((row) => ({
10836
+ channel: row.channel,
10837
+ clientId: row.client_id,
10838
+ state: row.state ?? {}
10839
+ }));
10840
+ }
10841
+ /**
10842
+ * Remove every row this instance owns. Called on graceful shutdown so a
10843
+ * rolling deploy does not leave a TTL window of ghosts in every roster.
10844
+ */
10845
+ async removeInstance() {
10846
+ await this.db.execute(sql`
10847
+ DELETE FROM rebase.channel_presence WHERE instance_id = ${this.instanceId}
10848
+ `);
10849
+ }
10850
+ };
10851
+ //#endregion
10852
+ //#region src/services/channel-bus/ChannelBus.ts
10853
+ /**
10854
+ * The default: no cross-instance delivery at all.
10855
+ *
10856
+ * This is what every deployment ran before the bus existed, and what a
10857
+ * single-instance deployment should keep running — `publish` resolves without
10858
+ * touching the network, so the broadcast path is the same handful of `ws.send`
10859
+ * calls it always was.
10860
+ */
10861
+ var MemoryChannelBus = class {
10862
+ kind = "memory";
10863
+ maxFrameBytes = Infinity;
10864
+ async start() {}
10865
+ async publish() {}
10866
+ async stop() {}
10867
+ };
10868
+ /** Encoded size of a frame, for a transport's size check. */
10869
+ function frameByteLength(frame) {
10870
+ return Buffer.byteLength(JSON.stringify(frame), "utf8");
10871
+ }
10872
+ //#endregion
10873
+ //#region src/services/channel-bus/PostgresChannelBus.ts
10874
+ /**
10875
+ * Channel bus over Postgres LISTEN/NOTIFY.
10876
+ *
10877
+ * Chosen because it needs nothing that a Rebase deployment does not already
10878
+ * have — the same database, the same direct URL the CDC listener uses. Three
10879
+ * properties of `NOTIFY` shape everything below:
10880
+ *
10881
+ * - **8000 bytes per payload.** Presence and cursors fit with room to spare; a
10882
+ * scene snapshot does not. Rather than truncate or drop, an oversized frame
10883
+ * on a *retained* channel is published as a pointer — the body is already in
10884
+ * `rebase.channel_messages` with a sequence number, so the receiver reads it
10885
+ * back. That is the same trick the entity path uses (notify an address,
10886
+ * refetch the row), applied to a different table. On an ephemeral channel
10887
+ * there is nothing to point at, so the publish is refused loudly instead of
10888
+ * reaching some instances and not others.
10889
+ *
10890
+ * - **A notify is a query on the primary database.** Not a slow one, but it
10891
+ * competes with the application's real queries, and that — not throughput —
10892
+ * is what actually limits this transport. Measured, it carried ~10k
10893
+ * cross-instance messages/second and stayed flat out to eight instances; what
10894
+ * it should not do is spend 10k queries/second of the database's budget on
10895
+ * cursor movement. Hence the batching below.
10896
+ *
10897
+ * - **Delivery is best-effort.** Retained channels repair themselves through
10898
+ * the client's history replay, so a lost frame costs a live update rather
10899
+ * than correctness. That is what makes coalescing safe.
10900
+ */
10901
+ /** NOTIFY channel carrying channel-bus frames. */
10902
+ var CHANNEL_BUS_NOTIFY_CHANNEL = "rebase_channel_bus";
10903
+ /**
10904
+ * Postgres refuses a NOTIFY payload of 8000 bytes or more. The margin below it
10905
+ * is for nothing in particular — it is there so that a payload which passes this
10906
+ * check cannot fail at the server for being a few bytes over.
10907
+ */
10908
+ var PG_NOTIFY_MAX_PAYLOAD_BYTES = 7500;
10909
+ /**
10910
+ * How long a batching window stays open.
10911
+ *
10912
+ * Ten milliseconds is below the threshold where a human notices a cursor lag,
10913
+ * and it is the difference between one query per message and one query per
10914
+ * window under load. Set to 0 to disable coalescing entirely.
10915
+ */
10916
+ var DEFAULT_BATCH_WINDOW_MS = 10;
10917
+ /** JSON overhead per frame inside a batch: the wrapping array's comma. */
10918
+ var BATCH_SEPARATOR_BYTES = 1;
10919
+ /** JSON overhead of the batch envelope itself: `{"batch":[]}`. */
10920
+ var BATCH_ENVELOPE_BYTES = 12;
10921
+ var PostgresChannelBus = class {
10922
+ db;
10923
+ connectionString;
10924
+ kind = "postgres";
10925
+ maxFrameBytes = PG_NOTIFY_MAX_PAYLOAD_BYTES;
10926
+ listener;
10927
+ batchWindowMs;
10928
+ /**
10929
+ * Frames waiting for the current window to close.
10930
+ *
10931
+ * The window is opened by a publish that found none open, and that publish
10932
+ * is sent *immediately* rather than joining a batch — see {@link publish}.
10933
+ */
10934
+ pending = [];
10935
+ pendingBytes = BATCH_ENVELOPE_BYTES;
10936
+ windowTimer;
10937
+ stopped = false;
10938
+ constructor(db, connectionString, options = {}) {
10939
+ this.db = db;
10940
+ this.connectionString = connectionString;
10941
+ const configured = options.batchWindowMs;
10942
+ this.batchWindowMs = typeof configured === "number" && configured >= 0 ? configured : 10;
10943
+ }
10944
+ async start(handler) {
10945
+ this.stopped = false;
10946
+ this.listener = new PgNotifyListener({
10947
+ connectionString: this.connectionString,
10948
+ channel: CHANNEL_BUS_NOTIFY_CHANNEL,
10949
+ logLabel: "[ChannelBus]",
10950
+ onPayload: async (payload) => {
10951
+ const frames = parseChannelBusPayload(payload);
10952
+ if (!frames.length) {
10953
+ logger.warn("⚠️ [ChannelBus] Dropping unparseable payload.");
10954
+ return;
10955
+ }
10956
+ for (const frame of frames) await handler(frame);
10957
+ }
10958
+ });
10959
+ await this.listener.start();
10960
+ }
10961
+ /**
10962
+ * Publish, coalescing under load.
10963
+ *
10964
+ * The window is *leading edge*: a publish arriving when no window is open is
10965
+ * sent straight away and opens one, so an idle channel pays no added latency
10966
+ * at all. Frames arriving while it is open are collected and leave together
10967
+ * when it closes. The effect is that cost tracks elapsed time rather than
10968
+ * message count — one query per window instead of one per message — which is
10969
+ * the same shape as the retention pruning throttle, for the same reason.
10970
+ *
10971
+ * The returned promise settles when the frame has actually left, not when it
10972
+ * was queued, so the contract ("reaches the other instances, or rejects")
10973
+ * still holds.
10974
+ */
10975
+ async publish(frame) {
10976
+ if (this.batchWindowMs === 0 || this.stopped) {
10977
+ await this.send([frame]);
10978
+ return;
10979
+ }
10980
+ if (!this.windowTimer) {
10981
+ this.openWindow();
10982
+ await this.send([frame]);
10983
+ return;
10984
+ }
10985
+ const bytes = frameByteLength(frame) + BATCH_SEPARATOR_BYTES;
10986
+ if (this.pending.length && this.pendingBytes + bytes > this.maxFrameBytes) this.flush();
10987
+ return new Promise((resolve, reject) => {
10988
+ this.pending.push({
10989
+ frame,
10990
+ bytes,
10991
+ resolve,
10992
+ reject
10993
+ });
10994
+ this.pendingBytes += bytes;
10995
+ });
10996
+ }
10997
+ async stop() {
10998
+ this.stopped = true;
10999
+ if (this.windowTimer) {
11000
+ clearTimeout(this.windowTimer);
11001
+ this.windowTimer = void 0;
11002
+ }
11003
+ this.flush();
11004
+ await this.listener?.stop();
11005
+ this.listener = void 0;
11006
+ }
11007
+ openWindow() {
11008
+ this.windowTimer = setTimeout(() => {
11009
+ this.windowTimer = void 0;
11010
+ if (this.pending.length) {
11011
+ this.flush();
11012
+ this.openWindow();
11013
+ }
11014
+ }, this.batchWindowMs);
11015
+ this.windowTimer.unref?.();
11016
+ }
11017
+ /** Send everything queued and settle the promises waiting on it. */
11018
+ flush() {
11019
+ if (!this.pending.length) return;
11020
+ const batch = this.pending;
11021
+ this.pending = [];
11022
+ this.pendingBytes = BATCH_ENVELOPE_BYTES;
11023
+ this.send(batch.map((p) => p.frame)).then(() => {
11024
+ for (const p of batch) p.resolve();
11025
+ }).catch((error) => {
11026
+ for (const p of batch) p.reject(error);
11027
+ });
11028
+ }
11029
+ /**
11030
+ * One NOTIFY.
11031
+ *
11032
+ * A single frame goes out in the plain, unwrapped shape. That is not just
11033
+ * economy: during a rolling deploy an instance running the previous build
11034
+ * understands only that shape, and low-rate traffic — presence, the tail of
11035
+ * a session — is exactly what is flowing while pods restart. Batching only
11036
+ * appears under load, which shrinks the mixed-version window to almost
11037
+ * nothing.
11038
+ */
11039
+ async send(frames) {
11040
+ if (!frames.length) return;
11041
+ const payload = frames.length === 1 ? JSON.stringify(frames[0]) : JSON.stringify({ batch: frames });
11042
+ await this.db.execute(sql`SELECT pg_notify(${CHANNEL_BUS_NOTIFY_CHANNEL}, ${payload})`);
11043
+ }
11044
+ };
11045
+ /**
11046
+ * Parse a bus payload into the frames it carries.
11047
+ *
11048
+ * Accepts both wire shapes — a bare frame and a `{ batch: [...] }` envelope —
11049
+ * so an instance on the new build understands one on the old. Returns an empty
11050
+ * array for anything unrecognisable: a malformed or future-versioned message
11051
+ * must never take the listener down.
11052
+ */
11053
+ function parseChannelBusPayload(payload) {
11054
+ let parsed;
11055
+ try {
11056
+ parsed = JSON.parse(payload);
11057
+ } catch {
11058
+ return [];
11059
+ }
11060
+ if (!parsed || typeof parsed !== "object") return [];
11061
+ const batch = parsed.batch;
11062
+ if (Array.isArray(batch)) return batch.map((entry) => coerceFrame(entry)).filter((frame) => frame !== null);
11063
+ const single = coerceFrame(parsed);
11064
+ return single ? [single] : [];
11065
+ }
11066
+ /**
11067
+ * Parse a single bus frame, returning null for anything that is not a frame we
11068
+ * understand.
11069
+ */
11070
+ function parseChannelBusFrame(payload) {
11071
+ try {
11072
+ return coerceFrame(JSON.parse(payload));
11073
+ } catch {
11074
+ return null;
11075
+ }
11076
+ }
11077
+ function coerceFrame(value) {
11078
+ if (!value || typeof value !== "object") return null;
11079
+ const obj = value;
11080
+ const sid = typeof obj.sid === "string" ? obj.sid : void 0;
11081
+ const channel = typeof obj.channel === "string" ? obj.channel : void 0;
11082
+ if (!sid || !channel) return null;
11083
+ switch (obj.kind) {
11084
+ case "broadcast":
11085
+ if (typeof obj.event !== "string") return null;
11086
+ return {
11087
+ kind: "broadcast",
11088
+ sid,
11089
+ channel,
11090
+ event: obj.event,
11091
+ from: typeof obj.from === "string" ? obj.from : void 0,
11092
+ seq: typeof obj.seq === "number" ? obj.seq : void 0,
11093
+ payload: obj.payload
11094
+ };
11095
+ case "broadcast_ref":
11096
+ if (typeof obj.seq !== "number") return null;
11097
+ return {
11098
+ kind: "broadcast_ref",
11099
+ sid,
11100
+ channel,
11101
+ from: typeof obj.from === "string" ? obj.from : void 0,
11102
+ seq: obj.seq
11103
+ };
11104
+ case "presence_diff": return {
11105
+ kind: "presence_diff",
11106
+ sid,
11107
+ channel,
11108
+ joins: obj.joins ?? {},
11109
+ leaves: obj.leaves ?? {}
11110
+ };
11111
+ default: return null;
11112
+ }
11113
+ }
11114
+ //#endregion
11115
+ //#region src/services/channel-bus/index.ts
11116
+ /**
11117
+ * Merge `REALTIME_CHANNEL_BUS` into the configured bus.
11118
+ *
11119
+ * The environment wins over a *named* built-in, so the transport can be changed
11120
+ * per deployment without a rebuild — the same reason `REALTIME_CDC` is an env
11121
+ * var. It does **not** win over a supplied instance: the env var can only name
11122
+ * transports this package knows how to construct, so honouring it there would
11123
+ * mean silently discarding the object the application handed us.
11124
+ */
11125
+ function resolveChannelBusSetting(configured) {
11126
+ const raw = (process.env.REALTIME_CHANNEL_BUS || "").trim().toLowerCase();
11127
+ if (isChannelBusInstance(configured)) {
11128
+ if (raw && raw !== configured.kind) logger.warn(`⚠️ [ChannelBus] REALTIME_CHANNEL_BUS="${raw}" is ignored because realtime.bus was given a "${configured.kind}" transport instance directly. Remove one of the two to make the intent clear.`);
11129
+ return configured;
11130
+ }
11131
+ if (!raw) return configured ?? { type: "memory" };
11132
+ if (raw !== "memory" && raw !== "postgres") {
11133
+ logger.warn(`⚠️ [ChannelBus] Unknown REALTIME_CHANNEL_BUS value "${raw}" — expected memory|postgres, or pass a ChannelBus instance as realtime.bus for a transport that ships separately. Falling back to the configured bus.`);
11134
+ return configured ?? { type: "memory" };
11135
+ }
11136
+ if (configured?.type === raw) return configured;
11137
+ return raw === "memory" ? { type: "memory" } : { type: "postgres" };
11138
+ }
11139
+ /**
11140
+ * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
11141
+ * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
11142
+ * config-only callers keep their exact types.
11143
+ */
11144
+ function resolveChannelBusConfig(configured) {
11145
+ return resolveChannelBusSetting(configured);
11146
+ }
11147
+ /**
11148
+ * Produce the bus a setting asks for.
11149
+ *
11150
+ * An instance is handed straight back — constructing it was the application's
11151
+ * job, and this function has nothing to add. A named built-in that turns out to
11152
+ * be unusable degrades to the memory bus, with the reason logged, rather than
11153
+ * throwing: a misconfigured bus should cost a deployment its cross-instance
11154
+ * fan-out, not its ability to boot.
11155
+ */
11156
+ function createChannelBus(setting, deps) {
11157
+ if (isChannelBusInstance(setting)) return setting;
11158
+ switch (setting.type) {
11159
+ case "postgres": {
11160
+ const connectionString = setting.connectionString || deps.directUrl;
11161
+ if (!connectionString) {
11162
+ logger.warn("⚠️ [ChannelBus] realtime.bus is \"postgres\" but no direct database URL is available (set DATABASE_DIRECT_URL or realtime.bus.connectionString) — channel broadcast and presence stay per-instance.");
11163
+ return new MemoryChannelBus();
11164
+ }
11165
+ return new PostgresChannelBus(deps.db, connectionString, { batchWindowMs: setting.batchWindowMs });
11166
+ }
11167
+ default: return new MemoryChannelBus();
11168
+ }
11169
+ }
11170
+ //#endregion
10642
11171
  //#region src/services/realtimeService.ts
10643
11172
  /** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
10644
11173
  var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
@@ -10674,8 +11203,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10674
11203
  * wait on each other.
10675
11204
  */
10676
11205
  channelSendQueues = /* @__PURE__ */ new Map();
11206
+ /**
11207
+ * Cross-instance transport for channel frames and presence.
11208
+ *
11209
+ * Defaults to the memory bus, which publishes nowhere — so a single-instance
11210
+ * deployment runs the same fan-out it always did, with one resolved promise
11211
+ * per broadcast for company. See `channel-bus/ChannelBus.ts`.
11212
+ */
11213
+ bus = new MemoryChannelBus();
11214
+ /**
11215
+ * The shared presence roster, present only when a real bus is active.
11216
+ *
11217
+ * Fan-out alone is not enough for presence: `presence_state` has to answer
11218
+ * with everyone in the channel, and per-process maps can only answer for
11219
+ * this replica's clients. See `channel-presence.ts`.
11220
+ */
11221
+ presenceStore;
11222
+ /** Sweeps roster rows left behind by instances that stopped heartbeating. */
11223
+ presenceSweepInterval;
11224
+ /**
11225
+ * Channels whose oversized ephemeral broadcasts have already been reported,
11226
+ * so a hot channel logs the problem once rather than once per message.
11227
+ */
11228
+ oversizedBroadcastWarned = /* @__PURE__ */ new Set();
10677
11229
  presenceInterval;
10678
11230
  static PRESENCE_TIMEOUT_MS = 3e4;
11231
+ /** How often stale roster rows from other instances are reaped. */
11232
+ static PRESENCE_SWEEP_INTERVAL_MS = 1e4;
10679
11233
  dataService;
10680
11234
  _subscriptions = /* @__PURE__ */ new Map();
10681
11235
  subscriptionCallbacks = /* @__PURE__ */ new Map();
@@ -10824,10 +11378,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10824
11378
  }
10825
11379
  for (const [channel, members] of this.channels.entries()) if (members.has(clientId)) {
10826
11380
  members.delete(clientId);
10827
- this.removePresence(clientId, channel);
11381
+ this.removePresence(clientId, channel, { skipStore: true });
10828
11382
  if (members.size === 0) this.channels.delete(channel);
10829
11383
  }
10830
- for (const [channel] of this.presence) this.removePresence(clientId, channel);
11384
+ for (const [channel] of this.presence) this.removePresence(clientId, channel, { skipStore: true });
11385
+ this.presenceStoreOp(() => this.presenceStore.removeClient(clientId), "client removal");
10831
11386
  }
10832
11387
  async handleMessage(clientId, message, authContext) {
10833
11388
  const payload = message.payload;
@@ -11357,6 +11912,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11357
11912
  const retention = this.channelHistory?.retentionFor(channel);
11358
11913
  if (!retention) {
11359
11914
  this.fanOutBroadcast(clientId, channel, event, payload);
11915
+ this.publishBroadcast(clientId, channel, event, payload);
11360
11916
  return;
11361
11917
  }
11362
11918
  const next = (this.channelSendQueues.get(channel) ?? Promise.resolve()).catch(() => {}).then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
@@ -11385,6 +11941,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11385
11941
  return;
11386
11942
  }
11387
11943
  this.fanOutBroadcast(clientId, channel, event, payload, seq);
11944
+ this.publishBroadcast(clientId, channel, event, payload, seq);
11388
11945
  try {
11389
11946
  await this.channelHistory.prune(channel, retention);
11390
11947
  } catch (error) {
@@ -11409,6 +11966,132 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11409
11966
  }
11410
11967
  }
11411
11968
  /**
11969
+ * Install the transport that carries channel frames between instances.
11970
+ *
11971
+ * Called once at boot. A bus that cannot start is reported and replaced with
11972
+ * the memory bus: losing cross-instance fan-out degrades collaboration to
11973
+ * what it was before this existed, whereas refusing to boot takes the whole
11974
+ * backend down for it.
11975
+ */
11976
+ async configureChannelBus(bus) {
11977
+ if (bus.kind === "memory") {
11978
+ this.bus = bus;
11979
+ return;
11980
+ }
11981
+ try {
11982
+ await bus.start((frame) => this.handleBusFrame(frame));
11983
+ } catch (error) {
11984
+ logger.warn(`⚠️ [ChannelBus] Could not start the "${bus.kind}" channel bus — channel broadcast and presence stay per-instance. Clients served by different replicas will not see each other.`, { error });
11985
+ await bus.stop().catch(() => {});
11986
+ this.bus = new MemoryChannelBus();
11987
+ return;
11988
+ }
11989
+ this.bus = bus;
11990
+ try {
11991
+ const store = new ChannelPresenceStore(this.db, this.instanceId);
11992
+ await store.ensureTables();
11993
+ this.presenceStore = store;
11994
+ this.ensurePresenceSweep();
11995
+ } catch (error) {
11996
+ logger.warn("⚠️ [ChannelBus] Could not create the shared presence table — presence rosters will only list clients connected to this instance (broadcast is unaffected).", { error });
11997
+ this.presenceStore = void 0;
11998
+ }
11999
+ logger.info(`📡 [ChannelBus] Cross-instance channels active via ${bus.kind} (instanceId: ${this.instanceId}).`);
12000
+ }
12001
+ /** Which transport is in use — `"memory"` means per-instance only. */
12002
+ getChannelBusKind() {
12003
+ return this.bus.kind;
12004
+ }
12005
+ /**
12006
+ * Send a broadcast to the other instances.
12007
+ *
12008
+ * Fire-and-forget by design: the clients on this instance have already been
12009
+ * served, and a bus that is briefly unreachable must not turn a broadcast
12010
+ * into an error for the sender.
12011
+ */
12012
+ publishBroadcast(clientId, channel, event, payload, seq) {
12013
+ if (this.bus.kind === "memory") return;
12014
+ const frame = {
12015
+ kind: "broadcast",
12016
+ sid: this.instanceId,
12017
+ channel,
12018
+ event,
12019
+ from: clientId,
12020
+ ...seq !== void 0 ? { seq } : {},
12021
+ payload
12022
+ };
12023
+ if (frameByteLength(frame) > this.bus.maxFrameBytes) {
12024
+ if (seq === void 0) {
12025
+ this.reportOversizedBroadcast(clientId, channel);
12026
+ return;
12027
+ }
12028
+ this.publishFrame({
12029
+ kind: "broadcast_ref",
12030
+ sid: this.instanceId,
12031
+ channel,
12032
+ from: clientId,
12033
+ seq
12034
+ });
12035
+ return;
12036
+ }
12037
+ this.publishFrame(frame);
12038
+ }
12039
+ async publishFrame(frame) {
12040
+ try {
12041
+ await this.bus.publish(frame);
12042
+ } catch (error) {
12043
+ logger.error("❌ [ChannelBus] Failed to publish frame — other instances did not receive it", {
12044
+ detail: `${frame.kind} on "${frame.channel}"`,
12045
+ error
12046
+ });
12047
+ }
12048
+ }
12049
+ /**
12050
+ * Tell the sender that a message was delivered locally but nowhere else.
12051
+ *
12052
+ * Staying quiet here would be the worst option available: on one instance
12053
+ * the app works, on two it works for half the users, and nothing in the
12054
+ * logs connects the two. The fix is a one-liner in config — give the
12055
+ * channel a retention rule and the message travels as a pointer instead —
12056
+ * so the message says exactly that.
12057
+ */
12058
+ reportOversizedBroadcast(clientId, channel) {
12059
+ const remedy = `Add a retention rule for "${channel}" (realtime.channels) — retained messages travel by reference and have no size limit.`;
12060
+ if (!this.oversizedBroadcastWarned.has(channel)) {
12061
+ this.oversizedBroadcastWarned.add(channel);
12062
+ logger.warn(`⚠️ [ChannelBus] A broadcast on ephemeral channel "${channel}" exceeds the ${this.bus.maxFrameBytes}-byte limit of the ${this.bus.kind} bus and reached only this instance. ` + remedy);
12063
+ }
12064
+ this.sendError(clientId, `Broadcast on "${channel}" was too large to reach other instances. ${remedy}`, void 0, "CHANNEL_BUS_PAYLOAD_TOO_LARGE");
12065
+ }
12066
+ /**
12067
+ * Deliver a frame published by another instance to this one's clients.
12068
+ *
12069
+ * Frames we published ourselves are dropped on arrival — the local fan-out
12070
+ * happened before the publish — exactly as the entity-change handler skips
12071
+ * its own `sid`.
12072
+ */
12073
+ async handleBusFrame(frame) {
12074
+ if (frame.sid === this.instanceId) return;
12075
+ switch (frame.kind) {
12076
+ case "broadcast":
12077
+ this.fanOutBroadcast(frame.from ?? "", frame.channel, frame.event, frame.payload, frame.seq);
12078
+ return;
12079
+ case "broadcast_ref": {
12080
+ if (!this.channels.get(frame.channel)?.size) return;
12081
+ const entry = await this.channelHistory?.getBySeq(frame.channel, frame.seq);
12082
+ if (!entry) {
12083
+ logger.warn(`⚠️ [ChannelBus] Message ${frame.seq} on "${frame.channel}" is no longer retained — clients on this instance will need to replay (channel_history) to catch up.`);
12084
+ return;
12085
+ }
12086
+ this.fanOutBroadcast(frame.from ?? "", frame.channel, entry.event, entry.payload, entry.seq);
12087
+ return;
12088
+ }
12089
+ case "presence_diff":
12090
+ this.deliverPresenceDiff(frame.channel, frame.joins, frame.leaves);
12091
+ return;
12092
+ }
12093
+ }
12094
+ /**
11412
12095
  * Install retention rules and create the tables they need.
11413
12096
  *
11414
12097
  * Safe to call with no rules (and safe not to call at all): the store stays
@@ -11457,32 +12140,77 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11457
12140
  ...latestSeq !== void 0 ? { latestSeq } : {}
11458
12141
  }));
11459
12142
  }
11460
- /** Track presence in a channel */
12143
+ /**
12144
+ * Track presence in a channel.
12145
+ *
12146
+ * The client re-sends this every ~20s as a heartbeat against the 30s
12147
+ * timeout, so most calls carry the state that is already recorded. Those
12148
+ * refresh `last_seen` and stop there: re-announcing an unchanged state to
12149
+ * every instance would put a bus message per client per heartbeat on the
12150
+ * wire to tell everyone nothing happened.
12151
+ */
11461
12152
  trackPresence(clientId, channel, state) {
11462
12153
  if (!this.presence.has(channel)) this.presence.set(channel, /* @__PURE__ */ new Map());
11463
- this.presence.get(channel).set(clientId, {
12154
+ const channelPresence = this.presence.get(channel);
12155
+ const previous = channelPresence.get(clientId);
12156
+ const changed = !previous || JSON.stringify(previous.state) !== JSON.stringify(state);
12157
+ channelPresence.set(clientId, {
11464
12158
  state,
11465
12159
  lastSeen: Date.now()
11466
12160
  });
11467
- this.broadcastPresenceDiff(channel, { [clientId]: state }, {});
12161
+ this.presenceStoreOp(() => this.presenceStore.track(channel, clientId, state), "track");
12162
+ this.deliverPresenceDiff(channel, { [clientId]: state }, {});
12163
+ if (changed) this.publishPresenceDiff(channel, { [clientId]: state }, {});
11468
12164
  this.ensurePresenceCleanup();
11469
12165
  }
11470
- /** Remove presence from a channel */
11471
- removePresence(clientId, channel) {
12166
+ /**
12167
+ * Remove presence from a channel.
12168
+ *
12169
+ * `skipStore` is for the socket-close path, which clears every channel at
12170
+ * once and then deletes the client's rows in a single statement instead of
12171
+ * one per channel.
12172
+ */
12173
+ removePresence(clientId, channel, options) {
11472
12174
  const channelPresence = this.presence.get(channel);
11473
12175
  if (!channelPresence) return;
11474
12176
  const entry = channelPresence.get(clientId);
11475
12177
  if (entry) {
11476
12178
  channelPresence.delete(clientId);
11477
- this.broadcastPresenceDiff(channel, {}, { [clientId]: entry.state });
12179
+ this.deliverPresenceDiff(channel, {}, { [clientId]: entry.state });
12180
+ this.publishPresenceDiff(channel, {}, { [clientId]: entry.state });
12181
+ if (!options?.skipStore) this.presenceStoreOp(() => this.presenceStore.remove(channel, clientId), "remove");
11478
12182
  }
11479
12183
  if (channelPresence.size === 0) this.presence.delete(channel);
11480
12184
  }
11481
- /** Send full presence state to a specific client */
12185
+ /**
12186
+ * Send the full roster for a channel to one client.
12187
+ *
12188
+ * Answered from the shared table when there is one, because "who is in this
12189
+ * document?" has a single answer that must not depend on which replica the
12190
+ * asker happens to be connected to. Without a bus there is nothing to share
12191
+ * and the local map *is* the roster — that path stays synchronous, which is
12192
+ * what it always was.
12193
+ */
11482
12194
  sendPresenceState(clientId, channel) {
12195
+ if (!this.presenceStore) {
12196
+ this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
12197
+ return;
12198
+ }
12199
+ this.presenceStore.roster(channel).then((presences) => {
12200
+ this.sendPresenceStateMessage(clientId, channel, presences);
12201
+ }).catch((error) => {
12202
+ logger.warn(`⚠️ [Presence] Could not read the shared roster for "${channel}" — answering with this instance's clients only.`, { error });
12203
+ this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
12204
+ });
12205
+ }
12206
+ /** Presence of the clients connected to this instance. */
12207
+ localPresences(channel) {
11483
12208
  const channelPresence = this.presence.get(channel);
11484
12209
  const presences = {};
11485
12210
  if (channelPresence) for (const [id, { state }] of channelPresence) presences[id] = state;
12211
+ return presences;
12212
+ }
12213
+ sendPresenceStateMessage(clientId, channel, presences) {
11486
12214
  const ws = this.clients.get(clientId);
11487
12215
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
11488
12216
  type: "presence_state",
@@ -11490,8 +12218,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11490
12218
  presences
11491
12219
  }));
11492
12220
  }
11493
- /** Broadcast presence diff (joins/leaves) to channel */
11494
- broadcastPresenceDiff(channel, joins, leaves) {
12221
+ /** Deliver a presence diff to this instance's members of the channel. */
12222
+ deliverPresenceDiff(channel, joins, leaves) {
11495
12223
  const members = this.channels.get(channel);
11496
12224
  if (!members) return;
11497
12225
  const message = JSON.stringify({
@@ -11505,6 +12233,26 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11505
12233
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
11506
12234
  }
11507
12235
  }
12236
+ /** Tell the other instances about a presence change. */
12237
+ publishPresenceDiff(channel, joins, leaves) {
12238
+ if (this.bus.kind === "memory") return;
12239
+ this.publishFrame({
12240
+ kind: "presence_diff",
12241
+ sid: this.instanceId,
12242
+ channel,
12243
+ joins,
12244
+ leaves
12245
+ });
12246
+ }
12247
+ /** Run a roster write when there is a roster, and never let it throw. */
12248
+ async presenceStoreOp(op, label) {
12249
+ if (!this.presenceStore) return;
12250
+ try {
12251
+ await op();
12252
+ } catch (error) {
12253
+ logger.warn(`⚠️ [Presence] Shared roster ${label} failed`, { error });
12254
+ }
12255
+ }
11508
12256
  /** Periodic cleanup for stale presences */
11509
12257
  ensurePresenceCleanup() {
11510
12258
  if (this.presenceInterval) return;
@@ -11518,6 +12266,35 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11518
12266
  }, 1e4);
11519
12267
  }
11520
12268
  /**
12269
+ * Reap roster rows whose owning instance stopped heartbeating.
12270
+ *
12271
+ * This is the cross-instance half of the sweep above, and it doubles as
12272
+ * crash recovery: a pod that dies takes its clients with it but leaves
12273
+ * their rows behind, and after one TTL window they look exactly like any
12274
+ * other client that went quiet. The delete returns what it removed, so
12275
+ * whichever instance wins the race is the one that announces the
12276
+ * departures — once for the cluster, not once per replica.
12277
+ */
12278
+ ensurePresenceSweep() {
12279
+ if (this.presenceSweepInterval || !this.presenceStore) return;
12280
+ this.presenceSweepInterval = setInterval(() => void this.sweepStalePresence(), RealtimeService.PRESENCE_SWEEP_INTERVAL_MS);
12281
+ this.presenceSweepInterval.unref?.();
12282
+ }
12283
+ /** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
12284
+ async sweepStalePresence() {
12285
+ if (!this.presenceStore) return;
12286
+ try {
12287
+ const removed = await this.presenceStore.sweepStale(RealtimeService.PRESENCE_TIMEOUT_MS);
12288
+ for (const row of removed) {
12289
+ this.debugLog(`👻 [Presence] Reaped stale presence ${row.clientId} on "${row.channel}"`);
12290
+ this.deliverPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
12291
+ this.publishPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
12292
+ }
12293
+ } catch (error) {
12294
+ logger.warn("⚠️ [Presence] Stale-roster sweep failed", { error });
12295
+ }
12296
+ }
12297
+ /**
11521
12298
  * Gracefully tear down all realtime resources.
11522
12299
  *
11523
12300
  * This MUST be called during process shutdown, **before** `pool.end()`.
@@ -11544,8 +12321,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
11544
12321
  clearInterval(this.presenceInterval);
11545
12322
  this.presenceInterval = void 0;
11546
12323
  }
12324
+ if (this.presenceSweepInterval) {
12325
+ clearInterval(this.presenceSweepInterval);
12326
+ this.presenceSweepInterval = void 0;
12327
+ }
12328
+ this.oversizedBroadcastWarned.clear();
12329
+ if (this.presenceStore) {
12330
+ try {
12331
+ await this.presenceStore.removeInstance();
12332
+ } catch (error) {
12333
+ logger.warn("⚠️ [Presence] Could not clear this instance's roster rows on shutdown", { error });
12334
+ }
12335
+ this.presenceStore = void 0;
12336
+ }
11547
12337
  await this.stopListening();
11548
12338
  await this.stopCdc();
12339
+ await this.bus.stop().catch((error) => logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
12340
+ this.bus = new MemoryChannelBus();
11549
12341
  this.clients.clear();
11550
12342
  this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
11551
12343
  }
@@ -23676,6 +24468,15 @@ function createPostgresBootstrapper(pgConfig) {
23676
24468
  logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
23677
24469
  }
23678
24470
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
24471
+ try {
24472
+ const busSetting = resolveChannelBusSetting(pgConfig.realtime?.bus);
24473
+ if (isChannelBusInstance(busSetting) || busSetting.type !== "memory") await realtimeService.configureChannelBus(createChannelBus(busSetting, {
24474
+ db: schemaAwareDb,
24475
+ directUrl
24476
+ }));
24477
+ } catch (err) {
24478
+ logger.warn("⚠️ [ChannelBus] Could not configure the channel bus — channel broadcast and presence stay per-instance", { error: err });
24479
+ }
23679
24480
  const validModes = new Set([
23680
24481
  "auto",
23681
24482
  "wal",
@@ -23870,6 +24671,6 @@ function createPostgresAdapter(pgConfig) {
23870
24671
  };
23871
24672
  }
23872
24673
  //#endregion
23873
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, globalsFileForDump, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
24674
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusConfig, resolveChannelBusSetting, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
23874
24675
 
23875
24676
  //# sourceMappingURL=index.es.js.map