@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.
- package/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- 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
|
+
}
|