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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Start with Retry
3
+ *
4
+ * Handles port race conditions by automatically retrying with a new port
5
+ * when the original port becomes unavailable between check and bind.
6
+ */
7
+
8
+ import { portManager } from './port-manager'
9
+ import { containerManager } from './container-manager'
10
+ import { logWarning, logDebug } from './error-handler'
11
+ import type { BaseEngine } from '../engines/base-engine'
12
+ import type { ContainerConfig } from '../types'
13
+
14
+ // =============================================================================
15
+ // Types
16
+ // =============================================================================
17
+
18
+ export type StartWithRetryOptions = {
19
+ engine: BaseEngine
20
+ config: ContainerConfig
21
+ maxRetries?: number // Default: 3
22
+ onPortChange?: (oldPort: number, newPort: number) => void
23
+ }
24
+
25
+ export type StartWithRetryResult = {
26
+ success: boolean
27
+ finalPort: number
28
+ retriesUsed: number
29
+ error?: Error
30
+ }
31
+
32
+ // =============================================================================
33
+ // Port Error Detection
34
+ // =============================================================================
35
+
36
+ /**
37
+ * Check if an error is a port-in-use error
38
+ */
39
+ function isPortInUseError(err: unknown): boolean {
40
+ const message = (err as Error)?.message?.toLowerCase() || ''
41
+ return (
42
+ message.includes('address already in use') ||
43
+ message.includes('eaddrinuse') ||
44
+ (message.includes('port') && message.includes('in use')) ||
45
+ message.includes('could not bind') ||
46
+ message.includes('socket already in use')
47
+ )
48
+ }
49
+
50
+ // =============================================================================
51
+ // Start with Retry Implementation
52
+ // =============================================================================
53
+
54
+ /**
55
+ * Start a database container with automatic port retry on conflict
56
+ *
57
+ * This handles the race condition where a port is available when checked
58
+ * but taken by the time the database server tries to bind to it.
59
+ */
60
+ export async function startWithRetry(
61
+ options: StartWithRetryOptions,
62
+ ): Promise<StartWithRetryResult> {
63
+ const { engine, config, maxRetries = 3, onPortChange } = options
64
+
65
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
66
+ try {
67
+ logDebug(`Starting ${engine.name} (attempt ${attempt}/${maxRetries})`, {
68
+ containerName: config.name,
69
+ port: config.port,
70
+ })
71
+
72
+ await engine.start(config)
73
+
74
+ return {
75
+ success: true,
76
+ finalPort: config.port,
77
+ retriesUsed: attempt - 1,
78
+ }
79
+ } catch (err) {
80
+ const isPortError = isPortInUseError(err)
81
+
82
+ logDebug(`Start attempt ${attempt} failed`, {
83
+ containerName: config.name,
84
+ port: config.port,
85
+ isPortError,
86
+ error: err instanceof Error ? err.message : String(err),
87
+ })
88
+
89
+ if (isPortError && attempt < maxRetries) {
90
+ const oldPort = config.port
91
+
92
+ // Find a new available port, excluding the one that just failed
93
+ const { port: newPort } = await portManager.findAvailablePort({
94
+ portRange: getEnginePortRange(config.engine),
95
+ })
96
+
97
+ // Update config with new port
98
+ config.port = newPort
99
+ await containerManager.updateConfig(config.name, { port: newPort })
100
+
101
+ // Notify caller of port change
102
+ if (onPortChange) {
103
+ onPortChange(oldPort, newPort)
104
+ }
105
+
106
+ // Log and retry
107
+ logWarning(
108
+ `Port ${oldPort} is in use, retrying with port ${newPort}...`,
109
+ )
110
+ continue
111
+ }
112
+
113
+ // Not a port error or max retries exceeded
114
+ return {
115
+ success: false,
116
+ finalPort: config.port,
117
+ retriesUsed: attempt - 1,
118
+ error: err instanceof Error ? err : new Error(String(err)),
119
+ }
120
+ }
121
+ }
122
+
123
+ // Should never reach here, but TypeScript needs a return
124
+ return {
125
+ success: false,
126
+ finalPort: config.port,
127
+ retriesUsed: maxRetries,
128
+ error: new Error('Max retries exceeded'),
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get the port range for an engine
134
+ */
135
+ function getEnginePortRange(engine: string): { start: number; end: number } {
136
+ // Import defaults dynamically to avoid circular dependency
137
+ // These are the standard ranges from config/defaults.ts
138
+ if (engine === 'postgresql') {
139
+ return { start: 5432, end: 5500 }
140
+ }
141
+ if (engine === 'mysql') {
142
+ return { start: 3306, end: 3400 }
143
+ }
144
+ // Default fallback range
145
+ return { start: 5432, end: 6000 }
146
+ }
147
+
148
+ /**
149
+ * Wrapper that simplifies the common use case
150
+ */
151
+ export async function startContainerWithRetry(
152
+ engine: BaseEngine,
153
+ config: ContainerConfig,
154
+ options?: {
155
+ onPortChange?: (oldPort: number, newPort: number) => void
156
+ },
157
+ ): Promise<void> {
158
+ const result = await startWithRetry({
159
+ engine,
160
+ config,
161
+ onPortChange: options?.onPortChange,
162
+ })
163
+
164
+ if (!result.success && result.error) {
165
+ throw result.error
166
+ }
167
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Transaction Manager
3
+ *
4
+ * Provides rollback support for multi-step operations like container creation.
5
+ * If any step fails, all previously completed steps are rolled back in reverse order.
6
+ */
7
+
8
+ import { logError, logDebug, ErrorCodes } from './error-handler'
9
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ export type RollbackAction = {
15
+ description: string
16
+ execute: () => Promise<void>
17
+ }
18
+
19
+ // =============================================================================
20
+ // Transaction Manager Implementation
21
+ // =============================================================================
22
+
23
+ /**
24
+ * Manages a stack of rollback actions for transactional operations.
25
+ *
26
+ * Usage:
27
+ * ```typescript
28
+ * const tx = new TransactionManager()
29
+ *
30
+ * try {
31
+ * await createDirectory()
32
+ * tx.addRollback({
33
+ * description: 'Delete directory',
34
+ * execute: () => deleteDirectory()
35
+ * })
36
+ *
37
+ * await initDatabase()
38
+ * // Directory rollback covers this too
39
+ *
40
+ * await startServer()
41
+ * tx.addRollback({
42
+ * description: 'Stop server',
43
+ * execute: () => stopServer()
44
+ * })
45
+ *
46
+ * tx.commit() // Success - clear rollback stack
47
+ * } catch (err) {
48
+ * await tx.rollback() // Error - undo everything
49
+ * throw err
50
+ * }
51
+ * ```
52
+ */
53
+ export class TransactionManager {
54
+ private rollbackStack: RollbackAction[] = []
55
+ private committed = false
56
+
57
+ /**
58
+ * Add a rollback action to the stack.
59
+ * Actions are executed in reverse order during rollback.
60
+ */
61
+ addRollback(action: RollbackAction): void {
62
+ if (this.committed) {
63
+ throw new Error('Cannot add rollback action after commit')
64
+ }
65
+ this.rollbackStack.push(action)
66
+ logDebug(`Added rollback action: ${action.description}`, {
67
+ totalActions: this.rollbackStack.length,
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Execute all rollbacks in reverse order.
73
+ * Continues even if individual rollback actions fail.
74
+ */
75
+ async rollback(): Promise<void> {
76
+ if (this.committed) {
77
+ logDebug('Skipping rollback - transaction was committed')
78
+ return
79
+ }
80
+
81
+ if (this.rollbackStack.length === 0) {
82
+ logDebug('No rollback actions to execute')
83
+ return
84
+ }
85
+
86
+ logDebug(`Starting rollback of ${this.rollbackStack.length} actions`)
87
+
88
+ // Execute in reverse order (LIFO)
89
+ while (this.rollbackStack.length > 0) {
90
+ const action = this.rollbackStack.pop()!
91
+
92
+ try {
93
+ logDebug(`Executing rollback: ${action.description}`)
94
+ await action.execute()
95
+ logDebug(`Rollback successful: ${action.description}`)
96
+ } catch (err) {
97
+ // Log error but continue with other rollbacks
98
+ logError({
99
+ code: ErrorCodes.ROLLBACK_FAILED,
100
+ message: `Failed to rollback: ${action.description}`,
101
+ severity: 'warning',
102
+ context: {
103
+ error: err instanceof Error ? err.message : String(err),
104
+ },
105
+ })
106
+ }
107
+ }
108
+
109
+ logDebug('Rollback complete')
110
+ }
111
+
112
+ /**
113
+ * Mark the transaction as committed.
114
+ * Clears the rollback stack since we don't need to undo anything.
115
+ */
116
+ commit(): void {
117
+ if (this.committed) {
118
+ return // Already committed
119
+ }
120
+
121
+ logDebug(`Committing transaction with ${this.rollbackStack.length} actions`)
122
+ this.rollbackStack = []
123
+ this.committed = true
124
+ }
125
+
126
+ /**
127
+ * Check if the transaction has been committed.
128
+ */
129
+ isCommitted(): boolean {
130
+ return this.committed
131
+ }
132
+
133
+ /**
134
+ * Get the number of pending rollback actions.
135
+ */
136
+ getPendingCount(): number {
137
+ return this.rollbackStack.length
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Helper function to execute an operation with automatic rollback on failure.
143
+ *
144
+ * Usage:
145
+ * ```typescript
146
+ * await withTransaction(async (tx) => {
147
+ * await step1()
148
+ * tx.addRollback({ description: 'Undo step1', execute: undoStep1 })
149
+ *
150
+ * await step2()
151
+ * tx.addRollback({ description: 'Undo step2', execute: undoStep2 })
152
+ *
153
+ * // If we get here without throwing, transaction commits automatically
154
+ * })
155
+ * ```
156
+ */
157
+ export async function withTransaction<T>(
158
+ operation: (tx: TransactionManager) => Promise<T>,
159
+ ): Promise<T> {
160
+ const tx = new TransactionManager()
161
+
162
+ try {
163
+ const result = await operation(tx)
164
+ tx.commit()
165
+ return result
166
+ } catch (err) {
167
+ await tx.rollback()
168
+ throw err
169
+ }
170
+ }
@@ -5,89 +5,15 @@
5
5
 
6
6
  import { exec } from 'child_process'
7
7
  import { promisify } from 'util'
8
- import { existsSync } from 'fs'
9
- import { platform } from 'os'
8
+ import { platformService } from '../../core/platform-service'
10
9
 
11
10
  const execAsync = promisify(exec)
12
11
 
13
12
  /**
14
- * Common paths where MySQL binaries might be installed
13
+ * Find a MySQL binary by name using the platform service
15
14
  */
16
- const MYSQL_SEARCH_PATHS = {
17
- darwin: [
18
- // Homebrew (Apple Silicon)
19
- '/opt/homebrew/bin',
20
- '/opt/homebrew/opt/mysql/bin',
21
- '/opt/homebrew/opt/mysql@8.0/bin',
22
- '/opt/homebrew/opt/mysql@8.4/bin',
23
- '/opt/homebrew/opt/mysql@5.7/bin',
24
- // Homebrew (Intel)
25
- '/usr/local/bin',
26
- '/usr/local/opt/mysql/bin',
27
- '/usr/local/opt/mysql@8.0/bin',
28
- '/usr/local/opt/mysql@8.4/bin',
29
- '/usr/local/opt/mysql@5.7/bin',
30
- // Official MySQL installer
31
- '/usr/local/mysql/bin',
32
- ],
33
- linux: [
34
- '/usr/bin',
35
- '/usr/sbin',
36
- '/usr/local/bin',
37
- '/usr/local/mysql/bin',
38
- ],
39
- win32: [
40
- 'C:\\Program Files\\MySQL\\MySQL Server 8.0\\bin',
41
- 'C:\\Program Files\\MySQL\\MySQL Server 8.4\\bin',
42
- 'C:\\Program Files\\MySQL\\MySQL Server 5.7\\bin',
43
- ],
44
- }
45
-
46
- /**
47
- * Get search paths for the current platform
48
- */
49
- function getSearchPaths(): string[] {
50
- const plat = platform()
51
- return MYSQL_SEARCH_PATHS[plat as keyof typeof MYSQL_SEARCH_PATHS] || []
52
- }
53
-
54
- /**
55
- * Check if a binary exists at the given path
56
- */
57
- function binaryExists(path: string): boolean {
58
- return existsSync(path)
59
- }
60
-
61
- /**
62
- * Find a MySQL binary by name
63
- */
64
- export async function findMysqlBinary(
65
- name: string,
66
- ): Promise<string | null> {
67
- // First, try using 'which' or 'where' command
68
- try {
69
- const cmd = platform() === 'win32' ? 'where' : 'which'
70
- const { stdout } = await execAsync(`${cmd} ${name}`)
71
- const path = stdout.trim().split('\n')[0]
72
- if (path && binaryExists(path)) {
73
- return path
74
- }
75
- } catch {
76
- // Not found in PATH, continue to search common locations
77
- }
78
-
79
- // Search common installation paths
80
- const searchPaths = getSearchPaths()
81
- for (const dir of searchPaths) {
82
- const fullPath = platform() === 'win32'
83
- ? `${dir}\\${name}.exe`
84
- : `${dir}/${name}`
85
- if (binaryExists(fullPath)) {
86
- return fullPath
87
- }
88
- }
89
-
90
- return null
15
+ export async function findMysqlBinary(name: string): Promise<string | null> {
16
+ return platformService.findToolPath(name)
91
17
  }
92
18
 
93
19
  /**
@@ -180,6 +106,7 @@ export async function detectInstalledVersions(): Promise<
180
106
  Record<string, string>
181
107
  > {
182
108
  const versions: Record<string, string> = {}
109
+ const { platform } = platformService.getPlatformInfo()
183
110
 
184
111
  // Check default mysqld
185
112
  const defaultMysqld = await getMysqldPath()
@@ -191,25 +118,26 @@ export async function detectInstalledVersions(): Promise<
191
118
  }
192
119
  }
193
120
 
194
- // Check versioned Homebrew installations
195
- const homebrewPaths = platform() === 'darwin'
196
- ? [
197
- '/opt/homebrew/opt/mysql@5.7/bin/mysqld',
198
- '/opt/homebrew/opt/mysql@8.0/bin/mysqld',
199
- '/opt/homebrew/opt/mysql@8.4/bin/mysqld',
200
- '/usr/local/opt/mysql@5.7/bin/mysqld',
201
- '/usr/local/opt/mysql@8.0/bin/mysqld',
202
- '/usr/local/opt/mysql@8.4/bin/mysqld',
203
- ]
204
- : []
205
-
206
- for (const path of homebrewPaths) {
207
- if (binaryExists(path)) {
208
- const version = await getMysqlVersion(path)
209
- if (version) {
210
- const major = getMajorVersion(version)
211
- if (!versions[major]) {
212
- versions[major] = version
121
+ // Check versioned Homebrew installations (macOS only)
122
+ if (platform === 'darwin') {
123
+ const homebrewPaths = [
124
+ '/opt/homebrew/opt/mysql@5.7/bin/mysqld',
125
+ '/opt/homebrew/opt/mysql@8.0/bin/mysqld',
126
+ '/opt/homebrew/opt/mysql@8.4/bin/mysqld',
127
+ '/usr/local/opt/mysql@5.7/bin/mysqld',
128
+ '/usr/local/opt/mysql@8.0/bin/mysqld',
129
+ '/usr/local/opt/mysql@8.4/bin/mysqld',
130
+ ]
131
+
132
+ const { existsSync } = await import('fs')
133
+ for (const path of homebrewPaths) {
134
+ if (existsSync(path)) {
135
+ const version = await getMysqlVersion(path)
136
+ if (version) {
137
+ const major = getMajorVersion(version)
138
+ if (!versions[major]) {
139
+ versions[major] = version
140
+ }
213
141
  }
214
142
  }
215
143
  }
@@ -222,9 +150,9 @@ export async function detectInstalledVersions(): Promise<
222
150
  * Get install instructions for MySQL
223
151
  */
224
152
  export function getInstallInstructions(): string {
225
- const plat = platform()
153
+ const { platform } = platformService.getPlatformInfo()
226
154
 
227
- if (plat === 'darwin') {
155
+ if (platform === 'darwin') {
228
156
  return (
229
157
  'MySQL server not found. Install MySQL:\n' +
230
158
  ' brew install mysql\n' +
@@ -233,7 +161,7 @@ export function getInstallInstructions(): string {
233
161
  )
234
162
  }
235
163
 
236
- if (plat === 'linux') {
164
+ if (platform === 'linux') {
237
165
  return (
238
166
  'MySQL server not found. Install MySQL:\n' +
239
167
  ' Ubuntu/Debian: sudo apt install mysql-server\n' +
@@ -246,3 +174,152 @@ export function getInstallInstructions(): string {
246
174
  ' https://dev.mysql.com/downloads/mysql/'
247
175
  )
248
176
  }
177
+
178
+ export type MysqlPackageManager =
179
+ | 'homebrew'
180
+ | 'apt'
181
+ | 'yum'
182
+ | 'dnf'
183
+ | 'pacman'
184
+ | 'unknown'
185
+
186
+ export type MysqlInstallInfo = {
187
+ packageManager: MysqlPackageManager
188
+ packageName: string
189
+ path: string
190
+ uninstallCommand: string
191
+ isMariaDB: boolean
192
+ }
193
+
194
+ /**
195
+ * Detect which package manager installed MySQL and get uninstall info
196
+ */
197
+ export async function getMysqlInstallInfo(
198
+ mysqldPath: string,
199
+ ): Promise<MysqlInstallInfo> {
200
+ const { platform } = platformService.getPlatformInfo()
201
+ const mariadb = await isMariaDB()
202
+
203
+ // macOS: Check if path is in Homebrew directories
204
+ if (platform === 'darwin') {
205
+ if (
206
+ mysqldPath.includes('/opt/homebrew/') ||
207
+ mysqldPath.includes('/usr/local/Cellar/')
208
+ ) {
209
+ // Extract package name from path
210
+ // e.g., /opt/homebrew/opt/mysql@8.0/bin/mysqld -> mysql@8.0
211
+ // e.g., /opt/homebrew/bin/mysqld -> mysql (linked)
212
+ let packageName = mariadb ? 'mariadb' : 'mysql'
213
+
214
+ const versionMatch = mysqldPath.match(/mysql@(\d+\.\d+)/)
215
+ if (versionMatch) {
216
+ packageName = `mysql@${versionMatch[1]}`
217
+ } else {
218
+ // Try to get from Homebrew directly
219
+ try {
220
+ const { stdout } = await execAsync('brew list --formula')
221
+ const packages = stdout.split('\n')
222
+ const mysqlPackage = packages.find(
223
+ (p) =>
224
+ p.startsWith('mysql') ||
225
+ p.startsWith('mariadb') ||
226
+ p === 'percona-server',
227
+ )
228
+ if (mysqlPackage) {
229
+ packageName = mysqlPackage
230
+ }
231
+ } catch {
232
+ // Ignore errors
233
+ }
234
+ }
235
+
236
+ return {
237
+ packageManager: 'homebrew',
238
+ packageName,
239
+ path: mysqldPath,
240
+ uninstallCommand: `brew uninstall ${packageName}`,
241
+ isMariaDB: mariadb,
242
+ }
243
+ }
244
+ }
245
+
246
+ // Linux: Detect package manager from path or check installed packages
247
+ if (platform === 'linux') {
248
+ // Check for apt (Debian/Ubuntu)
249
+ try {
250
+ const { stdout } = await execAsync('which apt 2>/dev/null')
251
+ if (stdout.trim()) {
252
+ const packageName = mariadb ? 'mariadb-server' : 'mysql-server'
253
+ return {
254
+ packageManager: 'apt',
255
+ packageName,
256
+ path: mysqldPath,
257
+ uninstallCommand: `sudo apt remove ${packageName}`,
258
+ isMariaDB: mariadb,
259
+ }
260
+ }
261
+ } catch {
262
+ // Not apt
263
+ }
264
+
265
+ // Check for dnf (Fedora/RHEL 8+)
266
+ try {
267
+ const { stdout } = await execAsync('which dnf 2>/dev/null')
268
+ if (stdout.trim()) {
269
+ const packageName = mariadb ? 'mariadb-server' : 'mysql-server'
270
+ return {
271
+ packageManager: 'dnf',
272
+ packageName,
273
+ path: mysqldPath,
274
+ uninstallCommand: `sudo dnf remove ${packageName}`,
275
+ isMariaDB: mariadb,
276
+ }
277
+ }
278
+ } catch {
279
+ // Not dnf
280
+ }
281
+
282
+ // Check for yum (CentOS/RHEL 7)
283
+ try {
284
+ const { stdout } = await execAsync('which yum 2>/dev/null')
285
+ if (stdout.trim()) {
286
+ const packageName = mariadb ? 'mariadb-server' : 'mysql-server'
287
+ return {
288
+ packageManager: 'yum',
289
+ packageName,
290
+ path: mysqldPath,
291
+ uninstallCommand: `sudo yum remove ${packageName}`,
292
+ isMariaDB: mariadb,
293
+ }
294
+ }
295
+ } catch {
296
+ // Not yum
297
+ }
298
+
299
+ // Check for pacman (Arch Linux)
300
+ try {
301
+ const { stdout } = await execAsync('which pacman 2>/dev/null')
302
+ if (stdout.trim()) {
303
+ const packageName = mariadb ? 'mariadb' : 'mysql'
304
+ return {
305
+ packageManager: 'pacman',
306
+ packageName,
307
+ path: mysqldPath,
308
+ uninstallCommand: `sudo pacman -Rs ${packageName}`,
309
+ isMariaDB: mariadb,
310
+ }
311
+ }
312
+ } catch {
313
+ // Not pacman
314
+ }
315
+ }
316
+
317
+ // Unknown package manager
318
+ return {
319
+ packageManager: 'unknown',
320
+ packageName: mariadb ? 'mariadb' : 'mysql',
321
+ path: mysqldPath,
322
+ uninstallCommand: 'Use your system package manager to uninstall',
323
+ isMariaDB: mariadb,
324
+ }
325
+ }