@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.
- package/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- 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;
|