@happyvertical/sql 0.80.0 → 0.80.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 (31) hide show
  1. package/dist/backup/index.js +210 -188
  2. package/dist/backup/index.js.map +1 -1
  3. package/dist/chunks/alter-utils-K7tiuj5L.js +135 -0
  4. package/dist/chunks/alter-utils-K7tiuj5L.js.map +1 -0
  5. package/dist/chunks/duckdb-DykBBzd6.js +1056 -0
  6. package/dist/chunks/duckdb-DykBBzd6.js.map +1 -0
  7. package/dist/chunks/duckdb-schema-utils-B-XBP8QF.js +115 -0
  8. package/dist/chunks/duckdb-schema-utils-B-XBP8QF.js.map +1 -0
  9. package/dist/chunks/postgres-cli-DzZcXSIG.js +272 -0
  10. package/dist/chunks/postgres-cli-DzZcXSIG.js.map +1 -0
  11. package/dist/chunks/schema-manager-a8RlxIE2.js +450 -0
  12. package/dist/chunks/schema-manager-a8RlxIE2.js.map +1 -0
  13. package/dist/chunks/sqlite-YgU9LCVy.js +1045 -0
  14. package/dist/chunks/sqlite-YgU9LCVy.js.map +1 -0
  15. package/dist/chunks/sqlite-native-BGtpx7Ro.js +902 -0
  16. package/dist/chunks/sqlite-native-BGtpx7Ro.js.map +1 -0
  17. package/dist/cli/claude-context.js +17 -17
  18. package/dist/cli/claude-context.js.map +1 -1
  19. package/dist/index.js +3244 -35
  20. package/dist/index.js.map +1 -1
  21. package/package.json +5 -5
  22. package/dist/chunks/duckdb-B6m78QdL.js +0 -924
  23. package/dist/chunks/duckdb-B6m78QdL.js.map +0 -1
  24. package/dist/chunks/index-CzdYvRFp.js +0 -3692
  25. package/dist/chunks/index-CzdYvRFp.js.map +0 -1
  26. package/dist/chunks/postgres-cli-Cr6bBZ3q.js +0 -209
  27. package/dist/chunks/postgres-cli-Cr6bBZ3q.js.map +0 -1
  28. package/dist/chunks/sqlite-CPCNeJIH.js +0 -937
  29. package/dist/chunks/sqlite-CPCNeJIH.js.map +0 -1
  30. package/dist/chunks/sqlite-native-DqVPKNUu.js +0 -1129
  31. package/dist/chunks/sqlite-native-DqVPKNUu.js.map +0 -1
@@ -1,212 +1,234 @@
1
+ import { a as dropPostgresDatabase, l as redactDatabaseUrl, n as assertCanImportDatabase, o as dumpPostgresDatabase, r as createPostgresDatabase, t as assertCanExportDatabase, u as restorePostgresDatabase } from "../chunks/postgres-cli-DzZcXSIG.js";
1
2
  import { execFile } from "node:child_process";
2
- import { readFile, writeFile, rename, mkdir, rm, access } from "node:fs/promises";
3
- import { homedir } from "node:os";
3
+ import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
- import { a as assertCanExportDatabase, f as dumpPostgresDatabase, r as redactDatabaseUrl, b as assertCanImportDatabase, e as dropPostgresDatabase, c as createPostgresDatabase, g as restorePostgresDatabase } from "../chunks/postgres-cli-Cr6bBZ3q.js";
6
- const BACKUP_KIND = "happyvertical-backup";
7
- const BACKUP_VERSION = 1;
8
- const DEFAULT_DUMP_FILE = "database.dump";
9
- const DEFAULT_FILES_DIR = "files";
10
- const DEFAULT_MANIFEST_FILE = "manifest.json";
5
+ import { homedir } from "node:os";
6
+ //#region src/backup/manifest.ts
7
+ /**
8
+ * Backup manifest shape and helpers.
9
+ *
10
+ * The manifest is the source-of-truth for what's in a backup directory:
11
+ * which database it came from, when, at what commit, and any
12
+ * app-specific metadata that the caller chose to attach via the `extra`
13
+ * field. Bumping `BACKUP_VERSION` is the way to evolve the shape — older
14
+ * backups will fail `readBackupManifest` until upgraded.
15
+ */
16
+ var BACKUP_KIND = "happyvertical-backup";
17
+ var BACKUP_VERSION = 1;
18
+ var DEFAULT_DUMP_FILE = "database.dump";
19
+ var DEFAULT_FILES_DIR = "files";
20
+ var DEFAULT_MANIFEST_FILE = "manifest.json";
21
+ /**
22
+ * Read a backup manifest from `backupPath/manifest.json` and validate
23
+ * that it matches this package's kind+version. Throws (with the path
24
+ * included) on mismatch — that's almost always a real problem worth
25
+ * surfacing, not silently tolerating.
26
+ */
11
27
  async function readBackupManifest(backupPath) {
12
- const manifestPath = join(resolve(backupPath), DEFAULT_MANIFEST_FILE);
13
- const manifest = JSON.parse(
14
- await readFile(manifestPath, "utf8")
15
- );
16
- if (manifest.kind !== BACKUP_KIND || manifest.version !== BACKUP_VERSION) {
17
- throw new Error(
18
- `Unsupported backup manifest at ${manifestPath} (kind=${manifest.kind}, version=${manifest.version}).`
19
- );
20
- }
21
- return manifest;
28
+ const manifestPath = join(resolve(backupPath), DEFAULT_MANIFEST_FILE);
29
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
30
+ if (manifest.kind !== "happyvertical-backup" || manifest.version !== 1) throw new Error(`Unsupported backup manifest at ${manifestPath} (kind=${manifest.kind}, version=${manifest.version}).`);
31
+ return manifest;
22
32
  }
