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.
@@ -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 PostgreSQL client tools
199
+ * Get common installation paths for database tools
174
200
  */
175
201
  private getCommonBinaryPaths(tool: BinaryTool): string[] {
176
- const paths: string[] = []
177
-
178
- // Homebrew (macOS)
179
- paths.push(`/opt/homebrew/bin/${tool}`)
180
- paths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
181
- paths.push(`/usr/local/bin/${tool}`)
182
- paths.push(`/usr/local/opt/libpq/bin/${tool}`)
183
-
184
- // Postgres.app (macOS)
185
- paths.push(
186
- `/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
187
- )
188
-
189
- // Linux common paths
190
- paths.push(`/usr/bin/${tool}`)
191
- paths.push(`/usr/lib/postgresql/16/bin/${tool}`)
192
- paths.push(`/usr/lib/postgresql/15/bin/${tool}`)
193
- paths.push(`/usr/lib/postgresql/14/bin/${tool}`)
194
-
195
- return paths
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 tools) {
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<{ found: BinaryTool[]; missing: BinaryTool[] }> {
224
- const tools: BinaryTool[] = [
225
- 'psql',
226
- 'pg_dump',
227
- 'pg_restore',
228
- 'pg_basebackup',
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 tools) {
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 { found, missing }
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
- return JSON.parse(content) as ContainerConfig
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
- return JSON.parse(content) as ContainerConfig
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) {
@@ -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
+ }
@@ -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 = ''