spindb 0.4.1 → 0.5.3

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 (44) hide show
  1. package/README.md +207 -101
  2. package/cli/commands/clone.ts +3 -1
  3. package/cli/commands/connect.ts +54 -24
  4. package/cli/commands/create.ts +309 -189
  5. package/cli/commands/delete.ts +3 -1
  6. package/cli/commands/deps.ts +19 -4
  7. package/cli/commands/edit.ts +245 -0
  8. package/cli/commands/engines.ts +434 -0
  9. package/cli/commands/info.ts +279 -0
  10. package/cli/commands/list.ts +14 -3
  11. package/cli/commands/menu.ts +510 -198
  12. package/cli/commands/restore.ts +66 -43
  13. package/cli/commands/start.ts +50 -19
  14. package/cli/commands/stop.ts +3 -1
  15. package/cli/commands/url.ts +79 -0
  16. package/cli/index.ts +9 -3
  17. package/cli/ui/prompts.ts +99 -34
  18. package/config/defaults.ts +40 -15
  19. package/config/engine-defaults.ts +107 -0
  20. package/config/os-dependencies.ts +119 -124
  21. package/config/paths.ts +82 -56
  22. package/core/binary-manager.ts +44 -6
  23. package/core/config-manager.ts +17 -5
  24. package/core/container-manager.ts +124 -60
  25. package/core/dependency-manager.ts +9 -15
  26. package/core/error-handler.ts +336 -0
  27. package/core/platform-service.ts +634 -0
  28. package/core/port-manager.ts +51 -32
  29. package/core/process-manager.ts +26 -8
  30. package/core/start-with-retry.ts +167 -0
  31. package/core/transaction-manager.ts +170 -0
  32. package/engines/index.ts +7 -2
  33. package/engines/mysql/binary-detection.ts +325 -0
  34. package/engines/mysql/index.ts +808 -0
  35. package/engines/mysql/restore.ts +257 -0
  36. package/engines/mysql/version-validator.ts +373 -0
  37. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  38. package/engines/postgresql/binary-urls.ts +5 -3
  39. package/engines/postgresql/index.ts +17 -9
  40. package/engines/postgresql/restore.ts +54 -5
  41. package/engines/postgresql/version-validator.ts +262 -0
  42. package/package.json +9 -3
  43. package/types/index.ts +29 -5
  44. package/cli/commands/postgres-tools.ts +0 -216
