@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,581 @@
1
+ /**
2
+ * PostgresBootstrapper
3
+ *
4
+ * Implements the `BackendBootstrapper` interface for PostgreSQL.
5
+ */
6
+
7
+ import { getTableName, isTable, Relations, sql } from "drizzle-orm";
8
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
+ import { PgEnum, PgTable } from "drizzle-orm/pg-core";
10
+ import type { RebasePgTable } from "./types";
11
+ import {
12
+ type AuthAdapter,
13
+ BackendBootstrapper,
14
+ BootstrappedAuth,
15
+ DatabaseAdmin,
16
+ type DataDriver,
17
+ CollectionConfig,
18
+ type HistoryConfig,
19
+ InitializedDriver,
20
+ RealtimeProvider
21
+ } from "@rebasepro/types";
22
+ import { PostgresBackendDriver } from "./PostgresBackendDriver";
23
+ import { RealtimeService } from "./services/realtimeService";
24
+ import { DatabasePoolManager } from "./databasePoolManager";
25
+ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
26
+ import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server";
27
+ import { getTableName as getCollectionTableName } from "@rebasepro/common";
28
+ import { ensureAuthTablesExist } from "./auth/ensure-tables";
29
+ import { AuthSchemaTables, PostgresAuthRepository, UserService } from "./auth/services";
30
+ import { createAuthSchema } from "./schema/auth-schema";
31
+ import { HistoryService } from "./history/HistoryService";
32
+ import { ensureHistoryTableExists } from "./history/ensure-history-table";
33
+ import { patchPgArrayNullSafety } from "./utils/pg-array-null-patch";
34
+ import { buildCollectionsFromSchema, introspectSchema, readRlsStatus } from "./schema/introspect-runtime";
35
+ import { buildDrizzleTablesFromSchema, buildDrizzleRelationsFromSchema } from "./schema/dynamic-tables";
36
+ import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
37
+ import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cdc";
38
+
39
+ export interface PostgresDriverConfig {
40
+ connectionString?: string;
41
+ adminConnectionString?: string;
42
+ readConnectionString?: string;
43
+ connection?: unknown;
44
+ schema?: {
45
+ tables?: Record<string, unknown>;
46
+ enums?: Record<string, unknown>;
47
+ relations?: Record<string, unknown>;
48
+ };
49
+ /**
50
+ * PostgreSQL schema to read when deriving collections from the database
51
+ * (BaaS mode). Defaults to `public`.
52
+ */
53
+ introspectionSchema?: string;
54
+ }
55
+
56
+ /**
57
+ * Opaque internals bag that PostgresBootstrapper stores during `initializeDriver()`
58
+ * and re-uses in subsequent lifecycle hooks.
59
+ */
60
+ export interface PostgresDriverInternals {
61
+ db: NodePgDatabase<any>;
62
+ readDb?: NodePgDatabase<any>;
63
+ registry: PostgresCollectionRegistry;
64
+ realtimeService: RealtimeService;
65
+ driver: PostgresBackendDriver;
66
+ poolManager?: DatabasePoolManager;
67
+ }
68
+
69
+ // Re-export from shared CLI error utilities
70
+ import { isEconnrefused } from "./cli-errors";
71
+
72
+ /**
73
+ * Default PostgreSQL bootstrapper.
74
+ *
75
+ * Use it to register Postgres with `initializeRebaseBackend()`:
76
+ * ```typescript
77
+ * initializeRebaseBackend({
78
+ * ...config,
79
+ * bootstrappers: [postgresBootstrapper()]
80
+ * });
81
+ * ```
82
+ */
83
+ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): BackendBootstrapper {
84
+ return {
85
+ type: "postgres",
86
+
87
+ async initializeDriver(config: unknown): Promise<InitializedDriver> {
88
+ // config is passed from coordinator, we merge it with our internal pgConfig if needed
89
+ // Currently config from init.ts is `{ collections, collectionRegistry, mode }`
90
+ const { collections, collectionRegistry, mode, baas } = config as {
91
+ collections?: CollectionConfig[];
92
+ collectionRegistry?: unknown;
93
+ mode?: "cms" | "baas";
94
+ baas?: { unprotectedTables?: "exclude" | "serve" };
95
+ };
96
+ // Secure by default: a table with no RLS is not served.
97
+ const unprotectedTables = baas?.unprotectedTables ?? "exclude";
98
+
99
+ const connection = pgConfig.connection;
100
+ const rawClient = (connection && typeof connection === "object" && "$client" in connection
101
+ ? (connection as Record<string, unknown>).$client
102
+ : connection) as import("pg").Pool;
103
+
104
+ // ── BaaS mode: derive the schema from the database ───────────────
105
+ // No collection files and no generated drizzle schema exist, so read
106
+ // the live database and build both from what is actually there.
107
+ let introspectedCollections: CollectionConfig[] | undefined;
108
+ let introspectedTables: Record<string, PgTable> | undefined;
109
+ let introspectedRelations: Record<string, Relations> | undefined;
110
+ if (mode === "baas" && (!collections || collections.length === 0)) {
111
+ const pgSchemaName = pgConfig.introspectionSchema ?? "public";
112
+ const schema = await introspectSchema(rawClient, pgSchemaName);
113
+
114
+ // ── Only serve what the database protects ────────────────
115
+ // Requests run as rebase_user, which is granted DML on the
116
+ // schema. A table with RLS disabled therefore has no
117
+ // authorization model at all: serving it hands every row to
118
+ // every authenticated user. baas never runs `db push`, so
119
+ // nothing here would have enabled RLS on the user's behalf.
120
+ const rlsStatus = await readRlsStatus(rawClient, pgSchemaName);
121
+ const unprotected = [...schema.tablesMap.keys()].filter(
122
+ (t) => !schema.joinTables.has(t) && !rlsStatus.get(t)?.rlsEnabled
123
+ );
124
+ const policyless = [...schema.tablesMap.keys()].filter(
125
+ (t) => !schema.joinTables.has(t) && rlsStatus.get(t)?.rlsEnabled && rlsStatus.get(t)?.policyCount === 0
126
+ );
127
+
128
+ if (unprotected.length > 0) {
129
+ if (unprotectedTables === "serve") {
130
+ logger.warn(
131
+ `🔓 [rls] Serving ${unprotected.length} table(s) with row-level security DISABLED: ${unprotected.join(", ")}. ` +
132
+ "Every authenticated request can read and write every row of these. " +
133
+ "This is baas.unprotectedTables: \"serve\"."
134
+ );
135
+ } else {
136
+ logger.warn(
137
+ `🔒 [rls] Not serving ${unprotected.length} table(s) — row-level security is disabled, so they have no ` +
138
+ `authorization model: ${unprotected.join(", ")}\n` +
139
+ unprotected.map((t) => ` ALTER TABLE "${pgSchemaName}"."${t}" ENABLE ROW LEVEL SECURITY; -- then add a policy`).join("\n") +
140
+ "\n Set baas.unprotectedTables: \"serve\" to expose them regardless."
141
+ );
142
+ for (const t of unprotected) schema.tablesMap.delete(t);
143
+ }
144
+ }
145
+ if (policyless.length > 0) {
146
+ // Legal, and silently returns nothing — worth saying out loud,
147
+ // since an empty table reads exactly like one with no rows.
148
+ logger.warn(
149
+ `🔒 [rls] ${policyless.length} table(s) have RLS enabled but no policies, so they will return no rows: ${policyless.join(", ")}`
150
+ );
151
+ }
152
+
153
+ introspectedCollections = buildCollectionsFromSchema(schema, pgSchemaName);
154
+ introspectedTables = buildDrizzleTablesFromSchema(schema.tablesMap, pgSchemaName);
155
+ // Without these, drizzle's relational path can't resolve the
156
+ // relations the collections above advertise.
157
+ introspectedRelations = buildDrizzleRelationsFromSchema(schema.tablesMap, introspectedTables);
158
+ logger.info(
159
+ `🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map(c => c.slug).join(", ")}]`
160
+ );
161
+ }
162
+
163
+ const activeCollections = introspectedCollections ?? collections;
164
+
165
+ // Create a fresh registry for this driver
166
+ const registry = new PostgresCollectionRegistry();
167
+ if (activeCollections) {
168
+ registry.registerMultiple(activeCollections);
169
+ logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map(c => c.slug).join(", ")}]`);
170
+ }
171
+
172
+ const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
173
+
174
+ // Register tables
175
+ if (schemaTables) {
176
+ Object.values(schemaTables).forEach((table) => {
177
+ if (isTable(table)) {
178
+ const tableName = getTableName(table);
179
+ registry.registerTable(table as PgTable, tableName);
180
+ }
181
+ });
182
+ }
183
+
184
+ if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums as Record<string, PgEnum<[string, ...string[]]>>);
185
+
186
+ const schemaRelations = introspectedRelations ?? (pgConfig.schema?.relations as Record<string, Relations> | undefined);
187
+ if (schemaRelations) registry.registerRelations(schemaRelations);
188
+
189
+ // Patch Drizzle's PgArray columns to handle NULL values safely.
190
+ // Drizzle's mapFromDriverValue crashes with "value.map is not a function"
191
+ // when a native array column (text[], integer[], etc.) contains NULL.
192
+ if (schemaTables) {
193
+ patchPgArrayNullSafety(schemaTables as Record<string, unknown>);
194
+ }
195
+
196
+ // Build schema-aware Drizzle connection
197
+ const mergedSchema: Record<string, unknown> = {
198
+ ...schemaTables,
199
+ ...(schemaRelations || {})
200
+ };
201
+ const { drizzle: createDrizzle } = await import("drizzle-orm/node-postgres");
202
+ const schemaAwareDb = createDrizzle(rawClient, { schema: mergedSchema });
203
+
204
+ // Verify connection — fail fast if the database is unreachable
205
+ try {
206
+ await schemaAwareDb.execute(sql`SELECT 1`);
207
+ } catch (err: unknown) {
208
+ const isConnectionRefused = isEconnrefused(err);
209
+ if (isConnectionRefused) {
210
+ // Parse host/port from connection string for a helpful message
211
+ let hostInfo = pgConfig.connectionString || "unknown";
212
+ try {
213
+ const parsed = new URL(pgConfig.connectionString || "");
214
+ hostInfo = `${parsed.hostname}:${parsed.port || 5432}`;
215
+ } catch { /* use raw string */ }
216
+
217
+ const message =
218
+ `\n` +
219
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
220
+ ` ❌ Cannot connect to PostgreSQL at ${hostInfo}\n` +
221
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
222
+ `\n` +
223
+ ` The database server is not running or is not accepting\n` +
224
+ ` connections. Common fixes:\n` +
225
+ `\n` +
226
+ ` • brew services start postgresql@18\n` +
227
+ ` • docker compose up -d postgres\n` +
228
+ ` • Verify DATABASE_URL in your .env file\n` +
229
+ `\n` +
230
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
231
+ logger.error(message);
232
+ throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
233
+ }
234
+
235
+ // For other errors (timeouts, auth failures, etc.) warn but continue —
236
+ // the pool may recover on subsequent attempts.
237
+ logger.error("❌ Failed to connect to PostgreSQL", { error: err });
238
+ logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
239
+ }
240
+
241
+ // Create services
242
+ const realtimeService = new RealtimeService(schemaAwareDb, registry);
243
+
244
+ // Initialize read replica connection if configured
245
+ let readDb: import("drizzle-orm/node-postgres").NodePgDatabase<any> | undefined;
246
+ const readUrl = process.env.DATABASE_READ_URL;
247
+ if (readUrl && readUrl !== pgConfig.connectionString) {
248
+ try {
249
+ const { createReadReplicaConnection } = await import("./connection");
250
+ const readResources = createReadReplicaConnection(readUrl, mergedSchema);
251
+ readDb = readResources.db;
252
+ logger.info("📖 [PostgresBootstrapper] Read replica connection established");
253
+ } catch (err) {
254
+ logger.warn("⚠️ Could not connect to read replica, falling back to primary for all queries", { error: err });
255
+ }
256
+ }
257
+ const poolManager = pgConfig.adminConnectionString
258
+ ? new DatabasePoolManager(pgConfig.adminConnectionString)
259
+ : undefined;
260
+ const driver = new PostgresBackendDriver(schemaAwareDb, realtimeService, registry, undefined, poolManager);
261
+ realtimeService.setDataDriver(driver);
262
+
263
+ // ── RLS enforcement (user context) ───────────────────────────────
264
+ // Authenticated requests are authorized entirely by RLS policies,
265
+ // but a privileged connection (superuser / BYPASSRLS / table owner)
266
+ // bypasses RLS. Detect the posture and, when privileged, provision
267
+ // the restricted `rebase_user` role and route authenticated
268
+ // requests (reads AND writes) through it. The server context (base
269
+ // driver / dataAsAdmin / auth flows) stays on the owner connection
270
+ // and bypasses. Default-on: a privileged connection that cannot be
271
+ // isolated fails the boot rather than serving unenforced requests.
272
+ {
273
+ const runSql: RawSqlRunner = async (text) => {
274
+ const res = await schemaAwareDb.execute(sql.raw(text));
275
+ return (res.rows ?? []) as Record<string, unknown>[];
276
+ };
277
+ const posture = await detectConnectionPosture(runSql);
278
+ if (posture.privileged) {
279
+ const collectionSchemas = registry.getCollections()
280
+ .map((c) => (c as { schema?: string }).schema)
281
+ .filter((s): s is string => typeof s === "string");
282
+ await ensureAppRole(runSql, ["public", "rebase", "auth", ...collectionSchemas]);
283
+ driver.rlsUserRole = REBASE_USER_ROLE;
284
+ realtimeService.rlsUserRole = REBASE_USER_ROLE;
285
+ logger.info(`🔐 RLS enforcement active: authenticated requests run as "${REBASE_USER_ROLE}" (connection "${posture.role}" bypasses RLS: ${posture.superuser ? "superuser" : posture.bypassRLS ? "BYPASSRLS" : "table owner"})`);
286
+ if (posture.superuser || posture.bypassRLS) {
287
+ logger.warn(
288
+ `⚠️ The database connection runs as ${posture.superuser ? "a superuser" : "a BYPASSRLS role"} ("${posture.role}"). ` +
289
+ `User requests are isolated via SET LOCAL ROLE, but connect as a non-superuser ` +
290
+ `table-owner role in production so the server/owner context is least-privilege.`
291
+ );
292
+ }
293
+ } else {
294
+ logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
295
+ }
296
+
297
+ // Independent of posture: a policy targeting a role the request
298
+ // never runs as filters every row, so the collection reads as
299
+ // empty rather than erroring. Applies to both branches — the
300
+ // role that matters is whichever one requests actually use.
301
+ await validatePolicyPgRoles(
302
+ runSql,
303
+ registry.getCollections() as never,
304
+ driver.rlsUserRole ?? posture.role
305
+ );
306
+ }
307
+
308
+ // Ensure branch metadata table exists when branching is available
309
+ if (driver.branchService) {
310
+ try {
311
+ await driver.branchService.ensureBranchMetadataTable();
312
+ } catch (err) {
313
+ logger.warn("⚠️ Could not initialize branch metadata table", { error: err });
314
+ }
315
+ }
316
+
317
+ // ── Realtime change source ───────────────────────────────────────
318
+ // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
319
+ const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
320
+
321
+ // Database-level Change Data Capture. When active, realtime events are
322
+ // emitted for EVERY committed write — including ones that bypass the
323
+ // Rebase API (psql, another service's cron, raw SQL, the Studio SQL
324
+ // editor) — matching Supabase Realtime's WAL-tailing model. CDC also
325
+ // becomes the cross-instance channel, so the legacy per-mutation
326
+ // LISTEN/NOTIFY is not started alongside it.
327
+ // REALTIME_CDC=auto → default: enable where the connection supports
328
+ // it; silently fall back to app-level otherwise
329
+ // REALTIME_CDC=trigger → force trigger-based capture (warns if it can't)
330
+ // REALTIME_CDC=wal → prefer WAL logical replication (degrades to trigger)
331
+ // REALTIME_CDC=off → app-level realtime only
332
+ const validModes = new Set(["auto", "wal", "trigger", "off"]);
333
+ let cdcMode = (process.env.REALTIME_CDC || "auto").trim().toLowerCase();
334
+ if (!validModes.has(cdcMode)) {
335
+ logger.warn(`⚠️ [CDC] Unknown REALTIME_CDC value "${cdcMode}" — expected auto|wal|trigger|off. Defaulting to "auto".`);
336
+ cdcMode = "auto";
337
+ }
338
+
339
+ // `auto` tries CDC but treats "can't" as a normal outcome (info log);
340
+ // explicit trigger/wal was asked for, so a failure is worth a warning.
341
+ const wantsCdc = cdcMode !== "off";
342
+ const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
343
+ let cdcEnabled = false;
344
+
345
+ if (wantsCdc && !directUrl) {
346
+ const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
347
+ if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
348
+ else logger.info(`ℹ️ [CDC] Using app-level realtime — ${reason}.`);
349
+ } else if (wantsCdc && directUrl) {
350
+ if (cdcMode === "wal") {
351
+ // Native WAL logical-replication streaming requires wal_level=logical,
352
+ // a replication-privileged role and a replication slot, none of which
353
+ // are bundled with this adapter yet. Degrade to trigger-based capture,
354
+ // which provides equivalent database-level coverage.
355
+ logger.warn(
356
+ "⚠️ [CDC] REALTIME_CDC=wal: native WAL streaming is not bundled in this build; " +
357
+ "using trigger-based change capture instead (equivalent database-level coverage)."
358
+ );
359
+ }
360
+ try {
361
+ const cdcRunSql: RawSqlRunner = async (text) => {
362
+ const res = await schemaAwareDb.execute(sql.raw(text));
363
+ return (res.rows ?? []) as Record<string, unknown>[];
364
+ };
365
+ const cdcTables: CdcTableRef[] = registry.getCollections()
366
+ .map((c) => ({
367
+ schema: (c as { schema?: string }).schema ?? "public",
368
+ table: getCollectionTableName(c)
369
+ }))
370
+ .filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
371
+ // Provisioning throws only when the connection can't create the
372
+ // trigger function (insufficient privilege); enableCdc throws when
373
+ // the LISTEN connection can't be established. Either → fall back.
374
+ await provisionTriggerCdc(cdcRunSql, cdcTables);
375
+ await realtimeService.enableCdc(directUrl);
376
+ cdcEnabled = true;
377
+ logger.info(
378
+ `📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). ` +
379
+ `All writes now emit realtime events regardless of origin.`
380
+ );
381
+ } catch (err) {
382
+ if (explicitCdc) {
383
+ logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
384
+ } else {
385
+ logger.info(
386
+ "ℹ️ [CDC] Database-level change capture unavailable (likely insufficient privileges to create triggers, " +
387
+ "or the LISTEN connection was refused) — using app-level realtime. Set REALTIME_CDC=off to silence this.",
388
+ { detail: err instanceof Error ? err.message : String(err) }
389
+ );
390
+ }
391
+ }
392
+ }
393
+
394
+ // Legacy cross-instance realtime (app-level). Skipped when CDC is
395
+ // active because CDC already spans instances.
396
+ if (!cdcEnabled && directUrl) {
397
+ try {
398
+ await realtimeService.startListening(directUrl);
399
+ } catch (err) {
400
+ logger.warn("⚠️ Cross-instance realtime could not be started", { error: err });
401
+ }
402
+ }
403
+
404
+ // ── Startup Schema Validation ────────────────────────────────────
405
+ // One-directional: only checks collections → DB (extra DB tables
406
+ // that aren't mapped to collections are perfectly fine).
407
+ try {
408
+ const registeredCollections = registry.getCollections();
409
+ if (registeredCollections.length > 0) {
410
+ const schemasToCheck = Array.from(new Set(
411
+ registeredCollections.map(c => "schema" in c && c.schema ? c.schema : "public")
412
+ ));
413
+ const schemasList = schemasToCheck.map(s => `'${s}'`).join(",");
414
+ const result = await schemaAwareDb.execute(sql.raw(`
415
+ SELECT table_name, table_schema
416
+ FROM information_schema.tables
417
+ WHERE table_schema IN (${schemasList})
418
+ AND table_type = 'BASE TABLE'
419
+ `));
420
+ const dbTables = new Set(
421
+ (result.rows as Array<{ table_name: string; table_schema: string }>).map(r =>
422
+ r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
423
+ )
424
+ );
425
+ const missing: Array<{ slug: string; table: string }> = [];
426
+ for (const col of registeredCollections) {
427
+ const schemaName = "schema" in col && col.schema ? col.schema : "public";
428
+ const tableName = registry.hasTableForCollection(
429
+ col.table ?? col.slug
430
+ )
431
+ ? (col.table ?? col.slug)
432
+ : col.slug;
433
+ // Resolve the actual table name the registry stored
434
+ const resolvedTable = registry.getTableNames().find((k) =>
435
+ k === tableName ||
436
+ k === col.slug
437
+ );
438
+ const checkName = resolvedTable ?? tableName;
439
+ const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
440
+ if (!dbTables.has(fullCheckName)) {
441
+ missing.push({ slug: col.slug,
442
+ table: checkName });
443
+ }
444
+ }
445
+ if (missing.length > 0) {
446
+ const lines = missing.map(
447
+ m => ` • collection "${m.slug}" → table "${m.table}"`
448
+ );
449
+ logger.warn([
450
+ "",
451
+ "┌──────────────────────────────────────────────────────────────┐",
452
+ "│ ⚠️ SCHEMA DRIFT — Missing tables in database │",
453
+ "├──────────────────────────────────────────────────────────────┤",
454
+ ...lines.map(l => `│ ${l.padEnd(60)}│`),
455
+ "├──────────────────────────────────────────────────────────────┤",
456
+ "│ Run one of: │",
457
+ "│ pnpm db:push (dev — fast, no migration files) │",
458
+ "│ pnpm db:migrate (prod — creates migration files) │",
459
+ "└──────────────────────────────────────────────────────────────┘",
460
+ ""
461
+ ].join("\n"));
462
+ }
463
+ }
464
+ } catch (err) {
465
+ logger.warn("⚠️ Startup schema validation could not run", {
466
+ error: err instanceof Error ? err.message : String(err)
467
+ });
468
+ }
469
+
470
+ const internals: PostgresDriverInternals = {
471
+ db: schemaAwareDb,
472
+ readDb,
473
+ registry,
474
+ realtimeService,
475
+ driver,
476
+ poolManager
477
+ };
478
+
479
+ return {
480
+ driver,
481
+ realtimeProvider: realtimeService,
482
+ collectionRegistry: registry,
483
+ // Only set in baas mode — tells the server which collections the
484
+ // database turned out to have.
485
+ collections: introspectedCollections,
486
+ internals
487
+ };
488
+ },
489
+
490
+ async initializeAuth(config: unknown, driverResult: InitializedDriver): Promise<BootstrappedAuth | undefined> {
491
+ const authConfig = config as Record<string, unknown> | undefined;
492
+ if (!authConfig) return undefined;
493
+
494
+ const internals = driverResult.internals as PostgresDriverInternals;
495
+ const db = internals.db;
496
+ const registry = internals.registry;
497
+
498
+ // Resolve the auth collection from the explicit config.
499
+ // This replaces the old `registry.getTable("users")` magic string lookup.
500
+ const authCollection = authConfig.collection as CollectionConfig | undefined;
501
+
502
+ // ensureAuthTablesExist works with the collection abstraction — no Drizzle leakage.
503
+ await ensureAuthTablesExist(db, authCollection);
504
+
505
+ let emailService: EmailService | undefined;
506
+ if (authConfig.email) {
507
+ emailService = createEmailService(authConfig.email as EmailConfig);
508
+ }
509
+
510
+ // Resolve the Drizzle table for the internal UserService/AuthRepository.
511
+ // These are internal Postgres-specific services that need the Drizzle table reference.
512
+ const tableName = authCollection
513
+ ? ("table" in authCollection && typeof authCollection.table === "string"
514
+ ? authCollection.table
515
+ : authCollection.slug)
516
+ : undefined;
517
+ const usersTable = tableName
518
+ ? registry.getTable(tableName) as RebasePgTable | undefined
519
+ : undefined;
520
+
521
+ let usersSchemaName = "rebase";
522
+ if (authCollection && "schema" in authCollection && typeof authCollection.schema === "string") {
523
+ usersSchemaName = authCollection.schema;
524
+ }
525
+
526
+ const authTables = createAuthSchema(usersSchemaName) as unknown as AuthSchemaTables;
527
+ if (usersTable) {
528
+ authTables.users = usersTable as RebasePgTable;
529
+ }
530
+
531
+ const userService = new UserService(db, authTables);
532
+ const authRepository = new PostgresAuthRepository(db, authTables);
533
+
534
+ return { userService,
535
+ roleService: userService,
536
+ emailService,
537
+ authRepository };
538
+ },
539
+
540
+ async initializeHistory(config: HistoryConfig, driverResult: InitializedDriver): Promise<{ historyService: HistoryService } | undefined> {
541
+ if (!config) return undefined;
542
+
543
+ const internals = driverResult.internals as PostgresDriverInternals;
544
+ const db = internals.db;
545
+
546
+ await ensureHistoryTableExists(db);
547
+
548
+ const retention = typeof config === "object" ? config.retention : undefined;
549
+ const historyService = new HistoryService(db, retention ? { ttlDays: retention } : undefined);
550
+
551
+ return { historyService };
552
+ },
553
+
554
+ async initializeRealtime(_config: unknown, driverResult: InitializedDriver): Promise<RealtimeProvider | undefined> {
555
+ const internals = driverResult.internals as PostgresDriverInternals;
556
+ return internals.realtimeService;
557
+ },
558
+
559
+ getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
560
+ const internals = driverResult.internals as PostgresDriverInternals;
561
+ return internals.driver.admin;
562
+ },
563
+
564
+ mountRoutes(app: unknown, basePath: string, driverResult: InitializedDriver): void {
565
+ // The coordinator handles auth/storage/data routes.
566
+ // This hook is for driver-specific extensions only.
567
+ // Currently Postgres doesn't need additional routes beyond what the coordinator mounts.
568
+ },
569
+
570
+ async initializeWebsockets(server: unknown, realtimeService: RealtimeProvider, driver: DataDriver, config?: unknown, adapter?: unknown): Promise<void> {
571
+ const { createPostgresWebSocket } = await import("./websocket");
572
+ createPostgresWebSocket(
573
+ server as import("http").Server,
574
+ realtimeService as RealtimeService,
575
+ driver as PostgresBackendDriver,
576
+ config as { requireAuth?: boolean; jwtSecret?: string; serviceKey?: string },
577
+ adapter as AuthAdapter | undefined
578
+ );
579
+ }
580
+ };
581
+ }