spindb 0.4.1 → 0.5.2

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,699 @@
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, createReadStream } from 'fs'
9
+ import { mkdir, writeFile, readFile, rm } 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
+ getMysqldPath,
16
+ getMysqlClientPath,
17
+ getMysqladminPath,
18
+ getMysqldumpPath,
19
+ getMysqlInstallDbPath,
20
+ getMariadbInstallDbPath,
21
+ isMariaDB,
22
+ detectInstalledVersions,
23
+ getInstallInstructions,
24
+ } from './binary-detection'
25
+ import type {
26
+ ContainerConfig,
27
+ ProgressCallback,
28
+ BackupFormat,
29
+ RestoreResult,
30
+ DumpResult,
31
+ StatusResult,
32
+ } from '../../types'
33
+
34
+ const execAsync = promisify(exec)
35
+
36
+ const ENGINE = 'mysql'
37
+ const engineDef = getEngineDefaults(ENGINE)
38
+
39
+ export class MySQLEngine extends BaseEngine {
40
+ name = ENGINE
41
+ displayName = 'MySQL'
42
+ defaultPort = engineDef.defaultPort
43
+ supportedVersions = engineDef.supportedVersions
44
+
45
+ /**
46
+ * Fetch available versions from system
47
+ * Unlike PostgreSQL which downloads binaries, MySQL uses system-installed versions
48
+ */
49
+ async fetchAvailableVersions(): Promise<Record<string, string[]>> {
50
+ const installed = await detectInstalledVersions()
51
+ const versions: Record<string, string[]> = {}
52
+
53
+ for (const [major, full] of Object.entries(installed)) {
54
+ versions[major] = [full]
55
+ }
56
+
57
+ // If no versions found, return supported versions as placeholders
58
+ if (Object.keys(versions).length === 0) {
59
+ for (const v of this.supportedVersions) {
60
+ versions[v] = [v]
61
+ }
62
+ }
63
+
64
+ return versions
65
+ }
66
+
67
+ /**
68
+ * Get binary download URL - not applicable for MySQL (uses system binaries)
69
+ */
70
+ getBinaryUrl(_version: string, _platform: string, _arch: string): string {
71
+ throw new Error(
72
+ 'MySQL uses system-installed binaries. ' + getInstallInstructions(),
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Verify that MySQL binaries are available
78
+ */
79
+ async verifyBinary(_binPath: string): Promise<boolean> {
80
+ const mysqld = await getMysqldPath()
81
+ return mysqld !== null
82
+ }
83
+
84
+ /**
85
+ * Check if MySQL is installed
86
+ */
87
+ async isBinaryInstalled(_version: string): Promise<boolean> {
88
+ const mysqld = await getMysqldPath()
89
+ return mysqld !== null
90
+ }
91
+
92
+ /**
93
+ * Ensure MySQL binaries are available (just checks system installation)
94
+ */
95
+ async ensureBinaries(
96
+ _version: string,
97
+ _onProgress?: ProgressCallback,
98
+ ): Promise<string> {
99
+ const mysqld = await getMysqldPath()
100
+ if (!mysqld) {
101
+ throw new Error(getInstallInstructions())
102
+ }
103
+ return mysqld
104
+ }
105
+
106
+ /**
107
+ * Initialize a new MySQL/MariaDB data directory
108
+ * MySQL: mysqld --initialize-insecure --datadir={dir}
109
+ * MariaDB: mysql_install_db --datadir={dir} --auth-root-authentication-method=normal
110
+ */
111
+ async initDataDir(
112
+ containerName: string,
113
+ _version: string,
114
+ _options: Record<string, unknown> = {},
115
+ ): Promise<string> {
116
+ const dataDir = paths.getContainerDataPath(containerName, { engine: ENGINE })
117
+
118
+ // Create data directory if it doesn't exist
119
+ if (!existsSync(dataDir)) {
120
+ await mkdir(dataDir, { recursive: true })
121
+ }
122
+
123
+ // Check if we're using MariaDB or MySQL
124
+ const usingMariaDB = await isMariaDB()
125
+
126
+ if (usingMariaDB) {
127
+ // MariaDB uses mysql_install_db or mariadb-install-db
128
+ const installDb =
129
+ (await getMariadbInstallDbPath()) || (await getMysqlInstallDbPath())
130
+ if (!installDb) {
131
+ throw new Error(
132
+ 'MariaDB detected but mysql_install_db not found.\n' +
133
+ 'Install MariaDB server package which includes the initialization script.',
134
+ )
135
+ }
136
+
137
+ // MariaDB initialization
138
+ // --auth-root-authentication-method=normal allows passwordless root login via socket
139
+ const args = [
140
+ `--datadir=${dataDir}`,
141
+ `--user=${process.env.USER || 'mysql'}`,
142
+ '--auth-root-authentication-method=normal',
143
+ ]
144
+
145
+ return new Promise((resolve, reject) => {
146
+ const proc = spawn(installDb, args, {
147
+ stdio: ['ignore', 'pipe', 'pipe'],
148
+ })
149
+
150
+ let stdout = ''
151
+ let stderr = ''
152
+
153
+ proc.stdout.on('data', (data: Buffer) => {
154
+ stdout += data.toString()
155
+ })
156
+ proc.stderr.on('data', (data: Buffer) => {
157
+ stderr += data.toString()
158
+ })
159
+
160
+ proc.on('close', (code) => {
161
+ if (code === 0) {
162
+ resolve(dataDir)
163
+ } else {
164
+ reject(
165
+ new Error(
166
+ `MariaDB initialization failed with code ${code}: ${stderr || stdout}`,
167
+ ),
168
+ )
169
+ }
170
+ })
171
+
172
+ proc.on('error', reject)
173
+ })
174
+ } else {
175
+ // MySQL uses mysqld --initialize-insecure
176
+ const mysqld = await getMysqldPath()
177
+ if (!mysqld) {
178
+ throw new Error(getInstallInstructions())
179
+ }
180
+
181
+ // MySQL initialization
182
+ // --initialize-insecure creates root user without password (for local dev)
183
+ const args = [
184
+ '--initialize-insecure',
185
+ `--datadir=${dataDir}`,
186
+ `--user=${process.env.USER || 'mysql'}`,
187
+ ]
188
+
189
+ return new Promise((resolve, reject) => {
190
+ const proc = spawn(mysqld, args, {
191
+ stdio: ['ignore', 'pipe', 'pipe'],
192
+ })
193
+
194
+ let stdout = ''
195
+ let stderr = ''
196
+
197
+ proc.stdout.on('data', (data: Buffer) => {
198
+ stdout += data.toString()
199
+ })
200
+ proc.stderr.on('data', (data: Buffer) => {
201
+ stderr += data.toString()
202
+ })
203
+
204
+ proc.on('close', (code) => {
205
+ if (code === 0) {
206
+ resolve(dataDir)
207
+ } else {
208
+ reject(
209
+ new Error(
210
+ `MySQL initialization failed with code ${code}: ${stderr || stdout}`,
211
+ ),
212
+ )
213
+ }
214
+ })
215
+
216
+ proc.on('error', reject)
217
+ })
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Start MySQL server
223
+ * CLI wrapper: mysqld_safe --datadir={dir} --port={port} &
224
+ */
225
+ async start(
226
+ container: ContainerConfig,
227
+ onProgress?: ProgressCallback,
228
+ ): Promise<{ port: number; connectionString: string }> {
229
+ const { name, port } = container
230
+
231
+ const mysqld = await getMysqldPath()
232
+ if (!mysqld) {
233
+ throw new Error(getInstallInstructions())
234
+ }
235
+
236
+ const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
237
+ const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
238
+ const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
239
+ const socketFile = join(
240
+ paths.getContainerPath(name, { engine: ENGINE }),
241
+ 'mysql.sock',
242
+ )
243
+
244
+ onProgress?.({ stage: 'starting', message: 'Starting MySQL...' })
245
+
246
+ // Start mysqld directly in background
247
+ // Note: We use --initialize-insecure during init which creates root without password
248
+ // This allows passwordless local connections without --skip-grant-tables
249
+ // (--skip-grant-tables disables TCP networking in MySQL 8+)
250
+ const args = [
251
+ `--datadir=${dataDir}`,
252
+ `--port=${port}`,
253
+ `--socket=${socketFile}`,
254
+ `--pid-file=${pidFile}`,
255
+ `--log-error=${logFile}`,
256
+ '--bind-address=127.0.0.1',
257
+ ]
258
+
259
+ return new Promise((resolve, reject) => {
260
+ const proc = spawn(mysqld, args, {
261
+ stdio: ['ignore', 'ignore', 'ignore'],
262
+ detached: true,
263
+ })
264
+
265
+ proc.unref()
266
+
267
+ // Give MySQL a moment to start
268
+ setTimeout(async () => {
269
+ // Write PID file manually since we're running detached
270
+ try {
271
+ await writeFile(pidFile, String(proc.pid))
272
+ } catch {
273
+ // PID file might be written by mysqld itself
274
+ }
275
+
276
+ // Wait for MySQL to be ready
277
+ let attempts = 0
278
+ const maxAttempts = 30
279
+ const checkInterval = 500
280
+
281
+ const checkReady = async () => {
282
+ attempts++
283
+ try {
284
+ const mysqladmin = await getMysqladminPath()
285
+ if (mysqladmin) {
286
+ await execAsync(
287
+ `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
288
+ )
289
+ resolve({
290
+ port,
291
+ connectionString: this.getConnectionString(container),
292
+ })
293
+ return
294
+ }
295
+ } catch {
296
+ if (attempts < maxAttempts) {
297
+ setTimeout(checkReady, checkInterval)
298
+ } else {
299
+ reject(new Error('MySQL failed to start within timeout'))
300
+ }
301
+ }
302
+ }
303
+
304
+ checkReady()
305
+ }, 1000)
306
+
307
+ proc.on('error', reject)
308
+ })
309
+ }
310
+
311
+ /**
312
+ * Stop MySQL server
313
+ * CLI wrapper: mysqladmin -u root -P {port} shutdown
314
+ */
315
+ async stop(container: ContainerConfig): Promise<void> {
316
+ const { name, port } = container
317
+ const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
318
+
319
+ // Try graceful shutdown first with mysqladmin
320
+ const mysqladmin = await getMysqladminPath()
321
+ if (mysqladmin) {
322
+ try {
323
+ await execAsync(
324
+ `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root shutdown`,
325
+ )
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
+ }
336
+ }
337
+ } else if (existsSync(pidFile)) {
338
+ // No mysqladmin, kill by PID
339
+ try {
340
+ const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
341
+ process.kill(pid, 'SIGTERM')
342
+ } catch {
343
+ // Process might already be dead
344
+ }
345
+ }
346
+
347
+ // Wait for the process to actually stop
348
+ const maxWaitMs = 10000
349
+ const checkIntervalMs = 200
350
+ const startTime = Date.now()
351
+
352
+ while (Date.now() - startTime < maxWaitMs) {
353
+ // Check if PID file is gone or process is dead
354
+ if (!existsSync(pidFile)) {
355
+ return
356
+ }
357
+
358
+ 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
+ process.kill(pid, 0)
362
+ // Process still running, wait a bit
363
+ await new Promise((resolve) => setTimeout(resolve, checkIntervalMs))
364
+ } 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
+ }
371
+ return
372
+ }
373
+ }
374
+
375
+ // Timeout - force kill if still running
376
+ if (existsSync(pidFile)) {
377
+ 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
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Get MySQL server status
389
+ */
390
+ async status(container: ContainerConfig): Promise<StatusResult> {
391
+ const { name, port } = container
392
+ const pidFile = paths.getContainerPidPath(name, { engine: ENGINE })
393
+
394
+ // Check if PID file exists
395
+ if (!existsSync(pidFile)) {
396
+ return { running: false, message: 'MySQL is not running' }
397
+ }
398
+
399
+ // Try to ping MySQL
400
+ const mysqladmin = await getMysqladminPath()
401
+ if (mysqladmin) {
402
+ try {
403
+ await execAsync(
404
+ `"${mysqladmin}" -h 127.0.0.1 -P ${port} -u root ping`,
405
+ )
406
+ return { running: true, message: 'MySQL is running' }
407
+ } catch {
408
+ return { running: false, message: 'MySQL is not responding' }
409
+ }
410
+ }
411
+
412
+ // Fall back to checking PID
413
+ try {
414
+ const pid = parseInt(await readFile(pidFile, 'utf8'), 10)
415
+ process.kill(pid, 0) // Check if process exists
416
+ return { running: true, message: `MySQL is running (PID: ${pid})` }
417
+ } catch {
418
+ return { running: false, message: 'MySQL is not running' }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Detect backup format
424
+ * MySQL dumps are typically SQL files
425
+ */
426
+ 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
+ }
454
+ }
455
+
456
+ /**
457
+ * Restore a backup
458
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
459
+ */
460
+ async restore(
461
+ container: ContainerConfig,
462
+ backupPath: string,
463
+ options: Record<string, unknown> = {},
464
+ ): Promise<RestoreResult> {
465
+ const { port } = container
466
+ const database = (options.database as string) || container.database
467
+
468
+ // Create the database if it doesn't exist
469
+ if (options.createDatabase !== false) {
470
+ await this.createDatabase(container, database)
471
+ }
472
+
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)
523
+ })
524
+ }
525
+
526
+ /**
527
+ * Get connection string
528
+ */
529
+ getConnectionString(container: ContainerConfig, database?: string): string {
530
+ const { port } = container
531
+ const db = database || container.database || 'mysql'
532
+ return `mysql://${engineDef.superuser}@127.0.0.1:${port}/${db}`
533
+ }
534
+
535
+ /**
536
+ * Open mysql interactive shell
537
+ * Spawn interactive: mysql -h 127.0.0.1 -P {port} -u root {db}
538
+ */
539
+ async connect(container: ContainerConfig, database?: string): Promise<void> {
540
+ const { port } = container
541
+ const db = database || container.database || 'mysql'
542
+
543
+ const mysql = await getMysqlClientPath()
544
+ if (!mysql) {
545
+ throw new Error(
546
+ 'mysql client not found. Install MySQL client tools:\n' +
547
+ ' macOS: brew install mysql-client\n' +
548
+ ' Ubuntu/Debian: sudo apt install mysql-client',
549
+ )
550
+ }
551
+
552
+ return new Promise((resolve, reject) => {
553
+ const proc = spawn(
554
+ mysql,
555
+ ['-h', '127.0.0.1', '-P', String(port), '-u', engineDef.superuser, db],
556
+ { stdio: 'inherit' },
557
+ )
558
+
559
+ proc.on('error', reject)
560
+ proc.on('close', () => resolve())
561
+ })
562
+ }
563
+
564
+ /**
565
+ * Create a new database
566
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'CREATE DATABASE `{db}`'
567
+ */
568
+ async createDatabase(
569
+ container: ContainerConfig,
570
+ database: string,
571
+ ): Promise<void> {
572
+ const { port } = container
573
+
574
+ const mysql = await getMysqlClientPath()
575
+ if (!mysql) {
576
+ throw new Error(
577
+ 'mysql client not found. Install MySQL client tools:\n' +
578
+ ' macOS: brew install mysql-client\n' +
579
+ ' Ubuntu/Debian: sudo apt install mysql-client',
580
+ )
581
+ }
582
+
583
+ try {
584
+ // Use backticks for MySQL database names
585
+ await execAsync(
586
+ `"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'CREATE DATABASE IF NOT EXISTS \`${database}\`'`,
587
+ )
588
+ } catch (error) {
589
+ const err = error as Error
590
+ // Ignore "database exists" error
591
+ if (!err.message.includes('database exists')) {
592
+ throw error
593
+ }
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Drop a database
599
+ * CLI wrapper: mysql -h 127.0.0.1 -P {port} -u root -e 'DROP DATABASE IF EXISTS `{db}`'
600
+ */
601
+ async dropDatabase(
602
+ container: ContainerConfig,
603
+ database: string,
604
+ ): Promise<void> {
605
+ const { port } = container
606
+
607
+ const mysql = await getMysqlClientPath()
608
+ if (!mysql) {
609
+ throw new Error('mysql client not found.')
610
+ }
611
+
612
+ try {
613
+ await execAsync(
614
+ `"${mysql}" -h 127.0.0.1 -P ${port} -u ${engineDef.superuser} -e 'DROP DATABASE IF EXISTS \`${database}\`'`,
615
+ )
616
+ } catch (error) {
617
+ const err = error as Error
618
+ if (!err.message.includes("database doesn't exist")) {
619
+ throw error
620
+ }
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Create a dump from a remote database using a connection string
626
+ * CLI wrapper: mysqldump -h {host} -P {port} -u {user} -p{pass} {db} > {file}
627
+ */
628
+ async dumpFromConnectionString(
629
+ connectionString: string,
630
+ outputPath: string,
631
+ ): Promise<DumpResult> {
632
+ const mysqldump = await getMysqldumpPath()
633
+ if (!mysqldump) {
634
+ throw new Error(
635
+ 'mysqldump not found. Install MySQL client tools:\n' +
636
+ ' macOS: brew install mysql-client\n' +
637
+ ' Ubuntu/Debian: sudo apt install mysql-client',
638
+ )
639
+ }
640
+
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 /
648
+
649
+ const args = [
650
+ '-h',
651
+ host,
652
+ '-P',
653
+ port,
654
+ '-u',
655
+ user,
656
+ '--result-file',
657
+ outputPath,
658
+ ]
659
+
660
+ if (password) {
661
+ args.push(`-p${password}`)
662
+ }
663
+
664
+ args.push(database)
665
+
666
+ return new Promise((resolve, reject) => {
667
+ const proc = spawn(mysqldump, args, {
668
+ stdio: ['pipe', 'pipe', 'pipe'],
669
+ })
670
+
671
+ let stdout = ''
672
+ let stderr = ''
673
+
674
+ proc.stdout?.on('data', (data: Buffer) => {
675
+ stdout += data.toString()
676
+ })
677
+ proc.stderr?.on('data', (data: Buffer) => {
678
+ stderr += data.toString()
679
+ })
680
+
681
+ proc.on('error', reject)
682
+
683
+ proc.on('close', (code) => {
684
+ if (code === 0) {
685
+ resolve({
686
+ filePath: outputPath,
687
+ stdout,
688
+ stderr,
689
+ code,
690
+ })
691
+ } else {
692
+ reject(new Error(stderr || `mysqldump exited with code ${code}`))
693
+ }
694
+ })
695
+ })
696
+ }
697
+ }
698
+
699
+ export const mysqlEngine = new MySQLEngine()