@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133

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 (117) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +57 -1
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1815 -1023
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  31. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  32. package/dist/schema/introspect-db-inference.d.ts +8 -1
  33. package/dist/schema/introspect-db-logic.d.ts +49 -0
  34. package/dist/schema/introspect-db-project.d.ts +21 -0
  35. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  36. package/dist/schema/search-column.d.ts +248 -0
  37. package/dist/security/policy-drift.d.ts +34 -0
  38. package/dist/security/rls-enforcement.d.ts +61 -5
  39. package/dist/services/FetchService.d.ts +34 -7
  40. package/dist/services/PersistService.d.ts +21 -17
  41. package/dist/services/RelationService.d.ts +9 -57
  42. package/dist/services/RelationWriteService.d.ts +82 -0
  43. package/dist/services/collection-helpers.d.ts +42 -0
  44. package/dist/services/dataService.d.ts +5 -0
  45. package/dist/services/junction-writes.d.ts +82 -0
  46. package/dist/services/realtimeService.d.ts +164 -23
  47. package/dist/services/write-denial.d.ts +36 -0
  48. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  49. package/dist/src-DCdn3Val.js.map +1 -0
  50. package/dist/utils/drizzle-conditions.d.ts +124 -2
  51. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  52. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  53. package/package.json +9 -8
  54. package/src/PostgresBackendDriver.ts +172 -6
  55. package/src/PostgresBootstrapper.ts +136 -11
  56. package/src/auth/ensure-tables.ts +212 -91
  57. package/src/auth/services.ts +82 -5
  58. package/src/backup/backup-cli.ts +59 -57
  59. package/src/cli-errors.ts +6 -6
  60. package/src/cli-helpers.ts +132 -13
  61. package/src/cli-output.ts +43 -0
  62. package/src/cli.ts +371 -161
  63. package/src/collections/buildRegistry.ts +3 -1
  64. package/src/collections/validate-relations.ts +124 -17
  65. package/src/data-transformer.ts +142 -28
  66. package/src/history/ensure-history-table.ts +9 -2
  67. package/src/schema/auth-schema.ts +17 -1
  68. package/src/schema/auth-users-columns.ts +131 -0
  69. package/src/schema/doctor-cli.ts +14 -65
  70. package/src/schema/doctor-policy-checks.ts +105 -0
  71. package/src/schema/doctor.ts +156 -77
  72. package/src/schema/ensure-collection-policies.ts +99 -6
  73. package/src/schema/ensure-collection-tables.ts +374 -32
  74. package/src/schema/generate-drizzle-schema-logic.ts +152 -66
  75. package/src/schema/generate-drizzle-schema.ts +11 -10
  76. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  77. package/src/schema/generate-postgres-ddl.ts +38 -14
  78. package/src/schema/generated-schema-staleness.ts +171 -0
  79. package/src/schema/introspect-db-inference.ts +9 -2
  80. package/src/schema/introspect-db-logic.ts +251 -75
  81. package/src/schema/introspect-db-project.ts +78 -0
  82. package/src/schema/introspect-db.ts +42 -25
  83. package/src/schema/introspect-runtime.ts +14 -2
  84. package/src/schema/non-sql-collections.test.ts +131 -0
  85. package/src/schema/rls-bootstrap-sql.ts +288 -0
  86. package/src/schema/search-column.ts +643 -0
  87. package/src/security/anonymous-grants.test.ts +4 -2
  88. package/src/security/policy-drift.test.ts +104 -3
  89. package/src/security/policy-drift.ts +129 -7
  90. package/src/security/rls-enforcement.ts +150 -7
  91. package/src/services/BranchService.ts +5 -0
  92. package/src/services/FetchService.ts +253 -115
  93. package/src/services/PersistService.ts +82 -43
  94. package/src/services/RelationService.ts +37 -696
  95. package/src/services/RelationWriteService.ts +653 -0
  96. package/src/services/cdc/trigger-cdc.ts +5 -1
  97. package/src/services/channel-history.ts +14 -0
  98. package/src/services/channel-presence.ts +13 -0
  99. package/src/services/collection-helpers.ts +89 -4
  100. package/src/services/dataService.ts +5 -0
  101. package/src/services/junction-writes.ts +295 -0
  102. package/src/services/pg-notify-listener.ts +1 -1
  103. package/src/services/realtimeService.ts +382 -118
  104. package/src/services/write-denial.ts +55 -0
  105. package/src/utils/drizzle-conditions.ts +433 -35
  106. package/src/utils/pg-error-utils.ts +8 -3
  107. package/src/websocket.ts +113 -16
  108. package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
  109. package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
  110. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  111. package/dist/policy-CeA1JcxP.js +0 -105
  112. package/dist/policy-CeA1JcxP.js.map +0 -1
  113. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  114. package/dist/src-DlPBctw_.js.map +0 -1
  115. package/dist/src-DoU9yPqq.js.map +0 -1
  116. package/dist/websocket-B2LsrINK.js.map +0 -1
  117. package/src/schema/auth-bootstrap-sql.ts +0 -47
package/src/cli.ts CHANGED
@@ -5,19 +5,25 @@ import path from "path";
5
5
  import fs from "fs";
6
6
  import { fileURLToPath } from "url";
7
7
  import { logger } from "@rebasepro/server";
8
+ import { out, outWarn, outError } from "./cli-output";
9
+ import { formatRelativeTime } from "@rebasepro/utils";
8
10
  import {
9
11
  diagnoseMissingBin,
10
12
  resolveLocalBin,
11
13
  getTableIncludes,
12
14
  getDevDatabaseUrl,
13
15
  ensureDevDatabaseExists,
16
+ applySearchDdl,
17
+ getSearchExcludes,
18
+ readSearchDdl,
19
+ seedDevDatabaseSearchHelpers,
14
20
  getTableExcludes,
15
21
  ExcludeIntrospectionError,
16
22
  promptConfirm
17
23
  } from "./cli-helpers";
18
24
  import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
19
25
  import { forLibpq } from "./utils/connection-string";
20
- import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
26
+ import { dropLegacyAuthSchema, RLS_BOOTSTRAP_SQL } from "./schema/rls-bootstrap-sql";
21
27
  import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
22
28
 
23
29
  const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
