@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -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-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1815 -1023
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- 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/auth-users-columns.d.ts +97 -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 +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- 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/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- 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 +5 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +164 -23
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- 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 +132 -13
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +371 -161
- package/src/collections/buildRegistry.ts +3 -1
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +142 -28
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +156 -77
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +152 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +171 -0
- 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/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +253 -115
- package/src/services/PersistService.ts +82 -43
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +5 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +382 -118
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
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
|
|
|
@@ -86,10 +86,16 @@ export function resolveLocalBin(binName: string): string | null {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
export async function getTableIncludesFromCollections(
|
|
90
|
-
const { getTableName, resolveCollectionRelations } = await import("@rebasepro/common");
|
|
89
|
+
export async function getTableIncludesFromCollections(allCollections: CollectionConfig[]): Promise<string[]> {
|
|
90
|
+
const { getTableName, resolveCollectionRelations, relationalCollections } = await import("@rebasepro/common");
|
|
91
91
|
const { isPostgresCollectionConfig } = await import("@rebasepro/types");
|
|
92
92
|
|
|
93
|
+
// The include list is the inverse of {@link getTableExcludes}: a name on it
|
|
94
|
+
// is a name Atlas is allowed to drop. A Firestore collection called
|
|
95
|
+
// `exercises` must not put `public.exercises` here — a real, unrelated table
|
|
96
|
+
// of that name would lose its protection and be dropped by the next
|
|
97
|
+
// auto-approved `db push`.
|
|
98
|
+
const collections = relationalCollections(allCollections);
|
|
93
99
|
const includes: string[] = [];
|
|
94
100
|
for (const col of collections) {
|
|
95
101
|
const tableName = getTableName(col);
|
|
@@ -113,6 +119,19 @@ export async function getTableIncludesFromCollections(collections: CollectionCon
|
|
|
113
119
|
}
|
|
114
120
|
|
|
115
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[]> {
|
|
116
135
|
// Note: a relative --collections path resolves against the *current working
|
|
117
136
|
// directory*, not the backend package. When invoked via the generated npm
|
|
118
137
|
// script (cwd = backend/), the script uses "../config/collections" to
|
|
@@ -121,7 +140,7 @@ export async function getTableIncludes(collectionsPath: string): Promise<string[
|
|
|
121
140
|
const resolvedPath = path.resolve(collectionsPath);
|
|
122
141
|
const collections: CollectionConfig[] = [];
|
|
123
142
|
if (!fs.existsSync(resolvedPath)) {
|
|
124
|
-
|
|
143
|
+
outWarn(chalk.yellow(
|
|
125
144
|
` ⚠ Collections path not found: "${collectionsPath}"\n` +
|
|
126
145
|
` Resolved to: ${resolvedPath}\n` +
|
|
127
146
|
` (relative to cwd: ${process.cwd()})\n` +
|
|
@@ -152,7 +171,7 @@ export async function getTableIncludes(collectionsPath: string): Promise<string[
|
|
|
152
171
|
}
|
|
153
172
|
}
|
|
154
173
|
|
|
155
|
-
return
|
|
174
|
+
return collections;
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
export function getDevDatabaseUrl(databaseUrl: string): string {
|
|
@@ -179,7 +198,7 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
|
|
|
179
198
|
const res = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [devDbName]);
|
|
180
199
|
if (res.rowCount === 0) {
|
|
181
200
|
await client.query(`CREATE DATABASE "${devDbName}"`);
|
|
182
|
-
|
|
201
|
+
out(chalk.gray(` ✓ Created validation database "${devDbName}"`));
|
|
183
202
|
}
|
|
184
203
|
} finally {
|
|
185
204
|
await client.end();
|
|
@@ -189,6 +208,99 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
|
|
|
189
208
|
}
|
|
190
209
|
}
|
|
191
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
|
+
|
|
192
304
|
/**
|
|
193
305
|
* Query the live database for every user table/view outside the system
|
|
194
306
|
* catalogs. Separated from {@link getTableExcludes} so its failure mode can
|
|
@@ -249,13 +361,20 @@ export async function getTableExcludes(
|
|
|
249
361
|
const queryTables = deps.queryExistingTables ?? queryExistingTables;
|
|
250
362
|
|
|
251
363
|
const includes = await getIncludes(collectionsPath);
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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.
|
|
259
378
|
const excludes: string[] = ["atlas_schema_revisions.*", "auth", "auth.*", "rebase", "rebase.*"];
|
|
260
379
|
|
|
261
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
|
+
};
|