@rebasepro/server-postgresql 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +24 -18
- package/src/PostgresBackendDriver.ts +65 -44
- package/src/PostgresBootstrapper.ts +34 -2
- package/src/auth/ensure-tables.ts +56 -1
- package/src/auth/services.ts +94 -1
- package/src/cli-errors.ts +162 -0
- package/src/cli-helpers.ts +183 -0
- package/src/cli.ts +198 -251
- package/src/schema/auth-default-policies.ts +90 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +4 -3
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +524 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +10 -8
- package/src/services/entityService.ts +28 -3
- package/src/utils/pg-error-utils.ts +16 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +9 -0
- package/test/auth-default-policies.test.ts +89 -0
- package/test/cli-helpers-extended.test.ts +324 -0
- package/test/cli-helpers.test.ts +59 -0
- package/test/connection.test.ts +292 -0
- package/test/databasePoolManager.test.ts +289 -0
- package/test/doctor-extended.test.ts +443 -0
- package/test/e2e/db-e2e.test.ts +293 -0
- package/test/e2e/pg-setup.ts +79 -0
- package/test/entity-persist-composite-keys.test.ts +451 -0
- package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
- package/test/generate-postgres-ddl.test.ts +300 -0
- package/test/mfa-service.test.ts +544 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vitest.e2e.config.ts +10 -0
- package/build-errors.txt +0 -37
- package/dist/PostgresAdapter.d.ts +0 -6
- package/dist/PostgresBackendDriver.d.ts +0 -110
- package/dist/PostgresBootstrapper.d.ts +0 -46
- package/dist/auth/ensure-tables.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -231
- package/dist/cli.d.ts +0 -1
- package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
- package/dist/connection.d.ts +0 -65
- package/dist/data-transformer.d.ts +0 -55
- package/dist/databasePoolManager.d.ts +0 -20
- package/dist/history/HistoryService.d.ts +0 -71
- package/dist/history/ensure-history-table.d.ts +0 -7
- package/dist/index.d.ts +0 -14
- package/dist/index.es.js +0 -10803
- package/dist/index.es.js.map +0 -1
- package/dist/interfaces.d.ts +0 -18
- package/dist/schema/auth-schema.d.ts +0 -2149
- package/dist/schema/doctor-cli.d.ts +0 -2
- package/dist/schema/doctor.d.ts +0 -52
- package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
- package/dist/schema/generate-drizzle-schema.d.ts +0 -1
- package/dist/schema/introspect-db-inference.d.ts +0 -5
- package/dist/schema/introspect-db-logic.d.ts +0 -118
- package/dist/schema/introspect-db.d.ts +0 -1
- package/dist/schema/test-schema.d.ts +0 -24
- package/dist/services/BranchService.d.ts +0 -47
- package/dist/services/EntityFetchService.d.ts +0 -214
- package/dist/services/EntityPersistService.d.ts +0 -40
- package/dist/services/RelationService.d.ts +0 -98
- package/dist/services/entity-helpers.d.ts +0 -38
- package/dist/services/entityService.d.ts +0 -110
- package/dist/services/index.d.ts +0 -4
- package/dist/services/realtimeService.d.ts +0 -220
- package/dist/types.d.ts +0 -3
- package/dist/utils/drizzle-conditions.d.ts +0 -138
- package/dist/utils/pg-array-null-patch.d.ts +0 -16
- package/dist/utils/pg-error-utils.d.ts +0 -55
- package/dist/websocket.d.ts +0 -11
package/src/cli.ts
CHANGED
|
@@ -3,32 +3,20 @@ import chalk from "chalk";
|
|
|
3
3
|
import { execa } from "execa";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import fs from "fs";
|
|
6
|
-
import { execSync } from "child_process";
|
|
7
6
|
import { fileURLToPath } from "url";
|
|
8
7
|
import { logger } from "@rebasepro/server-core";
|
|
8
|
+
import {
|
|
9
|
+
resolveLocalBin,
|
|
10
|
+
getTableIncludes,
|
|
11
|
+
getDevDatabaseUrl,
|
|
12
|
+
ensureDevDatabaseExists,
|
|
13
|
+
getTableExcludes
|
|
14
|
+
} from "./cli-helpers";
|
|
15
|
+
import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
|
|
16
|
+
|
|
17
|
+
const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
9
19
|
|
|
10
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
|
|
12
|
-
function resolveLocalBin(binName: string): string | null {
|
|
13
|
-
let cwd = process.cwd();
|
|
14
|
-
// Try to find node_modules/.bin upwards
|
|
15
|
-
while (true) {
|
|
16
|
-
const candidate = path.join(cwd, "node_modules", ".bin", binName);
|
|
17
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
18
|
-
const parent = path.dirname(cwd);
|
|
19
|
-
if (parent === cwd) break;
|
|
20
|
-
cwd = parent;
|
|
21
|
-
}
|
|
22
|
-
// Fall back to globally installed binary via which/where
|
|
23
|
-
try {
|
|
24
|
-
const cmd = process.platform === "win32" ? `where ${binName}` : `which ${binName}`;
|
|
25
|
-
const globalPath = execSync(cmd, { encoding: "utf-8" }).trim().split("\n")[0].trim();
|
|
26
|
-
if (globalPath && fs.existsSync(globalPath)) return globalPath;
|
|
27
|
-
} catch {
|
|
28
|
-
// not found globally
|
|
29
|
-
}
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
20
|
|
|
33
21
|
export async function runPluginCommand(args: string[]) {
|
|
34
22
|
const domain = args[0]; // "db" or "schema"
|
|
@@ -47,7 +35,7 @@ export async function runPluginCommand(args: string[]) {
|
|
|
47
35
|
}
|
|
48
36
|
|
|
49
37
|
async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
50
|
-
const VALID_ACTIONS = ["push", "generate", "migrate", "
|
|
38
|
+
const VALID_ACTIONS = ["push", "generate", "migrate", "branch"];
|
|
51
39
|
if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
|
|
52
40
|
logger.error(chalk.red(`Unknown db command. Valid: ${VALID_ACTIONS.join(", ")}`));
|
|
53
41
|
process.exit(1);
|
|
@@ -58,50 +46,98 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
58
46
|
return;
|
|
59
47
|
}
|
|
60
48
|
|
|
49
|
+
const argsList = arg(
|
|
50
|
+
{
|
|
51
|
+
"--collections": String,
|
|
52
|
+
"-c": "--collections"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
argv: rawArgs.slice(2),
|
|
56
|
+
permissive: true
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
|
|
60
|
+
|
|
61
61
|
if (subcommand === "generate") {
|
|
62
62
|
logger.info("");
|
|
63
63
|
logger.info(chalk.bold(" 📦 Rebase DB Generate"));
|
|
64
|
-
logger.info(chalk.gray(" Step 1/2: Generating Drizzle schema from collections..."));
|
|
64
|
+
logger.info(chalk.gray(" Step 1/2: Generating Drizzle schema & Postgres DDL from collections..."));
|
|
65
65
|
logger.info("");
|
|
66
66
|
await schemaCommand("generate", rawArgs);
|
|
67
|
+
await generatePostgresDdlCommand(rawArgs);
|
|
67
68
|
logger.info("");
|
|
68
|
-
logger.info(chalk.gray(" Step 2/2: Generating SQL migration files..."));
|
|
69
|
+
logger.info(chalk.gray(" Step 2/2: Generating SQL migration files with Atlas..."));
|
|
69
70
|
logger.info("");
|
|
70
|
-
|
|
71
|
-
await
|
|
71
|
+
const migrationName = argsList._[0] || "migration";
|
|
72
|
+
await runAtlas("migrate", ["diff", migrationName, "--dir", "file://drizzle/migrations", "--to", "file://drizzle/schema.sql"], collectionsPath);
|
|
73
|
+
|
|
74
|
+
// Post-process the newest migration file
|
|
75
|
+
try {
|
|
76
|
+
const migrationsDir = path.resolve(process.cwd(), "drizzle", "migrations");
|
|
77
|
+
if (fs.existsSync(migrationsDir)) {
|
|
78
|
+
const files = fs.readdirSync(migrationsDir);
|
|
79
|
+
const sqlFiles = files
|
|
80
|
+
.filter(f => f.endsWith(".sql"))
|
|
81
|
+
.sort();
|
|
82
|
+
if (sqlFiles.length > 0) {
|
|
83
|
+
const newestMigrationFile = path.join(migrationsDir, sqlFiles[sqlFiles.length - 1]);
|
|
84
|
+
|
|
85
|
+
// Make CREATE SCHEMA idempotent so it doesn't conflict with
|
|
86
|
+
// --revisions-schema (Atlas pre-creates the rebase schema
|
|
87
|
+
// for its revision table before running migrations).
|
|
88
|
+
let migrationContent = fs.readFileSync(newestMigrationFile, "utf-8");
|
|
89
|
+
migrationContent = migrationContent.replace(
|
|
90
|
+
/CREATE SCHEMA (?!IF NOT EXISTS)("[^"]+");/g,
|
|
91
|
+
"CREATE SCHEMA IF NOT EXISTS $1;"
|
|
92
|
+
);
|
|
93
|
+
fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
|
|
94
|
+
|
|
95
|
+
// Append RLS policies
|
|
96
|
+
const policiesFile = path.resolve(process.cwd(), "drizzle", "policies.sql");
|
|
97
|
+
if (fs.existsSync(policiesFile)) {
|
|
98
|
+
const policiesContent = fs.readFileSync(policiesFile, "utf-8");
|
|
99
|
+
fs.appendFileSync(newestMigrationFile, "\n\n" + policiesContent);
|
|
100
|
+
logger.info(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
|
|
101
|
+
|
|
102
|
+
// Re-hash the migration directory
|
|
103
|
+
logger.info(chalk.gray(" Re-hashing migration files..."));
|
|
104
|
+
await runAtlas("migrate", ["hash", "--dir", "file://drizzle/migrations"], collectionsPath);
|
|
105
|
+
logger.info(chalk.gray(" ✓ Migration directory checksum updated successfully."));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logger.warn(chalk.yellow(` ⚠️ Failed to append policies or re-hash migration: ${err instanceof Error ? err.message : String(err)}`));
|
|
111
|
+
}
|
|
112
|
+
|
|
72
113
|
logger.info("");
|
|
73
114
|
logger.info(` You can now run ${chalk.bold.green("rebase db migrate")} to apply the migrations to your database.`);
|
|
74
115
|
logger.info("");
|
|
75
|
-
} else if (subcommand === "pull") {
|
|
76
|
-
logger.info("");
|
|
77
|
-
logger.info(chalk.yellow(" ⚠ \"rebase db pull\" has been removed."));
|
|
78
|
-
logger.info(chalk.gray(" Use \"rebase schema introspect\" instead."));
|
|
79
|
-
logger.info("");
|
|
80
|
-
process.exit(1);
|
|
81
116
|
} else {
|
|
82
117
|
logger.info("");
|
|
83
118
|
logger.info(chalk.bold(` 🗄️ Rebase DB ${subcommand.charAt(0).toUpperCase() + subcommand.slice(1)}`));
|
|
84
119
|
logger.info("");
|
|
85
120
|
|
|
86
121
|
if (subcommand === "push") {
|
|
87
|
-
logger.info(chalk.gray(" Step 1/
|
|
122
|
+
logger.info(chalk.gray(" Step 1/3: Generating Drizzle schema & Postgres DDL from collections..."));
|
|
88
123
|
logger.info("");
|
|
89
124
|
await schemaCommand("generate", rawArgs);
|
|
125
|
+
await generatePostgresDdlCommand(rawArgs);
|
|
90
126
|
logger.info("");
|
|
91
|
-
logger.info(chalk.gray(" Step 2/
|
|
127
|
+
logger.info(chalk.gray(" Step 2/3: Pushing schema to database with Atlas..."));
|
|
92
128
|
logger.info("");
|
|
93
|
-
await
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
await runAtlas("schema", ["apply", "--to", "file://drizzle/schema.sql", "--auto-approve"], collectionsPath);
|
|
130
|
+
logger.info("");
|
|
131
|
+
|
|
132
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
133
|
+
if (databaseUrl) {
|
|
134
|
+
await applyPolicies(databaseUrl);
|
|
135
|
+
} else {
|
|
136
|
+
logger.warn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
|
|
101
137
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await
|
|
138
|
+
} else if (subcommand === "migrate") {
|
|
139
|
+
const extraArgs = argsList._.filter(arg => arg !== "migrate");
|
|
140
|
+
await runAtlas("migrate", ["apply", "--dir", "file://drizzle/migrations", ...extraArgs], collectionsPath);
|
|
105
141
|
}
|
|
106
142
|
|
|
107
143
|
logger.info("");
|
|
@@ -110,6 +146,35 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
110
146
|
}
|
|
111
147
|
}
|
|
112
148
|
|
|
149
|
+
async function applyPolicies(databaseUrl: string): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
const policiesPath = path.resolve(process.cwd(), "drizzle", "policies.sql");
|
|
152
|
+
if (!fs.existsSync(policiesPath)) return;
|
|
153
|
+
|
|
154
|
+
logger.info(chalk.gray(" Step 3/3: Applying RLS policies to database..."));
|
|
155
|
+
logger.info("");
|
|
156
|
+
|
|
157
|
+
const policiesContent = fs.readFileSync(policiesPath, "utf-8");
|
|
158
|
+
const { Client } = await import("pg");
|
|
159
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
160
|
+
await client.connect();
|
|
161
|
+
try {
|
|
162
|
+
await client.query(policiesContent);
|
|
163
|
+
logger.info(chalk.green(" ✓ RLS policies applied successfully."));
|
|
164
|
+
} finally {
|
|
165
|
+
await client.end();
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const hint = diagnoseDbError(err, databaseUrl);
|
|
169
|
+
if (hint) {
|
|
170
|
+
logger.error(hint);
|
|
171
|
+
} else {
|
|
172
|
+
logger.error(chalk.red(` ✗ Failed to apply RLS policies: ${err instanceof Error ? err.message : String(err)}`));
|
|
173
|
+
}
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
113
178
|
async function branchCommand(rawArgs: string[]): Promise<void> {
|
|
114
179
|
const branchAction = rawArgs[2]; // create, list, delete, info
|
|
115
180
|
|
|
@@ -296,118 +361,13 @@ function timeAgo(date: Date): string {
|
|
|
296
361
|
return `${days}d ago`;
|
|
297
362
|
}
|
|
298
363
|
|
|
299
|
-
/**
|
|
300
|
-
* Post-process generated migration files to fix statement ordering issues.
|
|
301
|
-
*
|
|
302
|
-
* Drizzle-kit can emit DROP POLICY statements *after* ALTER TABLE ... ALTER COLUMN
|
|
303
|
-
* for the same table. Postgres rejects this with:
|
|
304
|
-
* "cannot alter type of a column used in a policy definition"
|
|
305
|
-
*
|
|
306
|
-
* This scans the drizzle output directory for the most recently modified .sql file
|
|
307
|
-
* and reorders statements so that DROP POLICY on a table always precedes any
|
|
308
|
-
* ALTER TABLE on that same table.
|
|
309
|
-
*/
|
|
310
|
-
async function fixMigrationStatementOrder(): Promise<void> {
|
|
311
|
-
const drizzleDir = path.join(process.cwd(), "drizzle");
|
|
312
|
-
if (!fs.existsSync(drizzleDir)) return;
|
|
313
|
-
|
|
314
|
-
// Find the most recently modified .sql file
|
|
315
|
-
const sqlFiles = fs.readdirSync(drizzleDir)
|
|
316
|
-
.filter(f => f.endsWith(".sql"))
|
|
317
|
-
.map(f => ({
|
|
318
|
-
name: f,
|
|
319
|
-
mtime: fs.statSync(path.join(drizzleDir, f)).mtimeMs
|
|
320
|
-
}))
|
|
321
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
322
|
-
|
|
323
|
-
if (sqlFiles.length === 0) return;
|
|
324
|
-
|
|
325
|
-
const latestFile = path.join(drizzleDir, sqlFiles[0].name);
|
|
326
|
-
let content = fs.readFileSync(latestFile, "utf-8");
|
|
327
|
-
const originalContent = content;
|
|
328
|
-
|
|
329
|
-
// Replace CREATE SCHEMA with CREATE SCHEMA IF NOT EXISTS to prevent failures
|
|
330
|
-
content = content.replace(/CREATE SCHEMA "([^"]+)";/g, 'CREATE SCHEMA IF NOT EXISTS "$1";');
|
|
331
|
-
|
|
332
|
-
const DELIMITER = "--> statement-breakpoint";
|
|
333
|
-
const parts = content.split(DELIMITER);
|
|
334
|
-
|
|
335
|
-
// Parse each statement to detect DROP POLICY and ALTER TABLE targets
|
|
336
|
-
const dropPolicyRe = /DROP\s+POLICY\s+.+?\s+ON\s+"([^"]+)"/i;
|
|
337
|
-
const alterTableRe = /ALTER\s+TABLE\s+"([^"]+)"\s+ALTER\s+COLUMN/i;
|
|
338
|
-
|
|
339
|
-
// Collect indices of DROP POLICY statements and what tables they target
|
|
340
|
-
const dropPolicyIndices = new Map<string, number[]>(); // table -> indices
|
|
341
|
-
const alterColumnIndices = new Map<string, number>(); // table -> first ALTER index
|
|
342
|
-
|
|
343
|
-
for (let i = 0; i < parts.length; i++) {
|
|
344
|
-
const stmt = parts[i].trim();
|
|
345
|
-
const dropMatch = stmt.match(dropPolicyRe);
|
|
346
|
-
if (dropMatch) {
|
|
347
|
-
const table = dropMatch[1];
|
|
348
|
-
if (!dropPolicyIndices.has(table)) dropPolicyIndices.set(table, []);
|
|
349
|
-
dropPolicyIndices.get(table)!.push(i);
|
|
350
|
-
}
|
|
351
|
-
const alterMatch = stmt.match(alterTableRe);
|
|
352
|
-
if (alterMatch) {
|
|
353
|
-
const table = alterMatch[1];
|
|
354
|
-
if (!alterColumnIndices.has(table)) alterColumnIndices.set(table, i);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Check if any DROP POLICY comes after an ALTER COLUMN on the same table
|
|
359
|
-
let needsReorder = false;
|
|
360
|
-
for (const [table, dropIndices] of dropPolicyIndices) {
|
|
361
|
-
const firstAlter = alterColumnIndices.get(table);
|
|
362
|
-
if (firstAlter !== undefined) {
|
|
363
|
-
for (const dropIdx of dropIndices) {
|
|
364
|
-
if (dropIdx > firstAlter) {
|
|
365
|
-
needsReorder = true;
|
|
366
|
-
break;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
if (needsReorder) break;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (!needsReorder) {
|
|
374
|
-
if (content !== originalContent) {
|
|
375
|
-
fs.writeFileSync(latestFile, content, "utf-8");
|
|
376
|
-
}
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
364
|
|
|
380
|
-
// Reorder: move DROP POLICY statements for affected tables before their ALTER TABLE
|
|
381
|
-
// Strategy: stable sort — DROP POLICY on table X gets priority over ALTER on table X
|
|
382
|
-
const stmtEntries = parts.map((stmt, idx) => ({ stmt,
|
|
383
|
-
idx }));
|
|
384
|
-
|
|
385
|
-
stmtEntries.sort((a, b) => {
|
|
386
|
-
const aDropMatch = a.stmt.trim().match(dropPolicyRe);
|
|
387
|
-
const bAlterMatch = b.stmt.trim().match(alterTableRe);
|
|
388
|
-
const bDropMatch = b.stmt.trim().match(dropPolicyRe);
|
|
389
|
-
const aAlterMatch = a.stmt.trim().match(alterTableRe);
|
|
390
|
-
|
|
391
|
-
// If a is DROP POLICY on table X and b is ALTER on table X, a goes first
|
|
392
|
-
if (aDropMatch && bAlterMatch && aDropMatch[1] === bAlterMatch[1]) return -1;
|
|
393
|
-
// If b is DROP POLICY on table X and a is ALTER on table X, b goes first
|
|
394
|
-
if (bDropMatch && aAlterMatch && bDropMatch[1] === aAlterMatch[1]) return 1;
|
|
395
|
-
// Otherwise preserve original order
|
|
396
|
-
return a.idx - b.idx;
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
const reordered = stmtEntries.map(e => e.stmt).join(DELIMITER);
|
|
400
|
-
fs.writeFileSync(latestFile, reordered, "utf-8");
|
|
401
|
-
|
|
402
|
-
logger.info(chalk.yellow(` \u26A0 Reordered migration statements in ${sqlFiles[0].name} (DROP POLICY before ALTER COLUMN)`));
|
|
403
|
-
}
|
|
404
365
|
|
|
405
|
-
async function
|
|
406
|
-
const
|
|
407
|
-
if (!
|
|
408
|
-
logger.error(chalk.red("✗ Could not find
|
|
409
|
-
const
|
|
410
|
-
const installCmd = isNpm ? "npm install -D drizzle-kit" : "pnpm add -D drizzle-kit";
|
|
366
|
+
async function runAtlas(domain: "schema" | "migrate", args: string[], collectionsPath?: string): Promise<void> {
|
|
367
|
+
const atlasBin = resolveLocalBin("atlas");
|
|
368
|
+
if (!atlasBin) {
|
|
369
|
+
logger.error(chalk.red("✗ Could not find atlas binary."));
|
|
370
|
+
const installCmd = "pnpm add -D @ariga/atlas";
|
|
411
371
|
logger.error(chalk.gray(` Install it with: ${installCmd}`));
|
|
412
372
|
process.exit(1);
|
|
413
373
|
}
|
|
@@ -436,109 +396,97 @@ async function runDrizzleKit(action: string, _rawArgs: string[]): Promise<void>
|
|
|
436
396
|
}
|
|
437
397
|
}
|
|
438
398
|
} catch {
|
|
439
|
-
//
|
|
399
|
+
// ignore
|
|
440
400
|
}
|
|
441
401
|
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const drizzleKitArgs = [action];
|
|
447
|
-
if (action === "push") {
|
|
448
|
-
drizzleKitArgs.push("--strict", "--verbose");
|
|
402
|
+
const databaseUrl = env.DATABASE_URL;
|
|
403
|
+
if (!databaseUrl) {
|
|
404
|
+
logger.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
405
|
+
process.exit(1);
|
|
449
406
|
}
|
|
450
407
|
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
408
|
+
// Pre-flight: verify the database is reachable before running Atlas.
|
|
409
|
+
// This catches ECONNREFUSED / auth failures with a friendly banner
|
|
410
|
+
// instead of letting Atlas surface a raw error.
|
|
411
|
+
await checkDatabaseConnectivity(databaseUrl);
|
|
412
|
+
|
|
413
|
+
const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
|
|
414
|
+
await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
|
|
415
|
+
|
|
416
|
+
const atlasArgs = [domain, ...args];
|
|
417
|
+
|
|
418
|
+
if (domain === "schema") {
|
|
419
|
+
if (args.includes("apply")) {
|
|
420
|
+
atlasArgs.push("--url", databaseUrl, "--dev-url", devDatabaseUrl);
|
|
421
|
+
} else if (args.includes("clean") || args.includes("inspect")) {
|
|
422
|
+
atlasArgs.push("--url", databaseUrl);
|
|
461
423
|
}
|
|
462
|
-
|
|
463
|
-
|
|
424
|
+
} else if (domain === "migrate") {
|
|
425
|
+
if (args.includes("diff")) {
|
|
426
|
+
atlasArgs.push("--dev-url", devDatabaseUrl);
|
|
427
|
+
} else if (args.includes("apply") || args.includes("status")) {
|
|
428
|
+
atlasArgs.push("--url", databaseUrl, "--revisions-schema", "rebase");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (domain === "schema" && args.includes("apply") && collectionsPath) {
|
|
433
|
+
const excludes = await getTableExcludes(databaseUrl, collectionsPath);
|
|
434
|
+
for (const exc of excludes) {
|
|
435
|
+
atlasArgs.push("--exclude", exc);
|
|
464
436
|
}
|
|
465
437
|
}
|
|
466
438
|
|
|
467
439
|
try {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
reject: false
|
|
479
|
-
});
|
|
440
|
+
await execa(atlasBin, atlasArgs, {
|
|
441
|
+
cwd: process.cwd(),
|
|
442
|
+
stdio: "inherit",
|
|
443
|
+
env
|
|
444
|
+
});
|
|
445
|
+
} catch (err: unknown) {
|
|
446
|
+
logger.error(chalk.red(`\n✗ atlas ${domain} ${args.join(" ")} failed.\n`));
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
480
450
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
451
|
+
async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
|
|
452
|
+
const argsList = arg(
|
|
453
|
+
{
|
|
454
|
+
"--collections": String,
|
|
455
|
+
"--output": String,
|
|
456
|
+
"-c": "--collections",
|
|
457
|
+
"-o": "--output"
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
argv: rawArgs.slice(2),
|
|
461
|
+
permissive: true
|
|
462
|
+
}
|
|
463
|
+
);
|
|
484
464
|
|
|
485
|
-
|
|
465
|
+
const ddlScript = path.join(__cliDirname, "schema", "generate-postgres-ddl.ts");
|
|
466
|
+
const tsxBin = resolveLocalBin("tsx");
|
|
467
|
+
if (!tsxBin) {
|
|
468
|
+
logger.error(chalk.red("✗ Could not find tsx binary."));
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
486
471
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const stdout = stripAnsi(result.stdout || "").trim();
|
|
490
|
-
const stderr = stripAnsi(result.stderr || "").trim();
|
|
472
|
+
const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
|
|
473
|
+
const outputPath = argsList["--output"] || path.join("drizzle", "schema.sql");
|
|
491
474
|
|
|
492
|
-
|
|
493
|
-
|
|
475
|
+
const cmdParts = [
|
|
476
|
+
tsxBin,
|
|
477
|
+
ddlScript,
|
|
478
|
+
`--collections=${collectionsPath}`,
|
|
479
|
+
`--output=${outputPath}`
|
|
480
|
+
];
|
|
494
481
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const errorOutput = stderr || stdout;
|
|
502
|
-
if (errorOutput) {
|
|
503
|
-
const lines = errorOutput.split("\n").filter((l: string) => l.trim());
|
|
504
|
-
let printedCount = 0;
|
|
505
|
-
for (const line of lines) {
|
|
506
|
-
if (line.toLowerCase().includes("error") || line.includes("cannot") || line.includes("already exists") || line.includes("does not exist") || line.includes("violates") || line.includes("permission denied")) {
|
|
507
|
-
logger.error(chalk.red(` ${line.trim()}`));
|
|
508
|
-
printedCount++;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
if (printedCount === 0) {
|
|
512
|
-
lines.slice(0, 10).forEach(line => logger.error(chalk.red(` ${line.trim()}`)));
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
logger.error("");
|
|
517
|
-
process.exit(1);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
482
|
+
try {
|
|
483
|
+
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
484
|
+
cwd: process.cwd(),
|
|
485
|
+
stdio: "inherit",
|
|
486
|
+
env: { ...process.env as Record<string, string> }
|
|
487
|
+
});
|
|
520
488
|
} catch (err: unknown) {
|
|
521
|
-
|
|
522
|
-
// eslint-disable-next-line no-control-regex
|
|
523
|
-
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\[?[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣷⣯⣟⡿⢿⣻⣽]+\]\s*/g, "");
|
|
524
|
-
const cleaned = stripAnsi(msg).trim();
|
|
525
|
-
const hasTtyError = cleaned.includes("Interactive prompts require a TTY terminal");
|
|
526
|
-
logger.error(chalk.red(`\n✗ drizzle-kit ${action} failed.\n`));
|
|
527
|
-
if (hasTtyError) {
|
|
528
|
-
logger.error(chalk.red(" Error: Interactive prompts require a TTY terminal."));
|
|
529
|
-
logger.error(chalk.gray(" Please run with --force to skip interactive prompts or run in an interactive terminal."));
|
|
530
|
-
} else {
|
|
531
|
-
const lines = cleaned.split("\n").filter((l: string) => l.trim());
|
|
532
|
-
for (const line of lines) {
|
|
533
|
-
if (line.toLowerCase().includes("error") || line.includes("cannot") || line.includes("already exists") || line.includes("does not exist") || line.includes("violates")) {
|
|
534
|
-
logger.error(chalk.red(` ${line.trim()}`));
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
if (lines.length === 0) {
|
|
538
|
-
logger.error(chalk.gray(` ${cleaned}`));
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
logger.error("");
|
|
489
|
+
logger.error(chalk.red(`✗ Failed to run Postgres DDL generator: ${err instanceof Error ? err.message : String(err)}`));
|
|
542
490
|
process.exit(1);
|
|
543
491
|
}
|
|
544
492
|
}
|
|
@@ -561,8 +509,8 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
|
|
|
561
509
|
);
|
|
562
510
|
|
|
563
511
|
// Here we just invoke the local generate-drizzle-schema.ts since we are inside the postgresql-backend
|
|
564
|
-
// If installed in node_modules,
|
|
565
|
-
const generatorScript = path.join(
|
|
512
|
+
// If installed in node_modules, __cliDirname is node_modules/@rebasepro/server-postgresql/dist or src.
|
|
513
|
+
const generatorScript = path.join(__cliDirname, "schema", "generate-drizzle-schema.ts");
|
|
566
514
|
if (!fs.existsSync(generatorScript)) {
|
|
567
515
|
logger.error(chalk.red(`✗ Could not find generate-drizzle-schema.ts at ${generatorScript}`));
|
|
568
516
|
process.exit(1);
|
|
@@ -619,7 +567,7 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
|
|
|
619
567
|
}
|
|
620
568
|
);
|
|
621
569
|
|
|
622
|
-
const introspectScript = path.join(
|
|
570
|
+
const introspectScript = path.join(__cliDirname, "schema", "introspect-db.ts");
|
|
623
571
|
if (!fs.existsSync(introspectScript)) {
|
|
624
572
|
logger.error(chalk.red(`✗ Could not find introspect-db.ts at ${introspectScript}`));
|
|
625
573
|
process.exit(1);
|
|
@@ -677,7 +625,7 @@ async function doctorPluginCommand(rawArgs: string[]): Promise<void> {
|
|
|
677
625
|
}
|
|
678
626
|
);
|
|
679
627
|
|
|
680
|
-
const doctorScript = path.join(
|
|
628
|
+
const doctorScript = path.join(__cliDirname, "schema", "doctor-cli.ts");
|
|
681
629
|
if (!fs.existsSync(doctorScript)) {
|
|
682
630
|
logger.error(chalk.red(`✗ Could not find doctor.ts at ${doctorScript}`));
|
|
683
631
|
process.exit(1);
|
|
@@ -714,8 +662,7 @@ async function doctorPluginCommand(rawArgs: string[]): Promise<void> {
|
|
|
714
662
|
|
|
715
663
|
|
|
716
664
|
// Entry point when called directly
|
|
717
|
-
|
|
718
|
-
const argv1Real = process.argv[1] ? fsSync.realpathSync(process.argv[1]) : "";
|
|
665
|
+
const argv1Real = process.argv[1] ? fs.realpathSync(process.argv[1]) : "";
|
|
719
666
|
if (import.meta.url === `file://${argv1Real}`) {
|
|
720
667
|
// Drop node and script path
|
|
721
668
|
runPluginCommand(process.argv.slice(2)).catch(() => process.exit(1));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { EntityCollection, SecurityRule, SecurityOperation, AuthCollectionConfig, isPostgresCollection } from "@rebasepro/types";
|
|
2
|
+
import { getTableName } from "@rebasepro/common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default RLS for authentication collections.
|
|
6
|
+
*
|
|
7
|
+
* Users are persisted through the same write path as any other collection —
|
|
8
|
+
* the only thing that distinguishes them is the `auth` flag. That means
|
|
9
|
+
* privileged columns (most importantly `roles`) would be editable by any
|
|
10
|
+
* authenticated user unless the collection author remembers to write a correct
|
|
11
|
+
* admin-only write policy.
|
|
12
|
+
*
|
|
13
|
+
* To make this safe by default, the schema generator injects two policies for
|
|
14
|
+
* every write operation on an auth collection. Enforcement lives in the
|
|
15
|
+
* database (RLS), so it applies to every write path — REST, WebSocket, and any
|
|
16
|
+
* internal caller — not just one entry point:
|
|
17
|
+
*
|
|
18
|
+
* 1. A **restrictive** admin gate. Restrictive policies are AND'd with every
|
|
19
|
+
* other policy, so this guarantees a write is rejected unless the caller is
|
|
20
|
+
* an admin (or the trusted server context) — even if the author also wrote
|
|
21
|
+
* a permissive rule such as "a user may edit their own row". Without this,
|
|
22
|
+
* a permissive owner rule would let a user change their own `roles`.
|
|
23
|
+
*
|
|
24
|
+
* 2. A **permissive** admin grant. Restrictive policies only constrain; at
|
|
25
|
+
* least one permissive policy must also pass for the write to be allowed.
|
|
26
|
+
* This grants the baseline write so admins (and the server context) can
|
|
27
|
+
* manage users even when the collection has no other permissive write rule.
|
|
28
|
+
*
|
|
29
|
+
* Both mirror the framework's trusted-server convention: the write is allowed
|
|
30
|
+
* when there is no authenticated user context (`auth.uid() IS NULL`, used by
|
|
31
|
+
* the built-in auth flows that run as the service role) OR the caller holds the
|
|
32
|
+
* `admin` role. Net effect: only admins (or the server) can write the row, and
|
|
33
|
+
* therefore only admins can change privileged columns like `roles`.
|
|
34
|
+
*
|
|
35
|
+
* Author-provided write rules are preserved but cannot loosen this guarantee —
|
|
36
|
+
* a non-admin write is always blocked by the restrictive gate. To take full
|
|
37
|
+
* control of an auth collection's write authorization, set
|
|
38
|
+
* `disableDefaultAuthPolicies: true` on the collection.
|
|
39
|
+
*/
|
|
40
|
+
const ADMIN_WRITE_EXPR = "auth.uid() IS NULL OR string_to_array(auth.roles(), ',') && ARRAY['admin']";
|
|
41
|
+
|
|
42
|
+
/** Write operations that must be admin-gated by default on auth collections. */
|
|
43
|
+
const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
|
|
44
|
+
|
|
45
|
+
/** Whether a collection is flagged as an authentication collection. */
|
|
46
|
+
function isAuthCollection(collection: EntityCollection): boolean {
|
|
47
|
+
const auth = collection.auth;
|
|
48
|
+
return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns the security rules that should be applied to a collection: the
|
|
53
|
+
* author's explicit `securityRules`, plus, for auth collections, an
|
|
54
|
+
* auto-injected restrictive admin gate and permissive admin grant on every
|
|
55
|
+
* write operation. Together these guarantee only admins (or the trusted server
|
|
56
|
+
* context) can write the row.
|
|
57
|
+
*
|
|
58
|
+
* Non-auth collections, and auth collections that opt out via
|
|
59
|
+
* `disableDefaultAuthPolicies`, are returned unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export function getEffectiveSecurityRules(collection: EntityCollection): SecurityRule[] {
|
|
62
|
+
const explicit = (isPostgresCollection(collection) ? collection.securityRules : undefined) ?? [];
|
|
63
|
+
|
|
64
|
+
if (!isAuthCollection(collection) || collection.disableDefaultAuthPolicies) {
|
|
65
|
+
return explicit;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tableName = getTableName(collection);
|
|
69
|
+
|
|
70
|
+
// Restrictive gate: AND'd with all other policies, so no permissive rule
|
|
71
|
+
// (e.g. an owner "edit your own row" rule) can let a non-admin through.
|
|
72
|
+
const requireAdminGate: SecurityRule = {
|
|
73
|
+
name: `${tableName}_require_admin_write`,
|
|
74
|
+
mode: "restrictive",
|
|
75
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
76
|
+
using: ADMIN_WRITE_EXPR,
|
|
77
|
+
withCheck: ADMIN_WRITE_EXPR
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Permissive grant: a restrictive policy alone denies everything, so this
|
|
81
|
+
// grants the baseline write to admins / the server context.
|
|
82
|
+
const allowAdminWrite: SecurityRule = {
|
|
83
|
+
name: `${tableName}_default_admin_write`,
|
|
84
|
+
operations: [...DEFAULT_GUARDED_OPS],
|
|
85
|
+
using: ADMIN_WRITE_EXPR,
|
|
86
|
+
withCheck: ADMIN_WRITE_EXPR
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return [...explicit, allowAdminWrite, requireAdminGate];
|
|
90
|
+
}
|