@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
package/src/cli.ts
DELETED
|
@@ -1,1339 +0,0 @@
|
|
|
1
|
-
import arg from "arg";
|
|
2
|
-
import chalk from "chalk";
|
|
3
|
-
import { execa } from "execa";
|
|
4
|
-
import path from "path";
|
|
5
|
-
import fs from "fs";
|
|
6
|
-
import { fileURLToPath } from "url";
|
|
7
|
-
import { logger } from "@rebasepro/server";
|
|
8
|
-
import { out, outWarn, outError } from "./cli-output";
|
|
9
|
-
import { formatRelativeTime } from "@rebasepro/utils";
|
|
10
|
-
import {
|
|
11
|
-
diagnoseMissingBin,
|
|
12
|
-
resolveLocalBin,
|
|
13
|
-
getTableIncludes,
|
|
14
|
-
getDevDatabaseUrl,
|
|
15
|
-
ensureDevDatabaseExists,
|
|
16
|
-
applySearchDdl,
|
|
17
|
-
getSearchExcludes,
|
|
18
|
-
readSearchDdl,
|
|
19
|
-
applyVectorDdl,
|
|
20
|
-
getVectorExcludes,
|
|
21
|
-
readVectorDdl,
|
|
22
|
-
getTableExcludes,
|
|
23
|
-
getForeignIndexExcludes,
|
|
24
|
-
ExcludeIntrospectionError,
|
|
25
|
-
promptConfirm
|
|
26
|
-
} from "./cli-helpers";
|
|
27
|
-
import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
|
|
28
|
-
import { forLibpq } from "./utils/connection-string";
|
|
29
|
-
import { dropLegacyAuthSchema, RLS_BOOTSTRAP_SQL } from "./schema/rls-bootstrap-sql";
|
|
30
|
-
import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
|
|
31
|
-
import { stripCarvedOutStatements } from "./schema/carved-out-migration";
|
|
32
|
-
import { acceptsExcludeFlag, buildAtlasArgs } from "./schema/atlas-argv";
|
|
33
|
-
|
|
34
|
-
const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
async function loadEnv(): Promise<void> {
|
|
39
|
-
try {
|
|
40
|
-
const dotenv = await import("dotenv");
|
|
41
|
-
const envPaths = [
|
|
42
|
-
process.env.DOTENV_CONFIG_PATH,
|
|
43
|
-
path.resolve(process.cwd(), ".env"),
|
|
44
|
-
path.resolve(process.cwd(), "../.env"),
|
|
45
|
-
path.resolve(process.cwd(), "../../.env")
|
|
46
|
-
].filter(Boolean) as string[];
|
|
47
|
-
|
|
48
|
-
for (const p of envPaths) {
|
|
49
|
-
if (fs.existsSync(p)) {
|
|
50
|
-
const parsed = dotenv.config({ path: p, quiet: true });
|
|
51
|
-
if (parsed.parsed) {
|
|
52
|
-
for (const [key, val] of Object.entries(parsed.parsed)) {
|
|
53
|
-
if (process.env[key] === undefined) {
|
|
54
|
-
process.env[key] = val;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
} catch {
|
|
62
|
-
// ignore
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export async function runPluginCommand(args: string[]) {
|
|
67
|
-
// Also set as an environment variable, not just per `config()` call: this
|
|
68
|
-
// command spawns `tsx` subprocesses (the schema generator, the DDL
|
|
69
|
-
// generator, doctor) that load `.env` themselves, and they inherit this.
|
|
70
|
-
// dotenv reads it before `options.quiet`; an explicit `false` still wins.
|
|
71
|
-
if (process.env.DOTENV_CONFIG_QUIET === undefined) {
|
|
72
|
-
process.env.DOTENV_CONFIG_QUIET = "true";
|
|
73
|
-
}
|
|
74
|
-
await loadEnv();
|
|
75
|
-
const domain = args[0]; // "db" or "schema"
|
|
76
|
-
const subcommand = args[1];
|
|
77
|
-
|
|
78
|
-
if (domain === "db") {
|
|
79
|
-
await dbCommand(subcommand, args);
|
|
80
|
-
} else if (domain === "schema") {
|
|
81
|
-
await schemaCommand(subcommand, args);
|
|
82
|
-
} else if (domain === "doctor") {
|
|
83
|
-
await doctorPluginCommand(args);
|
|
84
|
-
} else {
|
|
85
|
-
outError(chalk.red(`Unknown domain command: ${domain}`));
|
|
86
|
-
process.exit(1);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The migration files on disk, sorted, or none if the directory is absent. */
|
|
91
|
-
function listMigrationFiles(): string[] {
|
|
92
|
-
const dir = path.resolve(process.cwd(), "drizzle", "migrations");
|
|
93
|
-
if (!fs.existsSync(dir)) return [];
|
|
94
|
-
return fs.readdirSync(dir).filter(f => f.endsWith(".sql")).sort();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Take Atlas's carve-outs back out of the migration `migrate diff` just wrote.
|
|
99
|
-
*
|
|
100
|
-
* The apply path hands Atlas `--exclude` and it never sees the search or vector
|
|
101
|
-
* columns. The diff path has no such flag — `atlas migrate diff` rejects
|
|
102
|
-
* `--exclude` outright, and an `atlas.hcl` `env` block's `exclude` is accepted
|
|
103
|
-
* and then ignored — so Atlas replays the migration directory (which builds
|
|
104
|
-
* both columns, because that DDL is appended to migrations), does not find them
|
|
105
|
-
* in `schema.sql`, and plans `DROP COLUMN`. Applied, that would take search and
|
|
106
|
-
* the embeddings away from every database the migration reaches.
|
|
107
|
-
*
|
|
108
|
-
* Returns whether a migration Atlas just wrote is still there afterwards: a
|
|
109
|
-
* file whose only content was the spurious drop is deleted, because an empty
|
|
110
|
-
* migration is worse than none — it takes a slot in the revision history and
|
|
111
|
-
* the caller would append the carved-out DDL and the policies to it.
|
|
112
|
-
*
|
|
113
|
-
* @returns true when a new migration survives and may be appended to.
|
|
114
|
-
*/
|
|
115
|
-
async function stripCarvedOutFromNewestMigration(collectionsPath: string): Promise<boolean> {
|
|
116
|
-
// Both carve-outs, for one reason: they are the same problem. Search is out
|
|
117
|
-
// of Atlas's sight because its free tier will not parse a function; vector
|
|
118
|
-
// is out because Atlas resolves the desired state in a dev database that
|
|
119
|
-
// can never have pgvector. What follows from that is identical — the diff
|
|
120
|
-
// replays a migration directory holding objects `schema.sql` omits, and
|
|
121
|
-
// plans a drop for every one.
|
|
122
|
-
const patterns = [
|
|
123
|
-
...await getSearchExcludes(collectionsPath),
|
|
124
|
-
...await getVectorExcludes(collectionsPath)
|
|
125
|
-
];
|
|
126
|
-
if (patterns.length === 0) return true;
|
|
127
|
-
|
|
128
|
-
const newest = listMigrationFiles().at(-1);
|
|
129
|
-
if (!newest) return false;
|
|
130
|
-
const file = path.resolve(process.cwd(), "drizzle", "migrations", newest);
|
|
131
|
-
|
|
132
|
-
const result = stripCarvedOutStatements(fs.readFileSync(file, "utf-8"), patterns);
|
|
133
|
-
|
|
134
|
-
// Fail CLOSED, and loudly. A drop this could not rewrite stays in the file
|
|
135
|
-
// exactly as Atlas wrote it, and the file is one somebody applies next week
|
|
136
|
-
// — by which time the column is gone and nothing connects it to this run.
|
|
137
|
-
// The destructive gate does not cover it either: that reads the *push*
|
|
138
|
-
// plan. So `db generate` stops here rather than hand back a migration that
|
|
139
|
-
// destroys a column the developer never asked to lose.
|
|
140
|
-
if (result.unhandled.length > 0) {
|
|
141
|
-
outError(chalk.red(
|
|
142
|
-
`\n ✗ ${newest} drops an object Rebase manages outside Atlas, in a form the CLI`
|
|
143
|
-
));
|
|
144
|
-
outError(chalk.red(" could not remove:"));
|
|
145
|
-
for (const statement of result.unhandled) {
|
|
146
|
-
outError(chalk.gray(` ${statement.replace(/\s+/g, " ")}`));
|
|
147
|
-
}
|
|
148
|
-
outError(chalk.gray(
|
|
149
|
-
" Left as Atlas wrote it. Delete those statements by hand before running\n" +
|
|
150
|
-
" `rebase db migrate` — and please report them: rewriting this is meant to\n" +
|
|
151
|
-
" be automatic."
|
|
152
|
-
));
|
|
153
|
-
process.exit(1);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (result.removed.length === 0) return true;
|
|
157
|
-
|
|
158
|
-
if (result.empty) {
|
|
159
|
-
fs.rmSync(file);
|
|
160
|
-
out(chalk.gray(
|
|
161
|
-
` ✓ Dropped ${newest} — it planned nothing but the removal of columns\n` +
|
|
162
|
-
" Atlas cannot see and Rebase applies itself."
|
|
163
|
-
));
|
|
164
|
-
} else {
|
|
165
|
-
fs.writeFileSync(file, result.sql, "utf-8");
|
|
166
|
-
out(chalk.gray(` ✓ Kept Rebase's own columns out of ${newest} (${result.removed.length} statement(s))`));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
await runAtlas("migrate", ["hash", "--dir", "file://drizzle/migrations"], collectionsPath);
|
|
170
|
-
return !result.empty;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
174
|
-
const VALID_ACTIONS = ["push", "generate", "migrate", "branch", "backup", "restore", "backups"];
|
|
175
|
-
if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
|
|
176
|
-
outError(chalk.red(`Unknown db command. Valid: ${VALID_ACTIONS.join(", ")}`));
|
|
177
|
-
process.exit(1);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// A second line of defence, and not a redundant one. `rebase db` answers
|
|
181
|
-
// `--help` before it ever spawns this file, but this file is also its own
|
|
182
|
-
// CLI — the driver is spawned directly by `resolvePluginCliScript`, and is
|
|
183
|
-
// executable on its own — so a `--help` reaching here means nobody upstream
|
|
184
|
-
// caught it. Only `branch` had a case for it; `push`, `migrate` and
|
|
185
|
-
// `restore` all took `--help` as an ordinary flag and did the work.
|
|
186
|
-
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
187
|
-
out("");
|
|
188
|
-
out(chalk.bold(` rebase db ${subcommand}`));
|
|
189
|
-
out(chalk.gray(" Run `rebase db --help` for the full page — this is the driver's own entry point."));
|
|
190
|
-
out("");
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (subcommand === "branch") {
|
|
195
|
-
await branchCommand(rawArgs);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (subcommand === "backup" || subcommand === "restore" || subcommand === "backups") {
|
|
200
|
-
const { backupCommand, restoreCommand, backupsCommand } = await import("./backup/backup-cli");
|
|
201
|
-
if (subcommand === "backup") await backupCommand(rawArgs);
|
|
202
|
-
else if (subcommand === "restore") await restoreCommand(rawArgs);
|
|
203
|
-
else await backupsCommand(rawArgs);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const argsList = arg(
|
|
208
|
-
{
|
|
209
|
-
"--collections": String,
|
|
210
|
-
"--allow-destructive": Boolean,
|
|
211
|
-
"--yes": Boolean,
|
|
212
|
-
"-c": "--collections",
|
|
213
|
-
"-y": "--yes"
|
|
214
|
-
},
|
|
215
|
-
{
|
|
216
|
-
argv: rawArgs.slice(2),
|
|
217
|
-
permissive: true
|
|
218
|
-
}
|
|
219
|
-
);
|
|
220
|
-
const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
|
|
221
|
-
|
|
222
|
-
if (subcommand === "generate") {
|
|
223
|
-
out("");
|
|
224
|
-
out(chalk.bold(" 📦 Rebase DB Generate"));
|
|
225
|
-
out(chalk.gray(" Step 1/2: Generating Drizzle schema & Postgres DDL from collections..."));
|
|
226
|
-
out("");
|
|
227
|
-
await schemaCommand("generate", rawArgs);
|
|
228
|
-
await generatePostgresDdlCommand(rawArgs);
|
|
229
|
-
out("");
|
|
230
|
-
out(chalk.gray(" Step 2/2: Generating SQL migration files with Atlas..."));
|
|
231
|
-
out("");
|
|
232
|
-
const migrationName = argsList._[0] || "migration";
|
|
233
|
-
const migrationsBefore = listMigrationFiles();
|
|
234
|
-
await runAtlas("migrate", ["diff", migrationName, "--dir", "file://drizzle/migrations", "--to", "file://drizzle/schema.sql"], collectionsPath);
|
|
235
|
-
// The diff cannot be told to ignore the carved-out objects the way the
|
|
236
|
-
// apply can, so the drop it plans for them is taken back out of the
|
|
237
|
-
// file it just wrote. A migration that was nothing else is deleted.
|
|
238
|
-
let wroteNewMigration = listMigrationFiles().length > migrationsBefore.length;
|
|
239
|
-
if (wroteNewMigration) {
|
|
240
|
-
wroteNewMigration = await stripCarvedOutFromNewestMigration(collectionsPath);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Post-process the migration Atlas just wrote.
|
|
244
|
-
//
|
|
245
|
-
// `wroteNewMigration` gates the whole block, and that gate is the point.
|
|
246
|
-
// "The newest migration file" is only Atlas's when Atlas wrote one;
|
|
247
|
-
// otherwise it is a migration that has already run in production, and
|
|
248
|
-
// appending to it changes a hash Atlas has recorded while the appended
|
|
249
|
-
// SQL never runs anywhere. The search half already said so and checked;
|
|
250
|
-
// the `CREATE SCHEMA` rewrite and the policy append did not, so every
|
|
251
|
-
// `db generate` that found nothing to diff still grew the last
|
|
252
|
-
// migration by another copy of the policies.
|
|
253
|
-
try {
|
|
254
|
-
const migrationsDir = path.resolve(process.cwd(), "drizzle", "migrations");
|
|
255
|
-
if (fs.existsSync(migrationsDir) && wroteNewMigration) {
|
|
256
|
-
const files = fs.readdirSync(migrationsDir);
|
|
257
|
-
const sqlFiles = files
|
|
258
|
-
.filter(f => f.endsWith(".sql"))
|
|
259
|
-
.sort();
|
|
260
|
-
if (sqlFiles.length > 0) {
|
|
261
|
-
const newestMigrationFile = path.join(migrationsDir, sqlFiles[sqlFiles.length - 1]);
|
|
262
|
-
|
|
263
|
-
// Make CREATE SCHEMA idempotent so it doesn't conflict with
|
|
264
|
-
// --revisions-schema (Atlas pre-creates the rebase schema
|
|
265
|
-
// for its revision table before running migrations).
|
|
266
|
-
let migrationContent = fs.readFileSync(newestMigrationFile, "utf-8");
|
|
267
|
-
migrationContent = migrationContent.replace(
|
|
268
|
-
/CREATE SCHEMA (?!IF NOT EXISTS)("[^"]+");/g,
|
|
269
|
-
"CREATE SCHEMA IF NOT EXISTS $1;"
|
|
270
|
-
);
|
|
271
|
-
// Atlas never writes the search or vector objects into a
|
|
272
|
-
// migration — it is not shown them — so a migration
|
|
273
|
-
// replayed against a fresh database would build every table
|
|
274
|
-
// except the parts that make search and vector search work.
|
|
275
|
-
// Appending, because these are `ALTER TABLE ... ADD COLUMN`
|
|
276
|
-
// over the tables the migration just created.
|
|
277
|
-
//
|
|
278
|
-
// Vector first: `vector.sql` may open with
|
|
279
|
-
// `CREATE EXTENSION vector`, so prerequisites lead, which is
|
|
280
|
-
// the order a reader of the migration expects. Nothing in
|
|
281
|
-
// search depends on it either way.
|
|
282
|
-
const vectorContent = readVectorDdl();
|
|
283
|
-
if (vectorContent) {
|
|
284
|
-
migrationContent = `${migrationContent}\n\n${vectorContent}`;
|
|
285
|
-
out(chalk.gray(" ✓ Appended vector DDL to the migration"));
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const searchContent = readSearchDdl();
|
|
289
|
-
if (searchContent) {
|
|
290
|
-
migrationContent = `${migrationContent}\n\n${searchContent}`;
|
|
291
|
-
out(chalk.gray(" ✓ Appended search DDL to the migration"));
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
|
|
295
|
-
|
|
296
|
-
// Append RLS policies, preceded by the RLS bootstrap so the
|
|
297
|
-
// migration is self-contained: Atlas replays migrations
|
|
298
|
-
// against a clean dev database where `rebase.uid()` would
|
|
299
|
-
// not otherwise exist.
|
|
300
|
-
const policiesFile = path.resolve(process.cwd(), "drizzle", "policies.sql");
|
|
301
|
-
if (fs.existsSync(policiesFile)) {
|
|
302
|
-
const policiesContent = fs.readFileSync(policiesFile, "utf-8");
|
|
303
|
-
fs.appendFileSync(newestMigrationFile, "\n\n" + RLS_BOOTSTRAP_SQL + "\n" + policiesContent);
|
|
304
|
-
out(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
|
|
305
|
-
|
|
306
|
-
// Re-hash the migration directory
|
|
307
|
-
out(chalk.gray(" Re-hashing migration files..."));
|
|
308
|
-
await runAtlas("migrate", ["hash", "--dir", "file://drizzle/migrations"], collectionsPath);
|
|
309
|
-
out(chalk.gray(" ✓ Migration directory checksum updated successfully."));
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
} catch (err) {
|
|
314
|
-
outWarn(chalk.yellow(` ⚠️ Failed to append policies or re-hash migration: ${err instanceof Error ? err.message : String(err)}`));
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (!wroteNewMigration) {
|
|
318
|
-
// Reachable whenever the only thing that changed is something Atlas
|
|
319
|
-
// cannot see — a `search` block, a `vector` property, or a
|
|
320
|
-
// `securityRules` edit. There is no new file to carry it, and the
|
|
321
|
-
// previous migration must not be reopened: it has already run
|
|
322
|
-
// wherever this project is deployed.
|
|
323
|
-
out(chalk.gray(
|
|
324
|
-
" ℹ No migration written — nothing in this change is visible to Atlas.\n" +
|
|
325
|
-
" Search, vector and RLS DDL are applied by `rebase db push`, and at boot by\n" +
|
|
326
|
-
" the schema ensure. For a migration-only deployment, add drizzle/search.sql,\n" +
|
|
327
|
-
" drizzle/vector.sql and drizzle/policies.sql to a migration by hand."
|
|
328
|
-
));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
out("");
|
|
332
|
-
out(` You can now run ${chalk.bold.green("rebase db migrate")} to apply the migrations to your database.`);
|
|
333
|
-
out("");
|
|
334
|
-
} else {
|
|
335
|
-
out("");
|
|
336
|
-
out(chalk.bold(` 🗄️ Rebase DB ${subcommand.charAt(0).toUpperCase() + subcommand.slice(1)}`));
|
|
337
|
-
out("");
|
|
338
|
-
|
|
339
|
-
if (subcommand === "push") {
|
|
340
|
-
out(chalk.gray(" Step 1/3: Generating Drizzle schema & Postgres DDL from collections..."));
|
|
341
|
-
out("");
|
|
342
|
-
await schemaCommand("generate", rawArgs);
|
|
343
|
-
await generatePostgresDdlCommand(rawArgs);
|
|
344
|
-
out("");
|
|
345
|
-
out(chalk.gray(" Step 2/3: Pushing schema to database with Atlas..."));
|
|
346
|
-
out("");
|
|
347
|
-
const databaseUrl = process.env.DATABASE_URL;
|
|
348
|
-
if (databaseUrl) {
|
|
349
|
-
await ensureAuthSchemaAndFunctions(databaseUrl);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Preview the plan before touching data. `atlas schema apply` with
|
|
353
|
-
// --auto-approve will silently DROP COLUMN on a field removal and
|
|
354
|
-
// drop+add on a rename, so we first dry-run to obtain the planned
|
|
355
|
-
// SQL and gate anything destructive.
|
|
356
|
-
const plan = await runAtlas(
|
|
357
|
-
"schema",
|
|
358
|
-
["apply", "--to", "file://drizzle/schema.sql", "--dry-run"],
|
|
359
|
-
collectionsPath,
|
|
360
|
-
{ captureStdout: true }
|
|
361
|
-
);
|
|
362
|
-
const destructive = detectDestructiveStatements(plan);
|
|
363
|
-
const allowDestructive = argsList["--allow-destructive"] === true || argsList["--yes"] === true;
|
|
364
|
-
const decision = decidePushSafety({
|
|
365
|
-
destructiveCount: destructive.length,
|
|
366
|
-
allowDestructive,
|
|
367
|
-
interactive: process.stdin.isTTY === true
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
if (destructive.length > 0) {
|
|
371
|
-
outWarn(chalk.yellow(` ⚠️ This push includes ${destructive.length} destructive change(s) that will DESTROY data:`));
|
|
372
|
-
outWarn("");
|
|
373
|
-
for (const d of destructive) {
|
|
374
|
-
outWarn(chalk.red(` ${d.kind}: `) + chalk.gray(d.statement.replace(/\s+/g, " ")));
|
|
375
|
-
}
|
|
376
|
-
outWarn("");
|
|
377
|
-
outWarn(chalk.yellow(" Full planned changes:"));
|
|
378
|
-
outWarn(chalk.gray(plan.trim().split("\n").map((l) => ` ${l}`).join("\n")));
|
|
379
|
-
outWarn("");
|
|
380
|
-
|
|
381
|
-
if (decision === "refuse") {
|
|
382
|
-
outError(chalk.red(" ✗ Aborting: destructive changes require confirmation."));
|
|
383
|
-
outError(chalk.gray(" Re-run interactively, or pass --allow-destructive to proceed. Back up first: rebase db backup"));
|
|
384
|
-
process.exit(1);
|
|
385
|
-
}
|
|
386
|
-
if (decision === "confirm") {
|
|
387
|
-
const confirmed = await promptConfirm(
|
|
388
|
-
chalk.yellow(" Type 'yes' to apply these destructive changes (this cannot be undone): ")
|
|
389
|
-
);
|
|
390
|
-
if (!confirmed) {
|
|
391
|
-
out(chalk.gray(" Aborted. No changes were made."));
|
|
392
|
-
process.exit(1);
|
|
393
|
-
}
|
|
394
|
-
} else {
|
|
395
|
-
// decision === "apply" via --allow-destructive
|
|
396
|
-
outWarn(chalk.yellow(" Proceeding because --allow-destructive was passed."));
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
await runAtlas("schema", ["apply", "--to", "file://drizzle/schema.sql", "--auto-approve"], collectionsPath);
|
|
401
|
-
out("");
|
|
402
|
-
|
|
403
|
-
if (databaseUrl) {
|
|
404
|
-
await ensureAuthTables(databaseUrl, collectionsPath);
|
|
405
|
-
// After the tables exist and before the policies: the vector and
|
|
406
|
-
// search columns are `ALTER TABLE ... ADD COLUMN`, and a policy
|
|
407
|
-
// may reference the table they are added to.
|
|
408
|
-
await applyVectorDdl(databaseUrl);
|
|
409
|
-
await applySearchDdl(databaseUrl);
|
|
410
|
-
await applyPolicies(databaseUrl);
|
|
411
|
-
await reconcilePolicies(databaseUrl, collectionsPath);
|
|
412
|
-
await ensureRlsUserRole(databaseUrl);
|
|
413
|
-
await retireLegacyAuthSchema(databaseUrl);
|
|
414
|
-
} else {
|
|
415
|
-
outWarn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
|
|
416
|
-
}
|
|
417
|
-
} else if (subcommand === "migrate") {
|
|
418
|
-
const databaseUrl = process.env.DATABASE_URL;
|
|
419
|
-
if (databaseUrl) {
|
|
420
|
-
await ensureAuthSchemaAndFunctions(databaseUrl);
|
|
421
|
-
}
|
|
422
|
-
const extraArgs = argsList._.filter(arg => arg !== "migrate");
|
|
423
|
-
await runAtlas("migrate", ["apply", "--dir", "file://drizzle/migrations", ...extraArgs], collectionsPath);
|
|
424
|
-
if (databaseUrl) {
|
|
425
|
-
await ensureRlsUserRole(databaseUrl);
|
|
426
|
-
await retireLegacyAuthSchema(databaseUrl);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
out("");
|
|
431
|
-
out(chalk.green(` ✓ rebase db ${subcommand} completed successfully.`));
|
|
432
|
-
out("");
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
async function ensureAuthSchemaAndFunctions(databaseUrl: string): Promise<void> {
|
|
437
|
-
try {
|
|
438
|
-
const { Client } = await import("pg");
|
|
439
|
-
const client = new Client({ connectionString: databaseUrl });
|
|
440
|
-
await client.connect();
|
|
441
|
-
try {
|
|
442
|
-
// Creates the `rebase` schema as its first statement, which also
|
|
443
|
-
// covers the schema Atlas puts its revision table in
|
|
444
|
-
// (`--revisions-schema rebase`). That used to need a separate,
|
|
445
|
-
// deliberately migration-stream-excluded statement here, because the
|
|
446
|
-
// helper functions lived in `auth` and creating `rebase` from the
|
|
447
|
-
// preamble would have made Atlas plan a DROP for it. The generator
|
|
448
|
-
// now always declares `rebase` in the desired schema, so there is
|
|
449
|
-
// nothing to keep out.
|
|
450
|
-
await client.query(RLS_BOOTSTRAP_SQL);
|
|
451
|
-
} finally {
|
|
452
|
-
await client.end();
|
|
453
|
-
}
|
|
454
|
-
} catch (err) {
|
|
455
|
-
outWarn(chalk.yellow(` ⚠️ Failed to bootstrap the RLS helper functions: ${err instanceof Error ? err.message : String(err)}`));
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Create the framework auth tables (e.g. `rebase.users`) that the generated RLS
|
|
461
|
-
* policies reference, before `applyPolicies` runs. Mirrors what the server does
|
|
462
|
-
* at boot (`PostgresBootstrapper.initializeAuth` → `ensureAuthTablesExist`).
|
|
463
|
-
*
|
|
464
|
-
* Without this, `rebase db push` against a database that has never booted the
|
|
465
|
-
* server fails while applying policies with `relation "rebase.users" does not
|
|
466
|
-
* exist` — the documented first-run does `db push` *before* the first `dev`.
|
|
467
|
-
* `ensureAuthTablesExist` is idempotent (CREATE TABLE IF NOT EXISTS), so it is
|
|
468
|
-
* safe to run on every push and harmless once the server has also created them.
|
|
469
|
-
*/
|
|
470
|
-
async function ensureAuthTables(databaseUrl: string, collectionsPath: string): Promise<void> {
|
|
471
|
-
try {
|
|
472
|
-
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
473
|
-
const { ensureAuthTablesExist } = await import("./auth/ensure-tables");
|
|
474
|
-
const { loadCollections } = await import("./schema/doctor");
|
|
475
|
-
const { Client } = await import("pg");
|
|
476
|
-
|
|
477
|
-
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
478
|
-
// The auth collection is flagged with `auth: true` or `auth: { enabled: true }`.
|
|
479
|
-
const authCollection = collections.find((c) => {
|
|
480
|
-
const a = c.auth;
|
|
481
|
-
return a === true || (typeof a === "object" && a !== null && a.enabled === true);
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
const client = new Client({ connectionString: databaseUrl });
|
|
485
|
-
await client.connect();
|
|
486
|
-
try {
|
|
487
|
-
await ensureAuthTablesExist(drizzle(client), authCollection);
|
|
488
|
-
} finally {
|
|
489
|
-
await client.end();
|
|
490
|
-
}
|
|
491
|
-
} catch (err) {
|
|
492
|
-
outWarn(chalk.yellow(` ⚠️ Failed to ensure framework auth tables: ${err instanceof Error ? err.message : String(err)}`));
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* Provision the restricted `rebase_user` role right after schema changes land,
|
|
498
|
-
* so grants cover freshly created tables (default privileges cover future
|
|
499
|
-
* ones). Only needed — and only possible without extra setup — when the
|
|
500
|
-
* connection would bypass RLS (superuser / BYPASSRLS / table owner).
|
|
501
|
-
*/
|
|
502
|
-
async function ensureRlsUserRole(databaseUrl: string): Promise<void> {
|
|
503
|
-
// Every failure in here used to escape uncaught, and `db migrate`'s caller
|
|
504
|
-
// turns that into a bare `process.exit(1)` — so the migration would apply,
|
|
505
|
-
// print `-- ok`, and then the command would die with NO output whatsoever
|
|
506
|
-
// and a non-zero status. Seen with a module-resolution error inside the
|
|
507
|
-
// dynamic import, where the entire diagnosis was one silent exit code.
|
|
508
|
-
//
|
|
509
|
-
// Reported rather than rethrown, and deliberately not fatal: the schema
|
|
510
|
-
// change has already landed at this point, so failing the command implies a
|
|
511
|
-
// rollback that did not happen. What is actually lost is the role
|
|
512
|
-
// provisioning, and the message says so and how to finish it by hand.
|
|
513
|
-
// No `auth`: the RLS helpers live in `rebase` now, and a schema the
|
|
514
|
-
// framework no longer creates must not be granted on — on a Supabase
|
|
515
|
-
// database that would hand the end-user role USAGE on their auth schema.
|
|
516
|
-
const schemas = ["public", "rebase"];
|
|
517
|
-
let rls: typeof import("./security/rls-enforcement");
|
|
518
|
-
try {
|
|
519
|
-
rls = await import("./security/rls-enforcement");
|
|
520
|
-
} catch (err) {
|
|
521
|
-
// A module-resolution failure here is a build problem, not a database
|
|
522
|
-
// one, and it is exactly the case that used to produce the silent exit.
|
|
523
|
-
outError(chalk.red(
|
|
524
|
-
`\n ✗ Could not load the RLS provisioning module: ${err instanceof Error ? err.message : String(err)}`
|
|
525
|
-
));
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
try {
|
|
530
|
-
const { Client } = await import("pg");
|
|
531
|
-
const client = new Client({ connectionString: databaseUrl });
|
|
532
|
-
await client.connect();
|
|
533
|
-
try {
|
|
534
|
-
const runSql = async (text: string) => (await client.query(text)).rows as Record<string, unknown>[];
|
|
535
|
-
const posture = await rls.detectConnectionPosture(runSql);
|
|
536
|
-
if (posture.privileged) {
|
|
537
|
-
await rls.ensureAppRole(runSql, schemas);
|
|
538
|
-
out(chalk.gray(` ✓ RLS role "${rls.REBASE_USER_ROLE}" provisioned/refreshed.`));
|
|
539
|
-
}
|
|
540
|
-
} finally {
|
|
541
|
-
await client.end();
|
|
542
|
-
}
|
|
543
|
-
} catch (err) {
|
|
544
|
-
outError(chalk.red(
|
|
545
|
-
`\n ✗ The schema change was applied, but the "${rls.REBASE_USER_ROLE}" role could not be ` +
|
|
546
|
-
`provisioned: ${err instanceof Error ? err.message : String(err)}`
|
|
547
|
-
));
|
|
548
|
-
outError(chalk.gray(rls.appRoleSetupInstructions("your database user", schemas)));
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Retire the pre-1.0 `auth` schema, once nothing depends on it any more.
|
|
554
|
-
*
|
|
555
|
-
* Runs last on purpose. Postgres refuses to drop a function an RLS policy still
|
|
556
|
-
* calls, so this only succeeds after the policies above have been rewritten to
|
|
557
|
-
* `rebase.uid()`. See `dropLegacyAuthSchema` for what it reports when something
|
|
558
|
-
* hand-written is still holding the schema open, and DROP_LEGACY_AUTH_SCHEMA_SQL
|
|
559
|
-
* for the guards that keep it off a Supabase `auth` schema.
|
|
560
|
-
*/
|
|
561
|
-
async function retireLegacyAuthSchema(databaseUrl: string): Promise<void> {
|
|
562
|
-
try {
|
|
563
|
-
const { Client } = await import("pg");
|
|
564
|
-
const client = new Client({ connectionString: databaseUrl });
|
|
565
|
-
await client.connect();
|
|
566
|
-
try {
|
|
567
|
-
await dropLegacyAuthSchema(
|
|
568
|
-
async (text) => (await client.query(text)).rows as Record<string, unknown>[],
|
|
569
|
-
{
|
|
570
|
-
info: (m) => out(chalk.gray(` ${m}`)),
|
|
571
|
-
warn: (m) => outWarn(chalk.yellow(` ⚠️ ${m}`))
|
|
572
|
-
}
|
|
573
|
-
);
|
|
574
|
-
} finally {
|
|
575
|
-
await client.end();
|
|
576
|
-
}
|
|
577
|
-
} catch {
|
|
578
|
-
// Connection-level failure only; the schema is inert either way.
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async function applyPolicies(databaseUrl: string): Promise<void> {
|
|
583
|
-
try {
|
|
584
|
-
const policiesPath = path.resolve(process.cwd(), "drizzle", "policies.sql");
|
|
585
|
-
if (!fs.existsSync(policiesPath)) return;
|
|
586
|
-
|
|
587
|
-
out(chalk.gray(" Step 3/3: Applying RLS policies to database..."));
|
|
588
|
-
out("");
|
|
589
|
-
|
|
590
|
-
const policiesContent = fs.readFileSync(policiesPath, "utf-8");
|
|
591
|
-
const { Client } = await import("pg");
|
|
592
|
-
const client = new Client({ connectionString: databaseUrl });
|
|
593
|
-
await client.connect();
|
|
594
|
-
try {
|
|
595
|
-
await client.query(policiesContent);
|
|
596
|
-
out(chalk.green(" ✓ RLS policies applied successfully."));
|
|
597
|
-
} finally {
|
|
598
|
-
await client.end();
|
|
599
|
-
}
|
|
600
|
-
} catch (err) {
|
|
601
|
-
const hint = diagnoseDbError(err, databaseUrl);
|
|
602
|
-
if (hint) {
|
|
603
|
-
outError(hint);
|
|
604
|
-
} else {
|
|
605
|
-
outError(chalk.red(` ✗ Failed to apply RLS policies: ${err instanceof Error ? err.message : String(err)}`));
|
|
606
|
-
}
|
|
607
|
-
process.exit(1);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Remove the policies an earlier push superseded but never dropped.
|
|
613
|
-
*
|
|
614
|
-
* `policies.sql` only DROPs the names it is about to CREATE, and a rule's
|
|
615
|
-
* generated name contains a hash of its own semantics — so editing a rule
|
|
616
|
-
* writes a *new* policy and abandons the old one. Postgres ORs PERMISSIVE
|
|
617
|
-
* policies together, which makes an abandoned grant outrank every tightening
|
|
618
|
-
* that replaced it, and push reported success the whole time.
|
|
619
|
-
*
|
|
620
|
-
* Runs after `applyPolicies` so the current policies are already in place: the
|
|
621
|
-
* drift check then sees exactly the set that should survive, and anything else
|
|
622
|
-
* on a managed table is by definition left over.
|
|
623
|
-
*/
|
|
624
|
-
async function reconcilePolicies(databaseUrl: string, collectionsPath: string): Promise<void> {
|
|
625
|
-
try {
|
|
626
|
-
const { checkPolicyDrift, dropOrphanedPolicies, formatPolicyDrift, hasDrift } =
|
|
627
|
-
await import("./security/policy-drift");
|
|
628
|
-
const { loadCollections } = await import("./schema/doctor");
|
|
629
|
-
|
|
630
|
-
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
631
|
-
const { Client } = await import("pg");
|
|
632
|
-
const client = new Client({ connectionString: databaseUrl });
|
|
633
|
-
await client.connect();
|
|
634
|
-
try {
|
|
635
|
-
const drift = await checkPolicyDrift(client as never, collections);
|
|
636
|
-
const { dropped, kept } = await dropOrphanedPolicies(client as never, drift, collections);
|
|
637
|
-
|
|
638
|
-
for (const p of dropped) {
|
|
639
|
-
out(chalk.gray(` ✓ Dropped superseded policy "${p.name}" on ${p.schema}.${p.table}`));
|
|
640
|
-
}
|
|
641
|
-
if (dropped.length > 0) {
|
|
642
|
-
out(chalk.green(` ✓ Removed ${dropped.length} superseded RLS ${dropped.length === 1 ? "policy" : "policies"}.`));
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Custom-named orphans are indistinguishable from policies someone
|
|
646
|
-
// wrote in SQL deliberately, so they are reported, never dropped.
|
|
647
|
-
if (kept.length > 0) {
|
|
648
|
-
outWarn(chalk.yellow(" ⚠️ Policies in the database that no collection describes:"));
|
|
649
|
-
for (const p of kept) {
|
|
650
|
-
outWarn(chalk.yellow(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`));
|
|
651
|
-
}
|
|
652
|
-
outWarn(chalk.yellow(" These still grant access. Drop them by hand if they are stale."));
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// Missing/diverged are not push's to fix, but staying silent about
|
|
656
|
-
// them is how a database ends up not matching its config.
|
|
657
|
-
const remaining = { ...drift, orphaned: kept };
|
|
658
|
-
if (hasDrift(remaining) && (remaining.missing.length > 0 || remaining.diverged.length > 0)) {
|
|
659
|
-
outWarn(chalk.yellow(" ⚠️ RLS policies do not match your collections:"));
|
|
660
|
-
outWarn(formatPolicyDrift({ ...remaining, orphaned: [] }));
|
|
661
|
-
}
|
|
662
|
-
} finally {
|
|
663
|
-
await client.end();
|
|
664
|
-
}
|
|
665
|
-
} catch (err) {
|
|
666
|
-
outWarn(chalk.yellow(` ⚠️ Could not reconcile RLS policies: ${err instanceof Error ? err.message : String(err)}`));
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
async function branchCommand(rawArgs: string[]): Promise<void> {
|
|
671
|
-
const branchAction = rawArgs[2]; // create, list, delete, info
|
|
672
|
-
|
|
673
|
-
if (!branchAction || branchAction === "--help") {
|
|
674
|
-
printBranchHelp();
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Load .env for DATABASE_URL
|
|
679
|
-
try {
|
|
680
|
-
const dotenv = await import("dotenv");
|
|
681
|
-
const envPath = process.env.DOTENV_CONFIG_PATH;
|
|
682
|
-
if (envPath) {
|
|
683
|
-
dotenv.config({ path: envPath, quiet: true });
|
|
684
|
-
} else {
|
|
685
|
-
dotenv.config({ quiet: true });
|
|
686
|
-
}
|
|
687
|
-
} catch {
|
|
688
|
-
// dotenv may not be installed
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
692
|
-
if (!databaseUrl) {
|
|
693
|
-
outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
694
|
-
process.exit(1);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// Dynamic imports to avoid loading heavy deps when not needed
|
|
698
|
-
const { DatabasePoolManager } = await import("./databasePoolManager");
|
|
699
|
-
const { BranchService } = await import("./services/BranchService");
|
|
700
|
-
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
701
|
-
const { Pool } = await import("pg");
|
|
702
|
-
|
|
703
|
-
const pool = new Pool({ connectionString: databaseUrl,
|
|
704
|
-
max: 3 });
|
|
705
|
-
const db = drizzle(pool);
|
|
706
|
-
const poolManager = new DatabasePoolManager(databaseUrl);
|
|
707
|
-
const branchService = new BranchService(db, poolManager);
|
|
708
|
-
|
|
709
|
-
// Ensure metadata table exists
|
|
710
|
-
await branchService.ensureBranchMetadataTable();
|
|
711
|
-
|
|
712
|
-
try {
|
|
713
|
-
switch (branchAction) {
|
|
714
|
-
case "create": {
|
|
715
|
-
const name = rawArgs[3];
|
|
716
|
-
if (!name) {
|
|
717
|
-
outError(chalk.red("✗ Branch name is required."));
|
|
718
|
-
out(chalk.gray(" Usage: rebase db branch create <name> [--from <source>]"));
|
|
719
|
-
process.exit(1);
|
|
720
|
-
}
|
|
721
|
-
let source: string | undefined;
|
|
722
|
-
const fromIdx = rawArgs.indexOf("--from");
|
|
723
|
-
if (fromIdx !== -1 && rawArgs[fromIdx + 1]) {
|
|
724
|
-
source = rawArgs[fromIdx + 1];
|
|
725
|
-
}
|
|
726
|
-
out("");
|
|
727
|
-
out(chalk.bold(" 🌿 Creating database branch..."));
|
|
728
|
-
out(chalk.gray(` Name: ${name}`));
|
|
729
|
-
if (source) out(chalk.gray(` Source: ${source}`));
|
|
730
|
-
out("");
|
|
731
|
-
const branch = await branchService.createBranch(name, source ? { source } : undefined);
|
|
732
|
-
out(chalk.green(` ✓ Branch "${branch.name}" created successfully.`));
|
|
733
|
-
out(chalk.gray(` Database: rb_${branch.name}`));
|
|
734
|
-
out(chalk.gray(` Parent: ${branch.parentDatabase}`));
|
|
735
|
-
out("");
|
|
736
|
-
break;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
case "list": {
|
|
740
|
-
const branches = await branchService.listBranches();
|
|
741
|
-
out("");
|
|
742
|
-
if (branches.length === 0) {
|
|
743
|
-
out(chalk.gray(" No branches found. Create one with: rebase db branch create <name>"));
|
|
744
|
-
} else {
|
|
745
|
-
out(chalk.bold(` 🌿 ${branches.length} branch(es):`));
|
|
746
|
-
out("");
|
|
747
|
-
for (const b of branches) {
|
|
748
|
-
const size = b.sizeBytes != null
|
|
749
|
-
? chalk.gray(` (${formatBytes(b.sizeBytes)})`)
|
|
750
|
-
: "";
|
|
751
|
-
const age = chalk.gray(` — created ${timeAgo(b.createdAt)}`);
|
|
752
|
-
out(` ${chalk.green("●")} ${chalk.bold(b.name)}${size}${age}`);
|
|
753
|
-
out(chalk.gray(` from ${b.parentDatabase}`));
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
out("");
|
|
757
|
-
break;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
case "delete": {
|
|
761
|
-
const name = rawArgs[3];
|
|
762
|
-
if (!name) {
|
|
763
|
-
outError(chalk.red("✗ Branch name is required."));
|
|
764
|
-
out(chalk.gray(" Usage: rebase db branch delete <name>"));
|
|
765
|
-
process.exit(1);
|
|
766
|
-
}
|
|
767
|
-
out("");
|
|
768
|
-
out(chalk.bold(` 🗑️ Deleting branch "${name}"...`));
|
|
769
|
-
await branchService.deleteBranch(name);
|
|
770
|
-
out(chalk.green(` ✓ Branch "${name}" deleted.`));
|
|
771
|
-
out("");
|
|
772
|
-
break;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
case "info": {
|
|
776
|
-
const name = rawArgs[3];
|
|
777
|
-
if (!name) {
|
|
778
|
-
outError(chalk.red("✗ Branch name is required."));
|
|
779
|
-
out(chalk.gray(" Usage: rebase db branch info <name>"));
|
|
780
|
-
process.exit(1);
|
|
781
|
-
}
|
|
782
|
-
const info = await branchService.getBranchInfo(name);
|
|
783
|
-
out("");
|
|
784
|
-
if (!info) {
|
|
785
|
-
outError(chalk.red(` ✗ Branch "${name}" not found.`));
|
|
786
|
-
} else {
|
|
787
|
-
out(chalk.bold(` 🌿 Branch: ${info.name}`));
|
|
788
|
-
out(chalk.gray(` Database: rb_${info.name}`));
|
|
789
|
-
out(chalk.gray(` Parent: ${info.parentDatabase}`));
|
|
790
|
-
out(chalk.gray(` Created: ${info.createdAt.toISOString()}`));
|
|
791
|
-
if (info.sizeBytes != null) {
|
|
792
|
-
out(chalk.gray(` Size: ${formatBytes(info.sizeBytes)}`));
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
out("");
|
|
796
|
-
break;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
default:
|
|
800
|
-
outError(chalk.red(`Unknown branch action: "${branchAction}".`));
|
|
801
|
-
printBranchHelp();
|
|
802
|
-
process.exit(1);
|
|
803
|
-
}
|
|
804
|
-
} finally {
|
|
805
|
-
await poolManager.shutdown();
|
|
806
|
-
await pool.end();
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
function printBranchHelp() {
|
|
811
|
-
out(`
|
|
812
|
-
${chalk.bold("rebase db branch")} — Database branching commands
|
|
813
|
-
|
|
814
|
-
${chalk.green.bold("Usage")}
|
|
815
|
-
rebase db branch ${chalk.blue("<command>")} [options]
|
|
816
|
-
|
|
817
|
-
${chalk.green.bold("Commands")}
|
|
818
|
-
${chalk.blue.bold("create")} <name> [--from <source>] Create a new branch
|
|
819
|
-
${chalk.blue.bold("list")} List all branches
|
|
820
|
-
${chalk.blue.bold("delete")} <name> Delete a branch
|
|
821
|
-
${chalk.blue.bold("info")} <name> Show branch details
|
|
822
|
-
|
|
823
|
-
${chalk.green.bold("Examples")}
|
|
824
|
-
${chalk.gray("# Create a branch from the current database")}
|
|
825
|
-
rebase db branch create feature_auth
|
|
826
|
-
|
|
827
|
-
${chalk.gray("# Create a branch from a specific source")}
|
|
828
|
-
rebase db branch create staging --from production
|
|
829
|
-
|
|
830
|
-
${chalk.gray("# List all branches")}
|
|
831
|
-
rebase db branch list
|
|
832
|
-
|
|
833
|
-
${chalk.gray("# Delete a branch")}
|
|
834
|
-
rebase db branch delete feature_auth
|
|
835
|
-
`);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
function formatBytes(bytes: number): string {
|
|
839
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
840
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
841
|
-
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
842
|
-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
/** A branch's age, for "…created 6d ago". */
|
|
846
|
-
function timeAgo(date: Date): string {
|
|
847
|
-
// No horizon: a branch created a year ago should still report an age here,
|
|
848
|
-
// rather than fall back to a bare date in a one-line list entry.
|
|
849
|
-
return formatRelativeTime(date, { maxMs: Number.POSITIVE_INFINITY }) ?? "unknown";
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
async function runAtlas(
|
|
855
|
-
domain: "schema" | "migrate",
|
|
856
|
-
args: string[],
|
|
857
|
-
collectionsPath?: string,
|
|
858
|
-
opts: { captureStdout?: boolean } = {}
|
|
859
|
-
): Promise<string> {
|
|
860
|
-
const atlasBin = resolveLocalBin("atlas");
|
|
861
|
-
if (!atlasBin) {
|
|
862
|
-
// Two very different causes, and the advice for one is a loop for the
|
|
863
|
-
// other — see `diagnoseMissingBin`. This used to say "Install it with:
|
|
864
|
-
// pnpm add -D @ariga/atlas" unconditionally, which is the exact command
|
|
865
|
-
// that produces the far more common of the two states.
|
|
866
|
-
outError(chalk.red("\n✗ The atlas binary is missing, so the schema cannot be applied.\n"));
|
|
867
|
-
|
|
868
|
-
if (diagnoseMissingBin("@ariga/atlas") === "build-script-blocked") {
|
|
869
|
-
outError(chalk.yellow(" @ariga/atlas IS installed — only its binary is missing.\n"));
|
|
870
|
-
outError(chalk.gray(
|
|
871
|
-
" It downloads that binary in a `preinstall` script, and pnpm 10+ does not\n" +
|
|
872
|
-
" run a dependency's scripts unless you allow it. The install still exits 0,\n" +
|
|
873
|
-
" so the only sign is `Ignored build scripts: @ariga/atlas` in its output.\n"
|
|
874
|
-
));
|
|
875
|
-
outError(" Fix it with either:\n");
|
|
876
|
-
outError(chalk.bold(" pnpm approve-builds\n"));
|
|
877
|
-
outError(" or, to record it in the project (what `rebase init` scaffolds):\n");
|
|
878
|
-
outError(chalk.bold(
|
|
879
|
-
" // package.json\n" +
|
|
880
|
-
" \"pnpm\": { \"onlyBuiltDependencies\": [\"@ariga/atlas\"] }\n"
|
|
881
|
-
));
|
|
882
|
-
outError(chalk.gray(" Then re-run `pnpm install`.\n"));
|
|
883
|
-
} else {
|
|
884
|
-
outError(chalk.gray(" It is not installed in this project.\n"));
|
|
885
|
-
outError(" Install it with:\n");
|
|
886
|
-
outError(chalk.bold(" pnpm add -D @ariga/atlas\n"));
|
|
887
|
-
outError(chalk.gray(
|
|
888
|
-
" If pnpm then reports `Ignored build scripts`, also run `pnpm approve-builds` —\n" +
|
|
889
|
-
" the package carries a `preinstall` script that fetches the binary.\n"
|
|
890
|
-
));
|
|
891
|
-
}
|
|
892
|
-
process.exit(1);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const env = { ...process.env as Record<string, string> };
|
|
896
|
-
try {
|
|
897
|
-
const dotenv = await import("dotenv");
|
|
898
|
-
const envPaths = [
|
|
899
|
-
process.env.DOTENV_CONFIG_PATH,
|
|
900
|
-
path.resolve(process.cwd(), ".env"),
|
|
901
|
-
path.resolve(process.cwd(), "../.env"),
|
|
902
|
-
path.resolve(process.cwd(), "../../.env")
|
|
903
|
-
].filter(Boolean) as string[];
|
|
904
|
-
|
|
905
|
-
for (const p of envPaths) {
|
|
906
|
-
if (fs.existsSync(p)) {
|
|
907
|
-
const parsed = dotenv.config({ path: p, quiet: true });
|
|
908
|
-
if (parsed.parsed) {
|
|
909
|
-
for (const [key, val] of Object.entries(parsed.parsed)) {
|
|
910
|
-
if (env[key] === undefined) {
|
|
911
|
-
env[key] = val;
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
break;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
} catch {
|
|
919
|
-
// ignore
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
const databaseUrl = env.DATABASE_URL;
|
|
923
|
-
if (!databaseUrl) {
|
|
924
|
-
outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
925
|
-
process.exit(1);
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// Pre-flight: verify the database is reachable before running Atlas.
|
|
929
|
-
// This catches ECONNREFUSED / auth failures with a friendly banner
|
|
930
|
-
// instead of letting Atlas surface a raw error.
|
|
931
|
-
await checkDatabaseConnectivity(databaseUrl);
|
|
932
|
-
|
|
933
|
-
const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
|
|
934
|
-
await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
|
|
935
|
-
|
|
936
|
-
// Atlas speaks libpq, which rejects the `sslmode=no-verify` that
|
|
937
|
-
// node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
|
|
938
|
-
// everything above still connects with the URL as configured.
|
|
939
|
-
const atlasUrl = forLibpq(databaseUrl);
|
|
940
|
-
const atlasDevUrl = forLibpq(devDatabaseUrl);
|
|
941
|
-
|
|
942
|
-
// Everything Atlas must keep its hands off, in one list.
|
|
943
|
-
//
|
|
944
|
-
// Gathered only for the invocations that accept the flag — which is
|
|
945
|
-
// `schema apply` and nothing else. `atlas migrate diff` and `migrate apply`
|
|
946
|
-
// both reject `--exclude` outright with `unknown flag`, and passing it there
|
|
947
|
-
// took `rebase db generate` and `rebase db migrate` down for every project
|
|
948
|
-
// declaring a `search` block; the diff keeps the same objects out of the
|
|
949
|
-
// migration afterwards instead, with `stripCarvedOutStatements`. See
|
|
950
|
-
// `acceptsExcludeFlag`.
|
|
951
|
-
const excludes: string[] = [];
|
|
952
|
-
if (collectionsPath && acceptsExcludeFlag(domain, args)) {
|
|
953
|
-
// Search and vector objects are Rebase's, not Atlas's: the desired state
|
|
954
|
-
// does not carry them, so an apply left to itself would drop them.
|
|
955
|
-
excludes.push(...await getSearchExcludes(collectionsPath));
|
|
956
|
-
excludes.push(...await getVectorExcludes(collectionsPath));
|
|
957
|
-
|
|
958
|
-
// Fail CLOSED: the exclude list is the only thing shielding
|
|
959
|
-
// non-collection tables from the auto-approved apply. If we can't
|
|
960
|
-
// introspect the database to build it, abort rather than proceed with
|
|
961
|
-
// a partial list that would let Atlas drop unmanaged tables.
|
|
962
|
-
try {
|
|
963
|
-
excludes.push(...await getTableExcludes(databaseUrl, collectionsPath));
|
|
964
|
-
} catch (err) {
|
|
965
|
-
if (err instanceof ExcludeIntrospectionError) {
|
|
966
|
-
outError(chalk.red("\n✗ Aborting push: could not determine which tables to protect."));
|
|
967
|
-
outError(chalk.gray(` ${err.message}`));
|
|
968
|
-
outError(chalk.gray(" Refusing to apply — a partial exclude list could drop tables Rebase does not manage."));
|
|
969
|
-
const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
|
|
970
|
-
if (hint) outError(hint);
|
|
971
|
-
process.exit(1);
|
|
972
|
-
}
|
|
973
|
-
throw err;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
// And the indexes on those tables that Rebase did not create. Same
|
|
977
|
-
// fail-closed contract as the table list above, for the same reason: a
|
|
978
|
-
// partial answer here silently drops somebody's index.
|
|
979
|
-
try {
|
|
980
|
-
excludes.push(...await getForeignIndexExcludes(databaseUrl, collectionsPath));
|
|
981
|
-
} catch (err) {
|
|
982
|
-
if (err instanceof ExcludeIntrospectionError) {
|
|
983
|
-
outError(chalk.red("\n✗ Aborting push: could not determine which indexes to protect."));
|
|
984
|
-
outError(chalk.gray(` ${err.message}`));
|
|
985
|
-
outError(chalk.gray(" Refusing to apply — a partial exclude list could drop an index Rebase does not manage."));
|
|
986
|
-
const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
|
|
987
|
-
if (hint) outError(hint);
|
|
988
|
-
process.exit(1);
|
|
989
|
-
}
|
|
990
|
-
throw err;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
const atlasArgs = buildAtlasArgs({ domain, args, url: atlasUrl, devUrl: atlasDevUrl, excludes });
|
|
995
|
-
|
|
996
|
-
// Stream stdout live but tee stderr so we can inspect Atlas's error text
|
|
997
|
-
// for known, actionable failure modes (e.g. a dependency-drop that leaves
|
|
998
|
-
// the schema half-applied) after the process exits. When capturing (used
|
|
999
|
-
// for the destructive-change dry-run), pipe stdout and collect it instead.
|
|
1000
|
-
const subprocess = execa(atlasBin, atlasArgs, {
|
|
1001
|
-
cwd: process.cwd(),
|
|
1002
|
-
stdout: opts.captureStdout ? "pipe" : "inherit",
|
|
1003
|
-
stderr: "pipe",
|
|
1004
|
-
env
|
|
1005
|
-
});
|
|
1006
|
-
let stdoutText = "";
|
|
1007
|
-
if (opts.captureStdout) {
|
|
1008
|
-
subprocess.stdout?.on("data", (chunk: Buffer) => {
|
|
1009
|
-
stdoutText += chunk.toString();
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
let stderrText = "";
|
|
1013
|
-
subprocess.stderr?.on("data", (chunk: Buffer) => {
|
|
1014
|
-
const text = chunk.toString();
|
|
1015
|
-
stderrText += text;
|
|
1016
|
-
process.stderr.write(text);
|
|
1017
|
-
});
|
|
1018
|
-
try {
|
|
1019
|
-
await subprocess;
|
|
1020
|
-
} catch {
|
|
1021
|
-
outError(chalk.red(`\n✗ atlas ${domain} ${args.join(" ")} failed.\n`));
|
|
1022
|
-
// Surface actionable recovery guidance for recognized failures.
|
|
1023
|
-
const hint = diagnoseDbError({ message: stderrText }, databaseUrl);
|
|
1024
|
-
if (hint) {
|
|
1025
|
-
outError(hint);
|
|
1026
|
-
}
|
|
1027
|
-
process.exit(1);
|
|
1028
|
-
}
|
|
1029
|
-
// Atlas prints the plan to stdout, but fall back to stderr so the
|
|
1030
|
-
// destructive-change detector never misses a plan on a version/build that
|
|
1031
|
-
// routes it differently.
|
|
1032
|
-
if (opts.captureStdout) {
|
|
1033
|
-
return stdoutText.trim().length > 0 ? stdoutText : stderrText;
|
|
1034
|
-
}
|
|
1035
|
-
return "";
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
|
|
1039
|
-
const argsList = arg(
|
|
1040
|
-
{
|
|
1041
|
-
"--collections": String,
|
|
1042
|
-
"--output": String,
|
|
1043
|
-
"-c": "--collections",
|
|
1044
|
-
"-o": "--output"
|
|
1045
|
-
},
|
|
1046
|
-
{
|
|
1047
|
-
argv: rawArgs.slice(2),
|
|
1048
|
-
permissive: true
|
|
1049
|
-
}
|
|
1050
|
-
);
|
|
1051
|
-
|
|
1052
|
-
const ddlScript = path.join(__cliDirname, "schema", "generate-postgres-ddl.ts");
|
|
1053
|
-
const tsxBin = resolveLocalBin("tsx");
|
|
1054
|
-
if (!tsxBin) {
|
|
1055
|
-
outError(chalk.red("✗ Could not find tsx binary."));
|
|
1056
|
-
process.exit(1);
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
|
|
1060
|
-
const outputPath = argsList["--output"] || path.join("drizzle", "schema.sql");
|
|
1061
|
-
|
|
1062
|
-
const cmdParts = [
|
|
1063
|
-
tsxBin,
|
|
1064
|
-
ddlScript,
|
|
1065
|
-
`--collections=${collectionsPath}`,
|
|
1066
|
-
`--output=${outputPath}`
|
|
1067
|
-
];
|
|
1068
|
-
|
|
1069
|
-
try {
|
|
1070
|
-
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
1071
|
-
cwd: process.cwd(),
|
|
1072
|
-
stdio: "inherit",
|
|
1073
|
-
env: { ...process.env as Record<string, string> }
|
|
1074
|
-
});
|
|
1075
|
-
} catch (err: unknown) {
|
|
1076
|
-
outError(chalk.red(`✗ Failed to run Postgres DDL generator: ${err instanceof Error ? err.message : String(err)}`));
|
|
1077
|
-
process.exit(1);
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
/**
|
|
1082
|
-
* `schema stale [--fix]` — is the generated Drizzle schema older than the rule
|
|
1083
|
-
* that derives its foreign-key names?
|
|
1084
|
-
*
|
|
1085
|
-
* This exists for one upgrade path and is worth the command. 0.13 derives
|
|
1086
|
-
* `category_id` where 0.12 derived `categorie_id`; boot-ensure renames the
|
|
1087
|
-
* database column to match, and the project's checked-in
|
|
1088
|
-
* `backend/src/schema.generated.ts` is then wrong in a way nothing the developer
|
|
1089
|
-
* did would explain. Relation validation refuses to boot on it, permanently,
|
|
1090
|
-
* because the rename has already been applied and will not run again.
|
|
1091
|
-
*
|
|
1092
|
-
* `rebase dev` calls this with `--fix` before starting the backend, so the
|
|
1093
|
-
* upgrade behaves the way the release note says it does: the column moves and the
|
|
1094
|
-
* project keeps working. Without `--fix` it reports and exits non-zero, which is
|
|
1095
|
-
* what a build or a CI step wants.
|
|
1096
|
-
*/
|
|
1097
|
-
async function schemaStaleCommand(rawArgs: string[]): Promise<void> {
|
|
1098
|
-
const argsList = arg(
|
|
1099
|
-
{
|
|
1100
|
-
"--collections": String,
|
|
1101
|
-
"--output": String,
|
|
1102
|
-
"--fix": Boolean,
|
|
1103
|
-
"-c": "--collections",
|
|
1104
|
-
"-o": "--output"
|
|
1105
|
-
},
|
|
1106
|
-
{ argv: rawArgs.slice(2), permissive: true }
|
|
1107
|
-
);
|
|
1108
|
-
|
|
1109
|
-
const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
|
|
1110
|
-
const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
|
|
1111
|
-
const schemaFile = path.resolve(process.cwd(), outputPath);
|
|
1112
|
-
|
|
1113
|
-
// No generated schema yet is not staleness — a fresh project has not run the
|
|
1114
|
-
// generator, and saying "stale" about a file that does not exist would send
|
|
1115
|
-
// the reader looking for something to fix.
|
|
1116
|
-
if (!fs.existsSync(schemaFile)) return;
|
|
1117
|
-
|
|
1118
|
-
const { loadCollections } = await import("./schema/doctor");
|
|
1119
|
-
const { findLegacyForeignKeyNames, describeLegacyForeignKeyNames } =
|
|
1120
|
-
await import("./schema/generated-schema-staleness");
|
|
1121
|
-
|
|
1122
|
-
let stale;
|
|
1123
|
-
try {
|
|
1124
|
-
const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
|
|
1125
|
-
stale = findLegacyForeignKeyNames(fs.readFileSync(schemaFile, "utf8"), collections);
|
|
1126
|
-
} catch (err) {
|
|
1127
|
-
// Best-effort by design: a collections directory that will not load is a
|
|
1128
|
-
// real error, but it is one the boot reports far better than this does.
|
|
1129
|
-
logger.debug(`schema stale: skipped (${err instanceof Error ? err.message : String(err)})`);
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
if (stale.length === 0) return;
|
|
1134
|
-
|
|
1135
|
-
out("");
|
|
1136
|
-
outWarn(chalk.yellow(
|
|
1137
|
-
` ⚠️ ${outputPath} names ${stale.length} foreign key(s) the way an earlier release did:`
|
|
1138
|
-
));
|
|
1139
|
-
out(chalk.gray(describeLegacyForeignKeyNames(stale)));
|
|
1140
|
-
out("");
|
|
1141
|
-
|
|
1142
|
-
if (!argsList["--fix"]) {
|
|
1143
|
-
outError(chalk.red(
|
|
1144
|
-
" The database column has already been renamed at boot, so the generated schema no " +
|
|
1145
|
-
"longer matches it and the server will refuse to start."
|
|
1146
|
-
));
|
|
1147
|
-
outError(chalk.red(" Run `rebase schema generate` to regenerate it."));
|
|
1148
|
-
process.exit(1);
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
out(chalk.gray(" Regenerating the Drizzle schema so it matches..."));
|
|
1152
|
-
await schemaCommand("generate", ["schema", "generate", `--collections=${collectionsPath}`, `--output=${outputPath}`]);
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
1156
|
-
if (subcommand === "stale") {
|
|
1157
|
-
await schemaStaleCommand(rawArgs);
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
if (subcommand === "generate") {
|
|
1162
|
-
const argsList = arg(
|
|
1163
|
-
{
|
|
1164
|
-
"--collections": String,
|
|
1165
|
-
"--output": String,
|
|
1166
|
-
"--watch": Boolean,
|
|
1167
|
-
"-c": "--collections",
|
|
1168
|
-
"-o": "--output",
|
|
1169
|
-
"-w": "--watch"
|
|
1170
|
-
},
|
|
1171
|
-
{
|
|
1172
|
-
argv: rawArgs.slice(2), // db generate ... or schema generate ...
|
|
1173
|
-
permissive: true
|
|
1174
|
-
}
|
|
1175
|
-
);
|
|
1176
|
-
|
|
1177
|
-
// Here we just invoke the local generate-drizzle-schema.ts since we are inside the postgresql-backend
|
|
1178
|
-
// If installed in node_modules, __cliDirname is node_modules/@rebasepro/server-postgres/dist or src.
|
|
1179
|
-
const generatorScript = path.join(__cliDirname, "schema", "generate-drizzle-schema.ts");
|
|
1180
|
-
if (!fs.existsSync(generatorScript)) {
|
|
1181
|
-
outError(chalk.red(`✗ Could not find generate-drizzle-schema.ts at ${generatorScript}`));
|
|
1182
|
-
process.exit(1);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
const tsxBin = resolveLocalBin("tsx");
|
|
1186
|
-
if (!tsxBin) {
|
|
1187
|
-
outError(chalk.red("✗ Could not find tsx binary."));
|
|
1188
|
-
process.exit(1);
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
|
|
1192
|
-
const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
|
|
1193
|
-
const watch = argsList["--watch"] || false;
|
|
1194
|
-
|
|
1195
|
-
out("");
|
|
1196
|
-
out(chalk.bold(" 🔧 Rebase Schema Generator"));
|
|
1197
|
-
out("");
|
|
1198
|
-
|
|
1199
|
-
const cmdParts = [
|
|
1200
|
-
tsxBin,
|
|
1201
|
-
generatorScript,
|
|
1202
|
-
`--collections=${collectionsPath}`,
|
|
1203
|
-
`--output=${outputPath}`
|
|
1204
|
-
];
|
|
1205
|
-
if (watch) {
|
|
1206
|
-
cmdParts.push("--watch");
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
try {
|
|
1210
|
-
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
1211
|
-
cwd: process.cwd(),
|
|
1212
|
-
stdio: "inherit",
|
|
1213
|
-
env: { ...process.env as Record<string, string> }
|
|
1214
|
-
});
|
|
1215
|
-
} catch (err: unknown) {
|
|
1216
|
-
outError(chalk.red(`✗ Failed to run schema generator: ${err instanceof Error ? err.message : String(err)}`));
|
|
1217
|
-
process.exit(1);
|
|
1218
|
-
}
|
|
1219
|
-
} else if (subcommand === "introspect") {
|
|
1220
|
-
const argsList = arg(
|
|
1221
|
-
{
|
|
1222
|
-
"--output": String,
|
|
1223
|
-
"--collections": String,
|
|
1224
|
-
"--force": Boolean,
|
|
1225
|
-
"--schema": String,
|
|
1226
|
-
"-o": "--output",
|
|
1227
|
-
"-c": "--collections",
|
|
1228
|
-
"-f": "--force"
|
|
1229
|
-
},
|
|
1230
|
-
{
|
|
1231
|
-
argv: rawArgs.slice(2),
|
|
1232
|
-
permissive: true
|
|
1233
|
-
}
|
|
1234
|
-
);
|
|
1235
|
-
|
|
1236
|
-
const introspectScript = path.join(__cliDirname, "schema", "introspect-db.ts");
|
|
1237
|
-
if (!fs.existsSync(introspectScript)) {
|
|
1238
|
-
outError(chalk.red(`✗ Could not find introspect-db.ts at ${introspectScript}`));
|
|
1239
|
-
process.exit(1);
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
const tsxBin = resolveLocalBin("tsx");
|
|
1243
|
-
if (!tsxBin) {
|
|
1244
|
-
outError(chalk.red("✗ Could not find tsx binary."));
|
|
1245
|
-
process.exit(1);
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
const outputPath = argsList["--output"] || argsList["--collections"] || path.join("..", "config", "collections");
|
|
1249
|
-
|
|
1250
|
-
out("");
|
|
1251
|
-
out(chalk.bold(" 🔍 Rebase Schema Introspector"));
|
|
1252
|
-
out("");
|
|
1253
|
-
|
|
1254
|
-
const cmdParts = [
|
|
1255
|
-
tsxBin,
|
|
1256
|
-
introspectScript,
|
|
1257
|
-
`--output=${outputPath}`,
|
|
1258
|
-
...(argsList["--force"] ? ["--force"] : []),
|
|
1259
|
-
...(argsList["--schema"] ? [`--schema=${argsList["--schema"]}`] : [])
|
|
1260
|
-
];
|
|
1261
|
-
|
|
1262
|
-
try {
|
|
1263
|
-
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
1264
|
-
cwd: process.cwd(),
|
|
1265
|
-
stdio: "inherit",
|
|
1266
|
-
env: { ...process.env as Record<string, string> }
|
|
1267
|
-
});
|
|
1268
|
-
} catch (err: unknown) {
|
|
1269
|
-
outError(chalk.red(`✗ Failed to run schema introspector: ${err instanceof Error ? err.message : String(err)}`));
|
|
1270
|
-
process.exit(1);
|
|
1271
|
-
}
|
|
1272
|
-
} else {
|
|
1273
|
-
outError(chalk.red("Unknown schema command."));
|
|
1274
|
-
process.exit(1);
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
async function doctorPluginCommand(rawArgs: string[]): Promise<void> {
|
|
1279
|
-
const parsedArgs = arg(
|
|
1280
|
-
{
|
|
1281
|
-
"--collections": String,
|
|
1282
|
-
"--schema": String,
|
|
1283
|
-
"--sdk": String,
|
|
1284
|
-
"--policies": Boolean,
|
|
1285
|
-
"-c": "--collections",
|
|
1286
|
-
"-s": "--schema",
|
|
1287
|
-
"-k": "--sdk"
|
|
1288
|
-
},
|
|
1289
|
-
{
|
|
1290
|
-
argv: rawArgs.slice(1), // skip "doctor"
|
|
1291
|
-
permissive: true
|
|
1292
|
-
}
|
|
1293
|
-
);
|
|
1294
|
-
|
|
1295
|
-
const doctorScript = path.join(__cliDirname, "schema", "doctor-cli.ts");
|
|
1296
|
-
if (!fs.existsSync(doctorScript)) {
|
|
1297
|
-
outError(chalk.red(`✗ Could not find doctor.ts at ${doctorScript}`));
|
|
1298
|
-
process.exit(1);
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
const tsxBin = resolveLocalBin("tsx");
|
|
1302
|
-
if (!tsxBin) {
|
|
1303
|
-
outError(chalk.red("✗ Could not find tsx binary."));
|
|
1304
|
-
process.exit(1);
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
const collectionsPath = parsedArgs["--collections"] || path.join("..", "config", "collections");
|
|
1308
|
-
const schemaPath = parsedArgs["--schema"] || path.join("src", "schema.generated.ts");
|
|
1309
|
-
const sdkPath = parsedArgs["--sdk"] || path.join("..", "generated", "sdk", "database.types.ts");
|
|
1310
|
-
|
|
1311
|
-
const cmdParts = [
|
|
1312
|
-
tsxBin,
|
|
1313
|
-
doctorScript,
|
|
1314
|
-
`--collections=${collectionsPath}`,
|
|
1315
|
-
`--schema=${schemaPath}`,
|
|
1316
|
-
`--sdk=${sdkPath}`,
|
|
1317
|
-
// Only the RLS checks: skips the schema/SDK diff, so it can run against
|
|
1318
|
-
// a deployed database as a CI gate.
|
|
1319
|
-
...(parsedArgs["--policies"] ? ["--policies"] : [])
|
|
1320
|
-
];
|
|
1321
|
-
|
|
1322
|
-
try {
|
|
1323
|
-
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
1324
|
-
cwd: process.cwd(),
|
|
1325
|
-
stdio: "inherit",
|
|
1326
|
-
env: { ...process.env as Record<string, string> }
|
|
1327
|
-
});
|
|
1328
|
-
} catch {
|
|
1329
|
-
process.exit(1);
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
// Entry point when called directly
|
|
1335
|
-
const argv1Real = process.argv[1] ? fs.realpathSync(process.argv[1]) : "";
|
|
1336
|
-
if (import.meta.url === `file://${argv1Real}`) {
|
|
1337
|
-
// Drop node and script path
|
|
1338
|
-
runPluginCommand(process.argv.slice(2)).catch(() => process.exit(1));
|
|
1339
|
-
}
|