@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.
Files changed (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. 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
- }