23
33
  async function writeBackupManifest(backupPath, manifest) {
24
- const targetPath = join(backupPath, DEFAULT_MANIFEST_FILE);
25
- const tempPath = join(
26
- backupPath,
27
- `.manifest.${process.pid}.${Date.now().toString(36)}.tmp`
28
- );
29
- await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}
30
- `, {
31
- mode: 384
32
- });
33
- await rename(tempPath, targetPath);
34
+ const targetPath = join(backupPath, DEFAULT_MANIFEST_FILE);
35
+ const tempPath = join(backupPath, `.manifest.${process.pid}.${Date.now().toString(36)}.tmp`);
36
+ await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 384 });
37
+ await rename(tempPath, targetPath);
34
38
  }
39
+ /**
40
+ * Filesystem-safe timestamp suitable for backup directory names — same
41
+ * shape as `Date.toISOString()` but with `:` and `.` swapped to `-` so
42
+ * the value is a valid path segment on Windows and friendly to shell
43
+ * tab-completion everywhere else.
44
+ */
35
45
  function timestampForBackup(date = /* @__PURE__ */ new Date()) {
36
- return date.toISOString().replace(/[:.]/g, "-");
46
+ return date.toISOString().replace(/[:.]/g, "-");
37
47
  }
48
+ //#endregion
49
+ //#region src/backup/index.ts
50
+ /**
51
+ * Backup orchestration — composes the pg shell-out helpers in
52
+ * `../postgres-cli` with caller-supplied hooks for file storage,
53
+ * app-specific manifest extras, and post-restore steps.
54
+ *
55
+ * Decoupled from `@happyvertical/files` on purpose: callers that want a
56
+ * file-storage backup pass an `onBackup` callback that does the copy
57
+ * however they prefer (local cp, S3 sync, WebDAV walk). That keeps this
58
+ * subpath usable by apps that only need the database half.
59
+ */
60
+ /** Default root for backups. Override via $HAPPYVERTICAL_BACKUP_DIR or `backupRoot`. */
38
61
  function defaultBackupRoot() {
39
- return process.env.HAPPYVERTICAL_BACKUP_DIR ?? join(homedir(), ".local", "share", "happyvertical", "backups");
62
+ return process.env.HAPPYVERTICAL_BACKUP_DIR ?? join(homedir(), ".local", "share", "happyvertical", "backups");
40
63
  }
64
+ /**
65
+ * Take a postgres dump and (optionally) a snapshot of file storage,
66
+ * writing both plus a manifest into a fresh timestamped directory.
67
+ *
68
+ * Returns the directory path and the manifest. The manifest's `extra`
69
+ * field carries whatever `onBackup` returned.
70
+ *
71
+ * **Failure model:** the function is all-or-nothing. If any step after
72
+ * the initial directory creation throws — `dumpPostgresDatabase`,
73
+ * `onBackup`, `onDatabaseMetadata`, `getGitSha`, or the final manifest
74
+ * write — the partial backup directory is removed (best-effort)
75
+ * before the original error is re-thrown. Callers therefore never see
76
+ * a backup directory on disk without a complete manifest, which would
77
+ * otherwise poison later `restoreBackup` calls with confusing
78
+ * "no manifest" errors and leak a partial dump containing sensitive
79
+ * data. If cleanup itself fails, that error is logged via
80
+ * `console.warn` and swallowed so it doesn't mask the original.
81
+ */
41
82
  async function exportBackup(options) {
42
- assertCanExportDatabase(options.databaseUrl, options);
43
- const label = validateLabel(options.label ?? "backup");
44
- const backupPath = await createUniqueBackupDir(
45
- options.backupRoot ?? defaultBackupRoot(),
46
- label
47
- );
48
- try {
49
- const dumpPath = join(backupPath, DEFAULT_DUMP_FILE);
50
- await dumpPostgresDatabase(options.databaseUrl, dumpPath);
51
- const filesDir = join(backupPath, DEFAULT_FILES_DIR);
52
- let filesInfo = {
53
- directory: DEFAULT_FILES_DIR,
54
- exported: false,
55
- count: 0,
56
- bytes: 0,
57
- reason: "Skipped by skipFiles.",
58
- storageConfig: options.filesStorageConfig
59
- };
60
- let extra;
61
- if (!options.skipFiles && options.onBackup) {
62
- await mkdir(filesDir, { recursive: true, mode: 448 });
63
- const result = await options.onBackup({
64
- backupPath,
65
- databaseUrl: options.databaseUrl,
66
- filesDir
67
- });
68
- if (result !== void 0 && result !== null) extra = result;
69
- filesInfo = {
70
- directory: DEFAULT_FILES_DIR,
71
- exported: true,
72
- count: 0,
73
- bytes: 0,
74
- storageConfig: options.filesStorageConfig
75
- };
76
- }
77
- const metadata = options.onDatabaseMetadata ? await options.onDatabaseMetadata({ databaseUrl: options.databaseUrl }) : void 0;
78
- const manifest = {
79
- kind: BACKUP_KIND,
80
- version: BACKUP_VERSION,
81
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
82
- gitSha: await getGitSha(options.gitCwd),
83
- source: label,
84
- database: {
85
- dumpFile: DEFAULT_DUMP_FILE,
86
- url: redactDatabaseUrl(options.databaseUrl),
87
- metadata
88
- },
89
- files: filesInfo,
90
- extra
91
- };
92
- await writeBackupManifest(backupPath, manifest);
93
- return { backupPath, manifest };
94
- } catch (error) {
95
- try {
96
- await rm(backupPath, { recursive: true, force: true });
97
- } catch (cleanupError) {
98
- console.warn(
99
- `exportBackup: failed to clean up partial backup at ${backupPath}:`,
100
- cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
101
- );
102
- }
103
- throw error;
104
- }
83
+ assertCanExportDatabase(options.databaseUrl, options);
84
+ const label = validateLabel(options.label ?? "backup");
85
+ const backupPath = await createUniqueBackupDir(options.backupRoot ?? defaultBackupRoot(), label);
86
+ try {
87
+ const dumpPath = join(backupPath, DEFAULT_DUMP_FILE);
88
+ await dumpPostgresDatabase(options.databaseUrl, dumpPath);
89
+ const filesDir = join(backupPath, DEFAULT_FILES_DIR);
90
+ let filesInfo = {
91
+ directory: DEFAULT_FILES_DIR,
92
+ exported: false,
93
+ count: 0,
94
+ bytes: 0,
95
+ reason: "Skipped by skipFiles.",
96
+ storageConfig: options.filesStorageConfig
97
+ };
98
+ let extra;
99
+ if (!options.skipFiles && options.onBackup) {
100
+ await mkdir(filesDir, {
101
+ recursive: true,
102
+ mode: 448
103
+ });
104
+ const result = await options.onBackup({
105
+ backupPath,
106
+ databaseUrl: options.databaseUrl,
107
+ filesDir
108
+ });
109
+ if (result !== void 0 && result !== null) extra = result;
110
+ filesInfo = {
111
+ directory: DEFAULT_FILES_DIR,
112
+ exported: true,
113
+ count: 0,
114
+ bytes: 0,
115
+ storageConfig: options.filesStorageConfig
116
+ };
117
+ }
118
+ const metadata = options.onDatabaseMetadata ? await options.onDatabaseMetadata({ databaseUrl: options.databaseUrl }) : void 0;
119
+ const manifest = {
120
+ kind: BACKUP_KIND,
121
+ version: 1,
122
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
123
+ gitSha: await getGitSha(options.gitCwd),
124
+ source: label,
125
+ database: {
126
+ dumpFile: DEFAULT_DUMP_FILE,
127
+ url: redactDatabaseUrl(options.databaseUrl),
128
+ metadata
129
+ },
130
+ files: filesInfo,
131
+ extra
132
+ };
133
+ await writeBackupManifest(backupPath, manifest);
134
+ return {
135
+ backupPath,
136
+ manifest
137
+ };
138
+ } catch (error) {
139
+ try {
140
+ await rm(backupPath, {
141
+ recursive: true,
142
+ force: true
143
+ });
144
+ } catch (cleanupError) {
145
+ console.warn(`exportBackup: failed to clean up partial backup at ${backupPath}:`, cleanupError instanceof Error ? cleanupError.message : String(cleanupError));
146
+ }
147
+ throw error;
148
+ }
105
149
  }
150
+ /**
151
+ * Restore a backup directory into `databaseUrl` and, if `onRestore` is
152
+ * provided, run the file-storage half. The caller controls everything
153
+ * after the dump completes (file copy, migrations, doctor) via the hook.
154
+ */
106
155
  async function restoreBackup(options) {
107
- const backupPath = resolve(options.backupPath);
108
- const manifest = await readBackupManifest(backupPath);
109
- assertCanImportDatabase(options.databaseUrl, options);
110
- const dumpPath = join(
111
- backupPath,
112
- validateManifestPathSegment(
113
- manifest.database.dumpFile,
114
- "database dump file"
115
- )
116
- );
117
- const filesDir = join(
118
- backupPath,
119
- validateManifestPathSegment(manifest.files.directory, "files directory")
120
- );
121
- await restorePostgresDatabase(options.databaseUrl, dumpPath);
122
- if (!options.skipFiles && options.onRestore && manifest.files.exported) {
123
- await options.onRestore({
124
- backupPath,
125
- databaseUrl: options.databaseUrl,
126
- filesDir,
127
- manifest
128
- });
129
- }
130
- return manifest;
156
+ const backupPath = resolve(options.backupPath);
157
+ const manifest = await readBackupManifest(backupPath);
158
+ assertCanImportDatabase(options.databaseUrl, options);
159
+ const dumpPath = join(backupPath, validateManifestPathSegment(manifest.database.dumpFile, "database dump file"));
160
+ const filesDir = join(backupPath, validateManifestPathSegment(manifest.files.directory, "files directory"));
161
+ await restorePostgresDatabase(options.databaseUrl, dumpPath);
162
+ if (!options.skipFiles && options.onRestore && manifest.files.exported) await options.onRestore({
163
+ backupPath,
164
+ databaseUrl: options.databaseUrl,
165
+ filesDir,
166
+ manifest
167
+ });
168
+ return manifest;
131
169
  }
170
+ /**
171
+ * Drop-and-recreate the target database, then run `restoreBackup`.
172
+ * Local-only: explicitly refuses non-local URLs without an escape hatch
173
+ * (this is a destructive operation by design).
174
+ */
132
175
  async function resetLocalDatabaseFromBackup(options) {
133
- assertCanImportDatabase(options.databaseUrl, { allowProduction: false });
134
- const backupPath = resolve(options.backupPath);
135
- const manifest = await readBackupManifest(backupPath);
136
- const dumpFile = validateManifestPathSegment(
137
- manifest.database.dumpFile,
138
- "database dump file"
139
- );
140
- validateManifestPathSegment(manifest.files.directory, "files directory");
141
- await access(join(backupPath, dumpFile));
142
- await dropPostgresDatabase(options.databaseUrl);
143
- await createPostgresDatabase(options.databaseUrl);
144
- return restoreBackup({ ...options, allowProduction: false });
176
+ assertCanImportDatabase(options.databaseUrl, { allowProduction: false });
177
+ const backupPath = resolve(options.backupPath);
178
+ const manifest = await readBackupManifest(backupPath);
179
+ const dumpFile = validateManifestPathSegment(manifest.database.dumpFile, "database dump file");
180
+ validateManifestPathSegment(manifest.files.directory, "files directory");
181
+ await access(join(backupPath, dumpFile));
182
+ await dropPostgresDatabase(options.databaseUrl);
183
+ await createPostgresDatabase(options.databaseUrl);
184
+ return restoreBackup({
185
+ ...options,
186
+ allowProduction: false
187
+ });
145
188
  }
146
189
  async function getGitSha(cwd = process.cwd()) {
147
- try {
148
- return await new Promise((resolvePromise, reject) => {
149
- execFile("git", ["rev-parse", "HEAD"], { cwd }, (error, stdout) => {
150
- if (error) {
151
- reject(error);
152
- return;
153
- }
154
- resolvePromise(stdout.trim());
155
- });
156
- });
157
- } catch {
158
- return null;
159
- }
190
+ try {
191
+ return await new Promise((resolvePromise, reject) => {
192
+ execFile("git", ["rev-parse", "HEAD"], { cwd }, (error, stdout) => {
193
+ if (error) {
194
+ reject(error);
195
+ return;
196
+ }
197
+ resolvePromise(stdout.trim());
198
+ });
199
+ });
200
+ } catch {
201
+ return null;
202
+ }
160
203
  }
161
204
  function validateLabel(label) {
162
- if (!label || label === "." || label === ".." || /[\\/]/u.test(label)) {
163
- throw new Error(
164
- `Backup label "${label}" is invalid. Labels must be a single path-safe segment.`
165
- );
166
- }
167
- return label;
205
+ if (!label || label === "." || label === ".." || /[\\/]/u.test(label)) throw new Error(`Backup label "${label}" is invalid. Labels must be a single path-safe segment.`);
206
+ return label;
168
207
  }
169
208
  function validateManifestPathSegment(value, field) {
170
- if (!value || value === "." || value === ".." || /[\\/]/u.test(value)) {
171
- throw new Error(
172
- `Backup manifest ${field} is invalid: "${value}". It must be a single path segment.`
173
- );
174
- }
175
- return value;
209
+ if (!value || value === "." || value === ".." || /[\\/]/u.test(value)) throw new Error(`Backup manifest ${field} is invalid: "${value}". It must be a single path segment.`);
210
+ return value;
176
211
  }
177
212
  async function createUniqueBackupDir(backupRoot, label) {
178
- const root = resolve(backupRoot);
179
- await mkdir(root, { recursive: true, mode: 448 });
180
- const baseName = `${label}-${timestampForBackup()}`;
181
- for (let attempt = 0; attempt < 64; attempt += 1) {
182
- const suffix = attempt === 0 ? "" : `-${attempt.toString(36)}`;
183
- const candidate = join(root, `${baseName}${suffix}`);
184
- try {
185
- await mkdir(candidate, { mode: 448 });
186
- return candidate;
187
- } catch (error) {
188
- if (error.code === "EEXIST") {
189
- continue;
190
- }
191
- throw error;
192
- }
193
- }
194
- throw new Error(
195
- `Failed to create a unique backup directory under ${root} for label "${label}".`
196
- );
213
+ const root = resolve(backupRoot);
214
+ await mkdir(root, {
215
+ recursive: true,
216
+ mode: 448
217
+ });
218
+ const baseName = `${label}-${timestampForBackup()}`;
219
+ for (let attempt = 0; attempt < 64; attempt += 1) {
220
+ const candidate = join(root, `${baseName}${attempt === 0 ? "" : `-${attempt.toString(36)}`}`);
221
+ try {
222
+ await mkdir(candidate, { mode: 448 });
223
+ return candidate;
224
+ } catch (error) {
225
+ if (error.code === "EEXIST") continue;
226
+ throw error;
227
+ }
228
+ }
229
+ throw new Error(`Failed to create a unique backup directory under ${root} for label "${label}".`);
197
230
  }
198
- export {
199
- BACKUP_KIND,
200
- BACKUP_VERSION,
201
- DEFAULT_DUMP_FILE,
202
- DEFAULT_FILES_DIR,
203
- DEFAULT_MANIFEST_FILE,
204
- defaultBackupRoot,
205
- exportBackup,
206
- readBackupManifest,
207
- resetLocalDatabaseFromBackup,
208
- restoreBackup,
209
- timestampForBackup,
210
- writeBackupManifest
211
- };
212
- //# sourceMappingURL=index.js.map
231
+ //#endregion
232
+ export { BACKUP_KIND, BACKUP_VERSION, DEFAULT_DUMP_FILE, DEFAULT_FILES_DIR, DEFAULT_MANIFEST_FILE, defaultBackupRoot, exportBackup, readBackupManifest, resetLocalDatabaseFromBackup, restoreBackup, timestampForBackup, writeBackupManifest };
233
+
234
+ //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../../src/backup/manifest.ts","../../src/backup/index.ts"],"sourcesContent":["/**\n * Backup manifest shape and helpers.\n *\n * The manifest is the source-of-truth for what's in a backup directory:\n * which database it came from, when, at what commit, and any\n * app-specific metadata that the caller chose to attach via the `extra`\n * field. Bumping `BACKUP_VERSION` is the way to evolve the shape — older\n * backups will fail `readBackupManifest` until upgraded.\n */\nimport { readFile, rename, writeFile } from 'node:fs/promises';\nimport { join, resolve } from 'node:path';\n\nexport const BACKUP_KIND = 'happyvertical-backup' as const;\nexport const BACKUP_VERSION = 1 as const;\nexport const DEFAULT_DUMP_FILE = 'database.dump';\nexport const DEFAULT_FILES_DIR = 'files';\nexport const DEFAULT_MANIFEST_FILE = 'manifest.json';\n\nexport interface BackupManifestDatabase {\n /** File name of the pg_dump archive within the backup directory. */\n dumpFile: string;\n /** Redacted URL the dump was taken from. */\n url: string;\n /** Caller-supplied diagnostic metadata (row counts, schema count, etc.). */\n metadata?: Record<string, unknown>;\n}\n\nexport interface BackupManifestFiles {\n /** Directory name within the backup that holds copied file storage. */\n directory: string;\n exported: boolean;\n count: number;\n bytes: number;\n reason?: string;\n /** Redacted provider config, if the caller chose to record it. */\n storageConfig?: unknown;\n}\n\nexport interface BackupManifest {\n kind: typeof BACKUP_KIND;\n version: typeof BACKUP_VERSION;\n timestamp: string;\n gitSha: string | null;\n /** Free-form label (e.g. 'current', 'prod', 'pre-migration'). */\n source: string;\n database: BackupManifestDatabase;\n files: BackupManifestFiles;\n /** Opaque slot for app-specific extension (resume publish status, etc.). */\n extra?: unknown;\n}\n\n/**\n * Read a backup manifest from `backupPath/manifest.json` and validate\n * that it matches this package's kind+version. Throws (with the path\n * included) on mismatch — that's almost always a real problem worth\n * surfacing, not silently tolerating.\n */\nexport async function readBackupManifest(\n backupPath: string,\n): Promise<BackupManifest> {\n const manifestPath = join(resolve(backupPath), DEFAULT_MANIFEST_FILE);\n const manifest = JSON.parse(\n await readFile(manifestPath, 'utf8'),\n ) as BackupManifest;\n if (manifest.kind !== BACKUP_KIND || manifest.version !== BACKUP_VERSION) {\n throw new Error(\n `Unsupported backup manifest at ${manifestPath} (kind=${manifest.kind}, version=${manifest.version}).`,\n );\n }\n return manifest;\n}\n\nexport async function writeBackupManifest(\n backupPath: string,\n manifest: BackupManifest,\n): Promise<void> {\n const targetPath = join(backupPath, DEFAULT_MANIFEST_FILE);\n const tempPath = join(\n backupPath,\n `.manifest.${process.pid}.${Date.now().toString(36)}.tmp`,\n );\n await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\\n`, {\n mode: 0o600,\n });\n await rename(tempPath, targetPath);\n}\n\n/**\n * Filesystem-safe timestamp suitable for backup directory names — same\n * shape as `Date.toISOString()` but with `:` and `.` swapped to `-` so\n * the value is a valid path segment on Windows and friendly to shell\n * tab-completion everywhere else.\n */\nexport function timestampForBackup(date = new Date()): string {\n return date.toISOString().replace(/[:.]/g, '-');\n}\n","/**\n * Backup orchestration — composes the pg shell-out helpers in\n * `../postgres-cli` with caller-supplied hooks for file storage,\n * app-specific manifest extras, and post-restore steps.\n *\n * Decoupled from `@happyvertical/files` on purpose: callers that want a\n * file-storage backup pass an `onBackup` callback that does the copy\n * however they prefer (local cp, S3 sync, WebDAV walk). That keeps this\n * subpath usable by apps that only need the database half.\n */\nimport { execFile } from 'node:child_process';\nimport { access, mkdir, rm } from 'node:fs/promises';\nimport { homedir } from 'node:os';\nimport { join, resolve } from 'node:path';\nimport {\n assertCanExportDatabase,\n assertCanImportDatabase,\n createPostgresDatabase,\n dropPostgresDatabase,\n dumpPostgresDatabase,\n redactDatabaseUrl,\n restorePostgresDatabase,\n} from '../postgres-cli';\nimport {\n BACKUP_KIND,\n BACKUP_VERSION,\n type BackupManifest,\n type BackupManifestFiles,\n DEFAULT_DUMP_FILE,\n DEFAULT_FILES_DIR,\n readBackupManifest,\n timestampForBackup,\n writeBackupManifest,\n} from './manifest';\n\nexport {\n BACKUP_KIND,\n BACKUP_VERSION,\n type BackupManifest,\n type BackupManifestDatabase,\n type BackupManifestFiles,\n DEFAULT_DUMP_FILE,\n DEFAULT_FILES_DIR,\n DEFAULT_MANIFEST_FILE,\n readBackupManifest,\n timestampForBackup,\n writeBackupManifest,\n} from './manifest';\n\nexport interface ExportBackupContext {\n backupPath: string;\n /**\n * The raw, credential-bearing database URL the caller passed to\n * `exportBackup`. The hook needs the raw form to open its own\n * connection, but **do not log this value verbatim** — it contains\n * credentials. Run it through `redactDatabaseUrl` (exported from\n * `@happyvertical/sql`) before including it in any output. The\n * manifest's `database.url` field is redacted; only `databaseUrl`\n * here is raw.\n */\n databaseUrl: string;\n /**\n * Local directory the caller should copy file storage into.\n * Already exists by the time `onBackup` runs.\n */\n filesDir: string;\n}\n\nexport interface ExportBackupResult<Extra = unknown> {\n /** Absolute path to the backup directory. */\n backupPath: string;\n /** The manifest that was written. */\n manifest: BackupManifest & { extra?: Extra };\n}\n\nexport interface ExportBackupOptions<Extra = unknown> {\n databaseUrl: string;\n /** Override the parent dir for the timestamped backup. Defaults to ~/.local/share/happyvertical/backups. */\n backupRoot?: string;\n /** Prefix the timestamp with this label (e.g. 'prod', 'pre-migration'). Defaults to 'backup'. */\n label?: string;\n /** Allow exporting from a non-local DB without --allow-production. */\n allowProduction?: boolean;\n /** Allow exporting from a local DB even though `prod: true` is set. */\n allowLocal?: boolean;\n /** Caller is running a prod-flavored export. */\n prod?: boolean;\n /** Skip the file-storage half entirely (no `onBackup` invoked). */\n skipFiles?: boolean;\n /**\n * Caller-supplied hook for backing up file storage. Receives the\n * already-created backup/files directory; whatever it returns is\n * recorded as `extra` on the manifest.\n */\n onBackup?: (ctx: ExportBackupContext) => Promise<Extra | void>;\n /**\n * Caller-supplied hook for filling the database half of the manifest\n * with diagnostic metadata (row counts, schema count, etc.) — runs\n * after the dump completes. As with `onBackup`, the `databaseUrl`\n * passed in is the raw credential-bearing form — redact via\n * `redactDatabaseUrl` before logging.\n */\n onDatabaseMetadata?: (ctx: {\n /** Raw URL — see warning on {@link ExportBackupContext.databaseUrl}. */\n databaseUrl: string;\n }) => Promise<Record<string, unknown>>;\n /** Recorded as `files.storageConfig` on the manifest after redaction by the caller. */\n filesStorageConfig?: unknown;\n /** Working dir used when discovering the git sha. Defaults to process.cwd(). */\n gitCwd?: string;\n}\n\nexport interface RestoreBackupContext {\n backupPath: string;\n /** Raw URL — see warning on {@link ExportBackupContext.databaseUrl}. */\n databaseUrl: string;\n filesDir: string;\n manifest: BackupManifest;\n}\n\nexport interface RestoreBackupOptions {\n databaseUrl: string;\n backupPath: string;\n allowProduction?: boolean;\n /** Skip the file-storage half (no `onRestore` invoked). */\n skipFiles?: boolean;\n /**\n * Caller-supplied hook for restoring file storage from\n * `backupPath/<files.directory>`. Runs after the pg_restore completes.\n */\n onRestore?: (ctx: RestoreBackupContext) => Promise<void>;\n}\n\nexport interface ResetLocalOptions\n extends Omit<RestoreBackupOptions, 'allowProduction'> {}\n\n/** Default root for backups. Override via $HAPPYVERTICAL_BACKUP_DIR or `backupRoot`. */\nexport function defaultBackupRoot(): string {\n return (\n process.env.HAPPYVERTICAL_BACKUP_DIR ??\n join(homedir(), '.local', 'share', 'happyvertical', 'backups')\n );\n}\n\n/**\n * Take a postgres dump and (optionally) a snapshot of file storage,\n * writing both plus a manifest into a fresh timestamped directory.\n *\n * Returns the directory path and the manifest. The manifest's `extra`\n * field carries whatever `onBackup` returned.\n *\n * **Failure model:** the function is all-or-nothing. If any step after\n * the initial directory creation throws — `dumpPostgresDatabase`,\n * `onBackup`, `onDatabaseMetadata`, `getGitSha`, or the final manifest\n * write — the partial backup directory is removed (best-effort)\n * before the original error is re-thrown. Callers therefore never see\n * a backup directory on disk without a complete manifest, which would\n * otherwise poison later `restoreBackup` calls with confusing\n * \"no manifest\" errors and leak a partial dump containing sensitive\n * data. If cleanup itself fails, that error is logged via\n * `console.warn` and swallowed so it doesn't mask the original.\n */\nexport async function exportBackup<Extra = unknown>(\n options: ExportBackupOptions<Extra>,\n): Promise<ExportBackupResult<Extra>> {\n assertCanExportDatabase(options.databaseUrl, options);\n\n const label = validateLabel(options.label ?? 'backup');\n const backupPath = await createUniqueBackupDir(\n options.backupRoot ?? defaultBackupRoot(),\n label,\n );\n\n try {\n const dumpPath = join(backupPath, DEFAULT_DUMP_FILE);\n await dumpPostgresDatabase(options.databaseUrl, dumpPath);\n\n const filesDir = join(backupPath, DEFAULT_FILES_DIR);\n let filesInfo: BackupManifestFiles = {\n directory: DEFAULT_FILES_DIR,\n exported: false,\n count: 0,\n bytes: 0,\n reason: 'Skipped by skipFiles.',\n storageConfig: options.filesStorageConfig,\n };\n let extra: Extra | undefined;\n\n if (!options.skipFiles && options.onBackup) {\n await mkdir(filesDir, { recursive: true, mode: 0o700 });\n const result = await options.onBackup({\n backupPath,\n databaseUrl: options.databaseUrl,\n filesDir,\n });\n if (result !== undefined && result !== null) extra = result as Extra;\n filesInfo = {\n directory: DEFAULT_FILES_DIR,\n exported: true,\n count: 0,\n bytes: 0,\n storageConfig: options.filesStorageConfig,\n };\n }\n\n const metadata = options.onDatabaseMetadata\n ? await options.onDatabaseMetadata({ databaseUrl: options.databaseUrl })\n : undefined;\n\n const manifest: BackupManifest & { extra?: Extra } = {\n kind: BACKUP_KIND,\n version: BACKUP_VERSION,\n timestamp: new Date().toISOString(),\n gitSha: await getGitSha(options.gitCwd),\n source: label,\n database: {\n dumpFile: DEFAULT_DUMP_FILE,\n url: redactDatabaseUrl(options.databaseUrl),\n metadata,\n },\n files: filesInfo,\n extra,\n };\n\n await writeBackupManifest(backupPath, manifest);\n return { backupPath, manifest };\n } catch (error) {\n // Partial state on disk would either confuse later restoreBackup\n // calls (no manifest → cryptic ENOENT) or leak sensitive dump\n // contents. Remove the directory; if cleanup fails, log but don't\n // mask the original error.\n try {\n await rm(backupPath, { recursive: true, force: true });\n } catch (cleanupError) {\n console.warn(\n `exportBackup: failed to clean up partial backup at ${backupPath}:`,\n cleanupError instanceof Error\n ? cleanupError.message\n : String(cleanupError),\n );\n }\n throw error;\n }\n}\n\n/**\n * Restore a backup directory into `databaseUrl` and, if `onRestore` is\n * provided, run the file-storage half. The caller controls everything\n * after the dump completes (file copy, migrations, doctor) via the hook.\n */\nexport async function restoreBackup(\n options: RestoreBackupOptions,\n): Promise<BackupManifest> {\n const backupPath = resolve(options.backupPath);\n const manifest = await readBackupManifest(backupPath);\n assertCanImportDatabase(options.databaseUrl, options);\n const dumpPath = join(\n backupPath,\n validateManifestPathSegment(\n manifest.database.dumpFile,\n 'database dump file',\n ),\n );\n const filesDir = join(\n backupPath,\n validateManifestPathSegment(manifest.files.directory, 'files directory'),\n );\n\n await restorePostgresDatabase(options.databaseUrl, dumpPath);\n\n // Skip `onRestore` for DB-only backups: if the backup was taken with\n // `skipFiles: true` or no `onBackup` hook, the manifest records\n // `files.exported: false` and the `files/` directory may not exist on\n // disk. Calling a generic restore hook anyway would either crash\n // after the database has already been restored, or — worse — let\n // the hook clear target file storage before discovering there's\n // nothing to restore from.\n if (!options.skipFiles && options.onRestore && manifest.files.exported) {\n await options.onRestore({\n backupPath,\n databaseUrl: options.databaseUrl,\n filesDir,\n manifest,\n });\n }\n\n return manifest;\n}\n\n/**\n * Drop-and-recreate the target database, then run `restoreBackup`.\n * Local-only: explicitly refuses non-local URLs without an escape hatch\n * (this is a destructive operation by design).\n */\nexport async function resetLocalDatabaseFromBackup(\n options: ResetLocalOptions,\n): Promise<BackupManifest> {\n assertCanImportDatabase(options.databaseUrl, { allowProduction: false });\n\n // Validate the backup BEFORE dropping the local database. A mistyped\n // backupPath, missing dump file, or corrupted manifest field would\n // otherwise destroy the existing database (drop + create) and only\n // then fail when restoreBackup tries to read those values — leaving\n // the developer with an empty local DB and a confusing ENOENT error.\n //\n // Validates every manifest field restoreBackup later relies on\n // (`database.dumpFile` AND `files.directory`), and verifies the dump\n // file actually exists on disk. The drop only happens if all of that\n // is sound.\n const backupPath = resolve(options.backupPath);\n const manifest = await readBackupManifest(backupPath);\n const dumpFile = validateManifestPathSegment(\n manifest.database.dumpFile,\n 'database dump file',\n );\n validateManifestPathSegment(manifest.files.directory, 'files directory');\n await access(join(backupPath, dumpFile));\n\n await dropPostgresDatabase(options.databaseUrl);\n await createPostgresDatabase(options.databaseUrl);\n return restoreBackup({ ...options, allowProduction: false });\n}\n\nasync function getGitSha(cwd: string = process.cwd()): Promise<string | null> {\n try {\n return await new Promise<string>((resolvePromise, reject) => {\n execFile('git', ['rev-parse', 'HEAD'], { cwd }, (error, stdout) => {\n if (error) {\n reject(error);\n return;\n }\n resolvePromise(stdout.trim());\n });\n });\n } catch {\n return null;\n }\n}\n\nfunction validateLabel(label: string): string {\n if (!label || label === '.' || label === '..' || /[\\\\/]/u.test(label)) {\n throw new Error(\n `Backup label \"${label}\" is invalid. Labels must be a single path-safe segment.`,\n );\n }\n return label;\n}\n\nfunction validateManifestPathSegment(value: string, field: string): string {\n if (!value || value === '.' || value === '..' || /[\\\\/]/u.test(value)) {\n throw new Error(\n `Backup manifest ${field} is invalid: \"${value}\". It must be a single path segment.`,\n );\n }\n return value;\n}\n\nasync function createUniqueBackupDir(\n backupRoot: string,\n label: string,\n): Promise<string> {\n const root = resolve(backupRoot);\n await mkdir(root, { recursive: true, mode: 0o700 });\n\n const baseName = `${label}-${timestampForBackup()}`;\n for (let attempt = 0; attempt < 64; attempt += 1) {\n const suffix = attempt === 0 ? '' : `-${attempt.toString(36)}`;\n const candidate = join(root, `${baseName}${suffix}`);\n try {\n await mkdir(candidate, { mode: 0o700 });\n return candidate;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'EEXIST') {\n continue;\n }\n throw error;\n }\n }\n\n throw new Error(\n `Failed to create a unique backup directory under ${root} for label \"${label}\".`,\n );\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/backup/manifest.ts","../../src/backup/index.ts"],"sourcesContent":["/**\n * Backup manifest shape and helpers.\n *\n * The manifest is the source-of-truth for what's in a backup directory:\n * which database it came from, when, at what commit, and any\n * app-specific metadata that the caller chose to attach via the `extra`\n * field. Bumping `BACKUP_VERSION` is the way to evolve the shape — older\n * backups will fail `readBackupManifest` until upgraded.\n */\nimport { readFile, rename, writeFile } from 'node:fs/promises';\nimport { join, resolve } from 'node:path';\n\nexport const BACKUP_KIND = 'happyvertical-backup' as const;\nexport const BACKUP_VERSION = 1 as const;\nexport const DEFAULT_DUMP_FILE = 'database.dump';\nexport const DEFAULT_FILES_DIR = 'files';\nexport const DEFAULT_MANIFEST_FILE = 'manifest.json';\n\nexport interface BackupManifestDatabase {\n /** File name of the pg_dump archive within the backup directory. */\n dumpFile: string;\n /** Redacted URL the dump was taken from. */\n url: string;\n /** Caller-supplied diagnostic metadata (row counts, schema count, etc.). */\n metadata?: Record<string, unknown>;\n}\n\nexport interface BackupManifestFiles {\n /** Directory name within the backup that holds copied file storage. */\n directory: string;\n exported: boolean;\n count: number;\n bytes: number;\n reason?: string;\n /** Redacted provider config, if the caller chose to record it. */\n storageConfig?: unknown;\n}\n\nexport interface BackupManifest {\n kind: typeof BACKUP_KIND;\n version: typeof BACKUP_VERSION;\n timestamp: string;\n gitSha: string | null;\n /** Free-form label (e.g. 'current', 'prod', 'pre-migration'). */\n source: string;\n database: BackupManifestDatabase;\n files: BackupManifestFiles;\n /** Opaque slot for app-specific extension (resume publish status, etc.). */\n extra?: unknown;\n}\n\n/**\n * Read a backup manifest from `backupPath/manifest.json` and validate\n * that it matches this package's kind+version. Throws (with the path\n * included) on mismatch — that's almost always a real problem worth\n * surfacing, not silently tolerating.\n */\nexport async function readBackupManifest(\n backupPath: string,\n): Promise<BackupManifest> {\n const manifestPath = join(resolve(backupPath), DEFAULT_MANIFEST_FILE);\n const manifest = JSON.parse(\n await readFile(manifestPath, 'utf8'),\n ) as BackupManifest;\n if (manifest.kind !== BACKUP_KIND || manifest.version !== BACKUP_VERSION) {\n throw new Error(\n `Unsupported backup manifest at ${manifestPath} (kind=${manifest.kind}, version=${manifest.version}).`,\n );\n }\n return manifest;\n}\n\nexport async function writeBackupManifest(\n backupPath: string,\n manifest: BackupManifest,\n): Promise<void> {\n const targetPath = join(backupPath, DEFAULT_MANIFEST_FILE);\n const tempPath = join(\n backupPath,\n `.manifest.${process.pid}.${Date.now().toString(36)}.tmp`,\n );\n await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\\n`, {\n mode: 0o600,\n });\n await rename(tempPath, targetPath);\n}\n\n/**\n * Filesystem-safe timestamp suitable for backup directory names — same\n * shape as `Date.toISOString()` but with `:` and `.` swapped to `-` so\n * the value is a valid path segment on Windows and friendly to shell\n * tab-completion everywhere else.\n */\nexport function timestampForBackup(date = new Date()): string {\n return date.toISOString().replace(/[:.]/g, '-');\n}\n","/**\n * Backup orchestration — composes the pg shell-out helpers in\n * `../postgres-cli` with caller-supplied hooks for file storage,\n * app-specific manifest extras, and post-restore steps.\n *\n * Decoupled from `@happyvertical/files` on purpose: callers that want a\n * file-storage backup pass an `onBackup` callback that does the copy\n * however they prefer (local cp, S3 sync, WebDAV walk). That keeps this\n * subpath usable by apps that only need the database half.\n */\nimport { execFile } from 'node:child_process';\nimport { access, mkdir, rm } from 'node:fs/promises';\nimport { homedir } from 'node:os';\nimport { join, resolve } from 'node:path';\nimport {\n assertCanExportDatabase,\n assertCanImportDatabase,\n createPostgresDatabase,\n dropPostgresDatabase,\n dumpPostgresDatabase,\n redactDatabaseUrl,\n restorePostgresDatabase,\n} from '../postgres-cli';\nimport {\n BACKUP_KIND,\n BACKUP_VERSION,\n type BackupManifest,\n type BackupManifestFiles,\n DEFAULT_DUMP_FILE,\n DEFAULT_FILES_DIR,\n readBackupManifest,\n timestampForBackup,\n writeBackupManifest,\n} from './manifest';\n\nexport {\n BACKUP_KIND,\n BACKUP_VERSION,\n type BackupManifest,\n type BackupManifestDatabase,\n type BackupManifestFiles,\n DEFAULT_DUMP_FILE,\n DEFAULT_FILES_DIR,\n DEFAULT_MANIFEST_FILE,\n readBackupManifest,\n timestampForBackup,\n writeBackupManifest,\n} from './manifest';\n\nexport interface ExportBackupContext {\n backupPath: string;\n /**\n * The raw, credential-bearing database URL the caller passed to\n * `exportBackup`. The hook needs the raw form to open its own\n * connection, but **do not log this value verbatim** — it contains\n * credentials. Run it through `redactDatabaseUrl` (exported from\n * `@happyvertical/sql`) before including it in any output. The\n * manifest's `database.url` field is redacted; only `databaseUrl`\n * here is raw.\n */\n databaseUrl: string;\n /**\n * Local directory the caller should copy file storage into.\n * Already exists by the time `onBackup` runs.\n */\n filesDir: string;\n}\n\nexport interface ExportBackupResult<Extra = unknown> {\n /** Absolute path to the backup directory. */\n backupPath: string;\n /** The manifest that was written. */\n manifest: BackupManifest & { extra?: Extra };\n}\n\nexport interface ExportBackupOptions<Extra = unknown> {\n databaseUrl: string;\n /** Override the parent dir for the timestamped backup. Defaults to ~/.local/share/happyvertical/backups. */\n backupRoot?: string;\n /** Prefix the timestamp with this label (e.g. 'prod', 'pre-migration'). Defaults to 'backup'. */\n label?: string;\n /** Allow exporting from a non-local DB without --allow-production. */\n allowProduction?: boolean;\n /** Allow exporting from a local DB even though `prod: true` is set. */\n allowLocal?: boolean;\n /** Caller is running a prod-flavored export. */\n prod?: boolean;\n /** Skip the file-storage half entirely (no `onBackup` invoked). */\n skipFiles?: boolean;\n /**\n * Caller-supplied hook for backing up file storage. Receives the\n * already-created backup/files directory; whatever it returns is\n * recorded as `extra` on the manifest.\n */\n onBackup?: (ctx: ExportBackupContext) => Promise<Extra | void>;\n /**\n * Caller-supplied hook for filling the database half of the manifest\n * with diagnostic metadata (row counts, schema count, etc.) — runs\n * after the dump completes. As with `onBackup`, the `databaseUrl`\n * passed in is the raw credential-bearing form — redact via\n * `redactDatabaseUrl` before logging.\n */\n onDatabaseMetadata?: (ctx: {\n /** Raw URL — see warning on {@link ExportBackupContext.databaseUrl}. */\n databaseUrl: string;\n }) => Promise<Record<string, unknown>>;\n /** Recorded as `files.storageConfig` on the manifest after redaction by the caller. */\n filesStorageConfig?: unknown;\n /** Working dir used when discovering the git sha. Defaults to process.cwd(). */\n gitCwd?: string;\n}\n\nexport interface RestoreBackupContext {\n backupPath: string;\n /** Raw URL — see warning on {@link ExportBackupContext.databaseUrl}. */\n databaseUrl: string;\n filesDir: string;\n manifest: BackupManifest;\n}\n\nexport interface RestoreBackupOptions {\n databaseUrl: string;\n backupPath: string;\n allowProduction?: boolean;\n /** Skip the file-storage half (no `onRestore` invoked). */\n skipFiles?: boolean;\n /**\n * Caller-supplied hook for restoring file storage from\n * `backupPath/<files.directory>`. Runs after the pg_restore completes.\n */\n onRestore?: (ctx: RestoreBackupContext) => Promise<void>;\n}\n\nexport interface ResetLocalOptions\n extends Omit<RestoreBackupOptions, 'allowProduction'> {}\n\n/** Default root for backups. Override via $HAPPYVERTICAL_BACKUP_DIR or `backupRoot`. */\nexport function defaultBackupRoot(): string {\n return (\n process.env.HAPPYVERTICAL_BACKUP_DIR ??\n join(homedir(), '.local', 'share', 'happyvertical', 'backups')\n );\n}\n\n/**\n * Take a postgres dump and (optionally) a snapshot of file storage,\n * writing both plus a manifest into a fresh timestamped directory.\n *\n * Returns the directory path and the manifest. The manifest's `extra`\n * field carries whatever `onBackup` returned.\n *\n * **Failure model:** the function is all-or-nothing. If any step after\n * the initial directory creation throws — `dumpPostgresDatabase`,\n * `onBackup`, `onDatabaseMetadata`, `getGitSha`, or the final manifest\n * write — the partial backup directory is removed (best-effort)\n * before the original error is re-thrown. Callers therefore never see\n * a backup directory on disk without a complete manifest, which would\n * otherwise poison later `restoreBackup` calls with confusing\n * \"no manifest\" errors and leak a partial dump containing sensitive\n * data. If cleanup itself fails, that error is logged via\n * `console.warn` and swallowed so it doesn't mask the original.\n */\nexport async function exportBackup<Extra = unknown>(\n options: ExportBackupOptions<Extra>,\n): Promise<ExportBackupResult<Extra>> {\n assertCanExportDatabase(options.databaseUrl, options);\n\n const label = validateLabel(options.label ?? 'backup');\n const backupPath = await createUniqueBackupDir(\n options.backupRoot ?? defaultBackupRoot(),\n label,\n );\n\n try {\n const dumpPath = join(backupPath, DEFAULT_DUMP_FILE);\n await dumpPostgresDatabase(options.databaseUrl, dumpPath);\n\n const filesDir = join(backupPath, DEFAULT_FILES_DIR);\n let filesInfo: BackupManifestFiles = {\n directory: DEFAULT_FILES_DIR,\n exported: false,\n count: 0,\n bytes: 0,\n reason: 'Skipped by skipFiles.',\n storageConfig: options.filesStorageConfig,\n };\n let extra: Extra | undefined;\n\n if (!options.skipFiles && options.onBackup) {\n await mkdir(filesDir, { recursive: true, mode: 0o700 });\n const result = await options.onBackup({\n backupPath,\n databaseUrl: options.databaseUrl,\n filesDir,\n });\n if (result !== undefined && result !== null) extra = result as Extra;\n filesInfo = {\n directory: DEFAULT_FILES_DIR,\n exported: true,\n count: 0,\n bytes: 0,\n storageConfig: options.filesStorageConfig,\n };\n }\n\n const metadata = options.onDatabaseMetadata\n ? await options.onDatabaseMetadata({ databaseUrl: options.databaseUrl })\n : undefined;\n\n const manifest: BackupManifest & { extra?: Extra } = {\n kind: BACKUP_KIND,\n version: BACKUP_VERSION,\n timestamp: new Date().toISOString(),\n gitSha: await getGitSha(options.gitCwd),\n source: label,\n database: {\n dumpFile: DEFAULT_DUMP_FILE,\n url: redactDatabaseUrl(options.databaseUrl),\n metadata,\n },\n files: filesInfo,\n extra,\n };\n\n await writeBackupManifest(backupPath, manifest);\n return { backupPath, manifest };\n } catch (error) {\n // Partial state on disk would either confuse later restoreBackup\n // calls (no manifest → cryptic ENOENT) or leak sensitive dump\n // contents. Remove the directory; if cleanup fails, log but don't\n // mask the original error.\n try {\n await rm(backupPath, { recursive: true, force: true });\n } catch (cleanupError) {\n console.warn(\n `exportBackup: failed to clean up partial backup at ${backupPath}:`,\n cleanupError instanceof Error\n ? cleanupError.message\n : String(cleanupError),\n );\n }\n throw error;\n }\n}\n\n/**\n * Restore a backup directory into `databaseUrl` and, if `onRestore` is\n * provided, run the file-storage half. The caller controls everything\n * after the dump completes (file copy, migrations, doctor) via the hook.\n */\nexport async function restoreBackup(\n options: RestoreBackupOptions,\n): Promise<BackupManifest> {\n const backupPath = resolve(options.backupPath);\n const manifest = await readBackupManifest(backupPath);\n assertCanImportDatabase(options.databaseUrl, options);\n const dumpPath = join(\n backupPath,\n validateManifestPathSegment(\n manifest.database.dumpFile,\n 'database dump file',\n ),\n );\n const filesDir = join(\n backupPath,\n validateManifestPathSegment(manifest.files.directory, 'files directory'),\n );\n\n await restorePostgresDatabase(options.databaseUrl, dumpPath);\n\n // Skip `onRestore` for DB-only backups: if the backup was taken with\n // `skipFiles: true` or no `onBackup` hook, the manifest records\n // `files.exported: false` and the `files/` directory may not exist on\n // disk. Calling a generic restore hook anyway would either crash\n // after the database has already been restored, or — worse — let\n // the hook clear target file storage before discovering there's\n // nothing to restore from.\n if (!options.skipFiles && options.onRestore && manifest.files.exported) {\n await options.onRestore({\n backupPath,\n databaseUrl: options.databaseUrl,\n filesDir,\n manifest,\n });\n }\n\n return manifest;\n}\n\n/**\n * Drop-and-recreate the target database, then run `restoreBackup`.\n * Local-only: explicitly refuses non-local URLs without an escape hatch\n * (this is a destructive operation by design).\n */\nexport async function resetLocalDatabaseFromBackup(\n options: ResetLocalOptions,\n): Promise<BackupManifest> {\n assertCanImportDatabase(options.databaseUrl, { allowProduction: false });\n\n // Validate the backup BEFORE dropping the local database. A mistyped\n // backupPath, missing dump file, or corrupted manifest field would\n // otherwise destroy the existing database (drop + create) and only\n // then fail when restoreBackup tries to read those values — leaving\n // the developer with an empty local DB and a confusing ENOENT error.\n //\n // Validates every manifest field restoreBackup later relies on\n // (`database.dumpFile` AND `files.directory`), and verifies the dump\n // file actually exists on disk. The drop only happens if all of that\n // is sound.\n const backupPath = resolve(options.backupPath);\n const manifest = await readBackupManifest(backupPath);\n const dumpFile = validateManifestPathSegment(\n manifest.database.dumpFile,\n 'database dump file',\n );\n validateManifestPathSegment(manifest.files.directory, 'files directory');\n await access(join(backupPath, dumpFile));\n\n await dropPostgresDatabase(options.databaseUrl);\n await createPostgresDatabase(options.databaseUrl);\n return restoreBackup({ ...options, allowProduction: false });\n}\n\nasync function getGitSha(cwd: string = process.cwd()): Promise<string | null> {\n try {\n return await new Promise<string>((resolvePromise, reject) => {\n execFile('git', ['rev-parse', 'HEAD'], { cwd }, (error, stdout) => {\n if (error) {\n reject(error);\n return;\n }\n resolvePromise(stdout.trim());\n });\n });\n } catch {\n return null;\n }\n}\n\nfunction validateLabel(label: string): string {\n if (!label || label === '.' || label === '..' || /[\\\\/]/u.test(label)) {\n throw new Error(\n `Backup label \"${label}\" is invalid. Labels must be a single path-safe segment.`,\n );\n }\n return label;\n}\n\nfunction validateManifestPathSegment(value: string, field: string): string {\n if (!value || value === '.' || value === '..' || /[\\\\/]/u.test(value)) {\n throw new Error(\n `Backup manifest ${field} is invalid: \"${value}\". It must be a single path segment.`,\n );\n }\n return value;\n}\n\nasync function createUniqueBackupDir(\n backupRoot: string,\n label: string,\n): Promise<string> {\n const root = resolve(backupRoot);\n await mkdir(root, { recursive: true, mode: 0o700 });\n\n const baseName = `${label}-${timestampForBackup()}`;\n for (let attempt = 0; attempt < 64; attempt += 1) {\n const suffix = attempt === 0 ? '' : `-${attempt.toString(36)}`;\n const candidate = join(root, `${baseName}${suffix}`);\n try {\n await mkdir(candidate, { mode: 0o700 });\n return candidate;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'EEXIST') {\n continue;\n }\n throw error;\n }\n }\n\n throw new Error(\n `Failed to create a unique backup directory under ${root} for label \"${label}\".`,\n );\n}\n"],"mappings":"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"}