@rebasepro/server-postgres 0.25.0-canary.ge06257e → 0.25.1
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/{BranchService-DNg4txyf.js → BranchService-ClklxyAQ.js} +283 -20
- package/dist/BranchService-ClklxyAQ.js.map +1 -0
- package/dist/backup/backup-cli.d.ts +70 -1
- package/dist/backup/backup-service.d.ts +79 -0
- package/dist/backup/pg-binary.d.ts +2 -2
- package/dist/backup/pg-tools.d.ts +60 -1
- package/dist/backup-cli-gijeXe0u.js +1961 -0
- package/dist/backup-cli-gijeXe0u.js.map +1 -0
- package/dist/{boot-ddl-Dnvpmm_T.js → boot-ddl-DYH95Qeg.js} +3 -3
- package/dist/{boot-ddl-Dnvpmm_T.js.map → boot-ddl-DYH95Qeg.js.map} +1 -1
- package/dist/branch-argv.d.ts +15 -0
- package/dist/branch-prune.d.ts +18 -0
- package/dist/{cli-console-C_ISGwJo.js → cli-console-CQrQ0lAY.js} +19 -13
- package/dist/cli-console-CQrQ0lAY.js.map +1 -0
- package/dist/cli-console.d.ts +4 -3
- package/dist/{cli-errors-mx4sSjB8.js → cli-errors-DiA0XFIS.js} +110 -14
- package/dist/{cli-errors-mx4sSjB8.js.map → cli-errors-DiA0XFIS.js.map} +1 -1
- package/dist/cli-errors.d.ts +26 -1
- package/dist/cli-flags.d.ts +51 -0
- package/dist/{cli-prompt-egtNKkZl.js → cli-prompt-DXG0yk-h.js} +39 -4
- package/dist/{cli-prompt-egtNKkZl.js.map → cli-prompt-DXG0yk-h.js.map} +1 -1
- package/dist/cli.js +120 -184
- package/dist/cli.js.map +1 -1
- package/dist/collections/validate-relations.d.ts +8 -2
- package/dist/{column-plan-helpers-D_V4oaZh.js → column-plan-helpers-IG6uFb1s.js} +2 -2
- package/dist/{column-plan-helpers-D_V4oaZh.js.map → column-plan-helpers-IG6uFb1s.js.map} +1 -1
- package/dist/{connection-C938nR3E.js → connection-czvI7TZL.js} +57 -10
- package/dist/connection-czvI7TZL.js.map +1 -0
- package/dist/{databasePoolManager-Yqdw14wY.js → databasePoolManager-3stMNFL-.js} +4 -6
- package/dist/databasePoolManager-3stMNFL-.js.map +1 -0
- package/dist/db-command-line.d.ts +14 -0
- package/dist/{rls-bootstrap-sql-Bd-hJk08.js → db-subcommands-BPoHCxJR.js} +455 -3
- package/dist/db-subcommands-BPoHCxJR.js.map +1 -0
- package/dist/db-subcommands.d.ts +11 -0
- package/dist/{deferred-index-build-Ca6OqKyR.js → deferred-index-build-BE0I6sYU.js} +8 -8
- package/dist/{deferred-index-build-Ca6OqKyR.js.map → deferred-index-build-BE0I6sYU.js.map} +1 -1
- package/dist/{doctor-CJyuhV1n.js → doctor-BT--1kq3.js} +107 -11
- package/dist/doctor-BT--1kq3.js.map +1 -0
- package/dist/{ensure-collection-policies-C1ux3V_z.js → ensure-collection-policies-BnGOURpG.js} +60 -11
- package/dist/ensure-collection-policies-BnGOURpG.js.map +1 -0
- package/dist/{ensure-collection-tables-Do-Bvb64.js → ensure-collection-tables-Cet4650m.js} +9 -9
- package/dist/{ensure-collection-tables-Do-Bvb64.js.map → ensure-collection-tables-Cet4650m.js.map} +1 -1
- package/dist/{ensure-tables-Dl_Eh9ow.js → ensure-tables-l_OgFQjN.js} +5 -5
- package/dist/{ensure-tables-Dl_Eh9ow.js.map → ensure-tables-l_OgFQjN.js.map} +1 -1
- package/dist/{enum-labels-kept-D9eTIWCz.js → enum-labels-kept-nheku1pB.js} +3 -3
- package/dist/{enum-labels-kept-D9eTIWCz.js.map → enum-labels-kept-nheku1pB.js.map} +1 -1
- package/dist/{generate-drizzle-schema-Bf8canaR.js → generate-drizzle-schema-DC0BOUpn.js} +3 -3
- package/dist/{generate-drizzle-schema-Bf8canaR.js.map → generate-drizzle-schema-DC0BOUpn.js.map} +1 -1
- package/dist/{generate-drizzle-schema-logic-BaT0H7Gq.js → generate-drizzle-schema-logic-CPzCRD-8.js} +4 -4
- package/dist/{generate-drizzle-schema-logic-BaT0H7Gq.js.map → generate-drizzle-schema-logic-CPzCRD-8.js.map} +1 -1
- package/dist/{generate-files-D50Pxw1G.js → generate-files-BP-4W_gm.js} +6 -6
- package/dist/{generate-files-D50Pxw1G.js.map → generate-files-BP-4W_gm.js.map} +1 -1
- package/dist/{generate-next-step-DCd7yiJ2.js → generate-next-step-DgK5BlBn.js} +22 -14
- package/dist/{generate-next-step-DCd7yiJ2.js.map → generate-next-step-DgK5BlBn.js.map} +1 -1
- package/dist/{generate-postgres-ddl-logic-B4k9djAV.js → generate-postgres-ddl-logic-DPl1oH_z.js} +4 -4
- package/dist/{generate-postgres-ddl-logic-B4k9djAV.js.map → generate-postgres-ddl-logic-DPl1oH_z.js.map} +1 -1
- package/dist/generated-schema-diff-eZCklRZQ.js +1025 -0
- package/dist/generated-schema-diff-eZCklRZQ.js.map +1 -0
- package/dist/{generated-schema-staleness-FlxM2i4i.js → generated-schema-staleness-BvNF3i-s.js} +2 -2
- package/dist/{generated-schema-staleness-FlxM2i4i.js.map → generated-schema-staleness-BvNF3i-s.js.map} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +70 -11
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-j2vYhmdP.js → introspect-db-logic-DgQVzccW.js} +2 -2
- package/dist/{introspect-db-logic-j2vYhmdP.js.map → introspect-db-logic-DgQVzccW.js.map} +1 -1
- package/dist/migrate-policy-stamps-3eqfhes6.js +89 -0
- package/dist/migrate-policy-stamps-3eqfhes6.js.map +1 -0
- package/dist/{open-rebase-D8rytTP0.js → open-rebase-EmqVmS14.js} +78 -1027
- package/dist/open-rebase-EmqVmS14.js.map +1 -0
- package/dist/{pg-error-utils-CdsGYMTm.js → pg-error-utils-CFEnm2m9.js} +11 -4
- package/dist/pg-error-utils-CFEnm2m9.js.map +1 -0
- package/dist/{plan-schema-CsNgK0qi.js → plan-schema-Czf3q4-Q.js} +4 -4
- package/dist/{plan-schema-CsNgK0qi.js.map → plan-schema-Czf3q4-Q.js.map} +1 -1
- package/dist/{policy-drift-CY5P1ZRu.js → policy-drift-X4A-xAzv.js} +3 -3
- package/dist/{policy-drift-CY5P1ZRu.js.map → policy-drift-X4A-xAzv.js.map} +1 -1
- package/dist/pool-disconnects.d.ts +28 -0
- package/dist/{push-index-prebuild-BDFcfRPo.js → push-index-prebuild-COd7i6wm.js} +4 -4
- package/dist/{push-index-prebuild-BDFcfRPo.js.map → push-index-prebuild-COd7i6wm.js.map} +1 -1
- package/dist/{push-legacy-renames-D4bvMWAb.js → push-legacy-renames-CLgYYwPE.js} +4 -4
- package/dist/{push-legacy-renames-D4bvMWAb.js.map → push-legacy-renames-CLgYYwPE.js.map} +1 -1
- package/dist/{push-policy-plan-DTXE6aVa.js → push-policy-plan-D2gpQsdv.js} +23 -7
- package/dist/push-policy-plan-D2gpQsdv.js.map +1 -0
- package/dist/{render-ddl-CrfEe__B.js → render-ddl-4v88y-Gv.js} +4 -4
- package/dist/{render-ddl-CrfEe__B.js.map → render-ddl-4v88y-Gv.js.map} +1 -1
- package/dist/{rls-enforcement-D7P4-ZYX.js → rls-enforcement-DDyNoOSy.js} +2 -2
- package/dist/{rls-enforcement-D7P4-ZYX.js.map → rls-enforcement-DDyNoOSy.js.map} +1 -1
- package/dist/schema/atlas-argv.d.ts +3 -1
- package/dist/schema/doctor-cli.js +5 -5
- package/dist/schema/doctor.d.ts +20 -2
- package/dist/schema/ensure-collection-policies.d.ts +28 -1
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-next-step.d.ts +20 -8
- package/dist/schema/generate-postgres-ddl.js +2 -2
- package/dist/schema/generated-schema-diff.d.ts +21 -0
- package/dist/schema/introspect-db.js +6 -6
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/migrate-policy-stamps.d.ts +23 -0
- package/dist/schema/push-policy-plan.d.ts +10 -2
- package/dist/services/BranchService.d.ts +85 -0
- package/dist/services/pg-notify-listener.d.ts +2 -0
- package/dist/{source-DB6xJv3p.js → source-fZb92pN8.js} +46 -3
- package/dist/source-fZb92pN8.js.map +1 -0
- package/dist/{vector-index-BFhyEaiz.js → vector-index-DsQ3V2d2.js} +2 -2
- package/dist/{vector-index-BFhyEaiz.js.map → vector-index-DsQ3V2d2.js.map} +1 -1
- package/dist/{websocket-DMeXOoyv.js → websocket-BKZwsjmf.js} +18 -5
- package/dist/websocket-BKZwsjmf.js.map +1 -0
- package/package.json +7 -7
- package/dist/BranchService-DNg4txyf.js.map +0 -1
- package/dist/backup-cli-HCEkPcQj.js +0 -556
- package/dist/backup-cli-HCEkPcQj.js.map +0 -1
- package/dist/backup-service-D-UpT7DH.js +0 -857
- package/dist/backup-service-D-UpT7DH.js.map +0 -1
- package/dist/cli-console-C_ISGwJo.js.map +0 -1
- package/dist/cli-flags-DUozBFyK.js +0 -205
- package/dist/cli-flags-DUozBFyK.js.map +0 -1
- package/dist/connection-C938nR3E.js.map +0 -1
- package/dist/databasePoolManager-Yqdw14wY.js.map +0 -1
- package/dist/doctor-CJyuhV1n.js.map +0 -1
- package/dist/ensure-collection-policies-C1ux3V_z.js.map +0 -1
- package/dist/open-rebase-D8rytTP0.js.map +0 -1
- package/dist/pg-error-utils-CdsGYMTm.js.map +0 -1
- package/dist/push-policy-plan-DTXE6aVa.js.map +0 -1
- package/dist/rls-bootstrap-sql-Bd-hJk08.js.map +0 -1
- package/dist/rolldown-runtime-V7rFbMRe.js +0 -39
- package/dist/source-DB6xJv3p.js.map +0 -1
- package/dist/websocket-DMeXOoyv.js.map +0 -1
|
@@ -1,857 +0,0 @@
|
|
|
1
|
-
import { createRequire as __createRequire } from "module";
|
|
2
|
-
__createRequire(import.meta.url);
|
|
3
|
-
import { n as __exportAll } from "./rolldown-runtime-V7rFbMRe.js";
|
|
4
|
-
import { Q as forLibpq, R as resolveLocalBin, et as splitPassword } from "./cli-errors-mx4sSjB8.js";
|
|
5
|
-
import { o as execa } from "./rls-bootstrap-sql-Bd-hJk08.js";
|
|
6
|
-
import path from "path";
|
|
7
|
-
import fs from "fs";
|
|
8
|
-
import os from "os";
|
|
9
|
-
//#region src/backup/pg-tools.ts
|
|
10
|
-
/**
|
|
11
|
-
* Pure helpers for the backup/restore commands.
|
|
12
|
-
*
|
|
13
|
-
* Everything in this file is side-effect free so it can be unit-tested
|
|
14
|
-
* without a live Postgres server, matching the constraint that CI must
|
|
15
|
-
* not require a database.
|
|
16
|
-
*/
|
|
17
|
-
/**
|
|
18
|
-
* Extract the database name from a Postgres connection string.
|
|
19
|
-
* Returns `null` when the URL has no database path (e.g. bare host).
|
|
20
|
-
*/
|
|
21
|
-
function parseDbNameFromUrl(connectionString) {
|
|
22
|
-
try {
|
|
23
|
-
const name = new URL(connectionString).pathname.replace(/^\//, "").trim();
|
|
24
|
-
return name.length > 0 ? name : null;
|
|
25
|
-
} catch {
|
|
26
|
-
const match = connectionString.match(/\/([^/?]+)(\?|$)/);
|
|
27
|
-
return match && match[1] ? match[1] : null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Swap the database name in a connection string, preserving credentials,
|
|
32
|
-
* host, port and query params. Used by `--target-db` / `--create-db` so a
|
|
33
|
-
* restore can target a fresh database instead of clobbering the live one.
|
|
34
|
-
*/
|
|
35
|
-
function withDatabaseName(connectionString, dbName) {
|
|
36
|
-
try {
|
|
37
|
-
const parsed = new URL(connectionString);
|
|
38
|
-
parsed.pathname = `/${dbName}`;
|
|
39
|
-
return parsed.toString();
|
|
40
|
-
} catch {
|
|
41
|
-
return connectionString.replace(/\/([^/?]+)(\?|$)/, `/${dbName}$2`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Parse the major version out of `pg_dump --version` / `pg_restore --version`
|
|
46
|
-
* output, e.g. `"pg_dump (PostgreSQL) 16.2"` → `16`. Handles the pre-10
|
|
47
|
-
* `9.6.x` scheme (returns `9`) and beta strings like `"17beta1"`.
|
|
48
|
-
*/
|
|
49
|
-
function parsePgToolMajor(versionOutput) {
|
|
50
|
-
const match = versionOutput.match(/(\d+)(?:\.(\d+))?/);
|
|
51
|
-
if (!match) return null;
|
|
52
|
-
const major = Number(match[1]);
|
|
53
|
-
if (!Number.isFinite(major)) return null;
|
|
54
|
-
if (major === 9 && match[2] !== void 0) return 9;
|
|
55
|
-
return major;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Convert `SELECT current_setting('server_version_num')` (e.g. `160002`)
|
|
59
|
-
* into a major version (`16`). Also accepts pre-10 encodings like `90603`
|
|
60
|
-
* → `9`.
|
|
61
|
-
*/
|
|
62
|
-
function serverVersionNumToMajor(versionNum) {
|
|
63
|
-
const num = typeof versionNum === "string" ? Number(versionNum) : versionNum;
|
|
64
|
-
if (!Number.isFinite(num) || num <= 0) return null;
|
|
65
|
-
if (num < 1e5) return Math.floor(num / 1e4);
|
|
66
|
-
return Math.floor(num / 1e4);
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* pg_dump / pg_restore must be **at least** as new as the server they talk
|
|
70
|
-
* to. A newer client against an older server is supported; an older client
|
|
71
|
-
* against a newer server is not and produces corrupt or rejected output.
|
|
72
|
-
*/
|
|
73
|
-
function checkToolServerCompatibility(toolMajor, serverMajor) {
|
|
74
|
-
if (toolMajor === null) return {
|
|
75
|
-
compatible: false,
|
|
76
|
-
reason: "Could not determine the client tool version."
|
|
77
|
-
};
|
|
78
|
-
if (serverMajor === null) return {
|
|
79
|
-
compatible: false,
|
|
80
|
-
reason: "Could not determine the Postgres server version."
|
|
81
|
-
};
|
|
82
|
-
if (toolMajor < serverMajor) return {
|
|
83
|
-
compatible: false,
|
|
84
|
-
reason: `Client tool is Postgres ${toolMajor} but the server is Postgres ${serverMajor}. pg_dump/pg_restore must be the same major version as the server or newer. Install Postgres ${serverMajor} client tools.`
|
|
85
|
-
};
|
|
86
|
-
return { compatible: true };
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Build a deterministic, sortable backup file name:
|
|
90
|
-
* `rebase-<db>-<YYYYMMDD>T<HHMMSS>Z.dump`
|
|
91
|
-
*
|
|
92
|
-
* The UTC timestamp is embedded so retention pruning can recover the
|
|
93
|
-
* creation time from the object key alone, without extra metadata.
|
|
94
|
-
*/
|
|
95
|
-
function buildBackupFilename(dbName, date = /* @__PURE__ */ new Date()) {
|
|
96
|
-
const stamp = date.toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
|
|
97
|
-
return `rebase-${dbName.replace(/[^a-zA-Z0-9_-]/g, "_")}-${stamp}.dump`;
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Recover the creation timestamp encoded in a backup file name by
|
|
101
|
-
* {@link buildBackupFilename}. Returns `null` for names that don't match,
|
|
102
|
-
* so foreign objects in a shared prefix are never pruned.
|
|
103
|
-
*/
|
|
104
|
-
function parseBackupTimestamp(fileName) {
|
|
105
|
-
const match = (fileName.split("/").pop() ?? fileName).match(/-(\d{8})T(\d{6})Z\.dump$/);
|
|
106
|
-
if (!match) return null;
|
|
107
|
-
const [, ymd, hms] = match;
|
|
108
|
-
const iso = `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;
|
|
109
|
-
const date = new Date(iso);
|
|
110
|
-
return Number.isNaN(date.getTime()) ? null : date;
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Parse a destination string into a structured {@link BackupDestination}.
|
|
114
|
-
* `s3://bucket/prefix` and `gs://bucket/prefix` map to object storage;
|
|
115
|
-
* anything else is treated as a local path.
|
|
116
|
-
*/
|
|
117
|
-
function parseBackupDestination(out) {
|
|
118
|
-
const s3 = out.match(/^s3:\/\/([^/]+)\/?(.*)$/);
|
|
119
|
-
if (s3) return {
|
|
120
|
-
kind: "s3",
|
|
121
|
-
bucket: s3[1],
|
|
122
|
-
prefix: stripTrailingSlash(s3[2])
|
|
123
|
-
};
|
|
124
|
-
const gcs = out.match(/^gs:\/\/([^/]+)\/?(.*)$/);
|
|
125
|
-
if (gcs) return {
|
|
126
|
-
kind: "gcs",
|
|
127
|
-
bucket: gcs[1],
|
|
128
|
-
prefix: stripTrailingSlash(gcs[2])
|
|
129
|
-
};
|
|
130
|
-
return {
|
|
131
|
-
kind: "local",
|
|
132
|
-
path: out
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
function stripTrailingSlash(s) {
|
|
136
|
-
return s.replace(/\/+$/, "");
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Join a storage prefix and a file name without producing a leading or
|
|
140
|
-
* doubled slash.
|
|
141
|
-
*/
|
|
142
|
-
function joinStorageKey(prefix, fileName) {
|
|
143
|
-
const clean = prefix.replace(/^\/+|\/+$/g, "");
|
|
144
|
-
return clean.length > 0 ? `${clean}/${fileName}` : fileName;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* `PGOPTIONS` carrying an identity, for a libpq tool that has no other way to
|
|
148
|
-
* set a GUC.
|
|
149
|
-
*
|
|
150
|
-
* A backslash escape rather than quoting, which is what libpq's `-c` parsing
|
|
151
|
-
* takes: a space inside a value ends the option otherwise, so a role list is
|
|
152
|
-
* comma-joined and never spaced.
|
|
153
|
-
*/
|
|
154
|
-
function buildRowSecurityPgOptions(identity) {
|
|
155
|
-
const escape = (value) => value.replace(/([\\ ])/g, "\\$1");
|
|
156
|
-
return [
|
|
157
|
-
`-c app.uid=${escape(identity.uid)}`,
|
|
158
|
-
`-c app.user_id=${escape(identity.uid)}`,
|
|
159
|
-
`-c app.user_roles=${escape(identity.roles.join(","))}`
|
|
160
|
-
].join(" ");
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Assemble the `pg_dump` argument vector. Uses the custom format (`-Fc`),
|
|
164
|
-
* which is compressed and restorable selectively via `pg_restore`.
|
|
165
|
-
*/
|
|
166
|
-
function buildPgDumpArgs(opts) {
|
|
167
|
-
const args = [
|
|
168
|
-
"--format=custom",
|
|
169
|
-
"--no-password",
|
|
170
|
-
`--file=${opts.outFile}`
|
|
171
|
-
];
|
|
172
|
-
if (opts.noOwner) args.push("--no-owner");
|
|
173
|
-
if (opts.rowSecurity) args.push("--enable-row-security");
|
|
174
|
-
for (const schema of opts.excludeSchemas ?? []) args.push(`--exclude-schema=${schema}`);
|
|
175
|
-
args.push(forLibpq(opts.connectionString));
|
|
176
|
-
return args;
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Whether a `pg_dump` failure is the row-security one, and what to do about it.
|
|
180
|
-
*
|
|
181
|
-
* The error text names the table and nothing else, so the first read of it is
|
|
182
|
-
* "why would a backup be affected by RLS at all?" — the answer being that the
|
|
183
|
-
* dumping role is not the tables' owner and has no `BYPASSRLS`, which is the
|
|
184
|
-
* normal state of the `postgres` user on Cloud SQL, RDS and every other managed
|
|
185
|
-
* Postgres. Nothing about that is visible from the message.
|
|
186
|
-
*
|
|
187
|
-
* Returns `null` for any other failure, so the caller reports it unchanged.
|
|
188
|
-
*/
|
|
189
|
-
function diagnoseRowSecurityDumpFailure(error) {
|
|
190
|
-
const text = [error?.stderr, error?.message].map((part) => typeof part === "string" ? part : "").join("\n");
|
|
191
|
-
if (!/row-level security policy/i.test(text)) return null;
|
|
192
|
-
const table = text.match(/for table "([^"]+)"/)?.[1];
|
|
193
|
-
return [
|
|
194
|
-
`pg_dump cannot read ${table ? `"${table}"` : "one of the tables"} because row-level security applies to it.`,
|
|
195
|
-
"",
|
|
196
|
-
" The dumping role is neither the table's owner nor `BYPASSRLS`, which is the normal",
|
|
197
|
-
" state of the `postgres` user on Cloud SQL, RDS and other managed Postgres — there is",
|
|
198
|
-
" no superuser to hand out.",
|
|
199
|
-
"",
|
|
200
|
-
" Two ways out:",
|
|
201
|
-
"",
|
|
202
|
-
" • Grant the dumping role BYPASSRLS, or make it the owner, and run this again. The",
|
|
203
|
-
" dump then contains every row, which is what a backup should mean.",
|
|
204
|
-
"",
|
|
205
|
-
" • Re-run with --enable-row-security to dump as an admin subject instead. Rebase",
|
|
206
|
-
" sets `app.uid`/`app.user_roles` so the generated `admin_full_access` policy",
|
|
207
|
-
" admits the dump. Read the warning it prints: the result contains exactly the",
|
|
208
|
-
" rows those policies admit, and any table whose policies do not include an",
|
|
209
|
-
" admin rule comes out short — with no error.",
|
|
210
|
-
"",
|
|
211
|
-
" Do not reach for a bare `pg_dump --enable-row-security` by hand. Without the",
|
|
212
|
-
" settings above it succeeds and silently omits rows."
|
|
213
|
-
].join("\n");
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Assemble the `pg_restore` argument vector for a custom-format dump.
|
|
217
|
-
*/
|
|
218
|
-
function buildPgRestoreArgs(opts) {
|
|
219
|
-
const args = [
|
|
220
|
-
"--format=custom",
|
|
221
|
-
"--no-password",
|
|
222
|
-
`--dbname=${forLibpq(opts.connectionString)}`
|
|
223
|
-
];
|
|
224
|
-
if (opts.clean) args.push("--clean", "--if-exists");
|
|
225
|
-
if (opts.noOwner) args.push("--no-owner");
|
|
226
|
-
if (opts.exitOnError !== false) args.push("--exit-on-error");
|
|
227
|
-
args.push(opts.inputFile);
|
|
228
|
-
return args;
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* Assemble the `pg_restore --list` argument vector. Reading a dump's table
|
|
232
|
-
* of contents parses the whole archive without touching a database, so it is
|
|
233
|
-
* a cheap integrity check that the file isn't truncated or corrupt.
|
|
234
|
-
*/
|
|
235
|
-
function buildPgRestoreListArgs(inputFile) {
|
|
236
|
-
return ["--list", inputFile];
|
|
237
|
-
}
|
|
238
|
-
/** `<id>; <catalog oid> <oid> <description> <namespace> <tag…> <owner>`, the shape of a TOC entry. */
|
|
239
|
-
var TOC_ENTRY = /^\d+;\s+\d+\s+\d+\s+(SCHEMA|TABLE)\s+(?!DATA\s)(\S+)\s+(.+)\s+(\S+)$/;
|
|
240
|
-
/**
|
|
241
|
-
* The schemas and tables in `pg_restore --list` output: the objects a
|
|
242
|
-
* restore without `--clean` fails on when the target already has them.
|
|
243
|
-
*
|
|
244
|
-
* A TOC line ends in the object's owner, after its namespace and its name;
|
|
245
|
-
* a schema's namespace is `-`. The name is what lies between the two, so a
|
|
246
|
-
* name with a space in it (`Odd Name`) reads whole. `TABLE DATA` and every
|
|
247
|
-
* other kind of entry are not objects of their own here.
|
|
248
|
-
*/
|
|
249
|
-
function parseDumpObjects(list) {
|
|
250
|
-
const objects = {
|
|
251
|
-
schemas: [],
|
|
252
|
-
tables: []
|
|
253
|
-
};
|
|
254
|
-
for (const line of list.split("\n")) {
|
|
255
|
-
const entry = TOC_ENTRY.exec(line.trim());
|
|
256
|
-
if (!entry) continue;
|
|
257
|
-
const [, kind, namespace, name] = entry;
|
|
258
|
-
if (kind === "SCHEMA") objects.schemas.push(name);
|
|
259
|
-
else objects.tables.push({
|
|
260
|
-
schema: namespace,
|
|
261
|
-
table: name
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
return objects;
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Assemble the `pg_dumpall --globals-only` argument vector. Roles (and other
|
|
268
|
-
* cluster-wide objects) live outside any single database, so a per-database
|
|
269
|
-
* `pg_dump` omits them. Without the `rebase_user` role the RLS GRANT
|
|
270
|
-
* statements in the main dump fail on restore and RLS is silently lost — so
|
|
271
|
-
* every backup captures the globals into a sidecar `.globals.sql`.
|
|
272
|
-
*
|
|
273
|
-
* `--no-role-passwords` keeps role secrets out of the artifact (backups may
|
|
274
|
-
* be shipped off-box); roles are recreated password-less and re-secured by
|
|
275
|
-
* the operator.
|
|
276
|
-
*/
|
|
277
|
-
function buildPgDumpallGlobalsArgs(opts) {
|
|
278
|
-
return [
|
|
279
|
-
"--globals-only",
|
|
280
|
-
"--no-role-passwords",
|
|
281
|
-
"--no-password",
|
|
282
|
-
`--file=${opts.outFile}`,
|
|
283
|
-
`--dbname=${forLibpq(opts.connectionString)}`
|
|
284
|
-
];
|
|
285
|
-
}
|
|
286
|
-
/**
|
|
287
|
-
* Derive the globals sidecar path/key for a given `.dump` file. Keeps the
|
|
288
|
-
* two artifacts adjacent so listing, uploading and pruning can find one from
|
|
289
|
-
* the other. A name that doesn't end in `.dump` is returned unchanged with a
|
|
290
|
-
* `.globals.sql` suffix appended.
|
|
291
|
-
*/
|
|
292
|
-
function globalsFileForDump(dumpPath) {
|
|
293
|
-
return dumpPath.endsWith(".dump") ? dumpPath.slice(0, -5) + ".globals.sql" : dumpPath + ".globals.sql";
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Split a `pg_dumpall --globals-only` script into individual statements.
|
|
297
|
-
* Used when replaying globals on restore so each `CREATE ROLE` / `GRANT`
|
|
298
|
-
* can run independently and a benign "role already exists" on one doesn't
|
|
299
|
-
* abort the rest. Drops `--` comment lines and blank statements.
|
|
300
|
-
*/
|
|
301
|
-
function splitGlobalsStatements(sql) {
|
|
302
|
-
return sql.split("\n").filter((line) => {
|
|
303
|
-
const trimmed = line.trim();
|
|
304
|
-
return !trimmed.startsWith("--") && !trimmed.startsWith("\\");
|
|
305
|
-
}).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Resolve the Postgres connection string the backup commands should use,
|
|
309
|
-
* mirroring the precedence the branch command already relies on.
|
|
310
|
-
*/
|
|
311
|
-
function resolveConnectionString(env) {
|
|
312
|
-
return env.DATABASE_URL || env.ADMIN_CONNECTION_STRING || null;
|
|
313
|
-
}
|
|
314
|
-
//#endregion
|
|
315
|
-
//#region src/backup/retention.ts
|
|
316
|
-
/**
|
|
317
|
-
* Retention pruning for scheduled backups. Pure logic — no I/O — so it can
|
|
318
|
-
* be exhaustively unit-tested without touching a storage backend.
|
|
319
|
-
*/
|
|
320
|
-
/**
|
|
321
|
-
* Decide which backups to delete under the given retention policy.
|
|
322
|
-
*
|
|
323
|
-
* Rules, applied in order:
|
|
324
|
-
* 1. Objects whose key isn't a recognised rebase backup (no parseable
|
|
325
|
-
* timestamp and no `createdAt`) are never selected — foreign files in a
|
|
326
|
-
* shared bucket are left untouched.
|
|
327
|
-
* 2. The `keepMinimum` newest backups are always retained.
|
|
328
|
-
* 3. Of the remainder, any older than `retentionDays` are selected for
|
|
329
|
-
* deletion.
|
|
330
|
-
*
|
|
331
|
-
* Returns the keys to delete, oldest first.
|
|
332
|
-
*/
|
|
333
|
-
function selectBackupsToPrune(backups, options) {
|
|
334
|
-
const retentionDays = options.retentionDays ?? 0;
|
|
335
|
-
const keepMinimum = Math.max(0, options.keepMinimum ?? 0);
|
|
336
|
-
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
337
|
-
if (retentionDays <= 0) return [];
|
|
338
|
-
const dated = backups.map((b) => {
|
|
339
|
-
const createdAt = b.createdAt ?? parseBackupTimestamp(b.key);
|
|
340
|
-
return createdAt ? {
|
|
341
|
-
key: b.key,
|
|
342
|
-
createdAt
|
|
343
|
-
} : null;
|
|
344
|
-
}).filter((b) => b !== null);
|
|
345
|
-
dated.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
346
|
-
const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1e3;
|
|
347
|
-
const toDelete = dated.slice(keepMinimum).filter((b) => b.createdAt.getTime() < cutoff);
|
|
348
|
-
toDelete.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
349
|
-
return toDelete.map((b) => b.key);
|
|
350
|
-
}
|
|
351
|
-
//#endregion
|
|
352
|
-
//#region src/backup/pg-binary.ts
|
|
353
|
-
/**
|
|
354
|
-
* Where the PostgreSQL client tools are, without spawning them.
|
|
355
|
-
*
|
|
356
|
-
* Kept apart from `backup-service.ts`, which pulls in `execa` (ESM-only), so
|
|
357
|
-
* `createBackupCron` can check for the tools when it is registered.
|
|
358
|
-
*/
|
|
359
|
-
var OVERRIDE_VARIABLE = {
|
|
360
|
-
pg_dump: "PG_DUMP_PATH",
|
|
361
|
-
pg_restore: "PG_RESTORE_PATH",
|
|
362
|
-
pg_dumpall: "PG_DUMPALL_PATH"
|
|
363
|
-
};
|
|
364
|
-
/** The variable that points at `tool` when it is not on the PATH. */
|
|
365
|
-
function pgToolVariable(tool) {
|
|
366
|
-
return OVERRIDE_VARIABLE[tool];
|
|
367
|
-
}
|
|
368
|
-
/** Locate `pg_dump` / `pg_restore` / `pg_dumpall`, honouring an env override. */
|
|
369
|
-
function resolvePgBinary(tool, env = process.env) {
|
|
370
|
-
const override = env[OVERRIDE_VARIABLE[tool]];
|
|
371
|
-
if (override && fs.existsSync(override)) return override;
|
|
372
|
-
return resolveLocalBin(tool);
|
|
373
|
-
}
|
|
374
|
-
//#endregion
|
|
375
|
-
//#region src/backup/backup-logic.ts
|
|
376
|
-
/**
|
|
377
|
-
* Pure-ish orchestration for the backup/restore paths, kept free of `execa`
|
|
378
|
-
* and `pg` value imports so it can be unit-tested under jest (the impure
|
|
379
|
-
* edges — spawning processes, opening connections — live in
|
|
380
|
-
* `backup-service.ts`, which is vitest/runtime only).
|
|
381
|
-
*
|
|
382
|
-
* The functions here take their side-effecting dependency as an argument
|
|
383
|
-
* (a statement runner, an object deleter) so tests can inject fakes.
|
|
384
|
-
*/
|
|
385
|
-
/**
|
|
386
|
-
* Replay a `pg_dumpall --globals-only` script one statement at a time,
|
|
387
|
-
* tolerating per-statement failures. On a same-cluster restore the roles
|
|
388
|
-
* usually already exist (`CREATE ROLE` → "already exists") and on a managed
|
|
389
|
-
* provider an `ALTER ROLE <superuser>` may be refused; neither should abort
|
|
390
|
-
* recreation of the roles that *are* missing. Returns how many statements
|
|
391
|
-
* applied vs were skipped.
|
|
392
|
-
*
|
|
393
|
-
* `runStatement` executes one SQL statement and rejects on error.
|
|
394
|
-
*/
|
|
395
|
-
async function applyGlobalsWith(runStatement, globalsSql, log = () => {}) {
|
|
396
|
-
let applied = 0;
|
|
397
|
-
let skipped = 0;
|
|
398
|
-
for (const statement of splitGlobalsStatements(globalsSql)) try {
|
|
399
|
-
await runStatement(statement);
|
|
400
|
-
applied++;
|
|
401
|
-
} catch (err) {
|
|
402
|
-
skipped++;
|
|
403
|
-
log(` • Skipped global: ${statement.split("\n")[0].slice(0, 80)} (${err instanceof Error ? err.message : String(err)})`);
|
|
404
|
-
}
|
|
405
|
-
return {
|
|
406
|
-
applied,
|
|
407
|
-
skipped
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
/**
|
|
411
|
-
* Delete each pruned dump together with its `.globals.sql` roles sidecar, so
|
|
412
|
-
* pruning never orphans the roles file. The sidecar is best-effort — older
|
|
413
|
-
* backups predate it, so a missing-sidecar failure is swallowed while a
|
|
414
|
-
* failure deleting the dump itself propagates.
|
|
415
|
-
*
|
|
416
|
-
* `deleteObject` removes one key and rejects if it cannot (e.g. not found).
|
|
417
|
-
*/
|
|
418
|
-
async function pruneWith(keys, deleteObject) {
|
|
419
|
-
for (const key of keys) {
|
|
420
|
-
await deleteObject(key);
|
|
421
|
-
try {
|
|
422
|
-
await deleteObject(globalsFileForDump(key));
|
|
423
|
-
} catch {}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* Remove a dump artifact abandoned by a failed tool run.
|
|
428
|
-
*
|
|
429
|
-
* `pg_dump` creates its `--file=` target before it finishes connecting, so any
|
|
430
|
-
* failure — a URL libpq rejects, a dropped connection, a full disk — leaves a
|
|
431
|
-
* 0-byte file behind. That corpse is not inert: `rebase db backups list` used
|
|
432
|
-
* to show it as an ordinary entry, and `selectBackupsToPrune` ranks by
|
|
433
|
-
* timestamp alone, so it occupies a protected `keepMinimum` slot and can push
|
|
434
|
-
* a real backup out of retention.
|
|
435
|
-
*
|
|
436
|
-
* Best-effort on purpose: the caller is already throwing, and a failed unlink
|
|
437
|
-
* must not replace the real diagnosis with an ENOENT/EPERM from the cleanup.
|
|
438
|
-
*
|
|
439
|
-
* Lives here rather than in `backup-service.ts` for the reason at the top of
|
|
440
|
-
* this file — that module's top-level `execa` import makes it unloadable under
|
|
441
|
-
* jest, so anything placed there cannot be unit-tested.
|
|
442
|
-
*
|
|
443
|
-
* `remove` deletes one file and throws if it cannot; `exists` reports presence.
|
|
444
|
-
*/
|
|
445
|
-
function discardPartialDumpWith(exists, remove, file) {
|
|
446
|
-
try {
|
|
447
|
-
if (exists(file)) remove(file);
|
|
448
|
-
} catch {}
|
|
449
|
-
}
|
|
450
|
-
//#endregion
|
|
451
|
-
//#region src/backup/backup-service.ts
|
|
452
|
-
/**
|
|
453
|
-
* Backup / restore orchestration: thin, well-typed wrappers around
|
|
454
|
-
* `pg_dump` and `pg_restore`, plus binary detection, version pre-flight,
|
|
455
|
-
* and upload/prune against a storage backend.
|
|
456
|
-
*
|
|
457
|
-
* The pure argument- and policy-logic lives in `pg-tools.ts` and
|
|
458
|
-
* `retention.ts`; this module is the impure edge (spawns processes, talks
|
|
459
|
-
* to Postgres and storage) and is intentionally kept small.
|
|
460
|
-
*/
|
|
461
|
-
var backup_service_exports = /* @__PURE__ */ __exportAll({
|
|
462
|
-
BackupToolError: () => BackupToolError,
|
|
463
|
-
applyGlobals: () => applyGlobals,
|
|
464
|
-
createDump: () => createDump,
|
|
465
|
-
detectToolMajor: () => detectToolMajor,
|
|
466
|
-
discardPartialDump: () => discardPartialDump,
|
|
467
|
-
ensureDatabaseExists: () => ensureDatabaseExists,
|
|
468
|
-
findRestoreConflicts: () => findRestoreConflicts,
|
|
469
|
-
getServerVersionMajor: () => getServerVersionMajor,
|
|
470
|
-
listBackups: () => listBackups,
|
|
471
|
-
preflight: () => preflight,
|
|
472
|
-
pruneBackups: () => pruneBackups,
|
|
473
|
-
readDumpObjects: () => readDumpObjects,
|
|
474
|
-
resolvePgBinary: () => resolvePgBinary,
|
|
475
|
-
restoreDump: () => restoreDump,
|
|
476
|
-
uploadBackup: () => uploadBackup,
|
|
477
|
-
validateDump: () => validateDump
|
|
478
|
-
});
|
|
479
|
-
/**
|
|
480
|
-
* How a libpq tool is invoked for a connection string: the URL its command
|
|
481
|
-
* line carries, and the environment it runs in.
|
|
482
|
-
*
|
|
483
|
-
* The password goes in `PGPASSWORD`, never on the command line, where every
|
|
484
|
-
* process listing could read it and every failure printed it back (execa's
|
|
485
|
-
* error repeats the command). See {@link splitPassword}.
|
|
486
|
-
*/
|
|
487
|
-
function libpqInvocation(connectionString, env) {
|
|
488
|
-
const split = splitPassword(connectionString);
|
|
489
|
-
const toolEnv = {};
|
|
490
|
-
for (const [key, value] of Object.entries(env)) if (value !== void 0) toolEnv[key] = value;
|
|
491
|
-
if (split.password !== null) toolEnv.PGPASSWORD = split.password;
|
|
492
|
-
return {
|
|
493
|
-
connectionString: split.connectionString,
|
|
494
|
-
env: toolEnv
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
/**
|
|
498
|
-
* Remove a dump artifact abandoned by a failed tool run. See
|
|
499
|
-
* {@link discardPartialDumpWith} for why this exists and why the logic lives
|
|
500
|
-
* in the execa-free module.
|
|
501
|
-
*/
|
|
502
|
-
function discardPartialDump(file) {
|
|
503
|
-
discardPartialDumpWith((f) => fs.existsSync(f), (f) => fs.unlinkSync(f), file);
|
|
504
|
-
}
|
|
505
|
-
var BackupToolError = class extends Error {
|
|
506
|
-
hint;
|
|
507
|
-
constructor(message, hint) {
|
|
508
|
-
super(message);
|
|
509
|
-
this.hint = hint;
|
|
510
|
-
this.name = "BackupToolError";
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
/** Run `<bin> --version` and extract the major version. */
|
|
514
|
-
async function detectToolMajor(bin) {
|
|
515
|
-
try {
|
|
516
|
-
const { stdout } = await execa(bin, ["--version"]);
|
|
517
|
-
return parsePgToolMajor(stdout);
|
|
518
|
-
} catch {
|
|
519
|
-
return null;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
/** Query the server for its major version via `server_version_num`. */
|
|
523
|
-
async function getServerVersionMajor(connectionString) {
|
|
524
|
-
const { Client } = await import("pg");
|
|
525
|
-
const client = new Client({ connectionString });
|
|
526
|
-
await client.connect();
|
|
527
|
-
try {
|
|
528
|
-
const raw = (await client.query("SHOW server_version_num")).rows?.[0]?.server_version_num;
|
|
529
|
-
return serverVersionNumToMajor(raw);
|
|
530
|
-
} finally {
|
|
531
|
-
await client.end();
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
/**
|
|
535
|
-
* Verify the requested client tool exists and its major version is
|
|
536
|
-
* compatible with the live server. Throws {@link BackupToolError} — with a
|
|
537
|
-
* doctor-style hint — when the binary is missing.
|
|
538
|
-
*/
|
|
539
|
-
async function preflight(tool, connectionString, env = process.env) {
|
|
540
|
-
const bin = resolvePgBinary(tool, env);
|
|
541
|
-
if (!bin) throw new BackupToolError(`Could not find the '${tool}' binary.`, `Install the PostgreSQL client tools (e.g. 'brew install libpq' or 'apt-get install postgresql-client'), or set ${tool === "pg_dump" ? "PG_DUMP_PATH" : "PG_RESTORE_PATH"} to its full path.`);
|
|
542
|
-
const [toolMajor, serverMajor] = await Promise.all([detectToolMajor(bin), getServerVersionMajor(connectionString)]);
|
|
543
|
-
return {
|
|
544
|
-
bin,
|
|
545
|
-
toolMajor,
|
|
546
|
-
serverMajor,
|
|
547
|
-
...checkToolServerCompatibility(toolMajor, serverMajor)
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
/**
|
|
551
|
-
* Produce a custom-format dump on local disk. When `outDir` is omitted the
|
|
552
|
-
* file is written to the OS temp directory (used by the upload path, which
|
|
553
|
-
* cleans it up afterwards).
|
|
554
|
-
*
|
|
555
|
-
* Alongside the `-Fc` dump it writes a `<name>.globals.sql` sidecar via
|
|
556
|
-
* `pg_dumpall --globals-only` so the roles the dump's GRANT/RLS statements
|
|
557
|
-
* depend on can be recreated on restore. Set `includeGlobals: false` to skip
|
|
558
|
-
* it (e.g. when the caller has no privilege to read cluster globals).
|
|
559
|
-
*/
|
|
560
|
-
async function createDump(opts) {
|
|
561
|
-
const env = opts.env ?? process.env;
|
|
562
|
-
const bin = resolvePgBinary("pg_dump", env);
|
|
563
|
-
if (!bin) throw new BackupToolError("Could not find the 'pg_dump' binary.", "Install the PostgreSQL client tools or set PG_DUMP_PATH.");
|
|
564
|
-
const fileName = opts.fileName ?? buildBackupFilename(opts.dbName);
|
|
565
|
-
const outDir = opts.outDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "rebase-backup-"));
|
|
566
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
567
|
-
const localFile = path.join(outDir, fileName);
|
|
568
|
-
createOwnerOnly(localFile);
|
|
569
|
-
const tool = libpqInvocation(opts.connectionString, env);
|
|
570
|
-
const args = buildPgDumpArgs({
|
|
571
|
-
connectionString: tool.connectionString,
|
|
572
|
-
outFile: localFile,
|
|
573
|
-
excludeSchemas: opts.excludeSchemas,
|
|
574
|
-
noOwner: opts.noOwner,
|
|
575
|
-
rowSecurity: opts.rowSecurity
|
|
576
|
-
});
|
|
577
|
-
const dumpEnv = { ...tool.env };
|
|
578
|
-
if (opts.rowSecurity) dumpEnv.PGOPTIONS = [env.PGOPTIONS, buildRowSecurityPgOptions(opts.rowSecurity)].filter(Boolean).join(" ");
|
|
579
|
-
try {
|
|
580
|
-
await execa(bin, args, {
|
|
581
|
-
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
582
|
-
env: dumpEnv
|
|
583
|
-
});
|
|
584
|
-
} catch (error) {
|
|
585
|
-
discardPartialDump(localFile);
|
|
586
|
-
const diagnosis = diagnoseRowSecurityDumpFailure(error);
|
|
587
|
-
if (!diagnosis) throw error;
|
|
588
|
-
throw new BackupToolError(diagnosis, "Run `rebase db backup --help` for the flag, and read what it says about partial dumps.");
|
|
589
|
-
}
|
|
590
|
-
const result = {
|
|
591
|
-
localFile,
|
|
592
|
-
fileName,
|
|
593
|
-
sizeBytes: fs.existsSync(localFile) ? fs.statSync(localFile).size : 0
|
|
594
|
-
};
|
|
595
|
-
if (opts.includeGlobals !== false) {
|
|
596
|
-
const dumpallBin = resolvePgBinary("pg_dumpall", env);
|
|
597
|
-
if (!dumpallBin) throw new BackupToolError("Could not find the 'pg_dumpall' binary needed to capture cluster roles.", "Install the PostgreSQL client tools or set PG_DUMPALL_PATH. To take a role-incomplete backup anyway, pass includeGlobals: false.");
|
|
598
|
-
const globalsFile = globalsFileForDump(localFile);
|
|
599
|
-
createOwnerOnly(globalsFile);
|
|
600
|
-
try {
|
|
601
|
-
await execa(dumpallBin, buildPgDumpallGlobalsArgs({
|
|
602
|
-
connectionString: tool.connectionString,
|
|
603
|
-
outFile: globalsFile
|
|
604
|
-
}), {
|
|
605
|
-
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
606
|
-
env: tool.env
|
|
607
|
-
});
|
|
608
|
-
} catch (error) {
|
|
609
|
-
discardPartialDump(globalsFile);
|
|
610
|
-
discardPartialDump(localFile);
|
|
611
|
-
throw error;
|
|
612
|
-
}
|
|
613
|
-
result.globalsFile = globalsFile;
|
|
614
|
-
result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
|
|
615
|
-
}
|
|
616
|
-
return result;
|
|
617
|
-
}
|
|
618
|
-
/**
|
|
619
|
-
* Create `file` empty and readable by its owner only, before a tool writes it.
|
|
620
|
-
*
|
|
621
|
-
* A dump holds every row, and the `.globals.sql` sidecar every role's password
|
|
622
|
-
* hash. pg_dump and pg_dumpall create their `--file=` target with the
|
|
623
|
-
* process's umask — 0644 on most machines, readable by every user — and keep
|
|
624
|
-
* the mode of a file that is already there. Created 0600 first, the file is
|
|
625
|
-
* never readable by anyone else, not even while the dump runs; chmod-ing it
|
|
626
|
-
* afterwards would leave it open for as long as that takes. A file that
|
|
627
|
-
* already existed is narrowed too.
|
|
628
|
-
*/
|
|
629
|
-
function createOwnerOnly(file) {
|
|
630
|
-
fs.closeSync(fs.openSync(file, "w", 384));
|
|
631
|
-
fs.chmodSync(file, 384);
|
|
632
|
-
}
|
|
633
|
-
/**
|
|
634
|
-
* Cheap integrity check on a freshly written dump: it must be non-empty and
|
|
635
|
-
* `pg_restore --list` must parse its table of contents without error. Used
|
|
636
|
-
* before pruning older backups so a corrupt-but-exit-0 dump never becomes
|
|
637
|
-
* the reason the last good backup is deleted.
|
|
638
|
-
*/
|
|
639
|
-
async function validateDump(localFile, env = process.env) {
|
|
640
|
-
if (!fs.existsSync(localFile)) return {
|
|
641
|
-
ok: false,
|
|
642
|
-
reason: `Dump file does not exist: ${localFile}`
|
|
643
|
-
};
|
|
644
|
-
if (fs.statSync(localFile).size === 0) return {
|
|
645
|
-
ok: false,
|
|
646
|
-
reason: "Dump file is empty (0 bytes)."
|
|
647
|
-
};
|
|
648
|
-
const bin = resolvePgBinary("pg_restore", env);
|
|
649
|
-
if (!bin) return {
|
|
650
|
-
ok: false,
|
|
651
|
-
reason: "Could not find 'pg_restore' to verify the dump."
|
|
652
|
-
};
|
|
653
|
-
try {
|
|
654
|
-
await execa(bin, buildPgRestoreListArgs(localFile), {
|
|
655
|
-
stdio: "pipe",
|
|
656
|
-
env: { ...env }
|
|
657
|
-
});
|
|
658
|
-
return { ok: true };
|
|
659
|
-
} catch (err) {
|
|
660
|
-
return {
|
|
661
|
-
ok: false,
|
|
662
|
-
reason: `pg_restore --list failed: ${err instanceof Error ? err.message : String(err)}`
|
|
663
|
-
};
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
/**
|
|
667
|
-
* Replay a `pg_dumpall --globals-only` script to recreate cluster roles
|
|
668
|
-
* before a restore, so the dump's GRANT/RLS statements (which reference
|
|
669
|
-
* `rebase_user` and any owner roles) actually apply. Runs statement by
|
|
670
|
-
* statement and tolerates per-statement failures — on a same-cluster restore
|
|
671
|
-
* the roles usually already exist (`CREATE ROLE` → "already exists"), and on
|
|
672
|
-
* a managed provider an `ALTER ROLE <superuser>` may be refused; neither
|
|
673
|
-
* should abort role recreation. Returns how many statements applied vs were
|
|
674
|
-
* skipped.
|
|
675
|
-
*/
|
|
676
|
-
async function applyGlobals(connectionString, globalsSql, log = () => {}) {
|
|
677
|
-
const { Client } = await import("pg");
|
|
678
|
-
const client = new Client({ connectionString });
|
|
679
|
-
await client.connect();
|
|
680
|
-
try {
|
|
681
|
-
return await applyGlobalsWith(async (sql) => {
|
|
682
|
-
await client.query(sql);
|
|
683
|
-
}, globalsSql, log);
|
|
684
|
-
} finally {
|
|
685
|
-
await client.end();
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* Restore a custom-format dump into the database named by
|
|
690
|
-
* `connectionString`. Destructive when `clean` is set (drops objects
|
|
691
|
-
* first). Never called automatically — the CLI gates it behind explicit
|
|
692
|
-
* confirmation.
|
|
693
|
-
*
|
|
694
|
-
* Runs with `--exit-on-error` by default: a restore that logs-and-continues
|
|
695
|
-
* past a failed GRANT (because a role was missing) reports success with RLS
|
|
696
|
-
* un-enforced. Callers should recreate roles first (see {@link applyGlobals})
|
|
697
|
-
* and only set `exitOnError: false` deliberately.
|
|
698
|
-
*/
|
|
699
|
-
async function restoreDump(opts) {
|
|
700
|
-
const env = opts.env ?? process.env;
|
|
701
|
-
const bin = resolvePgBinary("pg_restore", env);
|
|
702
|
-
if (!bin) throw new BackupToolError("Could not find the 'pg_restore' binary.", "Install the PostgreSQL client tools or set PG_RESTORE_PATH.");
|
|
703
|
-
const tool = libpqInvocation(opts.connectionString, env);
|
|
704
|
-
await execa(bin, buildPgRestoreArgs({
|
|
705
|
-
connectionString: tool.connectionString,
|
|
706
|
-
inputFile: opts.inputFile,
|
|
707
|
-
clean: opts.clean,
|
|
708
|
-
noOwner: opts.noOwner,
|
|
709
|
-
exitOnError: opts.exitOnError
|
|
710
|
-
}), {
|
|
711
|
-
stdio: opts.inheritStdio ? "inherit" : "pipe",
|
|
712
|
-
env: tool.env
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
/** The schemas and tables a dump creates, from `pg_restore --list`. See {@link parseDumpObjects}. */
|
|
716
|
-
async function readDumpObjects(inputFile, env = process.env) {
|
|
717
|
-
const bin = resolvePgBinary("pg_restore", env);
|
|
718
|
-
if (!bin) throw new BackupToolError("Could not find the 'pg_restore' binary.", "Install the PostgreSQL client tools or set PG_RESTORE_PATH.");
|
|
719
|
-
const toolEnv = Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0));
|
|
720
|
-
const { stdout } = await execa(bin, buildPgRestoreListArgs(inputFile), {
|
|
721
|
-
stdio: "pipe",
|
|
722
|
-
env: toolEnv
|
|
723
|
-
});
|
|
724
|
-
return parseDumpObjects(stdout);
|
|
725
|
-
}
|
|
726
|
-
/**
|
|
727
|
-
* Which of a dump's schemas and tables the target database already holds,
|
|
728
|
-
* as `schema <name>` / `table <schema>.<name>`: what a restore without
|
|
729
|
-
* `--clean` would stop on. `public` is left out of the schemas — every
|
|
730
|
-
* database has it, and pg_restore does not create it. None for a database
|
|
731
|
-
* that does not exist yet.
|
|
732
|
-
*/
|
|
733
|
-
async function findRestoreConflicts(connectionString, objects) {
|
|
734
|
-
const schemas = objects.schemas.filter((schema) => schema !== "public");
|
|
735
|
-
if (schemas.length === 0 && objects.tables.length === 0) return [];
|
|
736
|
-
const { Client } = await import("pg");
|
|
737
|
-
const client = new Client({ connectionString });
|
|
738
|
-
try {
|
|
739
|
-
await client.connect();
|
|
740
|
-
} catch (err) {
|
|
741
|
-
if (err instanceof Error && "code" in err && err.code === "3D000") return [];
|
|
742
|
-
throw err;
|
|
743
|
-
}
|
|
744
|
-
try {
|
|
745
|
-
const existingSchemas = await client.query("SELECT nspname FROM pg_namespace WHERE nspname = ANY($1::text[]) ORDER BY nspname", [schemas]);
|
|
746
|
-
const existingTables = await client.query(`SELECT n.nspname, c.relname
|
|
747
|
-
FROM pg_class c
|
|
748
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
749
|
-
JOIN unnest($1::text[], $2::text[]) AS d(schema_name, table_name)
|
|
750
|
-
ON d.schema_name = n.nspname AND d.table_name = c.relname
|
|
751
|
-
WHERE c.relkind IN ('r', 'p')
|
|
752
|
-
ORDER BY n.nspname, c.relname`, [objects.tables.map((t) => t.schema), objects.tables.map((t) => t.table)]);
|
|
753
|
-
return [...existingSchemas.rows.map((row) => `schema ${row.nspname}`), ...existingTables.rows.map((row) => `table ${row.nspname}.${row.relname}`)];
|
|
754
|
-
} finally {
|
|
755
|
-
await client.end();
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
/**
|
|
759
|
-
* Create a database (if absent) by connecting to the maintenance
|
|
760
|
-
* `postgres` database. Used by `restore --create-db`.
|
|
761
|
-
*/
|
|
762
|
-
async function ensureDatabaseExists(adminConnectionString, dbName) {
|
|
763
|
-
const { Client } = await import("pg");
|
|
764
|
-
const parsed = new URL(adminConnectionString);
|
|
765
|
-
parsed.pathname = "/postgres";
|
|
766
|
-
const client = new Client({ connectionString: parsed.toString() });
|
|
767
|
-
await client.connect();
|
|
768
|
-
try {
|
|
769
|
-
const existing = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
|
|
770
|
-
if (existing.rowCount && existing.rowCount > 0) return false;
|
|
771
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dbName)) throw new BackupToolError(`Invalid database name: "${dbName}"`);
|
|
772
|
-
await client.query(`CREATE DATABASE "${dbName}"`);
|
|
773
|
-
return true;
|
|
774
|
-
} finally {
|
|
775
|
-
await client.end();
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
/**
|
|
779
|
-
* Upload a local dump to object storage under the destination's prefix.
|
|
780
|
-
* Backups may contain secrets/PII, so the object is written with a
|
|
781
|
-
* private/octet-stream content type; bucket-level ACLs must stay private.
|
|
782
|
-
*/
|
|
783
|
-
async function uploadBackup(storage, localFile, dest) {
|
|
784
|
-
const fileName = path.basename(localFile);
|
|
785
|
-
const key = joinStorageKey(dest.prefix, fileName);
|
|
786
|
-
const file = new File([await fs.openAsBlob(localFile)], fileName, { type: "application/octet-stream" });
|
|
787
|
-
return {
|
|
788
|
-
key,
|
|
789
|
-
storageUrl: (await storage.putObject({
|
|
790
|
-
file,
|
|
791
|
-
key,
|
|
792
|
-
bucket: dest.bucket,
|
|
793
|
-
metadata: { "rebase-backup": "1" }
|
|
794
|
-
}, { uploadLimits: false })).storageUrl
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
/** Stats in flight at once while listing a local backup folder. */
|
|
798
|
-
var LOCAL_STAT_CONCURRENCY = 32;
|
|
799
|
-
/** `fn` over `items`, at most `limit` at a time; results in input order. */
|
|
800
|
-
async function mapBounded(items, limit, fn) {
|
|
801
|
-
const results = new Array(items.length);
|
|
802
|
-
let next = 0;
|
|
803
|
-
const worker = async () => {
|
|
804
|
-
while (next < items.length) {
|
|
805
|
-
const i = next++;
|
|
806
|
-
results[i] = await fn(items[i]);
|
|
807
|
-
}
|
|
808
|
-
};
|
|
809
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
810
|
-
return results;
|
|
811
|
-
}
|
|
812
|
-
/**
|
|
813
|
-
* List existing backups at a destination. For local destinations reads the
|
|
814
|
-
* directory; for object storage lists the prefix via the controller.
|
|
815
|
-
*/
|
|
816
|
-
async function listBackups(dest, storage) {
|
|
817
|
-
if (dest.kind === "local") {
|
|
818
|
-
const stat = await fs.promises.stat(dest.path).catch(() => null);
|
|
819
|
-
if (!stat) return [];
|
|
820
|
-
const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);
|
|
821
|
-
const dumps = (await fs.promises.readdir(dir)).filter((f) => f.endsWith(".dump"));
|
|
822
|
-
const stats = await mapBounded(dumps, LOCAL_STAT_CONCURRENCY, (f) => fs.promises.stat(path.join(dir, f)));
|
|
823
|
-
return dumps.map((f, i) => {
|
|
824
|
-
const createdAt = parseBackupTimestamp(f) ?? stats[i].mtime;
|
|
825
|
-
return {
|
|
826
|
-
key: path.join(dir, f),
|
|
827
|
-
createdAt,
|
|
828
|
-
sizeBytes: stats[i].size
|
|
829
|
-
};
|
|
830
|
-
}).sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
|
|
831
|
-
}
|
|
832
|
-
if (!storage) throw new BackupToolError(`Listing ${dest.kind} backups requires a configured storage backend.`);
|
|
833
|
-
const { listBackupObjects } = await import("@rebasepro/server");
|
|
834
|
-
return (await listBackupObjects(dest, storage)).map((info) => ({
|
|
835
|
-
key: info.key,
|
|
836
|
-
createdAt: info.createdAt ? new Date(info.createdAt) : void 0
|
|
837
|
-
}));
|
|
838
|
-
}
|
|
839
|
-
/**
|
|
840
|
-
* Apply a retention policy to a destination, deleting the backups selected
|
|
841
|
-
* by {@link selectBackupsToPrune}. Returns the keys that were removed.
|
|
842
|
-
*/
|
|
843
|
-
async function pruneBackups(dest, options, storage) {
|
|
844
|
-
const toDelete = selectBackupsToPrune(await listBackups(dest, storage), options);
|
|
845
|
-
await pruneWith(toDelete, dest.kind === "local" ? async (key) => {
|
|
846
|
-
if (!fs.existsSync(key)) throw new Error(`not found: ${key}`);
|
|
847
|
-
fs.unlinkSync(key);
|
|
848
|
-
} : async (key) => {
|
|
849
|
-
if (!storage) throw new BackupToolError("Storage backend required to prune object backups.");
|
|
850
|
-
await storage.deleteObject(key, dest.bucket);
|
|
851
|
-
});
|
|
852
|
-
return toDelete;
|
|
853
|
-
}
|
|
854
|
-
//#endregion
|
|
855
|
-
export { parseBackupDestination as A, buildPgRestoreArgs as C, diagnoseRowSecurityDumpFailure as D, checkToolServerCompatibility as E, resolveConnectionString as F, serverVersionNumToMajor as I, splitGlobalsStatements as L, parseDbNameFromUrl as M, parseDumpObjects as N, globalsFileForDump as O, parsePgToolMajor as P, withDatabaseName as R, buildPgDumpallGlobalsArgs as S, buildRowSecurityPgOptions as T, pgToolVariable as _, detectToolMajor as a, buildBackupFilename as b, findRestoreConflicts as c, preflight as d, pruneBackups as f, validateDump as g, uploadBackup as h, createDump as i, parseBackupTimestamp as j, joinStorageKey as k, getServerVersionMajor as l, restoreDump as m, applyGlobals as n, discardPartialDump as o, readDumpObjects as p, backup_service_exports as r, ensureDatabaseExists as s, BackupToolError as t, listBackups as u, resolvePgBinary as v, buildPgRestoreListArgs as w, buildPgDumpArgs as x, selectBackupsToPrune as y };
|
|
856
|
-
|
|
857
|
-
//# sourceMappingURL=backup-service-D-UpT7DH.js.map
|