spindb 0.26.2 → 0.27.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.
@@ -199,47 +199,32 @@ async function getMysqlClientPath(binPath?: string): Promise<string> {
199
199
  )
200
200
  }
201
201
 
202
- export async function restoreBackup(
203
- backupPath: string,
204
- options: RestoreOptions,
205
- ): Promise<RestoreResult> {
206
- const {
207
- port,
208
- database,
209
- user = engineDef.superuser,
210
- validateVersion = true,
211
- binPath,
212
- } = options
202
+ // Compatibility SQL to handle large row sizes and other edge cases
203
+ // - innodb_default_row_format=DYNAMIC: Store long columns off-page to avoid row size limits
204
+ // - innodb_strict_mode=OFF: Allow tables that might exceed row size limits in strict mode
205
+ // - foreign_key_checks=0: Defer FK checks until after all tables are created
206
+ // - unique_checks=0: Speed up bulk inserts
207
+ const COMPAT_INIT_SQL = [
208
+ "SET GLOBAL innodb_default_row_format='dynamic';",
209
+ 'SET SESSION innodb_strict_mode=OFF;',
210
+ "SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION';",
211
+ 'SET SESSION foreign_key_checks=0;',
212
+ 'SET SESSION unique_checks=0;',
213
+ '',
214
+ ].join('\n')
213
215
 
214
- // Validate version compatibility if requested
215
- if (validateVersion) {
216
- try {
217
- await validateRestoreCompatibility({ dumpPath: backupPath })
218
- } catch (error) {
219
- // Re-throw SpinDBError, log and continue for other errors
220
- if (error instanceof Error && error.name === 'SpinDBError') {
221
- throw error
222
- }
223
- logDebug('Version validation failed, proceeding anyway', {
224
- error: error instanceof Error ? error.message : String(error),
225
- })
226
- }
227
- }
228
-
229
- const mysql = await getMysqlClientPath(binPath)
230
-
231
- // Detect format and check for wrong engine
232
- const format = await detectBackupFormat(backupPath)
233
- logDebug('Detected backup format', { format: format.format })
234
- assertCompatibleFormat(format)
235
-
236
- // Restore using mysql client
237
- // CLI: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
238
- // For compressed files: gunzip -c {file} | mysql ...
239
-
240
- // Use plain spawn (no shell) so the executable path can be quoted when
241
- // it contains spaces (e.g., 'C:\Program Files\...'). Using shell:true
242
- // previously caused quoting issues on Windows.
216
+ /**
217
+ * Internal restore function with optional compatibility mode
218
+ */
219
+ function doRestore(
220
+ backupPath: string,
221
+ mysql: string,
222
+ port: number,
223
+ database: string,
224
+ user: string,
225
+ format: BackupFormat,
226
+ withCompatSettings: boolean,
227
+ ): Promise<RestoreResult & { rawStderr?: string }> {
243
228
  const spawnOptions: SpawnOptions = {
244
229
  stdio: ['pipe', 'pipe', 'pipe'],
245
230
  }
@@ -247,39 +232,68 @@ export async function restoreBackup(
247
232
  return new Promise((resolve, reject) => {
248
233
  const args = ['-h', '127.0.0.1', '-P', String(port), '-u', user, database]
249
234
 
250
- logDebug('Restoring backup with mysql', { mysql, args, spawnOptions })
235
+ logDebug('Restoring backup with mysql', {
236
+ mysql,
237
+ args,
238
+ withCompatSettings,
239
+ })
251
240
 
252
241
  const proc = spawn(mysql, args, spawnOptions)
253
242
 
254
- // Pipe backup file to stdin, decompressing if necessary
243
+ // Track whether we've already settled the promise to avoid duplicate rejections
244
+ let settled = false
255
245
  const fileStream = createReadStream(backupPath)
256
246
 
247
+ const rejectOnce = (err: Error) => {
248
+ if (settled) return
249
+ settled = true
250
+ fileStream.destroy()
251
+ proc.stdin?.end()
252
+ reject(err)
253
+ }
254
+
255
+ // Handle file read errors
256
+ fileStream.on('error', (err) => {
257
+ rejectOnce(new Error(`Failed to read backup file: ${err.message}`))
258
+ })
259
+
260
+ if (!proc.stdin) {
261
+ rejectOnce(
262
+ new Error(
263
+ 'MySQL process stdin is not available, cannot restore backup',
264
+ ),
265
+ )
266
+ return
267
+ }
268
+
269
+ // Handle EPIPE errors on stdin - this happens when mysql exits due to SQL errors
270
+ // while we're still piping data. The actual error will be in stderr.
271
+ proc.stdin.on('error', (err) => {
272
+ // EPIPE is expected when the process exits early - don't reject here,
273
+ // let the 'close' event handle it with the actual error from stderr
274
+ if ((err as NodeJS.ErrnoException).code !== 'EPIPE') {
275
+ rejectOnce(new Error(`Failed to write to MySQL process: ${err.message}`))
276
+ }
277
+ })
278
+
279
+ // Prepend compatibility settings if requested
280
+ if (withCompatSettings) {
281
+ proc.stdin.write(COMPAT_INIT_SQL)
282
+ logDebug('Prepended compatibility settings to restore')
283
+ }
284
+
257
285
  if (format.format === 'compressed') {
258
286
  // Decompress gzipped file before piping to mysql
259
287
  const gunzip = createGunzip()
260
- if (!proc.stdin) {
261
- reject(
262
- new Error(
263
- 'MySQL process stdin is not available, cannot restore backup',
264
- ),
265
- )
266
- return
267
- }
268
288
  fileStream.pipe(gunzip).pipe(proc.stdin)
269
289
 
270
290
  // Handle gunzip errors
271
291
  gunzip.on('error', (err) => {
272
- reject(new Error(`Failed to decompress backup file: ${err.message}`))
292
+ fileStream.unpipe(gunzip)
293
+ gunzip.unpipe(proc.stdin!)
294
+ rejectOnce(new Error(`Failed to decompress backup file: ${err.message}`))
273
295
  })
274
296
  } else {
275
- if (!proc.stdin) {
276
- reject(
277
- new Error(
278
- 'MySQL process stdin is not available, cannot restore backup',
279
- ),
280
- )
281
- return
282
- }
283
297
  fileStream.pipe(proc.stdin)
284
298
  }
285
299
 
@@ -294,18 +308,104 @@ export async function restoreBackup(
294
308
  })
295
309
 
296
310
  proc.on('close', (code) => {
311
+ if (settled) return
312
+ settled = true
313
+
297
314
  resolve({
298
315
  format: format.format,
299
316
  stdout,
300
317
  stderr,
318
+ rawStderr: stderr,
301
319
  code: code ?? undefined,
302
320
  })
303
321
  })
304
322
 
305
- proc.on('error', reject)
323
+ proc.on('error', (err) => {
324
+ rejectOnce(err)
325
+ })
306
326
  })
307
327
  }
308
328
 
329
+ /**
330
+ * Restore a MySQL backup to a database
331
+ *
332
+ * CLI equivalent: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
333
+ *
334
+ * Uses retry logic: if restore fails with ERROR 1118 (row size too large),
335
+ * automatically retries with compatibility settings that enable DYNAMIC row format.
336
+ */
337
+ export async function restoreBackup(
338
+ backupPath: string,
339
+ options: RestoreOptions,
340
+ ): Promise<RestoreResult> {
341
+ const {
342
+ port,
343
+ database,
344
+ user = engineDef.superuser,
345
+ validateVersion = true,
346
+ binPath,
347
+ } = options
348
+
349
+ // Validate version compatibility if requested
350
+ if (validateVersion) {
351
+ try {
352
+ await validateRestoreCompatibility({ dumpPath: backupPath })
353
+ } catch (error) {
354
+ // Re-throw SpinDBError, log and continue for other errors
355
+ if (error instanceof Error && error.name === 'SpinDBError') {
356
+ throw error
357
+ }
358
+ logDebug('Version validation failed, proceeding anyway', {
359
+ error: error instanceof Error ? error.message : String(error),
360
+ })
361
+ }
362
+ }
363
+
364
+ const mysql = await getMysqlClientPath(binPath)
365
+
366
+ // Detect format and check for wrong engine
367
+ const format = await detectBackupFormat(backupPath)
368
+ logDebug('Detected backup format', { format: format.format })
369
+ assertCompatibleFormat(format)
370
+
371
+ // First attempt: try without compatibility settings
372
+ const result = await doRestore(backupPath, mysql, port, database, user, format, false)
373
+
374
+ // Check if restore succeeded
375
+ if (result.code === 0) {
376
+ return result
377
+ }
378
+
379
+ // Check if it failed with row size error (ERROR 1118)
380
+ // This happens when tables have too many VARCHAR columns for the default row format
381
+ const isRowSizeError = result.rawStderr?.includes('ERROR 1118') ||
382
+ result.rawStderr?.includes('Row size too large')
383
+
384
+ if (isRowSizeError) {
385
+ logDebug('Detected row size error, retrying with compatibility settings')
386
+
387
+ // Retry with compatibility settings
388
+ const retryResult = await doRestore(backupPath, mysql, port, database, user, format, true)
389
+
390
+ if (retryResult.code === 0) {
391
+ return {
392
+ ...retryResult,
393
+ stdout: retryResult.stdout || 'Restore succeeded with compatibility mode (DYNAMIC row format)',
394
+ }
395
+ }
396
+
397
+ // Still failed - report the retry error
398
+ const errorMatch = retryResult.rawStderr?.match(/^ERROR\s+\d+.*$/m)
399
+ const errorMessage = errorMatch ? errorMatch[0] : retryResult.rawStderr?.trim() || 'Unknown error'
400
+ throw new Error(`MySQL restore failed: ${errorMessage}`)
401
+ }
402
+
403
+ // Failed with a different error - report it
404
+ const errorMatch = result.rawStderr?.match(/^ERROR\s+\d+.*$/m)
405
+ const errorMessage = errorMatch ? errorMatch[0] : result.rawStderr?.trim() || 'Unknown error'
406
+ throw new Error(`MySQL restore failed: ${errorMessage}`)
407
+ }
408
+
309
409
  /**
310
410
  * Parse a MySQL connection string
311
411
  *
@@ -0,0 +1,217 @@
1
+ /**
2
+ * QuestDB Backup Implementation
3
+ *
4
+ * QuestDB uses a custom backup format based on SQL exports.
5
+ * Since QuestDB uses PostgreSQL wire protocol, we can use psql for backup
6
+ * but QuestDB has its own SQL extensions for time-series data.
7
+ *
8
+ * Backup approach:
9
+ * - Export table schemas using SHOW CREATE TABLE
10
+ * - Export data using SELECT with proper ordering
11
+ * - Output as standard SQL statements
12
+ */
13
+
14
+ import { writeFile, stat } from 'fs/promises'
15
+ import { spawn } from 'child_process'
16
+ import { configManager } from '../../core/config-manager'
17
+ import { logDebug, logWarning } from '../../core/error-handler'
18
+ import type {
19
+ ContainerConfig,
20
+ BackupOptions,
21
+ BackupResult,
22
+ } from '../../types'
23
+
24
+ /**
25
+ * Execute a query against QuestDB using psql (PostgreSQL protocol)
26
+ */
27
+ async function executeQuery(
28
+ port: number,
29
+ database: string,
30
+ query: string,
31
+ ): Promise<string> {
32
+ // Try to find psql from config or PATH
33
+ let psqlPath = await configManager.getBinaryPath('psql')
34
+ if (!psqlPath) {
35
+ // Fall back to system psql
36
+ psqlPath = 'psql'
37
+ }
38
+
39
+ return new Promise((resolve, reject) => {
40
+ const args = [
41
+ '-h', '127.0.0.1',
42
+ '-p', String(port),
43
+ '-U', 'admin',
44
+ '-d', database,
45
+ '-t', // Tuples only (no headers)
46
+ '-A', // Unaligned output
47
+ '-c', query,
48
+ ]
49
+
50
+ const proc = spawn(psqlPath!, args, {
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ env: { ...process.env, PGPASSWORD: 'quest' },
53
+ })
54
+
55
+ let stdout = ''
56
+ let stderr = ''
57
+
58
+ proc.stdout.on('data', (data: Buffer) => {
59
+ stdout += data.toString()
60
+ })
61
+ proc.stderr.on('data', (data: Buffer) => {
62
+ stderr += data.toString()
63
+ })
64
+
65
+ proc.on('close', (code) => {
66
+ if (code === 0) {
67
+ resolve(stdout.trim())
68
+ } else {
69
+ reject(new Error(`psql error: ${stderr || `exit code ${code}`}`))
70
+ }
71
+ })
72
+ proc.on('error', (err) => {
73
+ reject(new Error(`Failed to execute psql: ${err.message}`))
74
+ })
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Create a backup of a QuestDB database
80
+ */
81
+ export async function createBackup(
82
+ container: ContainerConfig,
83
+ outputPath: string,
84
+ options: BackupOptions,
85
+ ): Promise<BackupResult> {
86
+ const { port } = container
87
+ const database = options.database || container.database || 'qdb'
88
+
89
+ const lines: string[] = []
90
+ lines.push('-- QuestDB backup generated by SpinDB')
91
+ lines.push(`-- Database: ${database}`)
92
+ lines.push(`-- Date: ${new Date().toISOString()}`)
93
+ lines.push('')
94
+
95
+ try {
96
+ // Get list of tables
97
+ const tablesQuery = `SELECT table_name FROM tables() WHERE table_name NOT LIKE 'sys.%'`
98
+ const tablesResult = await executeQuery(port, database, tablesQuery)
99
+ const tables = tablesResult.split('\n').filter((t) => t.trim())
100
+
101
+ logDebug(`Found ${tables.length} tables in database ${database}`)
102
+
103
+ for (const table of tables) {
104
+ if (!table.trim()) continue
105
+
106
+ lines.push(`-- Table: ${table}`)
107
+ lines.push('')
108
+
109
+ // Get CREATE TABLE statement
110
+ try {
111
+ const createQuery = `SHOW CREATE TABLE "${table}"`
112
+ let createResult = await executeQuery(port, database, createQuery)
113
+ if (createResult) {
114
+ // QuestDB's SHOW CREATE TABLE uses single quotes for table names,
115
+ // but SQL requires double quotes for identifiers. Fix the quoting.
116
+ // Also, the output sometimes ends with ; already, avoid double ;;
117
+ createResult = createResult
118
+ .replace(/^CREATE TABLE '([^']+)'/, 'CREATE TABLE "$1"')
119
+ .replace(/;$/, '') // Remove trailing semicolon if present
120
+ lines.push(createResult + ';')
121
+ lines.push('')
122
+ }
123
+ } catch (error) {
124
+ logWarning(`Could not get CREATE TABLE for ${table}: ${error}`)
125
+ continue
126
+ }
127
+
128
+ // Export table data
129
+ try {
130
+ // Try to get column names using table_columns() function
131
+ // This ensures INSERT statements have explicit column names for reliability
132
+ let columns: string[] = []
133
+ let useExplicitColumns = false
134
+
135
+ try {
136
+ const columnsQuery = `SELECT column FROM table_columns('${table}')`
137
+ const columnsResult = await executeQuery(port, database, columnsQuery)
138
+ columns = columnsResult.split('\n').filter((c) => c.trim())
139
+ useExplicitColumns = columns.length > 0
140
+ } catch {
141
+ // table_columns() failed - will use SELECT * without explicit column names
142
+ logDebug(`Could not get columns for ${table}, using SELECT *`)
143
+ }
144
+
145
+ // Get the designated timestamp column (if any) for ordering
146
+ // QuestDB tables have a designated timestamp column that can have any name
147
+ let orderClause = ''
148
+ try {
149
+ const tsQuery = `SELECT designatedTimestamp FROM tables() WHERE table_name = '${table}'`
150
+ const tsResult = await executeQuery(port, database, tsQuery)
151
+ if (tsResult && tsResult.trim()) {
152
+ orderClause = ` ORDER BY "${tsResult.trim()}"`
153
+ }
154
+ } catch {
155
+ // No designated timestamp or query failed - export without ordering
156
+ }
157
+
158
+ // Build SELECT query - use explicit columns if available, otherwise SELECT *
159
+ const columnList = useExplicitColumns
160
+ ? columns.map((c) => `"${c}"`).join(', ')
161
+ : '*'
162
+ const dataQuery = `SELECT ${columnList} FROM "${table}"${orderClause}`
163
+ const dataResult = await executeQuery(port, database, dataQuery)
164
+
165
+ if (dataResult) {
166
+ const rows = dataResult.split('\n').filter((r) => r.trim())
167
+ lines.push(`-- Data for ${table}: ${rows.length} rows`)
168
+
169
+ // Generate INSERT statements
170
+ for (const row of rows) {
171
+ // Parse the pipe-delimited output and convert to INSERT
172
+ // Trim each value to handle Windows CRLF line endings
173
+ const values = row.split('|').map((v) => {
174
+ const trimmed = v.trim()
175
+ if (trimmed === '' || trimmed === 'null') return 'NULL'
176
+ // Check if value looks like a number (int or float)
177
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
178
+ return trimmed // Don't quote numbers
179
+ }
180
+ // Escape single quotes and wrap strings
181
+ return `'${trimmed.replace(/'/g, "''")}'`
182
+ })
183
+
184
+ // Use explicit column names if available, otherwise positional VALUES
185
+ if (useExplicitColumns) {
186
+ lines.push(
187
+ `INSERT INTO "${table}" (${columnList}) VALUES (${values.join(', ')});`,
188
+ )
189
+ } else {
190
+ lines.push(`INSERT INTO "${table}" VALUES (${values.join(', ')});`)
191
+ }
192
+ }
193
+ lines.push('')
194
+ }
195
+ } catch (error) {
196
+ logWarning(`Could not export data for ${table}: ${error}`)
197
+ }
198
+ }
199
+
200
+ // Write backup file
201
+ const content = lines.join('\n')
202
+ await writeFile(outputPath, content, 'utf-8')
203
+
204
+ // Get file size
205
+ const stats = await stat(outputPath)
206
+
207
+ return {
208
+ path: outputPath,
209
+ format: 'sql',
210
+ size: stats.size,
211
+ }
212
+ } catch (error) {
213
+ throw new Error(
214
+ `Failed to create QuestDB backup: ${error instanceof Error ? error.message : String(error)}`,
215
+ )
216
+ }
217
+ }