@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,1387 @@
1
+ import { DataService } from "./services/dataService";
2
+ import { BranchService } from "./services/BranchService";
3
+ import { RealtimeService } from "./services/realtimeService";
4
+ import { DatabasePoolManager } from "./databasePoolManager";
5
+ import { DrizzleClient } from "./interfaces";
6
+ import {
7
+ DatabaseAdmin,
8
+ DataDriver,
9
+ DeleteProps,
10
+ CollectionConfig,
11
+ FetchCollectionProps,
12
+ FetchOneProps,
13
+ ListenCollectionProps,
14
+ ListenOneProps,
15
+ RebaseCallContext,
16
+ RebaseClient,
17
+ RebaseData,
18
+ RebaseSdkData,
19
+ RestFetchService,
20
+ SaveProps,
21
+ TableColumnInfo,
22
+ TableForeignKeyInfo,
23
+ TableJunctionInfo,
24
+ TableMetadata,
25
+ TablePolicyInfo,
26
+ User
27
+ } from "@rebasepro/types";
28
+ import { sql as drizzleSql } from "drizzle-orm";
29
+ import { buildPropertyCallbacks, buildSdkData, resolveCollectionRelations, updateDateAutoValues } from "@rebasepro/common";
30
+ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
31
+ import { HistoryService } from "./history/HistoryService";
32
+ import { mergeDeep } from "@rebasepro/utils";
33
+ import { logger } from "@rebasepro/server";
34
+ import { isRoleSwitchingPermissionError } from "./utils/pg-error-utils";
35
+ import { applyAuthContext } from "./security/rls-enforcement";
36
+ import { classifyTable, detectJunctionTables } from "./utils/table-classification";
37
+
38
+ export class PostgresBackendDriver implements DataDriver {
39
+ key = "postgres";
40
+ initialised = true;
41
+
42
+ public dataService: DataService;
43
+ public realtimeService: RealtimeService;
44
+ public historyService?: HistoryService;
45
+ public branchService?: BranchService;
46
+ public user?: User;
47
+ public data: RebaseSdkData;
48
+ public client?: RebaseClient;
49
+
50
+ /**
51
+ * Auto-set to `true` when a SET LOCAL ROLE fails with insufficient
52
+ * privileges, so subsequent queries skip the doomed attempt.
53
+ * Mirrors the static `DISABLE_DB_ROLE_SWITCHING` env var but is
54
+ * learned at runtime.
55
+ */
56
+ private _roleSwitchingDisabled = false;
57
+
58
+ /**
59
+ * Restricted role that authenticated (user-context) requests run as (via
60
+ * `SET LOCAL ROLE`) so RLS binds every statement — reads *and* writes. Set
61
+ * by the bootstrapper after posture detection: defined when the connection
62
+ * would otherwise bypass RLS (superuser / BYPASSRLS / table owner),
63
+ * undefined when RLS already applies natively. The base (server-context)
64
+ * driver never switches — it is the trusted owner plane (auth flows,
65
+ * migrations, `dataAsAdmin`).
66
+ */
67
+ public rlsUserRole?: string;
68
+
69
+ /**
70
+ * When true, realtime notifications are deferred until after the
71
+ * wrapping transaction commits. Set by `withAuth` → `withTransaction`.
72
+ */
73
+ _deferNotifications = false;
74
+ _pendingNotifications: Array<{
75
+ path: string;
76
+ id: string;
77
+ row: Record<string, unknown> | null;
78
+ databaseId?: string;
79
+ }> = [];
80
+
81
+ constructor(
82
+ public db: DrizzleClient,
83
+ realtimeService: RealtimeService,
84
+ public readonly registry: PostgresCollectionRegistry,
85
+ user?: User,
86
+ public poolManager?: DatabasePoolManager,
87
+ historyService?: HistoryService
88
+ ) {
89
+ this.dataService = new DataService(db, registry);
90
+ this.realtimeService = realtimeService;
91
+ this.historyService = historyService;
92
+ this.user = user;
93
+ this.data = buildSdkData(this);
94
+
95
+ // Initialize BranchService when adminConnectionString is configured
96
+ if (poolManager) {
97
+ this.branchService = new BranchService(db, poolManager);
98
+ }
99
+
100
+ }
101
+
102
+ /**
103
+ * Typed admin capabilities (SQLAdmin + SchemaAdmin + BranchAdmin).
104
+ * Implemented as a getter so method references are resolved at call-time,
105
+ * allowing test spies applied after construction to take effect.
106
+ */
107
+ get admin(): DatabaseAdmin {
108
+ return {
109
+ executeSql: (...args: Parameters<NonNullable<DatabaseAdmin["executeSql"]>>) => this.executeSql(...args),
110
+ fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
111
+ fetchAvailableRoles: () => this.fetchAvailableRoles(),
112
+ fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
113
+ fetchUnmappedTables: (...args: Parameters<NonNullable<DatabaseAdmin["fetchUnmappedTables"]>>) => this.fetchUnmappedTables(...args),
114
+ fetchTableMetadata: (...args: Parameters<NonNullable<DatabaseAdmin["fetchTableMetadata"]>>) => this.fetchTableMetadata(...args),
115
+ // Branch operations (only available when poolManager is configured)
116
+ ...(this.branchService ? {
117
+ createBranch: this.branchService.createBranch.bind(this.branchService),
118
+ deleteBranch: this.branchService.deleteBranch.bind(this.branchService),
119
+ listBranches: this.branchService.listBranches.bind(this.branchService),
120
+ getBranchInfo: this.branchService.getBranchInfo.bind(this.branchService)
121
+ } : {})
122
+ };
123
+ }
124
+
125
+ /**
126
+ * REST-optimised fetch service (include-aware eager-loading).
127
+ * Delegates to the underlying FetchService (include-aware eager loading),
128
+ * then runs the afterRead pipeline on the results. The raw FetchService does
129
+ * NOT run callbacks, so masking must be applied here — otherwise every
130
+ * REST/SDK read leaks unmasked data (see {@link applyAfterReadForRest}).
131
+ */
132
+ get restFetchService(): RestFetchService {
133
+ const raw = this.dataService.getFetchService();
134
+ return {
135
+ fetchCollectionForRest: async (collectionPath, options, include) => {
136
+ const rows = await raw.fetchCollectionForRest(collectionPath, options, include);
137
+ return this.applyAfterReadForRest(rows, collectionPath);
138
+ },
139
+ fetchOneForRest: async (collectionPath, id, include, databaseId) => {
140
+ const row = await raw.fetchOneForRest(collectionPath, id, include, databaseId);
141
+ if (!row) return row;
142
+ const [masked] = await this.applyAfterReadForRest([row], collectionPath);
143
+ return masked;
144
+ }
145
+ };
146
+ }
147
+
148
+ private buildCallContext(): RebaseCallContext {
149
+ return {
150
+ user: this.user,
151
+ driver: this,
152
+ data: this.data,
153
+ client: this.client,
154
+ storageSource: this.client?.storage
155
+ } as unknown as RebaseCallContext;
156
+ }
157
+
158
+ private resolveCollectionCallbacks<M extends Record<string, unknown>>(collection: CollectionConfig<M> | undefined, path: string) {
159
+ if (!collection && !path) return {
160
+ collection: undefined,
161
+ callbacks: undefined,
162
+ globalCallbacks: undefined,
163
+ propertyCallbacks: undefined
164
+ };
165
+ const registryCollection = this.registry?.getCollectionByPath(path);
166
+ const resolvedCollection = registryCollection
167
+ ? {
168
+ ...collection,
169
+ ...registryCollection
170
+ } as CollectionConfig<M>
171
+ : collection as CollectionConfig<M>;
172
+
173
+ const callbacks = resolvedCollection?.callbacks;
174
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
175
+ const properties = resolvedCollection?.properties;
176
+ let propertyCallbacks;
177
+ if (properties) {
178
+ propertyCallbacks = buildPropertyCallbacks(properties);
179
+ }
180
+ return {
181
+ collection: resolvedCollection,
182
+ callbacks,
183
+ globalCallbacks,
184
+ propertyCallbacks
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Run the three-tier afterRead pipeline (global → collection → property) on a
190
+ * single row for a collection whose callbacks have already been resolved.
191
+ */
192
+ private async applyAfterReadToRow(
193
+ row: Record<string, unknown>,
194
+ path: string,
195
+ resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]>,
196
+ contextForCallback: RebaseCallContext
197
+ ): Promise<Record<string, unknown>> {
198
+ const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = resolved;
199
+ let out = row;
200
+ if (globalCallbacks?.afterRead) {
201
+ out = await globalCallbacks.afterRead({
202
+ collection: resolvedCollection as unknown as CollectionConfig,
203
+ path, row: out, context: contextForCallback
204
+ }) ?? out;
205
+ }
206
+ if (callbacks?.afterRead) {
207
+ out = await callbacks.afterRead({
208
+ collection: resolvedCollection as CollectionConfig,
209
+ path, row: out, context: contextForCallback
210
+ }) ?? out;
211
+ }
212
+ if (propertyCallbacks?.afterRead) {
213
+ out = await propertyCallbacks.afterRead({
214
+ collection: resolvedCollection as unknown as CollectionConfig,
215
+ path, row: out, context: contextForCallback
216
+ }) ?? out;
217
+ }
218
+ return out;
219
+ }
220
+
221
+ private static hasAfterRead(resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]>): boolean {
222
+ return !!(resolved.globalCallbacks?.afterRead || resolved.callbacks?.afterRead || resolved.propertyCallbacks?.afterRead);
223
+ }
224
+
225
+ /**
226
+ * Apply afterRead to REST/SDK read results.
227
+ *
228
+ * The REST / `include` path fetches rows through the raw fetch service, which
229
+ * does NOT run callbacks — so without this, `afterRead` transforms (e.g. PII
230
+ * masking) are silently skipped on every SDK/REST read, leaking raw data.
231
+ * This choke point guarantees afterRead runs there too, matching the driver's
232
+ * fetchCollection/fetchOne paths.
233
+ *
234
+ * It also masks embedded relation data one level deep by running the TARGET
235
+ * collection's afterRead (so `post.author.email` is masked by the authors
236
+ * collection, not left raw).
237
+ */
238
+ async applyAfterReadForRest(
239
+ rows: Record<string, unknown>[],
240
+ path: string
241
+ ): Promise<Record<string, unknown>[]> {
242
+ if (!rows || rows.length === 0) return rows;
243
+
244
+ const resolved = this.resolveCollectionCallbacks(undefined, path);
245
+ const contextForCallback = this.buildCallContext();
246
+ const hasOwn = PostgresBackendDriver.hasAfterRead(resolved);
247
+
248
+ // Resolve embedded relation targets (relationKey -> { path, resolved callbacks })
249
+ // once, keeping only those whose target collection actually has an afterRead.
250
+ const relationTargets: Record<string, { path: string; resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]> }> = {};
251
+ if (resolved.collection) {
252
+ try {
253
+ const rels = resolveCollectionRelations(resolved.collection as CollectionConfig);
254
+ for (const [key, rel] of Object.entries(rels)) {
255
+ const target = typeof (rel as { target?: unknown }).target === "function"
256
+ ? (rel as { target: () => CollectionConfig }).target()
257
+ : undefined;
258
+ const targetPath = target?.slug;
259
+ if (!targetPath) continue;
260
+ const targetResolved = this.resolveCollectionCallbacks(undefined, targetPath);
261
+ if (PostgresBackendDriver.hasAfterRead(targetResolved)) {
262
+ relationTargets[key] = { path: targetPath, resolved: targetResolved };
263
+ }
264
+ }
265
+ } catch {
266
+ // Ignore relation resolution errors (e.g. incomplete config during setup)
267
+ }
268
+ }
269
+ const relKeys = Object.keys(relationTargets);
270
+
271
+ if (!hasOwn && relKeys.length === 0) return rows;
272
+
273
+ const maskEmbedded = async (value: unknown, target: { path: string; resolved: ReturnType<PostgresBackendDriver["resolveCollectionCallbacks"]> }): Promise<unknown> => {
274
+ if (Array.isArray(value)) {
275
+ return Promise.all(value.map((v) => maskEmbedded(v, target)));
276
+ }
277
+ if (!value || typeof value !== "object") return value;
278
+ const obj = value as Record<string, unknown>;
279
+ // A pre-fetched relation payload may nest the row under `.data`.
280
+ if (obj.__type === "relation" && obj.data && typeof obj.data === "object") {
281
+ return { ...obj, data: await this.applyAfterReadToRow(obj.data as Record<string, unknown>, target.path, target.resolved, contextForCallback) };
282
+ }
283
+ // A bare reference pointer carries no row data to mask.
284
+ if (obj.__type === "reference") return obj;
285
+ return this.applyAfterReadToRow(obj, target.path, target.resolved, contextForCallback);
286
+ };
287
+
288
+ return Promise.all(rows.map(async (row) => {
289
+ let out = hasOwn ? await this.applyAfterReadToRow(row, path, resolved, contextForCallback) : row;
290
+ for (const key of relKeys) {
291
+ if (out[key] === undefined || out[key] === null) continue;
292
+ out = { ...out, [key]: await maskEmbedded(out[key], relationTargets[key]) };
293
+ }
294
+ return out;
295
+ }));
296
+ }
297
+
298
+ async fetchCollection<M extends Record<string, unknown>>({
299
+ path,
300
+ collection,
301
+ filter,
302
+ limit,
303
+ offset,
304
+ startAfter,
305
+ orderBy,
306
+ searchString,
307
+ order,
308
+ vectorSearch
309
+ }: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
310
+
311
+ const rows = await this.dataService.fetchCollection<M>(path, {
312
+ filter,
313
+ orderBy,
314
+ order,
315
+ limit,
316
+ offset,
317
+ startAfter: startAfter as Record<string, unknown> | undefined,
318
+ databaseId: collection?.databaseId,
319
+ searchString,
320
+ vectorSearch
321
+ });
322
+
323
+ const {
324
+ collection: resolvedCollection,
325
+ callbacks,
326
+ globalCallbacks,
327
+ propertyCallbacks
328
+ } = this.resolveCollectionCallbacks(collection, path);
329
+
330
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
331
+ const contextForCallback = this.buildCallContext();
332
+ return Promise.all(rows.map(async (row) => {
333
+ let fetched = row;
334
+ // 1. Global callbacks first
335
+ if (globalCallbacks?.afterRead) {
336
+ fetched = await globalCallbacks.afterRead({
337
+ collection: resolvedCollection as unknown as CollectionConfig,
338
+ path,
339
+ row: fetched,
340
+ context: contextForCallback
341
+ });
342
+ }
343
+ // 2. Collection callbacks second
344
+ if (callbacks?.afterRead) {
345
+ fetched = await callbacks.afterRead({
346
+ collection: resolvedCollection as CollectionConfig<M>,
347
+ path,
348
+ row: fetched,
349
+ context: contextForCallback
350
+ }) ?? fetched;
351
+ }
352
+ // 3. Property callbacks third
353
+ if (propertyCallbacks?.afterRead) {
354
+ fetched = await propertyCallbacks.afterRead({
355
+ collection: resolvedCollection as unknown as CollectionConfig,
356
+ path,
357
+ row: fetched,
358
+ context: contextForCallback
359
+ });
360
+ }
361
+ return fetched;
362
+ }));
363
+ }
364
+
365
+ return rows;
366
+ }
367
+
368
+ listenCollection<M extends Record<string, unknown>>({
369
+ path,
370
+ collection,
371
+ filter,
372
+ limit,
373
+ offset,
374
+ startAfter,
375
+ orderBy,
376
+ searchString,
377
+ order,
378
+ onUpdate,
379
+ onError
380
+ }: ListenCollectionProps<M>): () => void {
381
+
382
+ const subscriptionId = this.generateSubscriptionId();
383
+
384
+ // Type-adapter wrapper: RealtimeService expects a union callback signature
385
+ const callbackWrapper = (rows: Record<string, unknown>[]) => {
386
+ onUpdate(rows);
387
+ };
388
+
389
+ // Store the subscription in RealtimeService properly using the new public method
390
+ this.realtimeService.registerDataDriverSubscription(subscriptionId, {
391
+ clientId: "driver",
392
+ type: "collection" as const,
393
+ path,
394
+ collectionRequest: {
395
+ filter,
396
+ orderBy,
397
+ order,
398
+ limit,
399
+ offset,
400
+ startAfter: startAfter as Record<string, unknown> | undefined,
401
+ databaseId: collection?.databaseId,
402
+ searchString
403
+ }
404
+ });
405
+
406
+ // Store the callback for this subscription
407
+ this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper as (data: Record<string, unknown> | Record<string, unknown>[] | null) => void);
408
+
409
+ // Send initial data immediately
410
+ this.fetchCollection({
411
+ path: path,
412
+ collection,
413
+ filter,
414
+ limit,
415
+ offset,
416
+ startAfter,
417
+ orderBy,
418
+ searchString,
419
+ order
420
+ }).then(rows => {
421
+ callbackWrapper(rows);
422
+ }).catch(error => {
423
+ if (onError) onError(error);
424
+ });
425
+
426
+ return () => {
427
+ this.realtimeService.removeSubscriptionCallback(subscriptionId);
428
+ this.realtimeService.subscriptions.delete(subscriptionId);
429
+ };
430
+ }
431
+
432
+ async fetchOne<M extends Record<string, unknown>>({
433
+ path,
434
+ id,
435
+ databaseId,
436
+ collection
437
+ }: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
438
+ let row = await this.dataService.fetchOne<M>(
439
+ path,
440
+ id,
441
+ databaseId || collection?.databaseId
442
+ );
443
+
444
+ const {
445
+ collection: resolvedCollection,
446
+ callbacks,
447
+ globalCallbacks,
448
+ propertyCallbacks
449
+ } = this.resolveCollectionCallbacks(collection, path);
450
+
451
+ if (row && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
452
+ const contextForCallback = this.buildCallContext();
453
+ // 1. Global callbacks first
454
+ if (globalCallbacks?.afterRead) {
455
+ row = await globalCallbacks.afterRead({
456
+ collection: resolvedCollection as unknown as CollectionConfig,
457
+ path,
458
+ row,
459
+ context: contextForCallback
460
+ });
461
+ }
462
+ // 2. Collection callbacks second
463
+ if (callbacks?.afterRead) {
464
+ row = await callbacks.afterRead({
465
+ collection: resolvedCollection as CollectionConfig<M>,
466
+ path,
467
+ row,
468
+ context: contextForCallback
469
+ }) ?? row;
470
+ }
471
+ // 3. Property callbacks third
472
+ if (propertyCallbacks?.afterRead) {
473
+ row = await propertyCallbacks.afterRead({
474
+ collection: resolvedCollection as unknown as CollectionConfig,
475
+ path,
476
+ row,
477
+ context: contextForCallback
478
+ });
479
+ }
480
+ }
481
+
482
+ return row;
483
+ }
484
+
485
+ listenOne<M extends Record<string, unknown>>({
486
+ path,
487
+ id,
488
+ collection,
489
+ onUpdate,
490
+ onError
491
+ }: ListenOneProps<M>): () => void {
492
+
493
+ const subscriptionId = this.generateSubscriptionId();
494
+ const callbackWrapper = (row: Record<string, unknown> | null) => {
495
+ if (row)
496
+ onUpdate(row);
497
+ };
498
+
499
+ // Register the subscription with the RealtimeService
500
+ this.realtimeService.registerDataDriverSubscription(subscriptionId, {
501
+ clientId: "driver",
502
+ type: "single" as const,
503
+ path,
504
+ id
505
+ });
506
+
507
+ // Store the callback for this subscription
508
+ this.realtimeService.addSubscriptionCallback(subscriptionId, callbackWrapper as (data: Record<string, unknown> | Record<string, unknown>[] | null) => void);
509
+
510
+ // Fetch initial data
511
+ this.fetchOne({
512
+ path,
513
+ id,
514
+ collection
515
+ })
516
+ .then(row => {
517
+ if (row) onUpdate(row);
518
+ })
519
+ .catch(error => {
520
+ if (onError) onError(error as Error);
521
+ });
522
+
523
+ // Return the unsubscribe function
524
+ return () => {
525
+ this.realtimeService.removeSubscriptionCallback(subscriptionId);
526
+ this.realtimeService.subscriptions.delete(subscriptionId);
527
+ };
528
+ }
529
+
530
+ async save<M extends Record<string, unknown>>({
531
+ path,
532
+ id,
533
+ values,
534
+ collection,
535
+ status
536
+ }: SaveProps<M>): Promise<Record<string, unknown>> {
537
+
538
+ const {
539
+ collection: resolvedCollection,
540
+ callbacks,
541
+ globalCallbacks,
542
+ propertyCallbacks
543
+ } = this.resolveCollectionCallbacks(collection, path);
544
+
545
+ let updatedValues = values;
546
+ const contextForCallback = this.buildCallContext();
547
+
548
+ // Fetch previous values for callbacks AND history recording
549
+ let previousValuesForHistory: Partial<M> | undefined;
550
+ if (status === "existing" && id) {
551
+ const existing = await this.dataService.fetchOne<M>(path, id, resolvedCollection?.databaseId);
552
+ if (existing) {
553
+ const { id: _existingId, ...existingValues } = existing;
554
+ previousValuesForHistory = existingValues as Partial<M>;
555
+ }
556
+ }
557
+
558
+ if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
559
+ // 1. Global callbacks first
560
+ if (globalCallbacks?.beforeSave) {
561
+ const result = await globalCallbacks.beforeSave({
562
+ collection: resolvedCollection as unknown as CollectionConfig,
563
+ path,
564
+ id,
565
+ values: updatedValues,
566
+ previousValues: previousValuesForHistory,
567
+ status,
568
+ context: contextForCallback
569
+ });
570
+ if (result) updatedValues = mergeDeep(updatedValues, result);
571
+ }
572
+
573
+ // 2. Collection callbacks second
574
+ if (callbacks?.beforeSave) {
575
+ const result = await callbacks.beforeSave({
576
+ collection: resolvedCollection as CollectionConfig<M>,
577
+ path,
578
+ id,
579
+ values: updatedValues,
580
+ previousValues: previousValuesForHistory,
581
+ status,
582
+ context: contextForCallback
583
+ });
584
+ if (result) updatedValues = mergeDeep(updatedValues, result);
585
+ }
586
+
587
+ // 3. Property callbacks third
588
+ if (propertyCallbacks?.beforeSave) {
589
+ const result = await propertyCallbacks.beforeSave({
590
+ collection: resolvedCollection as unknown as CollectionConfig,
591
+ path,
592
+ id,
593
+ values: updatedValues,
594
+ previousValues: previousValuesForHistory,
595
+ status,
596
+ context: contextForCallback
597
+ });
598
+ if (result) updatedValues = mergeDeep(updatedValues, result);
599
+ }
600
+
601
+ }
602
+
603
+ // Apply autoValue timestamps (on_create / on_update) at the application layer.
604
+ // This handles updated_at fields for all writes that flow through the Rebase backend.
605
+ if (resolvedCollection?.properties) {
606
+ updatedValues = updateDateAutoValues({
607
+ inputValues: updatedValues,
608
+ properties: resolvedCollection.properties,
609
+ status: status ?? "new",
610
+ timestampNowValue: new Date()
611
+ });
612
+ }
613
+
614
+ try {
615
+ let savedRow = await this.dataService.save<M>(
616
+ path,
617
+ updatedValues,
618
+ id,
619
+ resolvedCollection?.databaseId
620
+ );
621
+
622
+ if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
623
+ // 1. Global callbacks first
624
+ if (globalCallbacks?.afterRead) {
625
+ savedRow = await globalCallbacks.afterRead({
626
+ collection: resolvedCollection as unknown as CollectionConfig,
627
+ path,
628
+ row: savedRow,
629
+ context: contextForCallback
630
+ });
631
+ }
632
+ // 2. Collection callbacks second
633
+ if (callbacks?.afterRead) {
634
+ savedRow = await callbacks.afterRead({
635
+ collection: resolvedCollection as CollectionConfig<M>,
636
+ path,
637
+ row: savedRow,
638
+ context: contextForCallback
639
+ }) ?? savedRow;
640
+ }
641
+ // 3. Property callbacks third
642
+ if (propertyCallbacks?.afterRead) {
643
+ savedRow = await propertyCallbacks.afterRead({
644
+ collection: resolvedCollection as unknown as CollectionConfig,
645
+ path,
646
+ row: savedRow,
647
+ context: contextForCallback
648
+ });
649
+ }
650
+ }
651
+
652
+ const savedId = savedRow.id as string | number;
653
+ const { id: _savedId, ...savedValues } = savedRow;
654
+
655
+ if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
656
+ // 1. Global callbacks first
657
+ if (globalCallbacks?.afterSave) {
658
+ await globalCallbacks.afterSave({
659
+ collection: resolvedCollection as unknown as CollectionConfig,
660
+ path,
661
+ id: savedId,
662
+ values: savedValues,
663
+ previousValues: previousValuesForHistory,
664
+ status,
665
+ context: contextForCallback
666
+ });
667
+ }
668
+ // 2. Collection callbacks second
669
+ if (callbacks?.afterSave) {
670
+ await callbacks.afterSave({
671
+ collection: resolvedCollection as CollectionConfig<M>,
672
+ path,
673
+ id: savedId,
674
+ values: savedValues as Partial<M>,
675
+ previousValues: previousValuesForHistory,
676
+ status,
677
+ context: contextForCallback
678
+ });
679
+ }
680
+ // 3. Property callbacks third
681
+ if (propertyCallbacks?.afterSave) {
682
+ await propertyCallbacks.afterSave({
683
+ collection: resolvedCollection as unknown as CollectionConfig,
684
+ path,
685
+ id: savedId,
686
+ values: savedValues,
687
+ previousValues: previousValuesForHistory,
688
+ status,
689
+ context: contextForCallback
690
+ });
691
+ }
692
+ }
693
+
694
+ // Record row history (fire-and-forget, never blocks the save)
695
+ if (this.historyService && resolvedCollection?.history) {
696
+ this.historyService.recordHistory({
697
+ tableName: path,
698
+ id: savedId.toString(),
699
+ action: status === "new" ? "create" : "update",
700
+ values: savedValues as Record<string, unknown>,
701
+ previousValues: previousValuesForHistory as Record<string, unknown> | undefined,
702
+ updatedBy: this.user?.uid
703
+ });
704
+ }
705
+
706
+ // Notify real-time subscribers (deferred if inside a transaction)
707
+ if (this._deferNotifications) {
708
+ this._pendingNotifications.push({
709
+ path,
710
+ id: savedId.toString(),
711
+ row: savedRow,
712
+ databaseId: resolvedCollection?.databaseId
713
+ });
714
+ } else {
715
+ await this.realtimeService.notifyUpdate(
716
+ path,
717
+ savedId.toString(),
718
+ savedRow,
719
+ resolvedCollection?.databaseId
720
+ );
721
+ }
722
+
723
+ return savedRow;
724
+ } catch (error) {
725
+ if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
726
+ // 1. Global callbacks first
727
+ if (globalCallbacks?.afterSaveError) {
728
+ await globalCallbacks.afterSaveError({
729
+ collection: resolvedCollection as unknown as CollectionConfig,
730
+ path,
731
+ id: id || "unknown",
732
+ values: updatedValues,
733
+ previousValues: undefined,
734
+ status,
735
+ context: contextForCallback
736
+ });
737
+ }
738
+ // 2. Collection callbacks second
739
+ if (callbacks?.afterSaveError) {
740
+ await callbacks.afterSaveError({
741
+ collection: resolvedCollection as CollectionConfig<M>,
742
+ path,
743
+ id: id || "unknown",
744
+ values: updatedValues,
745
+ previousValues: undefined,
746
+ status,
747
+ context: contextForCallback
748
+ });
749
+ }
750
+ // 3. Property callbacks third
751
+ if (propertyCallbacks?.afterSaveError) {
752
+ await propertyCallbacks.afterSaveError({
753
+ collection: resolvedCollection as unknown as CollectionConfig,
754
+ path,
755
+ id: id || "unknown",
756
+ values: updatedValues,
757
+ previousValues: undefined,
758
+ status,
759
+ context: contextForCallback
760
+ });
761
+ }
762
+ }
763
+ throw error;
764
+ }
765
+ }
766
+
767
+ async delete<M extends Record<string, unknown>>({
768
+ row,
769
+ collection
770
+ }: DeleteProps<M>): Promise<void> {
771
+
772
+ const targetPath = row.path;
773
+ const targetRow: Record<string, unknown> = { id: row.id, ...(row.values ?? {}) };
774
+
775
+ // Resolve from backend registry to restore callbacks lost during WebSocket serialization
776
+ const {
777
+ collection: resolvedCollection,
778
+ callbacks,
779
+ globalCallbacks,
780
+ propertyCallbacks
781
+ } = this.resolveCollectionCallbacks(collection, targetPath);
782
+
783
+ const contextForCallback = this.buildCallContext();
784
+
785
+ if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
786
+ let preventDefault = false;
787
+ // 1. Global callbacks first
788
+ if (globalCallbacks?.beforeDelete) {
789
+ const result = await globalCallbacks.beforeDelete({
790
+ collection: resolvedCollection as unknown as CollectionConfig,
791
+ path: targetPath,
792
+ id: row.id,
793
+ row: targetRow,
794
+ context: contextForCallback
795
+ });
796
+ if (result === false) {
797
+ preventDefault = true;
798
+ }
799
+ }
800
+ // 2. Collection callbacks second
801
+ if (callbacks?.beforeDelete) {
802
+ const result = await callbacks.beforeDelete({
803
+ collection: resolvedCollection as CollectionConfig<M>,
804
+ path: targetPath,
805
+ id: row.id,
806
+ row: targetRow,
807
+ context: contextForCallback
808
+ });
809
+ if (result === false) {
810
+ preventDefault = true;
811
+ }
812
+ }
813
+ // 3. Property callbacks third
814
+ if (propertyCallbacks?.beforeDelete) {
815
+ const result = await propertyCallbacks.beforeDelete({
816
+ collection: resolvedCollection as unknown as CollectionConfig,
817
+ path: targetPath,
818
+ id: row.id,
819
+ row: targetRow,
820
+ context: contextForCallback
821
+ });
822
+ if (result === false) {
823
+ preventDefault = true;
824
+ }
825
+ }
826
+ if (preventDefault) {
827
+ return;
828
+ }
829
+ }
830
+
831
+ await this.dataService.delete(
832
+ targetPath,
833
+ row.id,
834
+ resolvedCollection?.databaseId
835
+ );
836
+
837
+ if (globalCallbacks?.afterDelete || callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
838
+ // 1. Global callbacks first
839
+ if (globalCallbacks?.afterDelete) {
840
+ await globalCallbacks.afterDelete({
841
+ collection: resolvedCollection as unknown as CollectionConfig,
842
+ path: targetPath,
843
+ id: row.id,
844
+ row: targetRow,
845
+ context: contextForCallback
846
+ });
847
+ }
848
+ // 2. Collection callbacks second
849
+ if (callbacks?.afterDelete) {
850
+ await callbacks.afterDelete({
851
+ collection: resolvedCollection as CollectionConfig<M>,
852
+ path: targetPath,
853
+ id: row.id,
854
+ row: targetRow,
855
+ context: contextForCallback
856
+ });
857
+ }
858
+ // 3. Property callbacks third
859
+ if (propertyCallbacks?.afterDelete) {
860
+ await propertyCallbacks.afterDelete({
861
+ collection: resolvedCollection as unknown as CollectionConfig,
862
+ path: targetPath,
863
+ id: row.id,
864
+ row: targetRow,
865
+ context: contextForCallback
866
+ });
867
+ }
868
+ }
869
+
870
+ // Record delete history (fire-and-forget)
871
+ if (this.historyService && resolvedCollection?.history) {
872
+ this.historyService.recordHistory({
873
+ tableName: targetPath,
874
+ id: row.id.toString(),
875
+ action: "delete",
876
+ values: row.values as Record<string, unknown> ?? {},
877
+ updatedBy: this.user?.uid
878
+ });
879
+ }
880
+
881
+ // Notify real-time subscribers (deferred if inside a transaction)
882
+ if (this._deferNotifications) {
883
+ this._pendingNotifications.push({
884
+ path: targetPath,
885
+ id: row.id.toString(),
886
+ row: null,
887
+ databaseId: resolvedCollection?.databaseId
888
+ });
889
+ } else {
890
+ await this.realtimeService.notifyUpdate(
891
+ targetPath,
892
+ row.id.toString(),
893
+ null,
894
+ resolvedCollection?.databaseId
895
+ );
896
+ }
897
+
898
+ }
899
+
900
+ async deleteAll(path: string): Promise<void> {
901
+ await this.dataService.deleteAll(path);
902
+ // Notify real-time subscribers of bulk change
903
+ await this.realtimeService.notifyUpdate(path, "*", null);
904
+ }
905
+
906
+ async checkUniqueField(
907
+ path: string,
908
+ name: string,
909
+ value: unknown,
910
+ id?: string,
911
+ collection?: CollectionConfig
912
+ ): Promise<boolean> {
913
+ return this.dataService.checkUniqueField(
914
+ path,
915
+ name,
916
+ value,
917
+ id,
918
+ collection?.databaseId
919
+ );
920
+ }
921
+
922
+ async count<M extends Record<string, unknown>>({
923
+ path,
924
+ collection,
925
+ filter,
926
+ searchString
927
+ }: FetchCollectionProps<M>): Promise<number> {
928
+ return this.dataService.count(
929
+ path,
930
+ {
931
+ filter,
932
+ searchString
933
+ }
934
+ );
935
+ }
936
+
937
+ private getTargetDb(databaseName?: string): DrizzleClient {
938
+ if (!databaseName || databaseName === this.poolManager?.defaultDatabaseName) {
939
+ return this.db;
940
+ }
941
+ if (!this.poolManager) {
942
+ throw new Error(
943
+ "Cross-database execution requires adminConnectionString to be configured in the backend."
944
+ );
945
+ }
946
+ return this.poolManager.getDrizzle(databaseName);
947
+ }
948
+
949
+ async executeSql(sqlText: string, options?: {
950
+ database?: string,
951
+ role?: string,
952
+ params?: unknown[]
953
+ }): Promise<Record<string, unknown>[]> {
954
+ if (!options?.database && !options?.role) {
955
+ return this.dataService.executeSql(sqlText, options?.params);
956
+ }
957
+
958
+ const targetDb = this.getTargetDb(options?.database);
959
+
960
+ try {
961
+ // Determine if we actually need to switch roles.
962
+ // Skip SET LOCAL ROLE when the requested role matches the current session role,
963
+ // as it's a no-op that can fail on managed Postgres setups where the connection
964
+ // user doesn't have permission to SET ROLE.
965
+ let needsRoleSwitch = false;
966
+ if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true" && !this._roleSwitchingDisabled) {
967
+ try {
968
+ const currentRoleResult = await targetDb.execute(drizzleSql.raw("SELECT current_user AS role"));
969
+ const currentRole = (currentRoleResult.rows?.[0] as Record<string, unknown>)?.role as string | undefined;
970
+ needsRoleSwitch = !!currentRole && currentRole !== options.role;
971
+ } catch {
972
+ // If we can't determine the current role, attempt the switch anyway
973
+ needsRoleSwitch = true;
974
+ }
975
+ }
976
+
977
+ if (needsRoleSwitch && options?.role) {
978
+ const safeRole = options.role.replace(/"/g, "\"\"");
979
+ try {
980
+ return await targetDb.transaction(async (tx) => {
981
+ await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
982
+ let result;
983
+ if (options?.params && options.params.length > 0) {
984
+ const parts = sqlText.split(/\$(\d+)/);
985
+ const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
986
+ for (let i = 0; i < parts.length; i++) {
987
+ if (i % 2 === 0) {
988
+ if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
989
+ } else {
990
+ chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
991
+ }
992
+ }
993
+ result = await tx.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
994
+ } else {
995
+ result = await tx.execute(drizzleSql.raw(sqlText));
996
+ }
997
+ return result.rows as Record<string, unknown>[];
998
+ });
999
+ } catch (roleError: unknown) {
1000
+ if (isRoleSwitchingPermissionError(roleError)) {
1001
+ logger.warn(
1002
+ `[PostgresBackendDriver] SET LOCAL ROLE "${safeRole}" failed — ` +
1003
+ `the connection user lacks permission. Falling back to executing ` +
1004
+ `without role switching. To suppress this warning, set ` +
1005
+ `DISABLE_DB_ROLE_SWITCHING=true in your .env file.`
1006
+ );
1007
+ this._roleSwitchingDisabled = true;
1008
+ // Fall through to execute without role switching below
1009
+ } else {
1010
+ throw roleError;
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ let result;
1016
+ if (options?.params && options.params.length > 0) {
1017
+ const parts = sqlText.split(/\$(\d+)/);
1018
+ const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
1019
+ for (let i = 0; i < parts.length; i++) {
1020
+ if (i % 2 === 0) {
1021
+ if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
1022
+ } else {
1023
+ chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
1024
+ }
1025
+ }
1026
+ result = await targetDb.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
1027
+ } else {
1028
+ result = await targetDb.execute(drizzleSql.raw(sqlText));
1029
+ }
1030
+ return result.rows as Record<string, unknown>[];
1031
+ } catch (error: unknown) {
1032
+ const msg = error instanceof Error ? error.message : String(error);
1033
+ // Provide a user-friendly message for connection/auth errors
1034
+ if (msg.includes("pg_hba.conf") || msg.includes("no encryption") || msg.includes("connection refused")) {
1035
+ const dbName = options?.database || "unknown";
1036
+ throw new Error(`Cannot connect to database "${dbName}": the server rejected the connection. This database may require SSL or is not accessible from this host.`);
1037
+ }
1038
+ throw error;
1039
+ }
1040
+ }
1041
+
1042
+ async fetchAvailableDatabases(): Promise<string[]> {
1043
+ // Exclude template databases, Cloud SQL internal databases, and the default 'postgres' system db
1044
+ const result = await this.executeSql(
1045
+ `SELECT datname FROM pg_database
1046
+ WHERE datistemplate = false
1047
+ AND datname NOT IN ('postgres', 'cloudsqladmin', '_cloudsqladmin')
1048
+ ORDER BY datname;`
1049
+ );
1050
+ const databases = result.map((r: Record<string, unknown>) => r.datname as string);
1051
+ // Ensure the current connected database is always first in the list
1052
+ const currentDb = this.poolManager?.defaultDatabaseName;
1053
+ if (currentDb && !databases.includes(currentDb)) {
1054
+ databases.unshift(currentDb);
1055
+ } else if (currentDb) {
1056
+ // Move it to the front
1057
+ const idx = databases.indexOf(currentDb);
1058
+ if (idx > 0) {
1059
+ databases.splice(idx, 1);
1060
+ databases.unshift(currentDb);
1061
+ }
1062
+ }
1063
+ return databases;
1064
+ }
1065
+
1066
+ async fetchAvailableRoles(): Promise<string[]> {
1067
+ const result = await this.executeSql(
1068
+ "SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;"
1069
+ );
1070
+ return result.map((r: Record<string, unknown>) => r.rolname as string);
1071
+ }
1072
+
1073
+ async fetchCurrentDatabase(): Promise<string | undefined> {
1074
+ return this.poolManager?.defaultDatabaseName;
1075
+ }
1076
+
1077
+ /**
1078
+ * Fetch public tables that are not yet mapped to a collection.
1079
+ * Excludes internal tables (_rebase_*, _auth_*, auth tables, etc.)
1080
+ * and junction/connection tables used for many-to-many relations.
1081
+ */
1082
+ async fetchUnmappedTables(mappedPaths?: string[]): Promise<string[]> {
1083
+ const result = await this.executeSql(`
1084
+ SELECT table_name
1085
+ FROM information_schema.tables
1086
+ WHERE table_schema = 'public'
1087
+ AND table_type = 'BASE TABLE'
1088
+ ORDER BY table_name;
1089
+ `);
1090
+
1091
+ const allTables = result
1092
+ .map((r: Record<string, unknown>) => r.table_name as string)
1093
+ .filter((name: string) => classifyTable(name, "public") !== "rebase-internal");
1094
+
1095
+ // Detect junction tables: tables where every column is part of a foreign key.
1096
+ // These are typically many-to-many connection tables and shouldn't be suggested.
1097
+ let junctionTables = new Set<string>();
1098
+ try {
1099
+ junctionTables = await detectJunctionTables(this.executeSql.bind(this));
1100
+ } catch (e) {
1101
+ logger.warn("Could not detect junction tables", { error: e });
1102
+ }
1103
+
1104
+ const filteredTables = allTables.filter(name => !junctionTables.has(name));
1105
+
1106
+ if (!mappedPaths || mappedPaths.length === 0) return filteredTables;
1107
+
1108
+ const mappedSet = new Set(mappedPaths.map(p => p.toLowerCase()));
1109
+ return filteredTables.filter((name: string) => !mappedSet.has(name.toLowerCase()));
1110
+ }
1111
+
1112
+ /**
1113
+ * Fetch metadata for a given table from information_schema (columns, policies, constraints).
1114
+ */
1115
+ async fetchTableMetadata(tableName: string): Promise<TableMetadata> {
1116
+ // Sanitize table name as defense-in-depth (parameterized below)
1117
+ const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
1118
+
1119
+ // 1. Fetch Columns
1120
+ const result = await this.db.execute(drizzleSql`
1121
+ SELECT column_name, data_type, udt_name, is_nullable, column_default, character_maximum_length
1122
+ FROM information_schema.columns
1123
+ WHERE table_schema = 'public'
1124
+ AND table_name = ${safeName}
1125
+ ORDER BY ordinal_position
1126
+ `);
1127
+ const columns = result.rows as Record<string, unknown>[];
1128
+
1129
+ // Also fetch enum values for any USER-DEFINED columns
1130
+ const enumColumns = columns.filter((c) => c.data_type === "USER-DEFINED");
1131
+ if (enumColumns.length > 0) {
1132
+ for (const col of enumColumns) {
1133
+ try {
1134
+ const enumResult = await this.db.execute(drizzleSql`
1135
+ SELECT e.enumlabel
1136
+ FROM pg_type t
1137
+ JOIN pg_enum e ON t.oid = e.enumtypid
1138
+ WHERE t.typname = ${col.udt_name as string}
1139
+ ORDER BY e.enumsortorder
1140
+ `);
1141
+ col.enum_values = (enumResult.rows as Record<string, unknown>[]).map(e => e.enumlabel);
1142
+ } catch {
1143
+ col.enum_values = [];
1144
+ }
1145
+ }
1146
+ }
1147
+ // SAFETY: Raw SQL result rows are typed as QueryResultRow[]; the query shape matches TableColumnInfo
1148
+ const typedColumns = columns as unknown as TableColumnInfo[];
1149
+
1150
+ // 2. Fetch Foreign Keys
1151
+ const fkResult = await this.db.execute(drizzleSql`
1152
+ SELECT
1153
+ kcu.column_name as column_name,
1154
+ ccu.table_name AS foreign_table_name,
1155
+ ccu.column_name AS foreign_column_name
1156
+ FROM
1157
+ information_schema.table_constraints AS tc
1158
+ JOIN information_schema.key_column_usage AS kcu
1159
+ ON tc.constraint_name = kcu.constraint_name
1160
+ AND tc.table_schema = kcu.table_schema
1161
+ JOIN information_schema.constraint_column_usage AS ccu
1162
+ ON ccu.constraint_name = tc.constraint_name
1163
+ AND ccu.table_schema = tc.table_schema
1164
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = ${safeName};
1165
+ `);
1166
+ // SAFETY: Raw SQL result rows match TableForeignKeyInfo shape from the SELECT aliases
1167
+ const foreignKeys = fkResult.rows as TableForeignKeyInfo[];
1168
+
1169
+ // 3. Fetch Junction Tables (Many-to-Many)
1170
+ // A simple junction table is one that has foreign keys to our table and other tables
1171
+ const junctionsResult = await this.db.execute(drizzleSql`
1172
+ SELECT
1173
+ tc1.table_name as junction_table_name,
1174
+ kcu1.column_name as source_column_name,
1175
+ ccu2.table_name as target_table_name,
1176
+ kcu2.column_name as target_column_name
1177
+ FROM information_schema.table_constraints tc1
1178
+ JOIN information_schema.key_column_usage kcu1 ON tc1.constraint_name = kcu1.constraint_name
1179
+ JOIN information_schema.constraint_column_usage ccu1 ON ccu1.constraint_name = tc1.constraint_name
1180
+ JOIN information_schema.table_constraints tc2 ON tc1.table_name = tc2.table_name AND tc2.constraint_type = 'FOREIGN KEY'
1181
+ JOIN information_schema.key_column_usage kcu2 ON tc2.constraint_name = kcu2.constraint_name
1182
+ JOIN information_schema.constraint_column_usage ccu2 ON ccu2.constraint_name = tc2.constraint_name
1183
+ WHERE tc1.constraint_type = 'FOREIGN KEY'
1184
+ AND ccu1.table_name = ${safeName}
1185
+ AND ccu2.table_name != ${safeName};
1186
+ `);
1187
+ // SAFETY: Raw SQL result rows match TableJunctionInfo shape from the SELECT aliases
1188
+ const junctions = junctionsResult.rows as TableJunctionInfo[];
1189
+
1190
+ // 4. Fetch RLS Policies
1191
+ const policiesResult = await this.db.execute(drizzleSql`
1192
+ SELECT
1193
+ polname as policy_name,
1194
+ polcmd as cmd,
1195
+ polroles::regrole[]::text[] as roles,
1196
+ pg_get_expr(polqual, polrelid) as qual,
1197
+ pg_get_expr(polwithcheck, polrelid) as with_check
1198
+ FROM pg_policy
1199
+ WHERE polrelid = (SELECT oid FROM pg_class WHERE relname = ${safeName} AND relnamespace = 'public'::regnamespace);
1200
+ `);
1201
+ // SAFETY: Raw SQL result rows match TablePolicyInfo shape from the SELECT aliases
1202
+ const policies = policiesResult.rows as TablePolicyInfo[];
1203
+
1204
+ return {
1205
+ columns: typedColumns,
1206
+ foreignKeys,
1207
+ junctions,
1208
+ policies
1209
+ };
1210
+ }
1211
+
1212
+ private generateSubscriptionId(): string {
1213
+ return `sub_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
1214
+ }
1215
+
1216
+ /**
1217
+ * Create a new delegate instance with authenticated context.
1218
+ * Starts a transaction and sets the current_user_id and current_user_roles
1219
+ * configuration parameters for PostgreSQL Row Level Security.
1220
+ */
1221
+ async withAuth(user: User): Promise<DataDriver> {
1222
+ return new AuthenticatedPostgresBackendDriver(this, user);
1223
+ }
1224
+ }
1225
+
1226
+ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1227
+ key = "postgres";
1228
+ initialised = true;
1229
+
1230
+ public user: User;
1231
+ public data: RebaseSdkData;
1232
+
1233
+ constructor(
1234
+ public delegate: PostgresBackendDriver,
1235
+ user: User
1236
+ ) {
1237
+ this.user = user;
1238
+ this.data = buildSdkData(this);
1239
+
1240
+ // Delegate admin ops to the base driver (no RLS wrapping for admin)
1241
+ this.admin = delegate.admin;
1242
+ }
1243
+
1244
+ /**
1245
+ * Typed admin capabilities — delegates to the base driver.
1246
+ */
1247
+ admin: DatabaseAdmin;
1248
+
1249
+ get restFetchService(): RestFetchService {
1250
+ return {
1251
+ // The base driver's restFetchService already applies the afterRead
1252
+ // pipeline, so we only wrap it in the authenticated transaction here.
1253
+ fetchCollectionForRest: async (collectionPath, options, include) => {
1254
+ return this.withTransaction(async (delegate) => {
1255
+ return delegate.restFetchService.fetchCollectionForRest(collectionPath, options, include);
1256
+ }, { accessMode: "read only" });
1257
+ },
1258
+ fetchOneForRest: async (collectionPath, id, include, databaseId) => {
1259
+ return this.withTransaction(async (delegate) => {
1260
+ return delegate.restFetchService.fetchOneForRest(collectionPath, id, include, databaseId);
1261
+ }, { accessMode: "read only" });
1262
+ }
1263
+ };
1264
+ }
1265
+
1266
+ private async withTransaction<T>(
1267
+ operation: (delegate: PostgresBackendDriver) => Promise<T>,
1268
+ options?: {
1269
+ accessMode?: "read only" | "read write";
1270
+ isolationLevel?: "read uncommitted" | "read committed" | "repeatable read" | "serializable"
1271
+ }
1272
+ ): Promise<T> {
1273
+ const pendingNotifications: PostgresBackendDriver["_pendingNotifications"] = [];
1274
+
1275
+ const result = await this.delegate.db.transaction(async (tx) => {
1276
+ let userId = this.user?.uid;
1277
+ if (!userId) {
1278
+ logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
1279
+ userId = "anonymous";
1280
+ }
1281
+
1282
+ const userRoles = this.user?.roles ?? [];
1283
+ if (!this.user?.roles) {
1284
+ logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
1285
+ }
1286
+
1287
+ // Set the RLS GUCs and downgrade to the restricted user role so RLS
1288
+ // binds every statement in this transaction — reads AND writes.
1289
+ // This is user context: the collection's securityRules are the
1290
+ // authorization model. The server context (base driver /
1291
+ // dataAsAdmin) never reaches here, so it stays on the owner
1292
+ // connection and bypasses RLS. The GUCs are transaction-local and
1293
+ // remain readable after the role switch, so `auth.uid()` /
1294
+ // `auth.roles()` in policies still resolve.
1295
+ //
1296
+ // Fails closed: if the switch cannot be performed, the transaction
1297
+ // aborts rather than falling back to an RLS-bypassing connection.
1298
+ await applyAuthContext(tx, { userId, roles: userRoles }, this.delegate.rlsUserRole);
1299
+
1300
+ const txEntityService = new DataService(tx, this.delegate.registry);
1301
+ const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
1302
+
1303
+ txDelegate.dataService = txEntityService;
1304
+ txDelegate._deferNotifications = true;
1305
+ txDelegate._pendingNotifications = pendingNotifications;
1306
+ txDelegate.client = this.delegate.client;
1307
+
1308
+ return await operation(txDelegate);
1309
+ }, options);
1310
+
1311
+ for (const notification of pendingNotifications) {
1312
+ try {
1313
+ await this.delegate.realtimeService.notifyUpdate(
1314
+ notification.path,
1315
+ notification.id,
1316
+ notification.row,
1317
+ notification.databaseId
1318
+ );
1319
+ } catch (e) {
1320
+ logger.error("[DataDriver] Error flushing deferred notification", { error: e });
1321
+ }
1322
+ }
1323
+
1324
+ return result;
1325
+ }
1326
+
1327
+ async fetchCollection<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
1328
+ return this.withTransaction((delegate) => delegate.fetchCollection(props), { accessMode: "read only" });
1329
+ }
1330
+
1331
+ /**
1332
+ * Injects the authenticated user's context into the most recently
1333
+ * registered realtime subscription so RLS-aware polling can apply.
1334
+ */
1335
+ private injectAuthContext(unsubscribe: () => void): () => void {
1336
+ const authContext = {
1337
+ userId: this.user?.uid || "anonymous",
1338
+ roles: this.user?.roles ?? []
1339
+ };
1340
+ const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
1341
+ const lastEntry = entries[entries.length - 1];
1342
+ const lastSub = lastEntry?.[1] as Record<string, unknown> | undefined;
1343
+ if (lastSub && lastSub.clientId === "driver") {
1344
+ lastSub.authContext = authContext;
1345
+ }
1346
+ return unsubscribe;
1347
+ }
1348
+
1349
+ listenCollection<M extends Record<string, unknown>>(props: ListenCollectionProps<M>): () => void {
1350
+ return this.injectAuthContext(this.delegate.listenCollection(props));
1351
+ }
1352
+
1353
+ async fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
1354
+ return this.withTransaction((delegate) => delegate.fetchOne(props), { accessMode: "read only" });
1355
+ }
1356
+
1357
+ listenOne<M extends Record<string, unknown>>(props: ListenOneProps<M>): () => void {
1358
+ return this.injectAuthContext(this.delegate.listenOne(props));
1359
+ }
1360
+
1361
+ async save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
1362
+ return this.withTransaction((delegate) => delegate.save(props));
1363
+ }
1364
+
1365
+ async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
1366
+ return this.withTransaction((delegate) => delegate.delete(props));
1367
+ }
1368
+
1369
+ async deleteAll(path: string): Promise<void> {
1370
+ return this.withTransaction((delegate) => delegate.deleteAll(path));
1371
+ }
1372
+
1373
+ async checkUniqueField(
1374
+ path: string,
1375
+ name: string,
1376
+ value: unknown,
1377
+ id?: string,
1378
+ collection?: CollectionConfig
1379
+ ): Promise<boolean> {
1380
+ return this.withTransaction((delegate) => delegate.checkUniqueField(path, name, value, id, collection), { accessMode: "read only" });
1381
+ }
1382
+
1383
+ async count<M extends Record<string, unknown>>(props: FetchCollectionProps<M>): Promise<number> {
1384
+ return this.withTransaction((delegate) => delegate.count(props), { accessMode: "read only" });
1385
+ }
1386
+
1387
+ }