@@ -36,7 +42,7 @@ async function loadEnv(): Promise<void> {
36
42
 
37
43
  for (const p of envPaths) {
38
44
  if (fs.existsSync(p)) {
39
- const parsed = dotenv.config({ path: p });
45
+ const parsed = dotenv.config({ path: p, quiet: true });
40
46
  if (parsed.parsed) {
41
47
  for (const [key, val] of Object.entries(parsed.parsed)) {
42
48
  if (process.env[key] === undefined) {
@@ -53,6 +59,13 @@ async function loadEnv(): Promise<void> {
53
59
  }
54
60
 
55
61
  export async function runPluginCommand(args: string[]) {
62
+ // Also set as an environment variable, not just per `config()` call: this
63
+ // command spawns `tsx` subprocesses (the schema generator, the DDL
64
+ // generator, doctor) that load `.env` themselves, and they inherit this.
65
+ // dotenv reads it before `options.quiet`; an explicit `false` still wins.
66
+ if (process.env.DOTENV_CONFIG_QUIET === undefined) {
67
+ process.env.DOTENV_CONFIG_QUIET = "true";
68
+ }
56
69
  await loadEnv();
57
70
  const domain = args[0]; // "db" or "schema"
58
71
  const subcommand = args[1];
@@ -64,15 +77,22 @@ export async function runPluginCommand(args: string[]) {
64
77
  } else if (domain === "doctor") {
65
78
  await doctorPluginCommand(args);
66
79
  } else {
67
- logger.error(chalk.red(`Unknown domain command: ${domain}`));
80
+ outError(chalk.red(`Unknown domain command: ${domain}`));
68
81
  process.exit(1);
69
82
  }
70
83
  }
71
84
 
85
+ /** The migration files on disk, sorted, or none if the directory is absent. */
86
+ function listMigrationFiles(): string[] {
87
+ const dir = path.resolve(process.cwd(), "drizzle", "migrations");
88
+ if (!fs.existsSync(dir)) return [];
89
+ return fs.readdirSync(dir).filter(f => f.endsWith(".sql")).sort();
90
+ }
91
+
72
92
  async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
73
93
  const VALID_ACTIONS = ["push", "generate", "migrate", "branch", "backup", "restore", "backups"];
74
94
  if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
75
- logger.error(chalk.red(`Unknown db command. Valid: ${VALID_ACTIONS.join(", ")}`));
95
+ outError(chalk.red(`Unknown db command. Valid: ${VALID_ACTIONS.join(", ")}`));
76
96
  process.exit(1);
77
97
  }
78
98
 
@@ -105,17 +125,19 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
105
125
  const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
106
126
 
107
127
  if (subcommand === "generate") {
108
- logger.info("");
109
- logger.info(chalk.bold(" 📦 Rebase DB Generate"));
110
- logger.info(chalk.gray(" Step 1/2: Generating Drizzle schema & Postgres DDL from collections..."));
111
- logger.info("");
128
+ out("");
129
+ out(chalk.bold(" 📦 Rebase DB Generate"));
130
+ out(chalk.gray(" Step 1/2: Generating Drizzle schema & Postgres DDL from collections..."));
131
+ out("");
112
132
  await schemaCommand("generate", rawArgs);
113
133
  await generatePostgresDdlCommand(rawArgs);
114
- logger.info("");
115
- logger.info(chalk.gray(" Step 2/2: Generating SQL migration files with Atlas..."));
116
- logger.info("");
134
+ out("");
135
+ out(chalk.gray(" Step 2/2: Generating SQL migration files with Atlas..."));
136
+ out("");
117
137
  const migrationName = argsList._[0] || "migration";
138
+ const migrationsBefore = listMigrationFiles();
118
139
  await runAtlas("migrate", ["diff", migrationName, "--dir", "file://drizzle/migrations", "--to", "file://drizzle/schema.sql"], collectionsPath);
140
+ const wroteNewMigration = listMigrationFiles().length > migrationsBefore.length;
119
141
 
120
142
  // Post-process the newest migration file
121
143
  try {
@@ -136,45 +158,72 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
136
158
  /CREATE SCHEMA (?!IF NOT EXISTS)("[^"]+");/g,
137
159
  "CREATE SCHEMA IF NOT EXISTS $1;"
138
160
  );
161
+ // Atlas never writes the search objects into a migration —
162
+ // it is not shown them — so a migration replayed against a
163
+ // fresh database would build every table except the part
164
+ // that makes search work. Appending, because these are
165
+ // `ALTER TABLE ... ADD COLUMN` over the tables the
166
+ // migration just created.
167
+ //
168
+ // Only into a migration Atlas just wrote. The file picked
169
+ // here is simply the newest one, which when there was no
170
+ // diff is a migration that has already run in production:
171
+ // editing it changes a hash Atlas has recorded, and the
172
+ // appended SQL would never be applied anywhere.
173
+ const searchContent = readSearchDdl();
174
+ if (searchContent && wroteNewMigration) {
175
+ migrationContent = `${migrationContent}\n\n${searchContent}`;
176
+ out(chalk.gray(" ✓ Appended search DDL to the migration"));
177
+ } else if (searchContent) {
178
+ // Reachable whenever a `search` block is the *only*
179
+ // thing that changed: Atlas cannot see one, so it finds
180
+ // nothing to diff and writes no file.
181
+ out(chalk.gray(
182
+ " ℹ Search DDL not written to a migration — this change produced none.\n" +
183
+ " It is applied by `rebase db push`, and at boot by the schema ensure.\n" +
184
+ " For a migration-only deployment, add drizzle/search.sql to a migration by hand."
185
+ ));
186
+ }
187
+
139
188
  fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
140
189
 
141
- // Append RLS policies, preceded by the auth bootstrap so the
190
+ // Append RLS policies, preceded by the RLS bootstrap so the
142
191
  // migration is self-contained: Atlas replays migrations
143
- // against a clean dev database where `auth.uid()` would not
144
- // otherwise exist.
192
+ // against a clean dev database where `rebase.uid()` would
193
+ // not otherwise exist.
145
194
  const policiesFile = path.resolve(process.cwd(), "drizzle", "policies.sql");
146
195
  if (fs.existsSync(policiesFile)) {
147
196
  const policiesContent = fs.readFileSync(policiesFile, "utf-8");
148
- fs.appendFileSync(newestMigrationFile, "\n\n" + AUTH_BOOTSTRAP_SQL + "\n" + policiesContent);
149
- logger.info(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
197
+ fs.appendFileSync(newestMigrationFile, "\n\n" + RLS_BOOTSTRAP_SQL + "\n" + policiesContent);
198
+ out(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
150
199
 
151
200
  // Re-hash the migration directory
152
- logger.info(chalk.gray(" Re-hashing migration files..."));
201
+ out(chalk.gray(" Re-hashing migration files..."));
153
202
  await runAtlas("migrate", ["hash", "--dir", "file://drizzle/migrations"], collectionsPath);
154
- logger.info(chalk.gray(" ✓ Migration directory checksum updated successfully."));
203
+ out(chalk.gray(" ✓ Migration directory checksum updated successfully."));
155
204
  }
156
205
  }
157
206
  }
158
207
  } catch (err) {
159
- logger.warn(chalk.yellow(` ⚠️ Failed to append policies or re-hash migration: ${err instanceof Error ? err.message : String(err)}`));
208
+ outWarn(chalk.yellow(` ⚠️ Failed to append policies or re-hash migration: ${err instanceof Error ? err.message : String(err)}`));
160
209
  }
161
210
 
162
- logger.info("");
163
- logger.info(` You can now run ${chalk.bold.green("rebase db migrate")} to apply the migrations to your database.`);
164
- logger.info("");
211
+ out("");
212
+ out(` You can now run ${chalk.bold.green("rebase db migrate")} to apply the migrations to your database.`);
213
+ out("");
165
214
  } else {
166
- logger.info("");
167
- logger.info(chalk.bold(` 🗄️ Rebase DB ${subcommand.charAt(0).toUpperCase() + subcommand.slice(1)}`));
168
- logger.info("");
215
+ out("");
216
+ out(chalk.bold(` 🗄️ Rebase DB ${subcommand.charAt(0).toUpperCase() + subcommand.slice(1)}`));
217
+ out("");
169
218
 
170
219
  if (subcommand === "push") {
171
- logger.info(chalk.gray(" Step 1/3: Generating Drizzle schema & Postgres DDL from collections..."));
172
- logger.info("");
220
+ out(chalk.gray(" Step 1/3: Generating Drizzle schema & Postgres DDL from collections..."));
221
+ out("");
173
222
  await schemaCommand("generate", rawArgs);
174
223
  await generatePostgresDdlCommand(rawArgs);
175
- logger.info("");
176
- logger.info(chalk.gray(" Step 2/3: Pushing schema to database with Atlas..."));
177
- logger.info("");
224
+ out("");
225
+ out(chalk.gray(" Step 2/3: Pushing schema to database with Atlas..."));
226
+ out("");
178
227
  const databaseUrl = process.env.DATABASE_URL;
179
228
  if (databaseUrl) {
180
229
  await ensureAuthSchemaAndFunctions(databaseUrl);
@@ -199,19 +248,19 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
199
248
  });
200
249
 
201
250
  if (destructive.length > 0) {
202
- logger.warn(chalk.yellow(` ⚠️ This push includes ${destructive.length} destructive change(s) that will DESTROY data:`));
203
- logger.warn("");
251
+ outWarn(chalk.yellow(` ⚠️ This push includes ${destructive.length} destructive change(s) that will DESTROY data:`));
252
+ outWarn("");
204
253
  for (const d of destructive) {
205
- logger.warn(chalk.red(` ${d.kind}: `) + chalk.gray(d.statement.replace(/\s+/g, " ")));
254
+ outWarn(chalk.red(` ${d.kind}: `) + chalk.gray(d.statement.replace(/\s+/g, " ")));
206
255
  }
207
- logger.warn("");
208
- logger.warn(chalk.yellow(" Full planned changes:"));
209
- logger.warn(chalk.gray(plan.trim().split("\n").map((l) => ` ${l}`).join("\n")));
210
- logger.warn("");
256
+ outWarn("");
257
+ outWarn(chalk.yellow(" Full planned changes:"));
258
+ outWarn(chalk.gray(plan.trim().split("\n").map((l) => ` ${l}`).join("\n")));
259
+ outWarn("");
211
260
 
212
261
  if (decision === "refuse") {
213
- logger.error(chalk.red(" ✗ Aborting: destructive changes require confirmation."));
214
- logger.error(chalk.gray(" Re-run interactively, or pass --allow-destructive to proceed. Back up first: rebase db backup"));
262
+ outError(chalk.red(" ✗ Aborting: destructive changes require confirmation."));
263
+ outError(chalk.gray(" Re-run interactively, or pass --allow-destructive to proceed. Back up first: rebase db backup"));
215
264
  process.exit(1);
216
265
  }
217
266
  if (decision === "confirm") {
@@ -219,25 +268,30 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
219
268
  chalk.yellow(" Type 'yes' to apply these destructive changes (this cannot be undone): ")
220
269
  );
221
270
  if (!confirmed) {
222
- logger.info(chalk.gray(" Aborted. No changes were made."));
271
+ out(chalk.gray(" Aborted. No changes were made."));
223
272
  process.exit(1);
224
273
  }
225
274
  } else {
226
275
  // decision === "apply" via --allow-destructive
227
- logger.warn(chalk.yellow(" Proceeding because --allow-destructive was passed."));
276
+ outWarn(chalk.yellow(" Proceeding because --allow-destructive was passed."));
228
277
  }
229
278
  }
230
279
 
231
280
  await runAtlas("schema", ["apply", "--to", "file://drizzle/schema.sql", "--auto-approve"], collectionsPath);
232
- logger.info("");
281
+ out("");
233
282
 
234
283
  if (databaseUrl) {
235
284
  await ensureAuthTables(databaseUrl, collectionsPath);
285
+ // After the tables exist and before the policies: the search
286
+ // column is `ALTER TABLE ... ADD COLUMN`, and a policy may
287
+ // reference the table it is added to.
288
+ await applySearchDdl(databaseUrl);
236
289
  await applyPolicies(databaseUrl);
237
290
  await reconcilePolicies(databaseUrl, collectionsPath);
238
291
  await ensureRlsUserRole(databaseUrl);
292
+ await retireLegacyAuthSchema(databaseUrl);
239
293
  } else {
240
- logger.warn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
294
+ outWarn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
241
295
  }
242
296
  } else if (subcommand === "migrate") {
243
297
  const databaseUrl = process.env.DATABASE_URL;
@@ -248,12 +302,13 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
248
302
  await runAtlas("migrate", ["apply", "--dir", "file://drizzle/migrations", ...extraArgs], collectionsPath);
249
303
  if (databaseUrl) {
250
304
  await ensureRlsUserRole(databaseUrl);
305
+ await retireLegacyAuthSchema(databaseUrl);
251
306
  }
252
307
  }
253
308
 
254
- logger.info("");
255
- logger.info(chalk.green(` ✓ rebase db ${subcommand} completed successfully.`));
256
- logger.info("");
309
+ out("");
310
+ out(chalk.green(` ✓ rebase db ${subcommand} completed successfully.`));
311
+ out("");
257
312
  }
258
313
  }
259
314
 
@@ -263,16 +318,20 @@ async function ensureAuthSchemaAndFunctions(databaseUrl: string): Promise<void>
263
318
  const client = new Client({ connectionString: databaseUrl });
264
319
  await client.connect();
265
320
  try {
266
- await client.query(AUTH_BOOTSTRAP_SQL);
267
- // Runtime-only: pre-create the schema Atlas uses for its revision
268
- // table (`--revisions-schema rebase`). Kept out of
269
- // AUTH_BOOTSTRAP_SQL so it never enters the migration stream.
270
- await client.query("CREATE SCHEMA IF NOT EXISTS rebase");
321
+ // Creates the `rebase` schema as its first statement, which also
322
+ // covers the schema Atlas puts its revision table in
323
+ // (`--revisions-schema rebase`). That used to need a separate,
324
+ // deliberately migration-stream-excluded statement here, because the
325
+ // helper functions lived in `auth` and creating `rebase` from the
326
+ // preamble would have made Atlas plan a DROP for it. The generator
327
+ // now always declares `rebase` in the desired schema, so there is
328
+ // nothing to keep out.
329
+ await client.query(RLS_BOOTSTRAP_SQL);
271
330
  } finally {
272
331
  await client.end();
273
332
  }
274
333
  } catch (err) {
275
- logger.warn(chalk.yellow(` ⚠️ Failed to bootstrap auth schema and helper functions: ${err instanceof Error ? err.message : String(err)}`));
334
+ outWarn(chalk.yellow(` ⚠️ Failed to bootstrap the RLS helper functions: ${err instanceof Error ? err.message : String(err)}`));
276
335
  }
