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.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * QuestDB Restore Implementation
3
+ *
4
+ * Restores SQL backups to QuestDB using PostgreSQL wire protocol.
5
+ * QuestDB is compatible with psql for executing SQL statements.
6
+ */
7
+
8
+ import { open, readFile } from 'fs/promises'
9
+ import { spawn, spawnSync } from 'child_process'
10
+ import { configManager } from '../../core/config-manager'
11
+ import { logDebug } from '../../core/error-handler'
12
+ import type { BackupFormat, RestoreResult } from '../../types'
13
+
14
+ // Read only the first 8KB for format detection
15
+ const HEADER_SIZE = 8192
16
+
17
+ /**
18
+ * Detect the backup format from file content
19
+ */
20
+ export async function detectBackupFormat(filePath: string): Promise<BackupFormat> {
21
+ // Check extension first
22
+ const lowerPath = filePath.toLowerCase()
23
+ if (lowerPath.endsWith('.sql')) {
24
+ return {
25
+ format: 'sql',
26
+ description: 'SQL dump file',
27
+ restoreCommand: 'psql',
28
+ }
29
+ }
30
+
31
+ // Read file header for content-based detection
32
+ const buffer = Buffer.alloc(HEADER_SIZE)
33
+ const fd = await open(filePath, 'r')
34
+ let bytesRead: number
35
+
36
+ try {
37
+ const result = await fd.read(buffer, 0, HEADER_SIZE, 0)
38
+ bytesRead = result.bytesRead
39
+ } finally {
40
+ await fd.close()
41
+ }
42
+
43
+ const content = buffer.toString('utf-8', 0, bytesRead)
44
+ const lines = content.split(/\r?\n/)
45
+
46
+ // Check for SQL patterns
47
+ for (const line of lines.slice(0, 20)) {
48
+ const trimmed = line.trim().toUpperCase()
49
+ if (
50
+ trimmed.startsWith('CREATE TABLE') ||
51
+ trimmed.startsWith('INSERT INTO') ||
52
+ trimmed.startsWith('-- QUESTDB') ||
53
+ trimmed.startsWith('-- TABLE:')
54
+ ) {
55
+ return {
56
+ format: 'sql',
57
+ description: 'SQL dump file',
58
+ restoreCommand: 'psql',
59
+ }
60
+ }
61
+ }
62
+
63
+ return {
64
+ format: 'unknown',
65
+ description: 'Unknown format',
66
+ restoreCommand: '',
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Parse a QuestDB/PostgreSQL connection string
72
+ */
73
+ export function parseConnectionString(connectionString: string): {
74
+ host: string
75
+ port: number
76
+ database: string
77
+ user: string
78
+ password?: string
79
+ } {
80
+ // Support both postgresql:// and questdb:// schemes
81
+ let url: URL
82
+ try {
83
+ // Replace questdb:// with postgresql:// for URL parsing
84
+ const normalized = connectionString.replace(/^questdb:\/\//, 'postgresql://')
85
+ url = new URL(normalized)
86
+ } catch {
87
+ throw new Error(
88
+ `Invalid connection string: ${connectionString}\n` +
89
+ 'Expected format: postgresql://user:password@host:port/database',
90
+ )
91
+ }
92
+
93
+ return {
94
+ host: url.hostname || '127.0.0.1',
95
+ port: url.port ? parseInt(url.port, 10) : 8812,
96
+ database: url.pathname.replace(/^\//, '') || 'qdb',
97
+ user: url.username || 'admin',
98
+ password: url.password || 'quest',
99
+ }
100
+ }
101
+
102
+ export type RestoreOptions = {
103
+ containerName: string
104
+ port: number
105
+ database: string
106
+ version: string
107
+ clean?: boolean
108
+ }
109
+
110
+ /**
111
+ * Restore a backup to QuestDB
112
+ */
113
+ export async function restoreBackup(
114
+ backupPath: string,
115
+ options: RestoreOptions,
116
+ ): Promise<RestoreResult> {
117
+ const { port, database, clean } = options
118
+
119
+ // Detect backup format
120
+ const format = await detectBackupFormat(backupPath)
121
+ if (format.format === 'unknown') {
122
+ throw new Error(
123
+ `Cannot detect backup format for: ${backupPath}\n` +
124
+ 'Supported formats: .sql (SQL dump)',
125
+ )
126
+ }
127
+
128
+ logDebug(`Restoring ${format.format} backup to QuestDB database ${database}`)
129
+
130
+ // Find psql binary
131
+ let psqlPath = await configManager.getBinaryPath('psql')
132
+ if (!psqlPath) {
133
+ psqlPath = 'psql'
134
+ }
135
+
136
+ // Build restore command args
137
+ // Use ON_ERROR_STOP to fail fast on any SQL error (otherwise psql continues silently)
138
+ const args = [
139
+ '-h', '127.0.0.1',
140
+ '-p', String(port),
141
+ '-U', 'admin',
142
+ '-d', database,
143
+ '-v', 'ON_ERROR_STOP=1',
144
+ '-f', backupPath,
145
+ ]
146
+
147
+ // For clean restore, drop existing tables before restoring
148
+ if (clean) {
149
+ logDebug('Clean restore requested - extracting table names from backup')
150
+
151
+ // Read the SQL file and extract table names from CREATE TABLE statements
152
+ const sqlContent = await readFile(backupPath, 'utf-8')
153
+ // Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE [IF NOT EXISTS] table_name
154
+ const tableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|(\w+))/gi
155
+ const tables: string[] = []
156
+ let match
157
+
158
+ while ((match = tableRegex.exec(sqlContent)) !== null) {
159
+ const tableName = match[1] || match[2]
160
+ if (tableName && !tables.includes(tableName)) {
161
+ tables.push(tableName)
162
+ }
163
+ }
164
+
165
+ if (tables.length > 0) {
166
+ logDebug(`Found ${tables.length} tables to drop: ${tables.join(', ')}`)
167
+
168
+ // Execute DROP TABLE IF EXISTS for each table
169
+ for (const table of tables) {
170
+ const dropQuery = `DROP TABLE IF EXISTS "${table}";`
171
+ logDebug(`Executing: ${dropQuery}`)
172
+
173
+ const dropResult = spawnSync(psqlPath!, [
174
+ '-h', '127.0.0.1',
175
+ '-p', String(port),
176
+ '-U', 'admin',
177
+ '-d', database,
178
+ '-c', dropQuery,
179
+ ], {
180
+ env: { ...process.env, PGPASSWORD: 'quest' },
181
+ })
182
+
183
+ if (dropResult.error) {
184
+ logDebug(`Warning: Failed to drop table ${table}: ${dropResult.error.message}`)
185
+ } else if (dropResult.status !== 0) {
186
+ logDebug(`Warning: DROP TABLE ${table} exited with code ${dropResult.status}`)
187
+ }
188
+ }
189
+ } else {
190
+ logDebug('No CREATE TABLE statements found in backup')
191
+ }
192
+ }
193
+
194
+ return new Promise((resolve, reject) => {
195
+ const proc = spawn(psqlPath!, args, {
196
+ stdio: ['ignore', 'pipe', 'pipe'],
197
+ env: { ...process.env, PGPASSWORD: 'quest' },
198
+ })
199
+
200
+ let stdout = ''
201
+ let stderr = ''
202
+
203
+ proc.stdout.on('data', (data: Buffer) => {
204
+ stdout += data.toString()
205
+ })
206
+ proc.stderr.on('data', (data: Buffer) => {
207
+ stderr += data.toString()
208
+ })
209
+
210
+ proc.on('close', (code) => {
211
+ if (code === 0) {
212
+ resolve({
213
+ format: format.format,
214
+ stdout,
215
+ stderr,
216
+ code: 0,
217
+ })
218
+ } else if (stderr.includes('already exists')) {
219
+ // Treat "already exists" as non-fatal (table recreation during restore)
220
+ resolve({
221
+ format: format.format,
222
+ stdout,
223
+ stderr,
224
+ code: code ?? 1,
225
+ })
226
+ } else {
227
+ reject(new Error(`Restore failed: ${stderr || `exit code ${code}`}`))
228
+ }
229
+ })
230
+
231
+ proc.on('error', (err) => {
232
+ reject(new Error(`Failed to execute psql: ${err.message}`))
233
+ })
234
+ })
235
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * QuestDB Version Maps
3
+ *
4
+ * IMPORTANT: Keep this in sync with hostdb releases.json:
5
+ * https://github.com/robertjbass/hostdb/blob/main/releases.json
6
+ *
7
+ * QuestDB uses standard semantic versioning (e.g., 9.2.3)
8
+ */
9
+
10
+ export const QUESTDB_VERSION_MAP: Record<string, string> = {
11
+ '9': '9.2.3',
12
+ '9.2': '9.2.3',
13
+ '9.2.3': '9.2.3',
14
+ }
15
+
16
+ export const SUPPORTED_MAJOR_VERSIONS = ['9']
17
+ export const FALLBACK_VERSION_MAP = QUESTDB_VERSION_MAP
18
+
19
+ /**
20
+ * Normalize a version string to a full version
21
+ * e.g., '9' -> '9.2.3'
22
+ */
23
+ export function normalizeVersion(version: string): string {
24
+ // If already a full version, return as-is
25
+ if (/^\d+\.\d+\.\d+$/.test(version)) {
26
+ return version
27
+ }
28
+
29
+ // Try to look up in version map
30
+ const fullVersion = QUESTDB_VERSION_MAP[version]
31
+ if (fullVersion) {
32
+ return fullVersion
33
+ }
34
+
35
+ // Return as-is if not found
36
+ return version
37
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * QuestDB Version Validator
3
+ *
4
+ * Provides version parsing, validation, and comparison utilities.
5
+ */
6
+
7
+ import { SUPPORTED_MAJOR_VERSIONS, QUESTDB_VERSION_MAP } from './version-maps'
8
+
9
+ export type ParsedVersion = {
10
+ major: number
11
+ minor: number
12
+ patch: number
13
+ full: string
14
+ }
15
+
16
+ /**
17
+ * Parse a version string into components
18
+ * @param version Version string (e.g., '9.2.3', '9.2', '9')
19
+ * @returns Parsed version or null if invalid
20
+ */
21
+ export function parseVersion(version: string): ParsedVersion | null {
22
+ // Match version patterns: 9.2.3, 9.2, 9
23
+ const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/)
24
+ if (!match) return null
25
+
26
+ const major = parseInt(match[1], 10)
27
+ const minor = match[2] ? parseInt(match[2], 10) : 0
28
+ const patch = match[3] ? parseInt(match[3], 10) : 0
29
+
30
+ return {
31
+ major,
32
+ minor,
33
+ patch,
34
+ full: `${major}.${minor}.${patch}`,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Check if a version is supported
40
+ */
41
+ export function isVersionSupported(version: string): boolean {
42
+ const parsed = parseVersion(version)
43
+ if (!parsed) return false
44
+
45
+ const majorStr = String(parsed.major)
46
+ return SUPPORTED_MAJOR_VERSIONS.includes(majorStr)
47
+ }
48
+
49
+ /**
50
+ * Get the major version string from a version
51
+ */
52
+ export function getMajorVersion(version: string): string | null {
53
+ const parsed = parseVersion(version)
54
+ if (!parsed) return null
55
+ return String(parsed.major)
56
+ }
57
+
58
+ /**
59
+ * Compare two versions
60
+ * @returns -1 if a < b, 0 if a == b, 1 if a > b
61
+ */
62
+ export function compareVersions(a: string, b: string): number {
63
+ const parsedA = parseVersion(a)
64
+ const parsedB = parseVersion(b)
65
+
66
+ if (!parsedA || !parsedB) {
67
+ // Fall back to string comparison if parsing fails
68
+ return a.localeCompare(b)
69
+ }
70
+
71
+ if (parsedA.major !== parsedB.major) {
72
+ return parsedA.major - parsedB.major
73
+ }
74
+ if (parsedA.minor !== parsedB.minor) {
75
+ return parsedA.minor - parsedB.minor
76
+ }
77
+ return parsedA.patch - parsedB.patch
78
+ }
79
+
80
+ /**
81
+ * Check if two versions are compatible for backup/restore
82
+ * QuestDB allows restoring to same or newer major version
83
+ */
84
+ export function isVersionCompatible(
85
+ sourceVersion: string,
86
+ targetVersion: string,
87
+ ): boolean {
88
+ const sourceMajor = getMajorVersion(sourceVersion)
89
+ const targetMajor = getMajorVersion(targetVersion)
90
+
91
+ if (!sourceMajor || !targetMajor) return false
92
+
93
+ // Same major version is always compatible
94
+ if (sourceMajor === targetMajor) return true
95
+
96
+ // Target major must be >= source major
97
+ return parseInt(targetMajor, 10) >= parseInt(sourceMajor, 10)
98
+ }
99
+
100
+ /**
101
+ * Resolve a version alias to full version
102
+ */
103
+ export function resolveVersion(version: string): string {
104
+ // Check if it's already a full version in the map
105
+ if (QUESTDB_VERSION_MAP[version]) {
106
+ return QUESTDB_VERSION_MAP[version]
107
+ }
108
+
109
+ // If already a full version format, return as-is
110
+ if (/^\d+\.\d+\.\d+$/.test(version)) {
111
+ return version
112
+ }
113
+
114
+ // Try major version lookup
115
+ const majorVersion = getMajorVersion(version)
116
+ if (majorVersion && QUESTDB_VERSION_MAP[majorVersion]) {
117
+ return QUESTDB_VERSION_MAP[majorVersion]
118
+ }
119
+
120
+ return version
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.26.2",
3
+ "version": "0.27.3",
4
4
  "description": "Zero-config Docker-free local database containers. Create, backup, and clone a variety of popular databases.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,6 +71,7 @@
71
71
  "chalk": "^5.3.0",
72
72
  "commander": "^12.1.0",
73
73
  "inquirer": "^9.3.7",
74
+ "inquirer-autocomplete-prompt": "^3.0.1",
74
75
  "ora": "^8.1.1",
75
76
  "string-width": "^8.1.0",
76
77
  "tsx": "^4.7.0",
@@ -79,6 +80,7 @@
79
80
  "devDependencies": {
80
81
  "@eslint/js": "^9.39.1",
81
82
  "@types/inquirer": "^9.0.7",
83
+ "@types/inquirer-autocomplete-prompt": "^3.0.3",
82
84
  "@types/node": "^20.10.0",
83
85
  "@types/unzipper": "^0.10.11",
84
86
  "eslint": "^9.39.1",
package/types/index.ts CHANGED
@@ -36,6 +36,7 @@ export enum Engine {
36
36
  CouchDB = 'couchdb',
37
37
  CockroachDB = 'cockroachdb',
38
38
  SurrealDB = 'surrealdb',
39
+ QuestDB = 'questdb',
39
40
  }
40
41
 
41
42
  // Supported operating systems (matches Node.js process.platform)
@@ -71,6 +72,7 @@ export const ALL_ENGINES = [
71
72
  Engine.CouchDB,
72
73
  Engine.CockroachDB,
73
74
  Engine.SurrealDB,
75
+ Engine.QuestDB,
74
76
  ] as const
75
77
 
76
78
  // File-based engines (no server process, data stored in user project directories)
@@ -199,6 +201,7 @@ export type FerretDBFormat = 'sql' | 'custom'
199
201
  export type CouchDBFormat = 'json'
200
202
  export type CockroachDBFormat = 'sql'
201
203
  export type SurrealDBFormat = 'surql'
204
+ export type QuestDBFormat = 'sql'
202
205
 
203
206
  // Union of all backup formats
204
207
  export type BackupFormatType =
@@ -217,6 +220,7 @@ export type BackupFormatType =
217
220
  | CouchDBFormat
218
221
  | CockroachDBFormat
219
222
  | SurrealDBFormat
223
+ | QuestDBFormat
220
224
 
221
225
  // Mapping from Engine to its corresponding backup format type
222
226
  type EngineFormatMap = {
@@ -235,6 +239,7 @@ type EngineFormatMap = {
235
239
  [Engine.CouchDB]: CouchDBFormat
236
240
  [Engine.CockroachDB]: CockroachDBFormat
237
241
  [Engine.SurrealDB]: SurrealDBFormat
242
+ [Engine.QuestDB]: QuestDBFormat
238
243
  }
239
244
 
240
245
  // Helper type to get format type for a specific engine
@@ -344,6 +349,8 @@ export type BinaryTool =
344
349
  | 'cockroach'
345
350
  // SurrealDB tools
346
351
  | 'surreal'
352
+ // QuestDB tools
353
+ | 'questdb'
347
354
  // Enhanced shells (optional)
348
355
  | 'pgcli'
349
356
  | 'mycli'
@@ -423,6 +430,8 @@ export type SpinDBConfig = {
423
430
  cockroach?: BinaryConfig
424
431
  // SurrealDB tools
425
432
  surreal?: BinaryConfig
433
+ // QuestDB tools
434
+ questdb?: BinaryConfig
426
435
  // Enhanced shells (optional)
427
436
  pgcli?: BinaryConfig
428
437
  mycli?: BinaryConfig