@rebasepro/server-postgres 0.14.0 → 0.14.1

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 (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +19 -0
  3. package/dist/auth/services.d.ts +10 -0
  4. package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
  5. package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
  6. package/dist/data_driver-ULAyJEi9.js.map +1 -1
  7. package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-DoHwhVf8.js} +3 -3
  8. package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-DT2eq859.js} +45 -5
  10. package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
  11. package/dist/index.es.js +543 -105
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
  14. package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
  15. package/dist/schema/drizzle-ddl.d.ts +9 -0
  16. package/dist/services/FetchService.d.ts +81 -5
  17. package/dist/services/RelationService.d.ts +3 -3
  18. package/dist/services/channel-presence.d.ts +16 -1
  19. package/dist/services/dataService.d.ts +6 -4
  20. package/dist/services/realtimeService.d.ts +54 -10
  21. package/dist/src-DCdn3Val.js.map +1 -1
  22. package/dist/utils/drizzle-conditions.d.ts +25 -0
  23. package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
  24. package/dist/websocket-D2jXv0Ds.js.map +1 -0
  25. package/package.json +6 -6
  26. package/src/PostgresBackendDriver.ts +7 -3
  27. package/src/PostgresBootstrapper.ts +105 -41
  28. package/src/auth/services.ts +26 -5
  29. package/src/schema/drizzle-ddl.ts +33 -0
  30. package/src/schema/ensure-collection-tables.test.ts +99 -0
  31. package/src/schema/ensure-collection-tables.ts +65 -3
  32. package/src/schema/generate-drizzle-schema-logic.ts +19 -1
  33. package/src/services/FetchService.ts +310 -63
  34. package/src/services/RelationService.ts +3 -3
  35. package/src/services/channel-history.ts +38 -6
  36. package/src/services/channel-presence.ts +31 -7
  37. package/src/services/dataService.ts +6 -4
  38. package/src/services/pg-notify-listener.ts +14 -0
  39. package/src/services/realtimeService.ts +161 -41
  40. package/src/utils/drizzle-conditions.ts +155 -5
  41. package/src/websocket.ts +44 -1
  42. package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
  43. package/dist/websocket-C8ZqVBiV.js.map +0 -1
@@ -29,6 +29,7 @@
29
29
  import { sql } from "drizzle-orm";
30
30
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
31
31
  import { revokeInternalTableSql } from "@rebasepro/common";
32
+ import { drizzleDdlBootstrapper } from "../schema/drizzle-ddl";
32
33
 
33
34
  /** A tracked client, as any instance sees it. */
