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.
@@ -11,13 +11,24 @@ import {
11
11
  getInstalledEngines,
12
12
  type InstalledPostgresEngine,
13
13
  type InstalledMysqlEngine,
14
+ type InstalledSqliteEngine,
14
15
  } from '../../helpers'
15
16
  import {
16
17
  getMysqlVersion,
17
18
  getMysqlInstallInfo,
18
19
  } from '../../../engines/mysql/binary-detection'
20
+
19
21
  import { type MenuChoice } from './shared'
20
22
 
23
+ /**
24
+ * Pad string to width, accounting for emoji taking 2 display columns
25
+ */
26
+ function padWithEmoji(str: string, width: number): string {
27
+ // Count emojis using Extended_Pictographic (excludes digits/symbols that \p{Emoji} matches)
28
+ const emojiCount = (str.match(/\p{Extended_Pictographic}/gu) || []).length
29
+ return str.padEnd(width + emojiCount)
30
+ }
31
+
21
32
  export async function handleEngines(): Promise<void> {
22
33
  console.clear()
23
34
  console.log(header('Installed Engines'))
@@ -46,6 +57,9 @@ export async function handleEngines(): Promise<void> {
46
57
  const mysqlEngine = engines.find(
47
58
  (e): e is InstalledMysqlEngine => e.engine === 'mysql',
48
59
  )
60
+ const sqliteEngine = engines.find(
61
+ (e): e is InstalledSqliteEngine => e.engine === 'sqlite',
62
+ )
49
63
 
50
64
  const totalPgSize = pgEngines.reduce((acc, e) => acc + e.sizeBytes, 0)
51
65
 
@@ -62,10 +76,11 @@ export async function handleEngines(): Promise<void> {
62
76
  for (const engine of pgEngines) {
63
77
  const icon = getEngineIcon(engine.engine)
64
78
  const platformInfo = `${engine.platform}-${engine.arch}`
79
+ const engineDisplay = `${icon} ${engine.engine}`
65
80
 
66
81
  console.log(
67
82
  chalk.gray(' ') +
68
- chalk.cyan(`${icon} ${engine.engine}`.padEnd(13)) +
83
+ chalk.cyan(padWithEmoji(engineDisplay, 13)) +
69
84
  chalk.yellow(engine.version.padEnd(12)) +
70
85
  chalk.gray(platformInfo.padEnd(18)) +
71
86
  chalk.white(formatBytes(engine.sizeBytes)),
@@ -75,16 +90,30 @@ export async function handleEngines(): Promise<void> {
75
90
  if (mysqlEngine) {
76
91
  const icon = ENGINE_ICONS.mysql
77
92
  const displayName = mysqlEngine.isMariaDB ? 'mariadb' : 'mysql'
93
+ const engineDisplay = `${icon} ${displayName}`
78
94
 
79
95
  console.log(
80
96
  chalk.gray(' ') +
81
- chalk.cyan(`${icon} ${displayName}`.padEnd(13)) +
97
+ chalk.cyan(padWithEmoji(engineDisplay, 13)) +
82
98
  chalk.yellow(mysqlEngine.version.padEnd(12)) +
83
99
  chalk.gray('system'.padEnd(18)) +
84
100
  chalk.gray('(system-installed)'),
85
101
  )
86
102
  }
87
103
 
104
+ if (sqliteEngine) {
105
+ const icon = ENGINE_ICONS.sqlite
106
+ const engineDisplay = `${icon} sqlite`
107
+
108
+ console.log(
109
+ chalk.gray(' ') +
110
+ chalk.cyan(padWithEmoji(engineDisplay, 13)) +
111
+ chalk.yellow(sqliteEngine.version.padEnd(12)) +
112
+ chalk.gray('system'.padEnd(18)) +
113
+ chalk.gray('(system-installed)'),
114
+ )
115
+ }
116
+
88
117
  console.log(chalk.gray(' ' + '─'.repeat(55)))
89
118
 
90
119
  console.log()
@@ -98,6 +127,9 @@ export async function handleEngines(): Promise<void> {
98
127
  if (mysqlEngine) {
99
128
  console.log(chalk.gray(` MySQL: system-installed at ${mysqlEngine.path}`))
100
129
  }
130
+ if (sqliteEngine) {
131
+ console.log(chalk.gray(` SQLite: system-installed at ${sqliteEngine.path}`))
132
+ }
101
133
  console.log()
102
134
 
103
135
  const choices: MenuChoice[] = []
@@ -117,6 +149,13 @@ export async function handleEngines(): Promise<void> {
117
149
  })
118
150
  }
119
151
 
152
+ if (sqliteEngine) {
153
+ choices.push({
154
+ name: `${chalk.blue('ℹ')} SQLite ${sqliteEngine.version} ${chalk.gray('(system-installed)')}`,
155
+ value: `sqlite-info:${sqliteEngine.path}`,
156
+ })
157
+ }
158
+
120
159
  choices.push(new inquirer.Separator())
121
160
  choices.push({ name: `${chalk.blue('←')} Back to main menu`, value: 'back' })
122
161
 
@@ -135,16 +174,29 @@ export async function handleEngines(): Promise<void> {
135
174
  }
136
175
 
137
176
  if (action.startsWith('delete:')) {
138
- const [, enginePath, engineName, engineVersion] = action.split(':')
177
+ // Parse from the end to preserve colons in path
178
+ // Format: delete:path:engineName:engineVersion
179
+ const withoutPrefix = action.slice('delete:'.length)
180
+ const lastColon = withoutPrefix.lastIndexOf(':')
181
+ const secondLastColon = withoutPrefix.lastIndexOf(':', lastColon - 1)
182
+ const enginePath = withoutPrefix.slice(0, secondLastColon)
183
+ const engineName = withoutPrefix.slice(secondLastColon + 1, lastColon)
184
+ const engineVersion = withoutPrefix.slice(lastColon + 1)
139
185
  await handleDeleteEngine(enginePath, engineName, engineVersion)
140
186
  await handleEngines()
141
187
  }
142
188
 
143
189
  if (action.startsWith('mysql-info:')) {
144
- const mysqldPath = action.replace('mysql-info:', '')
190
+ const mysqldPath = action.slice('mysql-info:'.length)
145
191
  await handleMysqlInfo(mysqldPath)
146
192
  await handleEngines()
147
193
  }
194
+
195
+ if (action.startsWith('sqlite-info:')) {
196
+ const sqlitePath = action.slice('sqlite-info:'.length)
197
+ await handleSqliteInfo(sqlitePath)
198
+ await handleEngines()
199
+ }
148
200
  }
149
201
 
150
202
  async function handleDeleteEngine(
@@ -360,3 +412,75 @@ async function handleMysqlInfo(mysqldPath: string): Promise<void> {
360
412
  },
361
413
  ])
362
414
  }
415
+
416
+ async function handleSqliteInfo(sqlitePath: string): Promise<void> {
417
+ console.clear()
418
+
419
+ console.log(header('SQLite Information'))
420
+ console.log()
421
+
422
+ // Get version
423
+ let version = 'unknown'
424
+ try {
425
+ const { exec } = await import('child_process')
426
+ const { promisify } = await import('util')
427
+ const execAsync = promisify(exec)
428
+ const { stdout } = await execAsync(`"${sqlitePath}" --version`)
429
+ const match = stdout.match(/^([\d.]+)/)
430
+ if (match) {
431
+ version = match[1]
432
+ }
433
+ } catch {
434
+ // Ignore
435
+ }
436
+
437
+ const containers = await containerManager.list()
438
+ const sqliteContainers = containers.filter((c) => c.engine === 'sqlite')
439
+
440
+ if (sqliteContainers.length > 0) {
441
+ console.log(info(`${sqliteContainers.length} SQLite database(s) registered:`))
442
+ console.log()
443
+ for (const c of sqliteContainers) {
444
+ const status =
445
+ c.status === 'running'
446
+ ? chalk.blue('🔵 available')
447
+ : chalk.gray('⚪ missing')
448
+ console.log(chalk.gray(` • ${c.name} ${status}`))
449
+ }
450
+ console.log()
451
+ }
452
+
453
+ console.log(chalk.white(' Installation Details:'))
454
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
455
+ console.log(
456
+ chalk.gray(' ') +
457
+ chalk.white('Version:'.padEnd(18)) +
458
+ chalk.yellow(version),
459
+ )
460
+ console.log(
461
+ chalk.gray(' ') +
462
+ chalk.white('Binary Path:'.padEnd(18)) +
463
+ chalk.gray(sqlitePath),
464
+ )
465
+ console.log(
466
+ chalk.gray(' ') +
467
+ chalk.white('Type:'.padEnd(18)) +
468
+ chalk.cyan('Embedded (file-based)'),
469
+ )
470
+ console.log()
471
+
472
+ console.log(chalk.white(' Notes:'))
473
+ console.log(chalk.gray(' ' + '─'.repeat(50)))
474
+ console.log(chalk.gray(' • SQLite is typically pre-installed on macOS and most Linux distributions'))
475
+ console.log(chalk.gray(' • No server process - databases are just files'))
476
+ console.log(chalk.gray(' • Use "spindb delete <name>" to unregister a database'))
477
+ console.log()
478
+
479
+ await inquirer.prompt([
480
+ {
481
+ type: 'input',
482
+ name: 'continue',
483
+ message: chalk.gray('Press Enter to go back...'),
484
+ },
485
+ ])
486
+ }
@@ -16,6 +16,8 @@ import { tmpdir } from 'os'
16
16
  import { join } from 'path'
17
17
  import { getMissingDependencies } from '../../core/dependency-manager'
18
18
  import { platformService } from '../../core/platform-service'
19
+ import { TransactionManager } from '../../core/transaction-manager'
20
+ import { logDebug } from '../../core/error-handler'
19
21
 
20
22
  export const restoreCommand = new Command('restore')
21
23
  .description('Restore a backup to a container')
@@ -245,41 +247,99 @@ export const restoreCommand = new Command('restore')
245
247
  const format = await engine.detectBackupFormat(backupPath)
246
248
  detectSpinner.succeed(`Detected: ${format.description}`)
247
249
 
250
+ // Use TransactionManager to ensure database is cleaned up on restore failure
251
+ const tx = new TransactionManager()
252
+ let databaseCreated = false
253
+
248
254
  const dbSpinner = createSpinner(
249
255
  `Creating database "${databaseName}"...`,
250
256
  )
251
257
  dbSpinner.start()
252
258
 
253
- await engine.createDatabase(config, databaseName)
254
- dbSpinner.succeed(`Database "${databaseName}" ready`)
259
+ try {
260
+ await engine.createDatabase(config, databaseName)
261
+ databaseCreated = true
262
+ dbSpinner.succeed(`Database "${databaseName}" ready`)
263
+
264
+ // Register rollback to drop database if restore fails
265
+ tx.addRollback({
266
+ description: `Drop database "${databaseName}"`,
267
+ execute: async () => {
268
+ try {
269
+ await engine.dropDatabase(config, databaseName)
270
+ logDebug(`Rolled back: dropped database "${databaseName}"`)
271
+ } catch (dropErr) {
272
+ logDebug(
273
+ `Failed to drop database during rollback: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`,
274
+ )
275
+ }
276
+ },
277
+ })
278
+
279
+ await containerManager.addDatabase(containerName, databaseName)
280
+
281
+ // Register rollback to remove database from container tracking
282
+ tx.addRollback({
283
+ description: `Remove "${databaseName}" from container tracking`,
284
+ execute: async () => {
285
+ try {
286
+ await containerManager.removeDatabase(
287
+ containerName,
288
+ databaseName,
289
+ )
290
+ logDebug(
291
+ `Rolled back: removed "${databaseName}" from container tracking`,
292
+ )
293
+ } catch (removeErr) {
294
+ logDebug(
295
+ `Failed to remove database from tracking during rollback: ${removeErr instanceof Error ? removeErr.message : String(removeErr)}`,
296
+ )
297
+ }
298
+ },
299
+ })
255
300
 
256
- await containerManager.addDatabase(containerName, databaseName)
301
+ const restoreSpinner = createSpinner('Restoring backup...')
302
+ restoreSpinner.start()
257
303
 
258
- const restoreSpinner = createSpinner('Restoring backup...')
259
- restoreSpinner.start()
304
+ const result = await engine.restore(config, backupPath, {
305
+ database: databaseName,
306
+ createDatabase: false,
307
+ })
260
308
 
261
- const result = await engine.restore(config, backupPath, {
262
- database: databaseName,
263
- createDatabase: false,
264
- })
309
+ // Check if restore completely failed (non-zero code with no data restored)
310
+ if (result.code !== 0 && result.stderr?.includes('FATAL')) {
311
+ restoreSpinner.fail('Restore failed')
312
+ throw new Error(result.stderr || 'Restore failed with fatal error')
313
+ }
265
314
 
266
- if (result.code === 0 || !result.stderr) {
267
- restoreSpinner.succeed('Backup restored successfully')
268
- } else {
269
- // pg_restore often returns warnings even on success
270
- restoreSpinner.warn('Restore completed with warnings')
271
- if (result.stderr) {
272
- console.log(chalk.yellow('\n Warnings:'))
273
- const lines = result.stderr.split('\n').slice(0, 5)
274
- lines.forEach((line) => {
275
- if (line.trim()) {
276
- console.log(chalk.gray(` ${line}`))
315
+ if (result.code === 0) {
316
+ restoreSpinner.succeed('Backup restored successfully')
317
+ } else {
318
+ // pg_restore often returns warnings even on success
319
+ restoreSpinner.warn('Restore completed with warnings')
320
+ if (result.stderr) {
321
+ console.log(chalk.yellow('\n Warnings:'))
322
+ const lines = result.stderr.split('\n').slice(0, 5)
323
+ lines.forEach((line) => {
324
+ if (line.trim()) {
325
+ console.log(chalk.gray(` ${line}`))
326
+ }
327
+ })
328
+ if (result.stderr.split('\n').length > 5) {
329
+ console.log(chalk.gray(' ...'))
277
330
  }
278
- })
279
- if (result.stderr.split('\n').length > 5) {
280
- console.log(chalk.gray(' ...'))
281
331
  }
282
332
  }
333
+
334
+ // Restore succeeded - commit transaction (clear rollback actions)
335
+ tx.commit()
336
+ } catch (restoreErr) {
337
+ // Restore failed - execute rollbacks to clean up created database
338
+ if (databaseCreated) {
339
+ console.log(chalk.yellow('\n Cleaning up after failed restore...'))
340
+ await tx.rollback()
341
+ }
342
+ throw restoreErr
283
343
  }
284
344
 
285
345
  const connectionString = engine.getConnectionString(
package/cli/helpers.ts CHANGED
@@ -30,7 +30,17 @@ export type InstalledMysqlEngine = {
30
30
  isMariaDB: boolean
31
31
  }
32
32
 
33
- export type InstalledEngine = InstalledPostgresEngine | InstalledMysqlEngine
33
+ export type InstalledSqliteEngine = {
34
+ engine: 'sqlite'
35
+ version: string
36
+ path: string
37
+ source: 'system'
38
+ }
39
+
40
+ export type InstalledEngine =
41
+ | InstalledPostgresEngine
42
+ | InstalledMysqlEngine
43
+ | InstalledSqliteEngine
34
44
 
35
45
  async function getPostgresVersion(binPath: string): Promise<string | null> {
36
46
  const postgresPath = join(binPath, 'bin', 'postgres')
@@ -125,6 +135,31 @@ async function getInstalledMysqlEngine(): Promise<InstalledMysqlEngine | null> {
125
135
  }
126
136
  }
127
137
 
138
+ async function getInstalledSqliteEngine(): Promise<InstalledSqliteEngine | null> {
139
+ try {
140
+ // TODO: Use 'where sqlite3' on Windows when adding Windows support
141
+ const { stdout: whichOutput } = await execAsync('which sqlite3')
142
+ const sqlitePath = whichOutput.trim()
143
+ if (!sqlitePath) {
144
+ return null
145
+ }
146
+
147
+ const { stdout: versionOutput } = await execAsync(`"${sqlitePath}" --version`)
148
+ // sqlite3 --version outputs: "3.43.2 2023-10-10 12:14:04 ..."
149
+ const versionMatch = versionOutput.match(/^([\d.]+)/)
150
+ const version = versionMatch ? versionMatch[1] : 'unknown'
151
+
152
+ return {
153
+ engine: 'sqlite',
154
+ version,
155
+ path: sqlitePath,
156
+ source: 'system',
157
+ }
158
+ } catch {
159
+ return null
160
+ }
161
+ }
162
+
128
163
  export function compareVersions(a: string, b: string): number {
129
164
  const partsA = a.split('.').map((p) => parseInt(p, 10) || 0)
130
165
  const partsB = b.split('.').map((p) => parseInt(p, 10) || 0)
@@ -148,5 +183,10 @@ export async function getInstalledEngines(): Promise<InstalledEngine[]> {
148
183
  engines.push(mysqlEngine)
149
184
  }
150
185
 
186
+ const sqliteEngine = await getInstalledSqliteEngine()
187
+ if (sqliteEngine) {
188
+ engines.push(sqliteEngine)
189
+ }
190
+
151
191
  return engines
152
192
  }
package/cli/ui/prompts.ts CHANGED
@@ -258,7 +258,8 @@ export async function promptContainerSelect(
258
258
  */
259
259
  function sanitizeDatabaseName(name: string): string {
260
260
  // Replace invalid characters with underscores
261
- let sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
261
+ // Note: hyphens are excluded because they require quoting in SQL
262
+ let sanitized = name.replace(/[^a-zA-Z0-9_]/g, '_')
262
263
  // Ensure it starts with a letter or underscore
263
264
  if (sanitized && !/^[a-zA-Z_]/.test(sanitized)) {
264
265
  sanitized = '_' + sanitized
@@ -267,6 +268,10 @@ function sanitizeDatabaseName(name: string): string {
267
268
  sanitized = sanitized.replace(/_+/g, '_')
268
269
  // Trim trailing underscores
269
270
  sanitized = sanitized.replace(/_+$/, '')
271
+ // Fallback if result is empty (e.g., input was "---")
272
+ if (!sanitized) {
273
+ sanitized = 'db'
274
+ }
270
275
  return sanitized
271
276
  }
272
277
 
@@ -295,8 +300,9 @@ export async function promptDatabaseName(
295
300
  validate: (input: string) => {
296
301
  if (!input) return 'Database name is required'
297
302
  // PostgreSQL database naming rules (also valid for MySQL)
298
- if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(input)) {
299
- return 'Database name must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens'
303
+ // Hyphens excluded to avoid requiring quoted identifiers in SQL
304
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) {
305
+ return 'Database name must start with a letter or underscore and contain only letters, numbers, and underscores'
300
306
  }
301
307
  if (input.length > 63) {
302
308
  return 'Database name must be 63 characters or less'
@@ -1,5 +1,14 @@
1
1
  import { existsSync } from 'fs'
2
- import { mkdir, readdir, readFile, writeFile, rm, cp, unlink } from 'fs/promises'
2
+ import {
3
+ mkdir,
4
+ readdir,
5
+ readFile,
6
+ writeFile,
7
+ rm,
8
+ cp,
9
+ unlink,
10
+ rename as fsRename,
11
+ } from 'fs/promises'
3
12
  import { paths } from '../config/paths'
4
13
  import { processManager } from './process-manager'
5
14
  import { portManager } from './port-manager'
@@ -76,7 +85,7 @@ export class ContainerManager {
76
85
 
77
86
  if (engine) {
78
87
  // SQLite uses registry instead of filesystem
79
- if (engine === 'sqlite') {
88
+ if (engine === Engine.SQLite) {
80
89
  return this.getSqliteConfig(name)
81
90
  }
82
91
 
@@ -201,7 +210,7 @@ export class ContainerManager {
201
210
 
202
211
  if (engine) {
203
212
  // SQLite uses registry
204
- if (engine === 'sqlite') {
213
+ if (engine === Engine.SQLite) {
205
214
  return sqliteRegistry.exists(name)
206
215
  }
207
216
  const configPath = paths.getContainerConfigPath(name, { engine })
@@ -366,26 +375,35 @@ export class ContainerManager {
366
375
 
367
376
  await cp(sourcePath, targetPath, { recursive: true })
368
377
 
369
- // Update target config
370
- const config = await this.getConfig(targetName, { engine })
371
- if (!config) {
372
- throw new Error('Failed to read cloned container config')
373
- }
378
+ // If anything fails after copy, clean up the target directory
379
+ try {
380
+ // Update target config
381
+ const config = await this.getConfig(targetName, { engine })
382
+ if (!config) {
383
+ throw new Error('Failed to read cloned container config')
384
+ }
374
385
 
375
- config.name = targetName
376
- config.created = new Date().toISOString()
377
- config.clonedFrom = sourceName
386
+ config.name = targetName
387
+ config.created = new Date().toISOString()
388
+ config.clonedFrom = sourceName
378
389
 
379
- // Assign new port (excluding ports already used by other containers)
380
- const engineDefaults = getEngineDefaults(engine)
381
- const { port } = await portManager.findAvailablePortExcludingContainers({
382
- portRange: engineDefaults.portRange,
383
- })
384
- config.port = port
390
+ // Assign new port (excluding ports already used by other containers)
391
+ const engineDefaults = getEngineDefaults(engine)
392
+ const { port } = await portManager.findAvailablePortExcludingContainers({
393
+ portRange: engineDefaults.portRange,
394
+ })
395
+ config.port = port
385
396
 
386
- await this.saveConfig(targetName, { engine }, config)
397
+ await this.saveConfig(targetName, { engine }, config)
387
398
 
388
- return config
399
+ return config
400
+ } catch (err) {
401
+ // Clean up the copied directory on failure
402
+ await rm(targetPath, { recursive: true, force: true }).catch(() => {
403
+ // Ignore cleanup errors
404
+ })
405
+ throw err
406
+ }
389
407
  }
390
408
 
391
409
  /**
@@ -419,7 +437,15 @@ export class ContainerManager {
419
437
  throw new Error(`SQLite container "${oldName}" not found in registry`)
420
438
  }
421
439
 
422
- // Remove old entry and add new one with updated name
440
+ // Move container directory first (if it exists) - do filesystem ops before registry
441
+ // This way if the move fails, registry is unchanged
442
+ const oldContainerPath = paths.getContainerPath(oldName, { engine })
443
+ const newContainerPath = paths.getContainerPath(newName, { engine })
444
+ if (existsSync(oldContainerPath)) {
445
+ await this.atomicMoveDirectory(oldContainerPath, newContainerPath)
446
+ }
447
+
448
+ // Now update registry - remove old entry and add new one with updated name
423
449
  await sqliteRegistry.remove(oldName)
424
450
  await sqliteRegistry.add({
425
451
  name: newName,
@@ -428,14 +454,6 @@ export class ContainerManager {
428
454
  lastVerified: entry.lastVerified,
429
455
  })
430
456
 
431
- // Rename container directory if it exists (created by containerManager.create)
432
- const oldContainerPath = paths.getContainerPath(oldName, { engine })
433
- const newContainerPath = paths.getContainerPath(newName, { engine })
434
- if (existsSync(oldContainerPath)) {
435
- await cp(oldContainerPath, newContainerPath, { recursive: true })
436
- await rm(oldContainerPath, { recursive: true, force: true })
437
- }
438
-
439
457
  // Return updated config
440
458
  return {
441
459
  ...sourceConfig,
@@ -453,8 +471,7 @@ export class ContainerManager {
453
471
  const oldPath = paths.getContainerPath(oldName, { engine })
454
472
  const newPath = paths.getContainerPath(newName, { engine })
455
473
 
456
- await cp(oldPath, newPath, { recursive: true })
457
- await rm(oldPath, { recursive: true, force: true })
474
+ await this.atomicMoveDirectory(oldPath, newPath)
458
475
 
459
476
  // Update config with new name
460
477
  const config = await this.getConfig(newName, { engine })
@@ -468,6 +485,40 @@ export class ContainerManager {
468
485
  return config
469
486
  }
470
487
 
488
+ /**
489
+ * Move a directory atomically when possible, with copy+delete fallback.
490
+ * Uses fs.rename which is atomic on same filesystem, falls back to
491
+ * copy+delete for cross-filesystem moves (with cleanup on failure).
492
+ */
493
+ private async atomicMoveDirectory(
494
+ sourcePath: string,
495
+ targetPath: string,
496
+ ): Promise<void> {
497
+ try {
498
+ // Try atomic rename first (only works on same filesystem)
499
+ await fsRename(sourcePath, targetPath)
500
+ } catch (err) {
501
+ const e = err as NodeJS.ErrnoException
502
+ if (e.code === 'EXDEV') {
503
+ // Cross-filesystem move - fall back to copy+delete
504
+ await cp(sourcePath, targetPath, { recursive: true })
505
+ try {
506
+ await rm(sourcePath, { recursive: true, force: true })
507
+ } catch {
508
+ // If delete fails after copy, we have duplicates
509
+ // Try to clean up the target to avoid inconsistency
510
+ await rm(targetPath, { recursive: true, force: true }).catch(() => {})
511
+ throw new Error(
512
+ `Failed to complete move: source and target may both exist. ` +
513
+ `Please manually remove one of: ${sourcePath} or ${targetPath}`,
514
+ )
515
+ }
516
+ } else {
517
+ throw err
518
+ }
519
+ }
520
+ }
521
+
471
522
  /**
472
523
  * Validate container name
473
524
  */
@@ -56,6 +56,7 @@ export const ErrorCodes = {
56
56
  CONTAINER_CREATE_FAILED: 'CONTAINER_CREATE_FAILED',
57
57
  INIT_FAILED: 'INIT_FAILED',
58
58
  DATABASE_CREATE_FAILED: 'DATABASE_CREATE_FAILED',
59
+ INVALID_DATABASE_NAME: 'INVALID_DATABASE_NAME',
59
60
 
60
61
  // Dependency errors
61
62
  DEPENDENCY_MISSING: 'DEPENDENCY_MISSING',
@@ -308,3 +309,33 @@ export function createDependencyMissingError(
308
309
  { toolName, engine },
309
310
  )
310
311
  }
312
+
313
+ /**
314
+ * Validate a database name to prevent SQL injection.
315
+ * Database names must start with a letter and contain only
316
+ * alphanumeric characters and underscores.
317
+ *
318
+ * Note: Hyphens are excluded because they require quoted identifiers
319
+ * in SQL, which is error-prone for users.
320
+ */
321
+ export function isValidDatabaseName(name: string): boolean {
322
+ // Must start with a letter to be valid in all database systems
323
+ // Hyphens excluded to avoid requiring quoted identifiers in SQL
324
+ return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)
325
+ }
326
+
327
+ /**
328
+ * Assert that a database name is valid, throwing SpinDBError if not.
329
+ * Use this at the entry points where database names are accepted.
330
+ */
331
+ export function assertValidDatabaseName(name: string): void {
332
+ if (!isValidDatabaseName(name)) {
333
+ throw new SpinDBError(
334
+ ErrorCodes.INVALID_DATABASE_NAME,
335
+ `Invalid database name: "${name}"`,
336
+ 'error',
337
+ 'Database names must start with a letter and contain only letters, numbers, and underscores',
338
+ { databaseName: name },
339
+ )
340
+ }
341
+ }
@@ -27,6 +27,8 @@ export class PortManager {
27
27
  const server = net.createServer()
28
28
 
29
29
  server.once('error', (err: NodeJS.ErrnoException) => {
30
+ // Always close the server to prevent resource leaks
31
+ server.close()
30
32
  if (err.code === 'EADDRINUSE') {
31
33
  resolve(false)
32
34
  } else {