@yunsoft/yuncms 0.1.2 → 0.1.5

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @yunsoft/yuncms
2
2
 
3
- Command-line setup and runtime launcher for YunCMS.
3
+ Command-line setup, runtime launcher and guarded upgrade tooling for YunCMS.
4
4
 
5
5
  ```bash
6
6
  npm install @yunsoft/yuncms
@@ -8,10 +8,23 @@ npx yuncms init
8
8
  npx yuncms start
9
9
  ```
10
10
 
11
- YunCMS requires Node.js 24 LTS and MySQL for V1. See the [project repository](https://github.com/Yunsoft-Software/yuncms) for setup, deployment and API documentation.
11
+ Production maintenance commands:
12
+
13
+ ```bash
14
+ npx yuncms backup
15
+ npx yuncms update --dry-run
16
+ npx yuncms update --to 0.2.0
17
+ npx yuncms restore /path/to/backup --yes
18
+ ```
19
+
20
+ `backup` and `update` require the YunCMS service supervisor to be stopped so database and local Files/extensions can be snapshotted consistently. Managed updates require a verified backup, inspect target migration compatibility, run the newly installed migration code, probe `/ready`, and attempt automatic rollback on failure.
21
+
22
+ S3 objects are not copied by YunCMS backup; use provider-side versioning/snapshots and verify recovery before acknowledging an S3 update.
23
+
24
+ YunCMS requires Node.js 24 LTS and MySQL for V1. See the [project repository](https://github.com/Yunsoft-Software/yuncms), `docs/setup-cli.md` and `docs/upgrades.md` for setup, deployment and upgrade documentation.
12
25
 
13
26
  ## Project status
14
27
 
15
28
  YunCMS is developed and maintained by [Yunsoft Software](https://yunsoft.com). It is under active development, so interfaces and behavior may change between releases. Test upgrades and keep verified backups before production use.
16
29
 
17
- Use YunCMS at your own risk. This package is provided under the [MIT License](https://github.com/Yunsoft-Software/yuncms/blob/16-08-2026/LICENSE) without warranty.
30
+ Use YunCMS at your own risk. This package is provided under the [MIT License](https://github.com/Yunsoft-Software/yuncms/blob/22-08-2026/LICENSE) without warranty.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Command-line setup and runtime launcher for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "test": "node --test"
31
31
  },
32
32
  "dependencies": {
33
- "@yunsoft/yuncms-api": "0.1.2",
34
- "@yunsoft/yuncms-core": "0.1.2"
33
+ "@yunsoft/yuncms-api": "0.1.5",
34
+ "@yunsoft/yuncms-core": "0.1.5"
35
35
  }
36
36
  }
@@ -0,0 +1,72 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { loadConfig } from '@yunsoft/yuncms-core';
5
+
6
+ import { parseCommandOptions } from './command-options.js';
7
+ import { acquireDatabaseMaintenanceLock } from './maintenance-lock.js';
8
+ import { createProjectBackup } from './project-backup.js';
9
+ import { assertYunCmsStopped } from './service-state.js';
10
+ import { acquireUpdateLock } from './update-lock.js';
11
+
12
+ function assertLockContract(lock) {
13
+ if (!lock || typeof lock.assertHeld !== 'function' || typeof lock.release !== 'function') {
14
+ const error = new Error('Database maintenance lock implementation is invalid');
15
+ error.code = 'DATABASE_MAINTENANCE_LOCK_INVALID';
16
+ throw error;
17
+ }
18
+ return lock;
19
+ }
20
+
21
+ export async function runBackupCommand({
22
+ args = [],
23
+ cwd = process.cwd(),
24
+ env = process.env,
25
+ output = console,
26
+ createBackup = createProjectBackup,
27
+ assertStopped = assertYunCmsStopped,
28
+ acquireProjectLock = acquireUpdateLock,
29
+ acquireMaintenanceLock = acquireDatabaseMaintenanceLock,
30
+ fetchFn = globalThis.fetch,
31
+ } = {}) {
32
+ const { values } = parseCommandOptions(args, {
33
+ string: ['--output'],
34
+ maxPositionals: 0,
35
+ });
36
+
37
+ const config = loadConfig(env);
38
+ const assertServiceStopped = () => assertStopped({
39
+ host: config.server.host,
40
+ port: config.server.port,
41
+ fetchFn,
42
+ });
43
+ await assertServiceStopped();
44
+
45
+ const projectLock = await acquireProjectLock({ cwd });
46
+ let maintenanceLock = null;
47
+ let backup = null;
48
+ try {
49
+ maintenanceLock = assertLockContract(await acquireMaintenanceLock({ env }));
50
+ await assertServiceStopped();
51
+ await maintenanceLock.assertHeld();
52
+
53
+ const backupPath = values['--output'] ? resolve(cwd, values['--output']) : null;
54
+ backup = await createBackup({ cwd, env, output, backupPath });
55
+
56
+ try {
57
+ await assertServiceStopped();
58
+ await maintenanceLock.assertHeld();
59
+ } catch (error) {
60
+ if (backup?.backupPath) {
61
+ await rm(backup.backupPath, { recursive: true, force: true }).catch(() => {});
62
+ error.backupDiscarded = true;
63
+ }
64
+ throw error;
65
+ }
66
+
67
+ return backup;
68
+ } finally {
69
+ if (maintenanceLock) await maintenanceLock.release();
70
+ await projectLock.release();
71
+ }
72
+ }
@@ -0,0 +1,118 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { lstat, readdir } from 'node:fs/promises';
4
+ import { relative, resolve, sep } from 'node:path';
5
+
6
+ function backupError(code, message, details = {}) {
7
+ const error = new Error(message);
8
+ error.code = code;
9
+ Object.assign(error, details);
10
+ return error;
11
+ }
12
+
13
+ async function pathInfo(path, { optional = false, kind = null } = {}) {
14
+ let info;
15
+ try {
16
+ info = await lstat(path);
17
+ } catch (error) {
18
+ if (optional && error?.code === 'ENOENT') return null;
19
+ throw error;
20
+ }
21
+
22
+ if (info.isSymbolicLink()) {
23
+ throw backupError(
24
+ 'BACKUP_SYMLINK_UNSUPPORTED',
25
+ `Managed backup/restore does not support symbolic links: ${path}`,
26
+ { path },
27
+ );
28
+ }
29
+
30
+ if (kind === 'file' && !info.isFile()) {
31
+ throw backupError('BACKUP_ASSET_TYPE_INVALID', `Expected a regular file: ${path}`, { path, kind });
32
+ }
33
+ if (kind === 'directory' && !info.isDirectory()) {
34
+ throw backupError('BACKUP_ASSET_TYPE_INVALID', `Expected a directory: ${path}`, { path, kind });
35
+ }
36
+ if (!info.isFile() && !info.isDirectory()) {
37
+ throw backupError('BACKUP_ASSET_TYPE_INVALID', `Unsupported backup asset type: ${path}`, { path, kind });
38
+ }
39
+
40
+ return info;
41
+ }
42
+
43
+ async function updateHashWithFile(hash, path) {
44
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
45
+ hash.update('\0END\0');
46
+ }
47
+
48
+ export async function assertBackupAssetType(path, kind, { optional = false } = {}) {
49
+ const info = await pathInfo(path, { optional, kind });
50
+ return info !== null;
51
+ }
52
+
53
+ export async function hashFile(path) {
54
+ await pathInfo(path, { kind: 'file' });
55
+ const hash = createHash('sha256');
56
+ hash.update('YunCMS:file:v1\0');
57
+ await updateHashWithFile(hash, path);
58
+ return hash.digest('hex');
59
+ }
60
+
61
+ function compareEntryNames(left, right) {
62
+ if (left.name < right.name) return -1;
63
+ if (left.name > right.name) return 1;
64
+ return 0;
65
+ }
66
+
67
+ async function hashDirectoryEntry(root, path, hash) {
68
+ const info = await pathInfo(path);
69
+ const relativePath = relative(root, path).split(sep).join('/');
70
+
71
+ if (info.isDirectory()) {
72
+ hash.update(`D\0${relativePath}\0`);
73
+ const entries = await readdir(path, { withFileTypes: true });
74
+ entries.sort(compareEntryNames);
75
+ for (const entry of entries) {
76
+ await hashDirectoryEntry(root, resolve(path, entry.name), hash);
77
+ }
78
+ return;
79
+ }
80
+
81
+ hash.update(`F\0${relativePath}\0${info.size}\0`);
82
+ await updateHashWithFile(hash, path);
83
+ }
84
+
85
+ export async function hashDirectory(path) {
86
+ await pathInfo(path, { kind: 'directory' });
87
+ const root = resolve(path);
88
+ const hash = createHash('sha256');
89
+ hash.update('YunCMS:directory:v1\0');
90
+ await hashDirectoryEntry(root, root, hash);
91
+ return hash.digest('hex');
92
+ }
93
+
94
+ export async function hashOptionalAsset(path, kind) {
95
+ const present = await assertBackupAssetType(path, kind, { optional: true });
96
+ if (!present) return null;
97
+ return kind === 'directory' ? hashDirectory(path) : hashFile(path);
98
+ }
99
+
100
+ export async function verifyAssetDigest(path, kind, expectedDigest) {
101
+ if (typeof expectedDigest !== 'string' || !/^[0-9a-f]{64}$/i.test(expectedDigest)) {
102
+ throw backupError(
103
+ 'BACKUP_MANIFEST_INVALID',
104
+ `Backup manifest contains an invalid SHA-256 digest for ${path}`,
105
+ { path },
106
+ );
107
+ }
108
+
109
+ const actualDigest = kind === 'directory' ? await hashDirectory(path) : await hashFile(path);
110
+ if (actualDigest.toLowerCase() !== expectedDigest.toLowerCase()) {
111
+ throw backupError(
112
+ 'BACKUP_INTEGRITY_MISMATCH',
113
+ `Backup integrity check failed for ${path}`,
114
+ { path, expectedDigest, actualDigest },
115
+ );
116
+ }
117
+ return true;
118
+ }
package/src/cli.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { runBackupCommand } from './backup-command.js';
1
2
  import { runBootstrapCommand } from './bootstrap-command.js';
2
3
  import { runInitCommand } from './init-command.js';
4
+ import { runRestoreCommand } from './restore-command.js';
3
5
  import { runStartCommand } from './start-command.js';
6
+ import { runUpdateCommand } from './update-command.js';
4
7
 
5
8
  function assertSupportedNode(version = process.versions.node) {
6
9
  const major = Number(String(version).split('.')[0]);
@@ -11,8 +14,15 @@ function assertSupportedNode(version = process.versions.node) {
11
14
  }
12
15
  }
13
16
 
17
+ function assertNoArguments(command, rest) {
18
+ if (rest.length === 0) return;
19
+ const error = new Error(`Unexpected arguments for ${command}: ${rest.join(' ')}`);
20
+ error.code = 'INVALID_CLI_ARGUMENTS';
21
+ throw error;
22
+ }
23
+
14
24
  function printHelp(output) {
15
- output.log?.(`YunCMS CLI\n\nCommands:\n yuncms init Configure MySQL, bootstrap schema and create the first administrator\n yuncms bootstrap Apply required core database migrations\n yuncms start Start the YunCMS API using the current project environment\n yuncms help Show this help`);
25
+ output.log?.(`YunCMS CLI\n\nCommands:\n yuncms init Configure MySQL, bootstrap schema and create the first administrator\n yuncms bootstrap Apply required core database migrations\n yuncms start Start the YunCMS API using the current project environment\n yuncms backup [--output PATH] Create a database/project backup\n yuncms restore PATH --yes Restore an exact backup snapshot\n yuncms update [--to VERSION] Backup, update package, migrate, probe and rollback on failure\n yuncms update --dry-run Inspect update safety without changing the project\n yuncms help Show this help`);
16
26
  }
17
27
 
18
28
  export async function runCli(argv = process.argv.slice(2), {
@@ -21,26 +31,33 @@ export async function runCli(argv = process.argv.slice(2), {
21
31
  cwd = process.cwd(),
22
32
  prompts,
23
33
  startCommand = runStartCommand,
34
+ backupCommand = runBackupCommand,
35
+ restoreCommand = runRestoreCommand,
36
+ updateCommand = runUpdateCommand,
24
37
  } = {}) {
25
38
  assertSupportedNode();
26
39
  const [command = 'help', ...rest] = argv;
27
40
 
28
- if (rest.length > 0) {
29
- const error = new Error(`Unexpected arguments for ${command}: ${rest.join(' ')}`);
30
- error.code = 'INVALID_CLI_ARGUMENTS';
31
- throw error;
32
- }
33
-
34
41
  switch (command) {
35
42
  case 'init':
43
+ assertNoArguments(command, rest);
36
44
  return runInitCommand({ env, cwd, output, ...(prompts ? { prompts } : {}) });
37
45
  case 'bootstrap':
46
+ assertNoArguments(command, rest);
38
47
  return runBootstrapCommand({ env, output });
39
48
  case 'start':
49
+ assertNoArguments(command, rest);
40
50
  return startCommand({ env, cwd, output });
51
+ case 'backup':
52
+ return backupCommand({ args: rest, env, cwd, output });
53
+ case 'restore':
54
+ return restoreCommand({ args: rest, env, cwd, output });
55
+ case 'update':
56
+ return updateCommand({ args: rest, env, cwd, output });
41
57
  case 'help':
42
58
  case '--help':
43
59
  case '-h':
60
+ assertNoArguments(command, rest);
44
61
  printHelp(output);
45
62
  return null;
46
63
  default: {
@@ -0,0 +1,57 @@
1
+ function argumentError(message) {
2
+ const error = new Error(message);
3
+ error.code = 'INVALID_CLI_ARGUMENTS';
4
+ return error;
5
+ }
6
+
7
+ export function parseCommandOptions(args = [], {
8
+ boolean = [],
9
+ string = [],
10
+ minPositionals = 0,
11
+ maxPositionals = 0,
12
+ } = {}) {
13
+ const booleanOptions = new Set(boolean);
14
+ const stringOptions = new Set(string);
15
+ const values = {};
16
+ const positionals = [];
17
+
18
+ for (let index = 0; index < args.length; index += 1) {
19
+ const token = args[index];
20
+ if (!token.startsWith('--')) {
21
+ positionals.push(token);
22
+ continue;
23
+ }
24
+
25
+ const separator = token.indexOf('=');
26
+ const name = separator === -1 ? token : token.slice(0, separator);
27
+ const inlineValue = separator === -1 ? null : token.slice(separator + 1);
28
+
29
+ if (booleanOptions.has(name)) {
30
+ if (inlineValue !== null) throw argumentError(`${name} does not accept a value`);
31
+ if (Object.hasOwn(values, name)) throw argumentError(`Duplicate option: ${name}`);
32
+ values[name] = true;
33
+ continue;
34
+ }
35
+
36
+ if (stringOptions.has(name)) {
37
+ if (Object.hasOwn(values, name)) throw argumentError(`Duplicate option: ${name}`);
38
+ const value = inlineValue ?? args[++index];
39
+ if (value == null || value === '' || value.startsWith('--')) {
40
+ throw argumentError(`${name} requires a value`);
41
+ }
42
+ values[name] = value;
43
+ continue;
44
+ }
45
+
46
+ throw argumentError(`Unknown option: ${name}`);
47
+ }
48
+
49
+ if (positionals.length < minPositionals || positionals.length > maxPositionals) {
50
+ const range = minPositionals === maxPositionals
51
+ ? `${minPositionals}`
52
+ : `${minPositionals}-${maxPositionals}`;
53
+ throw argumentError(`Expected ${range} positional arguments, received ${positionals.length}`);
54
+ }
55
+
56
+ return { values, positionals };
57
+ }
@@ -0,0 +1,336 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { Writable } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { createGunzip, createGzip } from 'node:zlib';
6
+
7
+ const MAX_STDERR_BYTES = 64 * 1024;
8
+ export const DEFAULT_DATABASE_TOOL_TIMEOUT_MS = 2 * 60 * 60 * 1000;
9
+ const MAX_DATABASE_TOOL_TIMEOUT_MS = 24 * 60 * 60 * 1000;
10
+ const DEFAULT_KILL_GRACE_MS = 5_000;
11
+
12
+ function delay(ms) {
13
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
14
+ }
15
+
16
+ function databaseArgs(config, { dump = false } = {}) {
17
+ const args = [
18
+ '--protocol=TCP',
19
+ `--host=${config.host}`,
20
+ `--port=${config.port}`,
21
+ `--user=${config.user}`,
22
+ '--default-character-set=utf8mb4',
23
+ ];
24
+
25
+ if (config.ssl) args.push('--ssl-mode=REQUIRED');
26
+ if (dump) {
27
+ args.push(
28
+ '--single-transaction',
29
+ '--quick',
30
+ '--hex-blob',
31
+ '--triggers',
32
+ '--no-tablespaces',
33
+ );
34
+ }
35
+
36
+ args.push(config.database);
37
+ return args;
38
+ }
39
+
40
+ function childEnvironment(config, env = process.env) {
41
+ return {
42
+ ...env,
43
+ MYSQL_PWD: config.password,
44
+ };
45
+ }
46
+
47
+ function positiveInteger(value, code, label, max) {
48
+ const parsed = Number(value);
49
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > max) {
50
+ const error = new Error(`${label} must be an integer between 1 and ${max}`);
51
+ error.code = code;
52
+ throw error;
53
+ }
54
+ return parsed;
55
+ }
56
+
57
+ export function resolveDatabaseToolTimeoutMs(env = process.env, explicitTimeoutMs = null) {
58
+ const value = explicitTimeoutMs ?? env.YUNCMS_DB_TOOL_TIMEOUT_MS ?? DEFAULT_DATABASE_TOOL_TIMEOUT_MS;
59
+ return positiveInteger(
60
+ value,
61
+ 'DATABASE_TOOL_TIMEOUT_INVALID',
62
+ 'YunCMS database tool timeout',
63
+ MAX_DATABASE_TOOL_TIMEOUT_MS,
64
+ );
65
+ }
66
+
67
+ function collectStderr(stream) {
68
+ let stderr = '';
69
+ stream?.setEncoding?.('utf8');
70
+ stream?.on?.('data', (chunk) => {
71
+ if (stderr.length >= MAX_STDERR_BYTES) return;
72
+ stderr += String(chunk).slice(0, MAX_STDERR_BYTES - stderr.length);
73
+ });
74
+ return () => stderr.trim();
75
+ }
76
+
77
+ function databaseTimeoutError(command, timeoutMs, readStderr) {
78
+ const detail = readStderr();
79
+ const error = new Error(
80
+ `${command} exceeded the ${timeoutMs}ms YunCMS database tool timeout${detail ? `: ${detail}` : ''}`,
81
+ );
82
+ error.code = 'DATABASE_TOOL_TIMEOUT';
83
+ error.command = command;
84
+ error.timeoutMs = timeoutMs;
85
+ error.stderr = detail;
86
+ return error;
87
+ }
88
+
89
+ function waitForChild(child, command, readStderr, { timeoutMs, killGraceMs }) {
90
+ return new Promise((resolve, reject) => {
91
+ let settled = false;
92
+ let timedOut = false;
93
+ let timeoutHandle = null;
94
+ let forceKillHandle = null;
95
+ let forceRejectHandle = null;
96
+
97
+ function clearTimers() {
98
+ if (timeoutHandle) clearTimeout(timeoutHandle);
99
+ if (forceKillHandle) clearTimeout(forceKillHandle);
100
+ if (forceRejectHandle) clearTimeout(forceRejectHandle);
101
+ }
102
+
103
+ function finish(callback) {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimers();
107
+ callback();
108
+ }
109
+
110
+ function requestKill(signal) {
111
+ try {
112
+ child.kill?.(signal);
113
+ } catch {
114
+ // The bounded timeout path still rejects if signalling the child handle fails.
115
+ }
116
+ }
117
+
118
+ timeoutHandle = setTimeout(() => {
119
+ if (settled) return;
120
+ timedOut = true;
121
+ requestKill('SIGTERM');
122
+ forceKillHandle = setTimeout(() => {
123
+ if (settled) return;
124
+ requestKill('SIGKILL');
125
+ forceRejectHandle = setTimeout(() => {
126
+ finish(() => reject(databaseTimeoutError(command, timeoutMs, readStderr)));
127
+ }, killGraceMs);
128
+ }, killGraceMs);
129
+ }, timeoutMs);
130
+
131
+ child.once('error', (error) => {
132
+ finish(() => {
133
+ if (timedOut) {
134
+ reject(databaseTimeoutError(command, timeoutMs, readStderr));
135
+ return;
136
+ }
137
+ error.code ||= 'BACKUP_PROCESS_START_FAILED';
138
+ error.command = command;
139
+ reject(error);
140
+ });
141
+ });
142
+
143
+ child.once('exit', (code, signal) => {
144
+ finish(() => {
145
+ if (timedOut) {
146
+ const error = databaseTimeoutError(command, timeoutMs, readStderr);
147
+ error.exitCode = code;
148
+ error.signal = signal;
149
+ reject(error);
150
+ return;
151
+ }
152
+ if (code === 0) {
153
+ resolve();
154
+ return;
155
+ }
156
+ const detail = readStderr();
157
+ const error = new Error(
158
+ `${command} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}${detail ? `: ${detail}` : ''}`,
159
+ );
160
+ error.code = 'BACKUP_PROCESS_FAILED';
161
+ error.command = command;
162
+ error.exitCode = code;
163
+ error.signal = signal;
164
+ error.stderr = detail;
165
+ reject(error);
166
+ });
167
+ });
168
+ });
169
+ }
170
+
171
+ async function terminateChild(child, childPromise, killGraceMs) {
172
+ let settled = false;
173
+ const observedChild = childPromise.then(
174
+ () => { settled = true; },
175
+ () => { settled = true; },
176
+ );
177
+
178
+ try {
179
+ child.kill?.('SIGTERM');
180
+ } catch {
181
+ // Continue to bounded wait/SIGKILL.
182
+ }
183
+ await Promise.race([observedChild, delay(killGraceMs)]);
184
+ if (settled) return;
185
+
186
+ try {
187
+ child.kill?.('SIGKILL');
188
+ } catch {
189
+ // The caller will still receive the original pipeline error.
190
+ }
191
+ await Promise.race([observedChild, delay(killGraceMs)]);
192
+ }
193
+
194
+ async function runDatabasePipeline({ child, command, streamPromise, timeoutMs, killGraceMs, readStderr }) {
195
+ const childPromise = waitForChild(child, command, readStderr, { timeoutMs, killGraceMs });
196
+ let streamFailed = false;
197
+ const observedStream = streamPromise.catch((error) => {
198
+ streamFailed = true;
199
+ throw error;
200
+ });
201
+
202
+ try {
203
+ await Promise.all([childPromise, observedStream]);
204
+ } catch (error) {
205
+ if (streamFailed) await terminateChild(child, childPromise, killGraceMs);
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ function spawnDatabaseProcess(spawnProcess, command, args, options) {
211
+ try {
212
+ return spawnProcess(command, args, options);
213
+ } catch (error) {
214
+ error.code ||= 'BACKUP_PROCESS_START_FAILED';
215
+ error.command = command;
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ export function buildDatabaseClientArgs(config, options = {}) {
221
+ return databaseArgs(config, options);
222
+ }
223
+
224
+ export async function dumpDatabase({
225
+ config,
226
+ outputPath,
227
+ env = process.env,
228
+ spawnProcess = spawn,
229
+ timeoutMs = null,
230
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
231
+ } = {}) {
232
+ if (!config || !outputPath) throw new Error('Database config and output path are required');
233
+ const resolvedTimeoutMs = resolveDatabaseToolTimeoutMs(env, timeoutMs);
234
+ const resolvedKillGraceMs = positiveInteger(
235
+ killGraceMs,
236
+ 'DATABASE_TOOL_KILL_GRACE_INVALID',
237
+ 'Database tool kill grace period',
238
+ 60_000,
239
+ );
240
+
241
+ const child = spawnDatabaseProcess(
242
+ spawnProcess,
243
+ 'mysqldump',
244
+ databaseArgs(config, { dump: true }),
245
+ {
246
+ env: childEnvironment(config, env),
247
+ stdio: ['ignore', 'pipe', 'pipe'],
248
+ },
249
+ );
250
+ const readStderr = collectStderr(child.stderr);
251
+ const streamPromise = pipeline(
252
+ child.stdout,
253
+ createGzip({ level: 6 }),
254
+ createWriteStream(outputPath, { mode: 0o600 }),
255
+ );
256
+
257
+ await runDatabasePipeline({
258
+ child,
259
+ command: 'mysqldump',
260
+ streamPromise,
261
+ timeoutMs: resolvedTimeoutMs,
262
+ killGraceMs: resolvedKillGraceMs,
263
+ readStderr,
264
+ });
265
+
266
+ return outputPath;
267
+ }
268
+
269
+ export async function verifyDatabaseDump({ inputPath } = {}) {
270
+ if (!inputPath) throw new Error('Database dump path is required');
271
+ let decompressedBytes = 0;
272
+ const sink = new Writable({
273
+ write(chunk, _encoding, callback) {
274
+ decompressedBytes += chunk.length;
275
+ callback();
276
+ },
277
+ });
278
+
279
+ try {
280
+ await pipeline(createReadStream(inputPath), createGunzip(), sink);
281
+ } catch (error) {
282
+ const invalid = new Error(`Database backup is not a valid gzip stream: ${inputPath}`);
283
+ invalid.code = 'BACKUP_DATABASE_INVALID';
284
+ invalid.cause = error;
285
+ throw invalid;
286
+ }
287
+
288
+ if (decompressedBytes === 0) {
289
+ const error = new Error(`Database backup decompressed to an empty dump: ${inputPath}`);
290
+ error.code = 'BACKUP_DATABASE_EMPTY';
291
+ throw error;
292
+ }
293
+
294
+ return { decompressedBytes };
295
+ }
296
+
297
+ export async function restoreDatabase({
298
+ config,
299
+ inputPath,
300
+ env = process.env,
301
+ spawnProcess = spawn,
302
+ timeoutMs = null,
303
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
304
+ } = {}) {
305
+ if (!config || !inputPath) throw new Error('Database config and input path are required');
306
+ const resolvedTimeoutMs = resolveDatabaseToolTimeoutMs(env, timeoutMs);
307
+ const resolvedKillGraceMs = positiveInteger(
308
+ killGraceMs,
309
+ 'DATABASE_TOOL_KILL_GRACE_INVALID',
310
+ 'Database tool kill grace period',
311
+ 60_000,
312
+ );
313
+
314
+ const child = spawnDatabaseProcess(
315
+ spawnProcess,
316
+ 'mysql',
317
+ databaseArgs(config),
318
+ {
319
+ env: childEnvironment(config, env),
320
+ stdio: ['pipe', 'ignore', 'pipe'],
321
+ },
322
+ );
323
+ const readStderr = collectStderr(child.stderr);
324
+ const streamPromise = pipeline(createReadStream(inputPath), createGunzip(), child.stdin);
325
+
326
+ await runDatabasePipeline({
327
+ child,
328
+ command: 'mysql',
329
+ streamPromise,
330
+ timeoutMs: resolvedTimeoutMs,
331
+ killGraceMs: resolvedKillGraceMs,
332
+ readStderr,
333
+ });
334
+
335
+ return true;
336
+ }