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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL/MariaDB Backup Detection and Restore
|
|
3
|
+
*
|
|
4
|
+
* Handles detecting backup formats and restoring MySQL dumps.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'child_process'
|
|
8
|
+
import { createReadStream } from 'fs'
|
|
9
|
+
import { open } from 'fs/promises'
|
|
10
|
+
import { getMysqlClientPath } from './binary-detection'
|
|
11
|
+
import { validateRestoreCompatibility } from './version-validator'
|
|
12
|
+
import { getEngineDefaults } from '../../config/defaults'
|
|
13
|
+
import { logDebug, SpinDBError, ErrorCodes } from '../../core/error-handler'
|
|
14
|
+
import type { BackupFormat, RestoreResult } from '../../types'
|
|
15
|
+
|
|
16
|
+
const engineDef = getEngineDefaults('mysql')
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Backup Format Detection
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Detect the format of a MySQL backup file
|
|
24
|
+
*
|
|
25
|
+
* MySQL primarily uses SQL dumps (unlike PostgreSQL which has multiple formats).
|
|
26
|
+
* We detect:
|
|
27
|
+
* - MySQL SQL dump (mysqldump output)
|
|
28
|
+
* - MariaDB SQL dump
|
|
29
|
+
* - PostgreSQL dumps (to provide helpful error)
|
|
30
|
+
* - Generic SQL files
|
|
31
|
+
* - Compressed files (gzip)
|
|
32
|
+
*/
|
|
33
|
+
export async function detectBackupFormat(
|
|
34
|
+
filePath: string,
|
|
35
|
+
): Promise<BackupFormat> {
|
|
36
|
+
const buffer = Buffer.alloc(128)
|
|
37
|
+
const file = await open(filePath, 'r')
|
|
38
|
+
await file.read(buffer, 0, 128, 0)
|
|
39
|
+
await file.close()
|
|
40
|
+
|
|
41
|
+
const header = buffer.toString('utf8')
|
|
42
|
+
|
|
43
|
+
// Check for PostgreSQL custom format (PGDMP magic bytes)
|
|
44
|
+
if (buffer.toString('ascii', 0, 5) === 'PGDMP') {
|
|
45
|
+
return {
|
|
46
|
+
format: 'postgresql_custom',
|
|
47
|
+
description: 'PostgreSQL custom format dump (incompatible with MySQL)',
|
|
48
|
+
restoreCommand: 'pg_restore',
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check for PostgreSQL SQL dump markers
|
|
53
|
+
if (
|
|
54
|
+
header.includes('-- PostgreSQL database dump') ||
|
|
55
|
+
header.includes('pg_dump') ||
|
|
56
|
+
header.includes('Dumped from database version')
|
|
57
|
+
) {
|
|
58
|
+
return {
|
|
59
|
+
format: 'postgresql_sql',
|
|
60
|
+
description: 'PostgreSQL SQL dump (incompatible with MySQL)',
|
|
61
|
+
restoreCommand: 'psql',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check for gzip compression
|
|
66
|
+
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
67
|
+
return {
|
|
68
|
+
format: 'compressed',
|
|
69
|
+
description: 'Gzip compressed SQL dump',
|
|
70
|
+
restoreCommand: 'mysql',
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check for MySQL dump markers
|
|
75
|
+
if (header.includes('-- MySQL dump')) {
|
|
76
|
+
return {
|
|
77
|
+
format: 'sql',
|
|
78
|
+
description: 'MySQL SQL dump (mysqldump)',
|
|
79
|
+
restoreCommand: 'mysql',
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check for MariaDB dump markers
|
|
84
|
+
if (header.includes('-- MariaDB dump')) {
|
|
85
|
+
return {
|
|
86
|
+
format: 'sql',
|
|
87
|
+
description: 'MariaDB SQL dump (mysqldump)',
|
|
88
|
+
restoreCommand: 'mysql',
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if it looks like SQL (starts with common SQL statements)
|
|
93
|
+
const textStart = header.toLowerCase()
|
|
94
|
+
if (
|
|
95
|
+
textStart.startsWith('--') ||
|
|
96
|
+
textStart.startsWith('/*') ||
|
|
97
|
+
textStart.startsWith('set ') ||
|
|
98
|
+
textStart.startsWith('create') ||
|
|
99
|
+
textStart.startsWith('drop') ||
|
|
100
|
+
textStart.startsWith('begin') ||
|
|
101
|
+
textStart.startsWith('use ')
|
|
102
|
+
) {
|
|
103
|
+
return {
|
|
104
|
+
format: 'sql',
|
|
105
|
+
description: 'SQL file',
|
|
106
|
+
restoreCommand: 'mysql',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Default to SQL format
|
|
111
|
+
return {
|
|
112
|
+
format: 'unknown',
|
|
113
|
+
description: 'Unknown format - will attempt as SQL',
|
|
114
|
+
restoreCommand: 'mysql',
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if the backup file is from the wrong engine and throw helpful error
|
|
120
|
+
*/
|
|
121
|
+
export function assertCompatibleFormat(format: BackupFormat): void {
|
|
122
|
+
if (
|
|
123
|
+
format.format === 'postgresql_custom' ||
|
|
124
|
+
format.format === 'postgresql_sql'
|
|
125
|
+
) {
|
|
126
|
+
throw new SpinDBError(
|
|
127
|
+
ErrorCodes.WRONG_ENGINE_DUMP,
|
|
128
|
+
`This appears to be a PostgreSQL dump file, but you're trying to restore it to MySQL.`,
|
|
129
|
+
'fatal',
|
|
130
|
+
`Create a PostgreSQL container instead:\n spindb create mydb --engine postgresql --from <dump-file>`,
|
|
131
|
+
{
|
|
132
|
+
detectedFormat: format.format,
|
|
133
|
+
expectedEngine: 'mysql',
|
|
134
|
+
detectedEngine: 'postgresql',
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// =============================================================================
|
|
141
|
+
// Restore Options
|
|
142
|
+
// =============================================================================
|
|
143
|
+
|
|
144
|
+
export type RestoreOptions = {
|
|
145
|
+
port: number
|
|
146
|
+
database: string
|
|
147
|
+
user?: string
|
|
148
|
+
createDatabase?: boolean
|
|
149
|
+
validateVersion?: boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// =============================================================================
|
|
153
|
+
// Restore Functions
|
|
154
|
+
// =============================================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Restore a MySQL backup to a database
|
|
158
|
+
*
|
|
159
|
+
* CLI equivalent: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
|
|
160
|
+
*/
|
|
161
|
+
export async function restoreBackup(
|
|
162
|
+
backupPath: string,
|
|
163
|
+
options: RestoreOptions,
|
|
164
|
+
): Promise<RestoreResult> {
|
|
165
|
+
const {
|
|
166
|
+
port,
|
|
167
|
+
database,
|
|
168
|
+
user = engineDef.superuser,
|
|
169
|
+
validateVersion = true,
|
|
170
|
+
} = options
|
|
171
|
+
|
|
172
|
+
// Validate version compatibility if requested
|
|
173
|
+
if (validateVersion) {
|
|
174
|
+
try {
|
|
175
|
+
await validateRestoreCompatibility({ dumpPath: backupPath })
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Re-throw SpinDBError, log and continue for other errors
|
|
178
|
+
if (err instanceof Error && err.name === 'SpinDBError') {
|
|
179
|
+
throw err
|
|
180
|
+
}
|
|
181
|
+
logDebug('Version validation failed, proceeding anyway', {
|
|
182
|
+
error: err instanceof Error ? err.message : String(err),
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const mysql = await getMysqlClientPath()
|
|
188
|
+
if (!mysql) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
'mysql client not found. Install MySQL client tools:\n' +
|
|
191
|
+
' macOS: brew install mysql-client\n' +
|
|
192
|
+
' Ubuntu/Debian: sudo apt install mysql-client',
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Detect format and check for wrong engine
|
|
197
|
+
const format = await detectBackupFormat(backupPath)
|
|
198
|
+
logDebug('Detected backup format', { format: format.format })
|
|
199
|
+
assertCompatibleFormat(format)
|
|
200
|
+
|
|
201
|
+
// Restore using mysql client
|
|
202
|
+
// CLI: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const args = ['-h', '127.0.0.1', '-P', String(port), '-u', user, database]
|
|
205
|
+
|
|
206
|
+
const proc = spawn(mysql, args, {
|
|
207
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// Pipe backup file to stdin
|
|
211
|
+
const fileStream = createReadStream(backupPath)
|
|
212
|
+
fileStream.pipe(proc.stdin)
|
|
213
|
+
|
|
214
|
+
let stdout = ''
|
|
215
|
+
let stderr = ''
|
|
216
|
+
|
|
217
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
218
|
+
stdout += data.toString()
|
|
219
|
+
})
|
|
220
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
221
|
+
stderr += data.toString()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
proc.on('close', (code) => {
|
|
225
|
+
resolve({
|
|
226
|
+
format: format.format,
|
|
227
|
+
stdout,
|
|
228
|
+
stderr,
|
|
229
|
+
code: code ?? undefined,
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
proc.on('error', reject)
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Parse a MySQL connection string
|
|
239
|
+
*
|
|
240
|
+
* Format: mysql://user:pass@host:port/database
|
|
241
|
+
*/
|
|
242
|
+
export function parseConnectionString(connectionString: string): {
|
|
243
|
+
host: string
|
|
244
|
+
port: string
|
|
245
|
+
user: string
|
|
246
|
+
password: string
|
|
247
|
+
database: string
|
|
248
|
+
} {
|
|
249
|
+
const url = new URL(connectionString)
|
|
250
|
+
return {
|
|
251
|
+
host: url.hostname,
|
|
252
|
+
port: url.port || '3306',
|
|
253
|
+
user: url.username || 'root',
|
|
254
|
+
password: url.password || '',
|
|
255
|
+
database: url.pathname.slice(1), // Remove leading /
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL/MariaDB Version Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates compatibility between mysql client version and dump file version.
|
|
5
|
+
* MySQL is generally more lenient than PostgreSQL, but we still warn about:
|
|
6
|
+
* - MariaDB dumps being restored to MySQL (and vice versa)
|
|
7
|
+
* - Newer dumps being restored to older clients
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { exec } from 'child_process'
|
|
11
|
+
import { promisify } from 'util'
|
|
12
|
+
import { createReadStream } from 'fs'
|
|
13
|
+
import { createInterface } from 'readline'
|
|
14
|
+
import {
|
|
15
|
+
SpinDBError,
|
|
16
|
+
ErrorCodes,
|
|
17
|
+
logWarning,
|
|
18
|
+
logDebug,
|
|
19
|
+
} from '../../core/error-handler'
|
|
20
|
+
import { getMysqlClientPath } from './binary-detection'
|
|
21
|
+
|
|
22
|
+
const execAsync = promisify(exec)
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export type VersionInfo = {
|
|
29
|
+
major: number
|
|
30
|
+
minor: number
|
|
31
|
+
patch: number
|
|
32
|
+
full: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type MySQLVariant = 'mysql' | 'mariadb' | 'unknown'
|
|
36
|
+
|
|
37
|
+
export type DumpInfo = {
|
|
38
|
+
version: VersionInfo | null
|
|
39
|
+
variant: MySQLVariant
|
|
40
|
+
serverVersion?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type CompatibilityResult = {
|
|
44
|
+
compatible: boolean
|
|
45
|
+
dumpInfo: DumpInfo
|
|
46
|
+
toolVersion: VersionInfo
|
|
47
|
+
toolVariant: MySQLVariant
|
|
48
|
+
warning?: string
|
|
49
|
+
error?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// Version Parsing
|
|
54
|
+
// =============================================================================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse version from mysql --version output
|
|
58
|
+
* Examples:
|
|
59
|
+
* "mysql Ver 8.0.35 for macos14.0 on arm64 (Homebrew)"
|
|
60
|
+
* "mysql Ver 14.14 Distrib 5.7.44, for Linux (x86_64)" (MySQL 5.7)
|
|
61
|
+
* "mysql Ver 15.1 Distrib 10.11.6-MariaDB, for osx10.19 (arm64)"
|
|
62
|
+
* "mysql from 11.4.3-MariaDB, client 15.2 for osx10.20 (arm64)"
|
|
63
|
+
*/
|
|
64
|
+
export function parseToolVersion(output: string): {
|
|
65
|
+
version: VersionInfo
|
|
66
|
+
variant: MySQLVariant
|
|
67
|
+
} {
|
|
68
|
+
// Check for MariaDB - must explicitly contain "mariadb" in the string
|
|
69
|
+
// Note: Both MySQL 5.7 and MariaDB use "Distrib", but only MariaDB includes "-MariaDB"
|
|
70
|
+
const isMariaDB = output.toLowerCase().includes('mariadb')
|
|
71
|
+
|
|
72
|
+
let match: RegExpMatchArray | null = null
|
|
73
|
+
|
|
74
|
+
if (isMariaDB) {
|
|
75
|
+
// MariaDB: "Distrib 10.11.6-MariaDB" or "from 11.4.3-MariaDB"
|
|
76
|
+
match = output.match(/(?:Distrib|from)\s+(\d+)\.(\d+)\.(\d+)/)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!match) {
|
|
80
|
+
// MySQL with Distrib: "Distrib 5.7.44" (MySQL 5.7 style)
|
|
81
|
+
match = output.match(/Distrib\s+(\d+)\.(\d+)\.(\d+)/)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!match) {
|
|
85
|
+
// MySQL: "Ver 8.0.35"
|
|
86
|
+
match = output.match(/Ver\s+(\d+)\.(\d+)(?:\.(\d+))?/)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!match) {
|
|
90
|
+
// Generic fallback
|
|
91
|
+
match = output.match(/(\d+)\.(\d+)(?:\.(\d+))?/)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!match) {
|
|
95
|
+
throw new Error(`Cannot parse version from: ${output}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
version: {
|
|
100
|
+
major: parseInt(match[1], 10),
|
|
101
|
+
minor: parseInt(match[2], 10),
|
|
102
|
+
patch: parseInt(match[3] || '0', 10),
|
|
103
|
+
full: match[0].replace(/^(Ver|Distrib|from)\s+/, ''),
|
|
104
|
+
},
|
|
105
|
+
variant: isMariaDB ? 'mariadb' : 'mysql',
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the first N lines of a file
|
|
111
|
+
*/
|
|
112
|
+
async function readFirstLines(
|
|
113
|
+
filePath: string,
|
|
114
|
+
lineCount: number,
|
|
115
|
+
): Promise<string> {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const lines: string[] = []
|
|
118
|
+
const stream = createReadStream(filePath, { encoding: 'utf8' })
|
|
119
|
+
const rl = createInterface({ input: stream })
|
|
120
|
+
|
|
121
|
+
rl.on('line', (line) => {
|
|
122
|
+
lines.push(line)
|
|
123
|
+
if (lines.length >= lineCount) {
|
|
124
|
+
rl.close()
|
|
125
|
+
stream.destroy()
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
rl.on('close', () => {
|
|
130
|
+
resolve(lines.join('\n'))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
rl.on('error', reject)
|
|
134
|
+
stream.on('error', reject)
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parse version from dump file header
|
|
140
|
+
*
|
|
141
|
+
* MySQL dump header:
|
|
142
|
+
* -- MySQL dump 10.13 Distrib 8.0.35, for macos14.0 (arm64)
|
|
143
|
+
* -- Server version 8.0.35
|
|
144
|
+
*
|
|
145
|
+
* MariaDB dump header:
|
|
146
|
+
* -- MariaDB dump 10.19-11.4.3-MariaDB, for osx10.20 (arm64)
|
|
147
|
+
* -- Server version 11.4.3-MariaDB
|
|
148
|
+
*/
|
|
149
|
+
export async function parseDumpVersion(dumpPath: string): Promise<DumpInfo> {
|
|
150
|
+
try {
|
|
151
|
+
const header = await readFirstLines(dumpPath, 30)
|
|
152
|
+
|
|
153
|
+
// Detect variant
|
|
154
|
+
let variant: MySQLVariant = 'unknown'
|
|
155
|
+
if (header.includes('MariaDB dump') || header.includes('-MariaDB')) {
|
|
156
|
+
variant = 'mariadb'
|
|
157
|
+
} else if (header.includes('MySQL dump')) {
|
|
158
|
+
variant = 'mysql'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Try to get server version (more accurate than dump tool version)
|
|
162
|
+
// "-- Server version 8.0.35" or "-- Server version 11.4.3-MariaDB"
|
|
163
|
+
const serverMatch = header.match(
|
|
164
|
+
/--\s*Server version\s+(\d+)\.(\d+)(?:\.(\d+))?/,
|
|
165
|
+
)
|
|
166
|
+
if (serverMatch) {
|
|
167
|
+
return {
|
|
168
|
+
version: {
|
|
169
|
+
major: parseInt(serverMatch[1], 10),
|
|
170
|
+
minor: parseInt(serverMatch[2], 10),
|
|
171
|
+
patch: parseInt(serverMatch[3] || '0', 10),
|
|
172
|
+
full: `${serverMatch[1]}.${serverMatch[2]}${serverMatch[3] ? `.${serverMatch[3]}` : ''}`,
|
|
173
|
+
},
|
|
174
|
+
variant,
|
|
175
|
+
serverVersion: header.match(/--\s*Server version\s+([^\n]+)/)?.[1],
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Fall back to Distrib version in header
|
|
180
|
+
// "Distrib 8.0.35" or "10.19-11.4.3-MariaDB"
|
|
181
|
+
let distribMatch = header.match(/Distrib\s+(\d+)\.(\d+)(?:\.(\d+))?/)
|
|
182
|
+
if (!distribMatch && variant === 'mariadb') {
|
|
183
|
+
// MariaDB format: "dump 10.19-11.4.3-MariaDB"
|
|
184
|
+
distribMatch = header.match(/dump\s+[\d.]+-(\d+)\.(\d+)\.(\d+)/)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (distribMatch) {
|
|
188
|
+
return {
|
|
189
|
+
version: {
|
|
190
|
+
major: parseInt(distribMatch[1], 10),
|
|
191
|
+
minor: parseInt(distribMatch[2], 10),
|
|
192
|
+
patch: parseInt(distribMatch[3] || '0', 10),
|
|
193
|
+
full: `${distribMatch[1]}.${distribMatch[2]}${distribMatch[3] ? `.${distribMatch[3]}` : ''}`,
|
|
194
|
+
},
|
|
195
|
+
variant,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { version: null, variant }
|
|
200
|
+
} catch (err) {
|
|
201
|
+
logDebug('Failed to parse dump version', {
|
|
202
|
+
dumpPath,
|
|
203
|
+
error: err instanceof Error ? err.message : String(err),
|
|
204
|
+
})
|
|
205
|
+
return { version: null, variant: 'unknown' }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get the version of the mysql client
|
|
211
|
+
*/
|
|
212
|
+
export async function getMysqlClientVersion(): Promise<{
|
|
213
|
+
version: VersionInfo
|
|
214
|
+
variant: MySQLVariant
|
|
215
|
+
}> {
|
|
216
|
+
const mysqlPath = await getMysqlClientPath()
|
|
217
|
+
if (!mysqlPath) {
|
|
218
|
+
throw new Error('mysql client not found')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { stdout } = await execAsync(`"${mysqlPath}" --version`)
|
|
222
|
+
return parseToolVersion(stdout)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// =============================================================================
|
|
226
|
+
// Compatibility Checking
|
|
227
|
+
// =============================================================================
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check version compatibility
|
|
231
|
+
*
|
|
232
|
+
* MySQL/MariaDB compatibility matrix:
|
|
233
|
+
* | Scenario | Result |
|
|
234
|
+
* |----------|--------|
|
|
235
|
+
* | MySQL 8 client + MySQL 8 dump | ✅ Works |
|
|
236
|
+
* | MySQL 8 client + MySQL 5.7 dump | ✅ Works (backwards compatible) |
|
|
237
|
+
* | MySQL 5.7 client + MySQL 8 dump | ⚠️ May have issues |
|
|
238
|
+
* | MariaDB client + MySQL dump | ⚠️ Warning (mostly compatible) |
|
|
239
|
+
* | MySQL client + MariaDB dump | ⚠️ Warning (mostly compatible) |
|
|
240
|
+
*/
|
|
241
|
+
export function checkVersionCompatibility(
|
|
242
|
+
dumpInfo: DumpInfo,
|
|
243
|
+
toolVersion: VersionInfo,
|
|
244
|
+
toolVariant: MySQLVariant,
|
|
245
|
+
): CompatibilityResult {
|
|
246
|
+
const result: CompatibilityResult = {
|
|
247
|
+
compatible: true,
|
|
248
|
+
dumpInfo,
|
|
249
|
+
toolVersion,
|
|
250
|
+
toolVariant,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If we couldn't parse dump version, proceed with warning
|
|
254
|
+
if (!dumpInfo.version) {
|
|
255
|
+
result.warning = 'Could not detect dump version. Proceeding anyway.'
|
|
256
|
+
return result
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check for variant mismatch (MySQL vs MariaDB)
|
|
260
|
+
if (
|
|
261
|
+
dumpInfo.variant !== 'unknown' &&
|
|
262
|
+
toolVariant !== 'unknown' &&
|
|
263
|
+
dumpInfo.variant !== toolVariant
|
|
264
|
+
) {
|
|
265
|
+
result.warning =
|
|
266
|
+
`Dump was created with ${dumpInfo.variant === 'mariadb' ? 'MariaDB' : 'MySQL'}, ` +
|
|
267
|
+
`but restoring with ${toolVariant === 'mariadb' ? 'MariaDB' : 'MySQL'}. ` +
|
|
268
|
+
`This usually works, but some features may not be compatible.`
|
|
269
|
+
return result
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// MySQL 8 introduced significant changes
|
|
273
|
+
// Restoring MySQL 8+ dump with MySQL 5.x client may fail
|
|
274
|
+
if (dumpInfo.version.major >= 8 && toolVersion.major < 8) {
|
|
275
|
+
result.compatible = false
|
|
276
|
+
result.error =
|
|
277
|
+
`Dump was created with MySQL ${dumpInfo.version.major}, ` +
|
|
278
|
+
`but your mysql client is version ${toolVersion.major}. ` +
|
|
279
|
+
`MySQL 8 dumps may contain syntax not supported by older clients.`
|
|
280
|
+
return result
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// MariaDB 10.x to MySQL may have issues with specific features
|
|
284
|
+
if (
|
|
285
|
+
dumpInfo.variant === 'mariadb' &&
|
|
286
|
+
toolVariant === 'mysql' &&
|
|
287
|
+
dumpInfo.version.major >= 10
|
|
288
|
+
) {
|
|
289
|
+
result.warning =
|
|
290
|
+
`Dump was created with MariaDB ${dumpInfo.version.full}. ` +
|
|
291
|
+
`Some MariaDB-specific features may not restore correctly to MySQL.`
|
|
292
|
+
return result
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Warn if dump is newer than tool (any variant)
|
|
296
|
+
if (dumpInfo.version.major > toolVersion.major) {
|
|
297
|
+
result.warning =
|
|
298
|
+
`Dump was created with version ${dumpInfo.version.full}, ` +
|
|
299
|
+
`but your client is version ${toolVersion.full}. ` +
|
|
300
|
+
`Some features may not restore correctly.`
|
|
301
|
+
return result
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Warn if dump is very old (5+ years)
|
|
305
|
+
if (
|
|
306
|
+
dumpInfo.version.major < 5 ||
|
|
307
|
+
(dumpInfo.version.major === 5 && dumpInfo.version.minor < 7)
|
|
308
|
+
) {
|
|
309
|
+
result.warning =
|
|
310
|
+
`Dump was created with MySQL ${dumpInfo.version.full}. ` +
|
|
311
|
+
`This is a very old version; some data types may not import correctly.`
|
|
312
|
+
return result
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return result
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// =============================================================================
|
|
319
|
+
// Main Validation Function
|
|
320
|
+
// =============================================================================
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Validate that a dump file can be restored with the available mysql client
|
|
324
|
+
*
|
|
325
|
+
* @throws SpinDBError if versions are incompatible
|
|
326
|
+
*/
|
|
327
|
+
export async function validateRestoreCompatibility(options: {
|
|
328
|
+
dumpPath: string
|
|
329
|
+
}): Promise<{
|
|
330
|
+
dumpInfo: DumpInfo
|
|
331
|
+
toolVersion: VersionInfo
|
|
332
|
+
toolVariant: MySQLVariant
|
|
333
|
+
}> {
|
|
334
|
+
const { dumpPath } = options
|
|
335
|
+
|
|
336
|
+
// Get tool version
|
|
337
|
+
const { version: toolVersion, variant: toolVariant } =
|
|
338
|
+
await getMysqlClientVersion()
|
|
339
|
+
logDebug('mysql client version detected', {
|
|
340
|
+
version: toolVersion.full,
|
|
341
|
+
variant: toolVariant,
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// Get dump version
|
|
345
|
+
const dumpInfo = await parseDumpVersion(dumpPath)
|
|
346
|
+
if (dumpInfo.version) {
|
|
347
|
+
logDebug('Dump version detected', {
|
|
348
|
+
version: dumpInfo.version.full,
|
|
349
|
+
variant: dumpInfo.variant,
|
|
350
|
+
})
|
|
351
|
+
} else {
|
|
352
|
+
logDebug('Could not detect dump version')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Check compatibility
|
|
356
|
+
const result = checkVersionCompatibility(dumpInfo, toolVersion, toolVariant)
|
|
357
|
+
|
|
358
|
+
if (!result.compatible) {
|
|
359
|
+
throw new SpinDBError(
|
|
360
|
+
ErrorCodes.VERSION_MISMATCH,
|
|
361
|
+
result.error!,
|
|
362
|
+
'fatal',
|
|
363
|
+
'Install a newer version of MySQL client tools',
|
|
364
|
+
{ dumpInfo, toolVersion, toolVariant },
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (result.warning) {
|
|
369
|
+
logWarning(result.warning)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { dumpInfo, toolVersion, toolVariant }
|
|
373
|
+
}
|