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.
- package/README.md +137 -8
- package/cli/commands/connect.ts +8 -4
- package/cli/commands/create.ts +106 -67
- package/cli/commands/deps.ts +19 -4
- package/cli/commands/edit.ts +245 -0
- package/cli/commands/engines.ts +434 -0
- package/cli/commands/info.ts +279 -0
- package/cli/commands/menu.ts +408 -153
- package/cli/commands/restore.ts +10 -24
- package/cli/commands/start.ts +25 -20
- package/cli/commands/url.ts +79 -0
- package/cli/index.ts +9 -3
- package/cli/ui/prompts.ts +8 -6
- package/config/engine-defaults.ts +24 -1
- package/config/os-dependencies.ts +59 -113
- package/config/paths.ts +7 -36
- package/core/binary-manager.ts +19 -6
- package/core/config-manager.ts +17 -5
- package/core/dependency-manager.ts +9 -15
- package/core/error-handler.ts +336 -0
- package/core/platform-service.ts +634 -0
- package/core/port-manager.ts +11 -3
- package/core/process-manager.ts +12 -2
- package/core/start-with-retry.ts +167 -0
- package/core/transaction-manager.ts +170 -0
- package/engines/mysql/binary-detection.ts +177 -100
- package/engines/mysql/index.ts +240 -131
- package/engines/mysql/restore.ts +257 -0
- package/engines/mysql/version-validator.ts +373 -0
- package/{core/postgres-binary-manager.ts → engines/postgresql/binary-manager.ts} +63 -23
- package/engines/postgresql/binary-urls.ts +5 -3
- package/engines/postgresql/index.ts +4 -3
- package/engines/postgresql/restore.ts +54 -5
- package/engines/postgresql/version-validator.ts +262 -0
- package/package.json +6 -2
- 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 '
|
|
5
|
-
import { warning, error as themeError, success } from '
|
|
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 '
|
|
12
|
-
import { getEngineDependencies } from '
|
|
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
|
-
|
|
45
|
+
`brew install ${pgPackage} && brew link --overwrite ${pgPackage}`,
|
|
43
46
|
updateCommand: () =>
|
|
44
|
-
|
|
47
|
+
`brew link --overwrite ${pgPackage} || brew install ${pgPackage} && brew link --overwrite ${pgPackage}`,
|
|
45
48
|
versionCheckCommand: () =>
|
|
46
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
364
|
-
|
|
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(
|
|
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
|
-
|
|
434
|
+
` brew unlink ${olderVersions.map((v) => `postgresql@${v}`).join(' ')}`,
|
|
395
435
|
),
|
|
396
436
|
)
|
|
397
|
-
console.log(chalk.yellow(
|
|
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 {
|
|
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(
|
|
36
|
+
const zonkyPlatform = platformService.getZonkyPlatform()
|
|
37
37
|
if (!zonkyPlatform) {
|
|
38
|
-
|
|
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 '
|
|
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
|
|
20
|
+
// Read the first 128 bytes to detect format
|
|
17
21
|
const file = await readFile(filePath)
|
|
18
|
-
const buffer = Buffer.alloc(
|
|
22
|
+
const buffer = Buffer.alloc(128)
|
|
19
23
|
|
|
20
24
|
// Copy first bytes
|
|
21
|
-
file.copy(buffer, 0, 0, Math.min(
|
|
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
|
-
|
|
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.
|
|
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",
|