@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.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/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
package/src/backup/backup-cli.ts
DELETED
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI handlers for `rebase db backup`, `rebase db restore`, and
|
|
3
|
-
* `rebase db backups list`. Kept out of the main `cli.ts` dispatcher so the
|
|
4
|
-
* backup surface stays self-contained.
|
|
5
|
-
*/
|
|
6
|
-
import arg from "arg";
|
|
7
|
-
import path from "path";
|
|
8
|
-
import fs from "fs";
|
|
9
|
-
import os from "os";
|
|
10
|
-
import readline from "readline";
|
|
11
|
-
import chalk from "chalk";
|
|
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";
|
|
15
|
-
import type { StorageController } from "@rebasepro/server";
|
|
16
|
-
import {
|
|
17
|
-
BackupDestination,
|
|
18
|
-
globalsFileForDump,
|
|
19
|
-
parseBackupDestination,
|
|
20
|
-
parseDbNameFromUrl,
|
|
21
|
-
resolveConnectionString,
|
|
22
|
-
withDatabaseName
|
|
23
|
-
} from "./pg-tools";
|
|
24
|
-
import {
|
|
25
|
-
applyGlobals,
|
|
26
|
-
BackupToolError,
|
|
27
|
-
createDump,
|
|
28
|
-
discardPartialDump,
|
|
29
|
-
ensureDatabaseExists,
|
|
30
|
-
listBackups,
|
|
31
|
-
preflight,
|
|
32
|
-
restoreDump,
|
|
33
|
-
uploadBackup,
|
|
34
|
-
validateDump
|
|
35
|
-
} from "./backup-service";
|
|
36
|
-
|
|
37
|
-
function formatBytes(bytes: number): string {
|
|
38
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
39
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
40
|
-
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
41
|
-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Build a StorageController for an object-storage destination from the same
|
|
46
|
-
* `S3_*` env vars the backend uses. Returns `null` for local destinations.
|
|
47
|
-
*/
|
|
48
|
-
async function resolveStorageForDestination(
|
|
49
|
-
dest: BackupDestination,
|
|
50
|
-
env: Record<string, string | undefined>
|
|
51
|
-
): Promise<StorageController | null> {
|
|
52
|
-
if (dest.kind === "local") return null;
|
|
53
|
-
if (dest.kind === "gcs") {
|
|
54
|
-
const { GCSStorageController } = await import("@rebasepro/server");
|
|
55
|
-
return new GCSStorageController({ type: "gcs", bucket: dest.bucket });
|
|
56
|
-
}
|
|
57
|
-
// s3 (also covers R2/MinIO/Hetzner/GCS-interop via S3_ENDPOINT)
|
|
58
|
-
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) {
|
|
59
|
-
throw new BackupToolError(
|
|
60
|
-
"S3 destination requires S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment.",
|
|
61
|
-
"Set the same S3_* variables your backend uses for storage."
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
const { S3StorageController } = await import("@rebasepro/server");
|
|
65
|
-
return new S3StorageController({
|
|
66
|
-
type: "s3",
|
|
67
|
-
bucket: dest.bucket,
|
|
68
|
-
region: env.S3_REGION || "auto",
|
|
69
|
-
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
70
|
-
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
71
|
-
endpoint: env.S3_ENDPOINT,
|
|
72
|
-
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true"
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function requireConnection(): string {
|
|
77
|
-
const conn = resolveConnectionString(process.env);
|
|
78
|
-
if (!conn) {
|
|
79
|
-
outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
80
|
-
process.exit(1);
|
|
81
|
-
}
|
|
82
|
-
return conn;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function promptConfirm(question: string): Promise<boolean> {
|
|
86
|
-
// Non-interactive shells (CI, pipes) can't answer — treat as "no".
|
|
87
|
-
if (!process.stdin.isTTY) return false;
|
|
88
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
89
|
-
try {
|
|
90
|
-
const answer: string = await new Promise((resolve) => rl.question(question, resolve));
|
|
91
|
-
return /^y(es)?$/i.test(answer.trim());
|
|
92
|
-
} finally {
|
|
93
|
-
rl.close();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
98
|
-
// rebase db backup
|
|
99
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
100
|
-
export async function backupCommand(rawArgs: string[]): Promise<void> {
|
|
101
|
-
const args = arg(
|
|
102
|
-
{
|
|
103
|
-
"--out": String,
|
|
104
|
-
"--exclude-schema": [String],
|
|
105
|
-
"--no-owner": Boolean,
|
|
106
|
-
"--enable-row-security": Boolean,
|
|
107
|
-
"--row-security-role": String,
|
|
108
|
-
"-o": "--out"
|
|
109
|
-
},
|
|
110
|
-
{ argv: rawArgs.slice(2), permissive: true }
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
if (args._.includes("--help") || rawArgs.includes("--help")) {
|
|
114
|
-
printBackupHelp();
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const connectionString = requireConnection();
|
|
119
|
-
const dbName = parseDbNameFromUrl(connectionString) ?? "database";
|
|
120
|
-
const out = args["--out"] || process.env.BACKUP_DESTINATION || path.join(process.cwd(), "backups");
|
|
121
|
-
const dest = parseBackupDestination(out);
|
|
122
|
-
|
|
123
|
-
print("");
|
|
124
|
-
print(chalk.bold(" 💾 Rebase DB Backup"));
|
|
125
|
-
print(chalk.gray(` Database: ${dbName}`));
|
|
126
|
-
print(chalk.gray(` Destination: ${out}`));
|
|
127
|
-
print("");
|
|
128
|
-
|
|
129
|
-
// Version pre-flight (doctor-style).
|
|
130
|
-
const pf = await preflight("pg_dump", connectionString);
|
|
131
|
-
if (!pf.compatible) {
|
|
132
|
-
outError(chalk.red(` ✗ ${pf.reason}`));
|
|
133
|
-
process.exit(1);
|
|
134
|
-
}
|
|
135
|
-
print(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
|
|
136
|
-
|
|
137
|
-
// Opt-in, and loud. With row security on, pg_dump stops refusing to read
|
|
138
|
-
// rows it cannot see and starts leaving them out — an exit-0 backup that
|
|
139
|
-
// is quietly short. Anyone choosing that should know they chose it.
|
|
140
|
-
const rowSecurity = args["--enable-row-security"]
|
|
141
|
-
? { uid: "rebase-db-backup", roles: [args["--row-security-role"] || "admin"] }
|
|
142
|
-
: undefined;
|
|
143
|
-
|
|
144
|
-
if (rowSecurity) {
|
|
145
|
-
outWarn("");
|
|
146
|
-
outWarn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
|
|
147
|
-
outWarn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
|
|
148
|
-
outWarn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
|
|
149
|
-
outWarn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
|
|
150
|
-
outWarn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
|
|
151
|
-
outWarn("");
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
if (dest.kind === "local") {
|
|
156
|
-
// Honour an explicit `…/name.dump` path; otherwise treat it as a
|
|
157
|
-
// directory and auto-name the file.
|
|
158
|
-
const explicitFile = dest.path.endsWith(".dump");
|
|
159
|
-
const dump = await createDump({
|
|
160
|
-
connectionString,
|
|
161
|
-
dbName,
|
|
162
|
-
outDir: explicitFile ? path.dirname(dest.path) : dest.path,
|
|
163
|
-
fileName: explicitFile ? path.basename(dest.path) : undefined,
|
|
164
|
-
excludeSchemas: args["--exclude-schema"],
|
|
165
|
-
noOwner: args["--no-owner"],
|
|
166
|
-
inheritStdio: true,
|
|
167
|
-
rowSecurity
|
|
168
|
-
});
|
|
169
|
-
await assertDumpValid(dump.localFile);
|
|
170
|
-
print("");
|
|
171
|
-
print(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
|
|
172
|
-
if (dump.globalsFile) {
|
|
173
|
-
print(chalk.gray(` ✓ Roles captured to ${dump.globalsFile} (needed so RLS survives a restore).`));
|
|
174
|
-
}
|
|
175
|
-
} else {
|
|
176
|
-
const storage = await resolveStorageForDestination(dest, process.env);
|
|
177
|
-
const dump = await createDump({
|
|
178
|
-
connectionString,
|
|
179
|
-
dbName,
|
|
180
|
-
excludeSchemas: args["--exclude-schema"],
|
|
181
|
-
noOwner: args["--no-owner"],
|
|
182
|
-
inheritStdio: true,
|
|
183
|
-
rowSecurity
|
|
184
|
-
});
|
|
185
|
-
try {
|
|
186
|
-
await assertDumpValid(dump.localFile);
|
|
187
|
-
const uploaded = await uploadBackup(storage!, dump.localFile, dest);
|
|
188
|
-
print("");
|
|
189
|
-
print(chalk.green(` ✓ Backup uploaded to ${uploaded.storageUrl} (${formatBytes(dump.sizeBytes)})`));
|
|
190
|
-
// Upload the roles sidecar next to the dump so a restore can
|
|
191
|
-
// recreate roles the dump's GRANT/RLS statements depend on.
|
|
192
|
-
if (dump.globalsFile && fs.existsSync(dump.globalsFile)) {
|
|
193
|
-
const globalsUpload = await uploadBackup(storage!, dump.globalsFile, dest);
|
|
194
|
-
print(chalk.gray(` ✓ Roles uploaded to ${globalsUpload.storageUrl} (needed so RLS survives a restore).`));
|
|
195
|
-
}
|
|
196
|
-
print(chalk.gray(" Ensure this bucket is private — backups may contain secrets and PII."));
|
|
197
|
-
} finally {
|
|
198
|
-
if (fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
|
|
199
|
-
if (dump.globalsFile && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
print("");
|
|
203
|
-
} catch (err) {
|
|
204
|
-
reportError(err);
|
|
205
|
-
process.exit(1);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
210
|
-
// rebase db restore <backup>
|
|
211
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
212
|
-
export async function restoreCommand(rawArgs: string[]): Promise<void> {
|
|
213
|
-
const args = arg(
|
|
214
|
-
{
|
|
215
|
-
"--target-db": String,
|
|
216
|
-
"--create-db": Boolean,
|
|
217
|
-
"--clean": Boolean,
|
|
218
|
-
"--no-owner": Boolean,
|
|
219
|
-
"--continue-on-error": Boolean,
|
|
220
|
-
"--yes": Boolean,
|
|
221
|
-
"-y": "--yes"
|
|
222
|
-
},
|
|
223
|
-
{ argv: rawArgs.slice(2), permissive: true }
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
const backupArg = args._[0];
|
|
227
|
-
if (!backupArg || rawArgs.includes("--help")) {
|
|
228
|
-
printRestoreHelp();
|
|
229
|
-
if (!backupArg && !rawArgs.includes("--help")) process.exit(1);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const baseConnection = requireConnection();
|
|
234
|
-
|
|
235
|
-
// Choose the target connection: an explicit --target-db (or --create-db's
|
|
236
|
-
// implied fresh db) swaps the database name so the live one isn't clobbered.
|
|
237
|
-
const targetDb = args["--target-db"] ?? parseDbNameFromUrl(baseConnection) ?? undefined;
|
|
238
|
-
const targetConnection = args["--target-db"]
|
|
239
|
-
? withDatabaseName(baseConnection, args["--target-db"])
|
|
240
|
-
: baseConnection;
|
|
241
|
-
|
|
242
|
-
print("");
|
|
243
|
-
print(chalk.bold(" ♻️ Rebase DB Restore"));
|
|
244
|
-
print(chalk.gray(` Source: ${backupArg}`));
|
|
245
|
-
print(chalk.gray(` Target: ${targetDb ?? "(from DATABASE_URL)"}`));
|
|
246
|
-
print("");
|
|
247
|
-
|
|
248
|
-
// Resolve the local file to restore from (download object-storage keys).
|
|
249
|
-
// Also resolve the `.globals.sql` roles sidecar so cluster roles can be
|
|
250
|
-
// recreated before the restore — without them the dump's GRANT/RLS
|
|
251
|
-
// statements fail and RLS is silently lost.
|
|
252
|
-
let localFile: string;
|
|
253
|
-
let globalsSql: string | null = null;
|
|
254
|
-
let cleanupTemp = false;
|
|
255
|
-
try {
|
|
256
|
-
if (/^(s3|gs):\/\//.test(backupArg)) {
|
|
257
|
-
const dest = parseBackupDestination(backupArg.replace(/\/[^/]+$/, ""));
|
|
258
|
-
const storage = await resolveStorageForDestination(dest, process.env);
|
|
259
|
-
if (!storage) throw new BackupToolError("Could not resolve storage for the given URL.");
|
|
260
|
-
const key = backupArg.replace(/^(s3|gs):\/\/[^/]+\//, "");
|
|
261
|
-
const bucket = backupArg.replace(/^(s3|gs):\/\/([^/]+)\/.*$/, "$2");
|
|
262
|
-
const file = await storage.getObject(key, bucket);
|
|
263
|
-
if (!file) throw new BackupToolError(`Backup not found in storage: ${backupArg}`);
|
|
264
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-restore-"));
|
|
265
|
-
localFile = path.join(tmpDir, path.basename(key));
|
|
266
|
-
fs.writeFileSync(localFile, Buffer.from(await file.arrayBuffer()));
|
|
267
|
-
cleanupTemp = true;
|
|
268
|
-
const globalsObj = await storage.getObject(globalsFileForDump(key), bucket);
|
|
269
|
-
if (globalsObj) globalsSql = Buffer.from(await globalsObj.arrayBuffer()).toString("utf-8");
|
|
270
|
-
} else {
|
|
271
|
-
localFile = path.resolve(backupArg);
|
|
272
|
-
if (!fs.existsSync(localFile)) {
|
|
273
|
-
outError(chalk.red(` ✗ Backup file not found: ${localFile}`));
|
|
274
|
-
process.exit(1);
|
|
275
|
-
}
|
|
276
|
-
const globalsPath = globalsFileForDump(localFile);
|
|
277
|
-
if (fs.existsSync(globalsPath)) globalsSql = fs.readFileSync(globalsPath, "utf-8");
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Version pre-flight. Check against the base connection — the server
|
|
281
|
-
// version is identical for every database, and the target may not
|
|
282
|
-
// exist yet when --create-db is used.
|
|
283
|
-
const pf = await preflight("pg_restore", baseConnection);
|
|
284
|
-
if (!pf.compatible) {
|
|
285
|
-
outError(chalk.red(` ✗ ${pf.reason}`));
|
|
286
|
-
process.exit(1);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// `--create-db` needs a name before anything else can be decided.
|
|
290
|
-
if (args["--create-db"] && !targetDb) {
|
|
291
|
-
outError(chalk.red(" ✗ --create-db requires a resolvable target database name (use --target-db)."));
|
|
292
|
-
process.exit(1);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Destructive-action gate. Restores overwrite data; never run without
|
|
296
|
-
// an explicit yes (interactive confirmation or --yes).
|
|
297
|
-
//
|
|
298
|
-
// Ahead of `--create-db`, not after it. Creating the database first
|
|
299
|
-
// meant an aborted run had already changed the cluster, while printing
|
|
300
|
-
// "No changes were made" — and it left an empty database behind that a
|
|
301
|
-
// second, confirmed run then reported as "already exists". The gate is
|
|
302
|
-
// now the first thing that can stop the command, so its own message is
|
|
303
|
-
// true whichever way the answer goes.
|
|
304
|
-
if (!args["--yes"]) {
|
|
305
|
-
outWarn(chalk.yellow(
|
|
306
|
-
` ⚠️ This will restore into "${targetDb ?? "the target database"}" and may overwrite existing data.`
|
|
307
|
-
));
|
|
308
|
-
const confirmed = await promptConfirm(chalk.yellow(" Type 'yes' to continue: "));
|
|
309
|
-
if (!confirmed) {
|
|
310
|
-
print(chalk.gray(" Aborted. No changes were made."));
|
|
311
|
-
process.exit(1);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Create the target database when requested.
|
|
316
|
-
if (args["--create-db"]) {
|
|
317
|
-
const created = await ensureDatabaseExists(baseConnection, targetDb!);
|
|
318
|
-
print(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Recreate cluster roles before restoring so GRANT/RLS statements in
|
|
322
|
-
// the dump apply. Best-effort and idempotent (see applyGlobals).
|
|
323
|
-
if (globalsSql) {
|
|
324
|
-
print(chalk.gray(" Recreating cluster roles from the backup's roles sidecar…"));
|
|
325
|
-
const { applied, skipped } = await applyGlobals(
|
|
326
|
-
targetConnection,
|
|
327
|
-
globalsSql,
|
|
328
|
-
(m) => print(chalk.gray(m))
|
|
329
|
-
);
|
|
330
|
-
print(chalk.gray(` ✓ Roles: ${applied} applied, ${skipped} skipped (already present or not permitted).`));
|
|
331
|
-
} else {
|
|
332
|
-
outWarn(chalk.yellow(" ⚠️ No roles sidecar (.globals.sql) accompanies this backup."));
|
|
333
|
-
outWarn(chalk.yellow(" If the dump grants to roles that don't exist (e.g. rebase_user), the restore"));
|
|
334
|
-
outWarn(chalk.yellow(" will fail — recreate those roles first, or use a backup that includes its globals."));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
await restoreDump({
|
|
338
|
-
connectionString: targetConnection,
|
|
339
|
-
inputFile: localFile,
|
|
340
|
-
clean: args["--clean"],
|
|
341
|
-
noOwner: args["--no-owner"],
|
|
342
|
-
// Fail loudly by default so a skipped GRANT never leaves RLS off.
|
|
343
|
-
exitOnError: !args["--continue-on-error"],
|
|
344
|
-
inheritStdio: true
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
print("");
|
|
348
|
-
print(chalk.green(` ✓ Restore completed into "${targetDb ?? "the target database"}".`));
|
|
349
|
-
print("");
|
|
350
|
-
} catch (err) {
|
|
351
|
-
reportError(err);
|
|
352
|
-
process.exit(1);
|
|
353
|
-
} finally {
|
|
354
|
-
if (cleanupTemp && typeof localFile! === "string" && fs.existsSync(localFile!)) {
|
|
355
|
-
fs.rmSync(path.dirname(localFile!), { recursive: true, force: true });
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
361
|
-
// rebase db backups list
|
|
362
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
363
|
-
export async function backupsCommand(rawArgs: string[]): Promise<void> {
|
|
364
|
-
const action = rawArgs[2];
|
|
365
|
-
if (!action || action === "--help") {
|
|
366
|
-
printBackupsHelp();
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
if (action !== "list") {
|
|
370
|
-
outError(chalk.red(`Unknown backups action: "${action}". Valid: list`));
|
|
371
|
-
process.exit(1);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const args = arg({ "--out": String, "-o": "--out" }, { argv: rawArgs.slice(3), permissive: true });
|
|
375
|
-
const out = args["--out"] || process.env.BACKUP_DESTINATION || path.join(process.cwd(), "backups");
|
|
376
|
-
const dest = parseBackupDestination(out);
|
|
377
|
-
|
|
378
|
-
try {
|
|
379
|
-
const storage = await resolveStorageForDestination(dest, process.env);
|
|
380
|
-
const backups = await listBackups(dest, storage ?? undefined);
|
|
381
|
-
print("");
|
|
382
|
-
if (backups.length === 0) {
|
|
383
|
-
print(chalk.gray(` No backups found at ${out}.`));
|
|
384
|
-
} else {
|
|
385
|
-
print(chalk.bold(` 💾 ${backups.length} backup(s) at ${out}:`));
|
|
386
|
-
print("");
|
|
387
|
-
for (const b of backups) {
|
|
388
|
-
const when = b.createdAt ? b.createdAt.toISOString() : "unknown date";
|
|
389
|
-
const name = dest.kind === "local" ? path.basename(b.key) : b.key;
|
|
390
|
-
// An empty file is called out rather than listed as a peer of
|
|
391
|
-
// the real ones. Older failures could leave a 0-byte dump here
|
|
392
|
-
// (fixed at the source now), and retention protects the newest
|
|
393
|
-
// by date whatever they contain — so a corpse left in place can
|
|
394
|
-
// hold a `keepMinimum` slot against a backup that matters.
|
|
395
|
-
const empty = b.sizeBytes === 0;
|
|
396
|
-
const size = b.sizeBytes === undefined ? "" : ` — ${formatBytes(b.sizeBytes)}`;
|
|
397
|
-
if (empty) {
|
|
398
|
-
print(` ${chalk.red("○")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}${chalk.red(" — EMPTY, not restorable")}`);
|
|
399
|
-
} else {
|
|
400
|
-
print(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}${size}`)}`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
if (backups.some(b => b.sizeBytes === 0)) {
|
|
404
|
-
print("");
|
|
405
|
-
print(chalk.yellow(" ⚠ Empty files above are leftovers from a failed backup. Delete them:"));
|
|
406
|
-
print(chalk.gray(" they count as recent backups for retention but restore nothing."));
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
print("");
|
|
410
|
-
} catch (err) {
|
|
411
|
-
reportError(err);
|
|
412
|
-
process.exit(1);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Verify a freshly written dump; abort the command if it looks corrupt.
|
|
418
|
-
*
|
|
419
|
-
* Discards the artifact on the way out. Refusing to *report* success was never
|
|
420
|
-
* enough on its own: the file stayed on disk, `rebase db backups list` showed
|
|
421
|
-
* it as an ordinary entry, and retention — which ranks by timestamp and never
|
|
422
|
-
* looks at size — would protect it as one of the `keepMinimum` newest while
|
|
423
|
-
* pruning a real backup underneath it. The roles sidecar goes too; the two are
|
|
424
|
-
* uploaded and pruned as a pair, so half a pair is not a backup either.
|
|
425
|
-
*/
|
|
426
|
-
async function assertDumpValid(localFile: string): Promise<void> {
|
|
427
|
-
const check = await validateDump(localFile);
|
|
428
|
-
if (!check.ok) {
|
|
429
|
-
discardPartialDump(globalsFileForDump(localFile));
|
|
430
|
-
discardPartialDump(localFile);
|
|
431
|
-
throw new BackupToolError(
|
|
432
|
-
`The backup failed validation and was discarded: ${check.reason}`,
|
|
433
|
-
"The dump was corrupt or truncated, so nothing was kept. Investigate before relying on this destination."
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function reportError(err: unknown): void {
|
|
439
|
-
if (err instanceof BackupToolError) {
|
|
440
|
-
outError(chalk.red(` ✗ ${err.message}`));
|
|
441
|
-
if (err.hint) outError(chalk.gray(` ${err.hint}`));
|
|
442
|
-
} else {
|
|
443
|
-
outError(chalk.red(` ✗ ${err instanceof Error ? err.message : String(err)}`));
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function printBackupHelp(): void {
|
|
448
|
-
print(`
|
|
449
|
-
${chalk.bold("rebase db backup")} — Create a database backup (pg_dump, custom format)
|
|
450
|
-
|
|
451
|
-
${chalk.green.bold("Usage")}
|
|
452
|
-
rebase db backup [--out <path|s3://bucket/prefix>] [options]
|
|
453
|
-
|
|
454
|
-
${chalk.green.bold("Options")}
|
|
455
|
-
${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
|
|
456
|
-
${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
|
|
457
|
-
${chalk.blue("--no-owner")} Omit ownership commands from the dump
|
|
458
|
-
${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
|
|
459
|
-
${chalk.red("(may produce a partial dump — see below)")}
|
|
460
|
-
${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
|
|
461
|
-
|
|
462
|
-
${chalk.green.bold("Row-level security")}
|
|
463
|
-
On a managed Postgres the dumping role usually owns nothing and has no
|
|
464
|
-
BYPASSRLS, so pg_dump refuses:
|
|
465
|
-
|
|
466
|
-
ERROR: query would be affected by row-level security policy for table "..."
|
|
467
|
-
|
|
468
|
-
That refusal is the safe behaviour. --enable-row-security replaces it by
|
|
469
|
-
reading as an admin subject: Rebase sets app.uid/app.user_roles so the
|
|
470
|
-
generated admin_full_access policy admits the dump. The dump then contains
|
|
471
|
-
exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
|
|
472
|
-
a table whose policies lack an admin rule comes out short, silently.
|
|
473
|
-
|
|
474
|
-
Granting the dumping role BYPASSRLS is the option that keeps a backup
|
|
475
|
-
meaning "every row".
|
|
476
|
-
|
|
477
|
-
${chalk.green.bold("Notes")}
|
|
478
|
-
Backups may contain secrets and PII. Use private storage destinations and
|
|
479
|
-
enable encryption-at-rest. See docs/backups.md.
|
|
480
|
-
`);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function printRestoreHelp(): void {
|
|
484
|
-
print(`
|
|
485
|
-
${chalk.bold("rebase db restore")} — Restore a database from a backup (pg_restore)
|
|
486
|
-
|
|
487
|
-
${chalk.green.bold("Usage")}
|
|
488
|
-
rebase db restore <backup> [options]
|
|
489
|
-
|
|
490
|
-
${chalk.green.bold("Arguments")}
|
|
491
|
-
<backup> Local .dump file, or s3://…/gs://… object key
|
|
492
|
-
|
|
493
|
-
${chalk.green.bold("Options")}
|
|
494
|
-
${chalk.blue("--target-db")} <name> Restore into this database instead of DATABASE_URL's
|
|
495
|
-
${chalk.blue("--create-db")} Create the target database first if it doesn't exist
|
|
496
|
-
${chalk.blue("--clean")} Drop existing objects before recreating them
|
|
497
|
-
${chalk.blue("--no-owner")} Ignore ownership from the dump
|
|
498
|
-
${chalk.blue("--continue-on-error")} Log and continue past errors ${chalk.red("(may leave RLS un-enforced!)")}
|
|
499
|
-
${chalk.blue("--yes, -y")} Skip the interactive confirmation ${chalk.red("(destructive!)")}
|
|
500
|
-
|
|
501
|
-
${chalk.red.bold("Warning")}
|
|
502
|
-
Restore is destructive and never runs automatically. Without --yes it
|
|
503
|
-
requires an interactive 'yes'. Prefer --create-db/--target-db to restore
|
|
504
|
-
into a fresh database rather than overwriting a live one.
|
|
505
|
-
|
|
506
|
-
By default the restore aborts on the first error (--exit-on-error) so a
|
|
507
|
-
skipped GRANT never silently leaves RLS un-enforced. Roles are recreated
|
|
508
|
-
from the backup's .globals.sql sidecar first; keep that file next to the
|
|
509
|
-
dump. Use --continue-on-error only when you understand the consequences.
|
|
510
|
-
`);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function printBackupsHelp(): void {
|
|
514
|
-
print(`
|
|
515
|
-
${chalk.bold("rebase db backups")} — Manage stored backups
|
|
516
|
-
|
|
517
|
-
${chalk.green.bold("Usage")}
|
|
518
|
-
rebase db backups list [--out <path|s3://bucket/prefix>]
|
|
519
|
-
`);
|
|
520
|
-
}
|