spindb 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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()
@@ -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
- fileStream.pipe(proc.stdin)
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
+ }
@@ -16,10 +16,13 @@ import {
16
16
  FALLBACK_VERSION_MAP,
17
17
  } from './binary-urls'
18
18
  import { detectBackupFormat, restoreBackup } from './restore'
19
+ import { createBackup } from './backup'
19
20
  import type {
20
21
  ContainerConfig,
21
22
  ProgressCallback,
22
23
  BackupFormat,
24
+ BackupOptions,
25
+ BackupResult,
23
26
  RestoreResult,
24
27
  DumpResult,
25
28
  StatusResult,
@@ -369,6 +372,29 @@ export class PostgreSQLEngine extends BaseEngine {
369
372
  }
370
373
  }
371
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
+
372
398
  /**
373
399
  * Create a dump from a remote database using a connection string
374
400
  * @param connectionString PostgreSQL connection string (e.g., postgresql://user:pass@host:port/dbname)
@@ -420,6 +446,17 @@ export class PostgreSQLEngine extends BaseEngine {
420
446
  })
421
447
  })
422
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
+ }
423
460
  }
424
461
 
425
462
  export const postgresqlEngine = new PostgreSQLEngine()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL and MySQL.",
5
5
  "type": "module",
6
6
  "bin": {
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?: {
@@ -127,4 +147,10 @@ export type SpinDBConfig = {
127
147
  }
128
148
  // Last updated timestamp
129
149
  updatedAt?: string
150
+ // Self-update tracking
151
+ update?: {
152
+ lastCheck?: string // ISO timestamp of last npm registry check
153
+ latestVersion?: string // Latest version found from registry
154
+ autoCheckEnabled?: boolean // Default true, user can disable
155
+ }
130
156
  }