@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Backup / restore orchestration: thin, well-typed wrappers around
3
+ * `pg_dump` and `pg_restore`, plus binary detection, version pre-flight,
4
+ * and upload/prune against a storage backend.
5
+ *
6
+ * The pure argument- and policy-logic lives in `pg-tools.ts` and
7
+ * `retention.ts`; this module is the impure edge (spawns processes, talks
8
+ * to Postgres and storage) and is intentionally kept small.
9
+ */
10
+ import fs from "fs";
11
+ import os from "os";
12
+ import path from "path";
13
+ import { execa } from "execa";
14
+ import type { StorageController } from "@rebasepro/server";
15
+ import { resolveLocalBin } from "../cli-helpers";
16
+ import {
17
+ BackupDestination,
18
+ buildBackupFilename,
19
+ buildPgDumpArgs,
20
+ buildPgRestoreArgs,
21
+ checkToolServerCompatibility,
22
+ joinStorageKey,
23
+ parsePgToolMajor,
24
+ parseBackupTimestamp,
25
+ serverVersionNumToMajor,
26
+ VersionCompatibility
27
+ } from "./pg-tools";
28
+ import { BackupObject, RetentionOptions, selectBackupsToPrune } from "./retention";
29
+
30
+ export class BackupToolError extends Error {
31
+ constructor(message: string, readonly hint?: string) {
32
+ super(message);
33
+ this.name = "BackupToolError";
34
+ }
35
+ }
36
+
37
+ /** Locate `pg_dump` / `pg_restore`, honouring an env override. */
38
+ export function resolvePgBinary(
39
+ tool: "pg_dump" | "pg_restore",
40
+ env: Record<string, string | undefined> = process.env
41
+ ): string | null {
42
+ const override = tool === "pg_dump" ? env.PG_DUMP_PATH : env.PG_RESTORE_PATH;
43
+ if (override && fs.existsSync(override)) return override;
44
+ return resolveLocalBin(tool);
45
+ }
46
+
47
+ /** Run `<bin> --version` and extract the major version. */
48
+ export async function detectToolMajor(bin: string): Promise<number | null> {
49
+ try {
50
+ const { stdout } = await execa(bin, ["--version"]);
51
+ return parsePgToolMajor(stdout);
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** Query the server for its major version via `server_version_num`. */
58
+ export async function getServerVersionMajor(connectionString: string): Promise<number | null> {
59
+ const { Client } = await import("pg");
60
+ const client = new Client({ connectionString });
61
+ await client.connect();
62
+ try {
63
+ const res = await client.query("SHOW server_version_num");
64
+ const raw = res.rows?.[0]?.server_version_num;
65
+ return serverVersionNumToMajor(raw);
66
+ } finally {
67
+ await client.end();
68
+ }
69
+ }
70
+
71
+ export interface PreflightResult extends VersionCompatibility {
72
+ bin: string;
73
+ toolMajor: number | null;
74
+ serverMajor: number | null;
75
+ }
76
+
77
+ /**
78
+ * Verify the requested client tool exists and its major version is
79
+ * compatible with the live server. Throws {@link BackupToolError} — with a
80
+ * doctor-style hint — when the binary is missing.
81
+ */
82
+ export async function preflight(
83
+ tool: "pg_dump" | "pg_restore",
84
+ connectionString: string,
85
+ env: Record<string, string | undefined> = process.env
86
+ ): Promise<PreflightResult> {
87
+ const bin = resolvePgBinary(tool, env);
88
+ if (!bin) {
89
+ throw new BackupToolError(
90
+ `Could not find the '${tool}' binary.`,
91
+ `Install the PostgreSQL client tools (e.g. 'brew install libpq' or 'apt-get install postgresql-client'), ` +
92
+ `or set ${tool === "pg_dump" ? "PG_DUMP_PATH" : "PG_RESTORE_PATH"} to its full path.`
93
+ );
94
+ }
95
+ const [toolMajor, serverMajor] = await Promise.all([
96
+ detectToolMajor(bin),
97
+ getServerVersionMajor(connectionString)
98
+ ]);
99
+ const compat = checkToolServerCompatibility(toolMajor, serverMajor);
100
+ return { bin, toolMajor, serverMajor, ...compat };
101
+ }
102
+
103
+ export interface BackupResult {
104
+ /** Absolute path of the produced dump file on local disk. */
105
+ localFile: string;
106
+ fileName: string;
107
+ sizeBytes: number;
108
+ }
109
+
110
+ /**
111
+ * Produce a custom-format dump on local disk. When `outDir` is omitted the
112
+ * file is written to the OS temp directory (used by the upload path, which
113
+ * cleans it up afterwards).
114
+ */
115
+ export async function createDump(opts: {
116
+ connectionString: string;
117
+ dbName: string;
118
+ outDir?: string;
119
+ fileName?: string;
120
+ excludeSchemas?: string[];
121
+ noOwner?: boolean;
122
+ inheritStdio?: boolean;
123
+ env?: Record<string, string | undefined>;
124
+ }): Promise<BackupResult> {
125
+ const env = opts.env ?? process.env;
126
+ const bin = resolvePgBinary("pg_dump", env);
127
+ if (!bin) {
128
+ throw new BackupToolError(
129
+ "Could not find the 'pg_dump' binary.",
130
+ "Install the PostgreSQL client tools or set PG_DUMP_PATH."
131
+ );
132
+ }
133
+
134
+ const fileName = opts.fileName ?? buildBackupFilename(opts.dbName);
135
+ const outDir = opts.outDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "rebase-backup-"));
136
+ fs.mkdirSync(outDir, { recursive: true });
137
+ const localFile = path.join(outDir, fileName);
138
+
139
+ const args = buildPgDumpArgs({
140
+ connectionString: opts.connectionString,
141
+ outFile: localFile,
142
+ excludeSchemas: opts.excludeSchemas,
143
+ noOwner: opts.noOwner
144
+ });
145
+
146
+ await execa(bin, args, {
147
+ stdio: opts.inheritStdio ? "inherit" : "pipe",
148
+ env: { ...(env as Record<string, string>) }
149
+ });
150
+
151
+ const sizeBytes = fs.existsSync(localFile) ? fs.statSync(localFile).size : 0;
152
+ return { localFile, fileName, sizeBytes };
153
+ }
154
+
155
+ /**
156
+ * Restore a custom-format dump into the database named by
157
+ * `connectionString`. Destructive when `clean` is set (drops objects
158
+ * first). Never called automatically — the CLI gates it behind explicit
159
+ * confirmation.
160
+ */
161
+ export async function restoreDump(opts: {
162
+ connectionString: string;
163
+ inputFile: string;
164
+ clean?: boolean;
165
+ noOwner?: boolean;
166
+ inheritStdio?: boolean;
167
+ env?: Record<string, string | undefined>;
168
+ }): Promise<void> {
169
+ const env = opts.env ?? process.env;
170
+ const bin = resolvePgBinary("pg_restore", env);
171
+ if (!bin) {
172
+ throw new BackupToolError(
173
+ "Could not find the 'pg_restore' binary.",
174
+ "Install the PostgreSQL client tools or set PG_RESTORE_PATH."
175
+ );
176
+ }
177
+ const args = buildPgRestoreArgs({
178
+ connectionString: opts.connectionString,
179
+ inputFile: opts.inputFile,
180
+ clean: opts.clean,
181
+ noOwner: opts.noOwner
182
+ });
183
+ await execa(bin, args, {
184
+ stdio: opts.inheritStdio ? "inherit" : "pipe",
185
+ env: { ...(env as Record<string, string>) }
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Create a database (if absent) by connecting to the maintenance
191
+ * `postgres` database. Used by `restore --create-db`.
192
+ */
193
+ export async function ensureDatabaseExists(
194
+ adminConnectionString: string,
195
+ dbName: string
196
+ ): Promise<boolean> {
197
+ const { Client } = await import("pg");
198
+ const parsed = new URL(adminConnectionString);
199
+ parsed.pathname = "/postgres";
200
+ const client = new Client({ connectionString: parsed.toString() });
201
+ await client.connect();
202
+ try {
203
+ const existing = await client.query("SELECT 1 FROM pg_database WHERE datname = $1", [dbName]);
204
+ if (existing.rowCount && existing.rowCount > 0) {
205
+ return false;
206
+ }
207
+ // Identifier can't be parameterised; guard against injection.
208
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(dbName)) {
209
+ throw new BackupToolError(`Invalid database name: "${dbName}"`);
210
+ }
211
+ await client.query(`CREATE DATABASE "${dbName}"`);
212
+ return true;
213
+ } finally {
214
+ await client.end();
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Upload a local dump to object storage under the destination's prefix.
220
+ * Backups may contain secrets/PII, so the object is written with a
221
+ * private/octet-stream content type; bucket-level ACLs must stay private.
222
+ */
223
+ export async function uploadBackup(
224
+ storage: StorageController,
225
+ localFile: string,
226
+ dest: Extract<BackupDestination, { kind: "s3" | "gcs" }>
227
+ ): Promise<{ key: string; storageUrl: string }> {
228
+ const fileName = path.basename(localFile);
229
+ const key = joinStorageKey(dest.prefix, fileName);
230
+ const buffer = fs.readFileSync(localFile);
231
+ const file = new File([buffer], fileName, { type: "application/octet-stream" });
232
+ const result = await storage.putObject({
233
+ file,
234
+ key,
235
+ bucket: dest.bucket,
236
+ metadata: { "rebase-backup": "1" }
237
+ });
238
+ return { key, storageUrl: result.storageUrl ?? `${dest.kind}://${dest.bucket}/${key}` };
239
+ }
240
+
241
+ /**
242
+ * List existing backups at a destination. For local destinations reads the
243
+ * directory; for object storage lists the prefix via the controller.
244
+ */
245
+ export async function listBackups(
246
+ dest: BackupDestination,
247
+ storage?: StorageController
248
+ ): Promise<BackupObject[]> {
249
+ if (dest.kind === "local") {
250
+ if (!fs.existsSync(dest.path)) return [];
251
+ const stat = fs.statSync(dest.path);
252
+ const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);
253
+ return fs
254
+ .readdirSync(dir)
255
+ .filter((f) => f.endsWith(".dump"))
256
+ .map((f) => {
257
+ const full = path.join(dir, f);
258
+ const createdAt = parseBackupTimestamp(f) ?? fs.statSync(full).mtime;
259
+ return { key: full, createdAt };
260
+ })
261
+ .sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
262
+ }
263
+
264
+ if (!storage) {
265
+ throw new BackupToolError(
266
+ `Listing ${dest.kind} backups requires a configured storage backend.`
267
+ );
268
+ }
269
+ const result = await storage.listObjects(dest.prefix ? `${dest.prefix}/` : "", {
270
+ bucket: dest.bucket,
271
+ maxResults: 1000
272
+ });
273
+ return result.items
274
+ .map((item) => item.fullPath)
275
+ .filter((key) => key.endsWith(".dump"))
276
+ .map((key) => ({ key, createdAt: parseBackupTimestamp(key) ?? undefined }))
277
+ .sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
278
+ }
279
+
280
+ /**
281
+ * Apply a retention policy to a destination, deleting the backups selected
282
+ * by {@link selectBackupsToPrune}. Returns the keys that were removed.
283
+ */
284
+ export async function pruneBackups(
285
+ dest: BackupDestination,
286
+ options: RetentionOptions,
287
+ storage?: StorageController
288
+ ): Promise<string[]> {
289
+ const backups = await listBackups(dest, storage);
290
+ const toDelete = selectBackupsToPrune(backups, options);
291
+ for (const key of toDelete) {
292
+ if (dest.kind === "local") {
293
+ if (fs.existsSync(key)) fs.unlinkSync(key);
294
+ } else if (storage) {
295
+ await storage.deleteObject(key, dest.bucket);
296
+ }
297
+ }
298
+ return toDelete;
299
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Backup / restore for self-hosted rebase.
3
+ *
4
+ * - `pg-tools` — pure argument/version/path helpers (unit-tested)
5
+ * - `retention` — pure retention-pruning policy (unit-tested)
6
+ * - `backup-service`— pg_dump/pg_restore + storage orchestration
7
+ * - `backup-cron` — scheduled backups for the server cron system
8
+ */
9
+ export * from "./pg-tools";
10
+ export * from "./retention";
11
+ export * from "./backup-service";
12
+ export * from "./backup-cron";
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Pure helpers for the backup/restore commands.
3
+ *
4
+ * Everything in this file is side-effect free so it can be unit-tested
5
+ * without a live Postgres server, matching the constraint that CI must
6
+ * not require a database.
7
+ */
8
+
9
+ /**
10
+ * A parsed backup destination. `--out` (and the scheduled-backup config)
11
+ * accepts either a local filesystem path or an object-storage URL.
12
+ */
13
+ export type BackupDestination =
14
+ | { kind: "local"; path: string }
15
+ | { kind: "s3"; bucket: string; prefix: string }
16
+ | { kind: "gcs"; bucket: string; prefix: string };
17
+
18
+ /**
19
+ * Extract the database name from a Postgres connection string.
20
+ * Returns `null` when the URL has no database path (e.g. bare host).
21
+ */
22
+ export function parseDbNameFromUrl(connectionString: string): string | null {
23
+ try {
24
+ const parsed = new URL(connectionString);
25
+ const name = parsed.pathname.replace(/^\//, "").trim();
26
+ return name.length > 0 ? name : null;
27
+ } catch {
28
+ // Fall back to a permissive regex for non-URL DSNs.
29
+ const match = connectionString.match(/\/([^/?]+)(\?|$)/);
30
+ return match && match[1] ? match[1] : null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Swap the database name in a connection string, preserving credentials,
36
+ * host, port and query params. Used by `--target-db` / `--create-db` so a
37
+ * restore can target a fresh database instead of clobbering the live one.
38
+ */
39
+ export function withDatabaseName(connectionString: string, dbName: string): string {
40
+ try {
41
+ const parsed = new URL(connectionString);
42
+ parsed.pathname = `/${dbName}`;
43
+ return parsed.toString();
44
+ } catch {
45
+ return connectionString.replace(/\/([^/?]+)(\?|$)/, `/${dbName}$2`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Parse the major version out of `pg_dump --version` / `pg_restore --version`
51
+ * output, e.g. `"pg_dump (PostgreSQL) 16.2"` → `16`. Handles the pre-10
52
+ * `9.6.x` scheme (returns `9`) and beta strings like `"17beta1"`.
53
+ */
54
+ export function parsePgToolMajor(versionOutput: string): number | null {
55
+ const match = versionOutput.match(/(\d+)(?:\.(\d+))?/);
56
+ if (!match) return null;
57
+ const major = Number(match[1]);
58
+ if (!Number.isFinite(major)) return null;
59
+ // Postgres 9.x used the first two numbers as the major (9.6, 9.4…).
60
+ if (major === 9 && match[2] !== undefined) {
61
+ return 9;
62
+ }
63
+ return major;
64
+ }
65
+
66
+ /**
67
+ * Convert `SELECT current_setting('server_version_num')` (e.g. `160002`)
68
+ * into a major version (`16`). Also accepts pre-10 encodings like `90603`
69
+ * → `9`.
70
+ */
71
+ export function serverVersionNumToMajor(versionNum: number | string): number | null {
72
+ const num = typeof versionNum === "string" ? Number(versionNum) : versionNum;
73
+ if (!Number.isFinite(num) || num <= 0) return null;
74
+ if (num < 100000) {
75
+ // 9.x scheme: 90603 = 9.6.3
76
+ return Math.floor(num / 10000);
77
+ }
78
+ return Math.floor(num / 10000);
79
+ }
80
+
81
+ export interface VersionCompatibility {
82
+ compatible: boolean;
83
+ reason?: string;
84
+ }
85
+
86
+ /**
87
+ * pg_dump / pg_restore must be **at least** as new as the server they talk
88
+ * to. A newer client against an older server is supported; an older client
89
+ * against a newer server is not and produces corrupt or rejected output.
90
+ */
91
+ export function checkToolServerCompatibility(
92
+ toolMajor: number | null,
93
+ serverMajor: number | null
94
+ ): VersionCompatibility {
95
+ if (toolMajor === null) {
96
+ return { compatible: false, reason: "Could not determine the client tool version." };
97
+ }
98
+ if (serverMajor === null) {
99
+ return { compatible: false, reason: "Could not determine the Postgres server version." };
100
+ }
101
+ if (toolMajor < serverMajor) {
102
+ return {
103
+ compatible: false,
104
+ reason:
105
+ `Client tool is Postgres ${toolMajor} but the server is Postgres ${serverMajor}. ` +
106
+ `pg_dump/pg_restore must be the same major version as the server or newer. ` +
107
+ `Install Postgres ${serverMajor} client tools.`
108
+ };
109
+ }
110
+ return { compatible: true };
111
+ }
112
+
113
+ /**
114
+ * Build a deterministic, sortable backup file name:
115
+ * `rebase-<db>-<YYYYMMDD>T<HHMMSS>Z.dump`
116
+ *
117
+ * The UTC timestamp is embedded so retention pruning can recover the
118
+ * creation time from the object key alone, without extra metadata.
119
+ */
120
+ export function buildBackupFilename(dbName: string, date: Date = new Date()): string {
121
+ const iso = date.toISOString(); // 2026-07-14T09:12:03.123Z
122
+ const stamp = iso.replace(/\.\d+Z$/, "Z").replace(/[-:]/g, "");
123
+ const safeDb = dbName.replace(/[^a-zA-Z0-9_-]/g, "_");
124
+ return `rebase-${safeDb}-${stamp}.dump`;
125
+ }
126
+
127
+ /**
128
+ * Recover the creation timestamp encoded in a backup file name by
129
+ * {@link buildBackupFilename}. Returns `null` for names that don't match,
130
+ * so foreign objects in a shared prefix are never pruned.
131
+ */
132
+ export function parseBackupTimestamp(fileName: string): Date | null {
133
+ const base = fileName.split("/").pop() ?? fileName;
134
+ const match = base.match(/-(\d{8})T(\d{6})Z\.dump$/);
135
+ if (!match) return null;
136
+ const [, ymd, hms] = match;
137
+ const iso =
138
+ `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}` +
139
+ `T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;
140
+ const date = new Date(iso);
141
+ return Number.isNaN(date.getTime()) ? null : date;
142
+ }
143
+
144
+ /**
145
+ * Parse a destination string into a structured {@link BackupDestination}.
146
+ * `s3://bucket/prefix` and `gs://bucket/prefix` map to object storage;
147
+ * anything else is treated as a local path.
148
+ */
149
+ export function parseBackupDestination(out: string): BackupDestination {
150
+ const s3 = out.match(/^s3:\/\/([^/]+)\/?(.*)$/);
151
+ if (s3) {
152
+ return { kind: "s3", bucket: s3[1], prefix: stripTrailingSlash(s3[2]) };
153
+ }
154
+ const gcs = out.match(/^gs:\/\/([^/]+)\/?(.*)$/);
155
+ if (gcs) {
156
+ return { kind: "gcs", bucket: gcs[1], prefix: stripTrailingSlash(gcs[2]) };
157
+ }
158
+ return { kind: "local", path: out };
159
+ }
160
+
161
+ function stripTrailingSlash(s: string): string {
162
+ return s.replace(/\/+$/, "");
163
+ }
164
+
165
+ /**
166
+ * Join a storage prefix and a file name without producing a leading or
167
+ * doubled slash.
168
+ */
169
+ export function joinStorageKey(prefix: string, fileName: string): string {
170
+ const clean = prefix.replace(/^\/+|\/+$/g, "");
171
+ return clean.length > 0 ? `${clean}/${fileName}` : fileName;
172
+ }
173
+
174
+ /**
175
+ * Assemble the `pg_dump` argument vector. Uses the custom format (`-Fc`),
176
+ * which is compressed and restorable selectively via `pg_restore`.
177
+ */
178
+ export function buildPgDumpArgs(opts: {
179
+ connectionString: string;
180
+ outFile: string;
181
+ /** Extra schemas/tables to exclude, e.g. Atlas revision tables. */
182
+ excludeSchemas?: string[];
183
+ /** Number of parallel jobs (directory format only; ignored for -Fc). */
184
+ noOwner?: boolean;
185
+ }): string[] {
186
+ const args = ["--format=custom", "--no-password", `--file=${opts.outFile}`];
187
+ if (opts.noOwner) {
188
+ args.push("--no-owner");
189
+ }
190
+ for (const schema of opts.excludeSchemas ?? []) {
191
+ args.push(`--exclude-schema=${schema}`);
192
+ }
193
+ args.push(opts.connectionString);
194
+ return args;
195
+ }
196
+
197
+ /**
198
+ * Assemble the `pg_restore` argument vector for a custom-format dump.
199
+ */
200
+ export function buildPgRestoreArgs(opts: {
201
+ connectionString: string;
202
+ inputFile: string;
203
+ /** Drop objects before recreating them (destructive but idempotent). */
204
+ clean?: boolean;
205
+ /** Continue past individual errors instead of aborting. */
206
+ exitOnError?: boolean;
207
+ noOwner?: boolean;
208
+ }): string[] {
209
+ const args = ["--format=custom", "--no-password", `--dbname=${opts.connectionString}`];
210
+ if (opts.clean) {
211
+ args.push("--clean", "--if-exists");
212
+ }
213
+ if (opts.noOwner) {
214
+ args.push("--no-owner");
215
+ }
216
+ if (opts.exitOnError) {
217
+ args.push("--exit-on-error");
218
+ }
219
+ args.push(opts.inputFile);
220
+ return args;
221
+ }
222
+
223
+ /**
224
+ * Resolve the Postgres connection string the backup commands should use,
225
+ * mirroring the precedence the branch command already relies on.
226
+ */
227
+ export function resolveConnectionString(
228
+ env: Record<string, string | undefined>
229
+ ): string | null {
230
+ return env.DATABASE_URL || env.ADMIN_CONNECTION_STRING || null;
231
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Retention pruning for scheduled backups. Pure logic — no I/O — so it can
3
+ * be exhaustively unit-tested without touching a storage backend.
4
+ */
5
+ import { parseBackupTimestamp } from "./pg-tools";
6
+
7
+ export interface BackupObject {
8
+ /** Storage key / file name. */
9
+ key: string;
10
+ /**
11
+ * Creation time. Optional — when absent it is recovered from the
12
+ * timestamp encoded in the key by `buildBackupFilename`.
13
+ */
14
+ createdAt?: Date;
15
+ }
16
+
17
+ export interface RetentionOptions {
18
+ /** Delete backups older than this many days. `0`/negative disables age pruning. */
19
+ retentionDays?: number;
20
+ /**
21
+ * Always keep at least this many of the most recent backups, even if
22
+ * they are older than `retentionDays`. Guards against wiping every
23
+ * backup after a long outage. Default: 0 (no minimum).
24
+ */
25
+ keepMinimum?: number;
26
+ /** Reference "now" — injectable for deterministic tests. */
27
+ now?: Date;
28
+ }
29
+
30
+ /**
31
+ * Decide which backups to delete under the given retention policy.
32
+ *
33
+ * Rules, applied in order:
34
+ * 1. Objects whose key isn't a recognised rebase backup (no parseable
35
+ * timestamp and no `createdAt`) are never selected — foreign files in a
36
+ * shared bucket are left untouched.
37
+ * 2. The `keepMinimum` newest backups are always retained.
38
+ * 3. Of the remainder, any older than `retentionDays` are selected for
39
+ * deletion.
40
+ *
41
+ * Returns the keys to delete, oldest first.
42
+ */
43
+ export function selectBackupsToPrune(
44
+ backups: BackupObject[],
45
+ options: RetentionOptions
46
+ ): string[] {
47
+ const retentionDays = options.retentionDays ?? 0;
48
+ const keepMinimum = Math.max(0, options.keepMinimum ?? 0);
49
+ const now = options.now ?? new Date();
50
+
51
+ if (retentionDays <= 0) {
52
+ return [];
53
+ }
54
+
55
+ // Resolve a timestamp for every object; drop the ones we can't date.
56
+ const dated = backups
57
+ .map((b) => {
58
+ const createdAt = b.createdAt ?? parseBackupTimestamp(b.key);
59
+ return createdAt ? { key: b.key, createdAt } : null;
60
+ })
61
+ .filter((b): b is { key: string; createdAt: Date } => b !== null);
62
+
63
+ // Newest first so the first `keepMinimum` are the protected ones.
64
+ dated.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
65
+
66
+ const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
67
+
68
+ const toDelete = dated
69
+ .slice(keepMinimum)
70
+ .filter((b) => b.createdAt.getTime() < cutoff);
71
+
72
+ // Return oldest first for predictable, readable logs.
73
+ toDelete.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
74
+ return toDelete.map((b) => b.key);
75
+ }