@rebasepro/server-postgres 0.17.3 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/BranchService-ByLfM_Tt.js +360 -0
- package/dist/BranchService-ByLfM_Tt.js.map +1 -0
- package/dist/__vite-browser-external-BnuHet1e.js +46 -0
- package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
- package/dist/arg-Dni7MzLB.js +87 -0
- package/dist/arg-Dni7MzLB.js.map +1 -0
- package/dist/auth-users-columns-D2LBFrMH.js +116 -0
- package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/backup-cli-oM9WQVlV.js +408 -0
- package/dist/backup-cli-oM9WQVlV.js.map +1 -0
- package/dist/backup-service-Br3tqwsy.js +715 -0
- package/dist/backup-service-Br3tqwsy.js.map +1 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors-DxN4yzdH.js +657 -0
- package/dist/cli-errors-DxN4yzdH.js.map +1 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/cli-output-CNdMql-L.js +47 -0
- package/dist/cli-output-CNdMql-L.js.map +1 -0
- package/dist/cli.js +2020 -0
- package/dist/cli.js.map +1 -0
- package/dist/collection-index-BHgT_TKd.js +276 -0
- package/dist/collection-index-BHgT_TKd.js.map +1 -0
- package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
- package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
- package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
- package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/doctor-D7HWmGzs.js +974 -0
- package/dist/doctor-D7HWmGzs.js.map +1 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
- package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
- package/dist/ensure-tables-CIojK-Rx.js +650 -0
- package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
- package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +7423 -1133
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
- package/dist/policy-drift-kNJ6X2Uj.js +313 -0
- package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
- package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
- package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
- package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
- package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor-cli.js +129 -0
- package/dist/schema/doctor-cli.js.map +1 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema.js +24 -0
- package/dist/schema/generate-drizzle-schema.js.map +1 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl.js +138 -0
- package/dist/schema/generate-postgres-ddl.js.map +1 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/schema/introspect-db.js +866 -0
- package/dist/schema/introspect-db.js.map +1 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/source-Br7L7GOI.js +410 -0
- package/dist/source-Br7L7GOI.js.map +1 -0
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/dist/vector-index-DkWxm2de.js +309 -0
- package/dist/vector-index-DkWxm2de.js.map +1 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- 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
|
-
}
|