spindb 0.8.1 → 0.9.0

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,597 @@
1
+ /**
2
+ * SQLite Engine
3
+ *
4
+ * SQLite is a file-based embedded database with no server process.
5
+ * Key differences from PostgreSQL/MySQL:
6
+ * - No start/stop operations (file-based)
7
+ * - No port management
8
+ * - Database files stored in user project directories (not ~/.spindb/)
9
+ * - Uses a registry to track file paths
10
+ */
11
+
12
+ import { spawn, execFile } from 'child_process'
13
+ import { promisify } from 'util'
14
+ import { existsSync, statSync, createReadStream, createWriteStream } from 'fs'
15
+ import { copyFile, unlink, mkdir, open, writeFile } from 'fs/promises'
16
+ import { resolve, dirname, join } from 'path'
17
+ import { tmpdir } from 'os'
18
+ import { BaseEngine } from '../base-engine'
19
+ import { sqliteRegistry } from './registry'
20
+ import { configManager } from '../../core/config-manager'
21
+ import { getEngineDefaults } from '../../config/engine-defaults'
22
+ import type {
23
+ ContainerConfig,
24
+ ProgressCallback,
25
+ BackupFormat,
26
+ BackupOptions,
27
+ BackupResult,
28
+ RestoreResult,
29
+ DumpResult,
30
+ StatusResult,
31
+ } from '../../types'
32
+
33
+ const execFileAsync = promisify(execFile)
34
+ const engineDef = getEngineDefaults('sqlite')
35
+
36
+ export class SQLiteEngine extends BaseEngine {
37
+ name = 'sqlite'
38
+ displayName = 'SQLite'
39
+ defaultPort = 0 // File-based, no port
40
+ supportedVersions = engineDef.supportedVersions
41
+
42
+ /**
43
+ * SQLite uses system binaries - no download URL
44
+ */
45
+ getBinaryUrl(): string {
46
+ throw new Error(
47
+ 'SQLite uses system-installed binaries. Install sqlite3:\n' +
48
+ ' macOS: brew install sqlite (or use built-in /usr/bin/sqlite3)\n' +
49
+ ' Ubuntu/Debian: sudo apt install sqlite3',
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Verify sqlite3 binary exists
55
+ */
56
+ async verifyBinary(): Promise<boolean> {
57
+ return this.isBinaryInstalled('3')
58
+ }
59
+
60
+ /**
61
+ * Check if sqlite3 is installed on the system
62
+ */
63
+ async isBinaryInstalled(_version: string): Promise<boolean> {
64
+ const sqlite3Path = await this.getSqlite3Path()
65
+ return sqlite3Path !== null
66
+ }
67
+
68
+ /**
69
+ * Ensure sqlite3 is available
70
+ * SQLite uses system binaries, so this just verifies it exists
71
+ */
72
+ async ensureBinaries(
73
+ _version: string,
74
+ _onProgress?: ProgressCallback,
75
+ ): Promise<string> {
76
+ const sqlite3Path = await this.getSqlite3Path()
77
+ if (!sqlite3Path) {
78
+ throw new Error(
79
+ 'sqlite3 not found. Install SQLite:\n' +
80
+ ' macOS: brew install sqlite (or use built-in /usr/bin/sqlite3)\n' +
81
+ ' Ubuntu/Debian: sudo apt install sqlite3\n' +
82
+ ' Fedora: sudo dnf install sqlite',
83
+ )
84
+ }
85
+ return sqlite3Path
86
+ }
87
+
88
+ /**
89
+ * Get path to sqlite3 binary
90
+ * First checks config manager, then falls back to system PATH
91
+ */
92
+ async getSqlite3Path(): Promise<string | null> {
93
+ // Check config manager first
94
+ const configPath = await configManager.getBinaryPath('sqlite3')
95
+ if (configPath) {
96
+ return configPath
97
+ }
98
+
99
+ // Check system PATH
100
+ try {
101
+ const { stdout } = await execFileAsync('which', ['sqlite3'])
102
+ const path = stdout.trim()
103
+ return path || null
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Get path to litecli (enhanced SQLite CLI)
111
+ */
112
+ async getLitecliPath(): Promise<string | null> {
113
+ // Check config manager first
114
+ const configPath = await configManager.getBinaryPath('litecli')
115
+ if (configPath) {
116
+ return configPath
117
+ }
118
+
119
+ // Check system PATH
120
+ try {
121
+ const { stdout } = await execFileAsync('which', ['litecli'])
122
+ const path = stdout.trim()
123
+ return path || null
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Initialize a new SQLite database file
131
+ * Creates an empty database at the specified path (or CWD)
132
+ */
133
+ async initDataDir(
134
+ containerName: string,
135
+ _version: string,
136
+ options: Record<string, unknown> = {},
137
+ ): Promise<string> {
138
+ // Determine file path - default to CWD
139
+ const pathOption = options.path as string | undefined
140
+ const filePath = pathOption || `./${containerName}.sqlite`
141
+ const absolutePath = resolve(filePath)
142
+
143
+ // Ensure parent directory exists
144
+ const dir = dirname(absolutePath)
145
+ if (!existsSync(dir)) {
146
+ await mkdir(dir, { recursive: true })
147
+ }
148
+
149
+ // Check if file already exists
150
+ if (existsSync(absolutePath)) {
151
+ throw new Error(`File already exists: ${absolutePath}`)
152
+ }
153
+
154
+ // Check if this path is already registered
155
+ if (await sqliteRegistry.isPathRegistered(absolutePath)) {
156
+ throw new Error(`Path is already registered: ${absolutePath}`)
157
+ }
158
+
159
+ // Create empty database by running a simple query
160
+ const sqlite3 = await this.getSqlite3Path()
161
+ if (!sqlite3) {
162
+ throw new Error('sqlite3 not found')
163
+ }
164
+
165
+ await execFileAsync(sqlite3, [absolutePath, 'SELECT 1'])
166
+
167
+ // Register in the SQLite registry
168
+ await sqliteRegistry.add({
169
+ name: containerName,
170
+ filePath: absolutePath,
171
+ created: new Date().toISOString(),
172
+ })
173
+
174
+ return absolutePath
175
+ }
176
+
177
+ /**
178
+ * Start is a no-op for SQLite (file-based, no server)
179
+ * Just verifies the file exists
180
+ */
181
+ async start(
182
+ container: ContainerConfig,
183
+ _onProgress?: ProgressCallback,
184
+ ): Promise<{ port: number; connectionString: string }> {
185
+ const entry = await sqliteRegistry.get(container.name)
186
+ if (!entry) {
187
+ throw new Error(`SQLite container "${container.name}" not found in registry`)
188
+ }
189
+ if (!existsSync(entry.filePath)) {
190
+ throw new Error(`SQLite database file not found: ${entry.filePath}`)
191
+ }
192
+
193
+ return {
194
+ port: 0,
195
+ connectionString: this.getConnectionString(container),
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Stop is a no-op for SQLite (file-based, no server)
201
+ */
202
+ async stop(_container: ContainerConfig): Promise<void> {
203
+ // No-op: SQLite is file-based, no server to stop
204
+ }
205
+
206
+ /**
207
+ * Get status - check if the file exists
208
+ */
209
+ async status(container: ContainerConfig): Promise<StatusResult> {
210
+ const entry = await sqliteRegistry.get(container.name)
211
+ if (!entry) {
212
+ return {
213
+ running: false,
214
+ message: 'Not registered in SQLite registry',
215
+ }
216
+ }
217
+ if (!existsSync(entry.filePath)) {
218
+ return {
219
+ running: false,
220
+ message: `File not found: ${entry.filePath}`,
221
+ }
222
+ }
223
+ return {
224
+ running: true,
225
+ message: 'Database file exists',
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Get connection string for SQLite
231
+ * Returns sqlite:// URL format
232
+ */
233
+ getConnectionString(container: ContainerConfig, _database?: string): string {
234
+ // container.database stores the file path for SQLite
235
+ const filePath = container.database
236
+ return `sqlite:///${filePath}`
237
+ }
238
+
239
+ /**
240
+ * Open interactive SQLite shell
241
+ * Prefers litecli if available, falls back to sqlite3
242
+ */
243
+ async connect(container: ContainerConfig, _database?: string): Promise<void> {
244
+ const entry = await sqliteRegistry.get(container.name)
245
+ if (!entry) {
246
+ throw new Error(`SQLite container "${container.name}" not found in registry`)
247
+ }
248
+ if (!existsSync(entry.filePath)) {
249
+ throw new Error(`SQLite database file not found: ${entry.filePath}`)
250
+ }
251
+
252
+ // Try litecli first, fall back to sqlite3
253
+ const litecli = await this.getLitecliPath()
254
+ const sqlite3 = await this.getSqlite3Path()
255
+
256
+ const cmd = litecli || sqlite3
257
+ if (!cmd) {
258
+ throw new Error(
259
+ 'sqlite3 not found. Install SQLite:\n' +
260
+ ' macOS: brew install sqlite\n' +
261
+ ' Ubuntu/Debian: sudo apt install sqlite3',
262
+ )
263
+ }
264
+
265
+ return new Promise((resolve, reject) => {
266
+ const proc = spawn(cmd, [entry.filePath], { stdio: 'inherit' })
267
+
268
+ proc.on('error', (err: NodeJS.ErrnoException) => {
269
+ reject(err)
270
+ })
271
+
272
+ proc.on('close', () => resolve())
273
+ })
274
+ }
275
+
276
+ /**
277
+ * Create database is a no-op for SQLite
278
+ * In SQLite, the file IS the database
279
+ */
280
+ async createDatabase(
281
+ _container: ContainerConfig,
282
+ _database: string,
283
+ ): Promise<void> {
284
+ // No-op: SQLite file IS the database
285
+ // If you need multiple "databases", create multiple containers
286
+ }
287
+
288
+ /**
289
+ * Drop database - deletes the file and removes from registry
290
+ */
291
+ async dropDatabase(
292
+ container: ContainerConfig,
293
+ _database: string,
294
+ ): Promise<void> {
295
+ const entry = await sqliteRegistry.get(container.name)
296
+ if (entry && existsSync(entry.filePath)) {
297
+ await unlink(entry.filePath)
298
+ }
299
+ await sqliteRegistry.remove(container.name)
300
+ }
301
+
302
+ /**
303
+ * Get database size by checking file size
304
+ */
305
+ async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
306
+ const entry = await sqliteRegistry.get(container.name)
307
+ if (!entry || !existsSync(entry.filePath)) {
308
+ return null
309
+ }
310
+ const stats = statSync(entry.filePath)
311
+ return stats.size
312
+ }
313
+
314
+ /**
315
+ * Detect backup format
316
+ * SQLite backups are either .sql (dump) or .sqlite/.db (file copy)
317
+ */
318
+ async detectBackupFormat(filePath: string): Promise<BackupFormat> {
319
+ if (filePath.endsWith('.sql')) {
320
+ return {
321
+ format: 'sql',
322
+ description: 'SQLite SQL dump',
323
+ restoreCommand: 'sqlite3 <db> < <file>',
324
+ }
325
+ }
326
+ return {
327
+ format: 'sqlite',
328
+ description: 'SQLite database file (binary copy)',
329
+ restoreCommand: 'cp <file> <db>',
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Create a backup of the SQLite database
335
+ */
336
+ async backup(
337
+ container: ContainerConfig,
338
+ outputPath: string,
339
+ options: BackupOptions,
340
+ ): Promise<BackupResult> {
341
+ const entry = await sqliteRegistry.get(container.name)
342
+ if (!entry || !existsSync(entry.filePath)) {
343
+ throw new Error('SQLite database file not found')
344
+ }
345
+
346
+ if (options.format === 'sql') {
347
+ // Use .dump command for SQL format
348
+ const sqlite3 = await this.getSqlite3Path()
349
+ if (!sqlite3) {
350
+ throw new Error('sqlite3 not found')
351
+ }
352
+
353
+ // Pipe .dump output to file (avoids shell injection)
354
+ await this.dumpToFile(sqlite3, entry.filePath, outputPath)
355
+ } else {
356
+ // Binary copy for 'dump' format
357
+ await copyFile(entry.filePath, outputPath)
358
+ }
359
+
360
+ const stats = statSync(outputPath)
361
+ return {
362
+ path: outputPath,
363
+ format: options.format,
364
+ size: stats.size,
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Restore a backup to the SQLite database
370
+ */
371
+ async restore(
372
+ container: ContainerConfig,
373
+ backupPath: string,
374
+ _options?: Record<string, unknown>,
375
+ ): Promise<RestoreResult> {
376
+ const entry = await sqliteRegistry.get(container.name)
377
+ if (!entry) {
378
+ throw new Error(`Container "${container.name}" not registered`)
379
+ }
380
+
381
+ const format = await this.detectBackupFormat(backupPath)
382
+
383
+ if (format.format === 'sql') {
384
+ // Restore SQL dump
385
+ const sqlite3 = await this.getSqlite3Path()
386
+ if (!sqlite3) {
387
+ throw new Error('sqlite3 not found')
388
+ }
389
+
390
+ // Pipe file to sqlite3 stdin (avoids shell injection)
391
+ await this.runSqlFile(sqlite3, entry.filePath, backupPath)
392
+ return { format: 'sql' }
393
+ } else {
394
+ // Binary file copy
395
+ await copyFile(backupPath, entry.filePath)
396
+ return { format: 'sqlite' }
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Create a dump from a SQLite file (for clone operations)
402
+ * Supports:
403
+ * - Local file paths: ./mydb.sqlite, /path/to/db.sqlite
404
+ * - Local sqlite:// URLs: sqlite:///path/to/db.sqlite
405
+ * - Remote HTTP/HTTPS: https://example.com/backup.sqlite
406
+ */
407
+ async dumpFromConnectionString(
408
+ connectionString: string,
409
+ outputPath: string,
410
+ ): Promise<DumpResult> {
411
+ let filePath = connectionString
412
+ let tempFile: string | null = null
413
+
414
+ // Handle HTTP/HTTPS URLs - download to temp file
415
+ if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
416
+ tempFile = join(tmpdir(), `spindb-download-${Date.now()}.sqlite`)
417
+ await this.downloadFile(filePath, tempFile)
418
+
419
+ // Validate it's a valid SQLite database
420
+ if (!(await this.isValidSqliteFile(tempFile))) {
421
+ await unlink(tempFile)
422
+ throw new Error('Downloaded file is not a valid SQLite database')
423
+ }
424
+
425
+ filePath = tempFile
426
+ }
427
+ // Handle sqlite:// URLs (strip prefix for local file)
428
+ else if (filePath.startsWith('sqlite:///')) {
429
+ filePath = filePath.slice('sqlite:///'.length)
430
+ } else if (filePath.startsWith('sqlite://')) {
431
+ filePath = filePath.slice('sqlite://'.length)
432
+ }
433
+
434
+ // Verify local file exists
435
+ if (!existsSync(filePath)) {
436
+ throw new Error(`SQLite database file not found: ${filePath}`)
437
+ }
438
+
439
+ const sqlite3 = await this.getSqlite3Path()
440
+ if (!sqlite3) {
441
+ throw new Error('sqlite3 not found')
442
+ }
443
+
444
+ // Pipe .dump output to file (avoids shell injection)
445
+ await this.dumpToFile(sqlite3, filePath, outputPath)
446
+
447
+ // Clean up temp file if we downloaded it
448
+ if (tempFile && existsSync(tempFile)) {
449
+ await unlink(tempFile)
450
+ }
451
+
452
+ return { filePath: outputPath }
453
+ }
454
+
455
+ /**
456
+ * Dump SQLite database to a file using spawn (avoids shell injection)
457
+ * Equivalent to: sqlite3 dbPath .dump > outputPath
458
+ */
459
+ private async dumpToFile(
460
+ sqlite3Path: string,
461
+ dbPath: string,
462
+ outputPath: string,
463
+ ): Promise<void> {
464
+ return new Promise((resolve, reject) => {
465
+ const output = createWriteStream(outputPath)
466
+ const proc = spawn(sqlite3Path, [dbPath, '.dump'])
467
+
468
+ proc.stdout.pipe(output)
469
+
470
+ proc.stderr.on('data', (data: Buffer) => {
471
+ // Collect stderr but don't fail immediately - sqlite3 may write warnings
472
+ console.error(data.toString())
473
+ })
474
+
475
+ proc.on('error', (err) => {
476
+ output.close()
477
+ reject(err)
478
+ })
479
+
480
+ proc.on('close', (code) => {
481
+ output.close()
482
+ if (code === 0) {
483
+ resolve()
484
+ } else {
485
+ reject(new Error(`sqlite3 dump failed with exit code ${code}`))
486
+ }
487
+ })
488
+ })
489
+ }
490
+
491
+ /**
492
+ * Run a SQL file against SQLite database using spawn (avoids shell injection)
493
+ * Equivalent to: sqlite3 dbPath < sqlFilePath
494
+ */
495
+ private async runSqlFile(
496
+ sqlite3Path: string,
497
+ dbPath: string,
498
+ sqlFilePath: string,
499
+ ): Promise<void> {
500
+ return new Promise((resolve, reject) => {
501
+ const input = createReadStream(sqlFilePath)
502
+ const proc = spawn(sqlite3Path, [dbPath])
503
+
504
+ input.pipe(proc.stdin)
505
+
506
+ proc.stderr.on('data', (data: Buffer) => {
507
+ // Collect stderr but don't fail immediately - sqlite3 may write warnings
508
+ console.error(data.toString())
509
+ })
510
+
511
+ input.on('error', (err) => {
512
+ proc.kill()
513
+ reject(err)
514
+ })
515
+
516
+ proc.on('error', (err) => {
517
+ reject(err)
518
+ })
519
+
520
+ proc.on('close', (code) => {
521
+ if (code === 0) {
522
+ resolve()
523
+ } else {
524
+ reject(new Error(`sqlite3 script execution failed with exit code ${code}`))
525
+ }
526
+ })
527
+ })
528
+ }
529
+
530
+ /**
531
+ * Download a file from HTTP/HTTPS URL
532
+ */
533
+ private async downloadFile(url: string, destPath: string): Promise<void> {
534
+ const response = await fetch(url)
535
+ if (!response.ok) {
536
+ throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
537
+ }
538
+
539
+ const buffer = await response.arrayBuffer()
540
+ await writeFile(destPath, Buffer.from(buffer))
541
+ }
542
+
543
+ /**
544
+ * Validate a file is a valid SQLite database
545
+ * SQLite files start with "SQLite format 3\0" (first 16 bytes)
546
+ */
547
+ private async isValidSqliteFile(filePath: string): Promise<boolean> {
548
+ try {
549
+ const buffer = Buffer.alloc(16)
550
+ const fd = await open(filePath, 'r')
551
+ await fd.read(buffer, 0, 16, 0)
552
+ await fd.close()
553
+ // Check for SQLite magic header
554
+ return buffer.toString('utf8', 0, 15) === 'SQLite format 3'
555
+ } catch {
556
+ return false
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Run a SQL file or inline SQL statement
562
+ */
563
+ async runScript(
564
+ container: ContainerConfig,
565
+ options: { file?: string; sql?: string; database?: string },
566
+ ): Promise<void> {
567
+ const entry = await sqliteRegistry.get(container.name)
568
+ if (!entry || !existsSync(entry.filePath)) {
569
+ throw new Error('SQLite database file not found')
570
+ }
571
+
572
+ const sqlite3 = await this.getSqlite3Path()
573
+ if (!sqlite3) {
574
+ throw new Error('sqlite3 not found')
575
+ }
576
+
577
+ if (options.file) {
578
+ // Run SQL file - pipe file to stdin (avoids shell injection)
579
+ await this.runSqlFile(sqlite3, entry.filePath, options.file)
580
+ } else if (options.sql) {
581
+ // Run inline SQL - pass as argument (avoids shell injection)
582
+ await execFileAsync(sqlite3, [entry.filePath, options.sql])
583
+ } else {
584
+ throw new Error('Either file or sql option must be provided')
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Get available versions - SQLite uses system version
590
+ */
591
+ async fetchAvailableVersions(): Promise<Record<string, string[]>> {
592
+ // SQLite uses system version, just return supported versions
593
+ return { '3': ['3'] }
594
+ }
595
+ }
596
+
597
+ export const sqliteEngine = new SQLiteEngine()