backupdrill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * manifest.json —— 每次快照的自描述清单。
3
+ * 云版演练靠它做校验(表数量、行数对比、体积异常告警);开源侧至少让用户能核对
4
+ * "这次到底备了什么、有多大、校验和是多少"。
5
+ */
6
+ export interface TableStat {
7
+ schema: string;
8
+ name: string;
9
+ estimatedRows: number;
10
+ }
11
+ export interface Manifest {
12
+ tool: "backupdrill-cli";
13
+ toolVersion: string;
14
+ createdAt: string;
15
+ projectName: string;
16
+ database: {
17
+ serverVersion: string;
18
+ pgDumpVersion: string;
19
+ schemas: string[];
20
+ tableCount: number;
21
+ estimatedRowTotal: number;
22
+ tables: TableStat[];
23
+ };
24
+ dump: {
25
+ key: string;
26
+ format: "custom";
27
+ bytes: number;
28
+ sha256: string;
29
+ };
30
+ storage: null | {
31
+ fileCount: number;
32
+ totalBytes: number;
33
+ files: StorageFile[];
34
+ };
35
+ }
36
+ export interface StorageFile {
37
+ bucket: string;
38
+ key: string;
39
+ bytes: number;
40
+ sha256: string;
41
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { BackupConfig } from "./config.js";
2
+ export interface RestoreResult {
3
+ snapshot: string;
4
+ restoredToDatabase: boolean;
5
+ storageFilesWritten: number;
6
+ storageDir?: string;
7
+ }
8
+ /**
9
+ * 把一份快照恢复出来:数据库还原进 --target-database-url;Storage 文件下载到本地目录
10
+ * (拿回文件后你可以按需回传到新 Supabase 项目)。这是 US-5 恢复向导的 DIY 版本。
11
+ */
12
+ export declare function runRestore(config: BackupConfig, opts: {
13
+ targetDatabaseUrl?: string;
14
+ snapshot?: string;
15
+ storageDir?: string;
16
+ }): Promise<RestoreResult>;
@@ -0,0 +1,77 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, mkdir, rm } from "node:fs/promises";
3
+ import { createWriteStream } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, dirname } from "node:path";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { GetObjectCommand } from "@aws-sdk/client-s3";
8
+ import { targetClient, resolveSnapshot, getObjectText, downloadToFile, } from "./snapshots.js";
9
+ import { log } from "./log.js";
10
+ function pgRestore(dumpPath, targetUrl) {
11
+ const bin = process.env.BACKUPDRILL_PG_RESTORE ||
12
+ (process.env.BACKUPDRILL_PG_DUMP
13
+ ? process.env.BACKUPDRILL_PG_DUMP.replace(/pg_dump$/, "pg_restore")
14
+ : "pg_restore");
15
+ return new Promise((resolve, reject) => {
16
+ const proc = spawn(bin, ["--clean", "--if-exists", "--no-owner", "--no-privileges", "--dbname", targetUrl, dumpPath], { stdio: ["ignore", "ignore", "pipe"] });
17
+ let stderr = "";
18
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
19
+ proc.on("error", reject);
20
+ proc.on("close", (code) => code === 0 || !/error:/i.test(stderr)
21
+ ? resolve()
22
+ : reject(new Error(`pg_restore failed: ${stderr.trim()}`)));
23
+ });
24
+ }
25
+ /**
26
+ * 把一份快照恢复出来:数据库还原进 --target-database-url;Storage 文件下载到本地目录
27
+ * (拿回文件后你可以按需回传到新 Supabase 项目)。这是 US-5 恢复向导的 DIY 版本。
28
+ */
29
+ export async function runRestore(config, opts) {
30
+ const s3 = targetClient(config);
31
+ const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
32
+ const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
33
+ const manifest = JSON.parse(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
34
+ const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
35
+ const result = {
36
+ snapshot,
37
+ restoredToDatabase: false,
38
+ storageFilesWritten: 0,
39
+ };
40
+ const workdir = await mkdtemp(join(tmpdir(), "backupdrill-restore-"));
41
+ try {
42
+ // 1. 数据库
43
+ if (opts.targetDatabaseUrl) {
44
+ log.step("Downloading dump…");
45
+ const dumpPath = join(workdir, "dump.pgcustom");
46
+ await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
47
+ log.step("Restoring database into target…");
48
+ await pgRestore(dumpPath, opts.targetDatabaseUrl);
49
+ result.restoredToDatabase = true;
50
+ log.ok(`Database restored (${manifest.database.tableCount} tables)`);
51
+ }
52
+ else {
53
+ log.warn("No --target-database-url given; skipping database restore.");
54
+ }
55
+ // 2. Storage 文件 → 本地目录
56
+ if (manifest.storage && manifest.storage.files.length) {
57
+ const outDir = opts.storageDir ?? join(process.cwd(), `restored-storage-${snapshot}`);
58
+ log.step(`Downloading ${manifest.storage.files.length} Storage files → ${outDir}`);
59
+ for (const f of manifest.storage.files) {
60
+ const dest = join(outDir, f.bucket, f.key);
61
+ await mkdir(dirname(dest), { recursive: true });
62
+ const res = await s3.send(new GetObjectCommand({
63
+ Bucket: config.storage.bucket,
64
+ Key: `${base}/storage/${f.bucket}/${f.key}`,
65
+ }));
66
+ await pipeline(res.Body, createWriteStream(dest));
67
+ result.storageFilesWritten += 1;
68
+ }
69
+ result.storageDir = outDir;
70
+ log.ok(`Storage files written to ${outDir}`);
71
+ }
72
+ return result;
73
+ }
74
+ finally {
75
+ await rm(workdir, { recursive: true, force: true }).catch(() => { });
76
+ }
77
+ }
@@ -0,0 +1,17 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ import type { BackupConfig } from "./config.js";
3
+ /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
4
+ export declare function targetClient(config: BackupConfig): S3Client;
5
+ /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
6
+ export declare function resolveSnapshot(s3: S3Client, config: BackupConfig, explicit?: string): Promise<string>;
7
+ export declare function getObjectText(s3: S3Client, bucket: string, key: string): Promise<string>;
8
+ /** 下载对象到本地文件,顺带算 sha256(验证读回的归档没被损坏)。 */
9
+ export declare function downloadToFile(s3: S3Client, bucket: string, key: string, dest: string): Promise<{
10
+ bytes: number;
11
+ sha256: string;
12
+ }>;
13
+ /** 只算对象校验和、不落盘(Storage 文件完整性校验用,省内存/磁盘)。 */
14
+ export declare function hashObject(s3: S3Client, bucket: string, key: string): Promise<{
15
+ bytes: number;
16
+ sha256: string;
17
+ }>;
@@ -0,0 +1,73 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createWriteStream } from "node:fs";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { Transform } from "node:stream";
5
+ import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
6
+ /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
7
+ export function targetClient(config) {
8
+ return new S3Client({
9
+ region: config.storage.region,
10
+ endpoint: config.storage.endpoint,
11
+ forcePathStyle: config.storage.forcePathStyle,
12
+ credentials: {
13
+ accessKeyId: config.storage.accessKeyId,
14
+ secretAccessKey: config.storage.secretAccessKey,
15
+ },
16
+ });
17
+ }
18
+ /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
19
+ export async function resolveSnapshot(s3, config, explicit) {
20
+ const root = `${config.storage.prefix}/${config.projectName}/`;
21
+ if (explicit)
22
+ return `${root}${explicit}/`;
23
+ const prefixes = [];
24
+ let token;
25
+ do {
26
+ const res = await s3.send(new ListObjectsV2Command({
27
+ Bucket: config.storage.bucket,
28
+ Prefix: root,
29
+ Delimiter: "/",
30
+ ContinuationToken: token,
31
+ }));
32
+ for (const cp of res.CommonPrefixes ?? []) {
33
+ if (cp.Prefix)
34
+ prefixes.push(cp.Prefix);
35
+ }
36
+ token = res.IsTruncated ? res.NextContinuationToken : undefined;
37
+ } while (token);
38
+ if (!prefixes.length) {
39
+ throw new Error(`No snapshots found under s3://${config.storage.bucket}/${root}. Run a backup first.`);
40
+ }
41
+ prefixes.sort();
42
+ return prefixes[prefixes.length - 1];
43
+ }
44
+ export async function getObjectText(s3, bucket, key) {
45
+ const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
46
+ return res.Body.transformToString();
47
+ }
48
+ /** 下载对象到本地文件,顺带算 sha256(验证读回的归档没被损坏)。 */
49
+ export async function downloadToFile(s3, bucket, key, dest) {
50
+ const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
51
+ const hash = createHash("sha256");
52
+ let bytes = 0;
53
+ const hasher = new Transform({
54
+ transform(chunk, _enc, cb) {
55
+ hash.update(chunk);
56
+ bytes += chunk.length;
57
+ cb(null, chunk);
58
+ },
59
+ });
60
+ await pipeline(res.Body, hasher, createWriteStream(dest));
61
+ return { bytes, sha256: hash.digest("hex") };
62
+ }
63
+ /** 只算对象校验和、不落盘(Storage 文件完整性校验用,省内存/磁盘)。 */
64
+ export async function hashObject(s3, bucket, key) {
65
+ const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
66
+ const hash = createHash("sha256");
67
+ let bytes = 0;
68
+ for await (const chunk of res.Body) {
69
+ hash.update(chunk);
70
+ bytes += chunk.length;
71
+ }
72
+ return { bytes, sha256: hash.digest("hex") };
73
+ }
@@ -0,0 +1,24 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ import type { SupabaseStorageConfig } from "./config.js";
3
+ import type { StorageFile } from "./manifest.js";
4
+ /**
5
+ * 仅列举、不下载 —— 用于接入时的容量/egress 估算,几乎不产生 egress。
6
+ */
7
+ export declare function measureStorage(cfg: SupabaseStorageConfig): Promise<{
8
+ fileCount: number;
9
+ totalBytes: number;
10
+ buckets: string[];
11
+ }>;
12
+ /**
13
+ * 把 Supabase Storage 的每个文件流式拷贝到目标桶,边拷边算 sha256。
14
+ * 目标键:<base>/storage/<bucket>/<原始 key>。
15
+ */
16
+ export declare function syncStorage(cfg: SupabaseStorageConfig, target: {
17
+ client: S3Client;
18
+ bucket: string;
19
+ base: string;
20
+ }): Promise<{
21
+ fileCount: number;
22
+ totalBytes: number;
23
+ files: StorageFile[];
24
+ }>;
@@ -0,0 +1,92 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Transform } from "node:stream";
3
+ import { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
4
+ import { Upload } from "@aws-sdk/lib-storage";
5
+ import { log } from "./log.js";
6
+ /**
7
+ * Supabase Storage 通过 S3 兼容端点访问(endpoint 形如
8
+ * https://<ref>.storage.supabase.co/storage/v1/s3)。这里的凭据是"读取源"的密钥,
9
+ * 与备份"写入目标"桶的密钥是两套 —— 数据只从 Supabase 流出到用户自己的桶。
10
+ */
11
+ function sourceClient(cfg) {
12
+ return new S3Client({
13
+ endpoint: cfg.endpoint,
14
+ region: cfg.region,
15
+ forcePathStyle: true, // Supabase 的 S3 端点需要 path-style
16
+ credentials: {
17
+ accessKeyId: cfg.accessKeyId,
18
+ secretAccessKey: cfg.secretAccessKey,
19
+ },
20
+ });
21
+ }
22
+ async function resolveBuckets(client, override) {
23
+ if (override && override.length)
24
+ return override;
25
+ const res = await client.send(new ListBucketsCommand({}));
26
+ return (res.Buckets ?? []).map((b) => b.Name).filter(Boolean);
27
+ }
28
+ async function* listObjects(client, bucket) {
29
+ let token;
30
+ do {
31
+ const res = await client.send(new ListObjectsV2Command({ Bucket: bucket, ContinuationToken: token }));
32
+ for (const obj of res.Contents ?? []) {
33
+ if (obj.Key)
34
+ yield { key: obj.Key, size: obj.Size ?? 0 };
35
+ }
36
+ token = res.IsTruncated ? res.NextContinuationToken : undefined;
37
+ } while (token);
38
+ }
39
+ /**
40
+ * 仅列举、不下载 —— 用于接入时的容量/egress 估算,几乎不产生 egress。
41
+ */
42
+ export async function measureStorage(cfg) {
43
+ const client = sourceClient(cfg);
44
+ const buckets = await resolveBuckets(client, cfg.buckets);
45
+ let fileCount = 0;
46
+ let totalBytes = 0;
47
+ for (const bucket of buckets) {
48
+ for await (const obj of listObjects(client, bucket)) {
49
+ fileCount += 1;
50
+ totalBytes += obj.size;
51
+ }
52
+ }
53
+ return { fileCount, totalBytes, buckets };
54
+ }
55
+ /**
56
+ * 把 Supabase Storage 的每个文件流式拷贝到目标桶,边拷边算 sha256。
57
+ * 目标键:<base>/storage/<bucket>/<原始 key>。
58
+ */
59
+ export async function syncStorage(cfg, target) {
60
+ const src = sourceClient(cfg);
61
+ const buckets = await resolveBuckets(src, cfg.buckets);
62
+ const files = [];
63
+ let totalBytes = 0;
64
+ for (const bucket of buckets) {
65
+ for await (const obj of listObjects(src, bucket)) {
66
+ const got = await src.send(new GetObjectCommand({ Bucket: bucket, Key: obj.key }));
67
+ const body = got.Body;
68
+ const hash = createHash("sha256");
69
+ let bytes = 0;
70
+ const meter = new Transform({
71
+ transform(chunk, _enc, cb) {
72
+ hash.update(chunk);
73
+ bytes += chunk.length;
74
+ cb(null, chunk);
75
+ },
76
+ });
77
+ body.pipe(meter);
78
+ await new Upload({
79
+ client: target.client,
80
+ params: {
81
+ Bucket: target.bucket,
82
+ Key: `${target.base}/storage/${bucket}/${obj.key}`,
83
+ Body: meter,
84
+ },
85
+ }).done();
86
+ files.push({ bucket, key: obj.key, bytes, sha256: hash.digest("hex") });
87
+ totalBytes += bytes;
88
+ }
89
+ log.step(` ${bucket}: ${files.filter((f) => f.bucket === bucket).length} files`);
90
+ }
91
+ return { fileCount: files.length, totalBytes, files };
92
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "backupdrill",
3
+ "version": "0.1.0",
4
+ "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
+ "type": "module",
6
+ "bin": {
7
+ "backupdrill": "dist/index.js"
8
+ },
9
+ "main": "./dist/lib.js",
10
+ "types": "./dist/lib.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/lib.d.ts",
14
+ "import": "./dist/lib.js"
15
+ },
16
+ "./cli": "./dist/index.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "dev": "node src/index.ts",
26
+ "typecheck": "tsc --noEmit",
27
+ "pretest": "tsc",
28
+ "test": "node --test test/*.test.js"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "keywords": [
34
+ "supabase",
35
+ "backup",
36
+ "postgres",
37
+ "pg_dump",
38
+ "s3",
39
+ "disaster-recovery"
40
+ ],
41
+ "license": "MIT",
42
+ "homepage": "https://backupdrill.com",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/backupdrill/cli.git"
46
+ },
47
+ "dependencies": {
48
+ "@aws-sdk/client-s3": "^3.658.0",
49
+ "@aws-sdk/lib-storage": "^3.658.0",
50
+ "commander": "^12.1.0",
51
+ "pg": "^8.13.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.16.0",
55
+ "@types/pg": "^8.11.0",
56
+ "typescript": "^5.6.0"
57
+ }
58
+ }