spindb 0.9.0 → 0.9.1

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.
@@ -1,7 +1,7 @@
1
1
  import { exec, spawn } from 'child_process'
2
2
  import { promisify } from 'util'
3
3
  import { existsSync } from 'fs'
4
- import { readFile } from 'fs/promises'
4
+ import { readFile, rm } from 'fs/promises'
5
5
  import { paths } from '../config/paths'
6
6
  import { logDebug } from './error-handler'
7
7
  import type { ProcessResult, StatusResult } from '../types'
@@ -42,6 +42,9 @@ export class ProcessManager {
42
42
  ): Promise<ProcessResult> {
43
43
  const { superuser = 'postgres' } = options
44
44
 
45
+ // Track if directory existed before initdb (to know if we should clean up)
46
+ const dirExistedBefore = existsSync(dataDir)
47
+
45
48
  const args = [
46
49
  '-D',
47
50
  dataDir,
@@ -52,6 +55,21 @@ export class ProcessManager {
52
55
  '--no-locale',
53
56
  ]
54
57
 
58
+ // Helper to clean up data directory on failure
59
+ const cleanupOnFailure = async () => {
60
+ // Only clean up if initdb created the directory (it didn't exist before)
61
+ if (!dirExistedBefore && existsSync(dataDir)) {
62
+ try {
63
+ await rm(dataDir, { recursive: true, force: true })
64
+ logDebug(`Cleaned up data directory after initdb failure: ${dataDir}`)
65
+ } catch (cleanupErr) {
66
+ logDebug(
67
+ `Failed to clean up data directory: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
68
+ )
69
+ }
70
+ }
71
+ }
72
+
55
73
  return new Promise((resolve, reject) => {
56
74
  const proc = spawn(initdbPath, args, {
57
75
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -67,15 +85,19 @@ export class ProcessManager {
67
85
  stderr += data.toString()
68
86
  })
69
87
 
70
- proc.on('close', (code) => {
88
+ proc.on('close', async (code) => {
71
89
  if (code === 0) {
72
90
  resolve({ stdout, stderr })
73
91
  } else {
92
+ await cleanupOnFailure()
74
93
  reject(new Error(`initdb failed with code ${code}: ${stderr}`))
75
94
  }
76
95
  })
77
96
 
78
- proc.on('error', reject)
97
+ proc.on('error', async (err) => {
98
+ await cleanupOnFailure()
99
+ reject(err)
100
+ })
79
101
  })
80
102
  }
81
103
 
@@ -56,6 +56,20 @@ async function createSqlBackup(
56
56
  outputPath: string,
57
57
  ): Promise<BackupResult> {
58
58
  return new Promise((resolve, reject) => {
59
+ let settled = false
60
+ const safeResolve = (value: BackupResult) => {
61
+ if (!settled) {
62
+ settled = true
63
+ resolve(value)
64
+ }
65
+ }
66
+ const safeReject = (err: Error) => {
67
+ if (!settled) {
68
+ settled = true
69
+ reject(err)
70
+ }
71
+ }
72
+
59
73
  const args = [
60
74
  '-h',
61
75
  '127.0.0.1',
@@ -79,20 +93,20 @@ async function createSqlBackup(
79
93
  })
80
94
 
81
95
  proc.on('error', (err: NodeJS.ErrnoException) => {
82
- reject(err)
96
+ safeReject(err)
83
97
  })
84
98
 
85
99
  proc.on('close', async (code) => {
86
100
  if (code === 0) {
87
101
  const stats = await stat(outputPath)
88
- resolve({
102
+ safeResolve({
89
103
  path: outputPath,
90
104
  format: 'sql',
91
105
  size: stats.size,
92
106
  })
93
107
  } else {
94
108
  const errorMessage = stderr || `mysqldump exited with code ${code}`
95
- reject(new Error(errorMessage))
109
+ safeReject(new Error(errorMessage))
96
110
  }
97
111
  })
98
112
  })
@@ -108,52 +122,55 @@ async function createCompressedBackup(
108
122
  database: string,
109
123
  outputPath: string,
110
124
  ): 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
+ const args = [
126
+ '-h',
127
+ '127.0.0.1',
128
+ '-P',
129
+ String(port),
130
+ '-u',
131
+ engineDef.superuser,
132
+ database,
133
+ ]
134
+
135
+ const proc = spawn(mysqldump, args, {
136
+ stdio: ['pipe', 'pipe', 'pipe'],
137
+ })
125
138
 
126
- const gzip = createGzip()
127
- const output = createWriteStream(outputPath)
139
+ const gzip = createGzip()
140
+ const output = createWriteStream(outputPath)
128
141
 
129
- let stderr = ''
142
+ let stderr = ''
130
143
 
131
- proc.stderr?.on('data', (data: Buffer) => {
132
- stderr += data.toString()
133
- })
144
+ proc.stderr?.on('data', (data: Buffer) => {
145
+ stderr += data.toString()
146
+ })
134
147
 
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)
148
+ // Create promise for pipeline completion
149
+ const pipelinePromise = pipeline(proc.stdout!, gzip, output)
146
150
 
151
+ // Create promise for process exit
152
+ const exitPromise = new Promise<void>((resolve, reject) => {
147
153
  proc.on('error', (err: NodeJS.ErrnoException) => {
148
154
  reject(err)
149
155
  })
150
156
 
151
157
  proc.on('close', (code) => {
152
- if (code !== 0) {
158
+ if (code === 0) {
159
+ resolve()
160
+ } else {
153
161
  const errorMessage = stderr || `mysqldump exited with code ${code}`
154
162
  reject(new Error(errorMessage))
155
163
  }
156
- // If code is 0, the pipeline promise will resolve
157
164
  })
158
165
  })
166
+
167
+ // Wait for both pipeline AND process exit to succeed
168
+ await Promise.all([pipelinePromise, exitPromise])
169
+
170
+ const stats = await stat(outputPath)
171
+ return {
172
+ path: outputPath,
173
+ format: 'compressed',
174
+ size: stats.size,
175
+ }
159
176
  }
@@ -6,7 +6,7 @@
6
6
  import { spawn, exec } from 'child_process'
7
7
  import { promisify } from 'util'
8
8
  import { existsSync, createReadStream } from 'fs'
9
- import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
9
+ import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises'
10
10
  import { join } from 'path'
11
11
  import { BaseEngine } from '../base-engine'
12
12
  import { paths } from '../../config/paths'
@@ -16,6 +16,7 @@ import {
16
16
  logWarning,
17
17
  ErrorCodes,
18
18
  SpinDBError,
19
+ assertValidDatabaseName,
19
20
  } from '../../core/error-handler'
20
21
  import {
21
22
  getMysqldPath,
@@ -135,9 +136,27 @@ export class MySQLEngine extends BaseEngine {
135
136
  engine: ENGINE,
136
137
  })
137
138
 
139
+ // Track if we created the directory (for cleanup on failure)
140
+ let createdDataDir = false
141
+
138
142
  // Create data directory if it doesn't exist
139
143
  if (!existsSync(dataDir)) {
140
144
  await mkdir(dataDir, { recursive: true })
145
+ createdDataDir = true
146
+ }
147
+
148
+ // Helper to clean up on failure
149
+ const cleanupOnFailure = async () => {
150
+ if (createdDataDir) {
151
+ try {
152
+ await rm(dataDir, { recursive: true, force: true })
153
+ logDebug(`Cleaned up data directory after init failure: ${dataDir}`)
154
+ } catch (cleanupErr) {
155
+ logDebug(
156
+ `Failed to clean up data directory: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
157
+ )
158
+ }
159
+ }
141
160
  }
142
161
 
143
162
  // Check if we're using MariaDB or MySQL
@@ -148,6 +167,7 @@ export class MySQLEngine extends BaseEngine {
148
167
  const installDb =
149
168
  (await getMariadbInstallDbPath()) || (await getMysqlInstallDbPath())
150
169
  if (!installDb) {
170
+ await cleanupOnFailure()
151
171
  throw new Error(
152
172
  'MariaDB detected but mysql_install_db not found.\n' +
153
173
  'Install MariaDB server package which includes the initialization script.',
@@ -177,10 +197,11 @@ export class MySQLEngine extends BaseEngine {
177
197
  stderr += data.toString()
178
198
  })
179
199
 
180
- proc.on('close', (code) => {
200
+ proc.on('close', async (code) => {
181
201
  if (code === 0) {
182
202
  resolve(dataDir)
183
203
  } else {
204
+ await cleanupOnFailure()
184
205
  reject(
185
206
  new Error(
186
207
  `MariaDB initialization failed with code ${code}: ${stderr || stdout}`,
@@ -189,12 +210,16 @@ export class MySQLEngine extends BaseEngine {
189
210
  }
190
211
  })
191
212
 
192
- proc.on('error', reject)
213
+ proc.on('error', async (err) => {
214
+ await cleanupOnFailure()
215
+ reject(err)
216
+ })
193
217
  })
194
218
  } else {
195
219
  // MySQL uses mysqld --initialize-insecure
196
220
  const mysqld = await getMysqldPath()
197
221
  if (!mysqld) {
222
+ await cleanupOnFailure()
198
223
  throw new Error(getInstallInstructions())
199
224
  }
200
225
 
@@ -221,10 +246,11 @@ export class MySQLEngine extends BaseEngine {
221
246
  stderr += data.toString()
222
247
  })
223
248
 
224
- proc.on('close', (code) => {
249
+ proc.on('close', async (code) => {
225
250
  if (code === 0) {
226
251
  resolve(dataDir)
227
252
  } else {
253
+ await cleanupOnFailure()
228
254
  reject(
229
255
  new Error(
230
256
  `MySQL initialization failed with code ${code}: ${stderr || stdout}`,
@@ -233,7 +259,10 @@ export class MySQLEngine extends BaseEngine {
233
259
  }
234
260
  })
235
261
 
236
- proc.on('error', reject)
262
+ proc.on('error', async (err) => {
263
+ await cleanupOnFailure()
264
+ reject(err)
265
+ })
237
266
  })
238
267
  }
239
268
  }
@@ -313,6 +342,14 @@ export class MySQLEngine extends BaseEngine {
313
342
  connectionString: this.getConnectionString(container),
314
343
  })
315
344
  return
345
+ } else {
346
+ // mysqladmin not found - cannot verify MySQL is ready
347
+ reject(
348
+ new Error(
349
+ 'mysqladmin not found - cannot verify MySQL startup. Install MySQL client tools.',
350
+ ),
351
+ )
352
+ return
316
353
  }
317
354
  } catch {
318
355
  if (attempts < maxAttempts) {
@@ -687,6 +724,7 @@ export class MySQLEngine extends BaseEngine {
687
724
  container: ContainerConfig,
688
725
  database: string,
689
726
  ): Promise<void> {
727
+ assertValidDatabaseName(database)
690
728
  const { port } = container
691
729
 
692
730
  const mysql = await getMysqlClientPath()
@@ -720,6 +758,7 @@ export class MySQLEngine extends BaseEngine {
720
758
  container: ContainerConfig,
721
759
  database: string,
722
760
  ): Promise<void> {
761
+ assertValidDatabaseName(database)
723
762
  const { port } = container
724
763
 
725
764
  const mysql = await getMysqlClientPath()
@@ -748,6 +787,9 @@ export class MySQLEngine extends BaseEngine {
748
787
  const { port, database } = container
749
788
  const db = database || 'mysql'
750
789
 
790
+ // Validate database name to prevent SQL injection
791
+ assertValidDatabaseName(db)
792
+
751
793
  try {
752
794
  const mysql = await getMysqlClientPath()
753
795
  if (!mysql) return null
@@ -856,6 +898,7 @@ export class MySQLEngine extends BaseEngine {
856
898
  ): Promise<void> {
857
899
  const { port } = container
858
900
  const db = options.database || container.database || 'mysql'
901
+ assertValidDatabaseName(db)
859
902
 
860
903
  const mysql = await getMysqlClientPath()
861
904
  if (!mysql) {
@@ -18,6 +18,7 @@ import {
18
18
  } from './binary-urls'
19
19
  import { detectBackupFormat, restoreBackup } from './restore'
20
20
  import { createBackup } from './backup'
21
+ import { assertValidDatabaseName } from '../../core/error-handler'
21
22
  import type {
22
23
  ContainerConfig,
23
24
  ProgressCallback,
@@ -395,6 +396,7 @@ export class PostgreSQLEngine extends BaseEngine {
395
396
  container: ContainerConfig,
396
397
  database: string,
397
398
  ): Promise<void> {
399
+ assertValidDatabaseName(database)
398
400
  const { port } = container
399
401
  const psqlPath = await this.getPsqlPath()
400
402
 
@@ -418,6 +420,7 @@ export class PostgreSQLEngine extends BaseEngine {
418
420
  container: ContainerConfig,
419
421
  database: string,
420
422
  ): Promise<void> {
423
+ assertValidDatabaseName(database)
421
424
  const { port } = container
422
425
  const psqlPath = await this.getPsqlPath()
423
426
 
@@ -443,6 +446,9 @@ export class PostgreSQLEngine extends BaseEngine {
443
446
  const { port, database } = container
444
447
  const db = database || 'postgres'
445
448
 
449
+ // Validate database name to prevent SQL injection
450
+ assertValidDatabaseName(db)
451
+
446
452
  try {
447
453
  const psqlPath = await this.getPsqlPath()
448
454
  // Query pg_database_size for the specific database
@@ -98,6 +98,7 @@ export class SQLiteEngine extends BaseEngine {
98
98
 
99
99
  // Check system PATH
100
100
  try {
101
+ // TODO - update when windows support is added
101
102
  const { stdout } = await execFileAsync('which', ['sqlite3'])
102
103
  const path = stdout.trim()
103
104
  return path || null
@@ -184,7 +185,9 @@ export class SQLiteEngine extends BaseEngine {
184
185
  ): Promise<{ port: number; connectionString: string }> {
185
186
  const entry = await sqliteRegistry.get(container.name)
186
187
  if (!entry) {
187
- throw new Error(`SQLite container "${container.name}" not found in registry`)
188
+ throw new Error(
189
+ `SQLite container "${container.name}" not found in registry`,
190
+ )
188
191
  }
189
192
  if (!existsSync(entry.filePath)) {
190
193
  throw new Error(`SQLite database file not found: ${entry.filePath}`)
@@ -243,7 +246,9 @@ export class SQLiteEngine extends BaseEngine {
243
246
  async connect(container: ContainerConfig, _database?: string): Promise<void> {
244
247
  const entry = await sqliteRegistry.get(container.name)
245
248
  if (!entry) {
246
- throw new Error(`SQLite container "${container.name}" not found in registry`)
249
+ throw new Error(
250
+ `SQLite container "${container.name}" not found in registry`,
251
+ )
247
252
  }
248
253
  if (!existsSync(entry.filePath)) {
249
254
  throw new Error(`SQLite database file not found: ${entry.filePath}`)
@@ -521,7 +526,9 @@ export class SQLiteEngine extends BaseEngine {
521
526
  if (code === 0) {
522
527
  resolve()
523
528
  } else {
524
- reject(new Error(`sqlite3 script execution failed with exit code ${code}`))
529
+ reject(
530
+ new Error(`sqlite3 script execution failed with exit code ${code}`),
531
+ )
525
532
  }
526
533
  })
527
534
  })
@@ -533,7 +540,9 @@ export class SQLiteEngine extends BaseEngine {
533
540
  private async downloadFile(url: string, destPath: string): Promise<void> {
534
541
  const response = await fetch(url)
535
542
  if (!response.ok) {
536
- throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
543
+ throw new Error(
544
+ `Failed to download: ${response.status} ${response.statusText}`,
545
+ )
537
546
  }
538
547
 
539
548
  const buffer = await response.arrayBuffer()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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": {