277
336
  }
278
337
 
@@ -309,7 +368,7 @@ async function ensureAuthTables(databaseUrl: string, collectionsPath: string): P
309
368
  await client.end();
310
369
  }
311
370
  } catch (err) {
312
- logger.warn(chalk.yellow(` ⚠️ Failed to ensure framework auth tables: ${err instanceof Error ? err.message : String(err)}`));
371
+ outWarn(chalk.yellow(` ⚠️ Failed to ensure framework auth tables: ${err instanceof Error ? err.message : String(err)}`));
313
372
  }
314
373
  }
315
374
 
@@ -320,19 +379,82 @@ async function ensureAuthTables(databaseUrl: string, collectionsPath: string): P
320
379
  * connection would bypass RLS (superuser / BYPASSRLS / table owner).
321
380
  */
322
381
  async function ensureRlsUserRole(databaseUrl: string): Promise<void> {
323
- const { detectConnectionPosture, ensureAppRole, REBASE_USER_ROLE } = await import("./security/rls-enforcement");
324
- const { Client } = await import("pg");
325
- const client = new Client({ connectionString: databaseUrl });
326
- await client.connect();
382
+ // Every failure in here used to escape uncaught, and `db migrate`'s caller
383
+ // turns that into a bare `process.exit(1)` — so the migration would apply,
384
+ // print `-- ok`, and then the command would die with NO output whatsoever
385
+ // and a non-zero status. Seen with a module-resolution error inside the
386
+ // dynamic import, where the entire diagnosis was one silent exit code.
387
+ //
388
+ // Reported rather than rethrown, and deliberately not fatal: the schema
389
+ // change has already landed at this point, so failing the command implies a
390
+ // rollback that did not happen. What is actually lost is the role
391
+ // provisioning, and the message says so and how to finish it by hand.
392
+ // No `auth`: the RLS helpers live in `rebase` now, and a schema the
393
+ // framework no longer creates must not be granted on — on a Supabase
394
+ // database that would hand the end-user role USAGE on their auth schema.
395
+ const schemas = ["public", "rebase"];
396
+ let rls: typeof import("./security/rls-enforcement");
397
+ try {
398
+ rls = await import("./security/rls-enforcement");
399
+ } catch (err) {
400
+ // A module-resolution failure here is a build problem, not a database
401
+ // one, and it is exactly the case that used to produce the silent exit.
402
+ outError(chalk.red(
403
+ `\n ✗ Could not load the RLS provisioning module: ${err instanceof Error ? err.message : String(err)}`
404
+ ));
405
+ return;
406
+ }
407
+
327
408
  try {
328
- const runSql = async (text: string) => (await client.query(text)).rows as Record<string, unknown>[];
329
- const posture = await detectConnectionPosture(runSql);
330
- if (posture.privileged) {
331
- await ensureAppRole(runSql, ["public", "rebase", "auth"]);
332
- logger.info(chalk.gray(` ✓ RLS role "${REBASE_USER_ROLE}" provisioned/refreshed.`));
409
+ const { Client } = await import("pg");
410
+ const client = new Client({ connectionString: databaseUrl });
411
+ await client.connect();
412
+ try {
413
+ const runSql = async (text: string) => (await client.query(text)).rows as Record<string, unknown>[];
414
+ const posture = await rls.detectConnectionPosture(runSql);
415
+ if (posture.privileged) {
416
+ await rls.ensureAppRole(runSql, schemas);
417
+ out(chalk.gray(` ✓ RLS role "${rls.REBASE_USER_ROLE}" provisioned/refreshed.`));
418
+ }
419
+ } finally {
420
+ await client.end();
333
421
  }
334
- } finally {
335
- await client.end();
422
+ } catch (err) {
423
+ outError(chalk.red(
424
+ `\n ✗ The schema change was applied, but the "${rls.REBASE_USER_ROLE}" role could not be ` +
425
+ `provisioned: ${err instanceof Error ? err.message : String(err)}`
426
+ ));
427
+ outError(chalk.gray(rls.appRoleSetupInstructions("your database user", schemas)));
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Retire the pre-1.0 `auth` schema, once nothing depends on it any more.
433
+ *
434
+ * Runs last on purpose. Postgres refuses to drop a function an RLS policy still
435
+ * calls, so this only succeeds after the policies above have been rewritten to
436
+ * `rebase.uid()`. See `dropLegacyAuthSchema` for what it reports when something
437
+ * hand-written is still holding the schema open, and DROP_LEGACY_AUTH_SCHEMA_SQL
438
+ * for the guards that keep it off a Supabase `auth` schema.
439
+ */
440
+ async function retireLegacyAuthSchema(databaseUrl: string): Promise<void> {
441
+ try {
442
+ const { Client } = await import("pg");
443
+ const client = new Client({ connectionString: databaseUrl });
444
+ await client.connect();
445
+ try {
446
+ await dropLegacyAuthSchema(
447
+ async (text) => (await client.query(text)).rows as Record<string, unknown>[],
448
+ {
449
+ info: (m) => out(chalk.gray(` ${m}`)),
450
+ warn: (m) => outWarn(chalk.yellow(` ⚠️ ${m}`))
451
+ }
452
+ );
453
+ } finally {
454
+ await client.end();
455
+ }
456
+ } catch {
457
+ // Connection-level failure only; the schema is inert either way.
336
458
  }
337
459
  }
338
460
 
@@ -341,8 +463,8 @@ async function applyPolicies(databaseUrl: string): Promise<void> {
341
463
  const policiesPath = path.resolve(process.cwd(), "drizzle", "policies.sql");
342
464
  if (!fs.existsSync(policiesPath)) return;
343
465
 
344
- logger.info(chalk.gray(" Step 3/3: Applying RLS policies to database..."));
345
- logger.info("");
466
+ out(chalk.gray(" Step 3/3: Applying RLS policies to database..."));
467
+ out("");
346
468
 
347
469
  const policiesContent = fs.readFileSync(policiesPath, "utf-8");
348
470
  const { Client } = await import("pg");
@@ -350,16 +472,16 @@ async function applyPolicies(databaseUrl: string): Promise<void> {
350
472
  await client.connect();
351
473
  try {
352
474
  await client.query(policiesContent);
353
- logger.info(chalk.green(" ✓ RLS policies applied successfully."));
475
+ out(chalk.green(" ✓ RLS policies applied successfully."));
354
476
  } finally {
355
477
  await client.end();
356
478
  }
357
479
  } catch (err) {
358
480
  const hint = diagnoseDbError(err, databaseUrl);
359
481
  if (hint) {
360
- logger.error(hint);
482
+ outError(hint);
361
483
  } else {
362
- logger.error(chalk.red(` ✗ Failed to apply RLS policies: ${err instanceof Error ? err.message : String(err)}`));
484
+ outError(chalk.red(` ✗ Failed to apply RLS policies: ${err instanceof Error ? err.message : String(err)}`));
363
485
  }
364
486
  process.exit(1);
365
487
  }
@@ -393,34 +515,34 @@ async function reconcilePolicies(databaseUrl: string, collectionsPath: string):
393
515
  const { dropped, kept } = await dropOrphanedPolicies(client as never, drift, collections);
394
516
 
395
517
  for (const p of dropped) {
396
- logger.info(chalk.gray(` ✓ Dropped superseded policy "${p.name}" on ${p.schema}.${p.table}`));
518
+ out(chalk.gray(` ✓ Dropped superseded policy "${p.name}" on ${p.schema}.${p.table}`));
397
519
  }
398
520
  if (dropped.length > 0) {
399
- logger.info(chalk.green(` ✓ Removed ${dropped.length} superseded RLS ${dropped.length === 1 ? "policy" : "policies"}.`));
521
+ out(chalk.green(` ✓ Removed ${dropped.length} superseded RLS ${dropped.length === 1 ? "policy" : "policies"}.`));
400
522
  }
401
523
 
402
524
  // Custom-named orphans are indistinguishable from policies someone
403
525
  // wrote in SQL deliberately, so they are reported, never dropped.
404
526
  if (kept.length > 0) {
405
- logger.warn(chalk.yellow(" ⚠️ Policies in the database that no collection describes:"));
527
+ outWarn(chalk.yellow(" ⚠️ Policies in the database that no collection describes:"));
406
528
  for (const p of kept) {
407
- logger.warn(chalk.yellow(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`));
529
+ outWarn(chalk.yellow(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`));
408
530
  }
409
- logger.warn(chalk.yellow(" These still grant access. Drop them by hand if they are stale."));
531
+ outWarn(chalk.yellow(" These still grant access. Drop them by hand if they are stale."));
410
532
  }
411
533
 
412
534
  // Missing/diverged are not push's to fix, but staying silent about
413
535
  // them is how a database ends up not matching its config.
414
536
  const remaining = { ...drift, orphaned: kept };
415
537
  if (hasDrift(remaining) && (remaining.missing.length > 0 || remaining.diverged.length > 0)) {
416
- logger.warn(chalk.yellow(" ⚠️ RLS policies do not match your collections:"));
417
- logger.warn(formatPolicyDrift({ ...remaining, orphaned: [] }));
538
+ outWarn(chalk.yellow(" ⚠️ RLS policies do not match your collections:"));
539
+ outWarn(formatPolicyDrift({ ...remaining, orphaned: [] }));
418
540
  }
419
541
  } finally {
420
542
  await client.end();
421
543
  }
422
544
  } catch (err) {
423
- logger.warn(chalk.yellow(` ⚠️ Could not reconcile RLS policies: ${err instanceof Error ? err.message : String(err)}`));
545
+ outWarn(chalk.yellow(` ⚠️ Could not reconcile RLS policies: ${err instanceof Error ? err.message : String(err)}`));
424
546
  }
425
547
  }
426
548
 
@@ -437,9 +559,9 @@ async function branchCommand(rawArgs: string[]): Promise<void> {
437
559
  const dotenv = await import("dotenv");
438
560
  const envPath = process.env.DOTENV_CONFIG_PATH;
439
561
  if (envPath) {
440
- dotenv.config({ path: envPath });
562
+ dotenv.config({ path: envPath, quiet: true });
441
563
  } else {
442
- dotenv.config();
564
+ dotenv.config({ quiet: true });
443
565
  }
444
566
  } catch {
445
567
  // dotenv may not be installed
@@ -447,7 +569,7 @@ async function branchCommand(rawArgs: string[]): Promise<void> {
447
569
 
448
570
  const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
449
571
  if (!databaseUrl) {
450
- logger.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
572
+ outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
451
573
  process.exit(1);
452
574
  }
453
575
 
@@ -471,8 +593,8 @@ max: 3 });
471
593
  case "create": {
472
594
  const name = rawArgs[3];
473
595
  if (!name) {
474
- logger.error(chalk.red("✗ Branch name is required."));
475
- logger.info(chalk.gray(" Usage: rebase db branch create <name> [--from <source>]"));
596
+ outError(chalk.red("✗ Branch name is required."));
597
+ out(chalk.gray(" Usage: rebase db branch create <name> [--from <source>]"));
476
598
  process.exit(1);
477
599
  }
478
600
  let source: string | undefined;
@@ -480,81 +602,81 @@ max: 3 });
480
602
  if (fromIdx !== -1 && rawArgs[fromIdx + 1]) {
481
603
  source = rawArgs[fromIdx + 1];
482
604
  }
