@rebasepro/server-postgres 0.17.3 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,1157 +0,0 @@
1
- /**
2
- * PostgresBootstrapper
3
- *
4
- * Implements the `BackendBootstrapper` interface for PostgreSQL.
5
- */
6
-
7
- import { 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
- isRelationalCollectionConfig,
19
- type HistoryConfig,
20
- InitializedDriver,
21
- RealtimeProvider,
22
- type RealtimeChannelsConfig
23
- } from "@rebasepro/types";
24
- import { PostgresBackendDriver } from "./PostgresBackendDriver";
25
- import { RealtimeService } from "./services/realtimeService";
26
- import { buildCollectionRegistry } from "./collections/buildRegistry";
27
- import { DatabasePoolManager } from "./databasePoolManager";
28
- import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
29
- import { createEmailService, type EmailConfig, type EmailService, logger } from "@rebasepro/server";
30
- import { getTableName as getCollectionTableName } from "@rebasepro/common";
31
- import { ensureAuthTablesExist } from "./auth/ensure-tables";
32
- import { probeAuthSchema, resolveAuthSchema } from "./auth/schema-version";
33
- import { AuthSchemaTables, PostgresAuthRepository, UserService } from "./auth/services";
34
- import { createAuthSchema } from "./schema/auth-schema";
35
- import { HistoryService } from "./history/HistoryService";
36
- import { ensureHistoryTableExists } from "./history/ensure-history-table";
37
- import { patchPgArrayNullSafety } from "./utils/pg-array-null-patch";
38
- import { buildCollectionsFromSchema, introspectSchema, readRlsStatus } from "./schema/introspect-runtime";
39
- import { buildDrizzleTablesFromSchema, buildDrizzleRelationsFromSchema } from "./schema/dynamic-tables";
40
- import { detectConnectionPosture, ensureAppRole, validatePolicyPgRoles, warnOnAnonymousGrants, warnOnLegacyRlsFunctions, warnOnRoleSchemaCollision, REBASE_USER_ROLE, type RawSqlRunner } from "./security/rls-enforcement";
41
- import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cdc";
42
- import { collectJunctionLinks } from "./services/cdc/junction-tables";
43
- import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
44
- import { isChannelBusInstance } from "@rebasepro/types";
45
- import { configureUnknownFilterFields, type UnknownFilterFieldsMode } from "./utils/drizzle-conditions";
46
-
47
- export interface PostgresDriverConfig {
48
- connectionString?: string;
49
- adminConnectionString?: string;
50
- readConnectionString?: string;
51
- connection?: unknown;
52
- schema?: {
53
- tables?: Record<string, unknown>;
54
- enums?: Record<string, unknown>;
55
- relations?: Record<string, unknown>;
56
- };
57
- /**
58
- * PostgreSQL schema to read when deriving collections from the database
59
- * (BaaS mode). Defaults to `public`.
60
- */
61
- introspectionSchema?: string;
62
- /**
63
- * Realtime options, both opt-in:
64
- *
65
- * - `channels` — retention. Without rules no channel keeps any history and
66
- * broadcast stays fire-and-forget. See {@link ChannelRetentionRule}.
67
- * - `bus` — the cross-instance transport for channel broadcast and
68
- * presence. Defaults to in-process only, which is correct for a single
69
- * instance and wrong for two. See {@link ChannelBusConfig}.
70
- */
71
- realtime?: RealtimeChannelsConfig;
72
- /**
73
- * What to do with a filter field that resolves to no column at all.
74
- * Defaults to `"error"` — a filter that cannot be compiled would otherwise
75
- * be dropped, and a dropped condition can only widen the result set.
76
- * Set to `"warn"` to restore the pre-fix behaviour of dropping it silently.
77
- */
78
- unknownFilterFields?: UnknownFilterFieldsMode;
79
- }
80
-
81
- /**
82
- * Opaque internals bag that PostgresBootstrapper stores during `initializeDriver()`
83
- * and re-uses in subsequent lifecycle hooks.
84
- */
85
- export interface PostgresDriverInternals {
86
- db: NodePgDatabase<any>;
87
- readDb?: NodePgDatabase<any>;
88
- registry: PostgresCollectionRegistry;
89
- realtimeService: RealtimeService;
90
- driver: PostgresBackendDriver;
91
- poolManager?: DatabasePoolManager;
92
- /**
93
- * Attach CDC triggers to tables that did not exist when the driver
94
- * bootstrapped. Only set when database-level capture is actually active.
95
- *
96
- * Auth owns its own tables and creates them later in boot, so at driver
97
- * bootstrap they are legitimately missing and get skipped; without this
98
- * they would stay uninstrumented until the next restart.
99
- */
100
- provisionCdcForTables?: (tables: CdcTableRef[]) => Promise<void>;
101
- }
102
-
103
- // Re-export from shared CLI error utilities
104
- import { isEconnrefused } from "./cli-errors";
105
- import { classifyConnectFailure } from "./utils/pg-error-utils";
106
-
107
- /**
108
- * Which table name the boot-time drift check should look for, for one collection.
109
- *
110
- * A declared `table` IS the table name, not a hint to be second-guessed. This
111
- * used to ask the registry whether it had indexed the declared name and fall
112
- * back to the SLUG when it had not — but "the registry does not know this table"
113
- * is exactly the condition the drift check exists to report, so the fallback
114
- * fired precisely when it was most harmful.
115
- *
116
- * A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
117
- * missing table `usage-daily`: a name that does not exist, should never exist,
118
- * and that nobody can find by looking. Worse, the remediation the caller prints
119
- * says to run `rebase db push` — which would then CREATE that invented table
120
- * beside the correct one, the same "second copy" hazard the misplaced-schema
121
- * branch further down exists to prevent. Seen in production, where a correctly
122
- * migrated database reported drift on every boot.
123
- *
124
- * The slug is used only when nothing was declared, which is the config shape
125
- * where the slug genuinely is the table name.
126
- *
127
- * Exported for its own test: the caller needs a live pool and a real database,
128
- * and this is the part that was wrong.
129
- */
130
- export function resolveDriftCheckName(
131
- col: CollectionConfig,
132
- registeredTableNames: string[]
133
- ): string {
134
- const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
135
- return declaredTable
136
- ?? registeredTableNames.find((k) => k === col.slug)
137
- ?? col.slug;
138
- }
139
-
140
- /**
141
- * Why the tables this backend serves are not in the database — the part of the
142
- * drift warning that has to be true rather than merely plausible.
143
- *
144
- * The three answers need three different actions, and only the caller knows
145
- * which one applies. This warning used to assert the first ("this runtime
146
- * applies the collection schema at boot unless REBASE_MIGRATE_ON_BOOT=none")
147
- * and then point at that variable and at driver-version skew. For an app whose
148
- * boot path contained no provisioning step at all, every word of that was a
149
- * dead end: nothing read the variable, and the driver was current. The advice
150
- * cost an investigation, which is a strictly worse outcome than saying less.
151
- *
152
- * Exported for its own test: the surrounding check needs a live pool and a real
153
- * database, and this is the part that was wrong.
154
- */
155
- export function describeSchemaDriftCause(
156
- provisioning: { attempted: boolean; reason?: string } | undefined
157
- ): string[] {
158
- // A caller too old to send the signal gets no claim either way — just where
159
- // to look. Guessing is what got this wrong the first time.
160
- if (provisioning === undefined) {
161
- return [
162
- " This runtime could not determine whether a schema-creation step ran",
163
- " before this check (the caller predates that signal).",
164
- " • Look for a \"Collection schema:\" line above. No such line at all",
165
- " means nothing tried to create these tables in this process."
166
- ];
167
- }
168
- if (provisioning.attempted) {
169
- return [
170
- " A schema-creation step DID run this boot and these tables are still",
171
- " missing, so it did not create them — check the \"schema:\" lines above",
172
- " for what it did instead, and for DDL errors.",
173
- " • A collection routed to another engine or data source is not",
174
- " created here; that is reported separately at boot.",
175
- " • Otherwise this is a bug worth reporting, with those lines."
176
- ];
177
- }
178
- return [
179
- " No schema-creation step ran this boot:",
180
- ` ${provisioning.reason ?? "no reason was given."}`,
181
- " Resolve that reason — the drift is its consequence, not a separate",
182
- " problem, and re-running a migration tool will not change it."
183
- ];
184
- }
185
-
186
- /**
187
- * Is this the local database `rebase init` scaffolds — i.e. the one case where
188
- * "you are connected as a superuser" is not news?
189
- *
190
- * The scaffold's own `docker-compose.yml` sets `POSTGRES_USER: rebase_app`,
191
- * which makes that role the cluster superuser, so the superuser advisory below
192
- * was the only WARN a brand-new project ever saw and it was about a decision
193
- * the tool had made for the developer.
194
- *
195
- * Of the two available fixes — provision a non-superuser table-owner role in
196
- * the scaffold, or recognise the local shape and stay quiet — this is the
197
- * second, because the first breaks the scaffold it is meant to improve: a
198
- * non-superuser owner cannot `CREATE EXTENSION` (search collections need
199
- * `pg_trgm`/`unaccent`, applied by `rebase db push` and again by the boot
200
- * schema-ensure), so the very first `pnpm run db:push` on a scaffolded project
201
- * with a search block would fail. Trading a working first run for a quieter log
202
- * line is the wrong trade.
203
- *
204
- * The condition is deliberately narrow — a *non-production* process talking to
205
- * a database on the loopback interface. A genuine production superuser
206
- * connection still warns, and so does a non-production process pointed at a
207
- * remote database (the usual "my dev machine writes to staging" mistake, where
208
- * the advisory is exactly right). NODE_ENV alone would not do: the scaffold
209
- * ships `NODE_ENV=development` and some deployments inherit it.
210
- */
211
- export function isScaffoldedLocalDatabase(connectionString: string | undefined): boolean {
212
- if (process.env.NODE_ENV === "production") return false;
213
- if (!connectionString) return false;
214
- let host: string;
215
- try {
216
- host = new URL(connectionString).hostname;
217
- } catch {
218
- return false;
219
- }
220
- // `new URL` keeps IPv6 literals in brackets.
221
- const bare = host.replace(/^\[|\]$/g, "").toLowerCase();
222
- return bare === "localhost"
223
- || bare === "::1"
224
- || bare === "0.0.0.0"
225
- || bare === ""
226
- || /^127\./.test(bare)
227
- || bare.endsWith(".localhost");
228
- }
229
-
230
- /**
231
- * Default PostgreSQL bootstrapper.
232
- *
233
- * Use it to register Postgres with `initializeRebaseBackend()`:
234
- * ```typescript
235
- * initializeRebaseBackend({
236
- * ...config,
237
- * bootstrappers: [postgresBootstrapper()]
238
- * });
239
- * ```
240
- */
241
- /**
242
- * Where the collections schema stamp lives.
243
- *
244
- * The runtime's own internal schema, always — unlike the auth stamp, which
245
- * follows the users collection. `rebase` and `auth` sit outside
246
- * `introspectionSchema` by construction, so nothing here is ever served as a
247
- * collection.
248
- */
249
- const SCHEMA_META_SCHEMA = "rebase";
250
-
251
- export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): BackendBootstrapper {
252
- // Applied at construction rather than threaded through every read: the
253
- // condition builder's static methods are reached from call sites that
254
- // carry no config. See `UnknownFilterFieldsMode`.
255
- if (pgConfig.unknownFilterFields) {
256
- configureUnknownFilterFields(pgConfig.unknownFilterFields);
257
- }
258
-
259
- /**
260
- * The handle the schema/policy hooks issue their DDL through.
261
- *
262
- * Both hooks run BEFORE `initializeDriver`, so `driverResult` is a stand-in
263
- * the caller may not have: the bundle path can synthesize one from the
264
- * connection its coordinator opened, but an application that built this
265
- * adapter itself never handed the framework a connection — it handed it to
266
- * *us*, as `pgConfig.connection`. Falling back to that is what lets a
267
- * self-built adapter provision at all; requiring the argument is what left
268
- * those apps with no tables and a 500 on every data route.
269
- *
270
- * Either handle is equivalent here. The driver's `schemaAwareDb` differs
271
- * only by the drizzle schema object registered on it — relevant to the query
272
- * builder, not to `execute(sql.raw(...))` — and every statement these hooks
273
- * emit is schema-qualified DDL, so neither depends on `search_path`.
274
- */
275
- /**
276
- * The drizzle handle itself, for the statements that want parameters.
277
- *
278
- * `provisioningQueryable` below hands back a `query(text)` shim because the
279
- * DDL it serves is built as text. The schema stamp writes a *value*, so it
280
- * wants the parameterised form — building that string by hand would be one
281
- * more place a quoted literal has to be got right for no benefit.
282
- */
283
- const provisioningDb = (driverResult?: InitializedDriver) => {
284
- const internals = driverResult?.internals as PostgresDriverInternals | undefined;
285
- return internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
286
- };
287
-
288
- const provisioningQueryable = (driverResult?: InitializedDriver) => {
289
- const internals = driverResult?.internals as PostgresDriverInternals | undefined;
290
- const db = internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
291
- if (!db) {
292
- throw new Error(
293
- "Cannot provision the collection schema: this Postgres adapter was created without a " +
294
- "`connection`, and no initialized driver was supplied to fall back on. Pass `connection` " +
295
- "to `createPostgresAdapter` (see `createPostgresDatabaseConnection`)."
296
- );
297
- }
298
- return {
299
- async query<T>(text: string): Promise<{ rows: T[] }> {
300
- const result = await db.execute(sql.raw(text));
301
- const rows = (result as unknown as { rows?: T[] }).rows;
302
- return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
303
- }
304
- };
305
- };
306
-
307
- return {
308
- type: "postgres",
309
-
310
- async initializeDriver(config: unknown): Promise<InitializedDriver> {
311
- // config is passed from coordinator, we merge it with our internal pgConfig if needed
312
- // Currently config from init.ts is `{ collections, collectionRegistry, mode }`
313
- const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning, realtime } = config as {
314
- collections?: CollectionConfig[];
315
- collectionRegistry?: unknown;
316
- introspectCollections?: boolean;
317
- baas?: { unprotectedTables?: "exclude" | "serve" };
318
- schemaProvisioning?: { attempted: boolean; reason?: string };
319
- realtime?: { subscribe: boolean; provision: boolean };
320
- };
321
- // Absent means a caller that predates the field, and every one of
322
- // those is a single process that both serves websockets and owns the
323
- // schema. Defaulting to false here would silently disable realtime
324
- // for them — the failure this whole area is prone to.
325
- const realtimeSubscribes = realtime?.subscribe ?? true;
326
- const realtimeProvisions = realtime?.provision ?? true;
327
- // Secure by default: a table with no RLS is not served.
328
- const unprotectedTables = baas?.unprotectedTables ?? "exclude";
329
-
330
- const connection = pgConfig.connection;
331
- const rawClient = (connection && typeof connection === "object" && "$client" in connection
332
- ? (connection as Record<string, unknown>).$client
333
- : connection) as import("pg").Pool;
334
-
335
- // ── No declared collections: derive the schema from the database ──
336
- // No collection files and no generated drizzle schema exist, so read
337
- // the live database and build both from what is actually there.
338
- let introspectedCollections: CollectionConfig[] | undefined;
339
- let introspectedTables: Record<string, PgTable> | undefined;
340
- let introspectedRelations: Record<string, Relations> | undefined;
341
- if (introspectCollections && (!collections || collections.length === 0)) {
342
- const pgSchemaName = pgConfig.introspectionSchema ?? "public";
343
- const schema = await introspectSchema(rawClient, pgSchemaName);
344
-
345
- // ── Only serve what the database protects ────────────────
346
- // Requests run as rebase_user, which is granted DML on the
347
- // schema. A table with RLS disabled therefore has no
348
- // authorization model at all: serving it hands every row to
349
- // every authenticated user. baas never runs `db push`, so
350
- // nothing here would have enabled RLS on the user's behalf.
351
- const rlsStatus = await readRlsStatus(rawClient, pgSchemaName);
352
- const unprotected = [...schema.tablesMap.keys()].filter(
353
- (t) => !schema.joinTables.has(t) && !rlsStatus.get(t)?.rlsEnabled
354
- );
355
- const policyless = [...schema.tablesMap.keys()].filter(
356
- (t) => !schema.joinTables.has(t) && rlsStatus.get(t)?.rlsEnabled && rlsStatus.get(t)?.policyCount === 0
357
- );
358
-
359
- if (unprotected.length > 0) {
360
- if (unprotectedTables === "serve") {
361
- logger.warn(
362
- `🔓 [rls] Serving ${unprotected.length} table(s) with row-level security DISABLED: ${unprotected.join(", ")}. ` +
363
- "Every authenticated request can read and write every row of these. " +
364
- "This is baas.unprotectedTables: \"serve\"."
365
- );
366
- } else {
367
- logger.warn(
368
- `🔒 [rls] Not serving ${unprotected.length} table(s) — row-level security is disabled, so they have no ` +
369
- `authorization model: ${unprotected.join(", ")}\n` +
370
- unprotected.map((t) => ` ALTER TABLE "${pgSchemaName}"."${t}" ENABLE ROW LEVEL SECURITY; -- then add a policy`).join("\n") +
371
- "\n Set baas.unprotectedTables: \"serve\" to expose them regardless."
372
- );
373
- for (const t of unprotected) schema.tablesMap.delete(t);
374
- }
375
- }
376
- if (policyless.length > 0) {
377
- // Legal, and silently returns nothing — worth saying out loud,
378
- // since an empty table reads exactly like one with no rows.
379
- logger.warn(
380
- `🔒 [rls] ${policyless.length} table(s) have RLS enabled but no policies, so they will return no rows: ${policyless.join(", ")}`
381
- );
382
- }
383
-
384
- introspectedCollections = buildCollectionsFromSchema(schema, pgSchemaName);
385
- introspectedTables = buildDrizzleTablesFromSchema(schema.tablesMap, pgSchemaName);
386
- // Without these, drizzle's relational path can't resolve the
387
- // relations the collections above advertise.
388
- introspectedRelations = buildDrizzleRelationsFromSchema(schema.tablesMap, introspectedTables);
389
- logger.info(
390
- `🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map(c => c.slug).join(", ")}]`
391
- );
392
- }
393
-
394
- const activeCollections = introspectedCollections ?? collections;
395
- const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
396
- const schemaRelations = introspectedRelations ?? (pgConfig.schema?.relations as Record<string, Relations> | undefined);
397
-
398
- // Create a fresh registry for this driver. Registration order is
399
- // load-bearing, so it lives in one place — see `buildCollectionRegistry`.
400
- const registry = buildCollectionRegistry({
401
- collections: activeCollections,
402
- tables: schemaTables,
403
- enums: pgConfig.schema?.enums as Record<string, PgEnum<[string, ...string[]]>> | undefined,
404
- relations: schemaRelations
405
- });
406
-
407
- // Patch Drizzle's PgArray columns to handle NULL values safely.
408
- // Drizzle's mapFromDriverValue crashes with "value.map is not a function"
409
- // when a native array column (text[], integer[], etc.) contains NULL.
410
- if (schemaTables) {
411
- patchPgArrayNullSafety(schemaTables as Record<string, unknown>);
412
- }
413
-
414
- // Build schema-aware Drizzle connection
415
- const mergedSchema: Record<string, unknown> = {
416
- ...schemaTables,
417
- ...(schemaRelations || {})
418
- };
419
- const { drizzle: createDrizzle } = await import("drizzle-orm/node-postgres");
420
- const schemaAwareDb = createDrizzle(rawClient, { schema: mergedSchema });
421
-
422
- // Verify connection — fail fast if the database is unreachable
423
- try {
424
- await schemaAwareDb.execute(sql`SELECT 1`);
425
- } catch (err: unknown) {
426
- const isConnectionRefused = isEconnrefused(err);
427
- if (isConnectionRefused) {
428
- // Parse host/port from connection string for a helpful message
429
- let hostInfo = pgConfig.connectionString || "unknown";
430
- try {
431
- const parsed = new URL(pgConfig.connectionString || "");
432
- hostInfo = `${parsed.hostname}:${parsed.port || 5432}`;
433
- } catch { /* use raw string */ }
434
-
435
- const message =
436
- `\n` +
437
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
438
- ` ❌ Cannot connect to PostgreSQL at ${hostInfo}\n` +
439
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
440
- `\n` +
441
- ` The database server is not running or is not accepting\n` +
442
- ` connections. Common fixes:\n` +
443
- `\n` +
444
- ` • docker compose up -d db (the service a Rebase scaffold ships)\n` +
445
- ` • brew services start postgresql@18\n` +
446
- ` • Verify DATABASE_URL in your .env file\n` +
447
- `\n` +
448
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
449
- logger.error(message);
450
- throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
451
- }
452
-
453
- /*
454
- * Everything else.
455
- *
456
- * Two problems with what used to happen here. First, the logged
457
- * error was Drizzle's wrapper — `Failed query: SELECT 1` and a
458
- * stack through drizzle internals — while the sentence that
459
- * actually says what is wrong ("password authentication failed
460
- * for user …", "database … does not exist") sits in `.cause`
461
- * and was never printed. A developer with a typo in their
462
- * DATABASE_URL got two walls of stack trace and no cause.
463
- *
464
- * Second, "continuing… the pool may recover" is only true for
465
- * a transient fault. A wrong password or a missing database is
466
- * settled: the next query fails the same way, so the process
467
- * died seconds later anyway — after printing a reassurance.
468
- * Those now fail here, where the message can be about the
469
- * cause rather than about whichever query ran next.
470
- */
471
- const { fatal, reason, code } = classifyConnectFailure(err);
472
- const detail = code ? ` [${code}]` : "";
473
-
474
- if (fatal) {
475
- logger.error(
476
- `\n` +
477
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
478
- ` ❌ PostgreSQL refused the connection\n` +
479
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
480
- `\n` +
481
- ` ${reason}${detail}\n` +
482
- `\n` +
483
- ` The server is reachable, so this is the credentials or the\n` +
484
- ` database name in DATABASE_URL — check them in your .env.\n` +
485
- `\n` +
486
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
487
- );
488
- throw new Error(`PostgreSQL refused the connection: ${reason}${detail}`);
489
- }
490
-
491
- logger.error(`❌ Failed to connect to PostgreSQL: ${reason}${detail}`, { error: err });
492
- logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
493
- }
494
-
495
- // Create services
496
- const realtimeService = new RealtimeService(schemaAwareDb, registry);
497
-
498
- // Initialize read replica connection if configured
499
- let readDb: import("drizzle-orm/node-postgres").NodePgDatabase<any> | undefined;
500
- const readUrl = process.env.DATABASE_READ_URL;
501
- if (readUrl && readUrl !== pgConfig.connectionString) {
502
- try {
503
- const { createReadReplicaConnection } = await import("./connection");
504
- const readResources = createReadReplicaConnection(readUrl, mergedSchema);
505
- readDb = readResources.db;
506
- logger.info("📖 [PostgresBootstrapper] Read replica connection established");
507
- } catch (err) {
508
- logger.warn("⚠️ Could not connect to read replica, falling back to primary for all queries", { error: err });
509
- }
510
- }
511
- const poolManager = pgConfig.adminConnectionString
512
- ? new DatabasePoolManager(pgConfig.adminConnectionString)
513
- : undefined;
514
- const driver = new PostgresBackendDriver(schemaAwareDb, realtimeService, registry, undefined, poolManager);
515
- realtimeService.setDataDriver(driver);
516
-
517
- // ── RLS enforcement (user context) ───────────────────────────────
518
- // Authenticated requests are authorized entirely by RLS policies,
519
- // but a privileged connection (superuser / BYPASSRLS / table owner)
520
- // bypasses RLS. Detect the posture and, when privileged, provision
521
- // the restricted `rebase_user` role and route authenticated
522
- // requests (reads AND writes) through it. The server context (base
523
- // driver / auth flows / `rebase.sql`) stays on the owner connection
524
- // and bypasses; `rebase.dataAsAdmin` does NOT — it is scoped with
525
- // `withAuth({ uid: "service", roles: ["admin"] })` at boot, so it
526
- // runs as `rebase_user` and its policies are evaluated.
527
- // Default-on: a privileged connection that cannot be
528
- // isolated fails the boot rather than serving unenforced requests.
529
- {
530
- const runSql: RawSqlRunner = async (text) => {
531
- const res = await schemaAwareDb.execute(sql.raw(text));
532
- return (res.rows ?? []) as Record<string, unknown>[];
533
- };
534
- // Said before anything else touches the schema: if the role and
535
- // a schema share a name, unqualified SQL from any tool that does
536
- // not pin `search_path` has been landing in the wrong place, and
537
- // that is worth knowing before reading the drift report below.
538
- await warnOnRoleSchemaCollision(runSql);
539
-
540
- const posture = await detectConnectionPosture(runSql);
541
- if (posture.privileged) {
542
- const collectionSchemas = registry.getCollections()
543
- .map((c) => (c as { schema?: string }).schema)
544
- .filter((s): s is string => typeof s === "string");
545
- // `auth` is deliberately absent: the RLS helpers moved into
546
- // `rebase`, and granting USAGE on a schema Rebase does not
547
- // own would, on a Supabase database, hand the end-user role
548
- // access to theirs.
549
- await ensureAppRole(runSql, ["public", "rebase", ...collectionSchemas]);
550
- driver.rlsUserRole = REBASE_USER_ROLE;
551
- realtimeService.rlsUserRole = REBASE_USER_ROLE;
552
- 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"})`);
553
- if (posture.superuser || posture.bypassRLS) {
554
- const message =
555
- `The database connection runs as ${posture.superuser ? "a superuser" : "a BYPASSRLS role"} ("${posture.role}"). ` +
556
- `User requests are isolated via SET LOCAL ROLE, but connect as a non-superuser ` +
557
- `table-owner role in production so the server/owner context is least-privilege.`;
558
- if (isScaffoldedLocalDatabase(pgConfig.connectionString)) {
559
- logger.debug(`🔐 ${message}`);
560
- } else {
561
- logger.warn(`⚠️ ${message}`);
562
- }
563
- }
564
- } else {
565
- logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
566
- }
567
-
568
- // Independent of posture: a policy targeting a role the request
569
- // never runs as filters every row, so the collection reads as
570
- // empty rather than erroring. Applies to both branches — the
571
- // role that matters is whichever one requests actually use.
572
- await validatePolicyPgRoles(
573
- runSql,
574
- registry.getCollections() as never,
575
- driver.rlsUserRole ?? posture.role
576
- );
577
-
578
- // The same habit one surface over, and the dangerous direction:
579
- // a rule that reads as "signed in only" but is true for every
580
- // caller grants the data away rather than hiding it.
581
- warnOnAnonymousGrants(registry.getCollections() as never);
582
-
583
- // Raw policy SQL written against the pre-1.0 helper schema. It
584
- // is rewritten on compile, so this is the only place the project
585
- // is ever told the spelling moved.
586
- warnOnLegacyRlsFunctions(registry.getCollections() as never);
587
- }
588
-
589
- // Ensure branch metadata table exists when branching is available
590
- if (driver.branchService) {
591
- try {
592
- await driver.branchService.ensureBranchMetadataTable();
593
- } catch (err) {
594
- logger.warn("⚠️ Could not initialize branch metadata table", { error: err });
595
- }
596
- }
597
-
598
- // ── Channel history ──────────────────────────────────────────────
599
- // Opt-in per channel pattern. With no rules this creates no tables
600
- // and leaves broadcast on its original fire-and-forget path, so
601
- // presence-only apps pay nothing for it.
602
- try {
603
- await realtimeService.configureChannelHistory(
604
- pgConfig.realtime?.channels,
605
- { provision: realtimeProvisions }
606
- );
607
- } catch (err) {
608
- logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
609
- }
610
-
611
- // ── Realtime change source ───────────────────────────────────────
612
- // Prefer DATABASE_DIRECT_URL to bypass PgBouncer for LISTEN/NOTIFY.
613
- const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
614
-
615
- // ── Cross-instance channel bus ───────────────────────────────────
616
- // Entity changes already span instances (CDC / LISTEN below).
617
- // Channel broadcast and presence did not — they lived in per-process
618
- // maps, so behind two replicas the clients of one were invisible to
619
- // the other. Opt-in, and a no-op when left at "memory".
620
- try {
621
- const busSetting = resolveChannelBusSetting(pgConfig.realtime?.bus);
622
- // A supplied instance is always installed; a named built-in only
623
- // when it is not the in-process default, so the common case
624
- // touches none of this machinery.
625
- const wantsBus = isChannelBusInstance(busSetting) || busSetting.type !== "memory";
626
- if (wantsBus) {
627
- await realtimeService.configureChannelBus(
628
- createChannelBus(busSetting, {
629
- db: schemaAwareDb as unknown as NodePgDatabase<Record<string, unknown>>,
630
- directUrl
631
- })
632
- );
633
- }
634
- } catch (err) {
635
- logger.warn("⚠️ [ChannelBus] Could not configure the channel bus — channel broadcast and presence stay per-instance", { error: err });
636
- }
637
-
638
- // Database-level Change Data Capture. When active, realtime events are
639
- // emitted for EVERY committed write — including ones that bypass the
640
- // Rebase API (psql, another service's cron, raw SQL, the Studio SQL
641
- // editor) — matching Supabase Realtime's WAL-tailing model. CDC also
642
- // becomes the cross-instance channel, so the legacy per-mutation
643
- // LISTEN/NOTIFY is not started alongside it.
644
- // REALTIME_CDC=auto → default: enable where the connection supports
645
- // it; silently fall back to app-level otherwise
646
- // REALTIME_CDC=trigger → force trigger-based capture (warns if it can't)
647
- // REALTIME_CDC=wal → prefer WAL logical replication (degrades to trigger)
648
- // REALTIME_CDC=off → app-level realtime only
649
- const validModes = new Set(["auto", "wal", "trigger", "off"]);
650
- let cdcMode = (process.env.REALTIME_CDC || "auto").trim().toLowerCase();
651
- if (!validModes.has(cdcMode)) {
652
- logger.warn(`⚠️ [CDC] Unknown REALTIME_CDC value "${cdcMode}" — expected auto|wal|trigger|off. Defaulting to "auto".`);
653
- cdcMode = "auto";
654
- }
655
-
656
- // `auto` tries CDC but treats "can't" as a normal outcome (info log);
657
- // explicit trigger/wal was asked for, so a failure is worth a warning.
658
- // A process that consumes nothing needs no capture *started*, and a
659
- // process that does not own the schema installs no triggers. They
660
- // come apart: the `api` in a split with an external migration Job
661
- // subscribes without provisioning, and both answers are correct.
662
- const wantsCdc = cdcMode !== "off" && (realtimeSubscribes || realtimeProvisions);
663
- const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
664
- let cdcEnabled = false;
665
- let provisionCdcForTables: PostgresDriverInternals["provisionCdcForTables"];
666
-
667
- if (wantsCdc && !directUrl) {
668
- const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
669
- if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
670
- else logger.info(`ℹ️ [CDC] Using app-level realtime — ${reason}.`);
671
- } else if (wantsCdc && directUrl) {
672
- if (cdcMode === "wal") {
673
- // Native WAL logical-replication streaming requires wal_level=logical,
674
- // a replication-privileged role and a replication slot, none of which
675
- // are bundled with this adapter yet. Degrade to trigger-based capture,
676
- // which provides equivalent database-level coverage.
677
- logger.warn(
678
- "⚠️ [CDC] REALTIME_CDC=wal: native WAL streaming is not bundled in this build; " +
679
- "using trigger-based change capture instead (equivalent database-level coverage)."
680
- );
681
- }
682
- try {
683
- const cdcRunSql: RawSqlRunner = async (text) => {
684
- const res = await schemaAwareDb.execute(sql.raw(text));
685
- return (res.rows ?? []) as Record<string, unknown>[];
686
- };
687
- const cdcTables: CdcTableRef[] = registry.getCollections()
688
- .map((c) => ({
689
- schema: (c as { schema?: string }).schema ?? "public",
690
- table: getCollectionTableName(c)
691
- }))
692
- .filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table));
693
- // Junction tables back no collection, so the list above misses
694
- // them — and a link or unlink is a write nobody would hear
695
- // about. Their rows are the contents of a child list, which is
696
- // as much a change as a write to the rows themselves.
697
- for (const link of collectJunctionLinks(registry)) {
698
- cdcTables.push({ schema: link.schema,
699
- table: link.table });
700
- }
701
- // Provisioning throws only when the connection can't create the
702
- // trigger function (insufficient privilege); enableCdc throws when
703
- // the LISTEN connection can't be established. Either → fall back.
704
- if (realtimeProvisions) await provisionTriggerCdc(cdcRunSql, cdcTables);
705
- if (realtimeSubscribes) await realtimeService.enableCdc(directUrl);
706
- cdcEnabled = true;
707
- // Boot steps that create their own tables (auth) run after
708
- // this one and use it to instrument what they just created.
709
- // Left undefined where this process installs nothing, so a
710
- // later boot step cannot re-enter the DDL path by the side
711
- // door — the callers already treat it as optional, because a
712
- // driver without CDC never sets it either.
713
- if (realtimeProvisions) {
714
- provisionCdcForTables = async (tables) => {
715
- await provisionTriggerCdc(cdcRunSql, tables);
716
- };
717
- }
718
- // Say which half ran. "All writes now emit realtime events"
719
- // is a claim about the database and stays true for a process
720
- // that only installed the triggers; what changes is whether
721
- // *this* process is listening, and an operator reading one
722
- // pod's log should not have to infer that from its role.
723
- logger.info(
724
- `📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). ` +
725
- `All writes now emit realtime events regardless of origin.` +
726
- (realtimeSubscribes ? "" : " This process installs the capture but does not consume it.")
727
- );
728
- } catch (err) {
729
- if (explicitCdc) {
730
- logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
731
- } else {
732
- logger.info(
733
- "ℹ️ [CDC] Database-level change capture unavailable (likely insufficient privileges to create triggers, " +
734
- "or the LISTEN connection was refused) — using app-level realtime. Set REALTIME_CDC=off to silence this.",
735
- { detail: err instanceof Error ? err.message : String(err) }
736
- );
737
- }
738
- }
739
- }
740
-
741
- // Legacy cross-instance realtime (app-level). Skipped when CDC is
742
- // active because CDC already spans instances.
743
- if (!cdcEnabled && directUrl && realtimeSubscribes) {
744
- try {
745
- await realtimeService.startListening(directUrl);
746
- } catch (err) {
747
- logger.warn("⚠️ Cross-instance realtime could not be started", { error: err });
748
- }
749
- }
750
-
751
- // ── Startup Schema Validation ────────────────────────────────────
752
- // One-directional: only checks collections → DB (extra DB tables
753
- // that aren't mapped to collections are perfectly fine).
754
- try {
755
- const registeredCollections = registry.getCollections();
756
- if (registeredCollections.length > 0) {
757
- // Deliberately unfiltered by schema: a table that is missing
758
- // from where the collection says it lives is very often
759
- // sitting in another schema entirely (see the misplaced-table
760
- // report below), and knowing *which* is the whole answer.
761
- const result = await schemaAwareDb.execute(sql.raw(`
762
- SELECT table_name, table_schema
763
- FROM information_schema.tables
764
- WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
765
- AND table_type = 'BASE TABLE'
766
- `));
767
- const tablesByName = new Map<string, string[]>();
768
- for (const row of result.rows as Array<{ table_name: string; table_schema: string }>) {
769
- const schemas = tablesByName.get(row.table_name) ?? [];
770
- schemas.push(row.table_schema);
771
- tablesByName.set(row.table_name, schemas);
772
- }
773
- const dbTables = new Set(
774
- (result.rows as Array<{ table_name: string; table_schema: string }>).map(r =>
775
- r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
776
- )
777
- );
778
- const missing: Array<{ slug: string; table: string; foundIn: string[] }> = [];
779
- for (const col of registeredCollections) {
780
- // Auth owns its table and creates it later in this same
781
- // boot (initializeAuth → ensureAuthTablesExist), so it is
782
- // legitimately absent right now. Reporting it as drift
783
- // tells the user to `db:push` a table that is about to
784
- // exist — and on an introspected database, one that the
785
- // database was never supposed to hold.
786
- if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
787
-
788
- const schemaName = "schema" in col && col.schema ? col.schema : "public";
789
- const checkName = resolveDriftCheckName(col, registry.getTableNames());
790
- const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
791
- if (!dbTables.has(fullCheckName)) {
792
- // Report what was actually looked up: an unqualified
793
- // "users" sends people hunting for public.users.
794
- missing.push({ slug: col.slug,
795
- table: fullCheckName,
796
- foundIn: (tablesByName.get(checkName) ?? []).filter(s => s !== schemaName) });
797
- }
798
- }
799
- if (missing.length > 0) {
800
- const lines = missing.map(
801
- m => ` • collection "${m.slug}" → table "${m.table}"` +
802
- (m.foundIn.length > 0
803
- ? ` — but a table of that name exists in ${m.foundIn.map(s => `"${s}"`).join(", ")}`
804
- : "")
805
- );
806
- // A table that exists under the same name in another
807
- // schema is not ordinary drift: it is almost always this
808
- // framework's own `search_path` hazard. Postgres resolves
809
- // unqualified SQL through `"$user", public`, and this
810
- // project creates a schema named `rebase` while every
811
- // template names the database role `rebase` too — so an
812
- // unqualified CREATE TABLE (a hand-written migration, the
813
- // SQL editor, drizzle-kit) lands in `rebase`, and the
814
- // runtime, which now pins `search_path=public`, cannot see
815
- // it. Say so, because "missing table" sends people to
816
- // re-run a push that will create a *second* copy.
817
- const misplaced = missing.filter(m => m.foundIn.length > 0);
818
- const misplacedHelp = misplaced.length === 0 ? [] : [
819
- " Those tables exist — in the wrong schema. Unqualified SQL run by a",
820
- " role whose name matches a schema (`rebase` is both, by default)",
821
- " resolves through search_path's \"$user\" and creates there. Move them:",
822
- ...misplaced.map(m =>
823
- ` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`
824
- ),
825
- " and qualify the SQL that created them, or pin search_path in DATABASE_URL",
826
- " (`?options=-c%20search_path%3Dpublic`).",
827
- ""
828
- ];
829
- // What to tell the operator depends entirely on whether a
830
- // create step ran in this process, and the caller is the
831
- // only thing that knows. This warning used to assert that
832
- // it had ("this runtime applies the collection schema at
833
- // boot unless REBASE_MIGRATE_ON_BOOT=none") and send
834
- // people to that variable and to driver-version skew. For
835
- // an app whose boot path contained no provisioning step,
836
- // both were dead ends: nothing read that variable, and the
837
- // driver was current. Say which case this is instead of
838
- // guessing, and say nothing when the caller is too old to
839
- // tell us.
840
- const cause = describeSchemaDriftCause(schemaProvisioning);
841
- logger.warn([
842
- "",
843
- "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
844
- ...lines,
845
- "",
846
- ...misplacedHelp,
847
- ...cause,
848
- "",
849
- " To apply this project's schema:",
850
- " • Managed cloud: the runtime creates tables and RLS at boot. `rebase db",
851
- " push` cannot reach a tenant's in-cluster database — redeploy instead.",
852
- " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
853
- " against DATABASE_URL.",
854
- ""
855
- ].join("\n"));
856
- }
857
- }
858
- } catch (err) {
859
- logger.warn("⚠️ Startup schema validation could not run", {
860
- error: err instanceof Error ? err.message : String(err)
861
- });
862
- }
863
-
864
- const internals: PostgresDriverInternals = {
865
- db: schemaAwareDb,
866
- readDb,
867
- registry,
868
- realtimeService,
869
- driver,
870
- poolManager,
871
- provisionCdcForTables
872
- };
873
-
874
- return {
875
- driver,
876
- realtimeProvider: realtimeService,
877
- collectionRegistry: registry,
878
- // Only set in baas mode — tells the server which collections the
879
- // database turned out to have.
880
- collections: introspectedCollections,
881
- internals
882
- };
883
- },
884
-
885
- async initializeAuth(config: unknown, driverResult: InitializedDriver): Promise<BootstrappedAuth | undefined> {
886
- const authConfig = config as Record<string, unknown> | undefined;
887
- if (!authConfig) return undefined;
888
-
889
- const internals = driverResult.internals as PostgresDriverInternals;
890
- const db = internals.db;
891
- const registry = internals.registry;
892
-
893
- // Resolve the auth collection from the explicit config.
894
- // This replaces the old `registry.getTable("users")` magic string lookup.
895
- const authCollection = authConfig.collection as CollectionConfig | undefined;
896
-
897
- // ensureAuthTablesExist works with the collection abstraction — no Drizzle leakage.
898
- await ensureAuthTablesExist(db, authCollection);
899
-
900
- // The driver bootstrapped before these tables existed, so CDC skipped
901
- // them. Instrument them now, or writes to the user table emit no
902
- // realtime events until the next restart.
903
- if (authCollection && internals.provisionCdcForTables) {
904
- const authSchema = "schema" in authCollection && typeof authCollection.schema === "string"
905
- ? authCollection.schema
906
- : "rebase";
907
- const authTable = "table" in authCollection && typeof authCollection.table === "string"
908
- ? authCollection.table
909
- : authCollection.slug;
910
- if (authTable) {
911
- try {
912
- await internals.provisionCdcForTables([{ schema: authSchema, table: authTable }]);
913
- } catch (err) {
914
- logger.warn(
915
- `⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — ` +
916
- "writes to it won't emit database-level events.",
917
- { detail: err instanceof Error ? err.message : String(err) }
918
- );
919
- }
920
- }
921
- }
922
-
923
- let emailService: EmailService | undefined;
924
- if (authConfig.email) {
925
- emailService = createEmailService(authConfig.email as EmailConfig);
926
- }
927
-
928
- // Resolve the Drizzle table for the internal UserService/AuthRepository.
929
- // These are internal Postgres-specific services that need the Drizzle table reference.
930
- const tableName = authCollection
931
- ? ("table" in authCollection && typeof authCollection.table === "string"
932
- ? authCollection.table
933
- : authCollection.slug)
934
- : undefined;
935
- const usersTable = tableName
936
- ? registry.getTable(tableName) as RebasePgTable | undefined
937
- : undefined;
938
-
939
- let usersSchemaName = "rebase";
940
- if (authCollection && "schema" in authCollection && typeof authCollection.schema === "string") {
941
- usersSchemaName = authCollection.schema;
942
- }
943
-
944
- const authTables = createAuthSchema(usersSchemaName) as unknown as AuthSchemaTables;
945
- if (usersTable) {
946
- authTables.users = usersTable as RebasePgTable;
947
- }
948
-
949
- const userService = new UserService(db, authTables);
950
- const authRepository = new PostgresAuthRepository(db, authTables);
951
-
952
- return { userService,
953
- roleService: userService,
954
- emailService,
955
- authRepository,
956
- // Bound to the same schema `ensureAuthTablesExist` just migrated, so the
957
- // health endpoint reports on the tables auth actually reads.
958
- schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection)) };
959
- },
960
-
961
- async initializeHistory(config: HistoryConfig, driverResult: InitializedDriver): Promise<{ historyService: HistoryService } | undefined> {
962
- if (!config) return undefined;
963
-
964
- const internals = driverResult.internals as PostgresDriverInternals;
965
- const db = internals.db;
966
-
967
- await ensureHistoryTableExists(db);
968
-
969
- const retention = typeof config === "object" ? config.retention : undefined;
970
- const historyService = new HistoryService(db, retention ? { ttlDays: retention } : undefined);
971
-
972
- return { historyService };
973
- },
974
-
975
- async initializeRealtime(_config: unknown, driverResult: InitializedDriver): Promise<RealtimeProvider | undefined> {
976
- const internals = driverResult.internals as PostgresDriverInternals;
977
- return internals.realtimeService;
978
- },
979
-
980
- /**
981
- * Create any collection tables, columns and enum types the database is
982
- * missing — additively, never destructively.
983
- *
984
- * This is what lets the managed runtime boot a project against a fresh
985
- * database and actually serve it. Before this, only auth tables were
986
- * ensured, so a managed tenant came up with working sign-in and a 500 on
987
- * every data route.
988
- *
989
- * Runs through the drizzle handle's underlying session so it uses the
990
- * same connection (and therefore the same privileges) the driver already
991
- * proved it can bootstrap with.
992
- */
993
- async ensureCollectionSchema(
994
- collections: unknown[],
995
- driverResult?: InitializedDriver,
996
- log?: (message: string) => void
997
- ): Promise<{ applied: number }> {
998
- const { ensureCollectionTables } = await import("./schema/ensure-collection-tables");
999
- // Runs through the drizzle handle the driver already bootstrapped
1000
- // with, so it uses exactly the connection and privileges that were
1001
- // proven to work. Every statement is DDL or a catalogue read with no
1002
- // bindable values (schema names are identifiers), and the module
1003
- // validates them before they reach a string.
1004
- const queryable = provisioningQueryable(driverResult);
1005
- const plan = await ensureCollectionTables(
1006
- queryable,
1007
- collections as Parameters<typeof ensureCollectionTables>[1],
1008
- log
1009
- );
1010
- for (const failure of plan.failures) {
1011
- logger.warn(
1012
- failure.kind === "comment-column"
1013
- // The stamp records which `search` block the generated
1014
- // column was built from. Without it the next boot cannot
1015
- // tell a changed block from an unchanged one and adopts
1016
- // the column again instead of refusing.
1017
- ? `🔍 [schema] Could not record the search fingerprint on "${failure.target}" — search works, ` +
1018
- `but a later change to the \`search\` block will not be detected: ${failure.error}`
1019
- : `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the ` +
1020
- `collection still serves, but rows are not policed by this constraint: ${failure.error}`
1021
- );
1022
- }
1023
- return { applied: plan.actions.length - plan.failures.length };
1024
- },
1025
-
1026
- /**
1027
- * Apply the collections' RLS policies — ENABLE ROW LEVEL SECURITY and the
1028
- * `securityRules` compiled to `CREATE POLICY` — so a freshly provisioned
1029
- * database serves data instead of denying every user-context read.
1030
- *
1031
- * The companion to {@link ensureCollectionSchema}: that creates the
1032
- * tables, this makes them servable. Boot runs it *after* auth
1033
- * initialization, because the generated policies call `rebase.uid()` /
1034
- * `rebase.roles()`, and `CREATE POLICY` validates those functions exist.
1035
- *
1036
- * Runs through the same drizzle handle, one statement at a time (that
1037
- * handle speaks the extended query protocol, which rejects multi-command
1038
- * strings). Failures are per-table and non-fatal: a table left un-policed
1039
- * stays RLS-enabled, so it denies rather than leaks.
1040
- */
1041
- async ensureCollectionPolicies(
1042
- collections: unknown[],
1043
- driverResult?: InitializedDriver,
1044
- log?: (message: string) => void
1045
- ): Promise<{ applied: number }> {
1046
- const { ensureCollectionPolicies } = await import("./schema/ensure-collection-policies");
1047
- const queryable = provisioningQueryable(driverResult);
1048
- const outcome = await ensureCollectionPolicies(
1049
- queryable,
1050
- collections as CollectionConfig[],
1051
- log
1052
- );
1053
-
1054
- for (const skip of outcome.skipped) {
1055
- logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
1056
- }
1057
- for (const failure of outcome.failures) {
1058
- logger.warn(
1059
- `🔐 [rls] Could not fully apply policies to "${failure.table}" — RLS is on, so it denies until this is resolved: ${failure.error}`
1060
- );
1061
- }
1062
-
1063
- // RLS could not be turned on at all. The schema-wide grant to the
1064
- // user role has already run by this point, so without the revoke
1065
- // below the table is readable and writable by every authenticated
1066
- // request with no row filtering — the one state this boot path must
1067
- // never leave behind, and the one it used to describe as "locked".
1068
- const unrevoked = outcome.unsecured.filter(u => !u.grantWithdrawn);
1069
- for (const u of outcome.unsecured.filter(u => u.grantWithdrawn)) {
1070
- logger.error(
1071
- `🔐 [rls] Could not enable row-level security on "${u.table}": ${u.error}. ` +
1072
- `Its privileges have been revoked from ${REBASE_USER_ROLE}, so the table is ` +
1073
- "unreachable rather than unprotected. Reads and writes to that collection will " +
1074
- "fail until RLS can be enabled."
1075
- );
1076
- }
1077
- if (unrevoked.length > 0) {
1078
- // Neither securable nor closable. Serving here would mean
1079
- // handing every authenticated caller unfiltered access, so the
1080
- // boot fails instead — this is the one failure that is not
1081
- // survivable per-table.
1082
- throw new Error(
1083
- "Refusing to start: row-level security could not be enabled on " +
1084
- unrevoked.map(u => `"${u.table}" (${u.error})`).join(", ") +
1085
- `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. ` +
1086
- "The table would be served with no row filtering. Fix the database permissions " +
1087
- "(the connection role must own these tables, or be able to ALTER them) and boot again."
1088
- );
1089
- }
1090
-
1091
- // Retire the pre-1.0 `auth` schema now that the policies above no
1092
- // longer call into it. Deliberately after, and deliberately quiet:
1093
- // Postgres refuses to drop a function an RLS policy still
1094
- // references, so on a database where some table has not been
1095
- // recompiled yet this is expected to fail and succeed on a later
1096
- // boot. See DROP_LEGACY_AUTH_SCHEMA_SQL for the guards that keep it
1097
- // off a Supabase `auth` schema.
1098
- try {
1099
- const { dropLegacyAuthSchema } = await import("./schema/rls-bootstrap-sql");
1100
- await dropLegacyAuthSchema(
1101
- async (text) => (await queryable.query<Record<string, unknown>>(text)).rows,
1102
- { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
1103
- );
1104
- } catch (err) {
1105
- logger.info(
1106
- "Left the legacy `auth` schema in place: " +
1107
- (err instanceof Error ? err.message : String(err))
1108
- );
1109
- }
1110
-
1111
- return { applied: outcome.policiesApplied };
1112
- },
1113
-
1114
- /**
1115
- * Read what the last provisioning boot recorded, or `null`.
1116
- *
1117
- * The meta schema is `rebase` rather than the auth stamp's — see
1118
- * `schema/collections-schema-version.ts` for why the two can differ.
1119
- */
1120
- async readCollectionsSchemaVersion(driverResult?: InitializedDriver): Promise<string | null> {
1121
- const db = provisioningDb(driverResult);
1122
- if (!db) return null;
1123
- const { readCollectionsSchemaVersion } = await import("./schema/collections-schema-version");
1124
- return readCollectionsSchemaVersion(db as never, SCHEMA_META_SCHEMA);
1125
- },
1126
-
1127
- /** Record what this process just applied. Only the provisioning process calls this. */
1128
- async stampCollectionsSchemaVersion(version: string, driverResult?: InitializedDriver): Promise<void> {
1129
- const db = provisioningDb(driverResult);
1130
- if (!db) return;
1131
- const { stampCollectionsSchemaVersion } = await import("./schema/collections-schema-version");
1132
- await stampCollectionsSchemaVersion(db as never, SCHEMA_META_SCHEMA, version);
1133
- },
1134
-
1135
- getAdmin(driverResult: InitializedDriver): DatabaseAdmin | undefined {
1136
- const internals = driverResult.internals as PostgresDriverInternals;
1137
- return internals.driver.admin;
1138
- },
1139
-
1140
- mountRoutes(app: unknown, basePath: string, driverResult: InitializedDriver): void {
1141
- // The coordinator handles auth/storage/data routes.
1142
- // This hook is for driver-specific extensions only.
1143
- // Currently Postgres doesn't need additional routes beyond what the coordinator mounts.
1144
- },
1145
-
1146
- async initializeWebsockets(server: unknown, realtimeService: RealtimeProvider, driver: DataDriver, config?: unknown, adapter?: unknown): Promise<void> {
1147
- const { createPostgresWebSocket } = await import("./websocket");
1148
- createPostgresWebSocket(
1149
- server as import("http").Server,
1150
- realtimeService as RealtimeService,
1151
- driver as PostgresBackendDriver,
1152
- config as { requireAuth?: boolean; jwtSecret?: string; serviceKey?: string },
1153
- adapter as AuthAdapter | undefined
1154
- );
1155
- }
1156
- };
1157
- }