@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.
Files changed (126) hide show
  1. package/dist/{BranchService-DNg4txyf.js → BranchService-ClklxyAQ.js} +283 -20
  2. package/dist/BranchService-ClklxyAQ.js.map +1 -0
  3. package/dist/backup/backup-cli.d.ts +70 -1
  4. package/dist/backup/backup-service.d.ts +79 -0
  5. package/dist/backup/pg-binary.d.ts +2 -2
  6. package/dist/backup/pg-tools.d.ts +60 -1
  7. package/dist/backup-cli-gijeXe0u.js +1961 -0
  8. package/dist/backup-cli-gijeXe0u.js.map +1 -0
  9. package/dist/{boot-ddl-Dnvpmm_T.js → boot-ddl-DYH95Qeg.js} +3 -3
  10. package/dist/{boot-ddl-Dnvpmm_T.js.map → boot-ddl-DYH95Qeg.js.map} +1 -1
  11. package/dist/branch-argv.d.ts +15 -0
  12. package/dist/branch-prune.d.ts +18 -0
  13. package/dist/{cli-console-C_ISGwJo.js → cli-console-CQrQ0lAY.js} +19 -13
  14. package/dist/cli-console-CQrQ0lAY.js.map +1 -0
  15. package/dist/cli-console.d.ts +4 -3
  16. package/dist/{cli-errors-mx4sSjB8.js → cli-errors-DiA0XFIS.js} +110 -14
  17. package/dist/{cli-errors-mx4sSjB8.js.map → cli-errors-DiA0XFIS.js.map} +1 -1
  18. package/dist/cli-errors.d.ts +26 -1
  19. package/dist/cli-flags.d.ts +51 -0
  20. package/dist/{cli-prompt-egtNKkZl.js → cli-prompt-DXG0yk-h.js} +39 -4
  21. package/dist/{cli-prompt-egtNKkZl.js.map → cli-prompt-DXG0yk-h.js.map} +1 -1
  22. package/dist/cli.js +120 -184
  23. package/dist/cli.js.map +1 -1
  24. package/dist/collections/validate-relations.d.ts +8 -2
  25. package/dist/{column-plan-helpers-D_V4oaZh.js → column-plan-helpers-IG6uFb1s.js} +2 -2
  26. package/dist/{column-plan-helpers-D_V4oaZh.js.map → column-plan-helpers-IG6uFb1s.js.map} +1 -1
  27. package/dist/{connection-C938nR3E.js → connection-czvI7TZL.js} +57 -10
  28. package/dist/connection-czvI7TZL.js.map +1 -0
  29. package/dist/{databasePoolManager-Yqdw14wY.js → databasePoolManager-3stMNFL-.js} +4 -6
  30. package/dist/databasePoolManager-3stMNFL-.js.map +1 -0
  31. package/dist/db-command-line.d.ts +14 -0
  32. package/dist/{rls-bootstrap-sql-Bd-hJk08.js → db-subcommands-BPoHCxJR.js} +455 -3
  33. package/dist/db-subcommands-BPoHCxJR.js.map +1 -0
  34. package/dist/db-subcommands.d.ts +11 -0
  35. package/dist/{deferred-index-build-Ca6OqKyR.js → deferred-index-build-BE0I6sYU.js} +8 -8
  36. package/dist/{deferred-index-build-Ca6OqKyR.js.map → deferred-index-build-BE0I6sYU.js.map} +1 -1
  37. package/dist/{doctor-CJyuhV1n.js → doctor-BT--1kq3.js} +107 -11
  38. package/dist/doctor-BT--1kq3.js.map +1 -0
  39. package/dist/{ensure-collection-policies-C1ux3V_z.js → ensure-collection-policies-BnGOURpG.js} +60 -11
  40. package/dist/ensure-collection-policies-BnGOURpG.js.map +1 -0
  41. package/dist/{ensure-collection-tables-Do-Bvb64.js → ensure-collection-tables-Cet4650m.js} +9 -9
  42. package/dist/{ensure-collection-tables-Do-Bvb64.js.map → ensure-collection-tables-Cet4650m.js.map} +1 -1
  43. package/dist/{ensure-tables-Dl_Eh9ow.js → ensure-tables-l_OgFQjN.js} +5 -5
  44. package/dist/{ensure-tables-Dl_Eh9ow.js.map → ensure-tables-l_OgFQjN.js.map} +1 -1
  45. package/dist/{enum-labels-kept-D9eTIWCz.js → enum-labels-kept-nheku1pB.js} +3 -3
  46. package/dist/{enum-labels-kept-D9eTIWCz.js.map → enum-labels-kept-nheku1pB.js.map} +1 -1
  47. package/dist/{generate-drizzle-schema-Bf8canaR.js → generate-drizzle-schema-DC0BOUpn.js} +3 -3
  48. package/dist/{generate-drizzle-schema-Bf8canaR.js.map → generate-drizzle-schema-DC0BOUpn.js.map} +1 -1
  49. package/dist/{generate-drizzle-schema-logic-BaT0H7Gq.js → generate-drizzle-schema-logic-CPzCRD-8.js} +4 -4
  50. package/dist/{generate-drizzle-schema-logic-BaT0H7Gq.js.map → generate-drizzle-schema-logic-CPzCRD-8.js.map} +1 -1
  51. package/dist/{generate-files-D50Pxw1G.js → generate-files-BP-4W_gm.js} +6 -6
  52. package/dist/{generate-files-D50Pxw1G.js.map → generate-files-BP-4W_gm.js.map} +1 -1
  53. package/dist/{generate-next-step-DCd7yiJ2.js → generate-next-step-DgK5BlBn.js} +22 -14
  54. package/dist/{generate-next-step-DCd7yiJ2.js.map → generate-next-step-DgK5BlBn.js.map} +1 -1
  55. package/dist/{generate-postgres-ddl-logic-B4k9djAV.js → generate-postgres-ddl-logic-DPl1oH_z.js} +4 -4
  56. package/dist/{generate-postgres-ddl-logic-B4k9djAV.js.map → generate-postgres-ddl-logic-DPl1oH_z.js.map} +1 -1
  57. package/dist/generated-schema-diff-eZCklRZQ.js +1025 -0
  58. package/dist/generated-schema-diff-eZCklRZQ.js.map +1 -0
  59. package/dist/{generated-schema-staleness-FlxM2i4i.js → generated-schema-staleness-BvNF3i-s.js} +2 -2
  60. package/dist/{generated-schema-staleness-FlxM2i4i.js.map → generated-schema-staleness-BvNF3i-s.js.map} +1 -1
  61. package/dist/index.d.ts +1 -0
  62. package/dist/index.es.js +70 -11
  63. package/dist/index.es.js.map +1 -1
  64. package/dist/{introspect-db-logic-j2vYhmdP.js → introspect-db-logic-DgQVzccW.js} +2 -2
  65. package/dist/{introspect-db-logic-j2vYhmdP.js.map → introspect-db-logic-DgQVzccW.js.map} +1 -1
  66. package/dist/migrate-policy-stamps-3eqfhes6.js +89 -0
  67. package/dist/migrate-policy-stamps-3eqfhes6.js.map +1 -0
  68. package/dist/{open-rebase-D8rytTP0.js → open-rebase-EmqVmS14.js} +78 -1027
  69. package/dist/open-rebase-EmqVmS14.js.map +1 -0
  70. package/dist/{pg-error-utils-CdsGYMTm.js → pg-error-utils-CFEnm2m9.js} +11 -4
  71. package/dist/pg-error-utils-CFEnm2m9.js.map +1 -0
  72. package/dist/{plan-schema-CsNgK0qi.js → plan-schema-Czf3q4-Q.js} +4 -4
  73. package/dist/{plan-schema-CsNgK0qi.js.map → plan-schema-Czf3q4-Q.js.map} +1 -1
  74. package/dist/{policy-drift-CY5P1ZRu.js → policy-drift-X4A-xAzv.js} +3 -3
  75. package/dist/{policy-drift-CY5P1ZRu.js.map → policy-drift-X4A-xAzv.js.map} +1 -1
  76. package/dist/pool-disconnects.d.ts +28 -0
  77. package/dist/{push-index-prebuild-BDFcfRPo.js → push-index-prebuild-COd7i6wm.js} +4 -4
  78. package/dist/{push-index-prebuild-BDFcfRPo.js.map → push-index-prebuild-COd7i6wm.js.map} +1 -1
  79. package/dist/{push-legacy-renames-D4bvMWAb.js → push-legacy-renames-CLgYYwPE.js} +4 -4
  80. package/dist/{push-legacy-renames-D4bvMWAb.js.map → push-legacy-renames-CLgYYwPE.js.map} +1 -1
  81. package/dist/{push-policy-plan-DTXE6aVa.js → push-policy-plan-D2gpQsdv.js} +23 -7
  82. package/dist/push-policy-plan-D2gpQsdv.js.map +1 -0
  83. package/dist/{render-ddl-CrfEe__B.js → render-ddl-4v88y-Gv.js} +4 -4
  84. package/dist/{render-ddl-CrfEe__B.js.map → render-ddl-4v88y-Gv.js.map} +1 -1
  85. package/dist/{rls-enforcement-D7P4-ZYX.js → rls-enforcement-DDyNoOSy.js} +2 -2
  86. package/dist/{rls-enforcement-D7P4-ZYX.js.map → rls-enforcement-DDyNoOSy.js.map} +1 -1
  87. package/dist/schema/atlas-argv.d.ts +3 -1
  88. package/dist/schema/doctor-cli.js +5 -5
  89. package/dist/schema/doctor.d.ts +20 -2
  90. package/dist/schema/ensure-collection-policies.d.ts +28 -1
  91. package/dist/schema/generate-drizzle-schema.js +1 -1
  92. package/dist/schema/generate-next-step.d.ts +20 -8
  93. package/dist/schema/generate-postgres-ddl.js +2 -2
  94. package/dist/schema/generated-schema-diff.d.ts +21 -0
  95. package/dist/schema/introspect-db.js +6 -6
  96. package/dist/schema/introspect-db.js.map +1 -1
  97. package/dist/schema/migrate-policy-stamps.d.ts +23 -0
  98. package/dist/schema/push-policy-plan.d.ts +10 -2
  99. package/dist/services/BranchService.d.ts +85 -0
  100. package/dist/services/pg-notify-listener.d.ts +2 -0
  101. package/dist/{source-DB6xJv3p.js → source-fZb92pN8.js} +46 -3
  102. package/dist/source-fZb92pN8.js.map +1 -0
  103. package/dist/{vector-index-BFhyEaiz.js → vector-index-DsQ3V2d2.js} +2 -2
  104. package/dist/{vector-index-BFhyEaiz.js.map → vector-index-DsQ3V2d2.js.map} +1 -1
  105. package/dist/{websocket-DMeXOoyv.js → websocket-BKZwsjmf.js} +18 -5
  106. package/dist/websocket-BKZwsjmf.js.map +1 -0
  107. package/package.json +7 -7
  108. package/dist/BranchService-DNg4txyf.js.map +0 -1
  109. package/dist/backup-cli-HCEkPcQj.js +0 -556
  110. package/dist/backup-cli-HCEkPcQj.js.map +0 -1
  111. package/dist/backup-service-D-UpT7DH.js +0 -857
  112. package/dist/backup-service-D-UpT7DH.js.map +0 -1
  113. package/dist/cli-console-C_ISGwJo.js.map +0 -1
  114. package/dist/cli-flags-DUozBFyK.js +0 -205
  115. package/dist/cli-flags-DUozBFyK.js.map +0 -1
  116. package/dist/connection-C938nR3E.js.map +0 -1
  117. package/dist/databasePoolManager-Yqdw14wY.js.map +0 -1
  118. package/dist/doctor-CJyuhV1n.js.map +0 -1
  119. package/dist/ensure-collection-policies-C1ux3V_z.js.map +0 -1
  120. package/dist/open-rebase-D8rytTP0.js.map +0 -1
  121. package/dist/pg-error-utils-CdsGYMTm.js.map +0 -1
  122. package/dist/push-policy-plan-DTXE6aVa.js.map +0 -1
  123. package/dist/rls-bootstrap-sql-Bd-hJk08.js.map +0 -1
  124. package/dist/rolldown-runtime-V7rFbMRe.js +0 -39
  125. package/dist/source-DB6xJv3p.js.map +0 -1
  126. package/dist/websocket-DMeXOoyv.js.map +0 -1
