@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.
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1310 -1060
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/search-column.d.ts +49 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +9 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +137 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +54 -1
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +4 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +155 -147
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +111 -25
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +214 -17
- package/src/schema/generate-drizzle-schema-logic.ts +121 -65
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +28 -1
- package/src/schema/generate-postgres-ddl.ts +14 -13
- package/src/schema/generated-schema-staleness.ts +7 -5
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/search-column.ts +85 -0
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +105 -7
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +35 -695
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +2 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +337 -82
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +211 -34
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
- package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
- package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
- package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- package/dist/websocket-D0TBU3ia.js.map +0 -1
package/src/auth/services.ts
CHANGED
|
@@ -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
|
-
|
|
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`
|
package/src/backup/backup-cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
131
|
+
outError(chalk.red(` ✗ ${pf.reason}`));
|
|
130
132
|
process.exit(1);
|
|
131
133
|
}
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
+
print("");
|
|
170
|
+
print(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
|
|
169
171
|
if (dump.globalsFile) {
|
|
170
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
+
print(chalk.gray(` ✓ Roles uploaded to ${globalsUpload.storageUrl} (needed so RLS survives a restore).`));
|
|
192
194
|
}
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
318
|
+
(m) => print(chalk.gray(m))
|
|
317
319
|
);
|
|
318
|
-
|
|
320
|
+
print(chalk.gray(` ✓ Roles: ${applied} applied, ${skipped} skipped (already present or not permitted).`));
|
|
319
321
|
} else {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
+
print("");
|
|
370
372
|
if (backups.length === 0) {
|
|
371
|
-
|
|
373
|
+
print(chalk.gray(` No backups found at ${out}.`));
|
|
372
374
|
} else {
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
380
|
+
print(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}`);
|
|
379
381
|
}
|
|
380
382
|
}
|
|
381
|
-
|
|
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
|
-
|
|
402
|
-
if (err.hint)
|
|
403
|
+
outError(chalk.red(` ✗ ${err.message}`));
|
|
404
|
+
if (err.hint) outError(chalk.gray(` ${err.hint}`));
|
|
403
405
|
} else {
|
|
404
|
-
|
|
406
|
+
outError(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
405
407
|
}
|
|
406
408
|
}
|
|
407
409
|
|
|
408
410
|
function printBackupHelp(): void {
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
223
|
+
outError(formatConnectionRefusedBanner(databaseUrl));
|
|
224
224
|
process.exit(1);
|
|
225
225
|
}
|
|
226
226
|
if (isAuthFailure(err)) {
|
|
227
|
-
|
|
227
|
+
outError(formatAuthFailureBanner(databaseUrl));
|
|
228
228
|
process.exit(1);
|
|
229
229
|
}
|
|
230
230
|
if (isSslNotEnabled(err)) {
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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();
|
package/src/cli-helpers.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|