spindb 0.5.4 → 0.5.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 +27 -1
- package/cli/commands/backup.ts +263 -0
- package/cli/commands/config.ts +144 -66
- package/cli/commands/engines.ts +1 -9
- package/cli/commands/list.ts +42 -4
- package/cli/commands/menu.ts +191 -19
- package/cli/commands/restore.ts +3 -0
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +87 -0
- package/cli/ui/theme.ts +11 -0
- package/core/config-manager.ts +133 -37
- package/core/container-manager.ts +76 -2
- package/core/dependency-manager.ts +5 -0
- package/engines/base-engine.ts +20 -0
- package/engines/mysql/backup.ts +159 -0
- package/engines/mysql/index.ts +39 -0
- package/engines/mysql/restore.ts +16 -2
- package/engines/postgresql/backup.ts +93 -0
- package/engines/postgresql/index.ts +37 -0
- package/package.json +1 -1
- package/types/index.ts +20 -0
package/core/config-manager.ts
CHANGED
|
@@ -18,6 +18,32 @@ const DEFAULT_CONFIG: SpinDBConfig = {
|
|
|
18
18
|
binaries: {},
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Cache staleness threshold (7 days in milliseconds)
|
|
22
|
+
const CACHE_STALENESS_MS = 7 * 24 * 60 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
// All tools organized by category
|
|
25
|
+
const POSTGRESQL_TOOLS: BinaryTool[] = [
|
|
26
|
+
'psql',
|
|
27
|
+
'pg_dump',
|
|
28
|
+
'pg_restore',
|
|
29
|
+
'pg_basebackup',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const MYSQL_TOOLS: BinaryTool[] = [
|
|
33
|
+
'mysql',
|
|
34
|
+
'mysqldump',
|
|
35
|
+
'mysqladmin',
|
|
36
|
+
'mysqld',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const ENHANCED_SHELLS: BinaryTool[] = ['pgcli', 'mycli', 'usql']
|
|
40
|
+
|
|
41
|
+
const ALL_TOOLS: BinaryTool[] = [
|
|
42
|
+
...POSTGRESQL_TOOLS,
|
|
43
|
+
...MYSQL_TOOLS,
|
|
44
|
+
...ENHANCED_SHELLS,
|
|
45
|
+
]
|
|
46
|
+
|
|
21
47
|
export class ConfigManager {
|
|
22
48
|
private config: SpinDBConfig | null = null
|
|
23
49
|
|
|
@@ -170,44 +196,55 @@ export class ConfigManager {
|
|
|
170
196
|
}
|
|
171
197
|
|
|
172
198
|
/**
|
|
173
|
-
* Get common installation paths for
|
|
199
|
+
* Get common installation paths for database tools
|
|
174
200
|
*/
|
|
175
201
|
private getCommonBinaryPaths(tool: BinaryTool): string[] {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
// Homebrew (macOS)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
`/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
const commonPaths: string[] = []
|
|
203
|
+
|
|
204
|
+
// Homebrew (macOS ARM)
|
|
205
|
+
commonPaths.push(`/opt/homebrew/bin/${tool}`)
|
|
206
|
+
// Homebrew (macOS Intel)
|
|
207
|
+
commonPaths.push(`/usr/local/bin/${tool}`)
|
|
208
|
+
|
|
209
|
+
// PostgreSQL-specific paths
|
|
210
|
+
if (POSTGRESQL_TOOLS.includes(tool) || tool === 'pgcli') {
|
|
211
|
+
commonPaths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
|
|
212
|
+
commonPaths.push(`/usr/local/opt/libpq/bin/${tool}`)
|
|
213
|
+
// Postgres.app (macOS)
|
|
214
|
+
commonPaths.push(
|
|
215
|
+
`/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
|
|
216
|
+
)
|
|
217
|
+
// Linux PostgreSQL paths
|
|
218
|
+
commonPaths.push(`/usr/lib/postgresql/17/bin/${tool}`)
|
|
219
|
+
commonPaths.push(`/usr/lib/postgresql/16/bin/${tool}`)
|
|
220
|
+
commonPaths.push(`/usr/lib/postgresql/15/bin/${tool}`)
|
|
221
|
+
commonPaths.push(`/usr/lib/postgresql/14/bin/${tool}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// MySQL-specific paths
|
|
225
|
+
if (MYSQL_TOOLS.includes(tool) || tool === 'mycli') {
|
|
226
|
+
commonPaths.push(`/opt/homebrew/opt/mysql/bin/${tool}`)
|
|
227
|
+
commonPaths.push(`/opt/homebrew/opt/mysql-client/bin/${tool}`)
|
|
228
|
+
commonPaths.push(`/usr/local/opt/mysql/bin/${tool}`)
|
|
229
|
+
commonPaths.push(`/usr/local/opt/mysql-client/bin/${tool}`)
|
|
230
|
+
// Linux MySQL/MariaDB paths
|
|
231
|
+
commonPaths.push(`/usr/bin/${tool}`)
|
|
232
|
+
commonPaths.push(`/usr/sbin/${tool}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// General Linux paths
|
|
236
|
+
commonPaths.push(`/usr/bin/${tool}`)
|
|
237
|
+
|
|
238
|
+
return commonPaths
|
|
196
239
|
}
|
|
197
240
|
|
|
198
241
|
/**
|
|
199
242
|
* Detect all available client tools on the system
|
|
200
243
|
*/
|
|
201
244
|
async detectAllTools(): Promise<Map<BinaryTool, string>> {
|
|
202
|
-
const tools: BinaryTool[] = [
|
|
203
|
-
'psql',
|
|
204
|
-
'pg_dump',
|
|
205
|
-
'pg_restore',
|
|
206
|
-
'pg_basebackup',
|
|
207
|
-
]
|
|
208
245
|
const found = new Map<BinaryTool, string>()
|
|
209
246
|
|
|
210
|
-
for (const tool of
|
|
247
|
+
for (const tool of ALL_TOOLS) {
|
|
211
248
|
const path = await this.detectSystemBinary(tool)
|
|
212
249
|
if (path) {
|
|
213
250
|
found.set(tool, path)
|
|
@@ -219,18 +256,19 @@ export class ConfigManager {
|
|
|
219
256
|
|
|
220
257
|
/**
|
|
221
258
|
* Initialize config by detecting all available tools
|
|
259
|
+
* Groups results by category for better display
|
|
222
260
|
*/
|
|
223
|
-
async initialize(): Promise<{
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
261
|
+
async initialize(): Promise<{
|
|
262
|
+
found: BinaryTool[]
|
|
263
|
+
missing: BinaryTool[]
|
|
264
|
+
postgresql: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
265
|
+
mysql: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
266
|
+
enhanced: { found: BinaryTool[]; missing: BinaryTool[] }
|
|
267
|
+
}> {
|
|
230
268
|
const found: BinaryTool[] = []
|
|
231
269
|
const missing: BinaryTool[] = []
|
|
232
270
|
|
|
233
|
-
for (const tool of
|
|
271
|
+
for (const tool of ALL_TOOLS) {
|
|
234
272
|
const path = await this.getBinaryPath(tool)
|
|
235
273
|
if (path) {
|
|
236
274
|
found.push(tool)
|
|
@@ -239,7 +277,57 @@ export class ConfigManager {
|
|
|
239
277
|
}
|
|
240
278
|
}
|
|
241
279
|
|
|
242
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
found,
|
|
282
|
+
missing,
|
|
283
|
+
postgresql: {
|
|
284
|
+
found: found.filter((t) => POSTGRESQL_TOOLS.includes(t)),
|
|
285
|
+
missing: missing.filter((t) => POSTGRESQL_TOOLS.includes(t)),
|
|
286
|
+
},
|
|
287
|
+
mysql: {
|
|
288
|
+
found: found.filter((t) => MYSQL_TOOLS.includes(t)),
|
|
289
|
+
missing: missing.filter((t) => MYSQL_TOOLS.includes(t)),
|
|
290
|
+
},
|
|
291
|
+
enhanced: {
|
|
292
|
+
found: found.filter((t) => ENHANCED_SHELLS.includes(t)),
|
|
293
|
+
missing: missing.filter((t) => ENHANCED_SHELLS.includes(t)),
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Check if the config cache is stale (older than 7 days)
|
|
300
|
+
*/
|
|
301
|
+
async isStale(): Promise<boolean> {
|
|
302
|
+
const config = await this.load()
|
|
303
|
+
if (!config.updatedAt) {
|
|
304
|
+
return true
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const updatedAt = new Date(config.updatedAt).getTime()
|
|
308
|
+
const now = Date.now()
|
|
309
|
+
return now - updatedAt > CACHE_STALENESS_MS
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Refresh all tool paths if cache is stale
|
|
314
|
+
* Returns true if refresh was performed
|
|
315
|
+
*/
|
|
316
|
+
async refreshIfStale(): Promise<boolean> {
|
|
317
|
+
if (await this.isStale()) {
|
|
318
|
+
await this.refreshAllBinaries()
|
|
319
|
+
return true
|
|
320
|
+
}
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Force refresh all binary paths
|
|
326
|
+
* Re-detects all tools and updates versions
|
|
327
|
+
*/
|
|
328
|
+
async refreshAllBinaries(): Promise<void> {
|
|
329
|
+
await this.clearAllBinaries()
|
|
330
|
+
await this.initialize()
|
|
243
331
|
}
|
|
244
332
|
|
|
245
333
|
/**
|
|
@@ -269,3 +357,11 @@ export class ConfigManager {
|
|
|
269
357
|
}
|
|
270
358
|
|
|
271
359
|
export const configManager = new ConfigManager()
|
|
360
|
+
|
|
361
|
+
// Export tool categories for use in commands
|
|
362
|
+
export {
|
|
363
|
+
POSTGRESQL_TOOLS,
|
|
364
|
+
MYSQL_TOOLS,
|
|
365
|
+
ENHANCED_SHELLS,
|
|
366
|
+
ALL_TOOLS,
|
|
367
|
+
}
|
|
@@ -51,6 +51,7 @@ export class ContainerManager {
|
|
|
51
51
|
version,
|
|
52
52
|
port,
|
|
53
53
|
database,
|
|
54
|
+
databases: [database],
|
|
54
55
|
created: new Date().toISOString(),
|
|
55
56
|
status: 'created',
|
|
56
57
|
}
|
|
@@ -63,6 +64,7 @@ export class ContainerManager {
|
|
|
63
64
|
/**
|
|
64
65
|
* Get container configuration
|
|
65
66
|
* If engine is not provided, searches all engine directories
|
|
67
|
+
* Automatically migrates old schemas to include databases array
|
|
66
68
|
*/
|
|
67
69
|
async getConfig(
|
|
68
70
|
name: string,
|
|
@@ -77,7 +79,8 @@ export class ContainerManager {
|
|
|
77
79
|
return null
|
|
78
80
|
}
|
|
79
81
|
const content = await readFile(configPath, 'utf8')
|
|
80
|
-
|
|
82
|
+
const config = JSON.parse(content) as ContainerConfig
|
|
83
|
+
return this.migrateConfig(config)
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// Search all engine directories
|
|
@@ -86,13 +89,43 @@ export class ContainerManager {
|
|
|
86
89
|
const configPath = paths.getContainerConfigPath(name, { engine: eng })
|
|
87
90
|
if (existsSync(configPath)) {
|
|
88
91
|
const content = await readFile(configPath, 'utf8')
|
|
89
|
-
|
|
92
|
+
const config = JSON.parse(content) as ContainerConfig
|
|
93
|
+
return this.migrateConfig(config)
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
return null
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Migrate old container configs to include databases array
|
|
102
|
+
* Ensures primary database is always in the databases array
|
|
103
|
+
*/
|
|
104
|
+
private async migrateConfig(
|
|
105
|
+
config: ContainerConfig,
|
|
106
|
+
): Promise<ContainerConfig> {
|
|
107
|
+
let needsSave = false
|
|
108
|
+
|
|
109
|
+
// If databases array is missing, create it with the primary database
|
|
110
|
+
if (!config.databases) {
|
|
111
|
+
config.databases = [config.database]
|
|
112
|
+
needsSave = true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Ensure primary database is in the array
|
|
116
|
+
if (!config.databases.includes(config.database)) {
|
|
117
|
+
config.databases = [config.database, ...config.databases]
|
|
118
|
+
needsSave = true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Save if we made changes
|
|
122
|
+
if (needsSave) {
|
|
123
|
+
await this.saveConfig(config.name, { engine: config.engine }, config)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return config
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
/**
|
|
97
130
|
* Save container configuration
|
|
98
131
|
*/
|
|
@@ -333,6 +366,47 @@ export class ContainerManager {
|
|
|
333
366
|
return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
|
|
334
367
|
}
|
|
335
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Add a database to the container's databases array
|
|
371
|
+
*/
|
|
372
|
+
async addDatabase(containerName: string, database: string): Promise<void> {
|
|
373
|
+
const config = await this.getConfig(containerName)
|
|
374
|
+
if (!config) {
|
|
375
|
+
throw new Error(`Container "${containerName}" not found`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Ensure databases array exists
|
|
379
|
+
if (!config.databases) {
|
|
380
|
+
config.databases = [config.database]
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Add if not already present
|
|
384
|
+
if (!config.databases.includes(database)) {
|
|
385
|
+
config.databases.push(database)
|
|
386
|
+
await this.saveConfig(containerName, { engine: config.engine }, config)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Remove a database from the container's databases array
|
|
392
|
+
*/
|
|
393
|
+
async removeDatabase(containerName: string, database: string): Promise<void> {
|
|
394
|
+
const config = await this.getConfig(containerName)
|
|
395
|
+
if (!config) {
|
|
396
|
+
throw new Error(`Container "${containerName}" not found`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Don't remove the primary database from the array
|
|
400
|
+
if (database === config.database) {
|
|
401
|
+
throw new Error(`Cannot remove primary database "${database}" from tracking`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (config.databases) {
|
|
405
|
+
config.databases = config.databases.filter((db) => db !== database)
|
|
406
|
+
await this.saveConfig(containerName, { engine: config.engine }, config)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
336
410
|
/**
|
|
337
411
|
* Get connection string for a container
|
|
338
412
|
* Delegates to the appropriate engine
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
mycliDependency,
|
|
21
21
|
} from '../config/os-dependencies'
|
|
22
22
|
import { platformService } from './platform-service'
|
|
23
|
+
import { configManager } from './config-manager'
|
|
23
24
|
|
|
24
25
|
const execAsync = promisify(exec)
|
|
25
26
|
|
|
@@ -268,6 +269,10 @@ export async function installDependency(
|
|
|
268
269
|
execWithInheritedStdio(cmd)
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
// Refresh config cache after package manager interaction
|
|
273
|
+
// This ensures newly installed tools are detected with correct versions
|
|
274
|
+
await configManager.refreshAllBinaries()
|
|
275
|
+
|
|
271
276
|
// Verify installation
|
|
272
277
|
const status = await checkDependency(dependency)
|
|
273
278
|
if (!status.installed) {
|
package/engines/base-engine.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
ContainerConfig,
|
|
3
3
|
ProgressCallback,
|
|
4
4
|
BackupFormat,
|
|
5
|
+
BackupOptions,
|
|
6
|
+
BackupResult,
|
|
5
7
|
RestoreResult,
|
|
6
8
|
DumpResult,
|
|
7
9
|
StatusResult,
|
|
@@ -131,4 +133,22 @@ export abstract class BaseEngine {
|
|
|
131
133
|
connectionString: string,
|
|
132
134
|
outputPath: string,
|
|
133
135
|
): Promise<DumpResult>
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get the size of a database in bytes
|
|
139
|
+
* Returns null if the container is not running or size cannot be determined
|
|
140
|
+
*/
|
|
141
|
+
abstract getDatabaseSize(container: ContainerConfig): Promise<number | null>
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create a backup of a database
|
|
145
|
+
* @param container - The container configuration
|
|
146
|
+
* @param outputPath - Path to write the backup file
|
|
147
|
+
* @param options - Backup options including database name and format
|
|
148
|
+
*/
|
|
149
|
+
abstract backup(
|
|
150
|
+
container: ContainerConfig,
|
|
151
|
+
outputPath: string,
|
|
152
|
+
options: BackupOptions,
|
|
153
|
+
): Promise<BackupResult>
|
|
134
154
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL Backup
|
|
3
|
+
*
|
|
4
|
+
* Creates database backups in SQL or compressed (.dump = gzipped SQL) format using mysqldump.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'child_process'
|
|
8
|
+
import { createWriteStream } from 'fs'
|
|
9
|
+
import { stat } from 'fs/promises'
|
|
10
|
+
import { createGzip } from 'zlib'
|
|
11
|
+
import { pipeline } from 'stream/promises'
|
|
12
|
+
import { getMysqldumpPath } from './binary-detection'
|
|
13
|
+
import { getEngineDefaults } from '../../config/defaults'
|
|
14
|
+
import type { ContainerConfig, BackupOptions, BackupResult } from '../../types'
|
|
15
|
+
|
|
16
|
+
const engineDef = getEngineDefaults('mysql')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a backup of a MySQL database
|
|
20
|
+
*
|
|
21
|
+
* CLI equivalent:
|
|
22
|
+
* - SQL format: mysqldump -h 127.0.0.1 -P {port} -u root --result-file={outputPath} {database}
|
|
23
|
+
* - Dump format: mysqldump -h 127.0.0.1 -P {port} -u root {database} | gzip > {outputPath}
|
|
24
|
+
*/
|
|
25
|
+
export async function createBackup(
|
|
26
|
+
container: ContainerConfig,
|
|
27
|
+
outputPath: string,
|
|
28
|
+
options: BackupOptions,
|
|
29
|
+
): Promise<BackupResult> {
|
|
30
|
+
const { port } = container
|
|
31
|
+
const { database, format } = options
|
|
32
|
+
|
|
33
|
+
const mysqldump = await getMysqldumpPath()
|
|
34
|
+
if (!mysqldump) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'mysqldump not found. Install MySQL client tools:\n' +
|
|
37
|
+
' macOS: brew install mysql-client\n' +
|
|
38
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (format === 'sql') {
|
|
43
|
+
return createSqlBackup(mysqldump, port, database, outputPath)
|
|
44
|
+
} else {
|
|
45
|
+
return createCompressedBackup(mysqldump, port, database, outputPath)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a plain SQL backup
|
|
51
|
+
*/
|
|
52
|
+
async function createSqlBackup(
|
|
53
|
+
mysqldump: string,
|
|
54
|
+
port: number,
|
|
55
|
+
database: string,
|
|
56
|
+
outputPath: string,
|
|
57
|
+
): Promise<BackupResult> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const args = [
|
|
60
|
+
'-h',
|
|
61
|
+
'127.0.0.1',
|
|
62
|
+
'-P',
|
|
63
|
+
String(port),
|
|
64
|
+
'-u',
|
|
65
|
+
engineDef.superuser,
|
|
66
|
+
'--result-file',
|
|
67
|
+
outputPath,
|
|
68
|
+
database,
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const proc = spawn(mysqldump, args, {
|
|
72
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
let stderr = ''
|
|
76
|
+
|
|
77
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
78
|
+
stderr += data.toString()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
proc.on('error', (err: NodeJS.ErrnoException) => {
|
|
82
|
+
reject(err)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
proc.on('close', async (code) => {
|
|
86
|
+
if (code === 0) {
|
|
87
|
+
const stats = await stat(outputPath)
|
|
88
|
+
resolve({
|
|
89
|
+
path: outputPath,
|
|
90
|
+
format: 'sql',
|
|
91
|
+
size: stats.size,
|
|
92
|
+
})
|
|
93
|
+
} else {
|
|
94
|
+
const errorMessage = stderr || `mysqldump exited with code ${code}`
|
|
95
|
+
reject(new Error(errorMessage))
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a compressed (gzipped) backup
|
|
103
|
+
* Uses Node's zlib for compression instead of relying on system gzip
|
|
104
|
+
*/
|
|
105
|
+
async function createCompressedBackup(
|
|
106
|
+
mysqldump: string,
|
|
107
|
+
port: number,
|
|
108
|
+
database: string,
|
|
109
|
+
outputPath: string,
|
|
110
|
+
): Promise<BackupResult> {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const args = [
|
|
113
|
+
'-h',
|
|
114
|
+
'127.0.0.1',
|
|
115
|
+
'-P',
|
|
116
|
+
String(port),
|
|
117
|
+
'-u',
|
|
118
|
+
engineDef.superuser,
|
|
119
|
+
database,
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
const proc = spawn(mysqldump, args, {
|
|
123
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const gzip = createGzip()
|
|
127
|
+
const output = createWriteStream(outputPath)
|
|
128
|
+
|
|
129
|
+
let stderr = ''
|
|
130
|
+
|
|
131
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
132
|
+
stderr += data.toString()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Pipe mysqldump stdout -> gzip -> file
|
|
136
|
+
pipeline(proc.stdout!, gzip, output)
|
|
137
|
+
.then(async () => {
|
|
138
|
+
const stats = await stat(outputPath)
|
|
139
|
+
resolve({
|
|
140
|
+
path: outputPath,
|
|
141
|
+
format: 'compressed',
|
|
142
|
+
size: stats.size,
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
.catch(reject)
|
|
146
|
+
|
|
147
|
+
proc.on('error', (err: NodeJS.ErrnoException) => {
|
|
148
|
+
reject(err)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
proc.on('close', (code) => {
|
|
152
|
+
if (code !== 0) {
|
|
153
|
+
const errorMessage = stderr || `mysqldump exited with code ${code}`
|
|
154
|
+
reject(new Error(errorMessage))
|
|
155
|
+
}
|
|
156
|
+
// If code is 0, the pipeline promise will resolve
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
}
|
package/engines/mysql/index.ts
CHANGED
|
@@ -33,10 +33,13 @@ import {
|
|
|
33
33
|
restoreBackup,
|
|
34
34
|
parseConnectionString,
|
|
35
35
|
} from './restore'
|
|
36
|
+
import { createBackup } from './backup'
|
|
36
37
|
import type {
|
|
37
38
|
ContainerConfig,
|
|
38
39
|
ProgressCallback,
|
|
39
40
|
BackupFormat,
|
|
41
|
+
BackupOptions,
|
|
42
|
+
BackupResult,
|
|
40
43
|
RestoreResult,
|
|
41
44
|
DumpResult,
|
|
42
45
|
StatusResult,
|
|
@@ -734,6 +737,31 @@ export class MySQLEngine extends BaseEngine {
|
|
|
734
737
|
}
|
|
735
738
|
}
|
|
736
739
|
|
|
740
|
+
/**
|
|
741
|
+
* Get the size of the container's database in bytes
|
|
742
|
+
* Uses information_schema.tables to sum data_length + index_length
|
|
743
|
+
* Returns null if container is not running or query fails
|
|
744
|
+
*/
|
|
745
|
+
async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
|
|
746
|
+
const { port, database } = container
|
|
747
|
+
const db = database || 'mysql'
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
const mysql = await getMysqlClientPath()
|
|
751
|
+
if (!mysql) return null
|
|
752
|
+
|
|
753
|
+
// Query information_schema for total data + index size
|
|
754
|
+
const { stdout } = await execAsync(
|
|
755
|
+
`"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -N -e "SELECT COALESCE(SUM(data_length + index_length), 0) FROM information_schema.tables WHERE table_schema = '${db}'"`,
|
|
756
|
+
)
|
|
757
|
+
const size = parseInt(stdout.trim(), 10)
|
|
758
|
+
return isNaN(size) ? null : size
|
|
759
|
+
} catch {
|
|
760
|
+
// Container not running or query failed
|
|
761
|
+
return null
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
737
765
|
/**
|
|
738
766
|
* Create a dump from a remote database using a connection string
|
|
739
767
|
* CLI wrapper: mysqldump -h {host} -P {port} -u {user} -p{pass} {db} > {file}
|
|
@@ -803,6 +831,17 @@ export class MySQLEngine extends BaseEngine {
|
|
|
803
831
|
})
|
|
804
832
|
})
|
|
805
833
|
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Create a backup of a MySQL database
|
|
837
|
+
*/
|
|
838
|
+
async backup(
|
|
839
|
+
container: ContainerConfig,
|
|
840
|
+
outputPath: string,
|
|
841
|
+
options: BackupOptions,
|
|
842
|
+
): Promise<BackupResult> {
|
|
843
|
+
return createBackup(container, outputPath, options)
|
|
844
|
+
}
|
|
806
845
|
}
|
|
807
846
|
|
|
808
847
|
export const mysqlEngine = new MySQLEngine()
|
package/engines/mysql/restore.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { spawn } from 'child_process'
|
|
8
8
|
import { createReadStream } from 'fs'
|
|
9
9
|
import { open } from 'fs/promises'
|
|
10
|
+
import { createGunzip } from 'zlib'
|
|
10
11
|
import { getMysqlClientPath } from './binary-detection'
|
|
11
12
|
import { validateRestoreCompatibility } from './version-validator'
|
|
12
13
|
import { getEngineDefaults } from '../../config/defaults'
|
|
@@ -200,6 +201,7 @@ export async function restoreBackup(
|
|
|
200
201
|
|
|
201
202
|
// Restore using mysql client
|
|
202
203
|
// CLI: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
|
|
204
|
+
// For compressed files: gunzip -c {file} | mysql ...
|
|
203
205
|
return new Promise((resolve, reject) => {
|
|
204
206
|
const args = ['-h', '127.0.0.1', '-P', String(port), '-u', user, database]
|
|
205
207
|
|
|
@@ -207,9 +209,21 @@ export async function restoreBackup(
|
|
|
207
209
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
208
210
|
})
|
|
209
211
|
|
|
210
|
-
// Pipe backup file to stdin
|
|
212
|
+
// Pipe backup file to stdin, decompressing if necessary
|
|
211
213
|
const fileStream = createReadStream(backupPath)
|
|
212
|
-
|
|
214
|
+
|
|
215
|
+
if (format.format === 'compressed') {
|
|
216
|
+
// Decompress gzipped file before piping to mysql
|
|
217
|
+
const gunzip = createGunzip()
|
|
218
|
+
fileStream.pipe(gunzip).pipe(proc.stdin)
|
|
219
|
+
|
|
220
|
+
// Handle gunzip errors
|
|
221
|
+
gunzip.on('error', (err) => {
|
|
222
|
+
reject(new Error(`Failed to decompress backup file: ${err.message}`))
|
|
223
|
+
})
|
|
224
|
+
} else {
|
|
225
|
+
fileStream.pipe(proc.stdin)
|
|
226
|
+
}
|
|
213
227
|
|
|
214
228
|
let stdout = ''
|
|
215
229
|
let stderr = ''
|