spindb 0.5.2 → 0.5.4

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.
Files changed (38) hide show
  1. package/README.md +188 -9
  2. package/cli/commands/connect.ts +334 -105
  3. package/cli/commands/create.ts +106 -67
  4. package/cli/commands/deps.ts +19 -4
  5. package/cli/commands/edit.ts +245 -0
  6. package/cli/commands/engines.ts +434 -0
  7. package/cli/commands/info.ts +279 -0
  8. package/cli/commands/list.ts +1 -1
  9. package/cli/commands/menu.ts +664 -167
  10. package/cli/commands/restore.ts +11 -25
  11. package/cli/commands/start.ts +25 -20
  12. package/cli/commands/url.ts +79 -0
  13. package/cli/index.ts +9 -3
  14. package/cli/ui/prompts.ts +20 -12
  15. package/cli/ui/theme.ts +1 -1
  16. package/config/engine-defaults.ts +24 -1
  17. package/config/os-dependencies.ts +151 -113
  18. package/config/paths.ts +7 -36
  19. package/core/binary-manager.ts +12 -6
  20. package/core/config-manager.ts +17 -5
  21. package/core/dependency-manager.ts +144 -15
  22. package/core/error-handler.ts +336 -0
  23. package/core/platform-service.ts +634 -0
  24. package/core/port-manager.ts +11 -3
  25. package/core/process-manager.ts +12 -2
  26. package/core/start-with-retry.ts +167 -0
  27. package/core/transaction-manager.ts +170 -0
  28. package/engines/mysql/binary-detection.ts +177 -100
  29. package/engines/mysql/index.ts +240 -131
  30. package/engines/mysql/restore.ts +257 -0
  31. package/engines/mysql/version-validator.ts +373 -0
  32. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  33. package/engines/postgresql/binary-urls.ts +5 -3
  34. package/engines/postgresql/index.ts +35 -4
  35. package/engines/postgresql/restore.ts +54 -5
  36. package/engines/postgresql/version-validator.ts +262 -0
  37. package/package.json +6 -2
  38. package/cli/commands/postgres-tools.ts +0 -216
@@ -5,12 +5,18 @@
5
5
 
6
6
  import { spawn, exec } from 'child_process'
7
7
  import { promisify } from 'util'
8
- import { existsSync, createReadStream } from 'fs'
9
- import { mkdir, writeFile, readFile, rm } from 'fs/promises'
8
+ import { existsSync } from 'fs'
9
+ import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
10
10
  import { join } from 'path'
11
11
  import { BaseEngine } from '../base-engine'
12
12
  import { paths } from '../../config/paths'
13
13
  import { getEngineDefaults } from '../../config/defaults'
