@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) 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-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  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 +56 -0
  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-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  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 +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -9,7 +9,9 @@ import fs from "fs";
9
9
  import os from "os";
10
10
  import readline from "readline";
11
11
  import chalk from "chalk";
12
- import { logger } from "@rebasepro/server";
12
+ // Aliased: `out` is already a local in two of the commands below (the `--out`
13
+ // backup destination).
14
+ import { out as print, outWarn, outError } from "../cli-output";
13
15
  import type { StorageController } from "@rebasepro/server";
14
16
  import {
15
17
  BackupDestination,
@@ -73,7 +75,7 @@ async function resolveStorageForDestination(
73
75
  function requireConnection(): string {
74
76
  const conn = resolveConnectionString(process.env);
75
77
  if (!conn) {
76
- logger.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
78
+ outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
77
79
  process.exit(1);
78
80
  }
79
81
  return conn;
@@ -117,19 +119,19 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
117
119
  const out = args["--out"] || process.env.BACKUP_DESTINATION || path.join(process.cwd(), "backups");
118
120
  const dest = parseBackupDestination(out);
119
121
 
120
- logger.info("");
121
- logger.info(chalk.bold(" 💾 Rebase DB Backup"));
122
- logger.info(chalk.gray(` Database: ${dbName}`));
123
- logger.info(chalk.gray(` Destination: ${out}`));
124
- logger.info("");
122
+ print("");
123
+ print(chalk.bold(" 💾 Rebase DB Backup"));
124
+ print(chalk.gray(` Database: ${dbName}`));
125
+ print(chalk.gray(` Destination: ${out}`));
126
+ print("");
125
127
 
126
128
  // Version pre-flight (doctor-style).
127
129
  const pf = await preflight("pg_dump", connectionString);
128
130
  if (!pf.compatible) {
129
- logger.error(chalk.red(` ✗ ${pf.reason}`));
131
+ outError(chalk.red(` ✗ ${pf.reason}`));
130
132
  process.exit(1);
131
133
  }
132
- logger.info(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
134
+ print(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
133
135
 
134
136
  // Opt-in, and loud. With row security on, pg_dump stops refusing to read
135
137
  // rows it cannot see and starts leaving them out — an exit-0 backup that
@@ -139,13 +141,13 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
139
141
  : undefined;
140
142
 
141
143
  if (rowSecurity) {
142
- logger.warn("");
143
- logger.warn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
144
- logger.warn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
145
- logger.warn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
146
- logger.warn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
147
- logger.warn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
148
- logger.warn("");
144
+ outWarn("");
145
+ outWarn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
146
+ outWarn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
147
+ outWarn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
148
+ outWarn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
149
+ outWarn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
150
+ outWarn("");
149
151
  }
150
152
 
151
153
  try {
@@ -164,10 +166,10 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
164
166
  rowSecurity
165
167
  });
166
168
  await assertDumpValid(dump.localFile);
167
- logger.info("");
168
- logger.info(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
169
+ print("");
170
+ print(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
169
171
  if (dump.globalsFile) {
170
- logger.info(chalk.gray(` ✓ Roles captured to ${dump.globalsFile} (needed so RLS survives a restore).`));
172
+ print(chalk.gray(` ✓ Roles captured to ${dump.globalsFile} (needed so RLS survives a restore).`));
171
173
  }
172
174
  } else {
173
175
  const storage = await resolveStorageForDestination(dest, process.env);
@@ -182,21 +184,21 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
182
184
  try {
183
185
  await assertDumpValid(dump.localFile);
184
186
  const uploaded = await uploadBackup(storage!, dump.localFile, dest);
185
- logger.info("");
186
- logger.info(chalk.green(` ✓ Backup uploaded to ${uploaded.storageUrl} (${formatBytes(dump.sizeBytes)})`));
187
+ print("");
188
+ print(chalk.green(` ✓ Backup uploaded to ${uploaded.storageUrl} (${formatBytes(dump.sizeBytes)})`));
187
189
  // Upload the roles sidecar next to the dump so a restore can
188
190
  // recreate roles the dump's GRANT/RLS statements depend on.
189
191
  if (dump.globalsFile && fs.existsSync(dump.globalsFile)) {
190
192
  const globalsUpload = await uploadBackup(storage!, dump.globalsFile, dest);
191
- logger.info(chalk.gray(` ✓ Roles uploaded to ${globalsUpload.storageUrl} (needed so RLS survives a restore).`));
193
+ print(chalk.gray(` ✓ Roles uploaded to ${globalsUpload.storageUrl} (needed so RLS survives a restore).`));
192
194
  }
193
- logger.info(chalk.gray(" Ensure this bucket is private — backups may contain secrets and PII."));
195
+ print(chalk.gray(" Ensure this bucket is private — backups may contain secrets and PII."));
194
196
  } finally {
195
197
  if (fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
196
198
  if (dump.globalsFile && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
197
199
  }
198
200
  }
199
- logger.info("");
201
+ print("");
200
202
  } catch (err) {
201
203
  reportError(err);
202
204
  process.exit(1);
@@ -236,11 +238,11 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
236
238
  ? withDatabaseName(baseConnection, args["--target-db"])
237
239
  : baseConnection;
238
240
 
239
- logger.info("");
240
- logger.info(chalk.bold(" ♻️ Rebase DB Restore"));
241
- logger.info(chalk.gray(` Source: ${backupArg}`));
242
- logger.info(chalk.gray(` Target: ${targetDb ?? "(from DATABASE_URL)"}`));
243
- logger.info("");
241
+ print("");
242
+ print(chalk.bold(" ♻️ Rebase DB Restore"));
243
+ print(chalk.gray(` Source: ${backupArg}`));
244
+ print(chalk.gray(` Target: ${targetDb ?? "(from DATABASE_URL)"}`));
245
+ print("");
244
246
 
245
247
  // Resolve the local file to restore from (download object-storage keys).
246
248
  // Also resolve the `.globals.sql` roles sidecar so cluster roles can be
@@ -267,7 +269,7 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
267
269
  } else {
268
270
  localFile = path.resolve(backupArg);
269
271
  if (!fs.existsSync(localFile)) {
270
- logger.error(chalk.red(` ✗ Backup file not found: ${localFile}`));
272
+ outError(chalk.red(` ✗ Backup file not found: ${localFile}`));
271
273
  process.exit(1);
272
274
  }
273
275
  const globalsPath = globalsFileForDump(localFile);
@@ -279,29 +281,29 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
279
281
  // exist yet when --create-db is used.
280
282
  const pf = await preflight("pg_restore", baseConnection);
281
283
  if (!pf.compatible) {
282
- logger.error(chalk.red(` ✗ ${pf.reason}`));
284
+ outError(chalk.red(` ✗ ${pf.reason}`));
283
285
  process.exit(1);
284
286
  }
285
287
 
286
288
  // Create the target database when requested.
287
289
  if (args["--create-db"]) {
288
290
  if (!targetDb) {
289
- logger.error(chalk.red(" ✗ --create-db requires a resolvable target database name (use --target-db)."));
291
+ outError(chalk.red(" ✗ --create-db requires a resolvable target database name (use --target-db)."));
290
292
  process.exit(1);
291
293
  }
292
294
  const created = await ensureDatabaseExists(baseConnection, targetDb);
293
- logger.info(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
295
+ print(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
294
296
  }
295
297
 
296
298
  // Destructive-action gate. Restores overwrite data; never run without
297
299
  // an explicit yes (interactive confirmation or --yes).
298
300
  if (!args["--yes"]) {
299
- logger.warn(chalk.yellow(
301
+ outWarn(chalk.yellow(
300
302
  ` ⚠️ This will restore into "${targetDb ?? "the target database"}" and may overwrite existing data.`
301
303
  ));
302
304
  const confirmed = await promptConfirm(chalk.yellow(" Type 'yes' to continue: "));
303
305
  if (!confirmed) {
304
- logger.info(chalk.gray(" Aborted. No changes were made."));
306
+ print(chalk.gray(" Aborted. No changes were made."));
305
307
  process.exit(1);
306
308
  }
307
309
  }
@@ -309,17 +311,17 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
309
311
  // Recreate cluster roles before restoring so GRANT/RLS statements in
310
312
  // the dump apply. Best-effort and idempotent (see applyGlobals).
311
313
  if (globalsSql) {
312
- logger.info(chalk.gray(" Recreating cluster roles from the backup's roles sidecar…"));
314
+ print(chalk.gray(" Recreating cluster roles from the backup's roles sidecar…"));
313
315
  const { applied, skipped } = await applyGlobals(
314
316
  targetConnection,
315
317
  globalsSql,
316
- (m) => logger.info(chalk.gray(m))
318
+ (m) => print(chalk.gray(m))
317
319
  );
318
- logger.info(chalk.gray(` ✓ Roles: ${applied} applied, ${skipped} skipped (already present or not permitted).`));
320
+ print(chalk.gray(` ✓ Roles: ${applied} applied, ${skipped} skipped (already present or not permitted).`));
319
321
  } else {
320
- logger.warn(chalk.yellow(" ⚠️ No roles sidecar (.globals.sql) accompanies this backup."));
321
- logger.warn(chalk.yellow(" If the dump grants to roles that don't exist (e.g. rebase_user), the restore"));
322
- logger.warn(chalk.yellow(" will fail — recreate those roles first, or use a backup that includes its globals."));
322
+ outWarn(chalk.yellow(" ⚠️ No roles sidecar (.globals.sql) accompanies this backup."));
323
+ outWarn(chalk.yellow(" If the dump grants to roles that don't exist (e.g. rebase_user), the restore"));
324
+ outWarn(chalk.yellow(" will fail — recreate those roles first, or use a backup that includes its globals."));
323
325
  }
324
326
 
325
327
  await restoreDump({
@@ -332,9 +334,9 @@ export async function restoreCommand(rawArgs: string[]): Promise<void> {
332
334
  inheritStdio: true
333
335
  });
334
336
 
335
- logger.info("");
336
- logger.info(chalk.green(` ✓ Restore completed into "${targetDb ?? "the target database"}".`));
337
- logger.info("");
337
+ print("");
338
+ print(chalk.green(` ✓ Restore completed into "${targetDb ?? "the target database"}".`));
339
+ print("");
338
340
  } catch (err) {
339
341
  reportError(err);
340
342
  process.exit(1);
@@ -355,7 +357,7 @@ export async function backupsCommand(rawArgs: string[]): Promise<void> {
355
357
  return;
356
358
  }
357
359
  if (action !== "list") {
358
- logger.error(chalk.red(`Unknown backups action: "${action}". Valid: list`));
360
+ outError(chalk.red(`Unknown backups action: "${action}". Valid: list`));
359
361
  process.exit(1);
360
362
  }
361
363
 
@@ -366,19 +368,19 @@ export async function backupsCommand(rawArgs: string[]): Promise<void> {
366
368
  try {
367
369
  const storage = await resolveStorageForDestination(dest, process.env);
368
370
  const backups = await listBackups(dest, storage ?? undefined);
369
- logger.info("");
371
+ print("");
370
372
  if (backups.length === 0) {
371
- logger.info(chalk.gray(` No backups found at ${out}.`));
373
+ print(chalk.gray(` No backups found at ${out}.`));
372
374
  } else {
373
- logger.info(chalk.bold(` 💾 ${backups.length} backup(s) at ${out}:`));
374
- logger.info("");
375
+ print(chalk.bold(` 💾 ${backups.length} backup(s) at ${out}:`));
376
+ print("");
375
377
  for (const b of backups) {
376
378
  const when = b.createdAt ? b.createdAt.toISOString() : "unknown date";
377
379
  const name = dest.kind === "local" ? path.basename(b.key) : b.key;
378
- logger.info(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}`);
380
+ print(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}`);
379
381
  }
380
382
  }
381
- logger.info("");
383
+ print("");
382
384
  } catch (err) {
383
385
  reportError(err);
384
386
  process.exit(1);
@@ -398,15 +400,15 @@ async function assertDumpValid(localFile: string): Promise<void> {
398
400
 
399
401
  function reportError(err: unknown): void {
400
402
  if (err instanceof BackupToolError) {
401
- logger.error(chalk.red(` ✗ ${err.message}`));
402
- if (err.hint) logger.error(chalk.gray(` ${err.hint}`));
403
+ outError(chalk.red(` ✗ ${err.message}`));
404
+ if (err.hint) outError(chalk.gray(` ${err.hint}`));
403
405
  } else {
404
- logger.error(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
406
+ outError(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
405
407
  }
406
408
  }
407
409
 
408
410
  function printBackupHelp(): void {
409
- logger.info(`
411
+ print(`
410
412
  ${chalk.bold("rebase db backup")} — Create a database backup (pg_dump, custom format)
411
413
 
412
414
  ${chalk.green.bold("Usage")}
@@ -442,7 +444,7 @@ ${chalk.green.bold("Notes")}
442
444
  }
443
445
 
444
446
  function printRestoreHelp(): void {
445
- logger.info(`
447
+ print(`
446
448
  ${chalk.bold("rebase db restore")} — Restore a database from a backup (pg_restore)
447
449
 
448
450
  ${chalk.green.bold("Usage")}
@@ -472,7 +474,7 @@ ${chalk.red.bold("Warning")}
472
474
  }
473
475
 
474
476
  function printBackupsHelp(): void {
475
- logger.info(`
477
+ print(`
476
478
  ${chalk.bold("rebase db backups")} — Manage stored backups
477
479
 
478
480
  ${chalk.green.bold("Usage")}
package/src/cli-errors.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { logger } from "@rebasepro/server";
2
+ import { outWarn, outError } from "./cli-output";
3
3
 
4
4
  /**
5
5
  * Detect whether an error (or AggregateError wrapping multiple attempts)
@@ -220,20 +220,20 @@ export async function checkDatabaseConnectivity(databaseUrl: string): Promise<vo
220
220
  await client.query("SELECT 1");
221
221
  } catch (err: unknown) {
222
222
  if (isEconnrefused(err)) {
223
- logger.error(formatConnectionRefusedBanner(databaseUrl));
223
+ outError(formatConnectionRefusedBanner(databaseUrl));
224
224
  process.exit(1);
225
225
  }
226
226
  if (isAuthFailure(err)) {
227
- logger.error(formatAuthFailureBanner(databaseUrl));
227
+ outError(formatAuthFailureBanner(databaseUrl));
228
228
  process.exit(1);
229
229
  }
230
230
  if (isSslNotEnabled(err)) {
231
- logger.error(formatSslNotEnabledBanner(databaseUrl));
231
+ outError(formatSslNotEnabledBanner(databaseUrl));
232
232
  process.exit(1);
233
233
  }
234
234
  // Unknown error — warn but don't block; let the downstream tool surface details
235
- logger.warn(chalk.yellow(` ⚠ Could not verify database connectivity: ${err instanceof Error ? err.message : String(err)}`));
236
- logger.warn(chalk.gray(" Proceeding anyway — the command may fail if the database is unreachable."));
235
+ outWarn(chalk.yellow(` ⚠ Could not verify database connectivity: ${err instanceof Error ? err.message : String(err)}`));
236
+ outWarn(chalk.gray(" Proceeding anyway — the command may fail if the database is unreachable."));
237
237
  } finally {
238
238
  try {
239
239
  await client?.end();
@@ -6,7 +6,7 @@ import { createRequire } from "module";
6
6
  import readline from "readline";
7
7
  import { pathToFileURL } from "url";
8
8
  import chalk from "chalk";
9
- import { logger } from "@rebasepro/server";
9
+ import { out, outWarn } from "./cli-output";
10
10
  import type { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
11
11
  import { moduleDir as __helpersDirname } from "./module-dir";
12
12
 
@@ -119,6 +119,19 @@ export async function getTableIncludesFromCollections(allCollections: Collection
119
119
  }
120
120
 
121
121
  export async function getTableIncludes(collectionsPath: string): Promise<string[]> {
122
+ return getTableIncludesFromCollections(await loadCollectionsForCli(collectionsPath));
123
+ }
124
+
125
+ /**
126
+ * Load a project's collections the way the Atlas-facing commands need them.
127
+ *
128
+ * Deliberately forgiving — a file that fails to import is skipped rather than
129
+ * fatal — because the callers use this to *narrow* what Atlas may touch, and a
130
+ * hard failure here would block a push over an unrelated broken file. Callers
131
+ * that cannot tolerate a partial answer (the table excludes, which fail closed)
132
+ * check the result themselves.
133
+ */
134
+ export async function loadCollectionsForCli(collectionsPath: string): Promise<CollectionConfig[]> {
122
135
  // Note: a relative --collections path resolves against the *current working
123
136
  // directory*, not the backend package. When invoked via the generated npm
124
137
  // script (cwd = backend/), the script uses "../config/collections" to
@@ -127,7 +140,7 @@ export async function getTableIncludes(collectionsPath: string): Promise<string[
127
140
  const resolvedPath = path.resolve(collectionsPath);
128
141
  const collections: CollectionConfig[] = [];
129
142
  if (!fs.existsSync(resolvedPath)) {
130
- logger.warn(chalk.yellow(
143
+ outWarn(chalk.yellow(
131
144
  ` ⚠ Collections path not found: "${collectionsPath}"\n` +
132
145
  ` Resolved to: ${resolvedPath}\n` +
133
146
  ` (relative to cwd: ${process.cwd()})\n` +
@@ -158,7 +171,7 @@ export async function getTableIncludes(collectionsPath: string): Promise<string[
158
171
  }
159
172
  }
160
173
 
161
- return getTableIncludesFromCollections(collections);
174
+ return collections;
162
175
  }
163
176
 
164
177
  export function getDevDatabaseUrl(databaseUrl: string): string {
@@ -185,7 +198,7 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
185
198
  const res = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [devDbName]);
186
199
  if (res.rowCount === 0) {
187
200
  await client.query(`CREATE DATABASE "${devDbName}"`);
188
- logger.info(chalk.gray(` ✓ Created validation database "${devDbName}"`));
201
+ out(chalk.gray(` ✓ Created validation database "${devDbName}"`));
189
202
  }
190
203
  } finally {
191
204
  await client.end();
@@ -195,6 +208,99 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
195
208
  }
196
209
  }
197
210
 
211
+ /**
212
+ * The generated SQL for the project's `search` blocks, if it has any.
213
+ *
214
+ * @param drizzleDir directory holding the generated SQL. Defaults to `drizzle`
215
+ * under the working directory.
216
+ */
217
+ export function readSearchDdl(drizzleDir: string = path.resolve(process.cwd(), "drizzle")): string {
218
+ const searchFile = path.join(drizzleDir, "search.sql");
219
+ if (!fs.existsSync(searchFile)) return "";
220
+ return fs.readFileSync(searchFile, "utf-8").trim();
221
+ }
222
+
223
+ /**
224
+ * Bring the search column, its index and their helpers up to date.
225
+ *
226
+ * Runs *after* Atlas, not before: the statements are `ALTER TABLE ... ADD
227
+ * COLUMN`, so the table has to exist. Atlas is told to ignore these objects
228
+ * entirely (`getSearchExcludes`) — it cannot manage them, and left to itself it
229
+ * would plan a `DROP COLUMN` for every one, since they are absent from the
230
+ * desired state it was given.
231
+ *
232
+ * A no-op when no collection declared `search`. A failure is *not* swallowed:
233
+ * silently pushing a schema whose search column never appeared is how a
234
+ * collection ends up with search configured, no error anywhere, and no results.
235
+ */
236
+ export async function applySearchDdl(
237
+ databaseUrl: string,
238
+ drizzleDir: string = path.resolve(process.cwd(), "drizzle")
239
+ ): Promise<void> {
240
+ const sql = readSearchDdl(drizzleDir);
241
+ if (!sql) return;
242
+
243
+ const { Client } = await import("pg");
244
+ const client = new Client({ connectionString: databaseUrl });
245
+ await client.connect();
246
+ try {
247
+ await client.query(sql);
248
+ } finally {
249
+ await client.end();
250
+ }
251
+ out(chalk.gray(" ✓ Applied full-text search columns and indexes"));
252
+ }
253
+
254
+ /**
255
+ * Glob patterns keeping Atlas away from the search objects.
256
+ *
257
+ * Returns an empty list — and so changes nothing — for a project with no
258
+ * `search` block, which is every project that has not opted in.
259
+ */
260
+ export async function getSearchExcludes(collectionsPath: string): Promise<string[]> {
261
+ const { searchExcludePatterns } = await import("./schema/generate-postgres-ddl-logic");
262
+ return searchExcludePatterns(await loadCollectionsForCli(collectionsPath));
263
+ }
264
+
265
+ /**
266
+ * Give the dev database the search helper functions before Atlas plans.
267
+ *
268
+ * Excluding the search column keeps Atlas from *diffing* it, but not from
269
+ * materialising the inspected schema — column and all — in the dev database to
270
+ * analyse the plan against. That replay is where a push against an
271
+ * already-searchable database died with `function public.rebase_search_text
272
+ * (jsonb) does not exist`: the column came across, the function it calls did
273
+ * not, because Atlas will not carry a function at all.
274
+ *
275
+ * Only the extensions and functions, never the tables: the dev database holds
276
+ * whatever Atlas puts there and nothing of ours.
277
+ *
278
+ * Best-effort by design. Failing here would block a push for a project whose
279
+ * collections merely failed to import, and if the functions really are needed
280
+ * and really are missing, Atlas says so a moment later in its own words.
281
+ */
282
+ export async function seedDevDatabaseSearchHelpers(
283
+ devDatabaseUrl: string,
284
+ collectionsPath: string
285
+ ): Promise<void> {
286
+ try {
287
+ const { searchPrerequisiteStatements } = await import("./schema/generate-postgres-ddl-logic");
288
+ const statements = searchPrerequisiteStatements(await loadCollectionsForCli(collectionsPath));
289
+ if (statements.length === 0) return;
290
+
291
+ const { Client } = await import("pg");
292
+ const client = new Client({ connectionString: devDatabaseUrl });
293
+ await client.connect();
294
+ try {
295
+ await client.query(statements.join("\n"));
296
+ } finally {
297
+ await client.end();
298
+ }
299
+ } catch {
300
+ // See above: not worth failing a push over.
301
+ }
302
+ }
303
+
198
304
  /**
199
305
  * Query the live database for every user table/view outside the system
200
306
  * catalogs. Separated from {@link getTableExcludes} so its failure mode can
@@ -255,13 +361,20 @@ export async function getTableExcludes(
255
361
  const queryTables = deps.queryExistingTables ?? queryExistingTables;
256
362
 
257
363
  const includes = await getIncludes(collectionsPath);
258
- // Framework-owned schemas Rebase manages itself the declarative apply
259
- // must never touch them. `auth` holds the helper functions; `rebase` holds
260
- // the auth tables (users, refresh_tokens, …) and Atlas's own revision
261
- // table. Excluding both the schema object AND its contents keeps Atlas
262
- // from planning a `DROP SCHEMA CASCADE` — which on a live database would
263
- // take the user/auth tables with it. `atlas_schema_revisions.*` is kept
264
- // for backwards-compatibility with any external revision schema.
364
+ // Schemas the declarative apply must never touch. `rebase` holds the auth
365
+ // tables (users, refresh_tokens, …), the RLS helper functions and Atlas's
366
+ // own revision table. Excluding both the schema object AND its contents
367
+ // keeps Atlas from planning a `DROP SCHEMA CASCADE` which on a live
368
+ // database would take the user/auth tables with it.
369
+ // `atlas_schema_revisions.*` is kept for backwards-compatibility with any
370
+ // external revision schema.
371
+ //
372
+ // `auth` stays on this list even though Rebase no longer creates it, and
373
+ // that is the point: on a database still holding the pre-1.0 schema it must
374
+ // survive until the guarded cleanup retires it, and on a Supabase database
375
+ // it is somebody else's and must never be in scope at all. Removing it here
376
+ // would let a `db push` plan `DROP SCHEMA auth CASCADE` against a live
377
+ // Supabase installation.
265
378
  const excludes: string[] = ["atlas_schema_revisions.*", "auth", "auth.*", "rebase", "rebase.*"];
266
379
 
267
380
  let existingTables: string[];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Terminal output for the `rebase db|schema|doctor` commands.
3
+ *
4
+ * These commands used to write every line through `logger`, and that is a
5
+ * category error with three separate consequences:
6
+ *
7
+ * - `logger` prefixes each line with its own level, so a box-drawn report
8
+ * arrived as `ℹ️ [INFO] ┌─ ✗ Missing Column ───` and the frame no longer
9
+ * lined up with anything;
10
+ * - `logger` is gated by `LOG_LEVEL`, which ships in the scaffold's own
11
+ * `.env.example` — a developer who quietened their dev server with
12
+ * `LOG_LEVEL=warn` got a `rebase db push` that printed almost nothing and
13
+ * still exited non-zero, indistinguishable from a crash;
14
+ * - under `NODE_ENV=production` `logger` emits JSON, so the whole report
15
+ * became log records with the chalk escape codes embedded in them.
16
+ *
17
+ * A CLI's report *is* its return value. It goes to the terminal unconditionally
18
+ * and unadorned. `packages/cli` has always written its output this way; this is
19
+ * the same three functions for the plugin CLI that `rebase` delegates to.
20
+ *
21
+ * `logger` still belongs in this package's *runtime* — a request handler has no
22
+ * terminal and its lines want levels, timestamps and redaction. The rule is the
23
+ * caller, not the severity: anything a developer reads because they typed a
24
+ * command goes here, anything a server emits while running goes to `logger`.
25
+ *
26
+ * Errors and warnings go to stderr so `rebase db push > plan.txt` keeps the
27
+ * diagnosis on the terminal where it is readable.
28
+ */
29
+
30
+ /** One line of human-facing output on stdout. */
31
+ export const out = (line = ""): void => {
32
+ console.log(line);
33
+ };
34
+
35
+ /** One line of human-facing warning output on stderr. */
36
+ export const outWarn = (line = ""): void => {
37
+ console.warn(line);
38
+ };
39
+
40
+ /** One line of human-facing error output on stderr. */
41
+ export const outError = (line = ""): void => {
42
+ console.error(line);
43
+ };