spindb 0.5.2 → 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 (36) hide show
  1. package/README.md +137 -8
  2. package/cli/commands/connect.ts +8 -4
  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/menu.ts +408 -153
  9. package/cli/commands/restore.ts +10 -24
  10. package/cli/commands/start.ts +25 -20
  11. package/cli/commands/url.ts +79 -0
  12. package/cli/index.ts +9 -3
  13. package/cli/ui/prompts.ts +8 -6
  14. package/config/engine-defaults.ts +24 -1
  15. package/config/os-dependencies.ts +59 -113
  16. package/config/paths.ts +7 -36
  17. package/core/binary-manager.ts +19 -6
  18. package/core/config-manager.ts +17 -5
  19. package/core/dependency-manager.ts +9 -15
  20. package/core/error-handler.ts +336 -0
  21. package/core/platform-service.ts +634 -0
  22. package/core/port-manager.ts +11 -3
  23. package/core/process-manager.ts +12 -2
  24. package/core/start-with-retry.ts +167 -0
  25. package/core/transaction-manager.ts +170 -0
  26. package/engines/mysql/binary-detection.ts +177 -100
  27. package/engines/mysql/index.ts +240 -131
  28. package/engines/mysql/restore.ts +257 -0
  29. package/engines/mysql/version-validator.ts +373 -0
  30. package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
  31. package/engines/postgresql/binary-urls.ts +5 -3
  32. package/engines/postgresql/index.ts +4 -3
  33. package/engines/postgresql/restore.ts +54 -5
  34. package/engines/postgresql/version-validator.ts +262 -0
  35. package/package.json +6 -2
  36. package/cli/commands/postgres-tools.ts +0 -216
@@ -1,15 +1,17 @@
1
1
  import { exec } from 'child_process'
2
2
  import { promisify } from 'util'
3
3
  import chalk from 'chalk'
4
- import { createSpinner } from '../cli/ui/spinner'
5
- import { warning, error as themeError, success } from '../cli/ui/theme'
4
+ import { createSpinner } from '../../cli/ui/spinner'
5
+ import { warning, error as themeError, success } from '../../cli/ui/theme'
6
6
  import {
7
7
  detectPackageManager as detectPM,
8
8
  installEngineDependencies,
9
9
  getManualInstallInstructions,
10
10
  getCurrentPlatform,
11
- } from './dependency-manager'
12
- import { getEngineDependencies } from '../config/os-dependencies'
11
+ } from '../../core/dependency-manager'
12
+ import { getEngineDependencies } from '../../config/os-dependencies'
13
+ import { getPostgresHomebrewPackage } from '../../config/engine-defaults'
14
+ import { logDebug } from '../../core/error-handler'
13
15
 
14
16
  const execAsync = promisify(exec)
15
17
 
