@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 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 (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -25,6 +25,7 @@ import {
25
25
  RoleData as Role
26
26
  } from "@rebasepro/server";
27
27
  import { toSnakeCase, camelCase } from "@rebasepro/utils";
28
+ import { escapeLikePattern } from "../utils/drizzle-conditions";
28
29
 
29
30
  export type { Role };
30
31
 
@@ -368,7 +369,11 @@ export class UserService implements UserRepository {
368
369
  conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
369
370
  }
370
371
  if (search) {
371
- const pattern = `%${search}%`;
372
+ // `search` is a substring search over the admin user list, not a
373
+ // pattern the caller writes: the same reasoning as the collection
374
+ // search path, so it shares that path's helper rather than growing
375
+ // a second copy that can drift. See `escapeLikePattern`.
376
+ const pattern = `%${escapeLikePattern(search)}%`;
372
377
  conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
373
378
  }
374
379
 
@@ -591,7 +596,7 @@ export class RefreshTokenService {
591
596
  userAgent: this.refreshTokensTable.userAgent,
592
597
  ipAddress: this.refreshTokensTable.ipAddress
593
598
  } as unknown as Record<string, never>;
594
- for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt"]) {
599
+ for (const optional of ["sessionId", "rotatedAt", "revoked", "sessionStartedAt", "aal"]) {
595
600
  if (this.has(optional)) selection[optional] = this.col(optional);
596
601
  }
597
602
  return selection;
@@ -624,6 +629,11 @@ export class RefreshTokenService {
624
629
  };
625
630
  if (session && this.has("sessionId")) values.sessionId = session.id;
626
631
  if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
632
+ // Written on every token of the session, including the ones rotation
633
+ // mints, because refresh reads the level off whichever row was
634
+ // presented. A table without the column degrades to `aal1` on read,
635
+ // which is the restrictive answer rather than a bypass.
636
+ if (session?.aal && this.has("aal")) values.aal = session.aal;
627
637
 
628
638
  await this.db.insert(this.refreshTokensTable).values(values);
629
639
  }
@@ -1262,6 +1272,10 @@ collectionPermissions: null }
1262
1272
  return this.getMfaService().verifyMfaFactor(factorId);
1263
1273
  }
1264
1274
 
1275
+ async updateMfaFactorSecret(factorId: string, secretEncrypted: string): Promise<void> {
1276
+ return this.getMfaService().updateMfaFactorSecret(factorId, secretEncrypted);
1277
+ }
1278
+
1265
1279
  async deleteMfaFactor(factorId: string, uid: string): Promise<void> {
1266
1280
  return this.getMfaService().deleteMfaFactor(factorId, uid);
1267
1281
  }
@@ -1297,6 +1311,14 @@ collectionPermissions: null }
1297
1311
  async hasVerifiedMfaFactors(uid: string): Promise<boolean> {
1298
1312
  return this.getMfaService().hasVerifiedMfaFactors(uid);
1299
1313
  }
1314
+
1315
+ async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
1316
+ return this.getMfaService().claimMfaFactorCounter(factorId, counter);
1317
+ }
1318
+
1319
+ async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
1320
+ return this.getMfaService().recordMfaChallengeAttempt(challengeId);
1321
+ }
1300
1322
  }
1301
1323
 
1302
1324
  // =============================================================================
@@ -1362,7 +1384,7 @@ export class MfaService implements MfaRepository {
1362
1384
  async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
1363
1385
  const tableName = this.qualify("mfa_factors");
1364
1386
  const result = await this.db.execute(sql`
1365
- SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
1387
+ SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
1366
1388
  FROM ${sql.raw(tableName)}
1367
1389
  WHERE id = ${factorId}
1368
1390
  `);
@@ -1377,11 +1399,36 @@ export class MfaService implements MfaRepository {
1377
1399
  secretEncrypted: row.secret_encrypted as string,
1378
1400
  friendlyName: (row.friendly_name as string | null) ?? undefined,
1379
1401
  verified: row.verified as boolean,
1402
+ // BIGINT comes back as a string from node-postgres.
1403
+ lastUsedCounter: row.last_used_counter === null || row.last_used_counter === undefined
1404
+ ? null
1405
+ : Number(row.last_used_counter),
1380
1406
  createdAt: new Date(row.created_at as string),
1381
1407
  updatedAt: new Date(row.updated_at as string)
1382
1408
  };
1383
1409
  }
1384
1410
 