@@ -0,0 +1,808 @@
1
+ /**
2
+ * MySQL Engine implementation
3
+ * Manages MySQL database containers using system-installed MySQL binaries
4
+ */
5
+
6
+ import { spawn, exec } from 'child_process'
7
+ import { promisify } from 'util'
8
+ import { existsSync } from 'fs'
9
+ import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
10
+ import { join } from 'path'
11
+ import { BaseEngine } from '../base-engine'
12
+ import { paths } from '../../config/paths'
13
+ import { getEngineDefaults } from '../../config/defaults'
14
+ import {
15
+ logDebug,
16
+ logWarning,
17
+ ErrorCodes,
18
+ SpinDBError,
19
+ } from '../../core/error-handler'
20
+ import {
21
+ getMysqldPath,
22
+ getMysqlClientPath,
23
+ getMysqladminPath,
24
+ getMysqldumpPath,
25
+ getMysqlInstallDbPath,
26
+ getMariadbInstallDbPath,
27
+ isMariaDB,
28
+ detectInstalledVersions,
29
+ getInstallInstructions,
30
+ } from './binary-detection'
31
+ import {
32
+ detectBackupFormat as detectBackupFormatImpl,
33
+ restoreBackup,
34
+ parseConnectionString,
35
+ } from './restore'
36
+ import type {
37
+ ContainerConfig,
38
+ ProgressCallback,
39
+ BackupFormat,
40
+ RestoreResult,
41
+ DumpResult,
42
+ StatusResult,
43
+ } from '../../types'
44
+
45
+ // Re-export modules for external access
46
+ export * from './version-validator'
47
+ export * from './restore'
48
+
49
+ const execAsync = promisify(exec)
50
+
51
+ const ENGINE = 'mysql'
52
+ const engineDef = getEngineDefaults(ENGINE)
53
+
54
+ export class MySQLEngine extends BaseEngine {
55
+ name = ENGINE
56
+ displayName = 'MySQL'
57
+ defaultPort = engineDef.defaultPort
58
+ supportedVersions = engineDef.supportedVersions
59
+
60
+ /**
61
+ * Fetch available versions from system
62
+ * Unlike PostgreSQL which downloads binaries, MySQL uses system-installed versions
63
+ */
64
+ async fetchAvailableVersions(): Promise<Record<string, string[]>> {
65
+ const installed = await detectInstalledVersions()
66
+ const versions: Record<string, string[]> = {}
67
+
68
+ for (const [major, full] of Object.entries(installed)) {
69
+ versions[major] = [full]
70
+ }
71
+
72
+ // If no versions found, return supported versions as placeholders
73
+ if (Object.keys(versions).length === 0) {
74
+ for (const v of this.supportedVersions) {
75
+ versions[v] = [v]
76
+ }
77
+ }
78
+
79
+ return versions
80
+ }
81
+
82
+ /**
83
+ * Get binary download URL - not applicable for MySQL (uses system binaries)
84
+ */
85
+ getBinaryUrl(_version: string, _platform: string, _arch: string): string {
86
+ throw new Error(
87
+ 'MySQL uses system-installed binaries. ' + getInstallInstructions(),
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Verify that MySQL binaries are available
93
+ */
94
+ async verifyBinary(_binPath: string): Promise<boolean> {
95
+ const mysqld = await getMysqldPath()
96
+ return mysqld !== null
97
+ }
98
+
99
+ /**
100
+ * Check if MySQL is installed
101
+ */
102
+ async isBinaryInstalled(_version: string): Promise<boolean> {
103
+ const mysqld = await getMysqldPath()
104
+ return mysqld !== null
105
+ }
106
+
107
+ /**
108
+ * Ensure MySQL binaries are available (just checks system installation)
109
+ */
110
+ async ensureBinaries(
111
+ _version: string,
112
+ _onProgress?: ProgressCallback,
113
+ ): Promise<string> {
114
+ const mysqld = await getMysqldPath()
115
+ if (!mysqld) {
116
+ throw new Error(getInstallInstructions())
117
+ }
118
+ return mysqld
119
+ }
120
+
121
+ /**
122
+ * Initialize a new MySQL/MariaDB data directory
123
+ * MySQL: mysqld --initialize-insecure --datadir={dir}
124
+ * MariaDB: mysql_install_db --datadir={dir} --auth-root-authentication-method=normal
125
+ */
126
+ async initDataDir(
127
+ containerName: string,
128
+ _version: string,
129
+ _options: Record<string, unknown> = {},
130
+ ): Promise<string> {
131
+ const dataDir = paths.getContainerDataPath(containerName, {
132
+ engine: ENGINE,
133
+ })
134
+
135
+ // Create data directory if it doesn't exist
136
+ if (!existsSync(dataDir)) {
137
+ await mkdir(dataDir, { recursive: true })
138
+ }
139
+
140
+ // Check if we're using MariaDB or MySQL
141
+ const usingMariaDB = await isMariaDB()
142
+
143
+ if (usingMariaDB) {
144
+ // MariaDB uses mysql_install_db or mariadb-install-db
145
+ const installDb =
146
+ (await getMariadbInstallDbPath()) || (await getMysqlInstallDbPath())
147
+ if (!installDb) {
148
+ throw new Error(
149
+ 'MariaDB detected but mysql_install_db not found.\n' +
150
+ 'Install MariaDB server package which includes the initialization script.',
151
+ )
152
+ }
153
+
154
+ // MariaDB initialization
155
+ // --auth-root-authentication-method=normal allows passwordless root login via socket
156
+ const args = [
157
+ `--datadir=${dataDir}`,
158
+ `--user=${process.env.USER || 'mysql'}`,
159
+ '--auth-root-authentication-method=normal',
160
+ ]
161
+
162
+ return new Promise((resolve, reject) => {
163
+ const proc = spawn(installDb, args, {
164
+ stdio: ['ignore', 'pipe', 'pipe'],
165
+ })
166
+
167
+ let stdout = ''
168
+ let stderr = ''
169
+
170
+ proc.stdout.on('data', (data: Buffer) => {
171
+ stdout += data.toString()
172
+ })
173
+ proc.stderr.on('data', (data: Buffer) => {
174
+ stderr += data.toString()
175
+ })
176
+
177
+ proc.on('close', (code) => {
178
+ if (code === 0) {
179
+ resolve(dataDir)
180
+ } else {
181
+ reject(
182
+ new Error(
183
+ `MariaDB initialization failed with code ${code}: ${stderr || stdout}`,
184
+ ),
185
+ )
186
+ }
187
+ })
188
+
189
+ proc.on('error', reject)
190
+ })
191
+ } else {
192
+ // MySQL uses mysqld --initialize-insecure
193
+ const mysqld = await getMysqldPath()
194
+ if (!mysqld) {
195
+ throw new Error(getInstallInstructions())
196
+ }
197
+
198
+ // MySQL initialization
199
+ // --initialize-insecure creates root user without password (for local dev)
200
+ const args = [
201
+ '--initialize-insecure',
202
+ `--datadir=${dataDir}`,
203
+ `--user=${process.env.USER || 'mysql'}`,
204
+ ]
205
+
206
+ return new Promise((resolve, reject) => {
207
+ const proc = spawn(mysqld, args, {
208
+ stdio: ['ignore', 'pipe', 'pipe'],
209
+ })
210
+
211
+ let stdout = ''
212
+ let stderr = ''
213
+
214
+ proc.stdout.on('data', (data: Buffer) => {
215
+ stdout += data.toString()
216
+ })
217
+ proc.stderr.on('data', (data: Buffer) => {
218
+ stderr += data.toString()
219
+ })
220
+
221
+ proc.on('close', (code) => {
222
+ if (code === 0) {
223
+ resolve(dataDir)
224
+ } else {
225
+ reject(
226
+ new Error(
227
+ `MySQL initialization failed with code ${code}: ${stderr || stdout}`,
228
+ ),
229
+ )
230
+ }
231
+ })
232
+
233
+ proc.on('error', reject)
234
+ })
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Start MySQL server
240
+ * CLI wrapper: mysqld_safe --datadir={dir} --port={port} &
241
+ */
242
+ async start(
243
+ container: ContainerConfig,
244
+ onProgress?: ProgressCallback,
245
+ ): Promise<{ port: number; connectionString: string }> {
246
+ const { name, port } = container
247
+
248
+ const mysqld = await getMysqldPath()
249
+ if (!mysqld) {
250
+ throw new Error(getInstallInstructions())
251
+ }
252
+
253
+ const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
254
+ const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
255
+ const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
256
+ const socketFile = join(
257
+ paths.getContainerPath(name, { engine: ENGINE }),
258
+ 'mysql.sock',
259
+ )
260
+
261
+ onProgress?.({ stage: 'starting', message: 'Starting MySQL...' })
262
+
263
+ // Start mysqld directly in background
264
+ // Note: We use --initialize-insecure during init which creates root without password
265
+ // This allows passwordless local connections without --skip-grant-tables
266
+ // (--skip-grant-tables disables TCP networking in MySQL 8+)
267
+ const args = [
268
+ `--datadir=${dataDir}`,
269
+ `--port=${port}`,
270
+ `--socket=${socketFile}`,
271
+ `--pid-file=${pidFile}`,
272
+ `--log-error=${logFile}`,
273
+ '--bind-address=127.0.0.1',
274
+ ]
275
+
276
+ return new Promise((resolve, reject) => {
277
+ const proc = spawn(mysqld, args, {
278
+ stdio: ['ignore', 'ignore', 'ignore'],
279
+ detached: true,
280
+ })
281
+
282
+ proc.unref()
283
+
284
+ // Give MySQL a moment to start
285
+ setTimeout(async () => {
286
+ // Write PID file manually since we're running detached
287
+ try {
288
+ await writeFile(pidFile, String(proc.pid))
289
+ } catch {
290
+ // PID file might be written by mysqld itself
291
+ }
292
+
293
+ // Wait for MySQL to be ready
294
+ let attempts = 0
295
+ const maxAttempts = 30
296
+ const checkInterval = 500
297
+
298
+ const checkReady = async () => {
299
+ attempts++
300
+ try {
301
+ const mysqladmin = await getMysqladminPath()
302
+ if (mysqladmin) {
303
+ await execAsync(
304
+ `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
305
+ )
306
+ resolve({
307
+ port,
308
+ connectionString: this.getConnectionString(container),
309
+ })
310
+ return
311
+ }
312
+ } catch {
313
+ if (attempts < maxAttempts) {
314
+ setTimeout(checkReady, checkInterval)
315
+ } else {
316
+ reject(new Error('MySQL failed to start within timeout'))
317
+ }
318
+ }
319
+ }
320
+
321
+ checkReady()
322
+ }, 1000)
323
+
324
+ proc.on('error', reject)
325
+ })
326
+ }
327
+
328
+ /**
329
+ * Stop MySQL server
330
+ * CLI wrapper: mysqladmin -u root -P {port} shutdown
331
+ */
332
+ async stop(container: ContainerConfig): Promise<void> {
333
+ const { name, port } = container
334
+ const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
335
+
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> {
431
+ const mysqladmin = await getMysqladminPath()
432
+
433
+ if (mysqladmin) {
434
+ try {
435
+ logDebug('Attempting mysqladmin shutdown')
436
+ await execAsync(
437
+ `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root shutdown`,
438
+ { timeout: 5000 },
439
+ )
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
444
+ }
445
+ } else if (pid) {
446
+ // No mysqladmin available, send SIGTERM
447
+ logDebug('No mysqladmin available, sending SIGTERM')
448
+ try {
449
+ process.kill(pid, 'SIGTERM')
450
+ } catch {
451
+ // Process may already be dead
452
+ return true
453
+ }
454
+ }
455
+
456
+ // Wait for process to terminate
457
+ if (pid) {
458
+ const startTime = Date.now()
459
+ const checkIntervalMs = 200
460
+
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
+ }
470
+ }
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
495
+ try {
496
+ process.kill(pid, 0)
497
+ } catch {
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)
508
+ return
509
+ }
510
+ logDebug(`SIGTERM failed: ${e.message}`)
511
+ }
512
+
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
520
+ try {
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)
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}`)
545
+ }
546
+ }
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
+
570
+ /**
571
+ * Get MySQL server status
572
+ */
573
+ async status(container: ContainerConfig): Promise<StatusResult> {
574
+ const { name, port } = container
575
+ const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
576
+
577
+ // Check if PID file exists
578
+ if (!existsSync(pidFile)) {
579
+ return { running: false, message: 'MySQL is not running' }
580
+ }
581
+
582
+ // Try to ping MySQL
583
+ const mysqladmin = await getMysqladminPath()
584
+ if (mysqladmin) {
585
+ try {
586
+ await execAsync(`"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`)
587
+ return { running: true, message: 'MySQL is running' }
588
+ } catch {
589
+ return { running: false, message: 'MySQL is not responding' }
590
+ }
591
+ }
592
+
593
+ // Fall back to checking PID
594
+ try {
595
+ const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
596
+ process.kill(pid, 0) // Check if process exists
597
+ return { running: true, message: `MySQL is running (PID: ${pid})` }
598
+ } catch {
599
+ return { running: false, message: 'MySQL is not running' }
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Detect backup format
605
+ * Delegates to restore.ts module
606
+ */
607
+ async detectBackupFormat(filePath: string): Promise<BackupFormat> {
608
+ return detectBackupFormatImpl(filePath)
609
+ }
610
+
611
+ /**
612
+ * Restore a backup
613
+ * Delegates to restore.ts module with version validation
614
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
615
+ */
616
+ async restore(
617
+ container: ContainerConfig,
618
+ backupPath: string,
619
+ options: Record<string, unknown> = {},
620
+ ): Promise<RestoreResult> {
621
+ const { port } = container
622
+ const database = (options.database as string) || container.database
623
+
624
+ // Create the database if it doesn't exist
625
+ if (options.createDatabase !== false) {
626
+ await this.createDatabase(container, database)
627
+ }
628
+
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,
636
+ })
637
+ }
638
+
639
+ /**
640
+ * Get connection string
641
+ */
642
+ getConnectionString(container: ContainerConfig, database?: string): string {
643
+ const { port } = container
644
+ const db = database || container.database || 'mysql'
645
+ return `mysql://${engineDef.superuser}@127.0.0.1:${port}/${db}`
646
+ }
647
+
648
+ /**
649
+ * Open mysql interactive shell
650
+ * Spawn interactive: mysql -h 127.0.0.1 -P {port} -u root {db}
651
+ */
652
+ async connect(container: ContainerConfig, database?: string): Promise<void> {
653
+ const { port } = container
654
+ const db = database || container.database || 'mysql'
655
+
656
+ const mysql = await getMysqlClientPath()
657
+ if (!mysql) {
658
+ throw new Error(
659
+ 'mysql client not found. Install MySQL client tools:\n' +
660
+ ' macOS: brew install mysql-client\n' +
661
+ ' Ubuntu/Debian: sudo apt install mysql-client',
662
+ )
663
+ }
664
+
665
+ return new Promise((resolve, reject) => {
666
+ const proc = spawn(
667
+ mysql,
668
+ ['-h', '127.0.0.1', '-P', String(port), '-u', engineDef.superuser, db],
669
+ { stdio: 'inherit' },
670
+ )
671
+
672
+ proc.on('error', reject)
673
+ proc.on('close', () => resolve())
674
+ })
675
+ }
676
+
677
+ /**
678
+ * Create a new database
679
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'CREATE DATABASE `{db}`'
680
+ */
681
+ async createDatabase(
682
+ container: ContainerConfig,
683
+ database: string,
684
+ ): Promise<void> {
685
+ const { port } = container
686
+
687
+ const mysql = await getMysqlClientPath()
688
+ if (!mysql) {
689
+ throw new Error(
690
+ 'mysql client not found. Install MySQL client tools:\n' +
691
+ ' macOS: brew install mysql-client\n' +
692
+ ' Ubuntu/Debian: sudo apt install mysql-client',
693
+ )
694
+ }
695
+
696
+ try {
697
+ // Use backticks for MySQL database names
698
+ await execAsync(
699
+ `"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'CREATE DATABASE IF NOT EXISTS \`${database}\`'`,
700
+ )
701
+ } catch (error) {
702
+ const err = error as Error
703
+ // Ignore "database exists" error
704
+ if (!err.message.includes('database exists')) {
705
+ throw error
706
+ }
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Drop a database
712
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'DROP DATABASE IF EXISTS `{db}`'
713
+ */
714
+ async dropDatabase(
715
+ container: ContainerConfig,
716
+ database: string,
717
+ ): Promise<void> {
718
+ const { port } = container
719
+
720
+ const mysql = await getMysqlClientPath()
721
+ if (!mysql) {
722
+ throw new Error('mysql client not found.')
723
+ }
724
+
725
+ try {
726
+ await execAsync(
727
+ `"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'DROP DATABASE IF EXISTS \`${database}\`'`,
728
+ )
729
+ } catch (error) {
730
+ const err = error as Error
731
+ if (!err.message.includes("database doesn't exist")) {
732
+ throw error
733
+ }
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Create a dump from a remote database using a connection string
739
+ * CLI wrapper: mysqldump -h {host} -P {port} -u {user} -p{pass} {db} > {file}
740
+ */
741
+ async dumpFromConnectionString(
742
+ connectionString: string,
743
+ outputPath: string,
744
+ ): Promise<DumpResult> {
745
+ const mysqldump = await getMysqldumpPath()
746
+ if (!mysqldump) {
747
+ throw new Error(
748
+ 'mysqldump not found. Install MySQL client tools:\n' +
749
+ ' macOS: brew install mysql-client\n' +
750
+ ' Ubuntu/Debian: sudo apt install mysql-client',
751
+ )
752
+ }
753
+
754
+ // Parse MySQL connection string using restore module helper
755
+ const { host, port, user, password, database } =
756
+ parseConnectionString(connectionString)
757
+
758
+ const args = [
759
+ '-h',
760
+ host,
761
+ '-P',
762
+ port,
763
+ '-u',
764
+ user,
765
+ '--result-file',
766
+ outputPath,
767
+ ]
768
+
769
+ if (password) {
770
+ args.push(`-p${password}`)
771
+ }
772
+
773
+ args.push(database)
774
+
775
+ return new Promise((resolve, reject) => {
776
+ const proc = spawn(mysqldump, args, {
777
+ stdio: ['pipe', 'pipe', 'pipe'],
778
+ })
779
+
780
+ let stdout = ''
781
+ let stderr = ''
782
+
783
+ proc.stdout?.on('data', (data: Buffer) => {
784
+ stdout += data.toString()
785
+ })
786
+ proc.stderr?.on('data', (data: Buffer) => {
787
+ stderr += data.toString()
788
+ })
789
+
790
+ proc.on('error', reject)
791
+
792
+ proc.on('close', (code) => {
793
+ if (code === 0) {
794
+ resolve({
795
+ filePath: outputPath,
796
+ stdout,
797
+ stderr,
798
+ code,
799
+ })
800
+ } else {
801
+ reject(new Error(stderr || `mysqldump exited with code ${code}`))
802
+ }
803
+ })
804
+ })
805
+ }
806
+ }
807
+
808
+ export const mysqlEngine = new MySQLEngine()