483
- logger.info("");
484
- logger.info(chalk.bold(" 🌿 Creating database branch..."));
485
- logger.info(chalk.gray(` Name: ${name}`));
486
- if (source) logger.info(chalk.gray(` Source: ${source}`));
487
- logger.info("");
605
+ out("");
606
+ out(chalk.bold(" 🌿 Creating database branch..."));
607
+ out(chalk.gray(` Name: ${name}`));
608
+ if (source) out(chalk.gray(` Source: ${source}`));
609
+ out("");
488
610
  const branch = await branchService.createBranch(name, source ? { source } : undefined);
489
- logger.info(chalk.green(` ✓ Branch "${branch.name}" created successfully.`));
490
- logger.info(chalk.gray(` Database: rb_${branch.name}`));
491
- logger.info(chalk.gray(` Parent: ${branch.parentDatabase}`));
492
- logger.info("");
611
+ out(chalk.green(` ✓ Branch "${branch.name}" created successfully.`));
612
+ out(chalk.gray(` Database: rb_${branch.name}`));
613
+ out(chalk.gray(` Parent: ${branch.parentDatabase}`));
614
+ out("");
493
615
  break;
494
616
  }
495
617
 
496
618
  case "list": {
497
619
  const branches = await branchService.listBranches();
498
- logger.info("");
620
+ out("");
499
621
  if (branches.length === 0) {
500
- logger.info(chalk.gray(" No branches found. Create one with: rebase db branch create <name>"));
622
+ out(chalk.gray(" No branches found. Create one with: rebase db branch create <name>"));
501
623
  } else {
502
- logger.info(chalk.bold(` 🌿 ${branches.length} branch(es):`));
503
- logger.info("");
624
+ out(chalk.bold(` 🌿 ${branches.length} branch(es):`));
625
+ out("");
504
626
  for (const b of branches) {
505
627
  const size = b.sizeBytes != null
506
628
  ? chalk.gray(` (${formatBytes(b.sizeBytes)})`)
507
629
  : "";
508
630
  const age = chalk.gray(` — created ${timeAgo(b.createdAt)}`);
509
- logger.info(` ${chalk.green("●")} ${chalk.bold(b.name)}${size}${age}`);
510
- logger.info(chalk.gray(` from ${b.parentDatabase}`));
631
+ out(` ${chalk.green("●")} ${chalk.bold(b.name)}${size}${age}`);
632
+ out(chalk.gray(` from ${b.parentDatabase}`));
511
633
  }
512
634
  }
513
- logger.info("");
635
+ out("");
514
636
  break;
515
637
  }