1411
+ /**
1412
+ * Spend a TOTP time step, once and only once.
1413
+ *
1414
+ * One statement: the `WHERE` is the check, the `UPDATE` is the act, and
1415
+ * `RETURNING` reports which of two concurrent requests carrying the same
1416
+ * six digits won. Reading the counter and then writing it would let both
1417
+ * pass — the exact replay this closes.
1418
+ */
1419
+ async claimMfaFactorCounter(factorId: string, counter: number): Promise<boolean> {
1420
+ const tableName = this.qualify("mfa_factors");
1421
+ const result = await this.db.execute(sql`
1422
+ UPDATE ${sql.raw(tableName)}
1423
+ SET last_used_counter = ${counter}, updated_at = NOW()
1424
+ WHERE id = ${factorId}
1425
+ AND (last_used_counter IS NULL OR last_used_counter < ${counter})
1426
+ RETURNING id
1427
+ `);
1428
+
1429
+ return result.rows.length > 0;
1430
+ }
1431
+
1385
1432
  async verifyMfaFactor(factorId: string): Promise<void> {
1386
1433
  const tableName = this.qualify("mfa_factors");
1387
1434
  await this.db.execute(sql`
@@ -1391,6 +1438,15 @@ export class MfaService implements MfaRepository {
1391
1438
  `);
1392
1439
  }
1393
1440
 
1441
+ async updateMfaFactorSecret(factorId: string, secretEncrypted: string): Promise<void> {
1442
+ const tableName = this.qualify("mfa_factors");
1443
+ await this.db.execute(sql`
1444
+ UPDATE ${sql.raw(tableName)}
1445
+ SET secret_encrypted = ${secretEncrypted}, updated_at = NOW()
1446
+ WHERE id = ${factorId}
1447
+ `);
1448
+ }
1449
+
1394
1450
  async deleteMfaFactor(factorId: string, uid: string): Promise<void> {
1395
1451
  const tableName = this.qualify("mfa_factors");
1396
1452
  await this.db.execute(sql`
@@ -1422,7 +1478,7 @@ export class MfaService implements MfaRepository {
1422
1478
  async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
1423
1479
  const tableName = this.qualify("mfa_challenges");
1424
1480
  const result = await this.db.execute(sql`
1425
- SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
1481
+ SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
1426
1482
  FROM ${sql.raw(tableName)}
1427
1483
  WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
1428
1484
  `);
@@ -1435,10 +1491,31 @@ export class MfaService implements MfaRepository {
1435
1491
  factorId: row.factor_id as string,
1436
1492
  createdAt: new Date(row.created_at as string),
1437
1493
  verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
1438
- ipAddress: (row.ip_address as string | null) ?? undefined
1494
+ ipAddress: (row.ip_address as string | null) ?? undefined,
1495
+ attempts: Number(row.attempts ?? 0)
1439
1496
  };
1440
1497
  }
1441
1498
 
1499
+ /**
1500
+ * Count one failed guess against a challenge and report the new total.
1501
+ *
1502
+ * Incremented in the database rather than in the route so that guesses
1503
+ * arriving in parallel — the shape any real brute-force takes — cannot
1504
+ * share a single increment.
1505
+ */
1506
+ async recordMfaChallengeAttempt(challengeId: string): Promise<number> {
1507
+ const tableName = this.qualify("mfa_challenges");
1508
+ const result = await this.db.execute(sql`
1509
+ UPDATE ${sql.raw(tableName)}
1510
+ SET attempts = attempts + 1
1511
+ WHERE id = ${challengeId}
1512
+ RETURNING attempts
1513
+ `);
1514
+
1515
+ if (result.rows.length === 0) return 0;
1516
+ return Number((result.rows[0] as { attempts: number | string }).attempts);
1517
+ }
1518
+
1442
1519
  async verifyMfaChallenge(challengeId: string): Promise<void> {
1443
1520
  const tableName = this.qualify("mfa_challenges");
1444
1521
  await this.db.execute(sql`
@@ -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
 
@@ -140,7 +140,7 @@ export async function loadCollectionsForCli(collectionsPath: string): Promise<Co
140
140
  const resolvedPath = path.resolve(collectionsPath);
141
141
  const collections: CollectionConfig[] = [];
142
142
  if (!fs.existsSync(resolvedPath)) {
143
- logger.warn(chalk.yellow(
143
+ outWarn(chalk.yellow(
144
144
  ` ⚠ Collections path not found: "${collectionsPath}"\n` +
145
145
  ` Resolved to: ${resolvedPath}\n` +
146
146
  ` (relative to cwd: ${process.cwd()})\n` +
@@ -198,7 +198,7 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
198
198
  const res = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [devDbName]);
199
199
  if (res.rowCount === 0) {
200
200
  await client.query(`CREATE DATABASE "${devDbName}"`);
201
- logger.info(chalk.gray(` ✓ Created validation database "${devDbName}"`));
201
+ out(chalk.gray(` ✓ Created validation database "${devDbName}"`));
202
202
  }
203
203
  } finally {
204
204
  await client.end();
@@ -248,7 +248,7 @@ export async function applySearchDdl(
248
248
  } finally {
249
249
  await client.end();
250
250
  }
251
- logger.info(chalk.gray(" ✓ Applied full-text search columns and indexes"));
251
+ out(chalk.gray(" ✓ Applied full-text search columns and indexes"));
252
252
  }
253
253
 
254
254
  /**
@@ -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
+ };