@@ -34,16 +36,17 @@ export type PackageManager = {
34
36
  * Detect which package manager is available on the system
35
37
  */
36
38
  export async function detectPackageManager(): Promise<PackageManager | null> {
39
+ const pgPackage = getPostgresHomebrewPackage()
37
40
  const managers: PackageManager[] = [
38
41
  {
39
42
  name: 'brew',
40
43
  checkCommand: 'brew --version',
41
44
  installCommand: () =>
42
- 'brew install postgresql@17 && brew link --overwrite postgresql@17',
45
+ `brew install ${pgPackage} && brew link --overwrite ${pgPackage}`,
43
46
  updateCommand: () =>
44
- 'brew link --overwrite postgresql@17 || brew install postgresql@17 && brew link --overwrite postgresql@17',
47
+ `brew link --overwrite ${pgPackage} || brew install ${pgPackage} && brew link --overwrite ${pgPackage}`,
45
48
  versionCheckCommand: () =>
46
- 'brew info postgresql@17 | grep "postgresql@17:" | head -1',
49
+ `brew info ${pgPackage} | grep "${pgPackage}:" | head -1`,
47
50
  },
48
51
  {
49
52
  name: 'apt',
@@ -74,8 +77,12 @@ export async function detectPackageManager(): Promise<PackageManager | null> {
74
77
  try {
75
78
  await execAsync(manager.checkCommand)
76
79
  return manager
77
- } catch {
78
- // Manager not available
80
+ } catch (error) {
81
+ // Manager not available - log for debugging
82
+ logDebug(`Package manager ${manager.name} not available`, {
83
+ command: manager.checkCommand,
84
+ error: error instanceof Error ? error.message : String(error),
85
+ })
79
86
  }
80
87
  }
81
88
 
@@ -92,7 +99,10 @@ export async function getPostgresVersion(
92
99
  const { stdout } = await execAsync(`${binary} --version`)
93
100
  const match = stdout.match(/(\d+\.\d+)/)
94
101
  return match ? match[1] : null
95
- } catch {
102
+ } catch (error) {
103
+ logDebug(`Failed to get version for ${binary}`, {
104
+ error: error instanceof Error ? error.message : String(error),
105
+ })
96
106
  return null
97
107
  }
98
108
  }
@@ -105,7 +115,10 @@ export async function findBinaryPath(binary: string): Promise<string | null> {
105
115
  const command = process.platform === 'win32' ? 'where' : 'which'
106
116
  const { stdout } = await execAsync(`${command} ${binary}`)
107
117
  return stdout.trim().split('\n')[0] || null
108
- } catch {
118
+ } catch (error) {
119
+ logDebug(`Binary ${binary} not found in PATH`, {
120
+ error: error instanceof Error ? error.message : String(error),
121
+ })
109
122
  return null
110
123
  }
111
124
  }
@@ -128,7 +141,10 @@ export async function findBinaryPathFresh(
128
141
  `${shell} -c 'source ~/.${shell.endsWith('zsh') ? 'zshrc' : 'bashrc'} && which ${binary}'`,
129
142
  )
130
143
  return stdout.trim().split('\n')[0] || null
131
- } catch {
144
+ } catch (error) {
145
+ logDebug(`Binary ${binary} not found after PATH refresh`, {
146
+ error: error instanceof Error ? error.message : String(error),
147
+ })
132
148
  return null
133
149
  }
134
150
  }
@@ -185,14 +201,20 @@ export async function getDumpRequiredVersion(
185
201
  return '15.0' // Minimum version for recent dumps
186
202
  }
187
203
  }
188
- } catch {
204
+ } catch (error) {
189
205
  // If hexdump fails, fall back to checking error patterns
206
+ logDebug(`hexdump failed for ${dumpPath}`, {
207
+ error: error instanceof Error ? error.message : String(error),
208
+ })
190
209
  }
191
210
  }
192
211
 
193
212
  // Fallback: if we can't determine, assume it needs a recent version
194
213
  return '15.0'
195
- } catch {
214
+ } catch (error) {
215
+ logDebug(`Failed to determine dump version for ${dumpPath}`, {
216
+ error: error instanceof Error ? error.message : String(error),
217
+ })
196
218
  return null
197
219
  }
198
220
  }