14
+ import {
15
+ logDebug,
16
+ logWarning,
17
+ ErrorCodes,
18
+ SpinDBError,
19
+ } from '../../core/error-handler'
14
20
  import {
15
21
  getMysqldPath,
16
22
  getMysqlClientPath,
@@ -22,6 +28,11 @@ import {
22
28
  detectInstalledVersions,
23
29
  getInstallInstructions,
24
30
  } from './binary-detection'
31
+ import {
32
+ detectBackupFormat as detectBackupFormatImpl,
33
+ restoreBackup,
34
+ parseConnectionString,
35
+ } from './restore'
25
36
  import type {
26
37
  ContainerConfig,
27
38
  ProgressCallback,
@@ -31,6 +42,10 @@ import type {
31
42
  StatusResult,
32
43
  } from '../../types'
33
44
 
45
+ // Re-export modules for external access
46
+ export * from './version-validator'
47
+ export * from './restore'
48
+
34
49
  const execAsync = promisify(exec)
35
50
 
36
51
  const ENGINE = 'mysql'
@@ -113,7 +128,9 @@ export class MySQLEngine extends BaseEngine {
113
128
  _version: string,
114
129
  _options: Record<string, unknown> = {},
115
130
  ): Promise<string> {
116
- const dataDir = paths.getContainerDataPath(containerName, { engine: ENGINE })
131
+ const dataDir = paths.getContainerDataPath(containerName, {
132
+ engine: ENGINE,
133
+ })
117
134
 
118
135
  // Create data directory if it doesn't exist
119
136
  if (!existsSync(dataDir)) {
@@ -316,74 +333,240 @@ export class MySQLEngine extends BaseEngine {
316
333
  const { name, port } = container
317
334
  const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
318
335
 
319
- // Try graceful shutdown first with mysqladmin
336
+ logDebug(`Stopping MySQL container "${name}" on port ${port}`)
337
+
338
+ // Step 1: Get PID with validation
339
+ const pid = await this.getValidatedPid(pidFile)
340
+ if (pid === null) {
341
+ // No valid PID file - check if process might still be running on port
342
+ logDebug('No valid PID, checking if MySQL is responding on port')
343
+ const mysqladmin = await getMysqladminPath()
344
+ if (mysqladmin) {
345
+ try {
346
+ await execAsync(
347
+ `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
348
+ { timeout: 2000 },
349
+ )
350
+ // MySQL is responding - try graceful shutdown even without PID
351
+ logWarning(`MySQL responding on port ${port} but no valid PID file`)
352
+ await this.gracefulShutdown(port)
353
+ } catch {
354
+ // MySQL not responding, nothing to stop
355
+ logDebug('MySQL not responding, nothing to stop')
356
+ }
357
+ }
358
+ return
359
+ }
360
+
361
+ // Step 2: Try graceful shutdown
362
+ const gracefulSuccess = await this.gracefulShutdown(port, pid)
363
+ if (gracefulSuccess) {
364
+ await this.cleanupPidFile(pidFile)
365
+ logDebug('MySQL stopped gracefully')
366
+ return
367
+ }
368
+
369
+ // Step 3: Force kill with escalation
370
+ await this.forceKillWithEscalation(pid, pidFile)
371
+ }
372
+
373
+ /**
374
+ * Get and validate PID from PID file
375
+ * Returns null if PID file doesn't exist, is corrupt, or references dead process
376
+ */
377
+ private async getValidatedPid(pidFile: string): Promise<number | null> {
378
+ if (!existsSync(pidFile)) {
379
+ logDebug('PID file does not exist')
380
+ return null
381
+ }
382
+
383
+ try {
384
+ const content = await readFile(pidFile, 'utf8')
385
+ const pid = parseInt(content.trim(), 10)
386
+
387
+ if (isNaN(pid) || pid <= 0) {
388
+ logWarning(`PID file contains invalid value: "${content.trim()}"`, {
389
+ code: ErrorCodes.PID_FILE_CORRUPT,
390
+ pidFile,
391
+ })
392
+ // Clean up corrupt PID file
393
+ await this.cleanupPidFile(pidFile)
394
+ return null
395
+ }
396
+
397
+ // Verify process exists
398
+ try {
399
+ process.kill(pid, 0) // Signal 0 = check existence
400
+ logDebug(`Validated PID ${pid}`)
401
+ return pid
402
+ } catch {
403
+ logWarning(`PID file references non-existent process ${pid}`, {
404
+ code: ErrorCodes.PID_FILE_STALE,
405
+ pidFile,
406
+ })
407
+ // Clean up stale PID file
408
+ await this.cleanupPidFile(pidFile)
409
+ return null
410
+ }
411
+ } catch (err) {
412
+ const e = err as NodeJS.ErrnoException
413
+ if (e.code !== 'ENOENT') {
414
+ logWarning(`Failed to read PID file: ${e.message}`, {
415
+ pidFile,
416
+ errorCode: e.code,
417
+ })
418
+ }
419
+ return null
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Attempt graceful shutdown via mysqladmin
425
+ */
426
+ private async gracefulShutdown(
427
+ port: number,
428
+ pid?: number,
429
+ timeoutMs = 10000,
430
+ ): Promise<boolean> {
320
431
  const mysqladmin = await getMysqladminPath()
432
+
321
433
  if (mysqladmin) {
322
434
  try {
435
+ logDebug('Attempting mysqladmin shutdown')
323
436
  await execAsync(
324
437
  `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root shutdown`,
438
+ { timeout: 5000 },
325
439
  )
326
- } catch {
327
- // Fall back to killing the process
328
- if (existsSync(pidFile)) {
329
- try {
330
- const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
331
- process.kill(pid, 'SIGTERM')
332
- } catch {
333
- // Process might already be dead
334
- }
335
- }
440
+ } catch (err) {
441
+ const e = err as Error
442
+ logDebug(`mysqladmin shutdown failed: ${e.message}`)
443
+ // Continue to wait for process to die or send SIGTERM
336
444
  }
337
- } else if (existsSync(pidFile)) {
338
- // No mysqladmin, kill by PID
445
+ } else if (pid) {
446
+ // No mysqladmin available, send SIGTERM
447
+ logDebug('No mysqladmin available, sending SIGTERM')
339
448
  try {
340
- const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
341
449
  process.kill(pid, 'SIGTERM')
342
450
  } catch {
343
- // Process might already be dead
451
+ // Process may already be dead
452
+ return true
344
453
  }
345
454
  }
346
455
 
347
- // Wait for the process to actually stop
348
- const maxWaitMs = 10000
349
- const checkIntervalMs = 200
350
- const startTime = Date.now()
456
+ // Wait for process to terminate
457
+ if (pid) {
458
+ const startTime = Date.now()
459
+ const checkIntervalMs = 200
351
460
 
352
- while (Date.now() - startTime < maxWaitMs) {
353
- // Check if PID file is gone or process is dead
354
- if (!existsSync(pidFile)) {
355
- return
461
+ while (Date.now() - startTime < timeoutMs) {
462
+ try {
463
+ process.kill(pid, 0)
464
+ await this.sleep(checkIntervalMs)
465
+ } catch {
466
+ // Process is gone
467
+ logDebug(`Process ${pid} terminated after graceful shutdown`)
468
+ return true
469
+ }
356
470
  }
357
471
 
472
+ logDebug(`Graceful shutdown timed out after ${timeoutMs}ms`)
473
+ return false
474
+ }
475
+
476
+ // No PID to check, assume success if mysqladmin didn't throw
477
+ return true
478
+ }
479
+
480
+ /**
481
+ * Force kill with signal escalation (SIGTERM -> SIGKILL)
482
+ */
483
+ private async forceKillWithEscalation(
484
+ pid: number,
485
+ pidFile: string,
486
+ ): Promise<void> {
487
+ logWarning(`Graceful shutdown failed, force killing process ${pid}`)
488
+
489
+ // Try SIGTERM first (if not already sent in graceful shutdown)
490
+ try {
491
+ process.kill(pid, 'SIGTERM')
492
+ await this.sleep(2000)
493
+
494
+ // Check if still running
358
495
  try {
359
- const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
360
- // Check if process is still running (signal 0 doesn't kill, just checks)
361
496
  process.kill(pid, 0)
362
- // Process still running, wait a bit
363
- await new Promise((resolve) => setTimeout(resolve, checkIntervalMs))
364
497
  } catch {
365
- // Process is dead, remove stale PID file if it exists
366
- try {
367
- await rm(pidFile, { force: true })
368
- } catch {
369
- // Ignore
370
- }
498
+ // Process terminated
499
+ logDebug(`Process ${pid} terminated after SIGTERM`)
500
+ await this.cleanupPidFile(pidFile)
501
+ return
502
+ }
503
+ } catch (err) {
504
+ const e = err as NodeJS.ErrnoException
505
+ if (e.code === 'ESRCH') {
506
+ // Process already dead
507
+ await this.cleanupPidFile(pidFile)
371
508
  return
372
509
  }
510
+ logDebug(`SIGTERM failed: ${e.message}`)
373
511
  }
374
512
 
375
- // Timeout - force kill if still running
376
- if (existsSync(pidFile)) {
513
+ // Escalate to SIGKILL
514
+ logWarning(`SIGTERM failed, escalating to SIGKILL for process ${pid}`)
515
+ try {
516
+ process.kill(pid, 'SIGKILL')
517
+ await this.sleep(1000)
518
+
519
+ // Verify process is gone
377
520
  try {
378
- const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
379
- process.kill(pid, 'SIGKILL')
380
- await rm(pidFile, { force: true })
381
- } catch {
382
- // Ignore
521
+ process.kill(pid, 0)
522
+ // Process still running after SIGKILL - this is unexpected
523
+ throw new SpinDBError(
524
+ ErrorCodes.PROCESS_STOP_TIMEOUT,
525
+ `Failed to stop MySQL process ${pid} even with SIGKILL`,
526
+ 'error',
527
+ `Try manually killing the process: kill -9 ${pid}`,
528
+ )
529
+ } catch (checkErr) {
530
+ const checkE = checkErr as NodeJS.ErrnoException
531
+ if (checkE instanceof SpinDBError) throw checkE
532
+ // Process is gone (ESRCH)
533
+ logDebug(`Process ${pid} terminated after SIGKILL`)
534
+ await this.cleanupPidFile(pidFile)
383
535
  }
536
+ } catch (err) {
537
+ if (err instanceof SpinDBError) throw err
538
+ const e = err as NodeJS.ErrnoException
539
+ if (e.code === 'ESRCH') {
540
+ // Process already dead
541
+ await this.cleanupPidFile(pidFile)
542
+ return
543
+ }
544
+ logDebug(`SIGKILL failed: ${e.message}`)
384
545
  }
385
546
  }
386
547
 
548
+ /**
549
+ * Clean up PID file
550
+ */
551
+ private async cleanupPidFile(pidFile: string): Promise<void> {
552
+ try {
553
+ await unlink(pidFile)
554
+ logDebug('PID file cleaned up')
555
+ } catch (err) {
556
+ const e = err as NodeJS.ErrnoException
557
+ if (e.code !== 'ENOENT') {
558
+ logDebug(`Failed to clean up PID file: ${e.message}`)
559
+ }
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Sleep helper
565
+ */
566
+ private sleep(ms: number): Promise<void> {
567
+ return new Promise((resolve) => setTimeout(resolve, ms))
568
+ }
569
+
387
570
  /**
388
571
  * Get MySQL server status
389
572
  */
@@ -400,9 +583,7 @@ export class MySQLEngine extends BaseEngine {
400
583
  const mysqladmin = await getMysqladminPath()
401
584
  if (mysqladmin) {
402
585
  try {
403
- await execAsync(
404
- `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
405
- )
586
+ await execAsync(`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`)
406
587
  return { running: true, message: 'MySQL is running' }
407
588
  } catch {
408
589
  return { running: false, message: 'MySQL is not responding' }
@@ -421,40 +602,15 @@ export class MySQLEngine extends BaseEngine {
421
602
 
422
603
  /**
423
604
  * Detect backup format
424
- * MySQL dumps are typically SQL files
605
+ * Delegates to restore.ts module
425
606
  */
426
607
  async detectBackupFormat(filePath: string): Promise<BackupFormat> {
427
- // Read first few bytes to detect format
428
- const buffer = Buffer.alloc(64)
429
- const { open } = await import('fs/promises')
430
- const file = await open(filePath, 'r')
431
- await file.read(buffer, 0, 64, 0)
432
- await file.close()
433
-
434
- const header = buffer.toString('utf8')
435
-
436
- // Check for MySQL dump markers
437
- if (
438
- header.includes('-- MySQL dump') ||
439
- header.includes('-- MariaDB dump')
440
- ) {
441
- return {
442
- format: 'sql',
443
- description: 'MySQL SQL dump',
444
- restoreCommand: 'mysql',
445
- }
446
- }
447
-
448
- // Default to SQL format
449
- return {
450
- format: 'sql',
451
- description: 'SQL file',
452
- restoreCommand: 'mysql',
453
- }
608
+ return detectBackupFormatImpl(filePath)
454
609
  }
455
610
 
456
611
  /**
457
612
  * Restore a backup
613
+ * Delegates to restore.ts module with version validation
458
614
  * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
459
615
  */
460
616
  async restore(
@@ -470,56 +626,13 @@ export class MySQLEngine extends BaseEngine {
470
626
  await this.createDatabase(container, database)
471
627
  }
472
628
 
473
- const mysql = await getMysqlClientPath()
474
- if (!mysql) {
475
- throw new Error(
476
- 'mysql client not found. Install MySQL client tools:\n' +
477
- ' macOS: brew install mysql-client\n' +
478
- ' Ubuntu/Debian: sudo apt install mysql-client',
479
- )
480
- }
481
-
482
- // Restore using mysql client
483
- // CLI: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
484
- return new Promise((resolve, reject) => {
485
- const args = [
486
- '-h',
487
- '127.0.0.1',
488
- '-P',
489
- String(port),
490
- '-u',
491
- engineDef.superuser,
492
- database,
493
- ]
494
-
495
- const proc = spawn(mysql, args, {
496
- stdio: ['pipe', 'pipe', 'pipe'],
497
- })
498
-
499
- // Pipe backup file to stdin
500
- const fileStream = createReadStream(backupPath)
501
- fileStream.pipe(proc.stdin)
502
-
503
- let stdout = ''
504
- let stderr = ''
505
-
506
- proc.stdout.on('data', (data: Buffer) => {
507
- stdout += data.toString()
508
- })
509
- proc.stderr.on('data', (data: Buffer) => {
510
- stderr += data.toString()
511
- })
512
-
513
- proc.on('close', (code) => {
514
- resolve({
515
- format: 'sql',
516
- stdout,
517
- stderr,
518
- code: code ?? undefined,
519
- })
520
- })
521
-
522
- proc.on('error', reject)
629
+ // Use the restore module with version validation
630
+ return restoreBackup(backupPath, {
631
+ port,
632
+ database,
633
+ user: engineDef.superuser,
634
+ createDatabase: false, // Already created above
635
+ validateVersion: options.validateVersion !== false,
523
636
  })
524
637
  }
525
638
 
@@ -638,13 +751,9 @@ export class MySQLEngine extends BaseEngine {
638
751
  )
639
752
  }
640
753
 
641
- // Parse MySQL connection string: mysql://user:pass@host:port/dbname
642
- const url = new URL(connectionString)
643
- const host = url.hostname
644
- const port = url.port || '3306'
645
- const user = url.username || 'root'
646
- const password = url.password
647
- const database = url.pathname.slice(1) // Remove leading /
754
+ // Parse MySQL connection string using restore module helper
755
+ const { host, port, user, password, database } =
756
+ parseConnectionString(connectionString)
648
757
 
649
758
  const args = [
650
759
  '-h',