34
35
  export interface PresenceRow {
@@ -45,17 +46,34 @@ export class ChannelPresenceStore {
45
46
  private readonly instanceId: string
46
47
  ) {}
47
48
 
48
- /** Create the roster table. Idempotent. */
49
+ /**
50
+ * Create the roster table. Idempotent, and safe to run on every instance at
51
+ * once.
52
+ *
53
+ * Written as separate contained steps rather than one straight sequence for
54
+ * a reason that only bites with more than one replica, which is exactly the
55
+ * deployment shape this table exists to serve: `CREATE … IF NOT EXISTS`
56
+ * reads the catalog and then writes to it non-atomically, so peers booting
57
+ * together collide, and the loser used to abandon everything after it —
58
+ * including the trailing `REVOKE`. That revoke is the only thing keeping the
59
+ * roster off the end-user role, so losing a boot race silently left the
60
+ * whole channel roster readable by every signed-in user.
61
+ *
62
+ * `tablesReady` is now set from a probe of what exists, not from having been
63
+ * the instance that created it.
64
+ */
49
65
  async ensureTables(): Promise<void> {
50
66
  if (this.tablesReady) return;
51
67
 
52
- await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
68
+ const ddl = drizzleDdlBootstrapper(this.db, "channel-presence");
69
+
70
+ await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
53
71
 
54
72
  // Keyed by (channel, client_id): a client id is globally unique, so the
55
73
  // instance is a column rather than part of the identity — a client that
56
74
  // reconnects onto another replica replaces its own row instead of
57
75
  // appearing twice in the roster.
58
- await this.db.execute(sql`
76
+ await ddl.ensureObject("channel_presence table", `
59
77
  CREATE TABLE IF NOT EXISTS rebase.channel_presence (
60
78
  channel TEXT NOT NULL,
61
79
  client_id TEXT NOT NULL,
@@ -67,7 +85,7 @@ export class ChannelPresenceStore {
67
85
  `);
68
86
 
69
87
  // The sweep's access path; the roster read rides the primary key.
70
- await this.db.execute(sql`
88
+ await ddl.ensureObject("channel_presence last_seen index", `
71
89
  CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
72
90
  ON rebase.channel_presence (last_seen)
73
91
  `);
@@ -82,9 +100,15 @@ export class ChannelPresenceStore {
82
100
  // see `docs/channel-authorization.md` for what it does *not*
83
101
  // yet decide. Revoke the schema-wide grant the driver handed out
84
102
  // before this table existed.
85
- await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
86
-
87
- this.tablesReady = true;
103
+ //
104
+ // Driven off the probe, not off who won the create: the privilege has to
105
+ // come off whether this instance created the table or found it.
106
+ if (await ddl.isReadable("rebase.channel_presence")) {
107
+ await ddl.step("channel_presence revoke", () =>
108
+ this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")))
109
+ );
110
+ this.tablesReady = true;
111
+ }
88
112
  }
89
113
 
90
114
  /** Record (or refresh) a client's presence. */
@@ -1,5 +1,5 @@
1
1
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { FilterValues, LogicalCondition } from "@rebasepro/types";
2
+ import { FilterValues, LogicalCondition, OrderByTuple } from "@rebasepro/types";
3
3
  import type { VectorSearchParams } from "@rebasepro/types";
4
4
  import { FetchService } from "./FetchService";
5
5
  import { PersistService } from "./PersistService";
@@ -62,7 +62,7 @@ export class DataService implements DataRepository {
62
62
  filter?: FilterValues<Extract<keyof M, string>>;
63
63
  /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
64
64
  logical?: LogicalCondition;
65
- orderBy?: string;
65
+ orderBy?: string | OrderByTuple[];
66
66
  order?: "desc" | "asc";
67
67
  limit?: number;
68
68
  offset?: number;
@@ -85,7 +85,7 @@ export class DataService implements DataRepository {
85
85
  filter?: FilterValues<Extract<keyof M, string>>;
86
86
  /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
87
87
  logical?: LogicalCondition;
88
- orderBy?: string;
88
+ orderBy?: string | OrderByTuple[];
89
89
  order?: "desc" | "asc";
90
90
  limit?: number;
91
91
  databaseId?: string;
@@ -106,6 +106,8 @@ export class DataService implements DataRepository {
106
106
  logical?: LogicalCondition;
107
107
  searchString?: string;
108
108
  databaseId?: string;
109
+ /** Only the `threshold` narrows the count — see `FetchService.count`. */
110
+ vectorSearch?: VectorSearchParams;
109
111
  } = {}
110
112
  ): Promise<number> {
111
113
  return this.fetchService.count<M>(collectionPath, options);
@@ -133,7 +135,7 @@ export class DataService implements DataRepository {
133
135
  relationKey: string,
134
136
  options: {
135
137
  filter?: FilterValues<Extract<keyof M, string>>;
136
- orderBy?: string;
138
+ orderBy?: string | OrderByTuple[];
137
139
  order?: "desc" | "asc";
138
140
  limit?: number;
139
141
  startAfter?: Record<string, unknown>;
@@ -84,8 +84,16 @@ export class PgNotifyListener {
84
84
 
85
85
  private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
86
86
  const { connectionString, channel, onPayload, logLabel } = this.options;
87
+ // Held here rather than only inside the `try` so the failure path can
88
+ // still reach it: everything below `connect()` can throw, and until
89
+ // `this.client` is assigned nothing else in this class knows the
90
+ // connection exists. Left unreleased it stays open on the server while
91
+ // `scheduleReconnect` opens another — one leaked backend per attempt,
92
+ // every few seconds, for as long as the failure lasts.
93
+ let pending: PgClient | undefined;
87
94
  try {
88
95
  const client = new PgClient({ connectionString });
96
+ pending = client;
89
97
 
90
98
  client.on("error", (err) => {
91
99
  logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
@@ -111,8 +119,14 @@ export class PgNotifyListener {
111
119
  await client.connect();
112
120
  await client.query(`LISTEN ${channel}`);
113
121
  this.client = client;
122
+ // Adopted: `stop()` and `scheduleReconnect` will close it now.
123
+ pending = undefined;
114
124
  logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
115
125
  } catch (err) {
126
+ // Never adopted, so nothing else will ever close it.
127
+ if (pending) {
128
+ try { await pending.end(); } catch { /* already dead */ }
129
+ }
116
130
  // Surface the initial failure so callers can choose to fall back;
117
131
  // for reconnects, keep retrying quietly in the background.
118
132
  if (initial) throw err;
@@ -4,12 +4,12 @@ import { Client as PgClient } from "pg";
4
4
  import { randomUUID } from "crypto";
5
5
  import { DataService } from "./dataService";
6
6
 
7
- import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, CollectionConfig, RebaseCallContext, resolveClientListLimit, ListLimitError } from "@rebasepro/types";
7
+ import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, OrderByTuple, CollectionConfig, RebaseCallContext, resolveClientListLimit, ListLimitError } from "@rebasepro/types";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import { sql as drizzleSql } from "drizzle-orm";
10
10
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
11
11
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
12
- import { buildPropertyCallbacks, getTableName } from "@rebasepro/common";
12
+ import { buildPropertyCallbacks, getTableName, OrderBySpecError, parseOrderBySpecStrict } from "@rebasepro/common";
13
13
  import { applyAuthContext } from "../security/rls-enforcement";
14
14
  import { buildJunctionLinkMap, type JunctionLink } from "./cdc/junction-tables";
15
15
  import { logger } from "@rebasepro/server";
@@ -85,7 +85,7 @@ type RealTimeListenCollectionProps = ListenCollectionProps & {
85
85
  type StoredCollectionRequest = {
86
86
  filter?: Record<string, unknown>;
87
87
  logical?: LogicalCondition;
88
- orderBy?: string;
88
+ orderBy?: string | OrderByTuple[];
89
89
  order?: "desc" | "asc";
90
90
  limit?: number;
91
91
  offset?: number;
@@ -98,6 +98,42 @@ type StoredCollectionRequest = {
98
98
 
99
99
  type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
100
100
 
101
+ /**
102
+ * A registered subscription, plus the two counters that order its deliveries.
103
+ *
104
+ * Every update a subscription delivers is a full re-fetch, and more than one
105
+ * thing starts one for the same subscription without coordinating: the initial
106
+ * fetch at subscribe time, and a debounced refetch per notification (app
107
+ * mutation, cross-instance NOTIFY, or CDC). A fetch that started earlier can
108
+ * finish later, and the delivery replaces everything the subscriber has — so
109
+ * the subscriber goes back to the state before the change and stays there,
110
+ * silently, until the next write to that collection.
111
+ *
112
+ * The debounce is not a fix for this. It collapses a burst into one refetch and
113
+ * does nothing about two refetches that overlap: notification A fires its timer
114
+ * and starts fetch A, notification B arrives while A is still in flight, and B's
115
+ * timer fires and starts fetch B regardless. See class 44 in
116
+ * `docs/bug-classes.md`.
117
+ *
118
+ * `started` is taken before the work, `delivered` after it — which makes the
119
+ * last delivery *started* the last one *delivered*.
120
+ */
121
+ type Subscription = {
122
+ clientId: string;
123
+ type: "collection" | "single";
124
+ path: string;
125
+ id?: string | number;
126
+ // Store full collection request parameters for proper refetching
127
+ collectionRequest?: StoredCollectionRequest;
128
+ // Auth context for RLS — when set, refetches run in a transaction
129
+ // with set_config('app.uid', ...) / set_config('app.user_roles', ...)
130
+ authContext?: SubscriptionAuthContext;
131
+ /** How many deliveries have been started for this subscription. */
132
+ started: number;
133
+ /** The highest started-sequence that has already reached the subscriber. */
134
+ delivered: number;
135
+ };
136
+
101
137
  /**
102
138
  * PostgreSQL-specific realtime service.
103
139
  * Handles WebSocket connections and subscriptions for real-time row updates.
@@ -195,17 +231,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
195
231
  private static readonly PRESENCE_SWEEP_INTERVAL_MS = 10000; // 10s
196
232
  private dataService: DataService;
197
233
  // Enhanced subscriptions storage with full request parameters
198
- private _subscriptions = new Map<string, {
199
- clientId: string;
200
- type: "collection" | "single";
201
- path: string;
202
- id?: string | number;
203
- // Store full collection request parameters for proper refetching
204
- collectionRequest?: StoredCollectionRequest;
205
- // Auth context for RLS — when set, refetches run in a transaction
206
- // with set_config('app.uid', ...) / set_config('app.user_roles', ...)
207
- authContext?: SubscriptionAuthContext;
208
- }>();
234
+ private _subscriptions = new Map<string, Subscription>();
209
235
 
210
236
  // Add callback storage for DataDriver subscriptions
211
237
  private subscriptionCallbacks = new Map<string, (data: Record<string, unknown>[] | Record<string, unknown> | null) => void>();
@@ -279,6 +305,34 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
279
305
  return this._subscriptions;
280
306
  }
281
307
 
308
+ /**
309
+ * Claim a delivery slot for a subscription, before doing the work.
310
+ *
311
+ * Returns the check to run immediately before delivering. It refuses in
312
+ * three cases, all of which used to deliver:
313
+ *
314
+ * - **Out of order.** A newer refetch has already delivered, so this one is
315
+ * stale — the subscriber would go back to the state before the change.
316
+ * - **Unsubscribed.** The subscription was cancelled while the fetch was in
317
+ * flight. The `has(subscriptionId)` check the debounced refetches ran
318
+ * *before* the await cannot answer this; only a check after it can.
319
+ * - **Replaced.** The same id can name a *different* subscription by the
320
+ * time a fetch lands — a re-subscribe overwrites the map entry, and the
321
+ * old filter's rows would be delivered to the new subscriber.
322
+ *
323
+ * The last two are identity, not presence: the map has to still hold *this
324
+ * exact object*, not merely something under this id.
325
+ */
326
+ private beginDelivery(subscriptionId: string, subscription: Subscription): () => boolean {
327
+ const seq = ++subscription.started;
328
+ return () => {
329
+ if (this._subscriptions.get(subscriptionId) !== subscription) return false;
330
+ if (seq <= subscription.delivered) return false;
331
+ subscription.delivered = seq;
332
+ return true;
333
+ };
334
+ }
335
+
282
336
  // Add public method to register DataDriver subscriptions
283
337
  registerDataDriverSubscription(subscriptionId: string, subscription: {
284
338
  clientId: string;
@@ -289,7 +343,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
289
343
  authContext?: SubscriptionAuthContext;
290
344
  }) {
291
345
  this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
292
- this._subscriptions.set(subscriptionId, subscription);
346
+ this._subscriptions.set(subscriptionId, { ...subscription, started: 0, delivered: 0 });
293
347
  }
294
348
 
295
349
  // Add callback management methods
@@ -328,7 +382,9 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
328
382
  databaseId: config.databaseId,
329
383
  searchString: config.searchString,
330
384
  searchExplain: config.searchExplain
331
- }
385
+ },
386
+ started: 0,
387
+ delivered: 0
332
388
  });
333
389
 
334
390
  if (callback) {
@@ -348,7 +404,9 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
348
404
  clientId: config.clientId,
349
405
  type: "single",
350
406
  path: config.path,
351
- id: config.id
407
+ id: config.id,
408
+ started: 0,
409
+ delivered: 0
352
410
  });
353
411
 
354
412
  if (callback) {
@@ -507,15 +565,31 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
507
565
  return;
508
566
  }
509
567
 
568
+ // The sort arrives as whatever JSON the client put in the frame, so
569
+ // its *shape* is checked here the way the REST ingress checks the
570
+ // query parameter. Unchecked, a malformed entry reads as a field
571
+ // name that resolves to no column, and under lenient unknown-field
572
+ // handling the subscription then streams rows in no order at all
573
+ // while reporting nothing wrong.
574
+ let orderBy: OrderByTuple[] | undefined;
575
+ try {
576
+ orderBy = parseOrderBySpecStrict(request.orderBy, request.order);
577
+ } catch (e) {
578
+ if (!(e instanceof OrderBySpecError)) throw e;
579
+ logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
580
+ this.sendError(clientId, e.message, subscriptionId, e.code);
581
+ return;
582
+ }
583
+
510
584
  // Store subscription with full request parameters and auth context for RLS
511
- this._subscriptions.set(subscriptionId, {
585
+ const subscription: Subscription = {
512
586
  clientId,
513
587
  type: "collection",
514
588
  path: request.path,
515
589
  collectionRequest: {
516
590
  filter: request.filter,
517
591
  logical: request.logical,
518
- orderBy: request.orderBy,
592
+ orderBy,
519
593
  order: request.order,
520
594
  limit: boundedLimit,
521
595
  offset: request.offset,
@@ -524,19 +598,30 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
524
598
  searchString: request.searchString,
525
599
  searchExplain: request.searchExplain
526
600
  },
527
- authContext
528
- });
601
+ authContext,
602
+ started: 0,
603
+ delivered: 0
604
+ };
605
+ this._subscriptions.set(subscriptionId, subscription);
606
+
607
+ // The subscription is registered before this fetch runs, so a write
608
+ // arriving in that window starts a refetch of its own — with nothing
609
+ // ordering the two. Claim a slot first: this fetch is the oldest, so
610
+ // if the refetch answers first, this one no longer delivers.
611
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
529
612
 
530
613
  // Send initial data. Built from the request the subscription just
531
614
  // stored, so the first answer and every refetch after it cannot
532
615
  // describe different queries.
533
616
  const rows = await this.fetchCollectionWithAuth(
534
617
  request.path,
535
- this._subscriptions.get(subscriptionId)!.collectionRequest!,
618
+ subscription.collectionRequest!,
536
619
  authContext
537
620
  );
538
621
 
539
- this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
622
+ if (canDeliver()) {
623
+ this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
624
+ }
540
625
 
541
626
  } catch (error) {
542
627
  const sanitized = sanitizeErrorForClient(error, request.path);
@@ -559,13 +644,21 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
559
644
  }
560
645
 
561
646
  // Store subscription in memory with auth context for RLS
562
- this._subscriptions.set(subscriptionId, {
647
+ const subscription: Subscription = {
563
648
  clientId,
564
649
  type: "single",
565
650
  path: request.path,
566
651
  id: request.id,
567
- authContext
568
- });
652
+ authContext,
653
+ started: 0,
654
+ delivered: 0
655
+ };
656
+ this._subscriptions.set(subscriptionId, subscription);
657
+
658
+ // Same race as the collection case: a write landing between the
659
+ // registration above and this fetch starts a refetch that can answer
660
+ // first, and this one must not overwrite it afterwards.
661
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
569
662
 
570
663
  // Send initial data
571
664
  const row = await this.fetchEntityWithAuth(
@@ -574,7 +667,9 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
574
667
  authContext
575
668
  );
576
669
 
577
- this.sendSingleUpdate(clientId, subscriptionId, row || null);
670
+ if (canDeliver()) {
671
+ this.sendSingleUpdate(clientId, subscriptionId, row || null);
672
+ }
578
673
 
579
674
  } catch (error) {
580
675
  const sanitized = sanitizeErrorForClient(error, request.path);
@@ -757,7 +852,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
757
852
  private debouncedCollectionRefetch(
758
853
  subscriptionId: string,
759
854
  notifyPath: string,
760
- subscription: { clientId: string; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext }
855
+ subscription: Subscription
761
856
  ) {
762
857
  const timerKey = `ws_${subscriptionId}`;
763
858
  const existing = this.refetchTimers.get(timerKey);
@@ -765,11 +860,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
765
860
 
766
861
  this.refetchTimers.set(timerKey, setTimeout(async () => {
767
862
  this.refetchTimers.delete(timerKey);
768
- // Verify subscription still exists (client may have disconnected)
769
- if (!this._subscriptions.has(subscriptionId)) return;
863
+ // Cheap bail before spending a query: the client may have
864
+ // disconnected, or re-subscribed under the same id. It is only an
865
+ // optimisation — `canDeliver()` after the await is what makes the
866
+ // delivery safe, because the same things can happen *during* it.
867
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
868
+ // Claimed here rather than when the timer was scheduled: the
869
+ // debounce coalesces, and no work exists to order until it fires.
870
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
770
871
  try {
771
872
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
772
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
873
+ if (canDeliver()) {
874
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
875
+ }
773
876
  } catch (error) {
774
877
  const sanitized = sanitizeErrorForClient(error, notifyPath);
775
878
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -783,7 +886,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
783
886
  private debouncedDriverRefetch(
784
887
  subscriptionId: string,
785
888
  notifyPath: string,
786
- subscription: { collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext },
889
+ subscription: Subscription,
787
890
  callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
788
891
  ) {
789
892
  const timerKey = `drv_${subscriptionId}`;
@@ -792,10 +895,11 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
792
895
 
793
896
  this.refetchTimers.set(timerKey, setTimeout(async () => {
794
897
  this.refetchTimers.delete(timerKey);
795
- if (!this._subscriptions.has(subscriptionId)) return;
898
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
899
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
796
900
  try {
797
901
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
798
- callback(rows);
902
+ if (canDeliver()) callback(rows);
799
903
  } catch (error) {
800
904
  logger.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}`, { error: error });
801
905
  }
@@ -960,7 +1064,7 @@ roles: activeAuth.roles },
960
1064
  subscriptionId: string,
961
1065
  notifyPath: string,
962
1066
  id: string,
963
- subscription: { clientId: string; authContext?: SubscriptionAuthContext }
1067
+ subscription: Subscription
964
1068
  ) {
965
1069
  const timerKey = `wse_${subscriptionId}`;
966
1070
  const existing = this.refetchTimers.get(timerKey);
@@ -968,10 +1072,13 @@ roles: activeAuth.roles },
968
1072
 
969
1073
  this.refetchTimers.set(timerKey, setTimeout(async () => {
970
1074
  this.refetchTimers.delete(timerKey);
971
- if (!this._subscriptions.has(subscriptionId)) return;
1075
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
1076
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
972
1077
  try {
973
1078
  const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
974
- this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
1079
+ if (canDeliver()) {
1080
+ this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
1081
+ }
975
1082
  } catch (error) {
976
1083
  const sanitized = sanitizeErrorForClient(error, notifyPath);
977
1084
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -986,7 +1093,7 @@ roles: activeAuth.roles },
986
1093
  subscriptionId: string,
987
1094
  notifyPath: string,
988
1095
  id: string,
989
- subscription: { clientId: string; authContext?: SubscriptionAuthContext },
1096
+ subscription: Subscription,
990
1097
  callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
991
1098
  ) {
992
1099
  const timerKey = `drve_${subscriptionId}`;
@@ -995,10 +1102,11 @@ roles: activeAuth.roles },
995
1102
 
996
1103
  this.refetchTimers.set(timerKey, setTimeout(async () => {
997
1104
  this.refetchTimers.delete(timerKey);
998
- if (!this._subscriptions.has(subscriptionId)) return;
1105
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
1106
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
999
1107
  try {
1000
1108
  const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
1001
- callback(row || null);
1109
+ if (canDeliver()) callback(row || null);
1002
1110
  } catch (error) {
1003
1111
  logger.error(`❌ [RealtimeService] Error in debounced row driver refetch for ${subscriptionId}`, { error: error });
1004
1112
  }
@@ -1362,7 +1470,7 @@ roles: activeAuth.roles },
1362
1470
  logger.warn(
1363
1471
  "⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
1364
1472
  "instance have been seen — this deployment runs more than one process. Broadcast and presence " +
1365
- "reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) " +
1473
+ "reach only the clients connected to this one. Set `realtime.bus` (or REALTIME_CHANNEL_BUS=postgres) " +
1366
1474
  "to make channels cross-instance."
1367
1475
  );
1368
1476
  }
@@ -2296,8 +2404,15 @@ lastSeen: Date.now() });
2296
2404
  private async connectListenClient(): Promise<void> {
2297
2405
  if (!this.listenConnectionString) return;
2298
2406
 
2407
+ let pending: PgClient | undefined;
2299
2408
  try {
2409
+ // See `PgNotifyListener.connect` — same shape, same reason. Until
2410
+ // `this.listenClient` is assigned, nothing else in this class knows
2411
+ // the connection exists, so a throw between `connect()` and that
2412
+ // assignment leaks a live backend and `scheduleReconnect` opens
2413
+ // another one three seconds later.
2300
2414
  const client = new PgClient({ connectionString: this.listenConnectionString });
2415
+ pending = client;
2301
2416
 
2302
2417
  client.on("error", (err) => {
2303
2418
  logger.error("❌ [RealtimeService] LISTEN client error", { detail: err.message });
@@ -2365,9 +2480,14 @@ lastSeen: Date.now() });
2365
2480
  await client.connect();
2366
2481
  await client.query(`LISTEN ${PG_NOTIFY_CHANNEL}`);
2367
2482
  this.listenClient = client;
2483
+ // Adopted: `destroy()` and `scheduleReconnect` close it now.
2484
+ pending = undefined;
2368
2485
 
2369
2486
  this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
2370
2487
  } catch (err) {
2488
+ if (pending) {
2489
+ try { await pending.end(); } catch { /* already dead */ }
2490
+ }
2371
2491
  logger.error("❌ [RealtimeService] Failed to connect LISTEN client", { error: err });
2372
2492
  this.scheduleReconnect();
2373
2493
  }