516
638
 
517
639
  case "delete": {
518
640
  const name = rawArgs[3];
519
641
  if (!name) {
520
- logger.error(chalk.red("✗ Branch name is required."));
521
- logger.info(chalk.gray(" Usage: rebase db branch delete <name>"));
642
+ outError(chalk.red("✗ Branch name is required."));
643
+ out(chalk.gray(" Usage: rebase db branch delete <name>"));
522
644
  process.exit(1);
523
645
  }
524
- logger.info("");
525
- logger.info(chalk.bold(` 🗑️ Deleting branch "${name}"...`));
646
+ out("");
647
+ out(chalk.bold(` 🗑️ Deleting branch "${name}"...`));
526
648
  await branchService.deleteBranch(name);
527
- logger.info(chalk.green(` ✓ Branch "${name}" deleted.`));
528
- logger.info("");
649
+ out(chalk.green(` ✓ Branch "${name}" deleted.`));
650
+ out("");
529
651
  break;
530
652
  }
531
653
 
532
654
  case "info": {
533
655
  const name = rawArgs[3];
534
656
  if (!name) {
535
- logger.error(chalk.red("✗ Branch name is required."));
536
- logger.info(chalk.gray(" Usage: rebase db branch info <name>"));
657
+ outError(chalk.red("✗ Branch name is required."));
658
+ out(chalk.gray(" Usage: rebase db branch info <name>"));
537
659
  process.exit(1);
538
660
  }
539
661
  const info = await branchService.getBranchInfo(name);
540
- logger.info("");
662
+ out("");
541
663
  if (!info) {
542
- logger.error(chalk.red(` ✗ Branch "${name}" not found.`));
664
+ outError(chalk.red(` ✗ Branch "${name}" not found.`));
543
665
  } else {
544
- logger.info(chalk.bold(` 🌿 Branch: ${info.name}`));
545
- logger.info(chalk.gray(` Database: rb_${info.name}`));
546
- logger.info(chalk.gray(` Parent: ${info.parentDatabase}`));
547
- logger.info(chalk.gray(` Created: ${info.createdAt.toISOString()}`));
666
+ out(chalk.bold(` 🌿 Branch: ${info.name}`));
667
+ out(chalk.gray(` Database: rb_${info.name}`));
668
+ out(chalk.gray(` Parent: ${info.parentDatabase}`));
669
+ out(chalk.gray(` Created: ${info.createdAt.toISOString()}`));
548
670
  if (info.sizeBytes != null) {
549
- logger.info(chalk.gray(` Size: ${formatBytes(info.sizeBytes)}`));
671
+ out(chalk.gray(` Size: ${formatBytes(info.sizeBytes)}`));
550
672
  }
551
673
  }
552
- logger.info("");
674
+ out("");
553
675
  break;
554
676
  }