@@ -309,12 +331,19 @@ export async function installPostgresBinaries(): Promise<boolean> {
309
331
  spinner.succeed(`Found package manager: ${packageManager.name}`)
310
332
 
311
333
  // Don't use a spinner during installation - it blocks TTY access for sudo password prompts
312
- console.log(chalk.cyan(` Installing PostgreSQL client tools with ${packageManager.name}...`))
334
+ console.log(
335
+ chalk.cyan(
336
+ ` Installing PostgreSQL client tools with ${packageManager.name}...`,
337
+ ),
338
+ )
313
339
  console.log(chalk.gray(' You may be prompted for your password.'))
314
340
  console.log()
315
341
 
316
342
  try {
317
- const results = await installEngineDependencies('postgresql', packageManager)
343
+ const results = await installEngineDependencies(
344
+ 'postgresql',
345
+ packageManager,
346
+ )
318
347
  const allSuccess = results.every((r) => r.success)
319
348
 
320
349
  if (allSuccess) {
@@ -356,14 +385,20 @@ export async function updatePostgresClientTools(): Promise<boolean> {
356
385
 
357
386
  spinner.succeed(`Found package manager: ${packageManager.name}`)
358
387
 
388
+ const pgPackage = getPostgresHomebrewPackage()
389
+ const latestMajor = pgPackage.split('@')[1] // e.g., '17' from 'postgresql@17'
390
+
359
391
  try {
360
392
  if (packageManager.name === 'brew') {
361
393
  // Handle brew conflicts and dependency issues
394
+ // Unlink all older PostgreSQL versions before linking the latest
395
+ const olderVersions = ['14', '15', '16'].filter((v) => v !== latestMajor)
396
+ const unlinkCommands = olderVersions.map(
397
+ (v) => `brew unlink postgresql@${v} 2>/dev/null || true`,
398
+ )
362
399
  const commands = [
363
- 'brew unlink postgresql@14 2>/dev/null || true', // Unlink old version if exists
364
- 'brew unlink postgresql@15 2>/dev/null || true', // Unlink other old versions
365
- 'brew unlink postgresql@16 2>/dev/null || true',
366
- 'brew link --overwrite postgresql@17', // Link postgresql@17 with overwrite
400
+ ...unlinkCommands,
401
+ `brew link --overwrite ${pgPackage}`, // Link latest version with overwrite
367
402
  'brew upgrade icu4c 2>/dev/null || true', // Fix ICU dependency issues
368
403
  ]
369
404
 
@@ -372,7 +407,11 @@ export async function updatePostgresClientTools(): Promise<boolean> {
372
407
  }
373
408
 
374
409
  spinner.succeed('PostgreSQL client tools updated')
375
- console.log(success('Client tools successfully linked to PostgreSQL 17'))
410
+ console.log(
411
+ success(
412
+ `Client tools successfully linked to PostgreSQL ${latestMajor}`,
413
+ ),
414
+ )
376
415
  console.log(chalk.gray('ICU dependencies have been updated'))
377
416
  return true
378
417
  } else {
@@ -388,13 +427,14 @@ export async function updatePostgresClientTools(): Promise<boolean> {
388
427
  console.log(warning('Please update manually:'))
389
428
 
390
429
  if (packageManager.name === 'brew') {
430
+ const olderVersions = ['14', '15', '16'].filter((v) => v !== latestMajor)
391
431
  console.log(chalk.yellow(' macOS:'))
392
432
  console.log(
393
433
  chalk.yellow(
394
- ' brew unlink postgresql@14 postgresql@15 postgresql@16',
434
+ ` brew unlink ${olderVersions.map((v) => `postgresql@${v}`).join(' ')}`,
395
435
  ),
396
436
  )
397
- console.log(chalk.yellow(' brew link --overwrite postgresql@17'))
437
+ console.log(chalk.yellow(` brew link --overwrite ${pgPackage}`))
398
438
  console.log(
399
439
  chalk.yellow(' brew upgrade icu4c # Fix ICU dependency issues'),
400
440
  )
@@ -1,4 +1,4 @@
1
- import { platform, arch } from 'os'
1
+ import { platformService } from '../../core/platform-service'
2
2
  import { defaults } from '../../config/defaults'
3
3
 
4
4
  /**
@@ -33,9 +33,10 @@ export async function fetchAvailableVersions(): Promise<
33
33
  return cachedVersions
34
34
  }
35
35
 
36
- const zonkyPlatform = getZonkyPlatform(platform(), arch())
36
+ const zonkyPlatform = platformService.getZonkyPlatform()
37
37
  if (!zonkyPlatform) {
38
- throw new Error(`Unsupported platform: ${platform()}-${arch()}`)
38
+ const { platform, arch } = platformService.getPlatformInfo()
39
+ throw new Error(`Unsupported platform: ${platform}-${arch}`)
39
40
  }
40
41
 
41
42
  const url = `https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-${zonkyPlatform}/`
@@ -123,6 +124,7 @@ export const VERSION_MAP = FALLBACK_VERSION_MAP
123
124
 
124
125
  /**
125
126
  * Get the zonky.io platform identifier
127
+ * @deprecated Use platformService.getZonkyPlatform() instead
126
128
  */
127
129
  export function getZonkyPlatform(
128
130
  platform: string,
@@ -1,4 +1,3 @@
1
- import { platform, arch } from 'os'
2
1
  import { join } from 'path'
3
2
  import { spawn, exec } from 'child_process'
4
3
  import { promisify } from 'util'
@@ -6,6 +5,7 @@ import { BaseEngine } from '../base-engine'
6
5
  import { binaryManager } from '../../core/binary-manager'
7
6
  import { processManager } from '../../core/process-manager'
8
7
  import { configManager } from '../../core/config-manager'
8
+ import { platformService } from '../../core/platform-service'
9
9
  import { paths } from '../../config/paths'
10
10
  import { defaults } from '../../config/defaults'
11
11
  import {
@@ -43,9 +43,10 @@ export class PostgreSQLEngine extends BaseEngine {
43
43
  * Get current platform info
44
44
  */
45
45
  getPlatformInfo(): { platform: string; arch: string } {
46
+ const info = platformService.getPlatformInfo()
46
47
  return {
47
- platform: platform(),
48
- arch: arch(),
48
+ platform: info.platform,
49
+ arch: info.arch,
49
50
  }
50
51
  }
51
52
 
@@ -2,23 +2,37 @@ import { readFile } from 'fs/promises'
2
2
  import { exec } from 'child_process'
3
3
  import { promisify } from 'util'
4
4
  import { configManager } from '../../core/config-manager'
5
- import { findBinaryPathFresh } from '../../core/postgres-binary-manager'
5
+ import { findBinaryPathFresh } from './binary-manager'
6
+ import { validateRestoreCompatibility } from './version-validator'
7
+ import { SpinDBError, ErrorCodes } from '../../core/error-handler'
6
8
  import type { BackupFormat, RestoreResult } from '../../types'
7
9
 
8
10
  const execAsync = promisify(exec)
9
11
 
10
12
  /**
11
13
  * Detect the format of a PostgreSQL backup file
14
+ *
15
+ * Also detects MySQL/MariaDB dumps to provide helpful error messages.
12
16
  */
13
17
  export async function detectBackupFormat(
14
18
  filePath: string,
15
19
  ): Promise<BackupFormat> {
16
- // Read the first few bytes to detect format
20
+ // Read the first 128 bytes to detect format
17
21
  const file = await readFile(filePath)
18
- const buffer = Buffer.alloc(16)
22
+ const buffer = Buffer.alloc(128)
19
23
 
20
24
  // Copy first bytes
21
- file.copy(buffer, 0, 0, Math.min(16, file.length))
25
+ file.copy(buffer, 0, 0, Math.min(128, file.length))
26
+ const header = buffer.toString('utf8')
27
+
28
+ // Check for MySQL/MariaDB dump markers (before PostgreSQL checks)
29
+ if (header.includes('-- MySQL dump') || header.includes('-- MariaDB dump')) {
30
+ return {
31
+ format: 'mysql_sql',
32
+ description: 'MySQL/MariaDB SQL dump (incompatible with PostgreSQL)',
33
+ restoreCommand: 'mysql',
34
+ }
35
+ }
22
36
 
23
37
  // Check for PostgreSQL custom format magic number
24
38
  // Custom format starts with "PGDMP"
@@ -78,6 +92,25 @@ export async function detectBackupFormat(
78
92
  }
79
93
  }
80
94
 
95
+ /**
96
+ * Check if the backup file is from the wrong engine and throw helpful error
97
+ */
98
+ export function assertCompatibleFormat(format: BackupFormat): void {
99
+ if (format.format === 'mysql_sql') {
100
+ throw new SpinDBError(
101
+ ErrorCodes.WRONG_ENGINE_DUMP,
102
+ `This appears to be a MySQL/MariaDB dump file, but you're trying to restore it to PostgreSQL.`,
103
+ 'fatal',
104
+ `Create a MySQL container instead:\n spindb create mydb --engine mysql --from <dump-file>`,
105
+ {
106
+ detectedFormat: format.format,
107
+ expectedEngine: 'postgresql',
108
+ detectedEngine: 'mysql',
109
+ },
110
+ )
111
+ }
112
+ }
113
+
81
114
  export type RestoreOptions = {
82
115
  port: number
83
116
  database: string
@@ -136,7 +169,23 @@ export async function restoreBackup(
136
169
  ): Promise<RestoreResult> {
137
170
  const { port, database, user = 'postgres', format, pgRestorePath } = options
138
171
 
139
- const detectedFormat = format || (await detectBackupFormat(backupPath)).format
172
+ // Detect format and check for wrong engine
173
+ const detectedBackupFormat = await detectBackupFormat(backupPath)
174
+ assertCompatibleFormat(detectedBackupFormat)
175
+
176
+ const detectedFormat = format || detectedBackupFormat.format
177
+
178
+ // For pg_restore formats, validate version compatibility
179
+ if (detectedFormat !== 'sql') {
180
+ const restorePath = pgRestorePath || (await getPgRestorePath())
181
+
182
+ // This will throw SpinDBError if versions are incompatible
183
+ await validateRestoreCompatibility({
184
+ dumpPath: backupPath,
185
+ format: detectedFormat,
186
+ pgRestorePath: restorePath,
187
+ })
188
+ }
140
189
 
141
190
  if (detectedFormat === 'sql') {
142
191
  const psqlPath = await getPsqlPath()
@@ -0,0 +1,262 @@
1
+ /**
2
+ * PostgreSQL Version Validator
3
+ *
4
+ * Validates compatibility between pg_restore tool version and dump file version.
5
+ * PostgreSQL is backwards compatible - we only fail when dump_version > tool_version.
6
+ */
7
+
8
+ import { exec } from 'child_process'
9
+ import { promisify } from 'util'
10
+ import { createReadStream } from 'fs'
11
+ import { createInterface } from 'readline'
12
+ import {
13
+ SpinDBError,
14
+ ErrorCodes,
15
+ logWarning,
16
+ logDebug,
17
+ } from '../../core/error-handler'
18
+
19
+ const execAsync = promisify(exec)
20
+
21
+ // =============================================================================
22
+ // Types
23
+ // =============================================================================
24
+
25
+ export type VersionInfo = {
26
+ major: number
27
+ minor: number
28
+ patch: number
29
+ full: string
30
+ }
31
+
32
+ export type CompatibilityResult = {
33
+ compatible: boolean
34
+ dumpVersion: VersionInfo | null
35
+ toolVersion: VersionInfo
36
+ warning?: string
37
+ error?: string
38
+ }
39
+
40
+ // =============================================================================
41
+ // Version Parsing
42
+ // =============================================================================
43
+
44
+ /**
45
+ * Parse version from pg_dump/pg_restore --version output
46
+ * Examples:
47
+ * "pg_restore (PostgreSQL) 16.1"
48
+ * "pg_restore (PostgreSQL) 14.9 (Homebrew)"
49
+ * "pg_dump (PostgreSQL) 17.0"
50
+ */
51
+ export function parseToolVersion(output: string): VersionInfo {
52
+ const match = output.match(/(\d+)\.(\d+)(?:\.(\d+))?/)
53
+ if (!match) {
54
+ throw new Error(`Cannot parse version from: ${output}`)
55
+ }
56
+ return {
57
+ major: parseInt(match[1], 10),
58
+ minor: parseInt(match[2], 10),
59
+ patch: parseInt(match[3] || '0', 10),
60
+ full: match[0],
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Read the first N lines of a file
66
+ */
67
+ async function readFirstLines(
68
+ filePath: string,
69
+ lineCount: number,
70
+ ): Promise<string> {
71
+ return new Promise((resolve, reject) => {
72
+ const lines: string[] = []
73
+ const stream = createReadStream(filePath, { encoding: 'utf8' })
74
+ const rl = createInterface({ input: stream })
75
+
76
+ rl.on('line', (line) => {
77
+ lines.push(line)
78
+ if (lines.length >= lineCount) {
79
+ rl.close()
80
+ stream.destroy()
81
+ }
82
+ })
83
+
84
+ rl.on('close', () => {
85
+ resolve(lines.join('\n'))
86
+ })
87
+
88
+ rl.on('error', reject)
89
+ stream.on('error', reject)
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Parse version from dump file header
95
+ *
96
+ * Plain SQL format: "-- Dumped from database version 16.1"
97
+ * Archive format: Uses `pg_restore -l` to read TOC header
98
+ */
99
+ export async function parseDumpVersion(
100
+ dumpPath: string,
101
+ format: string,
102
+ pgRestorePath?: string,
103
+ ): Promise<VersionInfo | null> {
104
+ try {
105
+ if (format === 'custom' || format === 'directory') {
106
+ // Use pg_restore -l to get archive info
107
+ const restorePath = pgRestorePath || 'pg_restore'
108
+ const { stdout } = await execAsync(
109
+ `"${restorePath}" -l "${dumpPath}" 2>&1 | head -20`,
110
+ )
111
+ // Look for: "; Dumped from database version 16.1"
112
+ const match = stdout.match(
113
+ /Dumped from database version (\d+)\.(\d+)(?:\.(\d+))?/,
114
+ )
115
+ if (match) {
116
+ return {
117
+ major: parseInt(match[1], 10),
118
+ minor: parseInt(match[2], 10),
119
+ patch: parseInt(match[3] || '0', 10),
120
+ full: `${match[1]}.${match[2]}${match[3] ? `.${match[3]}` : ''}`,
121
+ }
122
+ }
123
+ } else {
124
+ // Plain SQL format - read first 50 lines
125
+ const header = await readFirstLines(dumpPath, 50)
126
+ const match = header.match(
127
+ /Dumped from database version (\d+)\.(\d+)(?:\.(\d+))?/,
128
+ )
129
+ if (match) {
130
+ return {
131
+ major: parseInt(match[1], 10),
132
+ minor: parseInt(match[2], 10),
133
+ patch: parseInt(match[3] || '0', 10),
134
+ full: `${match[1]}.${match[2]}${match[3] ? `.${match[3]}` : ''}`,
135
+ }
136
+ }
137
+ }
138
+ } catch (err) {
139
+ logDebug('Failed to parse dump version', {
140
+ dumpPath,
141
+ format,
142
+ error: err instanceof Error ? err.message : String(err),
143
+ })
144
+ }
145
+
146
+ return null // Version not found in dump
147
+ }
148
+
149
+ /**
150
+ * Get the version of pg_restore
151
+ */
152
+ export async function getPgRestoreVersion(
153
+ pgRestorePath: string,
154
+ ): Promise<VersionInfo> {
155
+ const { stdout } = await execAsync(`"${pgRestorePath}" --version`)
156
+ return parseToolVersion(stdout)
157
+ }
158
+
159
+ // =============================================================================
160
+ // Compatibility Checking
161
+ // =============================================================================
162
+
163
+ /**
164
+ * Check version compatibility - ONLY fails when dump is NEWER than tool
165
+ *
166
+ * | Scenario | Result |
167
+ * |----------|--------|
168
+ * | pg_restore v16 + dump from v14 | ✅ Works (backwards compatible) |
169
+ * | pg_restore v16 + dump from v16 | ✅ Works (same version) |
170
+ * | pg_restore v14 + dump from v16 | ❌ Fails (dump newer than tool) |
171
+ * | pg_restore v16 + dump from v10 | ⚠️ Works with warning (very old) |
172
+ */
173
+ export function checkVersionCompatibility(
174
+ dumpVersion: VersionInfo | null,
175
+ toolVersion: VersionInfo,
176
+ ): CompatibilityResult {
177
+ // If we couldn't parse dump version, proceed with warning
178
+ if (!dumpVersion) {
179
+ return {
180
+ compatible: true,
181
+ dumpVersion: null,
182
+ toolVersion,
183
+ warning: 'Could not detect dump version. Proceeding anyway.',
184
+ }
185
+ }
186
+
187
+ // FAIL: Dump is newer than tool (e.g., pg_restore 14 + dump from 16)
188
+ if (dumpVersion.major > toolVersion.major) {
189
+ return {
190
+ compatible: false,
191
+ dumpVersion,
192
+ toolVersion,
193
+ error:
194
+ `Backup was created with PostgreSQL ${dumpVersion.major}, ` +
195
+ `but your pg_restore is version ${toolVersion.major}. ` +
196
+ `Install PostgreSQL ${dumpVersion.major} client tools to restore this backup.`,
197
+ }
198
+ }
199
+
200
+ // WARN: Dump is very old (3+ major versions behind)
201
+ if (dumpVersion.major < toolVersion.major - 2) {
202
+ return {
203
+ compatible: true,
204
+ dumpVersion,
205
+ toolVersion,
206
+ warning:
207
+ `Backup was created with PostgreSQL ${dumpVersion.major}. ` +
208
+ `Some features may not restore correctly.`,
209
+ }
210
+ }
211
+
212
+ // OK: Same version or dump is older (backwards compatible)
213
+ return { compatible: true, dumpVersion, toolVersion }
214
+ }
215
+
216
+ // =============================================================================
217
+ // Main Validation Function
218
+ // =============================================================================
219
+
220
+ /**
221
+ * Validate that a dump file can be restored with the available pg_restore
222
+ *
223
+ * @throws SpinDBError if versions are incompatible
224
+ */
225
+ export async function validateRestoreCompatibility(options: {
226
+ dumpPath: string
227
+ format: string
228
+ pgRestorePath: string
229
+ }): Promise<{ dumpVersion: VersionInfo | null; toolVersion: VersionInfo }> {
230
+ const { dumpPath, format, pgRestorePath } = options
231
+
232
+ // Get tool version
233
+ const toolVersion = await getPgRestoreVersion(pgRestorePath)
234
+ logDebug('pg_restore version detected', { version: toolVersion.full })
235
+
236
+ // Get dump version
237
+ const dumpVersion = await parseDumpVersion(dumpPath, format, pgRestorePath)
238
+ if (dumpVersion) {
239
+ logDebug('Dump version detected', { version: dumpVersion.full })
240
+ } else {
241
+ logDebug('Could not detect dump version')
242
+ }
243
+
244
+ // Check compatibility
245
+ const result = checkVersionCompatibility(dumpVersion, toolVersion)
246
+
247
+ if (!result.compatible) {
248
+ throw new SpinDBError(
249
+ ErrorCodes.VERSION_MISMATCH,
250
+ result.error!,
251
+ 'fatal',
252
+ `Install PostgreSQL ${dumpVersion!.major} client tools: brew install postgresql@${dumpVersion!.major}`,
253
+ { dumpVersion, toolVersion },
254
+ )
255
+ }
256
+
257
+ if (result.warning) {
258
+ logWarning(result.warning)
259
+ }
260
+
261
+ return { dumpVersion, toolVersion }
262
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.5.2",
4
- "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL.",
3
+ "version": "0.5.3",
4
+ "description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL and MySQL.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "spindb": "./bin/cli.js"
@@ -12,12 +12,16 @@
12
12
  "test": "pnpm test:pg && pnpm test:mysql",
13
13
  "test:pg": "node --import tsx --test tests/integration/postgresql.test.ts",
14
14
  "test:mysql": "node --import tsx --test tests/integration/mysql.test.ts",
15
+ "test:unit": "node --import tsx --test tests/unit/*.test.ts",
16
+ "test:integration": "pnpm test:pg && pnpm test:mysql",
17
+ "test:all": "pnpm test:unit && pnpm test:integration",
15
18
  "format": "prettier --write .",
16
19
  "lint": "tsc --noEmit && eslint ."
17
20
  },
18
21
  "keywords": [
19
22
  "postgres",
20
23
  "postgresql",
24
+ "mysql",
21
25
  "database",
22
26
  "local",
23
27
  "development",