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
@@ -54,6 +54,14 @@ export class BinaryManager {
54
54
  return version
55
55
  }
56
56
 
57
+ /**
58
+ * Get major version from any version string (e.g., "17.7.0" -> "17", "16" -> "16")
59
+ * Used for directory naming to ensure one directory per major version.
60
+ */
61
+ getMajorVersion(version: string): string {
62
+ return version.split('.')[0]
63
+ }
64
+
57
65
  /**
58
66
  * Check if binaries for a specific version are already installed
59
67
  */
@@ -62,7 +70,13 @@ export class BinaryManager {
62
70
  platform: string,
63
71
  arch: string,
64
72
  ): Promise<boolean> {
65
- const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
73
+ const majorVersion = this.getMajorVersion(version)
74
+ const binPath = paths.getBinaryPath({
75
+ engine: 'postgresql',
76
+ version: majorVersion,
77
+ platform,
78
+ arch,
79
+ })
66
80
  const postgresPath = join(binPath, 'bin', 'postgres')
67
81
  return existsSync(postgresPath)
68
82
  }
@@ -108,9 +122,15 @@ export class BinaryManager {
108
122
  arch: string,
109
123
  onProgress?: ProgressCallback,
110
124
  ): Promise<string> {
125
+ const majorVersion = this.getMajorVersion(version)
111
126
  const url = this.getDownloadUrl(version, platform, arch)
112
- const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
113
- const tempDir = join(paths.bin, `temp-${version}-${platform}-${arch}`)
127
+ const binPath = paths.getBinaryPath({
128
+ engine: 'postgresql',
129
+ version: majorVersion,
130
+ platform,
131
+ arch,
132
+ })
133
+ const tempDir = join(paths.bin, `temp-${majorVersion}-${platform}-${arch}`)
114
134
  const jarFile = join(tempDir, 'postgres.jar')
115
135
 
116
136
  // Ensure directories exist
@@ -190,7 +210,13 @@ export class BinaryManager {
190
210
  platform: string,
191
211
  arch: string,
192
212
  ): Promise<boolean> {
193
- const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
213
+ const majorVersion = this.getMajorVersion(version)
214
+ const binPath = paths.getBinaryPath({
215
+ engine: 'postgresql',
216
+ version: majorVersion,
217
+ platform,
218
+ arch,
219
+ })
194
220
  const postgresPath = join(binPath, 'bin', 'postgres')
195
221
 
196
222
  if (!existsSync(postgresPath)) {
@@ -241,7 +267,13 @@ export class BinaryManager {
241
267
  arch: string,
242
268
  binary: string,
243
269
  ): string {
244
- const binPath = paths.getBinaryPath('postgresql', version, platform, arch)
270
+ const majorVersion = this.getMajorVersion(version)
271
+ const binPath = paths.getBinaryPath({
272
+ engine: 'postgresql',
273
+ version: majorVersion,
274
+ platform,
275
+ arch,
276
+ })
245
277
  return join(binPath, 'bin', binary)
246
278
  }
247
279
 
@@ -254,12 +286,18 @@ export class BinaryManager {
254
286
  arch: string,
255
287
  onProgress?: ProgressCallback,
256
288
  ): Promise<string> {
289
+ const majorVersion = this.getMajorVersion(version)
257
290
  if (await this.isInstalled(version, platform, arch)) {
258
291
  onProgress?.({
259
292
  stage: 'cached',
260
293
  message: 'Using cached PostgreSQL binaries',
261
294
  })
262
- return paths.getBinaryPath('postgresql', version, platform, arch)
295
+ return paths.getBinaryPath({
296
+ engine: 'postgresql',
297
+ version: majorVersion,
298
+ platform,
299
+ arch,
300
+ })
263
301
  }
264
302
 
265
303
  return this.download(version, platform, arch, onProgress)
@@ -4,6 +4,7 @@ import { exec } from 'child_process'
4
4
  import { promisify } from 'util'
5
5
  import { dirname } from 'path'
6
6
  import { paths } from '../config/paths'
7
+ import { logDebug, logWarning } from './error-handler'
7
8
  import type {
8
9
  SpinDBConfig,
9
10
  BinaryConfig,
@@ -41,8 +42,12 @@ export class ConfigManager {
41
42
  const content = await readFile(configPath, 'utf8')
42
43
  this.config = JSON.parse(content) as SpinDBConfig
43
44
  return this.config
44
- } catch {
45
+ } catch (error) {
45
46
  // If config is corrupted, reset to default
47
+ logWarning('Config file corrupted, resetting to default', {
48
+ configPath,
49
+ error: error instanceof Error ? error.message : String(error),
50
+ })
46
51
  this.config = { ...DEFAULT_CONFIG }
47
52
  await this.save()
48
53
  return this.config
@@ -110,8 +115,12 @@ export class ConfigManager {
110
115
  if (match) {
111
116
  version = match[0]
112
117
  }
113
- } catch {
114
- // Version detection failed, that's ok
118
+ } catch (error) {
119
+ logDebug('Version detection failed', {
120
+ tool,
121
+ path,
122
+ error: error instanceof Error ? error.message : String(error),
123
+ })
115
124
  }
116
125
 
117
126
  config.binaries[tool] = {
@@ -142,8 +151,11 @@ export class ConfigManager {
142
151
  if (path && existsSync(path)) {
143
152
  return path
144
153
  }
145
- } catch {
146
- // which failed, binary not found
154
+ } catch (error) {
155
+ logDebug('which command failed for binary detection', {
156
+ tool,
157
+ error: error instanceof Error ? error.message : String(error),
158
+ })
147
159
  }
148
160
 
149
161
  // Check common locations
@@ -3,10 +3,12 @@ import { mkdir, readdir, readFile, writeFile, rm, cp } from 'fs/promises'
3
3
  import { paths } from '../config/paths'
4
4
  import { processManager } from './process-manager'
5
5
  import { portManager } from './port-manager'
6
- import type { ContainerConfig } from '../types'
6
+ import { getEngineDefaults, getSupportedEngines } from '../config/defaults'
7
+ import { getEngine } from '../engines'
8
+ import type { ContainerConfig, EngineName } from '../types'
7
9
 
8
10
  export type CreateOptions = {
9
- engine: string
11
+ engine: EngineName
10
12
  version: string
11
13
  port: number
12
14
  database: string
@@ -30,14 +32,14 @@ export class ContainerManager {
30
32
  )
31
33
  }
32
34
 
33
- // Check if container already exists
34
- if (await this.exists(name)) {
35
- throw new Error(`Container "${name}" already exists`)
35
+ // Check if container already exists (for this engine)
36
+ if (await this.exists(name, { engine })) {
37
+ throw new Error(`Container "${name}" already exists for engine ${engine}`)
36
38
  }
37
39
 
38
- // Create container directory
39
- const containerPath = paths.getContainerPath(name)
40
- const dataPath = paths.getContainerDataPath(name)
40
+ // Create container directory (engine-scoped)
41
+ const containerPath = paths.getContainerPath(name, { engine })
42
+ const dataPath = paths.getContainerDataPath(name, { engine })
41
43
 
42
44
  await mkdir(containerPath, { recursive: true })
43
45
  await mkdir(dataPath, { recursive: true })
@@ -53,30 +55,54 @@ export class ContainerManager {
53
55
  status: 'created',
54
56
  }
55
57
 
56
- await this.saveConfig(name, config)
58
+ await this.saveConfig(name, { engine }, config)
57
59
 
58
60
  return config
59
61
  }
60
62
 
61
63
  /**
62
64
  * Get container configuration
65
+ * If engine is not provided, searches all engine directories
63
66
  */
64
- async getConfig(name: string): Promise<ContainerConfig | null> {
65
- const configPath = paths.getContainerConfigPath(name)
67
+ async getConfig(
68
+ name: string,
69
+ options?: { engine?: string },
70
+ ): Promise<ContainerConfig | null> {
71
+ const { engine } = options || {}
72
+
73
+ if (engine) {
74
+ // Look in specific engine directory
75
+ const configPath = paths.getContainerConfigPath(name, { engine })
76
+ if (!existsSync(configPath)) {
77
+ return null
78
+ }
79
+ const content = await readFile(configPath, 'utf8')
80
+ return JSON.parse(content) as ContainerConfig
81
+ }
66
82
 
67
- if (!existsSync(configPath)) {
68
- return null
83
+ // Search all engine directories
84
+ const engines = getSupportedEngines()
85
+ for (const eng of engines) {
86
+ const configPath = paths.getContainerConfigPath(name, { engine: eng })
87
+ if (existsSync(configPath)) {
88
+ const content = await readFile(configPath, 'utf8')
89
+ return JSON.parse(content) as ContainerConfig
90
+ }
69
91
  }
70
92
 
71
- const content = await readFile(configPath, 'utf8')
72
- return JSON.parse(content) as ContainerConfig
93
+ return null
73
94
  }
74
95
 
75
96
  /**
76
97
  * Save container configuration
77
98
  */
78
- async saveConfig(name: string, config: ContainerConfig): Promise<void> {
79
- const configPath = paths.getContainerConfigPath(name)
99
+ async saveConfig(
100
+ name: string,
101
+ options: { engine: string },
102
+ config: ContainerConfig,
103
+ ): Promise<void> {
104
+ const { engine } = options
105
+ const configPath = paths.getContainerConfigPath(name, { engine })
80
106
  await writeFile(configPath, JSON.stringify(config, null, 2))
81
107
  }
82
108
 
@@ -93,20 +119,36 @@ export class ContainerManager {
93
119
  }
94
120
 
95
121
  const updatedConfig = { ...config, ...updates }
96
- await this.saveConfig(name, updatedConfig)
122
+ await this.saveConfig(name, { engine: config.engine }, updatedConfig)
97
123
  return updatedConfig
98
124
  }
99
125
 
100
126
  /**
101
127
  * Check if a container exists
128
+ * If engine is not provided, checks all engine directories
102
129
  */
103
- async exists(name: string): Promise<boolean> {
104
- const configPath = paths.getContainerConfigPath(name)
105
- return existsSync(configPath)
130
+ async exists(name: string, options?: { engine?: string }): Promise<boolean> {
131
+ const { engine } = options || {}
132
+
133
+ if (engine) {
134
+ const configPath = paths.getContainerConfigPath(name, { engine })
135
+ return existsSync(configPath)
136
+ }
137
+
138
+ // Check all engine directories
139
+ const engines = getSupportedEngines()
140
+ for (const eng of engines) {
141
+ const configPath = paths.getContainerConfigPath(name, { engine: eng })
142
+ if (existsSync(configPath)) {
143
+ return true
144
+ }
145
+ }
146
+
147
+ return false
106
148
  }
107
149
 
108
150
  /**
109
- * List all containers
151
+ * List all containers across all engines
110
152
  */
111
153
  async list(): Promise<ContainerConfig[]> {
112
154
  const containersDir = paths.containers
@@ -115,19 +157,30 @@ export class ContainerManager {
115
157
  return []
116
158
  }
117
159
 
118
- const entries = await readdir(containersDir, { withFileTypes: true })
119
160
  const containers: ContainerConfig[] = []
161
+ const engines = getSupportedEngines()
162
+
163
+ for (const engine of engines) {
164
+ const engineDir = paths.getEngineContainersPath(engine)
165
+ if (!existsSync(engineDir)) {
166
+ continue
167
+ }
120
168
 
121
- for (const entry of entries) {
122
- if (entry.isDirectory()) {
123
- const config = await this.getConfig(entry.name)
124
- if (config) {
125
- // Check if actually running
126
- const running = await processManager.isRunning(entry.name)
127
- containers.push({
128
- ...config,
129
- status: running ? 'running' : 'stopped',
130
- })
169
+ const entries = await readdir(engineDir, { withFileTypes: true })
170
+
171
+ for (const entry of entries) {
172
+ if (entry.isDirectory()) {
173
+ const config = await this.getConfig(entry.name, { engine })
174
+ if (config) {
175
+ // Check if actually running
176
+ const running = await processManager.isRunning(entry.name, {
177
+ engine,
178
+ })
179
+ containers.push({
180
+ ...config,
181
+ status: running ? 'running' : 'stopped',
182
+ })
183
+ }
131
184
  }
132
185
  }
133
186
  }
@@ -141,19 +194,23 @@ export class ContainerManager {
141
194
  async delete(name: string, options: DeleteOptions = {}): Promise<void> {
142
195
  const { force = false } = options
143
196
 
144
- if (!(await this.exists(name))) {
197
+ // Get container config to find engine
198
+ const config = await this.getConfig(name)
199
+ if (!config) {
145
200
  throw new Error(`Container "${name}" not found`)
146
201
  }
147
202
 
203
+ const { engine } = config
204
+
148
205
  // Check if running
149
- const running = await processManager.isRunning(name)
206
+ const running = await processManager.isRunning(name, { engine })
150
207
  if (running && !force) {
151
208
  throw new Error(
152
209
  `Container "${name}" is running. Stop it first or use --force`,
153
210
  )
154
211
  }
155
212
 
156
- const containerPath = paths.getContainerPath(name)
213
+ const containerPath = paths.getContainerPath(name, { engine })
157
214
  await rm(containerPath, { recursive: true, force: true })
158
215
  }
159
216
 
@@ -171,18 +228,21 @@ export class ContainerManager {
171
228
  )
172
229
  }
173
230
 
174
- // Check source exists
175
- if (!(await this.exists(sourceName))) {
231
+ // Get source config
232
+ const sourceConfig = await this.getConfig(sourceName)
233
+ if (!sourceConfig) {
176
234
  throw new Error(`Source container "${sourceName}" not found`)
177
235
  }
178
236
 
179
- // Check target doesn't exist
180
- if (await this.exists(targetName)) {
237
+ const { engine } = sourceConfig
238
+
239
+ // Check target doesn't exist (for this engine)
240
+ if (await this.exists(targetName, { engine })) {
181
241
  throw new Error(`Target container "${targetName}" already exists`)
182
242
  }
183
243
 
184
244
  // Check source is not running
185
- const running = await processManager.isRunning(sourceName)
245
+ const running = await processManager.isRunning(sourceName, { engine })
186
246
  if (running) {
187
247
  throw new Error(
188
248
  `Source container "${sourceName}" is running. Stop it first`,
@@ -190,13 +250,13 @@ export class ContainerManager {
190
250
  }
191
251
 
192
252
  // Copy container directory
193
- const sourcePath = paths.getContainerPath(sourceName)
194
- const targetPath = paths.getContainerPath(targetName)
253
+ const sourcePath = paths.getContainerPath(sourceName, { engine })
254
+ const targetPath = paths.getContainerPath(targetName, { engine })
195
255
 
196
256
  await cp(sourcePath, targetPath, { recursive: true })
197
257
 
198
258
  // Update target config
199
- const config = await this.getConfig(targetName)
259
+ const config = await this.getConfig(targetName, { engine })
200
260
  if (!config) {
201
261
  throw new Error('Failed to read cloned container config')
202
262
  }
@@ -206,10 +266,13 @@ export class ContainerManager {
206
266
  config.clonedFrom = sourceName
207
267
 
208
268
  // Assign new port (excluding ports already used by other containers)
209
- const { port } = await portManager.findAvailablePortExcludingContainers()
269
+ const engineDefaults = getEngineDefaults(engine)
270
+ const { port } = await portManager.findAvailablePortExcludingContainers({
271
+ portRange: engineDefaults.portRange,
272
+ })
210
273
  config.port = port
211
274
 
212
- await this.saveConfig(targetName, config)
275
+ await this.saveConfig(targetName, { engine }, config)
213
276
 
214
277
  return config
215
278
  }
@@ -225,37 +288,40 @@ export class ContainerManager {
225
288
  )
226
289
  }
227
290
 
228
- // Check source exists
229
- if (!(await this.exists(oldName))) {
291
+ // Get source config
292
+ const sourceConfig = await this.getConfig(oldName)
293
+ if (!sourceConfig) {
230
294
  throw new Error(`Container "${oldName}" not found`)
231
295
  }
232
296
 
297
+ const { engine } = sourceConfig
298
+
233
299
  // Check target doesn't exist
234
- if (await this.exists(newName)) {
300
+ if (await this.exists(newName, { engine })) {
235
301
  throw new Error(`Container "${newName}" already exists`)
236
302
  }
237
303
 
238
304
  // Check container is not running
239
- const running = await processManager.isRunning(oldName)
305
+ const running = await processManager.isRunning(oldName, { engine })
240
306
  if (running) {
241
307
  throw new Error(`Container "${oldName}" is running. Stop it first`)
242
308
  }
243
309
 
244
310
  // Rename directory
245
- const oldPath = paths.getContainerPath(oldName)
246
- const newPath = paths.getContainerPath(newName)
311
+ const oldPath = paths.getContainerPath(oldName, { engine })
312
+ const newPath = paths.getContainerPath(newName, { engine })
247
313
 
248
314
  await cp(oldPath, newPath, { recursive: true })
249
315
  await rm(oldPath, { recursive: true, force: true })
250
316
 
251
317
  // Update config with new name
252
- const config = await this.getConfig(newName)
318
+ const config = await this.getConfig(newName, { engine })
253
319
  if (!config) {
254
320
  throw new Error('Failed to read renamed container config')
255
321
  }
256
322
 
257
323
  config.name = newName
258
- await this.saveConfig(newName, config)
324
+ await this.saveConfig(newName, { engine }, config)
259
325
 
260
326
  return config
261
327
  }
@@ -269,13 +335,11 @@ export class ContainerManager {
269
335
 
270
336
  /**
271
337
  * Get connection string for a container
338
+ * Delegates to the appropriate engine
272
339
  */
273
- getConnectionString(
274
- config: ContainerConfig,
275
- database: string = 'postgres',
276
- ): string {
277
- const { port } = config
278
- return `postgresql://postgres@localhost:${port}/${database}`
340
+ getConnectionString(config: ContainerConfig, database?: string): string {
341
+ const engine = getEngine(config.engine)
342
+ return engine.getConnectionString(config, database)
279
343
  }
280
344
  }
281
345
 
@@ -16,6 +16,7 @@ import {
16
16
  getEngineDependencies,
17
17
  getUniqueDependencies,
18
18
  } from '../config/os-dependencies'
19
+ import { platformService } from './platform-service'
19
20
 
20
21
  const execAsync = promisify(exec)
21
22
 
@@ -46,7 +47,7 @@ export type InstallResult = {
46
47
  * Detect which package manager is available on the current system
47
48
  */
48
49
  export async function detectPackageManager(): Promise<DetectedPackageManager | null> {
49
- const platform = process.platform as Platform
50
+ const { platform } = platformService.getPlatformInfo()
50
51
 
51
52
  // Filter to package managers available on this platform
52
53
  const candidates = packageManagers.filter((pm) =>
@@ -73,7 +74,7 @@ export async function detectPackageManager(): Promise<DetectedPackageManager | n
73
74
  * Get the current platform
74
75
  */
75
76
  export function getCurrentPlatform(): Platform {
76
- return process.platform as Platform
77
+ return platformService.getPlatformInfo().platform as Platform
77
78
  }
78
79
 
79
80
  // =============================================================================
@@ -87,21 +88,12 @@ export async function findBinary(
87
88
  binary: string,
88
89
  ): Promise<{ path: string; version?: string } | null> {
89
90
  try {
90
- const command = process.platform === 'win32' ? 'where' : 'which'
91
- const { stdout } = await execAsync(`${command} ${binary}`)
92
- const path = stdout.trim().split('\n')[0]
93
-
91
+ // Use platformService to find the binary path
92
+ const path = await platformService.findToolPath(binary)
94
93
  if (!path) return null
95
94
 
96
95
  // Try to get version
97
- let version: string | undefined
98
- try {
99
- const { stdout: versionOutput } = await execAsync(`${binary} --version`)
100
- const match = versionOutput.match(/(\d+\.\d+(\.\d+)?)/)
101
- version = match ? match[1] : undefined
102
- } catch {
103
- // Version check failed, that's ok
104
- }
96
+ const version = (await platformService.getToolVersion(path)) || undefined
105
97
 
106
98
  return { path, version }
107
99
  } catch {
@@ -216,7 +208,9 @@ function execWithInheritedStdio(command: string): void {
216
208
  }
217
209
 
218
210
  if (result.status !== 0) {
219
- throw new Error(`Command failed with exit code ${result.status}: ${cmdToRun}`)
211
+ throw new Error(
212
+ `Command failed with exit code ${result.status}: ${cmdToRun}`,
213
+ )
220
214
  }
221
215
  }
222
216