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