@rebasepro/server-postgres 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
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
- }