@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,1502 @@
1
+ import { WebSocket } from "ws";
2
+ import { EventEmitter } from "events";
3
+ import { Client as PgClient } from "pg";
4
+ import { randomUUID } from "crypto";
5
+ import { DataService } from "./dataService";
6
+
7
+ import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext } from "@rebasepro/types";
8
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
+ import { sql as drizzleSql } from "drizzle-orm";
10
+ import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
11
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
12
+ import { buildPropertyCallbacks, getTableName } from "@rebasepro/common";
13
+ import { applyAuthContext } from "../security/rls-enforcement";
14
+ import { logger } from "@rebasepro/server";
15
+ import { sanitizeErrorForClient } from "../utils/pg-error-utils";
16
+ import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
17
+ import { getPrimaryKeys, buildCompositeId } from "./collection-helpers";
18
+
19
+ /** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
20
+ const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
21
+
22
+ /**
23
+ * Auth context stored per-subscription so real-time refetches respect RLS.
24
+ * Mirrors the session variables set by PostgresBackendDriver.withAuth().
25
+ */
26
+ export interface SubscriptionAuthContext {
27
+ userId: string;
28
+ roles: string[];
29
+ }
30
+
31
+ interface DataDriverWithData extends DataDriver {
32
+ data: unknown;
33
+ }
34
+
35
+ type RealTimeListenCollectionProps = ListenCollectionProps & {
36
+ subscriptionId: string
37
+ };
38
+
39
+ type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
40
+
41
+ /**
42
+ * PostgreSQL-specific realtime service.
43
+ * Handles WebSocket connections and subscriptions for real-time row updates.
44
+ *
45
+ * Implements the RealtimeProvider interface for database abstraction.
46
+ */
47
+ export class RealtimeService extends EventEmitter implements RealtimeProvider {
48
+ private clients = new Map<string, WebSocket>();
49
+
50
+ // Broadcast channels: channel name → set of client IDs
51
+ private channels = new Map<string, Set<string>>();
52
+
53
+ // Presence: channel → Map<clientId, { state, lastSeen }>
54
+ private presence = new Map<string, Map<string, { state: Record<string, unknown>; lastSeen: number }>>();
55
+ private presenceInterval?: ReturnType<typeof setInterval>;
56
+ private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
57
+ private dataService: DataService;
58
+ // Enhanced subscriptions storage with full request parameters
59
+ private _subscriptions = new Map<string, {
60
+ clientId: string;
61
+ type: "collection" | "single";
62
+ path: string;
63
+ id?: string | number;
64
+ // Store full collection request parameters for proper refetching
65
+ collectionRequest?: {
66
+ filter?: Record<string, unknown>;
67
+ orderBy?: string;
68
+ order?: "desc" | "asc";
69
+ limit?: number;
70
+ offset?: number;
71
+ startAfter?: Record<string, unknown>;
72
+ databaseId?: string;
73
+ searchString?: string;
74
+ };
75
+ // Auth context for RLS — when set, refetches run in a transaction
76
+ // with set_config('app.user_id', ...) / set_config('app.user_roles', ...)
77
+ authContext?: SubscriptionAuthContext;
78
+ }>();
79
+
80
+ // Add callback storage for DataDriver subscriptions
81
+ private subscriptionCallbacks = new Map<string, (data: Record<string, unknown>[] | Record<string, unknown> | null) => void>();
82
+
83
+ private driver?: DataDriver;
84
+
85
+ // ── Cross-instance LISTEN/NOTIFY ──
86
+ /** Unique identifier for this process instance, used to skip own notifications. */
87
+ private readonly instanceId = `inst_${randomUUID().slice(0, 8)}`;
88
+ /** Dedicated pg.Client for LISTEN (outside the Drizzle pool). */
89
+ private listenClient?: PgClient;
90
+ /** Connection string used for reconnecting the LISTEN client. */
91
+ private listenConnectionString?: string;
92
+ /** Whether cross-instance broadcasting is active. */
93
+ private broadcasting = false;
94
+ /** Reconnection timer handle. */
95
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
96
+ /** Debounce timers for collection refetches to prevent refetch storms. */
97
+ private refetchTimers = new Map<string, ReturnType<typeof setTimeout>>();
98
+ /** Debounce window (ms) for coalescing rapid row updates into a single correctness refetch. */
99
+ private static readonly REFETCH_DEBOUNCE_MS = 300;
100
+
101
+ // ── Database-level Change Data Capture (CDC) ──
102
+ /** Dedicated LISTEN client for DB-level change events (undefined unless CDC is enabled). */
103
+ private cdcListener?: CdcListener;
104
+ /** Whether database-level CDC is the active cross-instance change source. */
105
+ private cdcActive = false;
106
+ /** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
107
+ private cdcTableMap?: Map<string, CollectionConfig>;
108
+ /**
109
+ * Short-lived record of `path/id` keys this instance just fanned out via the
110
+ * app path (a Rebase-API mutation). When CDC echoes the same committed change
111
+ * back to *this* instance, we suppress the duplicate — the change was already
112
+ * delivered locally. Other instances have no such record, so they still
113
+ * deliver the CDC event. External writes (psql, cron, SQL editor) never match
114
+ * and always flow through. Keyed → expiry timestamp (ms).
115
+ */
116
+ private recentAppEmits = new Map<string, number>();
117
+ /** How long an app-emit key suppresses its own CDC echo. Covers NOTIFY round-trip latency. */
118
+ private static readonly CDC_DEDUP_WINDOW_MS = 5000;
119
+
120
+ constructor(private db: NodePgDatabase<any>, private registry: PostgresCollectionRegistry) {
121
+ super();
122
+ this.dataService = new DataService(db, registry);
123
+ }
124
+
125
+ /**
126
+ * Restricted role that auth-scoped refetches run as (via `SET LOCAL ROLE`)
127
+ * so RLS `select` policies bind. Set by the bootstrapper alongside
128
+ * `PostgresBackendDriver.rlsUserRole`; undefined when the connection
129
+ * is already subject to RLS natively. Without this, realtime refetches
130
+ * would leak rows the initial (isolated) fetch correctly hid.
131
+ */
132
+ public rlsUserRole?: string;
133
+
134
+ /** Whether to emit verbose debug logs (disabled in production). */
135
+ private static readonly DEBUG = process.env.NODE_ENV !== "production";
136
+ private debugLog(...args: unknown[]) {
137
+ if (RealtimeService.DEBUG) console.debug(...args);
138
+ }
139
+
140
+ setDataDriver(driver: DataDriver) {
141
+ this.driver = driver;
142
+ }
143
+
144
+ // Make subscriptions accessible for DataDriver
145
+ get subscriptions() {
146
+ return this._subscriptions;
147
+ }
148
+
149
+ // Add public method to register DataDriver subscriptions
150
+ registerDataDriverSubscription(subscriptionId: string, subscription: {
151
+ clientId: string;
152
+ type: "collection" | "single";
153
+ path: string;
154
+ id?: string | number;
155
+ collectionRequest?: {
156
+ filter?: Record<string, unknown>;
157
+ orderBy?: string;
158
+ order?: "desc" | "asc";
159
+ limit?: number;
160
+ offset?: number;
161
+ startAfter?: Record<string, unknown>;
162
+ databaseId?: string;
163
+ searchString?: string;
164
+ };
165
+ authContext?: SubscriptionAuthContext;
166
+ }) {
167
+ this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
168
+ this._subscriptions.set(subscriptionId, subscription);
169
+ }
170
+
171
+ // Add callback management methods
172
+ addSubscriptionCallback(subscriptionId: string, callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void) {
173
+ this.debugLog("📋 [RealtimeService] Adding callback for subscription:", subscriptionId);
174
+ this.subscriptionCallbacks.set(subscriptionId, callback);
175
+ }
176
+
177
+ removeSubscriptionCallback(subscriptionId: string) {
178
+ this.debugLog("📋 [RealtimeService] Removing callback for subscription:", subscriptionId);
179
+ this.subscriptionCallbacks.delete(subscriptionId);
180
+ }
181
+
182
+ // =============================================================================
183
+ // RealtimeProvider Interface Methods
184
+ // =============================================================================
185
+
186
+ /**
187
+ * Subscribe to collection changes (RealtimeProvider interface)
188
+ */
189
+ subscribeToCollection(
190
+ subscriptionId: string,
191
+ config: CollectionSubscriptionConfig,
192
+ callback?: (rows: Record<string, unknown>[]) => void
193
+ ): void {
194
+ this._subscriptions.set(subscriptionId, {
195
+ clientId: config.clientId,
196
+ type: "collection",
197
+ path: config.path,
198
+ collectionRequest: {
199
+ filter: config.filter as Record<string, unknown> | undefined,
200
+ orderBy: config.orderBy,
201
+ order: config.order,
202
+ limit: config.limit,
203
+ startAfter: config.startAfter as Record<string, unknown> | undefined,
204
+ databaseId: config.databaseId,
205
+ searchString: config.searchString
206
+ }
207
+ });
208
+
209
+ if (callback) {
210
+ this.subscriptionCallbacks.set(subscriptionId, callback as (data: Record<string, unknown>[] | Record<string, unknown> | null) => void);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Subscribe to single row changes (RealtimeProvider interface)
216
+ */
217
+ subscribeToOne(
218
+ subscriptionId: string,
219
+ config: SingleSubscriptionConfig,
220
+ callback?: (row: Record<string, unknown> | null) => void
221
+ ): void {
222
+ this._subscriptions.set(subscriptionId, {
223
+ clientId: config.clientId,
224
+ type: "single",
225
+ path: config.path,
226
+ id: config.id
227
+ });
228
+
229
+ if (callback) {
230
+ this.subscriptionCallbacks.set(subscriptionId, callback as (data: Record<string, unknown>[] | Record<string, unknown> | null) => void);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Unsubscribe from a subscription (RealtimeProvider interface)
236
+ */
237
+ unsubscribe(subscriptionId: string): void {
238
+ this._subscriptions.delete(subscriptionId);
239
+ this.subscriptionCallbacks.delete(subscriptionId);
240
+ }
241
+
242
+ // =============================================================================
243
+ // WebSocket Client Management
244
+ // =============================================================================
245
+
246
+ addClient(clientId: string, ws: WebSocket) {
247
+ this.clients.set(clientId, ws);
248
+
249
+ ws.on("close", () => {
250
+ this.removeClient(clientId);
251
+ });
252
+
253
+ ws.on("error", (error) => {
254
+ logger.error("WebSocket error for client", { detail: clientId, error });
255
+ this.removeClient(clientId);
256
+ });
257
+ }
258
+
259
+ // Public method to handle messages from external sources (like main WebSocket handler)
260
+ async handleClientMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
261
+ await this.handleMessage(clientId, message, authContext);
262
+ }
263
+
264
+ async removeClient(clientId: string) {
265
+ this.clients.delete(clientId);
266
+
267
+ // Remove all subscriptions, callbacks, and pending refetch timers for this client
268
+ for (const [subscriptionId, subscription] of this._subscriptions.entries()) {
269
+ if (subscription.clientId === clientId) {
270
+ this._subscriptions.delete(subscriptionId);
271
+ this.subscriptionCallbacks.delete(subscriptionId);
272
+
273
+ // Cancel any pending debounced refetch timers
274
+ for (const prefix of ["ws_", "drv_", "wse_", "drve_"]) {
275
+ const key = `${prefix}${subscriptionId}`;
276
+ const timer = this.refetchTimers.get(key);
277
+ if (timer) { clearTimeout(timer); this.refetchTimers.delete(key); }
278
+ }
279
+ }
280
+ }
281
+
282
+ // Remove from all broadcast channels
283
+ for (const [channel, members] of this.channels.entries()) {
284
+ if (members.has(clientId)) {
285
+ members.delete(clientId);
286
+ this.removePresence(clientId, channel);
287
+ if (members.size === 0) this.channels.delete(channel);
288
+ }
289
+ }
290
+
291
+ // Remove from all presence channels
292
+ for (const [channel] of this.presence) {
293
+ this.removePresence(clientId, channel);
294
+ }
295
+ }
296
+
297
+ private async handleMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
298
+ const payload = message.payload as Record<string, unknown> | undefined;
299
+ switch (message.type) {
300
+ case "subscribe_collection":
301
+ await this.handleCollectionSubscription(clientId, message.payload as RealTimeListenCollectionProps, authContext);
302
+ break;
303
+ case "subscribe_one":
304
+ await this.handleEntitySubscription(clientId, message.payload as RealTimeListenEntityProps, authContext);
305
+ break;
306
+ case "unsubscribe":
307
+ await this.handleUnsubscribe(clientId, message.subscriptionId!);
308
+ break;
309
+
310
+ // ── Broadcast Channels ──
311
+ case "join_channel":
312
+ this.joinChannel(clientId, payload?.channel as string);
313
+ break;
314
+ case "leave_channel":
315
+ this.leaveChannel(clientId, payload?.channel as string);
316
+ break;
317
+ case "broadcast":
318
+ this.broadcastToChannel(
319
+ clientId,
320
+ payload?.channel as string,
321
+ payload?.event as string,
322
+ payload?.payload
323
+ );
324
+ break;
325
+
326
+ // ── Presence ──
327
+ case "presence_track":
328
+ // Auto-join the channel so presence works without a separate join
329
+ this.joinChannel(clientId, payload?.channel as string);
330
+ this.trackPresence(
331
+ clientId,
332
+ payload?.channel as string,
333
+ payload?.state as Record<string, unknown> ?? {}
334
+ );
335
+ break;
336
+ case "presence_untrack":
337
+ this.removePresence(clientId, payload?.channel as string);
338
+ break;
339
+ case "presence_state":
340
+ this.sendPresenceState(clientId, payload?.channel as string);
341
+ break;
342
+
343
+ default:
344
+ this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
345
+ }
346
+ }
347
+
348
+ private async handleCollectionSubscription(clientId: string, request: RealTimeListenCollectionProps, authContext?: SubscriptionAuthContext) {
349
+ const subscriptionId = request.subscriptionId;
350
+
351
+ try {
352
+ // Early validation: ensure the requested collection exists in the registry
353
+ const collection = this.registry.getCollectionByPath(request.path);
354
+ if (!collection) {
355
+ const registered = this.registry.getCollections().map(c => c.slug).join(", ");
356
+ const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
357
+ logger.error(`[RealtimeService] ${msg}`);
358
+ this.sendError(clientId, msg, subscriptionId);
359
+ return;
360
+ }
361
+
362
+ // Store subscription with full request parameters and auth context for RLS
363
+ this._subscriptions.set(subscriptionId, {
364
+ clientId,
365
+ type: "collection",
366
+ path: request.path,
367
+ collectionRequest: {
368
+ filter: request.filter,
369
+ orderBy: request.orderBy,
370
+ order: request.order,
371
+ limit: request.limit,
372
+ startAfter: request.startAfter as Record<string, unknown> | undefined,
373
+ databaseId: request.collection?.databaseId,
374
+ searchString: request.searchString
375
+ },
376
+ authContext
377
+ });
378
+
379
+ // Send initial data
380
+ const rows = await this.fetchCollectionWithAuth(
381
+ request.path,
382
+ {
383
+ filter: request.filter,
384
+ orderBy: request.orderBy,
385
+ order: request.order,
386
+ limit: request.limit,
387
+ startAfter: request.startAfter as Record<string, unknown> | undefined,
388
+ searchString: request.searchString
389
+ },
390
+ authContext
391
+ );
392
+
393
+ this.sendCollectionUpdate(clientId, subscriptionId, rows);
394
+
395
+ } catch (error) {
396
+ const sanitized = sanitizeErrorForClient(error, request.path);
397
+ this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
398
+ }
399
+ }
400
+
401
+ private async handleEntitySubscription(clientId: string, request: RealTimeListenEntityProps, authContext?: SubscriptionAuthContext) {
402
+ const subscriptionId = request.subscriptionId;
403
+
404
+ try {
405
+ // Early validation: ensure the requested collection exists in the registry
406
+ const collection = this.registry.getCollectionByPath(request.path);
407
+ if (!collection) {
408
+ const registered = this.registry.getCollections().map(c => c.slug).join(", ");
409
+ const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
410
+ logger.error(`[RealtimeService] ${msg}`);
411
+ this.sendError(clientId, msg, subscriptionId);
412
+ return;
413
+ }
414
+
415
+ // Store subscription in memory with auth context for RLS
416
+ this._subscriptions.set(subscriptionId, {
417
+ clientId,
418
+ type: "single",
419
+ path: request.path,
420
+ id: request.id,
421
+ authContext
422
+ });
423
+
424
+ // Send initial data
425
+ const row = await this.fetchEntityWithAuth(
426
+ request.path,
427
+ String(request.id),
428
+ authContext
429
+ );
430
+
431
+ this.sendSingleUpdate(clientId, subscriptionId, row || null);
432
+
433
+ } catch (error) {
434
+ const sanitized = sanitizeErrorForClient(error, request.path);
435
+ this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
436
+ }
437
+ }
438
+
439
+ private async handleUnsubscribe(_clientId: string, subscriptionId: string) {
440
+ this._subscriptions.delete(subscriptionId);
441
+ this.subscriptionCallbacks.delete(subscriptionId);
442
+ // Cancel any pending debounced refetch
443
+ for (const prefix of ["ws_", "drv_", "wse_", "drve_"]) {
444
+ const key = `${prefix}${subscriptionId}`;
445
+ const timer = this.refetchTimers.get(key);
446
+ if (timer) { clearTimeout(timer); this.refetchTimers.delete(key); }
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Enhanced notification method that handles nested relation updates.
452
+ * @param broadcast When true (default), also sends a pg_notify so other instances
453
+ * pick up the change. Set to false when handling an incoming
454
+ * cross-instance notification to avoid infinite loops.
455
+ * @param origin `"app"` (default) — a Rebase-API mutation on this instance;
456
+ * `"cdc"` — a database-level change observed via CDC (any writer,
457
+ * any instance). The origin drives de-duplication: an app emit
458
+ * records the change so this instance can suppress the matching
459
+ * CDC echo, while an unmatched CDC event is delivered normally.
460
+ */
461
+ async notifyUpdate(path: string, id: string, row: Record<string, unknown> | null, databaseId?: string, broadcast = true, origin: "app" | "cdc" = "app") {
462
+ this.debugLog("🔔 [RealtimeService] notifyUpdate called for path:", path, "id:", id, "isDelete:", row === null, "origin:", origin);
463
+
464
+ // De-duplicate against database-level CDC. The app path (a mutation made
465
+ // through the Rebase API) fans out locally AND, once CDC is active, the
466
+ // same committed change is echoed back to this instance via the WAL /
467
+ // trigger stream. Record app emits so we can drop that echo here; deliver
468
+ // any CDC event we did not originate (external writes, other instances).
469
+ if (this.cdcActive) {
470
+ const key = this.dedupKey(path, id, databaseId);
471
+ if (origin === "cdc") {
472
+ if (this.consumeAppEmit(key)) {
473
+ this.debugLog("🔁 [RealtimeService] Suppressing CDC echo of local app mutation:", key);
474
+ return;
475
+ }
476
+ } else {
477
+ this.markAppEmit(key);
478
+ }
479
+ }
480
+
481
+ // Get all paths that need to be notified - the direct path plus any parent paths
482
+ const pathsToNotify = [path];
483
+
484
+ // If this is a nested relation path (like "posts/70/tags"), also notify parent paths
485
+ if (path.includes("/") && path.split("/").length > 1) {
486
+ const parentPaths = this.getParentPaths(path);
487
+ pathsToNotify.push(...parentPaths);
488
+ this.debugLog(`🔗 [RealtimeService] Nested path detected. Will notify paths: ${pathsToNotify.join(", ")}`);
489
+ }
490
+
491
+ // Process each path that needs notification
492
+ for (const notifyPath of pathsToNotify) {
493
+ await this.notifyPathUpdate(notifyPath, path, id, row, databaseId);
494
+ }
495
+
496
+ // Broadcast to other instances via pg_notify (only for local mutations).
497
+ // When CDC is active it IS the cross-instance channel — every instance
498
+ // observes every commit through the change stream — so the legacy
499
+ // per-mutation broadcast is redundant (and would double-deliver). Skip it.
500
+ if (broadcast && this.broadcasting && !this.cdcActive) {
501
+ try {
502
+ await this.broadcastChange(path, id, databaseId);
503
+ } catch (err) {
504
+ logger.error("❌ [RealtimeService] Failed to broadcast change via pg_notify", { error: err });
505
+ }
506
+ }
507
+
508
+ this.debugLog("🔔 [RealtimeService] notifyUpdate completed for path:", path);
509
+ }
510
+
511
+ /**
512
+ * Notify subscriptions for a specific path
513
+ */
514
+ private async notifyPathUpdate(notifyPath: string, originalPath: string, id: string, row: Record<string, unknown> | null, _databaseId?: string) {
515
+ this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
516
+
517
+ // Find all relevant subscriptions for this specific path
518
+ const allSubscriptions = Array.from(this._subscriptions.entries()).filter(([, sub]) => {
519
+ const isPathMatch = sub.path === notifyPath;
520
+
521
+ // For row subscriptions, check if the id matches (only for exact path matches)
522
+ if (sub.type === "single") {
523
+ return isPathMatch && (notifyPath === originalPath ? sub.id === id : true);
524
+ }
525
+ // For collection subscriptions, it's always relevant if the path matches
526
+ if (sub.type === "collection") {
527
+ return isPathMatch;
528
+ }
529
+ return false;
530
+ });
531
+
532
+ this.debugLog(`📡 [RealtimeService] Found ${allSubscriptions.length} subscriptions for path: ${notifyPath}`);
533
+
534
+ // Separate WebSocket subscriptions from DataDriver callback subscriptions
535
+ const webSocketSubscriptions = allSubscriptions.filter(([, sub]) =>
536
+ sub.clientId !== "driver" && this.clients.has(sub.clientId)
537
+ );
538
+
539
+ const driverSubscriptions = allSubscriptions.filter(([subscriptionId, sub]) =>
540
+ sub.clientId === "driver" && this.subscriptionCallbacks.has(subscriptionId)
541
+ );
542
+
543
+ // Handle WebSocket subscriptions
544
+ for (const [subscriptionId, subscription] of webSocketSubscriptions) {
545
+ try {
546
+ if (subscription.type === "single" && notifyPath === originalPath) {
547
+ // Send row update directly (only for exact path matches)
548
+ if (row && (row as Record<string, unknown>)?._rebase_invalidated) {
549
+ this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
550
+ } else {
551
+ this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
552
+ }
553
+ } else if (subscription.type === "collection" && subscription.collectionRequest) {
554
+ // Phase 1: Send instant row-level patch (no DB query)
555
+ // This gives immediate cross-tab feedback
556
+ if (!row || !(row as Record<string, unknown>)?._rebase_invalidated) {
557
+ this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
558
+ }
559
+
560
+ // Phase 2: Schedule a deferred full refetch for correctness
561
+ // Handles filter/sort changes and ensures consistency
562
+ this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
563
+ }
564
+ } catch (error) {
565
+ const sanitized = sanitizeErrorForClient(error, notifyPath);
566
+ this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
567
+ }
568
+ }
569
+
570
+ // Handle DataDriver callback subscriptions
571
+ for (const [subscriptionId, subscription] of driverSubscriptions) {
572
+ try {
573
+ const callback = this.subscriptionCallbacks.get(subscriptionId);
574
+ if (!callback) continue;
575
+
576
+ if (subscription.type === "single" && notifyPath === originalPath) {
577
+ if (row && (row as Record<string, unknown>)?._rebase_invalidated) {
578
+ this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
579
+ } else {
580
+ // Call the callback directly with the row (only for exact path matches)
581
+ callback(row);
582
+ }
583
+ } else if (subscription.type === "collection" && subscription.collectionRequest) {
584
+ // Debounce collection refetches for DataDriver subscriptions too
585
+ this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
586
+ }
587
+ } catch (error) {
588
+ logger.error(`❌ [RealtimeService] Error processing DataDriver subscription ${subscriptionId}`, { error: error });
589
+ }
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Debounce a collection refetch for a WebSocket subscription.
595
+ * Coalesces rapid row mutations into a single database query.
596
+ */
597
+ private debouncedCollectionRefetch(
598
+ subscriptionId: string,
599
+ notifyPath: string,
600
+ subscription: { clientId: string; collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext }
601
+ ) {
602
+ const timerKey = `ws_${subscriptionId}`;
603
+ const existing = this.refetchTimers.get(timerKey);
604
+ if (existing) clearTimeout(existing);
605
+
606
+ this.refetchTimers.set(timerKey, setTimeout(async () => {
607
+ this.refetchTimers.delete(timerKey);
608
+ // Verify subscription still exists (client may have disconnected)
609
+ if (!this._subscriptions.has(subscriptionId)) return;
610
+ try {
611
+ const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
612
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
613
+ } catch (error) {
614
+ const sanitized = sanitizeErrorForClient(error, notifyPath);
615
+ this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
616
+ }
617
+ }, RealtimeService.REFETCH_DEBOUNCE_MS));
618
+ }
619
+
620
+ /**
621
+ * Debounce a collection refetch for a DataDriver callback subscription.
622
+ */
623
+ private debouncedDriverRefetch(
624
+ subscriptionId: string,
625
+ notifyPath: string,
626
+ subscription: { collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext },
627
+ callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
628
+ ) {
629
+ const timerKey = `drv_${subscriptionId}`;
630
+ const existing = this.refetchTimers.get(timerKey);
631
+ if (existing) clearTimeout(existing);
632
+
633
+ this.refetchTimers.set(timerKey, setTimeout(async () => {
634
+ this.refetchTimers.delete(timerKey);
635
+ if (!this._subscriptions.has(subscriptionId)) return;
636
+ try {
637
+ const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
638
+ callback(rows);
639
+ } catch (error) {
640
+ logger.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}`, { error: error });
641
+ }
642
+ }, RealtimeService.REFETCH_DEBOUNCE_MS));
643
+ }
644
+
645
+ /**
646
+ * Fetch a collection with optional RLS auth context.
647
+ * When authContext is provided, the fetch runs inside a transaction
648
+ * with set_config calls so PostgreSQL RLS policies are enforced.
649
+ */
650
+ private async fetchCollectionWithAuth(
651
+ notifyPath: string,
652
+ collectionRequest: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string },
653
+ authContext?: SubscriptionAuthContext
654
+ ): Promise<Record<string, unknown>[]> {
655
+ if (this.driver) {
656
+ const collection = this.registry.getCollectionByPath(notifyPath);
657
+ const fetchFn = async () => this.driver!.fetchCollection({
658
+ path: notifyPath,
659
+ collection: collection,
660
+ filter: collectionRequest.filter as FetchCollectionProps["filter"],
661
+ orderBy: collectionRequest.orderBy,
662
+ order: collectionRequest.order,
663
+ limit: collectionRequest.limit,
664
+ offset: collectionRequest.offset,
665
+ startAfter: collectionRequest.startAfter,
666
+ searchString: collectionRequest.searchString
667
+ });
668
+
669
+ // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
670
+ // Refetches are reads: apply the same GUCs + reader-role downgrade as the
671
+ // driver's read path, so realtime cannot leak rows the initial fetch hid.
672
+ const activeAuth = authContext || { userId: "anon",
673
+ roles: ["anon"] };
674
+ return await this.db.transaction(async (tx) => {
675
+ await applyAuthContext(tx, { userId: activeAuth.userId, roles: activeAuth.roles }, this.rlsUserRole);
676
+ const txEntityService = new DataService(tx, this.registry);
677
+ let fetchedEntities;
678
+ if (collectionRequest.searchString) {
679
+ fetchedEntities = await txEntityService.searchRows(
680
+ notifyPath,
681
+ collectionRequest.searchString,
682
+ {
683
+ filter: collectionRequest.filter as FilterValues<string>,
684
+ orderBy: collectionRequest.orderBy,
685
+ order: collectionRequest.order,
686
+ limit: collectionRequest.limit,
687
+ databaseId: collectionRequest.databaseId
688
+ }
689
+ );
690
+ } else {
691
+ fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
692
+ filter: collectionRequest.filter as FilterValues<string>,
693
+ orderBy: collectionRequest.orderBy,
694
+ order: collectionRequest.order,
695
+ limit: collectionRequest.limit,
696
+ offset: collectionRequest.offset,
697
+ startAfter: collectionRequest.startAfter,
698
+ databaseId: collectionRequest.databaseId
699
+ });
700
+ }
701
+
702
+ // Re-apply `afterRead` lifecycle hooks to ensure consistent data structures
703
+ // between the initial driver fetch and this RLS-bound refetch.
704
+ const registryCollection = this.registry.getCollectionByPath(notifyPath);
705
+ const resolvedCollection = collection ? { ...collection,
706
+ ...registryCollection } as CollectionConfig : registryCollection as CollectionConfig;
707
+
708
+ const callbacks = resolvedCollection?.callbacks;
709
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
710
+ const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
711
+
712
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
713
+ const contextForCallback = {
714
+ user: { uid: activeAuth.userId,
715
+ roles: activeAuth.roles },
716
+ driver: this.driver,
717
+ data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
718
+ } as unknown as RebaseCallContext;
719
+
720
+ return await Promise.all(fetchedEntities.map(async (fetchedRow) => {
721
+ let processedEntity = fetchedRow;
722
+ // 1. Global callbacks first
723
+ if (globalCallbacks?.afterRead) {
724
+ processedEntity = await globalCallbacks.afterRead({
725
+ collection: resolvedCollection,
726
+ path: notifyPath,
727
+ row: processedEntity,
728
+ context: contextForCallback
729
+ }) ?? processedEntity;
730
+ }
731
+ // 2. Collection callbacks second
732
+ if (callbacks?.afterRead) {
733
+ processedEntity = await callbacks.afterRead({
734
+ collection: resolvedCollection,
735
+ path: notifyPath,
736
+ row: processedEntity,
737
+ context: contextForCallback
738
+ }) ?? processedEntity;
739
+ }
740
+ // 3. Property callbacks third
741
+ if (propertyCallbacks?.afterRead) {
742
+ processedEntity = await propertyCallbacks.afterRead({
743
+ collection: resolvedCollection,
744
+ path: notifyPath,
745
+ row: processedEntity,
746
+ context: contextForCallback
747
+ }) ?? processedEntity;
748
+ }
749
+ return processedEntity;
750
+ }));
751
+ }
752
+
753
+ return fetchedEntities;
754
+ });
755
+ }
756
+
757
+ // No driver — use dataService directly (no auth wrapping possible)
758
+ if (collectionRequest.searchString) {
759
+ return await this.dataService.searchRows(
760
+ notifyPath,
761
+ collectionRequest.searchString,
762
+ {
763
+ filter: collectionRequest.filter as FilterValues<string>,
764
+ orderBy: collectionRequest.orderBy,
765
+ order: collectionRequest.order,
766
+ limit: collectionRequest.limit,
767
+ databaseId: collectionRequest.databaseId
768
+ }
769
+ );
770
+ }
771
+ return await this.dataService.fetchCollection(notifyPath, {
772
+ filter: collectionRequest.filter as FilterValues<string>,
773
+ orderBy: collectionRequest.orderBy,
774
+ order: collectionRequest.order,
775
+ limit: collectionRequest.limit,
776
+ offset: collectionRequest.offset,
777
+ startAfter: collectionRequest.startAfter,
778
+ databaseId: collectionRequest.databaseId
779
+ });
780
+ }
781
+
782
+ /**
783
+ * Debounce an row refetch for a WebSocket subscription.
784
+ */
785
+ private debouncedSingleRefetch(
786
+ subscriptionId: string,
787
+ notifyPath: string,
788
+ id: string,
789
+ subscription: { clientId: string; authContext?: SubscriptionAuthContext }
790
+ ) {
791
+ const timerKey = `wse_${subscriptionId}`;
792
+ const existing = this.refetchTimers.get(timerKey);
793
+ if (existing) clearTimeout(existing);
794
+
795
+ this.refetchTimers.set(timerKey, setTimeout(async () => {
796
+ this.refetchTimers.delete(timerKey);
797
+ if (!this._subscriptions.has(subscriptionId)) return;
798
+ try {
799
+ const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
800
+ this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
801
+ } catch (error) {
802
+ const sanitized = sanitizeErrorForClient(error, notifyPath);
803
+ this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
804
+ }
805
+ }, RealtimeService.REFETCH_DEBOUNCE_MS));
806
+ }
807
+
808
+ /**
809
+ * Debounce an row refetch for a Driver callback subscription.
810
+ */
811
+ private debouncedSingleDriverRefetch(
812
+ subscriptionId: string,
813
+ notifyPath: string,
814
+ id: string,
815
+ subscription: { clientId: string; authContext?: SubscriptionAuthContext },
816
+ callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
817
+ ) {
818
+ const timerKey = `drve_${subscriptionId}`;
819
+ const existing = this.refetchTimers.get(timerKey);
820
+ if (existing) clearTimeout(existing);
821
+
822
+ this.refetchTimers.set(timerKey, setTimeout(async () => {
823
+ this.refetchTimers.delete(timerKey);
824
+ if (!this._subscriptions.has(subscriptionId)) return;
825
+ try {
826
+ const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
827
+ callback(row || null);
828
+ } catch (error) {
829
+ logger.error(`❌ [RealtimeService] Error in debounced row driver refetch for ${subscriptionId}`, { error: error });
830
+ }
831
+ }, RealtimeService.REFETCH_DEBOUNCE_MS));
832
+ }
833
+
834
+ /**
835
+ * Fetch a single row with optional RLS auth context.
836
+ */
837
+ private async fetchEntityWithAuth(
838
+ notifyPath: string,
839
+ id: string | number,
840
+ authContext?: SubscriptionAuthContext
841
+ ): Promise<Record<string, unknown> | undefined> {
842
+ if (this.driver) {
843
+ const collection = this.registry.getCollectionByPath(notifyPath);
844
+ const fetchFn = async () => this.driver!.fetchOne({
845
+ path: notifyPath,
846
+ id,
847
+ collection
848
+ });
849
+
850
+ // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
851
+ // Same read isolation as collection refetches: GUCs + reader-role downgrade.
852
+ const activeAuth = authContext || { userId: "anon",
853
+ roles: ["anon"] };
854
+ return await this.db.transaction(async (tx) => {
855
+ await applyAuthContext(tx, { userId: activeAuth.userId, roles: activeAuth.roles }, this.rlsUserRole);
856
+ const txEntityService = new DataService(tx, this.registry);
857
+ let processedEntity = await txEntityService.fetchOne(notifyPath, id, collection?.databaseId);
858
+
859
+ if (processedEntity) {
860
+ const registryCollection = this.registry.getCollectionByPath(notifyPath);
861
+ const resolvedCollection = collection ? { ...collection,
862
+ ...registryCollection } as CollectionConfig : registryCollection as CollectionConfig;
863
+
864
+ const callbacks = resolvedCollection?.callbacks;
865
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
866
+ const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
867
+
868
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
869
+ const contextForCallback = {
870
+ user: { uid: activeAuth.userId,
871
+ roles: activeAuth.roles },
872
+ driver: this.driver,
873
+ data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
874
+ } as unknown as RebaseCallContext;
875
+
876
+ // 1. Global callbacks first
877
+ if (globalCallbacks?.afterRead) {
878
+ processedEntity = await globalCallbacks.afterRead({
879
+ collection: resolvedCollection,
880
+ path: notifyPath,
881
+ row: processedEntity,
882
+ context: contextForCallback
883
+ }) ?? processedEntity;
884
+ }
885
+ // 2. Collection callbacks second
886
+ if (callbacks?.afterRead) {
887
+ processedEntity = await callbacks.afterRead({
888
+ collection: resolvedCollection,
889
+ path: notifyPath,
890
+ row: processedEntity,
891
+ context: contextForCallback
892
+ }) ?? processedEntity;
893
+ }
894
+ // 3. Property callbacks third
895
+ if (propertyCallbacks?.afterRead) {
896
+ processedEntity = await propertyCallbacks.afterRead({
897
+ collection: resolvedCollection,
898
+ path: notifyPath,
899
+ row: processedEntity,
900
+ context: contextForCallback
901
+ }) ?? processedEntity;
902
+ }
903
+ }
904
+ }
905
+
906
+ return processedEntity;
907
+ });
908
+ }
909
+
910
+ return await this.dataService.fetchOne(notifyPath, id);
911
+ }
912
+
913
+ private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[]) {
914
+ const message: CollectionUpdateMessage = {
915
+ type: "collection_update",
916
+ subscriptionId,
917
+ rows: rows
918
+ };
919
+ this.sendMessage(clientId, message);
920
+ }
921
+
922
+ private sendSingleUpdate(clientId: string, subscriptionId: string, row: Record<string, unknown> | null) {
923
+ const message: SingleUpdateMessage = {
924
+ type: "single_update",
925
+ subscriptionId,
926
+ row: row
927
+ };
928
+ this.sendMessage(clientId, message);
929
+ }
930
+
931
+ /**
932
+ * Send a lightweight row-level patch to a collection subscriber.
933
+ * The client can merge this into its cached data for instant feedback.
934
+ */
935
+ private sendCollectionPatch(clientId: string, subscriptionId: string, id: string, row: Record<string, unknown> | null) {
936
+ const message: CollectionPatchMessage = {
937
+ type: "collection_patch",
938
+ subscriptionId,
939
+ id,
940
+ row: row
941
+ };
942
+ this.sendMessage(clientId, message);
943
+ }
944
+
945
+ private sendError(clientId: string, error: string, subscriptionId?: string, code?: string) {
946
+ const message = {
947
+ type: "error" as const,
948
+ subscriptionId,
949
+ payload: {
950
+ error: code ? { message: error, code } : error
951
+ },
952
+ error
953
+ };
954
+ this.sendMessage(clientId, message);
955
+ }
956
+
957
+ private sendMessage(clientId: string, message: CollectionUpdateMessage | SingleUpdateMessage | CollectionPatchMessage | { type: string; subscriptionId?: string; error?: string; payload?: unknown }) {
958
+ const client = this.clients.get(clientId);
959
+ if (client && client.readyState === WebSocket.OPEN) {
960
+ client.send(JSON.stringify(message));
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Extract parent paths from a nested path like "posts/70/tags"
966
+ * Returns ["posts", "posts/70"] for the example above
967
+ */
968
+ private getParentPaths(path: string): string[] {
969
+ const segments = path.split("/").filter(s => s.length > 0);
970
+ const parentPaths: string[] = [];
971
+
972
+ // Build parent paths progressively
973
+ for (let i = 1; i < segments.length; i += 2) {
974
+ const parentPath = segments.slice(0, i).join("/");
975
+ if (parentPath) {
976
+ parentPaths.push(parentPath);
977
+ }
978
+
979
+ // If there's an row ID, add the path including the row
980
+ if (i + 1 < segments.length) {
981
+ const pathWithEntity = segments.slice(0, i + 1).join("/");
982
+ parentPaths.push(pathWithEntity);
983
+ }
984
+ }
985
+
986
+ return parentPaths;
987
+ }
988
+
989
+ // =============================================================================
990
+ // Broadcast Channels
991
+ // =============================================================================
992
+
993
+ /** Join a broadcast channel */
994
+ joinChannel(clientId: string, channel: string): void {
995
+ if (!this.channels.has(channel)) {
996
+ this.channels.set(channel, new Set());
997
+ }
998
+ this.channels.get(channel)!.add(clientId);
999
+ this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
1000
+ }
1001
+
1002
+ /** Leave a broadcast channel */
1003
+ leaveChannel(clientId: string, channel: string): void {
1004
+ const members = this.channels.get(channel);
1005
+ if (members) {
1006
+ members.delete(clientId);
1007
+ if (members.size === 0) this.channels.delete(channel);
1008
+ }
1009
+ // Also remove presence
1010
+ this.removePresence(clientId, channel);
1011
+ }
1012
+
1013
+ /** Broadcast a message to all clients in a channel except sender */
1014
+ broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void {
1015
+ const members = this.channels.get(channel);
1016
+ if (!members) return;
1017
+
1018
+ const message = JSON.stringify({
1019
+ type: "broadcast",
1020
+ channel,
1021
+ event,
1022
+ payload
1023
+ });
1024
+
1025
+ for (const memberId of members) {
1026
+ if (memberId === clientId) continue; // Don't echo back to sender
1027
+ const ws = this.clients.get(memberId);
1028
+ if (ws && ws.readyState === WebSocket.OPEN) {
1029
+ ws.send(message);
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // =============================================================================
1035
+ // Presence
1036
+ // =============================================================================
1037
+
1038
+ /** Track presence in a channel */
1039
+ trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void {
1040
+ if (!this.presence.has(channel)) {
1041
+ this.presence.set(channel, new Map());
1042
+ }
1043
+
1044
+ const channelPresence = this.presence.get(channel)!;
1045
+ channelPresence.set(clientId, { state,
1046
+ lastSeen: Date.now() });
1047
+
1048
+ // Broadcast join / state update to channel
1049
+ this.broadcastPresenceDiff(channel, { [clientId]: state }, {});
1050
+
1051
+ // Start cleanup interval if not running
1052
+ this.ensurePresenceCleanup();
1053
+ }
1054
+
1055
+ /** Remove presence from a channel */
1056
+ removePresence(clientId: string, channel: string): void {
1057
+ const channelPresence = this.presence.get(channel);
1058
+ if (!channelPresence) return;
1059
+
1060
+ const entry = channelPresence.get(clientId);
1061
+ if (entry) {
1062
+ channelPresence.delete(clientId);
1063
+ this.broadcastPresenceDiff(channel, {}, { [clientId]: entry.state });
1064
+ }
1065
+
1066
+ if (channelPresence.size === 0) {
1067
+ this.presence.delete(channel);
1068
+ }
1069
+ }
1070
+
1071
+ /** Send full presence state to a specific client */
1072
+ sendPresenceState(clientId: string, channel: string): void {
1073
+ const channelPresence = this.presence.get(channel);
1074
+ const presences: Record<string, Record<string, unknown>> = {};
1075
+
1076
+ if (channelPresence) {
1077
+ for (const [id, { state }] of channelPresence) {
1078
+ presences[id] = state;
1079
+ }
1080
+ }
1081
+
1082
+ const ws = this.clients.get(clientId);
1083
+ if (ws && ws.readyState === WebSocket.OPEN) {
1084
+ ws.send(JSON.stringify({
1085
+ type: "presence_state",
1086
+ channel,
1087
+ presences
1088
+ }));
1089
+ }
1090
+ }
1091
+
1092
+ /** Broadcast presence diff (joins/leaves) to channel */
1093
+ private broadcastPresenceDiff(
1094
+ channel: string,
1095
+ joins: Record<string, Record<string, unknown>>,
1096
+ leaves: Record<string, Record<string, unknown>>
1097
+ ): void {
1098
+ const members = this.channels.get(channel);
1099
+ if (!members) return;
1100
+
1101
+ const message = JSON.stringify({
1102
+ type: "presence_diff",
1103
+ channel,
1104
+ joins,
1105
+ leaves
1106
+ });
1107
+
1108
+ for (const memberId of members) {
1109
+ const ws = this.clients.get(memberId);
1110
+ if (ws && ws.readyState === WebSocket.OPEN) {
1111
+ ws.send(message);
1112
+ }
1113
+ }
1114
+ }
1115
+
1116
+ /** Periodic cleanup for stale presences */
1117
+ private ensurePresenceCleanup(): void {
1118
+ if (this.presenceInterval) return;
1119
+ this.presenceInterval = setInterval(() => {
1120
+ const now = Date.now();
1121
+ for (const [channel, channelPresence] of this.presence) {
1122
+ for (const [clientId, entry] of channelPresence) {
1123
+ if (now - entry.lastSeen > RealtimeService.PRESENCE_TIMEOUT_MS) {
1124
+ this.removePresence(clientId, channel);
1125
+ }
1126
+ }
1127
+ }
1128
+ // Stop interval if no presences tracked
1129
+ if (this.presence.size === 0 && this.presenceInterval) {
1130
+ clearInterval(this.presenceInterval);
1131
+ this.presenceInterval = undefined;
1132
+ }
1133
+ }, 10000); // Check every 10s
1134
+ }
1135
+
1136
+ // =============================================================================
1137
+ // Lifecycle / Cleanup
1138
+ // =============================================================================
1139
+
1140
+ /**
1141
+ * Gracefully tear down all realtime resources.
1142
+ *
1143
+ * This MUST be called during process shutdown, **before** `pool.end()`.
1144
+ * It ensures:
1145
+ * 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
1146
+ * 2. All subscription state and callbacks are cleared.
1147
+ * 3. The dedicated LISTEN client (outside the pool) is disconnected.
1148
+ * 4. All WebSocket clients are removed (but not forcefully closed — the
1149
+ * HTTP server close will handle that).
1150
+ */
1151
+ async destroy(): Promise<void> {
1152
+ // 1. Cancel every pending debounced refetch timer
1153
+ for (const [key, timer] of this.refetchTimers) {
1154
+ clearTimeout(timer);
1155
+ this.refetchTimers.delete(key);
1156
+ }
1157
+
1158
+ // 2. Clear subscriptions and callbacks
1159
+ this._subscriptions.clear();
1160
+ this.subscriptionCallbacks.clear();
1161
+
1162
+ // 3. Clear broadcast channels and presence
1163
+ this.channels.clear();
1164
+ this.presence.clear();
1165
+ if (this.presenceInterval) {
1166
+ clearInterval(this.presenceInterval);
1167
+ this.presenceInterval = undefined;
1168
+ }
1169
+
1170
+ // 4. Disconnect the dedicated LISTEN client(s)
1171
+ await this.stopListening();
1172
+ await this.stopCdc();
1173
+
1174
+ // 5. Drop client references (don't close — server.close drains them)
1175
+ this.clients.clear();
1176
+
1177
+ this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
1178
+ }
1179
+
1180
+ // =============================================================================
1181
+ // Database-level Change Data Capture (CDC)
1182
+ // =============================================================================
1183
+
1184
+ /** Whether database-level change capture is currently the active source. */
1185
+ public isCdcActive(): boolean {
1186
+ return this.cdcActive;
1187
+ }
1188
+
1189
+ /**
1190
+ * Enable database-level change capture as the realtime source.
1191
+ *
1192
+ * A dedicated LISTEN client consumes committed changes from the `rebase_cdc`
1193
+ * channel (fed by CDC triggers — see {@link provisionTriggerCdc}) and routes
1194
+ * them into the same {@link notifyUpdate} pipeline used by API mutations. The
1195
+ * effect: subscribers see a change no matter how it was written — psql, a
1196
+ * cron in another service, raw SQL, or the Studio SQL editor — exactly like
1197
+ * Supabase Realtime tailing the WAL.
1198
+ *
1199
+ * Because CDC observes every commit on every instance, it also *replaces* the
1200
+ * legacy per-mutation cross-instance broadcast (see the guard in
1201
+ * {@link notifyUpdate}); callers should not also call {@link startListening}.
1202
+ *
1203
+ * @param connectionString Direct Postgres connection for the LISTEN client
1204
+ * (bypass PgBouncer — LISTEN needs a session connection).
1205
+ */
1206
+ async enableCdc(connectionString: string): Promise<void> {
1207
+ if (this.cdcActive) {
1208
+ logger.warn("⚠️ [CDC] enableCdc called but CDC is already active. Ignoring.");
1209
+ return;
1210
+ }
1211
+ this.cdcTableMap = this.buildCdcTableMap();
1212
+ this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
1213
+ try {
1214
+ // start() validates the initial connection; if it can't be established
1215
+ // it rejects here, and we leave CDC inactive so the caller can fall
1216
+ // back to app-level realtime rather than silently dropping events.
1217
+ await this.cdcListener.start();
1218
+ } catch (err) {
1219
+ await this.cdcListener.stop().catch(() => { /* best effort */ });
1220
+ this.cdcListener = undefined;
1221
+ this.cdcTableMap = undefined;
1222
+ throw err;
1223
+ }
1224
+ this.cdcActive = true;
1225
+ logger.info(
1226
+ `📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events ` +
1227
+ `(${this.cdcTableMap.size} mapped table key(s)).`
1228
+ );
1229
+ }
1230
+
1231
+ /** Stop the CDC listener and clear its state. */
1232
+ async stopCdc(): Promise<void> {
1233
+ this.cdcActive = false;
1234
+ if (this.cdcListener) {
1235
+ await this.cdcListener.stop();
1236
+ this.cdcListener = undefined;
1237
+ }
1238
+ this.cdcTableMap = undefined;
1239
+ this.recentAppEmits.clear();
1240
+ }
1241
+
1242
+ /**
1243
+ * Build the reverse map from database table → collection. A change event
1244
+ * carries `schema` + `table`; realtime subscriptions are keyed by collection
1245
+ * path (slug). We index by both `schema.table` and bare `table` so the lookup
1246
+ * works whether or not the collection declares an explicit schema.
1247
+ */
1248
+ private buildCdcTableMap(): Map<string, CollectionConfig> {
1249
+ const map = new Map<string, CollectionConfig>();
1250
+ for (const collection of this.registry.getCollections()) {
1251
+ const table = getTableName(collection);
1252
+ if (!table) continue;
1253
+ const schema = (collection as { schema?: string }).schema ?? "public";
1254
+ map.set(`${schema}.${table}`, collection);
1255
+ // Bare-table fallback; first registration wins to keep it deterministic.
1256
+ if (!map.has(table)) map.set(table, collection);
1257
+ }
1258
+ return map;
1259
+ }
1260
+
1261
+ private resolveCollectionForTable(schema: string, table: string): CollectionConfig | undefined {
1262
+ if (!this.cdcTableMap) return undefined;
1263
+ return this.cdcTableMap.get(`${schema}.${table}`) ?? this.cdcTableMap.get(table);
1264
+ }
1265
+
1266
+ /**
1267
+ * Route a captured database change into the realtime pipeline.
1268
+ *
1269
+ * Delivery is RLS-safe by construction: the raw tuple from the WAL/trigger is
1270
+ * NOT forwarded to subscribers. Instead the change is marked invalidated, so
1271
+ * every matching subscription re-reads the row under its own auth context via
1272
+ * {@link fetchCollectionWithAuth} / {@link fetchEntityWithAuth}. A subscriber
1273
+ * therefore only ever receives rows its RLS policies permit — filtering is per
1274
+ * subscriber, never per publisher.
1275
+ */
1276
+ private async handleCdcEvent(event: CdcChangeEvent): Promise<void> {
1277
+ const collection = this.resolveCollectionForTable(event.schema, event.table);
1278
+ if (!collection) {
1279
+ // Unmapped table (not backed by a collection) — nothing to deliver.
1280
+ this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
1281
+ return;
1282
+ }
1283
+
1284
+ const path = collection.slug;
1285
+ const databaseId = (collection as { databaseId?: string }).databaseId;
1286
+ const id = this.extractIdFromCdcRow(collection, event.row);
1287
+
1288
+ // Deletes carry a null row (subscribers drop the id); inserts/updates carry
1289
+ // an invalidation marker that forces a per-subscriber RLS-bound refetch.
1290
+ const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
1291
+
1292
+ await this.notifyUpdate(path, id, row, databaseId, /* broadcast */ false, /* origin */ "cdc");
1293
+ }
1294
+
1295
+ /** Compute the canonical (possibly composite) id string from a captured row. */
1296
+ private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
1297
+ try {
1298
+ const primaryKeys = getPrimaryKeys(collection, this.registry);
1299
+ const composite = buildCompositeId(row, primaryKeys);
1300
+ if (composite && composite !== ":::") return composite;
1301
+ } catch {
1302
+ /* fall through to id-field / wildcard */
1303
+ }
1304
+ // Fallbacks: a plain `id` column (common), else a collection-level
1305
+ // invalidation ("*") — single-row subs won't match but collection subs refetch.
1306
+ if (row.id !== undefined && row.id !== null) return String(row.id);
1307
+ return "*";
1308
+ }
1309
+
1310
+ // ── App/CDC de-duplication ──
1311
+
1312
+ private dedupKey(path: string, id: string, databaseId?: string): string {
1313
+ return `${databaseId ?? ""}::${path}::${id}`;
1314
+ }
1315
+
1316
+ /** Record that this instance just delivered `key` via the app path. */
1317
+ private markAppEmit(key: string): void {
1318
+ const now = Date.now();
1319
+ this.recentAppEmits.set(key, now + RealtimeService.CDC_DEDUP_WINDOW_MS);
1320
+ // Opportunistic purge so the map cannot grow unbounded under write load.
1321
+ if (this.recentAppEmits.size > 1000) {
1322
+ for (const [k, expiry] of this.recentAppEmits) {
1323
+ if (expiry <= now) this.recentAppEmits.delete(k);
1324
+ }
1325
+ }
1326
+ }
1327
+
1328
+ /** Consume a matching app-emit record if present and unexpired; true ⇒ suppress the CDC echo. */
1329
+ private consumeAppEmit(key: string): boolean {
1330
+ const expiry = this.recentAppEmits.get(key);
1331
+ if (expiry === undefined) return false;
1332
+ this.recentAppEmits.delete(key);
1333
+ return expiry > Date.now();
1334
+ }
1335
+
1336
+ // =============================================================================
1337
+ // Cross-Instance LISTEN/NOTIFY
1338
+ // =============================================================================
1339
+
1340
+ /**
1341
+ * Enable cross-instance realtime broadcasting via Postgres LISTEN/NOTIFY.
1342
+ * Creates a dedicated pg.Client (outside the Drizzle pool) that stays
1343
+ * connected and listens for change notifications from other instances.
1344
+ *
1345
+ * This is an **optional** feature — if never called, the backend operates
1346
+ * in single-instance mode (the default, perfectly fine for most setups).
1347
+ *
1348
+ * @param connectionString Raw Postgres connection string for the LISTEN client.
1349
+ */
1350
+ async startListening(connectionString: string): Promise<void> {
1351
+ if (this.broadcasting) {
1352
+ logger.warn("⚠️ [RealtimeService] startListening called but already listening. Ignoring.");
1353
+ return;
1354
+ }
1355
+
1356
+ this.listenConnectionString = connectionString;
1357
+ // Set broadcasting BEFORE connecting so that scheduleReconnect()
1358
+ // works correctly if the initial connection attempt fails.
1359
+ this.broadcasting = true;
1360
+ await this.connectListenClient();
1361
+ logger.info(`📡 [RealtimeService] Cross-instance realtime enabled (instanceId: ${this.instanceId})`);
1362
+ }
1363
+
1364
+ /**
1365
+ * Stop listening and clean up the dedicated LISTEN connection.
1366
+ */
1367
+ async stopListening(): Promise<void> {
1368
+ this.broadcasting = false;
1369
+ if (this.reconnectTimer) {
1370
+ clearTimeout(this.reconnectTimer);
1371
+ this.reconnectTimer = undefined;
1372
+ }
1373
+ if (this.listenClient) {
1374
+ try {
1375
+ await this.listenClient.end();
1376
+ } catch { /* ignore close errors */ }
1377
+ this.listenClient = undefined;
1378
+ }
1379
+ logger.info("📡 [RealtimeService] Cross-instance realtime disabled.");
1380
+ }
1381
+
1382
+ /**
1383
+ * Broadcast a change notification to other instances via pg_notify.
1384
+ * Uses the main Drizzle connection (pooled) — NOT the LISTEN client.
1385
+ */
1386
+ private async broadcastChange(path: string, id: string, databaseId?: string): Promise<void> {
1387
+ const payload = JSON.stringify({
1388
+ sid: this.instanceId,
1389
+ p: path,
1390
+ eid: id,
1391
+ db: databaseId ?? null
1392
+ });
1393
+ await this.db.execute(drizzleSql`SELECT pg_notify(${PG_NOTIFY_CHANNEL}, ${payload})`);
1394
+ }
1395
+
1396
+ /**
1397
+ * Create and connect the dedicated LISTEN client with auto-reconnect.
1398
+ */
1399
+ private async connectListenClient(): Promise<void> {
1400
+ if (!this.listenConnectionString) return;
1401
+
1402
+ try {
1403
+ const client = new PgClient({ connectionString: this.listenConnectionString });
1404
+
1405
+ client.on("error", (err) => {
1406
+ logger.error("❌ [RealtimeService] LISTEN client error", { detail: err.message });
1407
+ this.scheduleReconnect();
1408
+ });
1409
+
1410
+ client.on("end", () => {
1411
+ if (this.broadcasting) {
1412
+ logger.warn("⚠️ [RealtimeService] LISTEN client disconnected unexpectedly.");
1413
+ this.scheduleReconnect();
1414
+ }
1415
+ });
1416
+
1417
+ client.on("notification", async (msg) => {
1418
+ if (!msg.payload) return;
1419
+ try {
1420
+ const { sid, p, eid, db } = JSON.parse(msg.payload) as {
1421
+ sid: string;
1422
+ p: string;
1423
+ eid: string;
1424
+ db: string | null;
1425
+ };
1426
+
1427
+ // Skip our own notifications — already processed locally
1428
+ if (sid === this.instanceId) return;
1429
+
1430
+ this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
1431
+
1432
+ // Refetch the row from the DB so row subscriptions
1433
+ // receive the actual data instead of null (which the client
1434
+ // would interpret as "deleted").
1435
+ let refetchedRow: Record<string, unknown> | null = null;
1436
+ try {
1437
+ if (this.driver) {
1438
+ const collection = this.registry.getCollectionByPath(p);
1439
+ const fetched = await this.driver.fetchOne({
1440
+ path: p,
1441
+ id: eid,
1442
+ collection: collection
1443
+ });
1444
+ refetchedRow = fetched ?? null;
1445
+ } else {
1446
+ const fetched = await this.dataService.fetchOne(
1447
+ p, eid, db ?? undefined
1448
+ );
1449
+ refetchedRow = fetched ?? null;
1450
+ }
1451
+ } catch (fetchErr) {
1452
+ // If the fetch fails (e.g. row was deleted), refetchedRow stays null
1453
+ this.debugLog(`📡 [RealtimeService] Could not refetch row ${eid} from ${p} — treating as deleted`, fetchErr);
1454
+ }
1455
+
1456
+ // Trigger local fan-out with broadcast=false to avoid re-broadcasting
1457
+ await this.notifyUpdate(p, eid, refetchedRow, db ?? undefined, false);
1458
+ } catch (err) {
1459
+ logger.error("❌ [RealtimeService] Error processing cross-instance notification", { error: err });
1460
+ }
1461
+ });
1462
+
1463
+ await client.connect();
1464
+ await client.query(`LISTEN ${PG_NOTIFY_CHANNEL}`);
1465
+ this.listenClient = client;
1466
+
1467
+ this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
1468
+ } catch (err) {
1469
+ logger.error("❌ [RealtimeService] Failed to connect LISTEN client", { error: err });
1470
+ this.scheduleReconnect();
1471
+ }
1472
+ }
1473
+
1474
+ /**
1475
+ * Schedule a reconnection attempt with a fixed 3s delay.
1476
+ */
1477
+ private scheduleReconnect(): void {
1478
+ if (!this.broadcasting || this.reconnectTimer) return;
1479
+
1480
+ const delay = 3000; // Fixed 3s delay; simple and predictable
1481
+ this.debugLog(`📡 [RealtimeService] Scheduling LISTEN reconnect in ${delay}ms...`);
1482
+
1483
+ this.reconnectTimer = setTimeout(async () => {
1484
+ this.reconnectTimer = undefined;
1485
+ if (!this.broadcasting) return;
1486
+
1487
+ // Clean up old client
1488
+ if (this.listenClient) {
1489
+ try { await this.listenClient.end(); } catch { /* ignore */ }
1490
+ this.listenClient = undefined;
1491
+ }
1492
+
1493
+ await this.connectListenClient();
1494
+ }, delay);
1495
+ }
1496
+ }
1497
+
1498
+ /**
1499
+ * Alias for RealtimeService for consistent naming with other database implementations.
1500
+ * This allows code to use PostgresRealtimeProvider alongside future MongoRealtimeProvider, etc.
1501
+ */
1502
+ export const PostgresRealtimeProvider = RealtimeService;