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
@@ -3,12 +3,21 @@ import { exec } from 'child_process'
3
3
  import { promisify } from 'util'
4
4
  import { existsSync } from 'fs'
5
5
  import { readdir, readFile } from 'fs/promises'
6
- import { defaults } from '../config/defaults'
6
+ import { defaults, getSupportedEngines } from '../config/defaults'
7
7
  import { paths } from '../config/paths'
8
+ import { logDebug } from './error-handler'
8
9
  import type { ContainerConfig, PortResult } from '../types'
9
10
 
10
11
  const execAsync = promisify(exec)
11
12
 
13
+ /**
14
+ * Options for finding an available port
15
+ */
16
+ type FindPortOptions = {
17
+ preferredPort?: number
18
+ portRange?: { start: number; end: number }
19
+ }
20
+
12
21
  export class PortManager {
13
22
  /**
14
23
  * Check if a specific port is available
@@ -36,12 +45,13 @@ export class PortManager {
36
45
  }
37
46
 
38
47
  /**
39
- * Find the next available port starting from the default
48
+ * Find the next available port starting from the preferred port
40
49
  * Returns the port number and whether it's the default port
41
50
  */
42
- async findAvailablePort(
43
- preferredPort: number = defaults.port,
44
- ): Promise<PortResult> {
51
+ async findAvailablePort(options: FindPortOptions = {}): Promise<PortResult> {
52
+ const preferredPort = options.preferredPort ?? defaults.port
53
+ const portRange = options.portRange ?? defaults.portRange
54
+
45
55
  // First try the preferred port
46
56
  if (await this.isPortAvailable(preferredPort)) {
47
57
  return {
@@ -51,11 +61,7 @@ export class PortManager {
51
61
  }
52
62
 
53
63
  // Scan for available ports in the range
54
- for (
55
- let port = defaults.portRange.start;
56
- port <= defaults.portRange.end;
57
- port++
58
- ) {
64
+ for (let port = portRange.start; port <= portRange.end; port++) {
59
65
  if (port === preferredPort) continue // Already tried this one
60
66
 
61
67
  if (await this.isPortAvailable(port)) {
@@ -67,7 +73,7 @@ export class PortManager {
67
73
  }
68
74
 
69
75
  throw new Error(
70
- `No available ports found in range ${defaults.portRange.start}-${defaults.portRange.end}`,
76
+ `No available ports found in range ${portRange.start}-${portRange.end}`,
71
77
  )
72
78
  }
73
79
 
@@ -78,13 +84,17 @@ export class PortManager {
78
84
  try {
79
85
  const { stdout } = await execAsync(`lsof -i :${port} -P -n | head -5`)
80
86
  return stdout.trim()
81
- } catch {
87
+ } catch (error) {
88
+ logDebug('Could not determine port user', {
89
+ port,
90
+ error: error instanceof Error ? error.message : String(error),
91
+ })
82
92
  return null
83
93
  }
84
94
  }
85
95
 
86
96
  /**
87
- * Get all ports currently assigned to containers
97
+ * Get all ports currently assigned to containers across all engines
88
98
  */
89
99
  async getContainerPorts(): Promise<number[]> {
90
100
  const containersDir = paths.containers
@@ -94,18 +104,29 @@ export class PortManager {
94
104
  }
95
105
 
96
106
  const ports: number[] = []
97
- const entries = await readdir(containersDir, { withFileTypes: true })
98
-
99
- for (const entry of entries) {
100
- if (entry.isDirectory()) {
101
- const configPath = `${containersDir}/${entry.name}/container.json`
102
- if (existsSync(configPath)) {
103
- try {
104
- const content = await readFile(configPath, 'utf8')
105
- const config = JSON.parse(content) as ContainerConfig
106
- ports.push(config.port)
107
- } catch {
108
- // Skip invalid configs
107
+ const engines = getSupportedEngines()
108
+
109
+ for (const engine of engines) {
110
+ const engineDir = paths.getEngineContainersPath(engine)
111
+ if (!existsSync(engineDir)) continue
112
+
113
+ const entries = await readdir(engineDir, { withFileTypes: true })
114
+ for (const entry of entries) {
115
+ if (entry.isDirectory()) {
116
+ const configPath = paths.getContainerConfigPath(entry.name, {
117
+ engine,
118
+ })
119
+ if (existsSync(configPath)) {
120
+ try {
121
+ const content = await readFile(configPath, 'utf8')
122
+ const config = JSON.parse(content) as ContainerConfig
123
+ ports.push(config.port)
124
+ } catch (error) {
125
+ logDebug('Skipping invalid container config', {
126
+ configPath,
127
+ error: error instanceof Error ? error.message : String(error),
128
+ })
129
+ }
109
130
  }
110
131
  }
111
132
  }
@@ -118,8 +139,10 @@ export class PortManager {
118
139
  * Find an available port that's not in use by any process AND not assigned to any container
119
140
  */
120
141
  async findAvailablePortExcludingContainers(
121
- preferredPort: number = defaults.port,
142
+ options: FindPortOptions = {},
122
143
  ): Promise<PortResult> {
144
+ const preferredPort = options.preferredPort ?? defaults.port
145
+ const portRange = options.portRange ?? defaults.portRange
123
146
  const containerPorts = await this.getContainerPorts()
124
147
 
125
148
  // First try the preferred port
@@ -134,11 +157,7 @@ export class PortManager {
134
157
  }
135
158
 
136
159
  // Scan for available ports in the range
137
- for (
138
- let port = defaults.portRange.start;
139
- port <= defaults.portRange.end;
140
- port++
141
- ) {
160
+ for (let port = portRange.start; port <= portRange.end; port++) {
142
161
  if (containerPorts.includes(port)) continue // Skip ports used by containers
143
162
  if (port === preferredPort) continue // Already tried this one
144
163
 
@@ -151,7 +170,7 @@ export class PortManager {
151
170
  }
152
171
 
153
172
  throw new Error(
154
- `No available ports found in range ${defaults.portRange.start}-${defaults.portRange.end}`,
173
+ `No available ports found in range ${portRange.start}-${portRange.end}`,
155
174
  )
156
175
  }
157
176
  }
@@ -3,6 +3,7 @@ import { promisify } from 'util'
3
3
  import { existsSync } from 'fs'
4
4
  import { readFile } from 'fs/promises'
5
5
  import { paths } from '../config/paths'
6
+ import { logDebug } from './error-handler'
6
7
  import type { ProcessResult, StatusResult } from '../types'
7
8
 
8
9
  const execAsync = promisify(exec)
@@ -202,10 +203,14 @@ export class ProcessManager {
202
203
  }
203
204
 
204
205
  /**
205
- * Check if PostgreSQL is running by looking for PID file
206
+ * Check if a database server is running by looking for PID file
206
207
  */
207
- async isRunning(containerName: string): Promise<boolean> {
208
- const pidFile = paths.getContainerPidPath(containerName)
208
+ async isRunning(
209
+ containerName: string,
210
+ options: { engine: string },
211
+ ): Promise<boolean> {
212
+ const { engine } = options
213
+ const pidFile = paths.getContainerPidPath(containerName, { engine })
209
214
  if (!existsSync(pidFile)) {
210
215
  return false
211
216
  }
@@ -217,16 +222,25 @@ export class ProcessManager {
217
222
  // Check if process is still running
218
223
  process.kill(pid, 0)
219
224
  return true
220
- } catch {
225
+ } catch (error) {
226
+ logDebug('PID file check failed', {
227
+ containerName,
228
+ engine: options.engine,
229
+ error: error instanceof Error ? error.message : String(error),
230
+ })
221
231
  return false
222
232
  }
223
233
  }
224
234
 
225
235
  /**
226
- * Get the PID of a running PostgreSQL server
236
+ * Get the PID of a running database server
227
237
  */
228
- async getPid(containerName: string): Promise<number | null> {
229
- const pidFile = paths.getContainerPidPath(containerName)
238
+ async getPid(
239
+ containerName: string,
240
+ options: { engine: string },
241
+ ): Promise<number | null> {
242
+ const { engine } = options
243
+ const pidFile = paths.getContainerPidPath(containerName, { engine })
230
244
  if (!existsSync(pidFile)) {
231
245
  return null
232
246
  }
@@ -234,7 +248,11 @@ export class ProcessManager {
234
248
  try {
235
249
  const content = await readFile(pidFile, 'utf8')
236
250
  return parseInt(content.split('\n')[0], 10)
237
- } catch {
251
+ } catch (error) {
252
+ logDebug('Failed to read PID file', {
253
+ pidFile,
254
+ error: error instanceof Error ? error.message : String(error),
255
+ })
238
256
  return null
239
257
  }
240
258
  }
@@ -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
+ }
package/engines/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { postgresqlEngine } from './postgresql'
2
+ import { mysqlEngine } from './mysql'
2
3
  import type { BaseEngine } from './base-engine'
3
4
  import type { EngineInfo } from '../types'
4
5
 
@@ -6,9 +7,13 @@ import type { EngineInfo } from '../types'
6
7
  * Registry of available database engines
7
8
  */
8
9
  export const engines: Record<string, BaseEngine> = {
10
+ // PostgreSQL and aliases
9
11
  postgresql: postgresqlEngine,
10
- postgres: postgresqlEngine, // Alias
11
- pg: postgresqlEngine, // Alias
12
+ postgres: postgresqlEngine,
13
+ pg: postgresqlEngine,
14
+ // MySQL and aliases
15
+ mysql: mysqlEngine,
16
+ mariadb: mysqlEngine, // MariaDB is MySQL-compatible
12
17
  }
13
18
 
14
19
  /**