@@ -0,0 +1,1961 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ __createRequire(import.meta.url);
3
+ import { i as __exportAll, t as promptConfirm } from "./cli-prompt-DXG0yk-h.js";
4
+ import { $ as forLibpq, et as redactConnectionPasswords, t as checkDatabaseConnectivity, tt as splitPassword, z as resolveLocalBin } from "./cli-errors-DiA0XFIS.js";
5
+ import { a as outWarn, i as outError, r as out, t as chalk } from "./source-fZb92pN8.js";
6
+ import { d as parseCommandFlags, l as closestWord, y as execa } from "./db-subcommands-BPoHCxJR.js";
7
+ import path from "path";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import { Readable } from "stream";
11
+ import { pipeline } from "stream/promises";
12
+ //#region src/backup/pg-tools.ts
13
+ /**
14
+ * Pure helpers for the backup/restore commands.
15
+ *
16
+ * Everything in this file is side-effect free so it can be unit-tested
17
+ * without a live Postgres server, matching the constraint that CI must
18
+ * not require a database.
19
+ */
20
+ /**
21
+ * Extract the database name from a Postgres connection string.
22
+ * Returns `null` when the URL has no database path (e.g. bare host).
23
+ */
24
+ function parseDbNameFromUrl(connectionString) {
25
+ try {
26
+ const name = new URL(connectionString).pathname.replace(/^\//, "").trim();
27
+ return name.length > 0 ? name : null;
28
+ } catch {
29
+ const match = connectionString.match(/\/([^/?]+)(\?|$)/);
30
+ return match && match[1] ? match[1] : null;
31
+ }
32
+ }
33
+ /**
34
+ * Swap the database name in a connection string, preserving credentials,
35
+ * host, port and query params. Used by `--target-db` / `--create-db` so a
36
+ * restore can target a fresh database instead of clobbering the live one.
37
+ */
38
+ function withDatabaseName(connectionString, dbName) {
39
+ try {
40
+ const parsed = new URL(connectionString);
41
+ parsed.pathname = `/${dbName}`;
42
+ return parsed.toString();
43
+ } catch {
44
+ return connectionString.replace(/\/([^/?]+)(\?|$)/, `/${dbName}$2`);
45
+ }
46
+ }
47
+ /**
48
+ * Parse the major version out of `pg_dump --version` / `pg_restore --version`
49
+ * output, e.g. `"pg_dump (PostgreSQL) 16.2"` → `16`. Handles the pre-10
50
+ * `9.6.x` scheme (returns `9`) and beta strings like `"17beta1"`.
51
+ */
52
+ function parsePgToolMajor(versionOutput) {
53
+ const match = versionOutput.match(/(\d+)(?:\.(\d+))?/);
54
+ if (!match) return null;
55
+ const major = Number(match[1]);
56
+ if (!Number.isFinite(major)) return null;
57
+ if (major === 9 && match[2] !== void 0) return 9;
58
+ return major;
59
+ }
60
+ /**
61
+ * Convert `SELECT current_setting('server_version_num')` (e.g. `160002`)
62
+ * into a major version (`16`). Also accepts pre-10 encodings like `90603`
63
+ * → `9`.
64
+ */
65
+ function serverVersionNumToMajor(versionNum) {
66
+ const num = typeof versionNum === "string" ? Number(versionNum) : versionNum;
67
+ if (!Number.isFinite(num) || num <= 0) return null;
68
+ if (num < 1e5) return Math.floor(num / 1e4);
69
+ return Math.floor(num / 1e4);
70
+ }
71
+ /**
72
+ * pg_dump / pg_restore must be **at least** as new as the server they talk
73
+ * to. A newer client against an older server is supported; an older client
74
+ * against a newer server is not and produces corrupt or rejected output.
75
+ */
76
+ function checkToolServerCompatibility(toolMajor, serverMajor) {
77
+ if (toolMajor === null) return {
78
+ compatible: false,
79
+ reason: "Could not determine the client tool version."
80
+ };
81
+ if (serverMajor === null) return {
82
+ compatible: false,
83
+ reason: "Could not determine the Postgres server version."
84
+ };
85
+ if (toolMajor < serverMajor) return {
86
+ compatible: false,
87
+ 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.`
88
+ };
89
+ return { compatible: true };
90
+ }
91
+ /**
92
+ * Build a deterministic, sortable backup file name:
93
+ * `rebase-<db>-<YYYYMMDD>T<HHMMSS>Z.dump`
94
+ *
95
+ * The UTC timestamp is embedded so retention pruning can recover the
96
+ * creation time from the object key alone, without extra metadata.
97
+ */
98
+ function buildBackupFilename(dbName, date = /* @__PURE__ */ new Date()) {
99
+ const stamp = date.toISOString().replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
100
+ return `rebase-${dbName.replace(/[^a-zA-Z0-9_-]/g, "_")}-${stamp}.dump`;
101
+ }
102
+ /**
103
+ * Recover the creation timestamp encoded in a backup file name by
104
+ * {@link buildBackupFilename}. Returns `null` for names that don't match,
105
+ * so foreign objects in a shared prefix are never pruned.
106
+ */
107
+ function parseBackupTimestamp(fileName) {
108
+ const match = (fileName.split("/").pop() ?? fileName).match(/-(\d{8})T(\d{6})Z\.dump$/);
109
+ if (!match) return null;
110
+ const [, ymd, hms] = match;
111
+ 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`;
112
+ const date = new Date(iso);
113
+ return Number.isNaN(date.getTime()) ? null : date;
114
+ }
115
+ /**
116
+ * Parse a destination string into a structured {@link BackupDestination}.
117
+ * `s3://bucket/prefix` and `gs://bucket/prefix` map to object storage;
118
+ * anything else is treated as a local path.
119
+ */
120
+ function parseBackupDestination(out) {
121
+ const s3 = out.match(/^s3:\/\/([^/]+)\/?(.*)$/);
122
+ if (s3) return {
123
+ kind: "s3",
124
+ bucket: s3[1],
125
+ prefix: stripTrailingSlash(s3[2])
126
+ };
127
+ const gcs = out.match(/^gs:\/\/([^/]+)\/?(.*)$/);
128
+ if (gcs) return {
129
+ kind: "gcs",
130
+ bucket: gcs[1],
131
+ prefix: stripTrailingSlash(gcs[2])
132
+ };
133
+ return {
134
+ kind: "local",
135
+ path: out
136
+ };
137
+ }
138
+ function stripTrailingSlash(s) {
139
+ return s.replace(/\/+$/, "");
140
+ }
141
+ /**
142
+ * Join a storage prefix and a file name without producing a leading or
143
+ * doubled slash.
144
+ */
145
+ function joinStorageKey(prefix, fileName) {
146
+ const clean = prefix.replace(/^\/+|\/+$/g, "");
147
+ return clean.length > 0 ? `${clean}/${fileName}` : fileName;
148
+ }
149
+ /**
150
+ * `PGOPTIONS` carrying an identity, for a libpq tool that has no other way to
151
+ * set a GUC.
152
+ *
153
+ * A backslash escape rather than quoting, which is what libpq's `-c` parsing
154
+ * takes: a space inside a value ends the option otherwise, so a role list is
155
+ * comma-joined and never spaced.
156
+ */
157
+ function buildRowSecurityPgOptions(identity) {
158
+ const escape = (value) => value.replace(/([\\ ])/g, "\\$1");
159
+ return [
160
+ `-c app.uid=${escape(identity.uid)}`,
161
+ `-c app.user_id=${escape(identity.uid)}`,
162
+ `-c app.user_roles=${escape(identity.roles.join(","))}`
163
+ ].join(" ");
164
+ }
165
+ /**
166
+ * Assemble the `pg_dump` argument vector. Uses the custom format (`-Fc`),
167
+ * which is compressed and restorable selectively via `pg_restore`.
168
+ */
169
+ function buildPgDumpArgs(opts) {
170
+ const args = [
171
+ "--format=custom",
172
+ "--no-password",
173
+ `--file=${opts.outFile}`
174
+ ];
175
+ if (opts.noOwner) args.push("--no-owner");
176
+ if (opts.rowSecurity) args.push("--enable-row-security");
177
+ for (const schema of opts.excludeSchemas ?? []) args.push(`--exclude-schema=${schema}`);
178
+ args.push(forLibpq(opts.connectionString));
179
+ return args;
180
+ }
181
+ /**
182
+ * Whether a `pg_dump` failure is the row-security one, and what to do about it.
183
+ *
184
+ * The error text names the table and nothing else, so the first read of it is
185
+ * "why would a backup be affected by RLS at all?" — the answer being that the
186
+ * dumping role is not the tables' owner and has no `BYPASSRLS`, which is the
187
+ * normal state of the `postgres` user on Cloud SQL, RDS and every other managed
188
+ * Postgres. Nothing about that is visible from the message.
189
+ *
190
+ * Returns `null` for any other failure, so the caller reports it unchanged.
191
+ */
192
+ function diagnoseRowSecurityDumpFailure(error) {
193
+ const text = [error?.stderr, error?.message].map((part) => typeof part === "string" ? part : "").join("\n");
194
+ if (!/row-level security policy/i.test(text)) return null;
195
+ const table = text.match(/for table "([^"]+)"/)?.[1];
196
+ return [
197
+ `pg_dump cannot read ${table ? `"${table}"` : "one of the tables"} because row-level security applies to it.`,
198
+ "",
199
+ " The dumping role is neither the table's owner nor `BYPASSRLS`, which is the normal",
200
+ " state of the `postgres` user on Cloud SQL, RDS and other managed Postgres — there is",
201
+ " no superuser to hand out.",
202
+ "",
203
+ " Two ways out:",
204
+ "",
205
+ " • Grant the dumping role BYPASSRLS, or make it the owner, and run this again. The",
206
+ " dump then contains every row, which is what a backup should mean.",
207
+ "",
208
+ " • Re-run with --enable-row-security to dump as an admin subject instead. Rebase",
209
+ " sets `app.uid`/`app.user_roles` so the generated `admin_full_access` policy",
210
+ " admits the dump. Read the warning it prints: the result contains exactly the",
211
+ " rows those policies admit, and any table whose policies do not include an",
212
+ " admin rule comes out short — with no error.",
213
+ "",
214
+ " Do not reach for a bare `pg_dump --enable-row-security` by hand. Without the",
215
+ " settings above it succeeds and silently omits rows."
216
+ ].join("\n");
217
+ }
218
+ /**
219
+ * Assemble the `pg_restore` argument vector for a custom-format dump.
220
+ */
221
+ function buildPgRestoreArgs(opts) {
222
+ const args = [
223
+ "--format=custom",
224
+ "--no-password",
225
+ `--dbname=${forLibpq(opts.connectionString)}`
226
+ ];
227
+ if (opts.clean) args.push("--clean", "--if-exists");
228
+ if (opts.noOwner) args.push("--no-owner");
229
+ if (opts.exitOnError !== false) args.push("--exit-on-error");
230
+ args.push(opts.inputFile);
231
+ return args;
232
+ }
233
+ /**
234
+ * Assemble the `pg_restore --list` argument vector. Reading a dump's table
235
+ * of contents parses the whole archive without touching a database, so it is
236
+ * a cheap integrity check that the file isn't truncated or corrupt.
237
+ */
238
+ function buildPgRestoreListArgs(inputFile) {
239
+ return ["--list", inputFile];
240
+ }
241
+ /** `<id>; <catalog oid> <oid> <description> <namespace> <tag…> <owner>`, the shape of a TOC entry. */
242
+ var TOC_ENTRY = /^\d+;\s+\d+\s+\d+\s+(SCHEMA|TABLE)\s+(?!DATA\s)(\S+)\s+(.+)\s+(\S+)$/;
243
+ /** A TOC entry for a relation of any kind. `TABLE DATA`, `SEQUENCE SET` and `SEQUENCE OWNED BY` are not relations. */
244
+ var TOC_RELATION = /^\d+;\s+\d+\s+\d+\s+(?:FOREIGN TABLE|MATERIALIZED VIEW|TABLE|VIEW|SEQUENCE)\s+(?!DATA\s|SET\s|OWNED BY\s)(\S+)\s+(.+)\s+(\S+)$/;
245
+ /** A function's TOC entry: its tag is the name and the argument types, `two(integer, text)`. */
246
+ var TOC_ROUTINE = /^\d+;\s+\d+\s+\d+\s+(?:FUNCTION|PROCEDURE|AGGREGATE)\s+(\S+)\s+(.+?)\(.*\)\s+\S+$/;
247
+ /** A type's or a domain's TOC entry. */
248
+ var TOC_TYPE = /^\d+;\s+\d+\s+\d+\s+(?:TYPE|DOMAIN)\s+(\S+)\s+(.+)\s+(\S+)$/;
249
+ /**
250
+ * The schemas and tables in `pg_restore --list` output: the objects a
251
+ * restore without `--clean` fails on when the target already has them.
252
+ *
253
+ * A TOC line ends in the object's owner, after its namespace and its name;
254
+ * a schema's namespace is `-`. The name is what lies between the two, so a
255
+ * name with a space in it (`Odd Name`) reads whole. `TABLE DATA` and every
256
+ * other kind of entry are not objects of their own here. Its relations,
257
+ * functions and types are read too, for what `restore --clean` would drop
258
+ * that the dump does not hold (`findCleanExtras`).
259
+ */
260
+ function parseDumpObjects(list) {
261
+ const objects = {
262
+ schemas: [],
263
+ tables: [],
264
+ relations: [],
265
+ routines: [],
266
+ types: []
267
+ };
268
+ for (const line of list.split("\n")) {
269
+ const trimmed = line.trim();
270
+ const relation = TOC_RELATION.exec(trimmed);
271
+ if (relation) objects.relations.push({
272
+ schema: relation[1],
273
+ name: relation[2]
274
+ });
275
+ const routine = TOC_ROUTINE.exec(trimmed);
276
+ if (routine) objects.routines.push({
277
+ schema: routine[1],
278
+ name: routine[2]
279
+ });
280
+ const type = TOC_TYPE.exec(trimmed);
281
+ if (type) objects.types.push({
282
+ schema: type[1],
283
+ name: type[2]
284
+ });
285
+ const entry = TOC_ENTRY.exec(trimmed);
286
+ if (!entry) continue;
287
+ const [, kind, namespace, name] = entry;
288
+ if (kind === "SCHEMA") objects.schemas.push(name);
289
+ else objects.tables.push({
290
+ schema: namespace,
291
+ table: name
292
+ });
293
+ }
294
+ return objects;
295
+ }
296
+ /** A Postgres identifier, double-quoted. */
297
+ function quoteIdentifier(name) {
298
+ return `"${name.replace(/"/g, "\"\"")}"`;
299
+ }
300
+ /**
301
+ * How long `restore --clean` waits for a lock before it gives up.
302
+ *
303
+ * A `rebase dev` serving from the database holds connections, and one inside a
304
+ * transaction would otherwise make the restore wait without a word. The dump's
305
+ * own script sets `lock_timeout = 0` after this, for the objects it creates.
306
+ */
307
+ var CLEAN_LOCK_TIMEOUT = "15s";
308
+ /**
309
+ * What `restore --clean` runs first, in the transaction the restore runs in:
310
+ * every schema the dump creates, dropped with `CASCADE`, and every table it
311
+ * creates in a schema it does not (`public`), the same.
312
+ *
313
+ * `CASCADE`, which `pg_restore --clean` never uses: a table the backup lacks
314
+ * in one of its schemas — `rebase.atlas_schema_revisions` from `rebase db
315
+ * migrate`, a table a newer runtime added — is exactly what stopped its plain
316
+ * `DROP SCHEMA rebase`, and a table that references one of the backup's is what
317
+ * stops a plain `DROP TABLE`. The dump's script follows, with its own `DROP …
318
+ * IF EXISTS` for everything else it creates.
319
+ */
320
+ function cleanRestorePrelude(objects) {
321
+ const schemas = objects.schemas.filter((schema) => schema !== "public");
322
+ const dropped = new Set(schemas);
323
+ const tables = objects.tables.filter((table) => !dropped.has(table.schema));
324
+ return [
325
+ "SET client_min_messages = warning",
326
+ `SET lock_timeout = '15s'`,
327
+ ...schemas.map((schema) => `DROP SCHEMA IF EXISTS ${quoteIdentifier(schema)} CASCADE`),
328
+ ...tables.map((table) => `DROP TABLE IF EXISTS ${quoteIdentifier(table.schema)}.${quoteIdentifier(table.table)} CASCADE`)
329
+ ];
330
+ }
331
+ /**
332
+ * `pg_restore` writing the dump as a SQL script on stdout, with the `DROP …
333
+ * IF EXISTS` that `--clean` adds, for `psql` to run. See {@link buildPsqlScriptArgs}.
334
+ */
335
+ function buildPgRestoreScriptArgs(opts) {
336
+ return [
337
+ "--format=custom",
338
+ "--clean",
339
+ "--if-exists",
340
+ ...opts.noOwner ? ["--no-owner"] : [],
341
+ "--file=-",
342
+ opts.inputFile
343
+ ];
344
+ }
345
+ /**
346
+ * `psql` running a script read from stdin, stopping at its first error.
347
+ *
348
+ * `-X`, so no `.psqlrc` changes what runs; `-q`, so it prints errors and
349
+ * warnings, not a command tag per statement. The transaction is the script's
350
+ * own: `restore --clean` writes the `BEGIN`, and the `COMMIT` only once
351
+ * `pg_restore` has written the whole dump.
352
+ */
353
+ function buildPsqlScriptArgs(connectionString) {
354
+ return [
355
+ "-X",
356
+ "-q",
357
+ "--no-password",
358
+ "-v",
359
+ "ON_ERROR_STOP=1",
360
+ `--dbname=${forLibpq(connectionString)}`,
361
+ "--file=-"
362
+ ];
363
+ }
364
+ /**
365
+ * Assemble the `pg_dumpall --globals-only` argument vector. Roles (and other
366
+ * cluster-wide objects) live outside any single database, so a per-database
367
+ * `pg_dump` omits them. Without the `rebase_user` role the RLS GRANT
368
+ * statements in the main dump fail on restore and RLS is silently lost — so
369
+ * every backup captures the globals into a sidecar `.globals.sql`.
370
+ *
371
+ * `--no-role-passwords` keeps role secrets out of the artifact (backups may
372
+ * be shipped off-box); roles are recreated password-less and re-secured by
373
+ * the operator.
374
+ */
375
+ function buildPgDumpallGlobalsArgs(opts) {
376
+ return [
377
+ "--globals-only",
378
+ "--no-role-passwords",
379
+ "--no-password",
380
+ `--file=${opts.outFile}`,
381
+ `--dbname=${forLibpq(opts.connectionString)}`
382
+ ];
383
+ }
384
+ /**
385
+ * Derive the globals sidecar path/key for a given `.dump` file. Keeps the
386
+ * two artifacts adjacent so listing, uploading and pruning can find one from
387
+ * the other. A name that doesn't end in `.dump` is returned unchanged with a
388
+ * `.globals.sql` suffix appended.
389
+ */
390
+ function globalsFileForDump(dumpPath) {
391
+ return dumpPath.endsWith(".dump") ? dumpPath.slice(0, -5) + ".globals.sql" : dumpPath + ".globals.sql";
392
+ }
393
+ /**
394
+ * Split a `pg_dumpall --globals-only` script into individual statements.
395
+ * Used when replaying globals on restore so each `CREATE ROLE` / `GRANT`
396
+ * can run independently and a benign "role already exists" on one doesn't
397
+ * abort the rest. Drops `--` comment lines and blank statements.
398
+ */
399
+ function splitGlobalsStatements(sql) {
400
+ return sql.split("\n").filter((line) => {
401
+ const trimmed = line.trim();
402
+ return !trimmed.startsWith("--") && !trimmed.startsWith("\\");
403
+ }).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
404
+ }
405
+ /**
406
+ * Resolve the Postgres connection string the backup commands should use,
407
+ * mirroring the precedence the branch command already relies on.
408
+ */
409
+ function resolveConnectionString(env) {
410
+ return env.DATABASE_URL || env.ADMIN_CONNECTION_STRING || null;
411
+ }
412
+ //#endregion
413
+ //#region src/backup/retention.ts
414
+ /**
415
+ * Retention pruning for scheduled backups. Pure logic — no I/O — so it can
416
+ * be exhaustively unit-tested without touching a storage backend.
417
+ */
418
+ /**
419
+ * Decide which backups to delete under the given retention policy.
420
+ *
421
+ * Rules, applied in order:
422
+ * 1. Objects whose key isn't a recognised rebase backup (no parseable
423
+ * timestamp and no `createdAt`) are never selected — foreign files in a
424
+ * shared bucket are left untouched.
425
+ * 2. The `keepMinimum` newest backups are always retained.
426
+ * 3. Of the remainder, any older than `retentionDays` are selected for
427
+ * deletion.
428
+ *
429
+ * Returns the keys to delete, oldest first.
430
+ */
431
+ function selectBackupsToPrune(backups, options) {
432
+ const retentionDays = options.retentionDays ?? 0;
433
+ const keepMinimum = Math.max(0, options.keepMinimum ?? 0);
434
+ const now = options.now ?? /* @__PURE__ */ new Date();
435
+ if (retentionDays <= 0) return [];
436
+ const dated = backups.map((b) => {
437
+ const createdAt = b.createdAt ?? parseBackupTimestamp(b.key);
438
+ return createdAt ? {
439
+ key: b.key,
440
+ createdAt
441
+ } : null;
442
+ }).filter((b) => b !== null);
443
+ dated.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
444
+ const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1e3;
445
+ const toDelete = dated.slice(keepMinimum).filter((b) => b.createdAt.getTime() < cutoff);
446
+ toDelete.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
447
+ return toDelete.map((b) => b.key);
448
+ }
449
+ //#endregion
450
+ //#region src/backup/pg-binary.ts
451
+ /**
452
+ * Where the PostgreSQL client tools are, without spawning them.
453
+ *
454
+ * Kept apart from `backup-service.ts`, which pulls in `execa` (ESM-only), so
455
+ * `createBackupCron` can check for the tools when it is registered.
456
+ */
457
+ var OVERRIDE_VARIABLE = {
458
+ pg_dump: "PG_DUMP_PATH",
459
+ pg_restore: "PG_RESTORE_PATH",
460
+ pg_dumpall: "PG_DUMPALL_PATH",
461
+ psql: "PSQL_PATH"
462
+ };
463
+ /** The variable that points at `tool` when it is not on the PATH. */
464
+ function pgToolVariable(tool) {
465
+ return OVERRIDE_VARIABLE[tool];
466
+ }
467
+ /** Locate `pg_dump` / `pg_restore` / `pg_dumpall` / `psql`, honouring an env override. */
468
+ function resolvePgBinary(tool, env = process.env) {
469
+ const override = env[OVERRIDE_VARIABLE[tool]];
470
+ if (override && fs.existsSync(override)) return override;
471
+ return resolveLocalBin(tool);
472
+ }
473
+ //#endregion
474
+ //#region src/backup/backup-logic.ts
475
+ /**
476
+ * Pure-ish orchestration for the backup/restore paths, kept free of `execa`
477
+ * and `pg` value imports so it can be unit-tested under jest (the impure
478
+ * edges — spawning processes, opening connections — live in
479
+ * `backup-service.ts`, which is vitest/runtime only).
480
+ *
481
+ * The functions here take their side-effecting dependency as an argument
482
+ * (a statement runner, an object deleter) so tests can inject fakes.
483
+ */
484
+ /**
485
+ * Replay a `pg_dumpall --globals-only` script one statement at a time,
486
+ * tolerating per-statement failures. On a same-cluster restore the roles
487
+ * usually already exist (`CREATE ROLE` → "already exists") and on a managed
488
+ * provider an `ALTER ROLE <superuser>` may be refused; neither should abort
489
+ * recreation of the roles that *are* missing. Returns how many statements
490
+ * applied vs were skipped.
491
+ *
492
+ * `runStatement` executes one SQL statement and rejects on error.
493
+ */
494
+ async function applyGlobalsWith(runStatement, globalsSql, log = () => {}) {
495
+ let applied = 0;
496
+ let skipped = 0;
497
+ for (const statement of splitGlobalsStatements(globalsSql)) try {
498
+ await runStatement(statement);
499
+ applied++;
500
+ } catch (err) {
501
+ skipped++;
502
+ log(` • Skipped global: ${statement.split("\n")[0].slice(0, 80)} (${err instanceof Error ? err.message : String(err)})`);
503
+ }
504
+ return {
505
+ applied,
506
+ skipped
507
+ };
508
+ }
509
+ /**
510
+ * Delete each pruned dump together with its `.globals.sql` roles sidecar, so
511
+ * pruning never orphans the roles file. The sidecar is best-effort — older
512
+ * backups predate it, so a missing-sidecar failure is swallowed while a
513
+ * failure deleting the dump itself propagates.
514
+ *
515
+ * `deleteObject` removes one key and rejects if it cannot (e.g. not found).
516
+ */
517
+ async function pruneWith(keys, deleteObject) {
518
+ for (const key of keys) {
519
+ await deleteObject(key);
520
+ try {
521
+ await deleteObject(globalsFileForDump(key));
522
+ } catch {}
523
+ }
524
+ }
525
+ /**
526
+ * Remove a dump artifact abandoned by a failed tool run.
527
+ *
528
+ * `pg_dump` creates its `--file=` target before it finishes connecting, so any
529
+ * failure — a URL libpq rejects, a dropped connection, a full disk — leaves a
530
+ * 0-byte file behind. That corpse is not inert: `rebase db backups list` used
531
+ * to show it as an ordinary entry, and `selectBackupsToPrune` ranks by
532
+ * timestamp alone, so it occupies a protected `keepMinimum` slot and can push
533
+ * a real backup out of retention.
534
+ *
535
+ * Best-effort on purpose: the caller is already throwing, and a failed unlink
536
+ * must not replace the real diagnosis with an ENOENT/EPERM from the cleanup.
537
+ *
538
+ * Lives here rather than in `backup-service.ts` for the reason at the top of
539
+ * this file — that module's top-level `execa` import makes it unloadable under
540
+ * jest, so anything placed there cannot be unit-tested.
541
+ *
542
+ * `remove` deletes one file and throws if it cannot; `exists` reports presence.
543
+ */
544
+ function discardPartialDumpWith(exists, remove, file) {
545
+ try {
546
+ if (exists(file)) remove(file);
547
+ } catch {}
548
+ }
549
+ //#endregion
550
+ //#region src/backup/backup-service.ts
551
+ /**
552
+ * Backup / restore orchestration: thin, well-typed wrappers around
553
+ * `pg_dump` and `pg_restore`, plus binary detection, version pre-flight,
554
+ * and upload/prune against a storage backend.
555
+ *
556
+ * The pure argument- and policy-logic lives in `pg-tools.ts` and
557
+ * `retention.ts`; this module is the impure edge (spawns processes, talks
558
+ * to Postgres and storage) and is intentionally kept small.
559
+ */
560
+ var backup_service_exports = /* @__PURE__ */ __exportAll({
561
+ BackupToolError: () => BackupToolError,
562
+ CleanRestoreFailed: () => CleanRestoreFailed,
563
+ applyGlobals: () => applyGlobals,
564
+ createDump: () => createDump,
565
+ describeUnreadableDump: () => describeUnreadableDump,
566
+ detectToolMajor: () => detectToolMajor,
567
+ discardPartialDump: () => discardPartialDump,
568
+ ensureDatabaseExists: () => ensureDatabaseExists,
569
+ findCleanExtras: () => findCleanExtras,
570
+ findRestoreConflicts: () => findRestoreConflicts,
571
+ getServerVersionMajor: () => getServerVersionMajor,
572
+ listBackups: () => listBackups,
573
+ preflight: () => preflight,
574
+ pruneBackups: () => pruneBackups,
575
+ readDumpObjects: () => readDumpObjects,
576
+ resetSharedSession: () => resetSharedSession,
577
+ resolvePgBinary: () => resolvePgBinary,
578
+ restoreDump: () => restoreDump,
579
+ restoreDumpReplacing: () => restoreDumpReplacing,
580
+ uploadBackup: () => uploadBackup,
581
+ validateDump: () => validateDump
582
+ });
583
+ /**
584
+ * How a libpq tool is invoked for a connection string: the URL its command
585
+ * line carries, and the environment it runs in.
586
+ *
587
+ * The password goes in `PGPASSWORD`, never on the command line, where every
588
+ * process listing could read it and every failure printed it back (execa's
589
+ * error repeats the command). See {@link splitPassword}.
590
+ */
591
+ function libpqInvocation(connectionString, env) {
592
+ const split = splitPassword(connectionString);
593
+ const toolEnv = {};
594
+ for (const [key, value] of Object.entries(env)) if (value !== void 0) toolEnv[key] = value;
595
+ if (split.password !== null) toolEnv.PGPASSWORD = split.password;
596
+ return {
597
+ connectionString: split.connectionString,
598
+ env: toolEnv
599
+ };
600
+ }
601
+ /**
602
+ * Remove a dump artifact abandoned by a failed tool run. See
603
+ * {@link discardPartialDumpWith} for why this exists and why the logic lives
604
+ * in the execa-free module.
605
+ */
606
+ function discardPartialDump(file) {
607
+ discardPartialDumpWith((f) => fs.existsSync(f), (f) => fs.unlinkSync(f), file);
608
+ }
609
+ var BackupToolError = class extends Error {
610
+ hint;
611
+ constructor(message, hint) {
612
+ super(message);
613
+ this.hint = hint;
614
+ this.name = "BackupToolError";
615
+ }
616
+ };
617
+ /** Run `<bin> --version` and extract the major version. */
618
+ async function detectToolMajor(bin) {
619
+ try {
620
+ const { stdout } = await execa(bin, ["--version"]);
621
+ return parsePgToolMajor(stdout);
622
+ } catch {
623
+ return null;
624
+ }
625
+ }
626
+ /** Query the server for its major version via `server_version_num`. */
627
+ async function getServerVersionMajor(connectionString) {
628
+ const { Client } = await import("pg");
629
+ const client = new Client({ connectionString });
630
+ await client.connect();
631
+ try {
632
+ const raw = (await client.query("SHOW server_version_num")).rows?.[0]?.server_version_num;
633
+ return serverVersionNumToMajor(raw);
634
+ } finally {
635
+ await client.end();
636
+ }
637
+ }
638
+ /** PGlite names itself in `version()`: `PostgreSQL 18.3 (PGlite 0.5.6) on wasm32-…`. */
639
+ var SHARED_SESSION_ENGINE = /\(PGlite\b/i;
640
+ /**
641
+ * Put back what a client tool left set on a database that is one session
642
+ * every connection shares, and do nothing on any other.
643
+ *
644
+ * The managed development database is PGlite: one session behind a socket
645
+ * server, so what `pg_dump` sets for itself — `search_path` emptied,
646
+ * `row_security` off, `PREPARE dumpEnumType …` — stays set for every client
647
+ * after it, a running `rebase dev` included. One `rebase db backup` there left
648
+ * every API request answering 500 "table does not exist", a restart did not
649
+ * help (the database outlives `rebase dev`), and the next backup failed on
650
+ * `prepared statement "dumpenumtype" already exists`.
651
+ *
652
+ * `RESET ALL` and `DEALLOCATE ALL`, not `DISCARD ALL`: that would also drop
653
+ * the `LISTEN` a running `rebase dev`'s realtime holds on the same session.
654
+ * Best effort: it runs after the tool, whose own outcome is what the caller
655
+ * reports.
656
+ */
657
+ async function resetSharedSession(connectionString) {
658
+ const { Client } = await import("pg");
659
+ const client = new Client({
660
+ connectionString,
661
+ connectionTimeoutMillis: 1e4,
662
+ query_timeout: 15e3
663
+ });
664
+ client.on("error", () => void 0);
665
+ try {
666
+ await client.connect();
667
+ const { rows } = await client.query("SELECT version() AS version");
668
+ if (!SHARED_SESSION_ENGINE.test(rows[0]?.version ?? "")) return;
669
+ await client.query("RESET ALL");
670
+ await client.query("DEALLOCATE ALL");
671
+ } catch {} finally {
672
+ await client.end().catch(() => void 0);
673
+ }
674
+ }
675
+ /**
676
+ * Verify the requested client tool exists and its major version is
677
+ * compatible with the live server. Throws {@link BackupToolError} — with a
678
+ * doctor-style hint — when the binary is missing.
679
+ */
680
+ async function preflight(tool, connectionString, env = process.env) {
681
+ const bin = resolvePgBinary(tool, env);
682
+ 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.`);
683
+ const [toolMajor, serverMajor] = await Promise.all([detectToolMajor(bin), getServerVersionMajor(connectionString)]);
684
+ return {
685
+ bin,
686
+ toolMajor,
687
+ serverMajor,
688
+ ...checkToolServerCompatibility(toolMajor, serverMajor)
689
+ };
690
+ }
691
+ /**
692
+ * Produce a custom-format dump on local disk. When `outDir` is omitted the
693
+ * file is written to the OS temp directory (used by the upload path, which
694
+ * cleans it up afterwards).
695
+ *
696
+ * Alongside the `-Fc` dump it writes a `<name>.globals.sql` sidecar via
697
+ * `pg_dumpall --globals-only` so the roles the dump's GRANT/RLS statements
698
+ * depend on can be recreated on restore. Set `includeGlobals: false` to skip
699
+ * it (e.g. when the caller has no privilege to read cluster globals).
700
+ */
701
+ async function createDump(opts) {
702
+ const env = opts.env ?? process.env;
703
+ const bin = resolvePgBinary("pg_dump", env);
704
+ if (!bin) throw new BackupToolError("Could not find the 'pg_dump' binary.", "Install the PostgreSQL client tools or set PG_DUMP_PATH.");
705
+ const fileName = opts.fileName ?? buildBackupFilename(opts.dbName);
706
+ const outDir = opts.outDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "rebase-backup-"));
707
+ fs.mkdirSync(outDir, { recursive: true });
708
+ const localFile = path.join(outDir, fileName);
709
+ createOwnerOnly(localFile);
710
+ const tool = libpqInvocation(opts.connectionString, env);
711
+ const args = buildPgDumpArgs({
712
+ connectionString: tool.connectionString,
713
+ outFile: localFile,
714
+ excludeSchemas: opts.excludeSchemas,
715
+ noOwner: opts.noOwner,
716
+ rowSecurity: opts.rowSecurity
717
+ });
718
+ const dumpEnv = { ...tool.env };
719
+ if (opts.rowSecurity) dumpEnv.PGOPTIONS = [env.PGOPTIONS, buildRowSecurityPgOptions(opts.rowSecurity)].filter(Boolean).join(" ");
720
+ try {
721
+ try {
722
+ await execa(bin, args, {
723
+ stdio: opts.inheritStdio ? "inherit" : "pipe",
724
+ env: dumpEnv
725
+ });
726
+ } finally {
727
+ await resetSharedSession(opts.connectionString);
728
+ }
729
+ } catch (error) {
730
+ discardPartialDump(localFile);
731
+ const diagnosis = diagnoseRowSecurityDumpFailure(error);
732
+ if (!diagnosis) throw error;
733
+ throw new BackupToolError(diagnosis, "Run `rebase db backup --help` for the flag, and read what it says about partial dumps.");
734
+ }
735
+ const result = {
736
+ localFile,
737
+ fileName,
738
+ sizeBytes: fs.existsSync(localFile) ? fs.statSync(localFile).size : 0
739
+ };
740
+ if (opts.includeGlobals !== false) {
741
+ const dumpallBin = resolvePgBinary("pg_dumpall", env);
742
+ 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.");
743
+ const globalsFile = globalsFileForDump(localFile);
744
+ createOwnerOnly(globalsFile);
745
+ try {
746
+ try {
747
+ await execa(dumpallBin, buildPgDumpallGlobalsArgs({
748
+ connectionString: tool.connectionString,
749
+ outFile: globalsFile
750
+ }), {
751
+ stdio: opts.inheritStdio ? "inherit" : "pipe",
752
+ env: tool.env
753
+ });
754
+ } finally {
755
+ await resetSharedSession(opts.connectionString);
756
+ }
757
+ } catch (error) {
758
+ discardPartialDump(globalsFile);
759
+ discardPartialDump(localFile);
760
+ throw error;
761
+ }
762
+ result.globalsFile = globalsFile;
763
+ result.globalsSizeBytes = fs.existsSync(globalsFile) ? fs.statSync(globalsFile).size : 0;
764
+ }
765
+ return result;
766
+ }
767
+ /**
768
+ * Create `file` empty and readable by its owner only, before a tool writes it.
769
+ *
770
+ * A dump holds every row, and the `.globals.sql` sidecar every role's password
771
+ * hash. pg_dump and pg_dumpall create their `--file=` target with the
772
+ * process's umask — 0644 on most machines, readable by every user — and keep
773
+ * the mode of a file that is already there. Created 0600 first, the file is
774
+ * never readable by anyone else, not even while the dump runs; chmod-ing it
775
+ * afterwards would leave it open for as long as that takes. A file that
776
+ * already existed is narrowed too.
777
+ */
778
+ function createOwnerOnly(file) {
779
+ fs.closeSync(fs.openSync(file, "w", 384));
780
+ fs.chmodSync(file, 384);
781
+ }
782
+ /**
783
+ * Cheap integrity check on a freshly written dump: it must be non-empty and
784
+ * `pg_restore --list` must parse its table of contents without error. Used
785
+ * before pruning older backups so a corrupt-but-exit-0 dump never becomes
786
+ * the reason the last good backup is deleted.
787
+ */
788
+ async function validateDump(localFile, env = process.env) {
789
+ if (!fs.existsSync(localFile)) return {
790
+ ok: false,
791
+ reason: `Dump file does not exist: ${localFile}`
792
+ };
793
+ if (fs.statSync(localFile).size === 0) return {
794
+ ok: false,
795
+ reason: "Dump file is empty (0 bytes)."
796
+ };
797
+ const bin = resolvePgBinary("pg_restore", env);
798
+ if (!bin) return {
799
+ ok: false,
800
+ reason: "Could not find 'pg_restore' to verify the dump."
801
+ };
802
+ try {
803
+ await execa(bin, buildPgRestoreListArgs(localFile), {
804
+ stdio: "pipe",
805
+ env: { ...env }
806
+ });
807
+ return { ok: true };
808
+ } catch (err) {
809
+ return {
810
+ ok: false,
811
+ reason: `pg_restore --list failed: ${err instanceof Error ? err.message : String(err)}`
812
+ };
813
+ }
814
+ }
815
+ /**
816
+ * Replay a `pg_dumpall --globals-only` script to recreate cluster roles
817
+ * before a restore, so the dump's GRANT/RLS statements (which reference
818
+ * `rebase_user` and any owner roles) actually apply. Runs statement by
819
+ * statement and tolerates per-statement failures — on a same-cluster restore
820
+ * the roles usually already exist (`CREATE ROLE` → "already exists"), and on
821
+ * a managed provider an `ALTER ROLE <superuser>` may be refused; neither
822
+ * should abort role recreation. Returns how many statements applied vs were
823
+ * skipped.
824
+ */
825
+ async function applyGlobals(connectionString, globalsSql, log = () => {}) {
826
+ const { Client } = await import("pg");
827
+ const client = new Client({ connectionString });
828
+ await client.connect();
829
+ try {
830
+ return await applyGlobalsWith(async (sql) => {
831
+ await client.query(sql);
832
+ }, globalsSql, log);
833
+ } finally {
834
+ await client.end();
835
+ }
836
+ }
837
+ /**
838
+ * Restore a custom-format dump into the database named by
839
+ * `connectionString`. Destructive when `clean` is set (drops objects
840
+ * first). Never called automatically — the CLI gates it behind explicit
841
+ * confirmation.
842
+ *
843
+ * Runs with `--exit-on-error` by default: a restore that logs-and-continues
844
+ * past a failed GRANT (because a role was missing) reports success with RLS
845
+ * un-enforced. Callers should recreate roles first (see {@link applyGlobals})
846
+ * and only set `exitOnError: false` deliberately.
847
+ */
848
+ async function restoreDump(opts) {
849
+ const env = opts.env ?? process.env;
850
+ const bin = resolvePgBinary("pg_restore", env);
851
+ if (!bin) throw new BackupToolError("Could not find the 'pg_restore' binary.", "Install the PostgreSQL client tools or set PG_RESTORE_PATH.");
852
+ const tool = libpqInvocation(opts.connectionString, env);
853
+ const args = buildPgRestoreArgs({
854
+ connectionString: tool.connectionString,
855
+ inputFile: opts.inputFile,
856
+ clean: opts.clean,
857
+ noOwner: opts.noOwner,
858
+ exitOnError: opts.exitOnError
859
+ });
860
+ try {
861
+ await execa(bin, args, {
862
+ stdio: opts.inheritStdio ? "inherit" : "pipe",
863
+ env: tool.env
864
+ });
865
+ } finally {
866
+ await resetSharedSession(opts.connectionString);
867
+ }
868
+ }
869
+ /**
870
+ * A `restore --clean` that stopped, and so was rolled back whole.
871
+ *
872
+ * `tool` is the one that stopped it: `pg_restore`, writing the dump's script,
873
+ * or `psql`, running it. `lockTimeout` when what `psql` stopped on was the
874
+ * wait for a lock — `CLEAN_LOCK_TIMEOUT` in `pg-tools.ts`.
875
+ */
876
+ var CleanRestoreFailed = class extends Error {
877
+ tool;
878
+ exitCode;
879
+ lockTimeout;
880
+ constructor(tool, exitCode, lockTimeout) {
881
+ super(`${tool} exited with code ${exitCode ?? "unknown"}`);
882
+ this.tool = tool;
883
+ this.exitCode = exitCode;
884
+ this.lockTimeout = lockTimeout;
885
+ this.name = "CleanRestoreFailed";
886
+ }
887
+ };
888
+ /**
889
+ * Restore a dump over what the database holds, as one transaction:
890
+ * {@link cleanRestorePrelude} first, then the dump as `pg_restore` writes it
891
+ * for `psql`, and the `COMMIT` only after the last of it.
892
+ *
893
+ * One unit, so it either replaces what the dump holds or changes nothing:
894
+ * `pg_restore --clean` runs each statement on its own, so the drops it starts
895
+ * with had all happened when a later one failed — and its plain `DROP SCHEMA
896
+ * rebase` fails on any table the backup lacks, which left a database with
897
+ * every table dropped and nothing restored. Here a failure anywhere —
898
+ * `pg_restore` cannot read the archive, a statement fails, the connection
899
+ * drops — ends the transaction without a `COMMIT`, and Postgres rolls it back.
900
+ *
901
+ * Throws {@link CleanRestoreFailed} when it did not commit.
902
+ */
903
+ async function restoreDumpReplacing(opts) {
904
+ const env = opts.env ?? process.env;
905
+ const restoreBin = resolvePgBinary("pg_restore", env);
906
+ const psqlBin = resolvePgBinary("psql", env);
907
+ if (!restoreBin || !psqlBin) {
908
+ const tool = restoreBin ? "psql" : "pg_restore";
909
+ throw new BackupToolError(`Could not find the '${tool}' binary${tool === "psql" ? ", which runs a --clean restore as one transaction" : ""}.`, `Install the PostgreSQL client tools or set ${pgToolVariable(tool)}.`);
910
+ }
911
+ const tool = libpqInvocation(opts.connectionString, env);
912
+ const script = execa(restoreBin, buildPgRestoreScriptArgs({
913
+ inputFile: opts.inputFile,
914
+ noOwner: opts.noOwner
915
+ }), {
916
+ stdin: "ignore",
917
+ stdout: "pipe",
918
+ stderr: opts.inheritStdio ? "inherit" : "ignore",
919
+ buffer: false,
920
+ reject: false,
921
+ env: tool.env
922
+ });
923
+ const apply = execa(psqlBin, buildPsqlScriptArgs(tool.connectionString), {
924
+ stdin: "pipe",
925
+ stdout: "ignore",
926
+ stderr: opts.inheritStdio ? ["pipe", "inherit"] : "pipe",
927
+ reject: false,
928
+ env: tool.env
929
+ });
930
+ const sink = apply.stdin;
931
+ const source = script.stdout;
932
+ if (!sink || !source) throw new BackupToolError("Could not connect pg_restore to psql.");
933
+ sink.on("error", () => void 0);
934
+ apply.once("exit", () => {
935
+ source.unpipe(sink);
936
+ source.resume();
937
+ script.kill();
938
+ });
939
+ sink.write(["BEGIN", ...cleanRestorePrelude(opts.objects)].map((statement) => `${statement};\n`).join(""));
940
+ source.pipe(sink, { end: false });
941
+ const written = await script;
942
+ const committed = written.exitCode === 0 && sink.writable;
943
+ sink.end(committed ? "COMMIT;\n" : void 0);
944
+ const applied = await apply;
945
+ await resetSharedSession(opts.connectionString);
946
+ if (applied.exitCode !== 0) {
947
+ const stderr = typeof applied.stderr === "string" ? applied.stderr : "";
948
+ throw new CleanRestoreFailed("psql", applied.exitCode, /lock timeout/i.test(stderr));
949
+ }
950
+ if (!committed) throw new CleanRestoreFailed("pg_restore", written.exitCode, false);
951
+ }
952
+ /**
953
+ * What `restore --clean` drops that the backup does not hold, so it can say
954
+ * so before it runs: what a schema the backup replaces whole holds that it has
955
+ * no copy of — tables, views, sequences, functions, procedures, aggregates,
956
+ * types and domains — and the views and foreign keys elsewhere that reference
957
+ * one of the relations it replaces. As `<kind> <name>`.
958
+ *
959
+ * A function is matched by its name, so an overload the backup lacks beside
960
+ * one it holds is not named. Sequences a column owns go with their table,
961
+ * and what an extension owns with the extension, so neither is named.
962
+ *
963
+ * None for a database that does not exist yet.
964
+ */
965
+ async function findCleanExtras(connectionString, objects) {
966
+ const schemas = objects.schemas.filter((schema) => schema !== "public");
967
+ if (schemas.length === 0 && objects.relations.length === 0) return [];
968
+ const { routines, types } = objects;
969
+ const { Client } = await import("pg");
970
+ const client = new Client({ connectionString });
971
+ try {
972
+ await client.connect();
973
+ } catch (err) {
974
+ if (err instanceof Error && "code" in err && err.code === "3D000") return [];
975
+ throw err;
976
+ }
977
+ try {
978
+ const { rows } = await client.query(`WITH dumped AS (
979
+ SELECT c.oid
980
+ FROM pg_class c
981
+ JOIN pg_namespace n ON n.oid = c.relnamespace
982
+ JOIN unnest($2::text[], $3::text[]) AS d(schema_name, rel_name)
983
+ ON d.schema_name = n.nspname AND d.rel_name = c.relname
984
+ )
985
+ SELECT kind, name FROM (
986
+ SELECT CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view'
987
+ WHEN 'f' THEN 'foreign table' WHEN 'S' THEN 'sequence' ELSE 'table' END AS kind,
988
+ n.nspname || '.' || c.relname AS name
989
+ FROM pg_class c
990
+ JOIN pg_namespace n ON n.oid = c.relnamespace
991
+ WHERE n.nspname = ANY($1::text[])
992
+ AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
993
+ AND c.oid NOT IN (SELECT oid FROM dumped)
994
+ AND NOT (c.relkind = 'S' AND EXISTS (
995
+ SELECT 1 FROM pg_depend owner
996
+ WHERE owner.classid = 'pg_class'::regclass AND owner.objid = c.oid
997
+ AND owner.deptype IN ('a', 'i')))
998
+ UNION
999
+ SELECT CASE p.prokind WHEN 'p' THEN 'procedure' WHEN 'a' THEN 'aggregate' ELSE 'function' END,
1000
+ n.nspname || '.' || p.proname
1001
+ FROM pg_proc p
1002
+ JOIN pg_namespace n ON n.oid = p.pronamespace
1003
+ WHERE n.nspname = ANY($1::text[])
1004
+ AND (n.nspname, p.proname) NOT IN (SELECT * FROM unnest($4::text[], $5::text[]))
1005
+ AND NOT EXISTS (
1006
+ SELECT 1 FROM pg_depend owner
1007
+ WHERE owner.classid = 'pg_proc'::regclass AND owner.objid = p.oid
1008
+ AND owner.deptype IN ('e', 'i'))
1009
+ UNION
1010
+ SELECT CASE t.typtype WHEN 'd' THEN 'domain' ELSE 'type' END,
1011
+ n.nspname || '.' || t.typname
1012
+ FROM pg_type t
1013
+ JOIN pg_namespace n ON n.oid = t.typnamespace
1014
+ WHERE n.nspname = ANY($1::text[])
1015
+ AND (t.typtype IN ('e', 'd', 'r')
1016
+ OR (t.typtype = 'c' AND EXISTS (
1017
+ SELECT 1 FROM pg_class tc WHERE tc.oid = t.typrelid AND tc.relkind = 'c')))
1018
+ AND (n.nspname, t.typname) NOT IN (SELECT * FROM unnest($6::text[], $7::text[]))
1019
+ AND NOT EXISTS (
1020
+ SELECT 1 FROM pg_depend owner
1021
+ WHERE owner.classid = 'pg_type'::regclass AND owner.objid = t.oid
1022
+ AND owner.deptype = 'e')
1023
+ UNION
1024
+ SELECT 'view', vn.nspname || '.' || v.relname
1025
+ FROM pg_depend dep
1026
+ JOIN pg_rewrite r ON r.oid = dep.objid AND dep.classid = 'pg_rewrite'::regclass
1027
+ JOIN pg_class v ON v.oid = r.ev_class
1028
+ JOIN pg_namespace vn ON vn.oid = v.relnamespace
1029
+ WHERE dep.refobjid IN (SELECT oid FROM dumped)
1030
+ AND v.oid NOT IN (SELECT oid FROM dumped)
1031
+ AND NOT vn.nspname = ANY($1::text[])
1032
+ UNION
1033
+ SELECT 'foreign key', con.conname || ' on ' || cn.nspname || '.' || cr.relname
1034
+ FROM pg_constraint con
1035
+ JOIN pg_class cr ON cr.oid = con.conrelid
1036
+ JOIN pg_namespace cn ON cn.oid = cr.relnamespace
1037
+ WHERE con.contype = 'f'
1038
+ AND con.confrelid IN (SELECT oid FROM dumped)
1039
+ AND con.conrelid NOT IN (SELECT oid FROM dumped)
1040
+ AND NOT cn.nspname = ANY($1::text[])
1041
+ ) extra
1042
+ ORDER BY name`, [
1043
+ schemas,
1044
+ objects.relations.map((r) => r.schema),
1045
+ objects.relations.map((r) => r.name),
1046
+ routines.map((r) => r.schema),
1047
+ routines.map((r) => r.name),
1048
+ types.map((t) => t.schema),
1049
+ types.map((t) => t.name)
1050
+ ]);
1051
+ return rows.map((row) => `${row.kind} ${row.name}`);
1052
+ } finally {
1053
+ await client.end();
1054
+ }
1055
+ }
1056
+ /** The schemas and tables a dump creates, from `pg_restore --list`. See {@link parseDumpObjects}. */
1057
+ async function readDumpObjects(inputFile, env = process.env) {
1058
+ const bin = resolvePgBinary("pg_restore", env);
1059
+ if (!bin) throw new BackupToolError("Could not find the 'pg_restore' binary.", "Install the PostgreSQL client tools or set PG_RESTORE_PATH.");
1060
+ const toolEnv = Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0));
1061
+ let listed;
1062
+ try {
1063
+ ({stdout: listed} = await execa(bin, buildPgRestoreListArgs(inputFile), {
1064
+ stdio: "pipe",
1065
+ env: toolEnv
1066
+ }));
1067
+ } catch (err) {
1068
+ const stderr = err instanceof Error && "stderr" in err && typeof err.stderr === "string" ? err.stderr : "";
1069
+ const { message, hint } = describeUnreadableDump(path.basename(inputFile), stderr);
1070
+ throw new BackupToolError(message, hint);
1071
+ }
1072
+ return parseDumpObjects(listed);
1073
+ }
1074
+ /**
1075
+ * What `db restore` says of a file whose table of contents pg_restore cannot
1076
+ * read: before anything is restored, so nothing has changed.
1077
+ *
1078
+ * `pgRestoreStderr` is what `pg_restore --list` printed. Its first error line
1079
+ * is quoted, so the reader sees the tool's own words too.
1080
+ */
1081
+ function describeUnreadableDump(file, pgRestoreStderr) {
1082
+ const said = pgRestoreStderr.split("\n").map((line) => line.replace(/^pg_restore:\s*(?:error:\s*)?/, "").trim()).find((line) => line.length > 0);
1083
+ const quoted = said ? ` (pg_restore: ${said})` : "";
1084
+ const unchanged = "Nothing was restored or changed.";
1085
+ const elsewhere = "Restore from another backup (`rebase db backups list` lists them), or copy this one again from where it came from.";
1086
+ if (said && /unsupported version \(([\d.]+)\) in file header/.test(said)) return {
1087
+ message: `${file} was written by a newer pg_dump than this pg_restore reads${quoted}. ${unchanged}`,
1088
+ hint: "Install the PostgreSQL client tools of the version that wrote it, or set PG_RESTORE_PATH to that pg_restore."
1089
+ };
1090
+ if (said && /does not appear to be a valid archive/.test(said)) return {
1091
+ message: `${file} is not a backup pg_restore can read${quoted}. \`rebase db restore\` takes the .dump files \`rebase db backup\` writes. ${unchanged}`,
1092
+ hint: elsewhere
1093
+ };
1094
+ if (said && /end of file|too short/.test(said)) return {
1095
+ message: `${file} is truncated: it ends before pg_restore can read what it holds${quoted}. A copy or download that did not finish, or a disk that filled while it was written, leaves a file like this. Nothing was restored or changed.`,
1096
+ hint: elsewhere
1097
+ };
1098
+ return {
1099
+ message: `${file} cannot be read as a backup${quoted}: it is damaged, or not a pg_dump archive. ${unchanged}`,
1100
+ hint: elsewhere
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Which of a dump's schemas and tables the target database already holds,
1105
+ * as `schema <name>` / `table <schema>.<name>`: what a restore without
1106
+ * `--clean` would stop on. `public` is left out of the schemas — every
1107
+ * database has it, and pg_restore does not create it. None for a database
1108
+ * that does not exist yet.
1109
+ */
1110
+ async function findRestoreConflicts(connectionString, objects) {
1111
+ const schemas = objects.schemas.filter((schema) => schema !== "public");
1112
+ if (schemas.length === 0 && objects.tables.length === 0) return [];
1113
+ const { Client } = await import("pg");
1114
+ const client = new Client({ connectionString });
1115
+ try {
1116
+ await client.connect();
1117
+ } catch (err) {
1118
+ if (err instanceof Error && "code" in err && err.code === "3D000") return [];
1119
+ throw err;
1120
+ }
1121
+ try {
1122
+ const existingSchemas = await client.query("SELECT nspname FROM pg_namespace WHERE nspname = ANY($1::text[]) ORDER BY nspname", [schemas]);
1123
+ const existingTables = await client.query(`SELECT n.nspname, c.relname
1124
+ FROM pg_class c
1125
+ JOIN pg_namespace n ON n.oid = c.relnamespace
1126
+ JOIN unnest($1::text[], $2::text[]) AS d(schema_name, table_name)
1127
+ ON d.schema_name = n.nspname AND d.table_name = c.relname
1128
+ WHERE c.relkind IN ('r', 'p')
1129
+ ORDER BY n.nspname, c.relname`, [objects.tables.map((t) => t.schema), objects.tables.map((t) => t.table)]);
1130
+ return [...existingSchemas.rows.map((row) => `schema ${row.nspname}`), ...existingTables.rows.map((row) => `table ${row.nspname}.${row.relname}`)];
1131
+ } finally {
1132
+ await client.end();
1133
+ }
1134
+ }
1135
+ /**
1136
+ * Create a database (if absent) by connecting to the maintenance
1137
+ * `postgres` database. Used by `restore --create-db`.
1138
+ */
1139
+ async function ensureDatabaseExists(adminConnectionString, dbName) {
1140
+ const { Client } = await import("pg");
1141
+ const parsed = new URL(adminConnectionString);
1142
+ parsed.pathname = "/postgres";
1143
+ const client = new Client({ connectionString: parsed.toString() });
1144
+ await client.connect();
1145
+ try {
1146
+ const existing = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
1147
+ if (existing.rowCount && existing.rowCount > 0) return false;
1148
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dbName)) throw new BackupToolError(`Invalid database name: "${dbName}"`);
1149
+ await client.query(`CREATE DATABASE "${dbName}"`);
1150
+ return true;
1151
+ } finally {
1152
+ await client.end();
1153
+ }
1154
+ }
1155
+ /**
1156
+ * Upload a local dump to object storage under the destination's prefix.
1157
+ * Backups may contain secrets/PII, so the object is written with a
1158
+ * private/octet-stream content type; bucket-level ACLs must stay private.
1159
+ */
1160
+ async function uploadBackup(storage, localFile, dest) {
1161
+ const fileName = path.basename(localFile);
1162
+ const key = joinStorageKey(dest.prefix, fileName);
1163
+ const file = new File([await fs.openAsBlob(localFile)], fileName, { type: "application/octet-stream" });
1164
+ return {
1165
+ key,
1166
+ storageUrl: (await storage.putObject({
1167
+ file,
1168
+ key,
1169
+ bucket: dest.bucket,
1170
+ metadata: { "rebase-backup": "1" }
1171
+ }, { uploadLimits: false })).storageUrl
1172
+ };
1173
+ }
1174
+ /** Stats in flight at once while listing a local backup folder. */
1175
+ var LOCAL_STAT_CONCURRENCY = 32;
1176
+ /** `fn` over `items`, at most `limit` at a time; results in input order. */
1177
+ async function mapBounded(items, limit, fn) {
1178
+ const results = new Array(items.length);
1179
+ let next = 0;
1180
+ const worker = async () => {
1181
+ while (next < items.length) {
1182
+ const i = next++;
1183
+ results[i] = await fn(items[i]);
1184
+ }
1185
+ };
1186
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
1187
+ return results;
1188
+ }
1189
+ /**
1190
+ * List existing backups at a destination. For local destinations reads the
1191
+ * directory; for object storage lists the prefix via the controller.
1192
+ */
1193
+ async function listBackups(dest, storage) {
1194
+ if (dest.kind === "local") {
1195
+ const stat = await fs.promises.stat(dest.path).catch(() => null);
1196
+ if (!stat) return [];
1197
+ const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);
1198
+ const dumps = (await fs.promises.readdir(dir)).filter((f) => f.endsWith(".dump"));
1199
+ const stats = await mapBounded(dumps, LOCAL_STAT_CONCURRENCY, (f) => fs.promises.stat(path.join(dir, f)));
1200
+ return dumps.map((f, i) => {
1201
+ const createdAt = parseBackupTimestamp(f) ?? stats[i].mtime;
1202
+ return {
1203
+ key: path.join(dir, f),
1204
+ createdAt,
1205
+ sizeBytes: stats[i].size
1206
+ };
1207
+ }).sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
1208
+ }
1209
+ if (!storage) throw new BackupToolError(`Listing ${dest.kind} backups requires a configured storage backend.`);
1210
+ const { listBackupObjects } = await import("@rebasepro/server");
1211
+ return (await listBackupObjects(dest, storage)).map((info) => ({
1212
+ key: info.key,
1213
+ createdAt: info.createdAt ? new Date(info.createdAt) : void 0
1214
+ }));
1215
+ }
1216
+ /**
1217
+ * Apply a retention policy to a destination, deleting the backups selected
1218
+ * by {@link selectBackupsToPrune}. Returns the keys that were removed.
1219
+ */
1220
+ async function pruneBackups(dest, options, storage) {
1221
+ const toDelete = selectBackupsToPrune(await listBackups(dest, storage), options);
1222
+ await pruneWith(toDelete, dest.kind === "local" ? async (key) => {
1223
+ if (!fs.existsSync(key)) throw new Error(`not found: ${key}`);
1224
+ fs.unlinkSync(key);
1225
+ } : async (key) => {
1226
+ if (!storage) throw new BackupToolError("Storage backend required to prune object backups.");
1227
+ await storage.deleteObject(key, dest.bucket);
1228
+ });
1229
+ return toDelete;
1230
+ }
1231
+ //#endregion
1232
+ //#region src/backup/backup-cli.ts
1233
+ var backup_cli_exports = /* @__PURE__ */ __exportAll({
1234
+ INVOCATION_CWD_ENV: () => INVOCATION_CWD_ENV,
1235
+ backupCommand: () => backupCommand,
1236
+ backupsCommand: () => backupsCommand,
1237
+ confirmRestore: () => confirmRestore,
1238
+ describeCleanExtras: () => describeCleanExtras,
1239
+ describeCleanRestoreFailure: () => describeCleanRestoreFailure,
1240
+ describeRestoreConflict: () => describeRestoreConflict,
1241
+ excludesRebaseSchema: () => excludesRebaseSchema,
1242
+ invocationDir: () => invocationDir,
1243
+ parseBackupLine: () => parseBackupLine,
1244
+ parseBackupsListLine: () => parseBackupsListLine,
1245
+ parseRestoreLine: () => parseRestoreLine,
1246
+ resolveBackupLocation: () => resolveBackupLocation,
1247
+ resolveStorageForDestination: () => resolveStorageForDestination,
1248
+ restoreCommand: () => restoreCommand,
1249
+ schemaPatternRegex: () => schemaPatternRegex,
1250
+ unknownBackupsAction: () => unknownBackupsAction
1251
+ });
1252
+ function formatBytes(bytes) {
1253
+ if (bytes < 1024) return `${bytes} B`;
1254
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1255
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1256
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
1257
+ }
1258
+ /** A web stream's chunks, for piping into a file. */
1259
+ async function* webChunks(stream) {
1260
+ const reader = stream.getReader();
1261
+ try {
1262
+ for (;;) {
1263
+ const { done, value } = await reader.read();
1264
+ if (done) return;
1265
+ yield value;
1266
+ }
1267
+ } finally {
1268
+ reader.releaseLock();
1269
+ }
1270
+ }
1271
+ /**
1272
+ * The controller for an object-storage destination, or `null` for a local one.
1273
+ *
1274
+ * Built by `backupStorageFromEnv` from `@rebasepro/server` — the function the
1275
+ * Backups panel's routes use — so the CLI and the panel read a bucket the same
1276
+ * way. This file had its own copy, and the two had already drifted once
1277
+ * (`S3_FORCE_PATH_STYLE` read as `=== "true"`, so a MinIO backup addressed the
1278
+ * bucket as a host).
1279
+ *
1280
+ * Exported for its test.
1281
+ */
1282
+ async function resolveStorageForDestination(dest, env) {
1283
+ if (dest.kind === "local") return null;
1284
+ const { backupStorageFromEnv } = await import("@rebasepro/server");
1285
+ try {
1286
+ return backupStorageFromEnv(dest, env);
1287
+ } catch (error) {
1288
+ throw new BackupToolError(error instanceof Error ? error.message : String(error), "Set the same S3_* variables your backend uses for storage.");
1289
+ }
1290
+ }
1291
+ /**
1292
+ * The directory `rebase` was run from, which `rebase` puts here for the driver.
1293
+ *
1294
+ * The driver runs in the project's `backend/`, so a relative path resolved
1295
+ * against its own working directory named a different place from the one the
1296
+ * user typed it in: `rebase db backup --out ./backups` wrote to `<root>/backups`
1297
+ * (the CLI resolves explicit paths itself) while `rebase db backups list`
1298
+ * looked in `<root>/backend/backups`. Spelled the same in
1299
+ * `packages/cli/src/commands/db.ts`.
1300
+ */
1301
+ var INVOCATION_CWD_ENV = "REBASE_INVOCATION_CWD";
1302
+ /** Where a relative path the user typed is relative to: where they ran `rebase`. */
1303
+ function invocationDir(env = process.env) {
1304
+ const dir = env[INVOCATION_CWD_ENV]?.trim();
1305
+ return dir && path.isAbsolute(dir) ? dir : process.cwd();
1306
+ }
1307
+ /**
1308
+ * Where `db backup` writes and `db backups list` reads, by one rule.
1309
+ *
1310
+ * - `--out` is relative to where `rebase` was run, like any path typed on a
1311
+ * command line.
1312
+ * - `BACKUP_DESTINATION` is relative to the backend directory, because the
1313
+ * server reads the same variable there: the Backups panel and the scheduled
1314
+ * backup have to find what the CLI wrote.
1315
+ * - With neither, `./backups` where `rebase` was run, as the help says.
1316
+ *
1317
+ * An `s3://` or `gs://` destination is returned as given.
1318
+ */
1319
+ function resolveBackupLocation(opts) {
1320
+ const flag = opts.flag?.trim();
1321
+ if (flag) return isRemoteDestination(flag) ? flag : path.resolve(opts.invocationDir, flag);
1322
+ const configured = opts.configured?.trim();
1323
+ if (configured) return isRemoteDestination(configured) ? configured : path.resolve(opts.backendDir, configured);
1324
+ return path.join(opts.invocationDir, "backups");
1325
+ }
1326
+ function isRemoteDestination(value) {
1327
+ return parseBackupDestination(value).kind !== "local";
1328
+ }
1329
+ /** {@link resolveBackupLocation} for this process. */
1330
+ function backupLocation(flag) {
1331
+ return resolveBackupLocation({
1332
+ flag,
1333
+ configured: process.env.BACKUP_DESTINATION,
1334
+ invocationDir: invocationDir(),
1335
+ backendDir: process.cwd()
1336
+ });
1337
+ }
1338
+ function requireConnection() {
1339
+ const conn = resolveConnectionString(process.env);
1340
+ if (!conn) {
1341
+ outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
1342
+ process.exit(1);
1343
+ }
1344
+ return conn;
1345
+ }
1346
+ var BACKUP_FLAGS = {
1347
+ "--out": String,
1348
+ "--output": "--out",
1349
+ "--exclude-schema": [String],
1350
+ "--no-owner": Boolean,
1351
+ "--enable-row-security": Boolean,
1352
+ "--row-security-role": String,
1353
+ "-o": "--out"
1354
+ };
1355
+ /**
1356
+ * Parse a `rebase db backup …` line. An undeclared flag is refused (see
1357
+ * `parseCommandFlags`), and so is a positional: `rebase db backup ./backups`
1358
+ * means `--out ./backups`, and taken as nothing it wrote to the default
1359
+ * directory.
1360
+ */
1361
+ function parseBackupLine(rawArgs) {
1362
+ const args = parseCommandFlags(BACKUP_FLAGS, rawArgs.slice(2), "db backup");
1363
+ const [typed] = args._;
1364
+ if (typed !== void 0) {
1365
+ const stray = redactConnectionPasswords(typed);
1366
+ throw new Error(`\`rebase db backup\` takes no argument "${stray}". Nothing was run. To choose where the backup goes, pass --out ${stray}.`);
1367
+ }
1368
+ const dropsRebase = (args["--exclude-schema"] ?? []).find(excludesRebaseSchema);
1369
+ if (dropsRebase !== void 0) throw new Error(excludesRebaseMessage(dropsRebase));
1370
+ return args;
1371
+ }
1372
+ /**
1373
+ * A `pg_dump --exclude-schema` pattern as the regular expression pg_dump
1374
+ * hands the server, by pg_dump's own rules (`patternToSQLRegex` in
1375
+ * PostgreSQL's `fe_utils/string_utils.c`):
1376
+ *
1377
+ * - outside double quotes, an upper-case letter is folded to lower case, `*`
1378
+ * is `.*`, `?` is `.`, and every other character, regular-expression syntax
1379
+ * included (`[r]`, `(a|b)`, `+`), passes through as it is;
1380
+ * - inside double quotes, case is kept and every special character is
1381
+ * matched literally; `""` is one quote;
1382
+ * - `$` is always literal, and `[]` is always the two characters;
1383
+ * - an unquoted `.` separates a database from the schema, so `shop.rebase`
1384
+ * is matched on `rebase`.
1385
+ *
1386
+ * The result is anchored, as pg_dump anchors it: `^(…)$`.
1387
+ */
1388
+ function schemaPatternRegex(pattern) {
1389
+ let regex = "";
1390
+ let inQuotes = false;
1391
+ for (let index = 0; index < pattern.length; index += 1) {
1392
+ const char = pattern[index];
1393
+ if (char === "\"") if (inQuotes && pattern[index + 1] === "\"") {
1394
+ regex += "\"";
1395
+ index += 1;
1396
+ } else inQuotes = !inQuotes;
1397
+ else if (!inQuotes && char >= "A" && char <= "Z") regex += char.toLowerCase();
1398
+ else if (!inQuotes && char === "*") regex += ".*";
1399
+ else if (!inQuotes && char === "?") regex += ".";
1400
+ else if (!inQuotes && char === ".") regex = "";
1401
+ else if (char === "$") regex += "\\$";
1402
+ else if (inQuotes && "|*+?()[]{}.^$\\".includes(char)) regex += `\\${char}`;
1403
+ else if (char === "[" && pattern[index + 1] === "]") regex += "\\[";
1404
+ else regex += char;
1405
+ }
1406
+ return `^(${regex})$`;
1407
+ }
1408
+ /**
1409
+ * Whether a `pg_dump --exclude-schema` pattern matches the `rebase` schema, by
1410
+ * pg_dump's own rules (see {@link schemaPatternRegex}).
1411
+ *
1412
+ * Evaluated here with JavaScript's regular expressions, which read the syntax
1413
+ * people write in a pattern (`[r]ebase`, `(rebase|x)`, `reb.+`) as the
1414
+ * server does. The server's engine has syntax JavaScript reads otherwise
1415
+ * (`[[:alpha:]]`, `\m`), so `db backup` asks the server too before it dumps
1416
+ * (see `backupCommand`); this check is the one that answers before anything
1417
+ * connects. A pattern JavaScript cannot compile is left to that one.
1418
+ */
1419
+ function excludesRebaseSchema(pattern) {
1420
+ try {
1421
+ return new RegExp(schemaPatternRegex(pattern)).test("rebase");
1422
+ } catch {
1423
+ return false;
1424
+ }
1425
+ }
1426
+ /**
1427
+ * Whether the server's regular-expression engine matches `name` with `regex`,
1428
+ * the way pg_dump's own query matches a schema pattern
1429
+ * (`nspname OPERATOR(pg_catalog.~) '^(…)$' COLLATE pg_catalog.default`).
1430
+ *
1431
+ * A regex the server will not compile matches nothing here; pg_dump then
1432
+ * refuses it in its own words.
1433
+ */
1434
+ async function serverRegexMatches(connectionString, regex, name) {
1435
+ const { Client } = await import("pg");
1436
+ const client = new Client({ connectionString });
1437
+ await client.connect();
1438
+ try {
1439
+ const { rows } = await client.query("SELECT $1::text OPERATOR(pg_catalog.~) $2::text COLLATE pg_catalog.default AS matches", [name, regex]);
1440
+ return rows[0]?.matches === true;
1441
+ } catch (err) {
1442
+ if (err instanceof Error && "code" in err && err.code === "2201B") return false;
1443
+ throw err;
1444
+ } finally {
1445
+ await client.end();
1446
+ }
1447
+ }
1448
+ /** The refusal for a pattern that leaves out the rebase schema. */
1449
+ function excludesRebaseMessage(pattern) {
1450
+ return `--exclude-schema ${pattern} leaves out the rebase schema, and a backup without it cannot be restored: it holds the users, roles and the functions every policy calls. Nothing was run.`;
1451
+ }
1452
+ async function backupCommand(rawArgs) {
1453
+ if (rawArgs.includes("--help")) {
1454
+ printBackupHelp();
1455
+ return;
1456
+ }
1457
+ const args = parseBackupLine(rawArgs);
1458
+ const connectionString = requireConnection();
1459
+ const dbName = parseDbNameFromUrl(connectionString) ?? "database";
1460
+ const out$1 = backupLocation(args["--out"]);
1461
+ const dest = parseBackupDestination(out$1);
1462
+ out("");
1463
+ out(chalk.bold(" 💾 Rebase DB Backup"));
1464
+ out(chalk.gray(` Database: ${dbName}`));
1465
+ out(chalk.gray(` Destination: ${out$1}`));
1466
+ out("");
1467
+ await checkDatabaseConnectivity(connectionString);
1468
+ for (const pattern of args["--exclude-schema"] ?? []) if (await serverRegexMatches(connectionString, schemaPatternRegex(pattern), "rebase")) {
1469
+ outError(chalk.red(` ✗ ${excludesRebaseMessage(pattern)}`));
1470
+ process.exit(1);
1471
+ }
1472
+ const pf = await preflight("pg_dump", connectionString);
1473
+ if (!pf.compatible) {
1474
+ outError(chalk.red(` ✗ ${pf.reason}`));
1475
+ process.exit(1);
1476
+ }
1477
+ out(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
1478
+ const rowSecurity = args["--enable-row-security"] ? {
1479
+ uid: "rebase-db-backup",
1480
+ roles: [args["--row-security-role"] || "admin"]
1481
+ } : void 0;
1482
+ if (rowSecurity) {
1483
+ outWarn("");
1484
+ outWarn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
1485
+ outWarn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
1486
+ outWarn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
1487
+ outWarn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
1488
+ outWarn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
1489
+ outWarn("");
1490
+ }
1491
+ try {
1492
+ if (dest.kind === "local") {
1493
+ const explicitFile = dest.path.endsWith(".dump");
1494
+ const dump = await createDump({
1495
+ connectionString,
1496
+ dbName,
1497
+ outDir: explicitFile ? path.dirname(dest.path) : dest.path,
1498
+ fileName: explicitFile ? path.basename(dest.path) : void 0,
1499
+ excludeSchemas: args["--exclude-schema"],
1500
+ noOwner: args["--no-owner"],
1501
+ inheritStdio: true,
1502
+ rowSecurity
1503
+ });
1504
+ await assertDumpValid(dump.localFile);
1505
+ out("");
1506
+ out(chalk.green(` ✓ Backup written to ${dump.localFile} (${formatBytes(dump.sizeBytes)})`));
1507
+ if (dump.globalsFile) out(chalk.gray(` ✓ Roles captured to ${dump.globalsFile} (needed so RLS survives a restore).`));
1508
+ } else {
1509
+ const storage = await resolveStorageForDestination(dest, process.env);
1510
+ const dump = await createDump({
1511
+ connectionString,
1512
+ dbName,
1513
+ excludeSchemas: args["--exclude-schema"],
1514
+ noOwner: args["--no-owner"],
1515
+ inheritStdio: true,
1516
+ rowSecurity
1517
+ });
1518
+ try {
1519
+ await assertDumpValid(dump.localFile);
1520
+ const uploaded = await uploadBackup(storage, dump.localFile, dest);
1521
+ out("");
1522
+ out(chalk.green(` ✓ Backup uploaded to ${uploaded.storageUrl} (${formatBytes(dump.sizeBytes)})`));
1523
+ if (dump.globalsFile && fs.existsSync(dump.globalsFile)) {
1524
+ const globalsUpload = await uploadBackup(storage, dump.globalsFile, dest);
1525
+ out(chalk.gray(` ✓ Roles uploaded to ${globalsUpload.storageUrl} (needed so RLS survives a restore).`));
1526
+ }
1527
+ out(chalk.gray(" Ensure this bucket is private — backups may contain secrets and PII."));
1528
+ } finally {
1529
+ if (fs.existsSync(dump.localFile)) fs.unlinkSync(dump.localFile);
1530
+ if (dump.globalsFile && fs.existsSync(dump.globalsFile)) fs.unlinkSync(dump.globalsFile);
1531
+ }
1532
+ }
1533
+ out("");
1534
+ } catch (err) {
1535
+ reportError(err);
1536
+ process.exit(1);
1537
+ }
1538
+ }
1539
+ var RESTORE_FLAGS = {
1540
+ "--target-db": String,
1541
+ "--create-db": Boolean,
1542
+ "--clean": Boolean,
1543
+ "--no-owner": Boolean,
1544
+ "--continue-on-error": Boolean,
1545
+ "--yes": Boolean,
1546
+ "-y": "--yes"
1547
+ };
1548
+ /**
1549
+ * Parse a `rebase db restore …` line; the one positional is the backup.
1550
+ *
1551
+ * Strict, because this is the command where a typo costs most: parsed
1552
+ * permissively, `--targt-db x` became a second positional, the target fell
1553
+ * back to DATABASE_URL's own database, and the restore ran against the live
1554
+ * one.
1555
+ */
1556
+ function parseRestoreLine(rawArgs) {
1557
+ const args = parseCommandFlags(RESTORE_FLAGS, rawArgs.slice(2), "db restore");
1558
+ if (args._.length > 1) throw new Error(`\`rebase db restore\` restores one backup, and was given ${args._.length}: ${args._.map((word) => `"${redactConnectionPasswords(word)}"`).join(", ")}. Nothing was run.`);
1559
+ return args;
1560
+ }
1561
+ /**
1562
+ * The restore's one question, asked before anything can change.
1563
+ *
1564
+ * `--yes` skips it. On a terminal it asks for a typed 'yes'. With no terminal
1565
+ * nobody can answer, and the refusal says to pass `--yes`, as `db reset`,
1566
+ * `db pull` and `db branch prune` do: "Aborted. No changes were made." alone
1567
+ * read as though someone had answered no.
1568
+ *
1569
+ * @returns whether to go on. When not, it has said why.
1570
+ */
1571
+ async function confirmRestore(opts) {
1572
+ if (opts.yes) return true;
1573
+ outWarn(chalk.yellow(` ⚠️ This will restore into "${opts.target}" and may overwrite existing data.`));
1574
+ if (!opts.interactive) {
1575
+ outError(chalk.red(" ✗ Nothing was restored: there is no terminal to confirm on."));
1576
+ outError(chalk.gray(` Re-run with --yes to restore into "${opts.target}".`));
1577
+ return false;
1578
+ }
1579
+ const confirmed = await opts.ask(chalk.yellow(" Type 'yes' to continue: "));
1580
+ if (!confirmed) out(chalk.gray(" Aborted. No changes were made."));
1581
+ return confirmed;
1582
+ }
1583
+ /** How many of the conflicting objects a refusal names before counting the rest. */
1584
+ var CONFLICTS_NAMED = 5;
1585
+ /**
1586
+ * What `db restore` says when the target already holds what the backup
1587
+ * creates, and the two ways round it: `--clean` replaces them with the
1588
+ * backup's copy, a new database restores beside them.
1589
+ *
1590
+ * `line` is the rest of what the user typed — the database it names, `--yes`,
1591
+ * `--no-owner` — which both commands keep, so the one copied runs against the
1592
+ * same database and does not stop again to ask. `--target-db` goes on the
1593
+ * `--clean` one only: the other names a new database.
1594
+ */
1595
+ function describeRestoreConflict(target, backupArg, conflicts, line = {}) {
1596
+ const named = conflicts.slice(0, CONFLICTS_NAMED).join(", ");
1597
+ const more = conflicts.length > CONFLICTS_NAMED ? `, and ${conflicts.length - CONFLICTS_NAMED} more` : "";
1598
+ const carried = line.carried?.length ? ` ${line.carried.join(" ")}` : "";
1599
+ const sameTarget = line.targetDb ? ` --target-db ${line.targetDb}` : "";
1600
+ return {
1601
+ message: `"${target}" already holds ${named}${more}, which this backup creates. Without --clean, pg_restore stops at the first of them. Nothing was restored or changed.`,
1602
+ hint: [`To replace them with the backup's copy: rebase db restore ${backupArg}${sameTarget} --clean${carried}`, `To restore beside them, in a new database: rebase db restore ${backupArg} --create-db --target-db <new name>${carried}`].join("\n ")
1603
+ };
1604
+ }
1605
+ /** How many of the objects `--clean` drops beyond the backup's own it names before counting the rest. */
1606
+ var EXTRAS_NAMED = 10;
1607
+ /**
1608
+ * What `restore --clean` says, before it asks, about what it drops that the
1609
+ * backup does not hold — see `findCleanExtras`. Nothing when there is none.
1610
+ */
1611
+ function describeCleanExtras(schemas, extras) {
1612
+ if (extras.length === 0) return [];
1613
+ const replaced = schemas.filter((schema) => schema !== "public");
1614
+ return [
1615
+ replaced.length > 0 ? `--clean replaces ${replaced.length === 1 ? "schema" : "schemas"} ${replaced.join(", ")} whole, so it also drops what this backup does not hold:` : "--clean also drops what this backup does not hold:",
1616
+ ...extras.slice(0, EXTRAS_NAMED).map((extra) => ` ${extra}`),
1617
+ ...extras.length > EXTRAS_NAMED ? [` and ${extras.length - EXTRAS_NAMED} more`] : []
1618
+ ];
1619
+ }
1620
+ /**
1621
+ * What a `restore --clean` that stopped says: it ran as one transaction, so
1622
+ * nothing changed. The cluster roles the sidecar recreated are the exception,
1623
+ * and are said to be.
1624
+ */
1625
+ function describeCleanRestoreFailure(target, failure, rolesApplied) {
1626
+ const why = failure.tool === "pg_restore" ? "pg_restore could not read the whole backup" : failure.lockTimeout ? `it waited too long for a lock on "${target}"` : "a statement failed";
1627
+ const hints = [...failure.lockTimeout ? ["Something holds a lock on it — usually a `rebase dev` serving from it. Stop that, then restore again."] : [], ...rolesApplied > 0 ? [`The ${rolesApplied} cluster role(s) recreated from the backup's sidecar before it stay.`] : []];
1628
+ return {
1629
+ message: `Nothing was restored or changed: the restore ran as one transaction, and it was rolled back when ${why} (above). "${target}" holds what it held before.`,
1630
+ hint: hints.length > 0 ? hints.join("\n ") : void 0
1631
+ };
1632
+ }
1633
+ async function restoreCommand(rawArgs) {
1634
+ if (rawArgs.includes("--help")) {
1635
+ printRestoreHelp();
1636
+ return;
1637
+ }
1638
+ const args = parseRestoreLine(rawArgs);
1639
+ const backupArg = args._[0];
1640
+ if (!backupArg) {
1641
+ printRestoreHelp();
1642
+ process.exit(1);
1643
+ }
1644
+ const baseConnection = requireConnection();
1645
+ const targetDb = args["--target-db"] ?? parseDbNameFromUrl(baseConnection) ?? void 0;
1646
+ const targetConnection = args["--target-db"] ? withDatabaseName(baseConnection, args["--target-db"]) : baseConnection;
1647
+ out("");
1648
+ out(chalk.bold(" ♻️ Rebase DB Restore"));
1649
+ out(chalk.gray(` Source: ${backupArg}`));
1650
+ out(chalk.gray(` Target: ${targetDb ?? "(from DATABASE_URL)"}`));
1651
+ out("");
1652
+ await checkDatabaseConnectivity(baseConnection);
1653
+ let localFile;
1654
+ let globalsSql = null;
1655
+ let cleanupTemp = false;
1656
+ try {
1657
+ if (/^(s3|gs):\/\//.test(backupArg)) {
1658
+ const storage = await resolveStorageForDestination(parseBackupDestination(backupArg.replace(/\/[^/]+$/, "")), process.env);
1659
+ if (!storage) throw new BackupToolError("Could not resolve storage for the given URL.");
1660
+ const key = backupArg.replace(/^(s3|gs):\/\/[^/]+\//, "");
1661
+ const bucket = backupArg.replace(/^(s3|gs):\/\/([^/]+)\/.*$/, "$2");
1662
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-restore-"));
1663
+ localFile = path.join(tmpDir, path.basename(key));
1664
+ cleanupTemp = true;
1665
+ const object = storage.getObjectStream ? await storage.getObjectStream(key, { bucket }) : await storage.getObject(key, bucket).then((file) => file && { stream: file.stream() });
1666
+ if (!object) throw new BackupToolError(`Backup not found in storage: ${backupArg}`);
1667
+ await pipeline(Readable.from(webChunks(object.stream)), fs.createWriteStream(localFile));
1668
+ const globalsObj = await storage.getObject(globalsFileForDump(key), bucket);
1669
+ if (globalsObj) globalsSql = Buffer.from(await globalsObj.arrayBuffer()).toString("utf-8");
1670
+ } else {
1671
+ localFile = path.resolve(invocationDir(), backupArg);
1672
+ if (!fs.existsSync(localFile)) throw new BackupToolError(`Backup file not found: ${localFile}`);
1673
+ const globalsPath = globalsFileForDump(localFile);
1674
+ if (fs.existsSync(globalsPath)) globalsSql = fs.readFileSync(globalsPath, "utf-8");
1675
+ }
1676
+ const pf = await preflight("pg_restore", baseConnection);
1677
+ if (!pf.compatible) throw new BackupToolError(pf.reason ?? "pg_restore cannot restore into this server.");
1678
+ if (args["--create-db"] && !targetDb) throw new BackupToolError("--create-db requires a resolvable target database name (use --target-db).");
1679
+ const objects = await readDumpObjects(localFile);
1680
+ if (!args["--clean"]) {
1681
+ const conflicts = await findRestoreConflicts(targetConnection, objects);
1682
+ if (conflicts.length > 0) {
1683
+ const { message, hint } = describeRestoreConflict(targetDb ?? "the target database", backupArg, conflicts, {
1684
+ targetDb: args["--target-db"],
1685
+ carried: [
1686
+ ...args["--database-url"] ? ["--database-url", `"${args["--database-url"]}"`] : [],
1687
+ ...args["--docker"] ? ["--docker"] : [],
1688
+ ...args["--no-owner"] ? ["--no-owner"] : [],
1689
+ ...args["--yes"] ? ["--yes"] : []
1690
+ ]
1691
+ });
1692
+ throw new BackupToolError(message, hint);
1693
+ }
1694
+ }
1695
+ const replacing = args["--clean"] === true && args["--continue-on-error"] !== true;
1696
+ if (replacing) {
1697
+ if (!resolvePgBinary("psql")) throw new BackupToolError("Could not find the 'psql' binary, which runs a --clean restore as one transaction. Nothing was restored or changed.", "Install the PostgreSQL client tools (they ship psql beside pg_restore), or set PSQL_PATH to its full path.");
1698
+ const extras = describeCleanExtras(objects.schemas, await findCleanExtras(targetConnection, objects));
1699
+ if (extras.length > 0) {
1700
+ outWarn(chalk.yellow(` ⚠️ ${extras[0]}`));
1701
+ for (const extra of extras.slice(1)) outWarn(chalk.yellow(` ${extra}`));
1702
+ }
1703
+ }
1704
+ if (!await confirmRestore({
1705
+ target: targetDb ?? "the target database",
1706
+ yes: args["--yes"] === true,
1707
+ interactive: process.stdin.isTTY === true,
1708
+ ask: promptConfirm
1709
+ })) {
1710
+ process.exitCode = 1;
1711
+ return;
1712
+ }
1713
+ if (args["--create-db"]) {
1714
+ const created = await ensureDatabaseExists(baseConnection, targetDb);
1715
+ out(chalk.gray(created ? ` ✓ Created database "${targetDb}".` : ` • Database "${targetDb}" already exists.`));
1716
+ }
1717
+ let rolesApplied = 0;
1718
+ if (globalsSql) {
1719
+ out(chalk.gray(" Recreating cluster roles from the backup's roles sidecar…"));
1720
+ const { applied, skipped } = await applyGlobals(targetConnection, globalsSql, (m) => out(chalk.gray(m)));
1721
+ rolesApplied = applied;
1722
+ out(chalk.gray(` ✓ Roles: ${applied} applied, ${skipped} skipped (already present or not permitted).`));
1723
+ } else {
1724
+ outWarn(chalk.yellow(" ⚠️ No roles sidecar (.globals.sql) accompanies this backup."));
1725
+ outWarn(chalk.yellow(" If the dump grants to roles that don't exist (e.g. rebase_user), the restore"));
1726
+ outWarn(chalk.yellow(" will fail — recreate those roles first, or use a backup that includes its globals."));
1727
+ }
1728
+ try {
1729
+ if (replacing) await restoreDumpReplacing({
1730
+ connectionString: targetConnection,
1731
+ inputFile: localFile,
1732
+ objects,
1733
+ noOwner: args["--no-owner"],
1734
+ inheritStdio: true
1735
+ });
1736
+ else await restoreDump({
1737
+ connectionString: targetConnection,
1738
+ inputFile: localFile,
1739
+ clean: args["--clean"],
1740
+ noOwner: args["--no-owner"],
1741
+ exitOnError: !args["--continue-on-error"],
1742
+ inheritStdio: true
1743
+ });
1744
+ } catch (err) {
1745
+ if (err instanceof BackupToolError) throw err;
1746
+ if (err instanceof CleanRestoreFailed) {
1747
+ const { message, hint } = describeCleanRestoreFailure(targetDb ?? "the target database", err, rolesApplied);
1748
+ throw new BackupToolError(message, hint);
1749
+ }
1750
+ throw new BackupToolError(`pg_restore stopped on the error above. What it restored before that stays in "${targetDb ?? "the target database"}".`, rolesApplied > 0 ? `The ${rolesApplied} cluster role(s) recreated from the backup's sidecar before it stay too.` : void 0);
1751
+ }
1752
+ out("");
1753
+ out(chalk.green(` ✓ Restore completed into "${targetDb ?? "the target database"}".`));
1754
+ out("");
1755
+ } catch (err) {
1756
+ reportError(err);
1757
+ process.exitCode = 1;
1758
+ } finally {
1759
+ if (cleanupTemp && typeof localFile === "string" && fs.existsSync(localFile)) fs.rmSync(path.dirname(localFile), {
1760
+ recursive: true,
1761
+ force: true
1762
+ });
1763
+ }
1764
+ }
1765
+ var BACKUPS_ACTIONS = ["list"];
1766
+ var BACKUPS_LIST_FLAGS = {
1767
+ "--out": String,
1768
+ "--output": "--out",
1769
+ "-o": "--out"
1770
+ };
1771
+ /**
1772
+ * Parse a `rebase db backups list …` line, or its `rebase db backup list …`
1773
+ * spelling: the flags start after `list`, the third word of either.
1774
+ */
1775
+ function parseBackupsListLine(rawArgs) {
1776
+ return parseCommandFlags(BACKUPS_LIST_FLAGS, rawArgs.slice(3), "db backups list");
1777
+ }
1778
+ /** What `db backups <action>` says of an action it does not have: the sentence every `db` command says. */
1779
+ function unknownBackupsAction(action) {
1780
+ const near = closestWord(action, BACKUPS_ACTIONS);
1781
+ return near ? `Unknown db backups command "${action}" — did you mean \`${near}\`? Run \`rebase db backups --help\` for the rest.` : `Unknown db backups command "${action}". Run \`rebase db backups --help\` for the ones there are.`;
1782
+ }
1783
+ async function backupsCommand(rawArgs) {
1784
+ const action = rawArgs[2];
1785
+ if (!action || action === "--help") {
1786
+ printBackupsHelp();
1787
+ return;
1788
+ }
1789
+ if (action !== "list") {
1790
+ outError(chalk.red(`✗ ${unknownBackupsAction(action)}`));
1791
+ process.exit(1);
1792
+ }
1793
+ const args = parseBackupsListLine(rawArgs);
1794
+ const out$2 = backupLocation(args["--out"]);
1795
+ const dest = parseBackupDestination(out$2);
1796
+ try {
1797
+ const backups = await listBackups(dest, await resolveStorageForDestination(dest, process.env) ?? void 0);
1798
+ out("");
1799
+ if (backups.length === 0) {
1800
+ out(chalk.gray(` No backups found at ${out$2}.`));
1801
+ await mentionBackendBackups(out$2, args["--out"]);
1802
+ } else {
1803
+ out(chalk.bold(` 💾 ${backups.length} backup(s) at ${out$2}:`));
1804
+ out("");
1805
+ for (const b of backups) {
1806
+ const when = b.createdAt ? b.createdAt.toISOString() : "unknown date";
1807
+ const name = dest.kind === "local" ? path.basename(b.key) : b.key;
1808
+ const empty = b.sizeBytes === 0;
1809
+ const size = b.sizeBytes === void 0 ? "" : ` — ${formatBytes(b.sizeBytes)}`;
1810
+ if (empty) out(` ${chalk.red("○")} ${chalk.bold(name)} ${chalk.gray(`— ${when}`)}${chalk.red(" — EMPTY, not restorable")}`);
1811
+ else out(` ${chalk.green("●")} ${chalk.bold(name)} ${chalk.gray(`— ${when}${size}`)}`);
1812
+ }
1813
+ if (backups.some((b) => b.sizeBytes === 0)) {
1814
+ out("");
1815
+ out(chalk.yellow(" ⚠ Empty files above are leftovers from a failed backup. Delete them:"));
1816
+ out(chalk.gray(" they count as recent backups for retention but restore nothing."));
1817
+ }
1818
+ }
1819
+ out("");
1820
+ } catch (err) {
1821
+ reportError(err);
1822
+ process.exit(1);
1823
+ }
1824
+ }
1825
+ /**
1826
+ * Point at `<backend>/backups` when the default listing is empty and that
1827
+ * directory holds dumps.
1828
+ *
1829
+ * Earlier releases resolved the default there, so after an upgrade the
1830
+ * backups a project already has are one directory away from the listing.
1831
+ */
1832
+ async function mentionBackendBackups(listed, flag) {
1833
+ if (flag || process.env.BACKUP_DESTINATION?.trim()) return;
1834
+ const backendBackups = path.join(process.cwd(), "backups");
1835
+ if (path.resolve(listed) === backendBackups) return;
1836
+ const found = await listBackups({
1837
+ kind: "local",
1838
+ path: backendBackups
1839
+ }).catch(() => []);
1840
+ if (found.length === 0) return;
1841
+ out(chalk.gray(` ${found.length} backup(s) are in ${backendBackups}, where earlier releases of`));
1842
+ out(chalk.gray(` \`rebase db backup\` wrote by default. List them with:`));
1843
+ out(chalk.gray(` rebase db backups list --out ${path.relative(invocationDir(), backendBackups) || "."}`));
1844
+ }
1845
+ /**
1846
+ * Verify a freshly written dump; abort the command if it looks corrupt.
1847
+ *
1848
+ * Discards the artifact on the way out. Refusing to *report* success was never
1849
+ * enough on its own: the file stayed on disk, `rebase db backups list` showed
1850
+ * it as an ordinary entry, and retention — which ranks by timestamp and never
1851
+ * looks at size — would protect it as one of the `keepMinimum` newest while
1852
+ * pruning a real backup underneath it. The roles sidecar goes too; the two are
1853
+ * uploaded and pruned as a pair, so half a pair is not a backup either.
1854
+ */
1855
+ async function assertDumpValid(localFile) {
1856
+ const check = await validateDump(localFile);
1857
+ if (!check.ok) {
1858
+ discardPartialDump(globalsFileForDump(localFile));
1859
+ discardPartialDump(localFile);
1860
+ throw new BackupToolError(`The backup failed validation and was discarded: ${check.reason}`, "The dump was corrupt or truncated, so nothing was kept. Investigate before relying on this destination.");
1861
+ }
1862
+ }
1863
+ /**
1864
+ * Say why a backup command failed, with no password in it.
1865
+ *
1866
+ * What reaches here is often a client tool's failure, and execa's message
1867
+ * repeats the tool's command line. The tools get the password through
1868
+ * `PGPASSWORD` now, so it is not on that line, but a connection string can
1869
+ * still arrive inside a message from elsewhere; this is the last place to
1870
+ * stop it reaching a terminal or a CI log.
1871
+ */
1872
+ function reportError(err) {
1873
+ if (err instanceof BackupToolError) {
1874
+ outError(chalk.red(` ✗ ${redactConnectionPasswords(err.message)}`));
1875
+ if (err.hint) outError(chalk.gray(` ${redactConnectionPasswords(err.hint)}`));
1876
+ } else outError(chalk.red(` ✗ ${redactConnectionPasswords(err instanceof Error ? err.message : String(err))}`));
1877
+ }
1878
+ function printBackupHelp() {
1879
+ out(`
1880
+ ${chalk.bold("rebase db backup")} — Create a database backup (pg_dump, custom format)
1881
+
1882
+ ${chalk.green.bold("Usage")}
1883
+ rebase db backup [--out <path|s3://bucket/prefix>] [options]
1884
+
1885
+ ${chalk.green.bold("Options")}
1886
+ ${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
1887
+ ${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable). Never rebase: a backup
1888
+ without it cannot be restored
1889
+ ${chalk.blue("--no-owner")} Omit ownership commands from the dump
1890
+ ${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
1891
+ ${chalk.red("(may produce a partial dump — see below)")}
1892
+ ${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
1893
+
1894
+ ${chalk.green.bold("Row-level security")}
1895
+ On a managed Postgres the dumping role usually owns nothing and has no
1896
+ BYPASSRLS, so pg_dump refuses:
1897
+
1898
+ ERROR: query would be affected by row-level security policy for table "..."
1899
+
1900
+ That refusal is the safe behaviour. --enable-row-security replaces it by
1901
+ reading as an admin subject: Rebase sets app.uid/app.user_roles so the
1902
+ generated admin_full_access policy admits the dump. The dump then contains
1903
+ exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
1904
+ a table whose policies lack an admin rule comes out short, silently.
1905
+
1906
+ Granting the dumping role BYPASSRLS is the option that keeps a backup
1907
+ meaning "every row".
1908
+
1909
+ ${chalk.green.bold("Notes")}
1910
+ Backups may contain secrets and PII. Use private storage destinations and
1911
+ enable encryption-at-rest. See docs/backups.md.
1912
+ `);
1913
+ }
1914
+ function printRestoreHelp() {
1915
+ out(`
1916
+ ${chalk.bold("rebase db restore")} — Restore a database from a backup (pg_restore)
1917
+
1918
+ ${chalk.green.bold("Usage")}
1919
+ rebase db restore <backup> [options]
1920
+
1921
+ ${chalk.green.bold("Arguments")}
1922
+ <backup> Local .dump file, or s3://…/gs://… object key
1923
+
1924
+ ${chalk.green.bold("Options")}
1925
+ ${chalk.blue("--target-db")} <name> Restore into this database instead of DATABASE_URL's
1926
+ ${chalk.blue("--create-db")} Create the target database first if it doesn't exist
1927
+ ${chalk.blue("--clean")} Replace what the backup holds, in one transaction with
1928
+ the restore (runs the dump through psql)
1929
+ ${chalk.blue("--no-owner")} Ignore ownership from the dump
1930
+ ${chalk.blue("--continue-on-error")} Log and continue past errors ${chalk.red("(may leave RLS un-enforced!)")}
1931
+ ${chalk.blue("--yes, -y")} Skip the interactive confirmation ${chalk.red("(destructive!)")}
1932
+
1933
+ ${chalk.red.bold("Warning")}
1934
+ Restore is destructive and never runs automatically. Without --yes it
1935
+ asks for a typed 'yes' on a terminal, and with no terminal to ask on it
1936
+ refuses. Prefer --create-db/--target-db to restore into a fresh database
1937
+ rather than overwriting a live one. Into a database that already holds
1938
+ what the backup creates, it needs --clean, and refuses before changing
1939
+ anything without it. --clean drops each schema the backup creates whole,
1940
+ with what it holds that the backup does not (it names those first), and
1941
+ restores in the same transaction: a restore that stops changes nothing.
1942
+
1943
+ By default the restore aborts on the first error (--exit-on-error) so a
1944
+ skipped GRANT never silently leaves RLS un-enforced. Roles are recreated
1945
+ from the backup's .globals.sql sidecar first; keep that file next to the
1946
+ dump. Use --continue-on-error only when you understand the consequences.
1947
+ `);
1948
+ }
1949
+ function printBackupsHelp() {
1950
+ out(`
1951
+ ${chalk.bold("rebase db backups")} — Manage stored backups
1952
+
1953
+ ${chalk.green.bold("Usage")}
1954
+ rebase db backups list [--out <path|s3://bucket/prefix>]
1955
+ rebase db backup list [--out <path|s3://bucket/prefix>] ${chalk.gray("(the same thing)")}
1956
+ `);
1957
+ }
1958
+ //#endregion
1959
+ export { buildBackupFilename as A, diagnoseRowSecurityDumpFailure as B, restoreDumpReplacing as C, resolvePgBinary as D, pgToolVariable as E, buildPgRestoreScriptArgs as F, parseDbNameFromUrl as G, joinStorageKey as H, buildPsqlScriptArgs as I, resolveConnectionString as J, parseDumpObjects as K, buildRowSecurityPgOptions as L, buildPgDumpallGlobalsArgs as M, buildPgRestoreArgs as N, selectBackupsToPrune as O, buildPgRestoreListArgs as P, checkToolServerCompatibility as R, restoreDump as S, validateDump as T, parseBackupDestination as U, globalsFileForDump as V, parseBackupTimestamp as W, splitGlobalsStatements as X, serverVersionNumToMajor as Y, withDatabaseName as Z, listBackups as _, BackupToolError as a, readDumpObjects as b, backup_service_exports as c, detectToolMajor as d, discardPartialDump as f, getServerVersionMajor as g, findRestoreConflicts as h, parseRestoreLine as i, buildPgDumpArgs as j, CLEAN_LOCK_TIMEOUT as k, createDump as l, findCleanExtras as m, parseBackupLine as n, CleanRestoreFailed as o, ensureDatabaseExists as p, parsePgToolMajor as q, parseBackupsListLine as r, applyGlobals as s, backup_cli_exports as t, describeUnreadableDump as u, preflight as v, uploadBackup as w, resetSharedSession as x, pruneBackups as y, cleanRestorePrelude as z };
1960
+
1961
+ //# sourceMappingURL=backup-cli-gijeXe0u.js.map