555
677
 
556
678
  default:
557
- logger.error(chalk.red(`Unknown branch action: "${branchAction}".`));
679
+ outError(chalk.red(`Unknown branch action: "${branchAction}".`));
558
680
  printBranchHelp();
559
681
  process.exit(1);
560
682
  }
@@ -565,7 +687,7 @@ max: 3 });
565
687
  }
566
688
 
567
689
  function printBranchHelp() {
568
- logger.info(`
690
+ out(`
569
691
  ${chalk.bold("rebase db branch")} — Database branching commands
570
692
 
571
693
  ${chalk.green.bold("Usage")}
@@ -599,15 +721,11 @@ function formatBytes(bytes: number): string {
599
721
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
600
722
  }
601
723
 
724
+ /** A branch's age, for "…created 6d ago". */
602
725
  function timeAgo(date: Date): string {
603
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
604
- if (seconds < 60) return "just now";
605
- const minutes = Math.floor(seconds / 60);
606
- if (minutes < 60) return `${minutes}m ago`;
607
- const hours = Math.floor(minutes / 60);
608
- if (hours < 24) return `${hours}h ago`;
609
- const days = Math.floor(hours / 24);
610
- return `${days}d ago`;
726
+ // No horizon: a branch created a year ago should still report an age here,
727
+ // rather than fall back to a bare date in a one-line list entry.
728
+ return formatRelativeTime(date, { maxMs: Number.POSITIVE_INFINITY }) ?? "unknown";
611
729
  }
612
730
 
613
731
 
@@ -624,28 +742,28 @@ async function runAtlas(
624
742
  // other — see `diagnoseMissingBin`. This used to say "Install it with:
625
743
  // pnpm add -D @ariga/atlas" unconditionally, which is the exact command
626
744
  // that produces the far more common of the two states.
627
- logger.error(chalk.red("\n✗ The atlas binary is missing, so the schema cannot be applied.\n"));
745
+ outError(chalk.red("\n✗ The atlas binary is missing, so the schema cannot be applied.\n"));
628
746
 
629
747
  if (diagnoseMissingBin("@ariga/atlas") === "build-script-blocked") {
630
- logger.error(chalk.yellow(" @ariga/atlas IS installed — only its binary is missing.\n"));
631
- logger.error(chalk.gray(
748
+ outError(chalk.yellow(" @ariga/atlas IS installed — only its binary is missing.\n"));
749
+ outError(chalk.gray(
632
750
  " It downloads that binary in a `preinstall` script, and pnpm 10+ does not\n" +
633
751
  " run a dependency's scripts unless you allow it. The install still exits 0,\n" +
634
752
  " so the only sign is `Ignored build scripts: @ariga/atlas` in its output.\n"
635
753
  ));
636
- logger.error(" Fix it with either:\n");
637
- logger.error(chalk.bold(" pnpm approve-builds\n"));
638
- logger.error(" or, to record it in the project (what `rebase init` scaffolds):\n");
639
- logger.error(chalk.bold(
754
+ outError(" Fix it with either:\n");
755
+ outError(chalk.bold(" pnpm approve-builds\n"));
756
+ outError(" or, to record it in the project (what `rebase init` scaffolds):\n");
757
+ outError(chalk.bold(
640
758
  " // package.json\n" +
641
759
  " \"pnpm\": { \"onlyBuiltDependencies\": [\"@ariga/atlas\"] }\n"
642
760
  ));
643
- logger.error(chalk.gray(" Then re-run `pnpm install`.\n"));
761
+ outError(chalk.gray(" Then re-run `pnpm install`.\n"));
644
762
  } else {
645
- logger.error(chalk.gray(" It is not installed in this project.\n"));
646
- logger.error(" Install it with:\n");
647
- logger.error(chalk.bold(" pnpm add -D @ariga/atlas\n"));
648
- logger.error(chalk.gray(
763
+ outError(chalk.gray(" It is not installed in this project.\n"));
764
+ outError(" Install it with:\n");
765
+ outError(chalk.bold(" pnpm add -D @ariga/atlas\n"));
766
+ outError(chalk.gray(
649
767
  " If pnpm then reports `Ignored build scripts`, also run `pnpm approve-builds` —\n" +
650
768
  " the package carries a `preinstall` script that fetches the binary.\n"
651
769
  ));
@@ -665,7 +783,7 @@ async function runAtlas(
665
783
 
666
784
  for (const p of envPaths) {
667
785
  if (fs.existsSync(p)) {
668
- const parsed = dotenv.config({ path: p });
786
+ const parsed = dotenv.config({ path: p, quiet: true });
669
787
  if (parsed.parsed) {
670
788
  for (const [key, val] of Object.entries(parsed.parsed)) {
671
789
  if (env[key] === undefined) {
@@ -682,7 +800,7 @@ async function runAtlas(
682
800
 
683
801
  const databaseUrl = env.DATABASE_URL;
684
802
  if (!databaseUrl) {
685
- logger.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
803
+ outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
686
804
  process.exit(1);
687
805
  }
688
806
 
@@ -693,6 +811,9 @@ async function runAtlas(
693
811
 
694
812
  const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
695
813
  await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
814
+ if (collectionsPath) {
815
+ await seedDevDatabaseSearchHelpers(devDatabaseUrl, collectionsPath);
816
+ }
696
817
 
697
818
  // Atlas speaks libpq, which rejects the `sslmode=no-verify` that
698
819
  // node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
@@ -719,6 +840,16 @@ async function runAtlas(
719
840
  }
720
841
  }
721
842
 
843
+ // Search objects are Rebase's, not Atlas's — on both paths. `schema apply`
844
+ // would drop them as absent from the desired state; `migrate diff` would
845
+ // write that same drop into the next migration file, where it would sit
846
+ // waiting to be applied later.
847
+ if (collectionsPath && (args.includes("apply") || args.includes("diff"))) {
848
+ for (const exc of await getSearchExcludes(collectionsPath)) {
849
+ atlasArgs.push("--exclude", exc);
850
+ }
851
+ }
852
+
722
853
  if (domain === "schema" && args.includes("apply") && collectionsPath) {
723
854
  // Fail CLOSED: the exclude list is the only thing shielding
724
855
  // non-collection tables from the auto-approved apply. If we can't
@@ -729,11 +860,11 @@ async function runAtlas(
729
860
  excludes = await getTableExcludes(databaseUrl, collectionsPath);
730
861
  } catch (err) {
731
862
  if (err instanceof ExcludeIntrospectionError) {
732
- logger.error(chalk.red("\n✗ Aborting push: could not determine which tables to protect."));
733
- logger.error(chalk.gray(` ${err.message}`));
734
- logger.error(chalk.gray(" Refusing to apply — a partial exclude list could drop tables Rebase does not manage."));
863
+ outError(chalk.red("\n✗ Aborting push: could not determine which tables to protect."));
864
+ outError(chalk.gray(` ${err.message}`));
865
+ outError(chalk.gray(" Refusing to apply — a partial exclude list could drop tables Rebase does not manage."));
735
866
  const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
736
- if (hint) logger.error(hint);
867
+ if (hint) outError(hint);
737
868
  process.exit(1);
738
869
  }
739
870
  throw err;
@@ -768,11 +899,11 @@ async function runAtlas(
768
899
  try {
769
900
  await subprocess;
770
901
  } catch {
771
- logger.error(chalk.red(`\n✗ atlas ${domain} ${args.join(" ")} failed.\n`));
902
+ outError(chalk.red(`\n✗ atlas ${domain} ${args.join(" ")} failed.\n`));
772
903
  // Surface actionable recovery guidance for recognized failures.
773
904
  const hint = diagnoseDbError({ message: stderrText }, databaseUrl);
774
905
  if (hint) {
775
- logger.error(hint);
906
+ outError(hint);
776
907
  }
777
908
  process.exit(1);
778
909
  }
@@ -802,7 +933,7 @@ async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
802
933
  const ddlScript = path.join(__cliDirname, "schema", "generate-postgres-ddl.ts");
803
934
  const tsxBin = resolveLocalBin("tsx");
804
935
  if (!tsxBin) {
805
- logger.error(chalk.red("✗ Could not find tsx binary."));
936
+ outError(chalk.red("✗ Could not find tsx binary."));
806
937
  process.exit(1);
807
938
  }
808
939
 
@@ -823,12 +954,91 @@ async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
823
954
  env: { ...process.env as Record<string, string> }
824
955
  });
825
956
  } catch (err: unknown) {
826
- logger.error(chalk.red(`✗ Failed to run Postgres DDL generator: ${err instanceof Error ? err.message : String(err)}`));
957
+ outError(chalk.red(`✗ Failed to run Postgres DDL generator: ${err instanceof Error ? err.message : String(err)}`));
958
+ process.exit(1);
959
+ }
960
+ }
961
+
962
+ /**
963
+ * `schema stale [--fix]` — is the generated Drizzle schema older than the rule
964
+ * that derives its foreign-key names?
965
+ *
966
+ * This exists for one upgrade path and is worth the command. 0.13 derives
967
+ * `category_id` where 0.12 derived `categorie_id`; boot-ensure renames the
968
+ * database column to match, and the project's checked-in
969
+ * `backend/src/schema.generated.ts` is then wrong in a way nothing the developer
970
+ * did would explain. Relation validation refuses to boot on it, permanently,
971
+ * because the rename has already been applied and will not run again.
972
+ *
973
+ * `rebase dev` calls this with `--fix` before starting the backend, so the
974
+ * upgrade behaves the way the release note says it does: the column moves and the
975
+ * project keeps working. Without `--fix` it reports and exits non-zero, which is
976
+ * what a build or a CI step wants.
977
+ */
978
+ async function schemaStaleCommand(rawArgs: string[]): Promise<void> {
979
+ const argsList = arg(
980
+ {
981
+ "--collections": String,
982
+ "--output": String,
983
+ "--fix": Boolean,
984
+ "-c": "--collections",
985
+ "-o": "--output"
986
+ },
987
+ { argv: rawArgs.slice(2), permissive: true }
988
+ );
989
+
990
+ const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
991
+ const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
992
+ const schemaFile = path.resolve(process.cwd(), outputPath);
993
+
994
+ // No generated schema yet is not staleness — a fresh project has not run the
995
+ // generator, and saying "stale" about a file that does not exist would send
996
+ // the reader looking for something to fix.
997
+ if (!fs.existsSync(schemaFile)) return;
998
+
999
+ const { loadCollections } = await import("./schema/doctor");
1000
+ const { findLegacyForeignKeyNames, describeLegacyForeignKeyNames } =
1001
+ await import("./schema/generated-schema-staleness");
1002
+
1003
+ let stale;
1004
+ try {
1005
+ const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
1006
+ stale = findLegacyForeignKeyNames(fs.readFileSync(schemaFile, "utf8"), collections);
1007
+ } catch (err) {
1008
+ // Best-effort by design: a collections directory that will not load is a
1009
+ // real error, but it is one the boot reports far better than this does.
1010
+ logger.debug(`schema stale: skipped (${err instanceof Error ? err.message : String(err)})`);
1011
+ return;
1012
+ }
1013
+
1014
+ if (stale.length === 0) return;
1015
+
1016
+ out("");
1017
+ outWarn(chalk.yellow(
1018
+ ` ⚠️ ${outputPath} names ${stale.length} foreign key(s) the way an earlier release did:`
1019
+ ));
1020
+ out(chalk.gray(describeLegacyForeignKeyNames(stale)));
1021
+ out("");
1022
+
1023
+ if (!argsList["--fix"]) {
1024
+ outError(chalk.red(
1025
+ " The database column has already been renamed at boot, so the generated schema no " +
1026
+ "longer matches it and the server will refuse to start."
1027
+ ));
1028
+ outError(chalk.red(" Run `rebase schema generate` to regenerate it."));
827
1029
  process.exit(1);
828
1030
  }
1031
+
1032
+ out(chalk.gray(" Regenerating the Drizzle schema so it matches..."));
1033
+ await schemaCommand("generate", ["schema", "generate", `--collections=${collectionsPath}`, `--output=${outputPath}`]);
829
1034
  }
830
1035
 
831
1036
  async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<void> {
1037
+ if (subcommand === "stale") {
1038
+ await schemaStaleCommand(rawArgs);
1039
+ return;
1040
+ }
1041
+
832
1042
  if (subcommand === "generate") {
833
1043
  const argsList = arg(
834
1044
  {
@@ -849,13 +1059,13 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
849
1059
  // If installed in node_modules, __cliDirname is node_modules/@rebasepro/server-postgres/dist or src.
850
1060
  const generatorScript = path.join(__cliDirname, "schema", "generate-drizzle-schema.ts");
851
1061
  if (!fs.existsSync(generatorScript)) {
852
- logger.error(chalk.red(`✗ Could not find generate-drizzle-schema.ts at ${generatorScript}`));
1062
+ outError(chalk.red(`✗ Could not find generate-drizzle-schema.ts at ${generatorScript}`));
853
1063
  process.exit(1);
854
1064
  }
855
1065
 
856
1066
  const tsxBin = resolveLocalBin("tsx");
857
1067
  if (!tsxBin) {
858
- logger.error(chalk.red("✗ Could not find tsx binary."));
1068
+ outError(chalk.red("✗ Could not find tsx binary."));
859
1069
  process.exit(1);
860
1070
  }
861
1071
 
@@ -863,9 +1073,9 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
863
1073
  const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
864
1074
  const watch = argsList["--watch"] || false;
865
1075
 
866
- logger.info("");
867
- logger.info(chalk.bold(" 🔧 Rebase Schema Generator"));
868
- logger.info("");
1076
+ out("");
1077
+ out(chalk.bold(" 🔧 Rebase Schema Generator"));
1078
+ out("");
869
1079
 
870
1080
  const cmdParts = [
871
1081
  tsxBin,
@@ -884,7 +1094,7 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
884
1094
  env: { ...process.env as Record<string, string> }
885
1095
  });
886
1096
  } catch (err: unknown) {
887
- logger.error(chalk.red(`✗ Failed to run schema generator: ${err instanceof Error ? err.message : String(err)}`));
1097
+ outError(chalk.red(`✗ Failed to run schema generator: ${err instanceof Error ? err.message : String(err)}`));
888
1098
  process.exit(1);
889
1099
  }
890
1100
  } else if (subcommand === "introspect") {
@@ -906,21 +1116,21 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
906
1116
 
907
1117
  const introspectScript = path.join(__cliDirname, "schema", "introspect-db.ts");
908
1118
  if (!fs.existsSync(introspectScript)) {
909
- logger.error(chalk.red(`✗ Could not find introspect-db.ts at ${introspectScript}`));
1119
+ outError(chalk.red(`✗ Could not find introspect-db.ts at ${introspectScript}`));
910
1120
  process.exit(1);
911
1121
  }
912
1122
 
913
1123
  const tsxBin = resolveLocalBin("tsx");
914
1124
  if (!tsxBin) {
915
- logger.error(chalk.red("✗ Could not find tsx binary."));
1125
+ outError(chalk.red("✗ Could not find tsx binary."));
916
1126
  process.exit(1);
917
1127
  }
918
1128
 
919
1129
  const outputPath = argsList["--output"] || argsList["--collections"] || path.join("..", "config", "collections");
920
1130
 
921
- logger.info("");
922
- logger.info(chalk.bold(" 🔍 Rebase Schema Introspector"));
923
- logger.info("");
1131
+ out("");
1132
+ out(chalk.bold(" 🔍 Rebase Schema Introspector"));
1133
+ out("");
924
1134
 
925
1135
  const cmdParts = [
926
1136
  tsxBin,
@@ -937,11 +1147,11 @@ async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<voi
937
1147
  env: { ...process.env as Record<string, string> }
938
1148
  });
939
1149
  } catch (err: unknown) {
940
- logger.error(chalk.red(`✗ Failed to run schema introspector: ${err instanceof Error ? err.message : String(err)}`));
1150
+ outError(chalk.red(`✗ Failed to run schema introspector: ${err instanceof Error ? err.message : String(err)}`));
941
1151
  process.exit(1);
942
1152
  }
943
1153
  } else {
944
- logger.error(chalk.red("Unknown schema command."));
1154
+ outError(chalk.red("Unknown schema command."));
945
1155
  process.exit(1);
946
1156
  }
947
1157
  }
@@ -965,13 +1175,13 @@ async function doctorPluginCommand(rawArgs: string[]): Promise<void> {
965
1175
 
966
1176
  const doctorScript = path.join(__cliDirname, "schema", "doctor-cli.ts");
967
1177
  if (!fs.existsSync(doctorScript)) {
968
- logger.error(chalk.red(`✗ Could not find doctor.ts at ${doctorScript}`));
1178
+ outError(chalk.red(`✗ Could not find doctor.ts at ${doctorScript}`));
969
1179
  process.exit(1);
970
1180
  }
971
1181
 
972
1182
  const tsxBin = resolveLocalBin("tsx");
973
1183
  if (!tsxBin) {
974
- logger.error(chalk.red("✗ Could not find tsx binary."));
1184
+ outError(chalk.red("✗ Could not find tsx binary."));
975
1185
  process.exit(1);
976
1186
  }
977
1187