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.
- package/README.md +27 -10
- package/cli/commands/backups.ts +6 -17
- package/cli/commands/config.ts +5 -4
- package/cli/commands/engines.ts +95 -43
- package/cli/commands/info.ts +4 -4
- package/cli/commands/list.ts +3 -9
- package/cli/commands/menu/backup-handlers.ts +12 -3
- package/cli/commands/menu/container-handlers.ts +65 -81
- package/cli/commands/menu/engine-handlers.ts +34 -16
- package/cli/commands/menu/index.ts +12 -12
- package/cli/commands/menu/shell-handlers.ts +27 -1
- package/cli/commands/menu/sql-handlers.ts +1 -0
- package/cli/constants.ts +38 -36
- package/cli/helpers.ts +72 -0
- package/cli/ui/prompts.ts +112 -1
- package/cli/ui/theme.ts +0 -2
- package/config/backup-formats.ts +14 -0
- package/config/engine-defaults.ts +13 -0
- package/config/engines.json +16 -0
- package/core/config-manager.ts +10 -0
- package/core/container-manager.ts +8 -6
- package/core/dependency-manager.ts +2 -0
- package/engines/index.ts +4 -0
- package/engines/mariadb/restore.ts +133 -57
- package/engines/mysql/restore.ts +160 -60
- package/engines/questdb/backup.ts +217 -0
- package/engines/questdb/binary-manager.ts +303 -0
- package/engines/questdb/binary-urls.ts +34 -0
- package/engines/questdb/hostdb-releases.ts +101 -0
- package/engines/questdb/index.ts +871 -0
- package/engines/questdb/restore.ts +235 -0
- package/engines/questdb/version-maps.ts +37 -0
- package/engines/questdb/version-validator.ts +121 -0
- package/package.json +3 -1
- package/types/index.ts +9 -0
package/engines/mysql/restore.ts
CHANGED
|
@@ -199,47 +199,32 @@ async function getMysqlClientPath(binPath?: string): Promise<string> {
|
|
|
199
199
|
)
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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', {
|
|
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
|
-
//
|
|
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
|
-
|
|
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',
|
|
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
|
+
}
|