spindb 0.5.3 → 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 +78 -2
- package/cli/commands/backup.ts +263 -0
- package/cli/commands/config.ts +144 -66
- package/cli/commands/connect.ts +336 -111
- package/cli/commands/engines.ts +2 -10
- package/cli/commands/info.ts +3 -3
- package/cli/commands/list.ts +43 -5
- package/cli/commands/menu.ts +447 -33
- package/cli/commands/restore.ts +4 -1
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +99 -6
- package/cli/ui/theme.ts +12 -1
- package/config/os-dependencies.ts +92 -0
- package/core/binary-manager.ts +12 -19
- package/core/config-manager.ts +133 -37
- package/core/container-manager.ts +76 -2
- package/core/dependency-manager.ts +140 -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 +68 -1
- package/package.json +1 -1
- package/types/index.ts +20 -0
|
@@ -15,8 +15,12 @@ import {
|
|
|
15
15
|
packageManagers,
|
|
16
16
|
getEngineDependencies,
|
|
17
17
|
getUniqueDependencies,
|
|
18
|
+
usqlDependency,
|
|
19
|
+
pgcliDependency,
|
|
20
|
+
mycliDependency,
|
|
18
21
|
} from '../config/os-dependencies'
|
|
19
22
|
import { platformService } from './platform-service'
|
|
23
|
+
import { configManager } from './config-manager'
|
|
20
24
|
|
|
21
25
|
const execAsync = promisify(exec)
|
|
22
26
|
|
|
@@ -265,6 +269,10 @@ export async function installDependency(
|
|
|
265
269
|
execWithInheritedStdio(cmd)
|
|
266
270
|
}
|
|
267
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
|
+
|
|
268
276
|
// Verify installation
|
|
269
277
|
const status = await checkDependency(dependency)
|
|
270
278
|
if (!status.installed) {
|
|
@@ -421,3 +429,135 @@ export async function getAllDependencyReports(): Promise<
|
|
|
421
429
|
)
|
|
422
430
|
return reports
|
|
423
431
|
}
|
|
432
|
+
|
|
433
|
+
// =============================================================================
|
|
434
|
+
// usql (Enhanced Shell) Support
|
|
435
|
+
// =============================================================================
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Check if usql is installed
|
|
439
|
+
*/
|
|
440
|
+
export async function isUsqlInstalled(): Promise<boolean> {
|
|
441
|
+
const status = await checkDependency(usqlDependency)
|
|
442
|
+
return status.installed
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Get usql dependency status
|
|
447
|
+
*/
|
|
448
|
+
export async function getUsqlStatus(): Promise<DependencyStatus> {
|
|
449
|
+
return checkDependency(usqlDependency)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Install usql using the detected package manager
|
|
454
|
+
*/
|
|
455
|
+
export async function installUsql(
|
|
456
|
+
packageManager: DetectedPackageManager,
|
|
457
|
+
): Promise<InstallResult> {
|
|
458
|
+
return installDependency(usqlDependency, packageManager)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get usql manual installation instructions
|
|
463
|
+
*/
|
|
464
|
+
export function getUsqlManualInstructions(
|
|
465
|
+
platform: Platform = getCurrentPlatform(),
|
|
466
|
+
): string[] {
|
|
467
|
+
return getManualInstallInstructions(usqlDependency, platform)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Get the usql dependency definition
|
|
472
|
+
*/
|
|
473
|
+
export function getUsqlDependency(): Dependency {
|
|
474
|
+
return usqlDependency
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// =============================================================================
|
|
478
|
+
// pgcli (PostgreSQL Enhanced Shell) Support
|
|
479
|
+
// =============================================================================
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Check if pgcli is installed
|
|
483
|
+
*/
|
|
484
|
+
export async function isPgcliInstalled(): Promise<boolean> {
|
|
485
|
+
const status = await checkDependency(pgcliDependency)
|
|
486
|
+
return status.installed
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Get pgcli dependency status
|
|
491
|
+
*/
|
|
492
|
+
export async function getPgcliStatus(): Promise<DependencyStatus> {
|
|
493
|
+
return checkDependency(pgcliDependency)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Install pgcli using the detected package manager
|
|
498
|
+
*/
|
|
499
|
+
export async function installPgcli(
|
|
500
|
+
packageManager: DetectedPackageManager,
|
|
501
|
+
): Promise<InstallResult> {
|
|
502
|
+
return installDependency(pgcliDependency, packageManager)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Get pgcli manual installation instructions
|
|
507
|
+
*/
|
|
508
|
+
export function getPgcliManualInstructions(
|
|
509
|
+
platform: Platform = getCurrentPlatform(),
|
|
510
|
+
): string[] {
|
|
511
|
+
return getManualInstallInstructions(pgcliDependency, platform)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Get the pgcli dependency definition
|
|
516
|
+
*/
|
|
517
|
+
export function getPgcliDependency(): Dependency {
|
|
518
|
+
return pgcliDependency
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// =============================================================================
|
|
522
|
+
// mycli (MySQL Enhanced Shell) Support
|
|
523
|
+
// =============================================================================
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Check if mycli is installed
|
|
527
|
+
*/
|
|
528
|
+
export async function isMycliInstalled(): Promise<boolean> {
|
|
529
|
+
const status = await checkDependency(mycliDependency)
|
|
530
|
+
return status.installed
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Get mycli dependency status
|
|
535
|
+
*/
|
|
536
|
+
export async function getMycliStatus(): Promise<DependencyStatus> {
|
|
537
|
+
return checkDependency(mycliDependency)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Install mycli using the detected package manager
|
|
542
|
+
*/
|
|
543
|
+
export async function installMycli(
|
|
544
|
+
packageManager: DetectedPackageManager,
|
|
545
|
+
): Promise<InstallResult> {
|
|
546
|
+
return installDependency(mycliDependency, packageManager)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Get mycli manual installation instructions
|
|
551
|
+
*/
|
|
552
|
+
export function getMycliManualInstructions(
|
|
553
|
+
platform: Platform = getCurrentPlatform(),
|
|
554
|
+
): string[] {
|
|
555
|
+
return getManualInstallInstructions(mycliDependency, platform)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Get the mycli dependency definition
|
|
560
|
+
*/
|
|
561
|
+
export function getMycliDependency(): Dependency {
|
|
562
|
+
return mycliDependency
|
|
563
|
+
}
|
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 = ''
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL Backup
|
|
3
|
+
*
|
|
4
|
+
* Creates database backups in SQL or custom (.dump) format using pg_dump.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'child_process'
|
|
8
|
+
import { stat } from 'fs/promises'
|
|
9
|
+
import { configManager } from '../../core/config-manager'
|
|
10
|
+
import { defaults } from '../../config/defaults'
|
|
11
|
+
import type { ContainerConfig, BackupOptions, BackupResult } from '../../types'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get pg_dump path from config, with helpful error message
|
|
15
|
+
*/
|
|
16
|
+
async function getPgDumpPath(): Promise<string> {
|
|
17
|
+
const pgDumpPath = await configManager.getBinaryPath('pg_dump')
|
|
18
|
+
if (!pgDumpPath) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'pg_dump not found. Install PostgreSQL client tools:\n' +
|
|
21
|
+
' macOS: brew install libpq && brew link --force libpq\n' +
|
|
22
|
+
' Ubuntu/Debian: apt install postgresql-client\n\n' +
|
|
23
|
+
'Or configure manually: spindb config set pg_dump /path/to/pg_dump',
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return pgDumpPath
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a backup of a PostgreSQL database
|
|
31
|
+
*
|
|
32
|
+
* CLI equivalent:
|
|
33
|
+
* - SQL format: pg_dump -Fp -h 127.0.0.1 -p {port} -U postgres -d {database} -f {outputPath}
|
|
34
|
+
* - Dump format: pg_dump -Fc -h 127.0.0.1 -p {port} -U postgres -d {database} -f {outputPath}
|
|
35
|
+
*/
|
|
36
|
+
export async function createBackup(
|
|
37
|
+
container: ContainerConfig,
|
|
38
|
+
outputPath: string,
|
|
39
|
+
options: BackupOptions,
|
|
40
|
+
): Promise<BackupResult> {
|
|
41
|
+
const { port } = container
|
|
42
|
+
const { database, format } = options
|
|
43
|
+
|
|
44
|
+
const pgDumpPath = await getPgDumpPath()
|
|
45
|
+
|
|
46
|
+
// -Fp = plain SQL format, -Fc = custom format
|
|
47
|
+
const formatFlag = format === 'sql' ? '-Fp' : '-Fc'
|
|
48
|
+
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const args = [
|
|
51
|
+
'-h',
|
|
52
|
+
'127.0.0.1',
|
|
53
|
+
'-p',
|
|
54
|
+
String(port),
|
|
55
|
+
'-U',
|
|
56
|
+
defaults.superuser,
|
|
57
|
+
'-d',
|
|
58
|
+
database,
|
|
59
|
+
formatFlag,
|
|
60
|
+
'-f',
|
|
61
|
+
outputPath,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
const proc = spawn(pgDumpPath, args, {
|
|
65
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
let stderr = ''
|
|
69
|
+
|
|
70
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
71
|
+
stderr += data.toString()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
proc.on('error', (err: NodeJS.ErrnoException) => {
|
|
75
|
+
reject(err)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
proc.on('close', async (code) => {
|
|
79
|
+
if (code === 0) {
|
|
80
|
+
// Get file size
|
|
81
|
+
const stats = await stat(outputPath)
|
|
82
|
+
resolve({
|
|
83
|
+
path: outputPath,
|
|
84
|
+
format: format === 'sql' ? 'sql' : 'custom',
|
|
85
|
+
size: stats.size,
|
|
86
|
+
})
|
|
87
|
+
} else {
|
|
88
|
+
const errorMessage = stderr || `pg_dump exited with code ${code}`
|
|
89
|
+
reject(new Error(errorMessage))
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
}
|
|
@@ -12,12 +12,17 @@ import {
|
|
|
12
12
|
getBinaryUrl,
|
|
13
13
|
SUPPORTED_MAJOR_VERSIONS,
|
|
14
14
|
fetchAvailableVersions,
|
|
15
|
+
getLatestVersion,
|
|
16
|
+
FALLBACK_VERSION_MAP,
|
|
15
17
|
} from './binary-urls'
|
|
16
18
|
import { detectBackupFormat, restoreBackup } from './restore'
|
|
19
|
+
import { createBackup } from './backup'
|
|
17
20
|
import type {
|
|
18
21
|
ContainerConfig,
|
|
19
22
|
ProgressCallback,
|
|
20
23
|
BackupFormat,
|
|
24
|
+
BackupOptions,
|
|
25
|
+
BackupResult,
|
|
21
26
|
RestoreResult,
|
|
22
27
|
DumpResult,
|
|
23
28
|
StatusResult,
|
|
@@ -50,14 +55,42 @@ export class PostgreSQLEngine extends BaseEngine {
|
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a version string to a full version.
|
|
60
|
+
* If given a major version like '17', resolves to '17.7.0'.
|
|
61
|
+
* If already a full version like '17.7.0', returns as-is.
|
|
62
|
+
*/
|
|
63
|
+
resolveFullVersion(version: string): string {
|
|
64
|
+
// Check if already a full version (has at least one dot with numbers after)
|
|
65
|
+
if (/^\d+\.\d+/.test(version)) {
|
|
66
|
+
return version
|
|
67
|
+
}
|
|
68
|
+
// It's a major version, resolve using fallback map (sync, no network)
|
|
69
|
+
return FALLBACK_VERSION_MAP[version] || `${version}.0.0`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve version asynchronously (tries network first for latest)
|
|
74
|
+
*/
|
|
75
|
+
async resolveFullVersionAsync(version: string): Promise<string> {
|
|
76
|
+
// Check if already a full version
|
|
77
|
+
if (/^\d+\.\d+/.test(version)) {
|
|
78
|
+
return version
|
|
79
|
+
}
|
|
80
|
+
// Resolve from network/cache
|
|
81
|
+
return getLatestVersion(version)
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
/**
|
|
54
85
|
* Get binary path for current platform
|
|
86
|
+
* Uses full version for directory naming (e.g., postgresql-17.7.0-darwin-arm64)
|
|
55
87
|
*/
|
|
56
88
|
getBinaryPath(version: string): string {
|
|
89
|
+
const fullVersion = this.resolveFullVersion(version)
|
|
57
90
|
const { platform: p, arch: a } = this.getPlatformInfo()
|
|
58
91
|
return paths.getBinaryPath({
|
|
59
92
|
engine: 'postgresql',
|
|
60
|
-
version,
|
|
93
|
+
version: fullVersion,
|
|
61
94
|
platform: p,
|
|
62
95
|
arch: a,
|
|
63
96
|
})
|
|
@@ -339,6 +372,29 @@ export class PostgreSQLEngine extends BaseEngine {
|
|
|
339
372
|
}
|
|
340
373
|
}
|
|
341
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Get the size of the container's database in bytes
|
|
377
|
+
* Uses pg_database_size() to get accurate data size
|
|
378
|
+
* Returns null if container is not running or query fails
|
|
379
|
+
*/
|
|
380
|
+
async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
|
|
381
|
+
const { port, database } = container
|
|
382
|
+
const db = database || 'postgres'
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const psqlPath = await this.getPsqlPath()
|
|
386
|
+
// Query pg_database_size for the specific database
|
|
387
|
+
const { stdout } = await execAsync(
|
|
388
|
+
`"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -t -A -c "SELECT pg_database_size('${db}')"`,
|
|
389
|
+
)
|
|
390
|
+
const size = parseInt(stdout.trim(), 10)
|
|
391
|
+
return isNaN(size) ? null : size
|
|
392
|
+
} catch {
|
|
393
|
+
// Container not running or query failed
|
|
394
|
+
return null
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
342
398
|
/**
|
|
343
399
|
* Create a dump from a remote database using a connection string
|
|
344
400
|
* @param connectionString PostgreSQL connection string (e.g., postgresql://user:pass@host:port/dbname)
|
|
@@ -390,6 +446,17 @@ export class PostgreSQLEngine extends BaseEngine {
|
|
|
390
446
|
})
|
|
391
447
|
})
|
|
392
448
|
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Create a backup of a PostgreSQL database
|
|
452
|
+
*/
|
|
453
|
+
async backup(
|
|
454
|
+
container: ContainerConfig,
|
|
455
|
+
outputPath: string,
|
|
456
|
+
options: BackupOptions,
|
|
457
|
+
): Promise<BackupResult> {
|
|
458
|
+
return createBackup(container, outputPath, options)
|
|
459
|
+
}
|
|
393
460
|
}
|
|
394
461
|
|
|
395
462
|
export const postgresqlEngine = new PostgreSQLEngine()
|
package/package.json
CHANGED
package/types/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type ContainerConfig = {
|
|
|
4
4
|
version: string
|
|
5
5
|
port: number
|
|
6
6
|
database: string
|
|
7
|
+
databases?: string[]
|
|
7
8
|
created: string
|
|
8
9
|
status: 'created' | 'running' | 'stopped'
|
|
9
10
|
clonedFrom?: string
|
|
@@ -56,6 +57,17 @@ export type RestoreResult = {
|
|
|
56
57
|
code?: number
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
export type BackupOptions = {
|
|
61
|
+
database: string
|
|
62
|
+
format: 'sql' | 'dump'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type BackupResult = {
|
|
66
|
+
path: string
|
|
67
|
+
format: string
|
|
68
|
+
size: number
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
export type DumpResult = {
|
|
60
72
|
filePath: string
|
|
61
73
|
stdout?: string
|
|
@@ -85,6 +97,10 @@ export type BinaryTool =
|
|
|
85
97
|
| 'mysqlpump'
|
|
86
98
|
| 'mysqld'
|
|
87
99
|
| 'mysqladmin'
|
|
100
|
+
// Enhanced shells (optional)
|
|
101
|
+
| 'pgcli'
|
|
102
|
+
| 'mycli'
|
|
103
|
+
| 'usql'
|
|
88
104
|
|
|
89
105
|
/**
|
|
90
106
|
* Source of a binary - bundled (downloaded by spindb) or system (found on PATH)
|
|
@@ -118,6 +134,10 @@ export type SpinDBConfig = {
|
|
|
118
134
|
mysqlpump?: BinaryConfig
|
|
119
135
|
mysqld?: BinaryConfig
|
|
120
136
|
mysqladmin?: BinaryConfig
|
|
137
|
+
// Enhanced shells (optional)
|
|
138
|
+
pgcli?: BinaryConfig
|
|
139
|
+
mycli?: BinaryConfig
|
|
140
|
+
usql?: BinaryConfig
|
|
121
141
|
}
|
|
122
142
|
// Default settings
|
|
123
143
|
defaults?: {
|