spindb 0.24.0 → 0.26.2

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.
Files changed (35) hide show
  1. package/README.md +53 -14
  2. package/cli/commands/engines.ts +89 -1
  3. package/cli/commands/menu/backup-handlers.ts +19 -0
  4. package/cli/commands/menu/container-handlers.ts +4 -2
  5. package/cli/commands/menu/shell-handlers.ts +52 -2
  6. package/cli/commands/menu/sql-handlers.ts +7 -1
  7. package/cli/constants.ts +4 -0
  8. package/cli/helpers.ts +144 -0
  9. package/cli/index.ts +1 -1
  10. package/config/backup-formats.ts +28 -0
  11. package/config/engine-defaults.ts +26 -0
  12. package/config/engines.json +32 -0
  13. package/core/config-manager.ts +5 -0
  14. package/core/container-manager.ts +10 -4
  15. package/core/dependency-manager.ts +4 -0
  16. package/engines/base-engine.ts +16 -0
  17. package/engines/cockroachdb/backup.ts +363 -0
  18. package/engines/cockroachdb/binary-manager.ts +45 -0
  19. package/engines/cockroachdb/binary-urls.ts +37 -0
  20. package/engines/cockroachdb/cli-utils.ts +384 -0
  21. package/engines/cockroachdb/hostdb-releases.ts +111 -0
  22. package/engines/cockroachdb/index.ts +1052 -0
  23. package/engines/cockroachdb/restore.ts +448 -0
  24. package/engines/cockroachdb/version-maps.ts +42 -0
  25. package/engines/index.ts +8 -0
  26. package/engines/surrealdb/backup.ts +122 -0
  27. package/engines/surrealdb/binary-manager.ts +45 -0
  28. package/engines/surrealdb/binary-urls.ts +37 -0
  29. package/engines/surrealdb/cli-utils.ts +175 -0
  30. package/engines/surrealdb/hostdb-releases.ts +111 -0
  31. package/engines/surrealdb/index.ts +949 -0
  32. package/engines/surrealdb/restore.ts +297 -0
  33. package/engines/surrealdb/version-maps.ts +41 -0
  34. package/package.json +3 -1
  35. package/types/index.ts +18 -0
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * CockroachDB Engine Implementation
3
+ *
4
+ * CockroachDB is a distributed SQL database with PostgreSQL wire protocol compatibility.
5
+ * It provides horizontal scaling, strong consistency, and built-in survivability.
6
+ *
7
+ * Key characteristics:
8
+ * - Default SQL port: 26257
9
+ * - HTTP UI port: SQL port + 1 (default 26258)
10
+ * - Uses PostgreSQL wire protocol for client connections
11
+ * - Single binary: `cockroach` (handles server, sql client, and admin tasks)
12
+ * - Default database: `defaultdb`
13
+ * - Default user: `root` (no password in insecure mode)
14
+ */
15
+
16
+ import { spawn, type SpawnOptions } from 'child_process'
17
+ import { existsSync } from 'fs'
18
+ import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
19
+ import { join } from 'path'
20
+ import { BaseEngine } from '../base-engine'
21
+ import { paths } from '../../config/paths'
22
+ import { getEngineDefaults } from '../../config/defaults'
23
+ import { platformService } from '../../core/platform-service'
24
+ import { configManager } from '../../core/config-manager'
25
+ import { logDebug, logWarning } from '../../core/error-handler'
26
+ import { findBinary } from '../../core/dependency-manager'
27
+ import { processManager } from '../../core/process-manager'
28
+ import { cockroachdbBinaryManager } from './binary-manager'
29
+ import { getBinaryUrl } from './binary-urls'
30
+ import {
31
+ normalizeVersion,
32
+ SUPPORTED_MAJOR_VERSIONS,
33
+ COCKROACHDB_VERSION_MAP,
34
+ } from './version-maps'
35
+ import { fetchAvailableVersions as fetchHostdbVersions } from './hostdb-releases'
36
+ import {
37
+ detectBackupFormat as detectBackupFormatImpl,
38
+ restoreBackup,
39
+ } from './restore'
40
+ import { createBackup } from './backup'
41
+ import {
42
+ validateCockroachIdentifier,
43
+ escapeCockroachIdentifier,
44
+ escapeSqlValue,
45
+ parseCsvLine,
46
+ parseCsvRecords,
47
+ isInsecureConnection,
48
+ } from './cli-utils'
49
+ import {
50
+ type Platform,
51
+ type Arch,
52
+ type ContainerConfig,
53
+ type ProgressCallback,
54
+ type BackupFormat,
55
+ type BackupOptions,
56
+ type BackupResult,
57
+ type RestoreResult,
58
+ type DumpResult,
59
+ type StatusResult,
60
+ } from '../../types'
61
+
62
+ const ENGINE = 'cockroachdb'
63
+ const engineDef = getEngineDefaults(ENGINE)
64
+
65
+ export class CockroachDBEngine extends BaseEngine {
66
+ name = ENGINE
67
+ displayName = 'CockroachDB'
68
+ defaultPort = engineDef.defaultPort
69
+ supportedVersions = SUPPORTED_MAJOR_VERSIONS
70
+
71
+ // Get platform info for binary operations
72
+ getPlatformInfo(): { platform: Platform; arch: Arch } {
73
+ return platformService.getPlatformInfo()
74
+ }
75
+
76
+ // Fetch available versions from hostdb (dynamically or from cache/fallback)
77
+ async fetchAvailableVersions(): Promise<Record<string, string[]>> {
78
+ return fetchHostdbVersions()
79
+ }
80
+
81
+ // Get binary download URL from hostdb
82
+ getBinaryUrl(version: string, platform: Platform, arch: Arch): string {
83
+ return getBinaryUrl(version, platform, arch)
84
+ }
85
+
86
+ // Resolves version string to full version (e.g., '25' -> '25.4.2')
87
+ resolveFullVersion(version: string): string {
88
+ if (/^\d+\.\d+\.\d+$/.test(version)) {
89
+ return version
90
+ }
91
+ return COCKROACHDB_VERSION_MAP[version] || version
92
+ }
93
+
94
+ // Get the path where binaries for a version would be installed
95
+ getBinaryPath(version: string): string {
96
+ const fullVersion = this.resolveFullVersion(version)
97
+ const { platform: p, arch: a } = this.getPlatformInfo()
98
+ return paths.getBinaryPath({
99
+ engine: 'cockroachdb',
100
+ version: fullVersion,
101
+ platform: p,
102
+ arch: a,
103
+ })
104
+ }
105
+
106
+ // Verify that CockroachDB binaries are available
107
+ async verifyBinary(binPath: string): Promise<boolean> {
108
+ const ext = platformService.getExecutableExtension()
109
+ const cockroachPath = join(binPath, 'bin', `cockroach${ext}`)
110
+ return existsSync(cockroachPath)
111
+ }
112
+
113
+ // Check if a specific CockroachDB version is installed (downloaded)
114
+ async isBinaryInstalled(version: string): Promise<boolean> {
115
+ const { platform, arch } = this.getPlatformInfo()
116
+ return cockroachdbBinaryManager.isInstalled(version, platform, arch)
117
+ }
118
+
119
+ /**
120
+ * Ensure CockroachDB binaries are available for a specific version
121
+ * Downloads from hostdb if not already installed
122
+ * Returns the path to the bin directory
123
+ */
124
+ async ensureBinaries(
125
+ version: string,
126
+ onProgress?: ProgressCallback,
127
+ ): Promise<string> {
128
+ const { platform, arch } = this.getPlatformInfo()
129
+
130
+ const binPath = await cockroachdbBinaryManager.ensureInstalled(
131
+ version,
132
+ platform,
133
+ arch,
134
+ onProgress,
135
+ )
136
+
137
+ // Register binary in config
138
+ const ext = platformService.getExecutableExtension()
139
+ const cockroachPath = join(binPath, 'bin', `cockroach${ext}`)
140
+ if (existsSync(cockroachPath)) {
141
+ await configManager.setBinaryPath('cockroach', cockroachPath, 'bundled')
142
+ }
143
+
144
+ return binPath
145
+ }
146
+
147
+ /**
148
+ * Initialize a new CockroachDB data directory
149
+ * Creates the directory structure for CockroachDB's storage
150
+ */
151
+ async initDataDir(
152
+ containerName: string,
153
+ _version: string,
154
+ _options: Record<string, unknown> = {},
155
+ ): Promise<string> {
156
+ const dataDir = paths.getContainerDataPath(containerName, {
157
+ engine: ENGINE,
158
+ })
159
+
160
+ // Create data directory
161
+ await mkdir(dataDir, { recursive: true })
162
+
163
+ logDebug(`Created CockroachDB data directory: ${dataDir}`)
164
+
165
+ return dataDir
166
+ }
167
+
168
+ // Get the path to cockroach binary for a version
169
+ async getCockroachPath(version: string): Promise<string> {
170
+ const { platform, arch } = this.getPlatformInfo()
171
+ const fullVersion = normalizeVersion(version)
172
+ const ext = platformService.getExecutableExtension()
173
+
174
+ const binPath = paths.getBinaryPath({
175
+ engine: 'cockroachdb',
176
+ version: fullVersion,
177
+ platform,
178
+ arch,
179
+ })
180
+ const cockroachPath = join(binPath, 'bin', `cockroach${ext}`)
181
+
182
+ if (existsSync(cockroachPath)) {
183
+ return cockroachPath
184
+ }
185
+
186
+ throw new Error(
187
+ `CockroachDB ${version} is not installed. Run: spindb engines download cockroachdb ${version}`,
188
+ )
189
+ }
190
+
191
+ /**
192
+ * Start CockroachDB server
193
+ */
194
+ async start(
195
+ container: ContainerConfig,
196
+ onProgress?: ProgressCallback,
197
+ ): Promise<{ port: number; connectionString: string }> {
198
+ const { name, port, version, binaryPath } = container
199
+
200
+ // Check if already running
201
+ const alreadyRunning = await processManager.isRunning(name, {
202
+ engine: ENGINE,
203
+ })
204
+ if (alreadyRunning) {
205
+ return {
206
+ port,
207
+ connectionString: this.getConnectionString(container),
208
+ }
209
+ }
210
+
211
+ // Get CockroachDB binary path
212
+ let cockroachBinary: string | null = null
213
+ const ext = platformService.getExecutableExtension()
214
+
215
+ if (binaryPath && existsSync(binaryPath)) {
216
+ const serverPath = join(binaryPath, 'bin', `cockroach${ext}`)
217
+ if (existsSync(serverPath)) {
218
+ cockroachBinary = serverPath
219
+ logDebug(`Using stored binary path: ${cockroachBinary}`)
220
+ }
221
+ }
222
+
223
+ if (!cockroachBinary) {
224
+ try {
225
+ cockroachBinary = await this.getCockroachPath(version)
226
+ } catch (error) {
227
+ const originalMessage =
228
+ error instanceof Error ? error.message : String(error)
229
+ throw new Error(
230
+ `CockroachDB ${version} is not installed. Run: spindb engines download cockroachdb ${version}\n` +
231
+ ` Original error: ${originalMessage}`,
232
+ )
233
+ }
234
+ }
235
+
236
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
237
+ const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
238
+ const logFile = join(containerDir, 'cockroach.log')
239
+ const pidFile = join(containerDir, 'cockroach.pid')
240
+ const httpPort = port + 1 // HTTP admin UI port
241
+
242
+ onProgress?.({ stage: 'starting', message: 'Starting CockroachDB...' })
243
+
244
+ logDebug(`Starting CockroachDB with data dir: ${dataDir}`)
245
+
246
+ // CockroachDB start command
247
+ // Using --insecure for local development (no TLS)
248
+ const args = [
249
+ 'start-single-node',
250
+ '--insecure',
251
+ '--store', dataDir,
252
+ '--listen-addr', `127.0.0.1:${port}`,
253
+ '--http-addr', `127.0.0.1:${httpPort}`,
254
+ '--pid-file', pidFile,
255
+ '--log-dir', containerDir,
256
+ ]
257
+
258
+ // On Unix, use --background flag which forks a daemon process
259
+ // On Windows, don't use --background - Windows doesn't have the same fork model
260
+ // and CockroachDB's background mode can fail silently. Instead, we detach manually.
261
+ const isWindows = process.platform === 'win32'
262
+ if (!isWindows) {
263
+ args.push('--background')
264
+ }
265
+
266
+ // IMPORTANT: Use 'ignore' for all stdio on all platforms.
267
+ // Using 'pipe' keeps file descriptors open which prevents proc.unref() from
268
+ // allowing Node.js to exit, causing spawn timeouts even when the process starts successfully.
269
+ const proc = spawn(cockroachBinary!, args, {
270
+ stdio: ['ignore', 'ignore', 'ignore'],
271
+ detached: true,
272
+ // On Windows, set cwd to container directory to ensure proper file handle behavior
273
+ cwd: isWindows ? containerDir : undefined,
274
+ // On Windows, hide the console window to prevent it from blocking
275
+ windowsHide: true,
276
+ })
277
+
278
+ // On Windows without --background, write PID file ourselves
279
+ // (On Unix, --background makes CockroachDB write the daemon PID)
280
+ if (isWindows && proc.pid) {
281
+ try {
282
+ await writeFile(pidFile, proc.pid.toString(), 'utf-8')
283
+ logDebug(`Wrote PID file: ${pidFile} (pid: ${proc.pid})`)
284
+ } catch (err) {
285
+ // PID file write failed - kill the process and fail fast
286
+ // Without the PID file, we can't stop the container later
287
+ const errMsg = `Failed to write PID file: ${err instanceof Error ? err.message : String(err)}`
288
+ logDebug(errMsg)
289
+ try {
290
+ process.kill(proc.pid, 'SIGTERM')
291
+ } catch {
292
+ // Process may have already exited
293
+ }
294
+ throw new Error(errMsg)
295
+ }
296
+ }
297
+
298
+ // Wait for the process to spawn
299
+ // On Windows, the 'spawn' event doesn't fire reliably with detached processes,
300
+ // so we use a simple delay and let waitForReady() handle detection.
301
+ // On Unix with --background, we wait for the spawn event.
302
+ if (isWindows) {
303
+ // Add error handler to catch spawn failures on Windows
304
+ await new Promise<void>((resolve, reject) => {
305
+ proc.on('error', (err) => {
306
+ logDebug(`CockroachDB spawn error on Windows: ${err}`)
307
+ reject(err)
308
+ })
309
+ proc.unref()
310
+ logDebug(`Windows: waiting fixed delay for CockroachDB to start (pid: ${proc.pid})`)
311
+ setTimeout(resolve, 3000)
312
+ })
313
+ } else {
314
+ const spawnTimeout = 30000 // 30 seconds to spawn
315
+ await new Promise<void>((resolve, reject) => {
316
+ const timeoutId = setTimeout(() => {
317
+ reject(new Error(`CockroachDB process failed to spawn within ${spawnTimeout}ms`))
318
+ }, spawnTimeout)
319
+
320
+ proc.on('error', (err) => {
321
+ clearTimeout(timeoutId)
322
+ logDebug(`CockroachDB spawn error: ${err}`)
323
+ reject(err)
324
+ })
325
+ proc.on('spawn', () => {
326
+ clearTimeout(timeoutId)
327
+ logDebug(`CockroachDB process spawned (pid: ${proc.pid})`)
328
+ proc.unref()
329
+ setTimeout(resolve, 500)
330
+ })
331
+ })
332
+ }
333
+
334
+ // Wait for server to be ready
335
+ // Windows needs a longer timeout since CockroachDB initialization takes more time
336
+ const timeout = isWindows ? 90000 : 60000
337
+ logDebug(`Waiting for CockroachDB server to be ready on port ${port}... (timeout: ${timeout}ms)`)
338
+ const ready = await this.waitForReady(port, version, timeout)
339
+ logDebug(`waitForReady returned: ${ready}`)
340
+
341
+ if (!ready) {
342
+ // Clean up the spawned process and PID file before throwing
343
+ try {
344
+ const pidStr = await readFile(pidFile, 'utf-8').catch(() => null)
345
+ if (pidStr) {
346
+ const pid = parseInt(pidStr.trim(), 10)
347
+ if (!isNaN(pid)) {
348
+ logDebug(`Cleaning up failed CockroachDB process (pid: ${pid})`)
349
+ await platformService.terminateProcess(pid, true)
350
+ }
351
+ }
352
+ await unlink(pidFile).catch(() => {})
353
+ } catch {
354
+ // Ignore cleanup errors
355
+ }
356
+ throw new Error(
357
+ `CockroachDB failed to start within timeout. Check logs at: ${logFile}`,
358
+ )
359
+ }
360
+
361
+ return {
362
+ port,
363
+ connectionString: this.getConnectionString(container),
364
+ }
365
+ }
366
+
367
+ // Wait for CockroachDB to be ready
368
+ private async waitForReady(
369
+ port: number,
370
+ version: string,
371
+ timeoutMs = 60000,
372
+ ): Promise<boolean> {
373
+ logDebug(`waitForReady called for port ${port}, version ${version}`)
374
+ const startTime = Date.now()
375
+ const checkInterval = 500
376
+
377
+ let cockroach: string
378
+ try {
379
+ logDebug('Getting cockroach binary path...')
380
+ cockroach = await this.getCockroachPath(version)
381
+ logDebug(`Got cockroach binary path: ${cockroach}`)
382
+ } catch (err) {
383
+ const errorMessage = err instanceof Error ? err.message : String(err)
384
+ logDebug(`Error getting cockroach binary path: ${errorMessage}`)
385
+ logWarning(
386
+ `CockroachDB binary not found, cannot verify server is ready: ${errorMessage}`,
387
+ )
388
+ return false
389
+ }
390
+
391
+ logDebug(`Starting connection loop, timeout: ${timeoutMs}ms`)
392
+ let attempt = 0
393
+ while (Date.now() - startTime < timeoutMs) {
394
+ attempt++
395
+ logDebug(`Connection attempt ${attempt}...`)
396
+ try {
397
+ const args = [
398
+ 'sql',
399
+ '--insecure',
400
+ '--host',
401
+ `127.0.0.1:${port}`,
402
+ '--execute',
403
+ 'SELECT 1',
404
+ ]
405
+ await new Promise<void>((resolve, reject) => {
406
+ const proc = spawn(cockroach, args, {
407
+ stdio: ['ignore', 'pipe', 'pipe'],
408
+ })
409
+ proc.on('close', (code) => {
410
+ logDebug(`Client process closed with code ${code}`)
411
+ if (code === 0) resolve()
412
+ else reject(new Error(`Exit code ${code}`))
413
+ })
414
+ proc.on('error', (err) => {
415
+ logDebug(`Client process error: ${err}`)
416
+ reject(err)
417
+ })
418
+ })
419
+ logDebug(`CockroachDB ready on port ${port}`)
420
+ return true
421
+ } catch (err) {
422
+ logDebug(`Attempt ${attempt} failed: ${err}`)
423
+ await new Promise((resolve) => setTimeout(resolve, checkInterval))
424
+ }
425
+ }
426
+
427
+ logWarning(`CockroachDB did not become ready within ${timeoutMs}ms`)
428
+ return false
429
+ }
430
+
431
+ /**
432
+ * Stop CockroachDB server
433
+ */
434
+ async stop(container: ContainerConfig): Promise<void> {
435
+ const { name, port } = container
436
+ const containerDir = paths.getContainerPath(name, { engine: ENGINE })
437
+ const pidFile = join(containerDir, 'cockroach.pid')
438
+
439
+ logDebug(`Stopping CockroachDB container "${name}" on port ${port}`)
440
+
441
+ // Find PID by checking the process using cross-platform helper
442
+ let pid: number | null = null
443
+
444
+ // Try to find CockroachDB process by port
445
+ try {
446
+ const pids = await platformService.findProcessByPort(port)
447
+ if (pids.length > 0) {
448
+ pid = pids[0]
449
+ }
450
+ } catch {
451
+ // Ignore
452
+ }
453
+
454
+ // Kill process if found
455
+ if (pid && platformService.isProcessRunning(pid)) {
456
+ logDebug(`Killing CockroachDB process ${pid}`)
457
+ try {
458
+ await platformService.terminateProcess(pid, false)
459
+ // Wait for graceful termination
460
+ // On Windows, CockroachDB's RocksDB uses memory-mapped files that
461
+ // take longer to release, so we wait longer to avoid EBUSY errors
462
+ const gracefulWait = process.platform === 'win32' ? 5000 : 2000
463
+ await new Promise((resolve) => setTimeout(resolve, gracefulWait))
464
+
465
+ if (platformService.isProcessRunning(pid)) {
466
+ logWarning(`Graceful termination failed, force killing ${pid}`)
467
+ await platformService.terminateProcess(pid, true)
468
+ // Additional wait after force kill on Windows for file handle release
469
+ if (process.platform === 'win32') {
470
+ await new Promise((resolve) => setTimeout(resolve, 3000))
471
+ }
472
+ }
473
+ } catch (error) {
474
+ logDebug(`Process termination error: ${error}`)
475
+ }
476
+ }
477
+
478
+ // Cleanup PID file
479
+ if (existsSync(pidFile)) {
480
+ try {
481
+ await unlink(pidFile)
482
+ } catch {
483
+ // Ignore
484
+ }
485
+ }
486
+
487
+ logDebug('CockroachDB stopped')
488
+ }
489
+
490
+ // Get CockroachDB server status
491
+ async status(container: ContainerConfig): Promise<StatusResult> {
492
+ const { port, version } = container
493
+
494
+ // Try to connect
495
+ try {
496
+ const cockroach = await this.getCockroachPath(version)
497
+ const args = [
498
+ 'sql',
499
+ '--insecure',
500
+ '--host',
501
+ `127.0.0.1:${port}`,
502
+ '--execute',
503
+ 'SELECT 1',
504
+ ]
505
+ await new Promise<void>((resolve, reject) => {
506
+ const proc = spawn(cockroach, args, {
507
+ stdio: ['ignore', 'pipe', 'pipe'],
508
+ })
509
+ proc.on('close', (code) => {
510
+ if (code === 0) resolve()
511
+ else reject(new Error(`Exit code ${code}`))
512
+ })
513
+ proc.on('error', reject)
514
+ })
515
+ return { running: true, message: 'CockroachDB is running' }
516
+ } catch {
517
+ return { running: false, message: 'CockroachDB is not running' }
518
+ }
519
+ }
520
+
521
+ // Detect backup format
522
+ async detectBackupFormat(filePath: string): Promise<BackupFormat> {
523
+ return detectBackupFormatImpl(filePath)
524
+ }
525
+
526
+ /**
527
+ * Restore a backup
528
+ */
529
+ async restore(
530
+ container: ContainerConfig,
531
+ backupPath: string,
532
+ options: { database?: string; clean?: boolean } = {},
533
+ ): Promise<RestoreResult> {
534
+ const { name, port, version } = container
535
+
536
+ return restoreBackup(backupPath, {
537
+ containerName: name,
538
+ port,
539
+ database: options.database || container.database || 'defaultdb',
540
+ version,
541
+ clean: options.clean,
542
+ })
543
+ }
544
+
545
+ /**
546
+ * Get connection string
547
+ * Format: postgresql://root@127.0.0.1:PORT/DATABASE?sslmode=disable
548
+ */
549
+ getConnectionString(container: ContainerConfig, database?: string): string {
550
+ const { port } = container
551
+ const db = database || container.database || 'defaultdb'
552
+ return `postgresql://root@127.0.0.1:${port}/${db}?sslmode=disable`
553
+ }
554
+
555
+ // Open cockroach sql interactive shell
556
+ async connect(container: ContainerConfig, database?: string): Promise<void> {
557
+ const { port, version } = container
558
+ const db = database || container.database || 'defaultdb'
559
+
560
+ const cockroach = await this.getCockroachPath(version)
561
+
562
+ const spawnOptions: SpawnOptions = {
563
+ stdio: 'inherit',
564
+ }
565
+
566
+ return new Promise((resolve, reject) => {
567
+ const proc = spawn(
568
+ cockroach,
569
+ ['sql', '--insecure', '--host', `127.0.0.1:${port}`, '--database', db],
570
+ spawnOptions,
571
+ )
572
+
573
+ proc.on('error', reject)
574
+ proc.on('close', () => resolve())
575
+ })
576
+ }
577
+
578
+ /**
579
+ * Create a new database
580
+ */
581
+ async createDatabase(
582
+ container: ContainerConfig,
583
+ database: string,
584
+ ): Promise<void> {
585
+ const { port, version } = container
586
+
587
+ // Validate database identifier to prevent SQL injection
588
+ validateCockroachIdentifier(database, 'database')
589
+ const escapedDb = escapeCockroachIdentifier(database)
590
+
591
+ const cockroach = await this.getCockroachPath(version)
592
+
593
+ const args = [
594
+ 'sql',
595
+ '--insecure',
596
+ '--host',
597
+ `127.0.0.1:${port}`,
598
+ '--execute',
599
+ `CREATE DATABASE IF NOT EXISTS ${escapedDb}`,
600
+ ]
601
+
602
+ await new Promise<void>((resolve, reject) => {
603
+ const proc = spawn(cockroach, args, {
604
+ stdio: ['ignore', 'pipe', 'pipe'],
605
+ })
606
+
607
+ let stderr = ''
608
+ proc.stderr?.on('data', (data: Buffer) => {
609
+ stderr += data.toString()
610
+ })
611
+
612
+ proc.on('close', (code) => {
613
+ if (code === 0) {
614
+ logDebug(`Created CockroachDB database: ${database}`)
615
+ resolve()
616
+ } else {
617
+ reject(new Error(`Failed to create database: ${stderr}`))
618
+ }
619
+ })
620
+ proc.on('error', reject)
621
+ })
622
+ }
623
+
624
+ /**
625
+ * Drop a database
626
+ */
627
+ async dropDatabase(
628
+ container: ContainerConfig,
629
+ database: string,
630
+ ): Promise<void> {
631
+ const { port, version } = container
632
+
633
+ // Don't allow dropping system databases
634
+ const systemDatabases = ['defaultdb', 'postgres', 'system']
635
+ if (systemDatabases.includes(database.toLowerCase())) {
636
+ throw new Error(`Cannot drop system database: ${database}`)
637
+ }
638
+
639
+ // Validate database identifier to prevent SQL injection
640
+ validateCockroachIdentifier(database, 'database')
641
+ const escapedDb = escapeCockroachIdentifier(database)
642
+
643
+ const cockroach = await this.getCockroachPath(version)
644
+
645
+ const args = [
646
+ 'sql',
647
+ '--insecure',
648
+ '--host',
649
+ `127.0.0.1:${port}`,
650
+ '--execute',
651
+ `DROP DATABASE IF EXISTS ${escapedDb}`,
652
+ ]
653
+
654
+ await new Promise<void>((resolve, reject) => {
655
+ const proc = spawn(cockroach, args, {
656
+ stdio: ['ignore', 'pipe', 'pipe'],
657
+ })
658
+
659
+ let stderr = ''
660
+ proc.stderr?.on('data', (data: Buffer) => {
661
+ stderr += data.toString()
662
+ })
663
+
664
+ proc.on('close', (code) => {
665
+ if (code === 0) {
666
+ logDebug(`Dropped CockroachDB database: ${database}`)
667
+ resolve()
668
+ } else {
669
+ reject(new Error(`Failed to drop database: ${stderr}`))
670
+ }
671
+ })
672
+ proc.on('error', reject)
673
+ })
674
+ }
675
+
676
+ /**
677
+ * Get the database size in bytes
678
+ */
679
+ async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
680
+ const { port, version, database } = container
681
+ const db = database || 'defaultdb'
682
+
683
+ try {
684
+ const cockroach = await this.getCockroachPath(version)
685
+ validateCockroachIdentifier(db, 'database')
686
+
687
+ // CockroachDB query to get database size
688
+ const query = `SELECT sum(range_size_mb) * 1024 * 1024 as size_bytes FROM [SHOW RANGES FROM DATABASE ${escapeCockroachIdentifier(db)}]`
689
+
690
+ const result = await new Promise<string>((resolve, reject) => {
691
+ const args = [
692
+ 'sql',
693
+ '--insecure',
694
+ '--host',
695
+ `127.0.0.1:${port}`,
696
+ '--database',
697
+ db,
698
+ '--execute',
699
+ query,
700
+ '--format=csv',
701
+ ]
702
+
703
+ const proc = spawn(cockroach, args, {
704
+ stdio: ['ignore', 'pipe', 'pipe'],
705
+ })
706
+
707
+ let stdout = ''
708
+ proc.stdout?.on('data', (data: Buffer) => {
709
+ stdout += data.toString()
710
+ })
711
+
712
+ proc.on('close', (code) => {
713
+ if (code === 0) resolve(stdout.trim())
714
+ else reject(new Error(`Exit code ${code}`))
715
+ })
716
+ proc.on('error', reject)
717
+ })
718
+
719
+ // Parse CSV output - skip header
720
+ const lines = result.split('\n')
721
+ if (lines.length >= 2) {
722
+ const size = parseFloat(lines[1])
723
+ return isNaN(size) ? null : Math.round(size)
724
+ }
725
+
726
+ return null
727
+ } catch {
728
+ return null
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Dump from a remote CockroachDB connection
734
+ * Uses cockroach sql to export schema and data
735
+ *
736
+ * Connection string format: postgresql://[user[:password]@]host[:port][/database][?sslmode=...]
737
+ *
738
+ * Supports both insecure (local dev) and secure (production) connections:
739
+ * - sslmode=disable or localhost without sslmode: uses --insecure flag
740
+ * - Other SSL modes: passes connection string directly (handles certs via URL params)
741
+ */
742
+ async dumpFromConnectionString(
743
+ connectionString: string,
744
+ outputPath: string,
745
+ ): Promise<DumpResult> {
746
+ // Parse connection string
747
+ let url: URL
748
+ try {
749
+ url = new URL(connectionString)
750
+ } catch {
751
+ // Redact credentials before including in error message
752
+ const sanitized = connectionString.replace(/\/\/([^@]+)@/, '//***@')
753
+ throw new Error(
754
+ `Invalid connection string: ${sanitized}\n` +
755
+ 'Expected format: postgresql://[user[:password]@]host[:port][/database][?sslmode=...]',
756
+ )
757
+ }
758
+
759
+ const host = url.hostname || '127.0.0.1'
760
+ const port = parseInt(url.port, 10) || 26257
761
+ const database = url.pathname.replace(/^\//, '') || 'defaultdb'
762
+
763
+ logDebug(`Connecting to remote CockroachDB at ${host}:${port} (db: ${database})`)
764
+
765
+ // For remote dump, we need a local cockroach binary
766
+ // Try multiple methods to find an installed version
767
+ let cockroach: string | null = null
768
+
769
+ // 1. Try 'cockroach' key in config
770
+ const cachedCockroach = await configManager.getBinaryPath('cockroach')
771
+ if (cachedCockroach && existsSync(cachedCockroach)) {
772
+ cockroach = cachedCockroach
773
+ logDebug(`Found cockroach binary via 'cockroach' config key: ${cockroach}`)
774
+ }
775
+
776
+ // 2. Try to find via dependency manager (checks config + system PATH)
777
+ if (!cockroach) {
778
+ const binaryResult = await findBinary('cockroach')
779
+ if (binaryResult?.path && existsSync(binaryResult.path)) {
780
+ cockroach = binaryResult.path
781
+ logDebug(`Found cockroach binary via dependency manager: ${cockroach}`)
782
+ }
783
+ }
784
+
785
+ // 3. Try to use any downloaded version via getCockroachPath
786
+ if (!cockroach) {
787
+ for (const version of SUPPORTED_MAJOR_VERSIONS) {
788
+ try {
789
+ cockroach = await this.getCockroachPath(version)
790
+ logDebug(`Found cockroach binary for version ${version}: ${cockroach}`)
791
+ break
792
+ } catch {
793
+ // Version not installed, try next
794
+ }
795
+ }
796
+ }
797
+
798
+ if (!cockroach) {
799
+ throw new Error(
800
+ 'CockroachDB binary not found. Run: spindb engines download cockroachdb 25\n' +
801
+ 'A local CockroachDB binary is needed to dump from remote connections.',
802
+ )
803
+ }
804
+
805
+ const lines: string[] = []
806
+ lines.push('-- CockroachDB backup generated by SpinDB')
807
+ lines.push(`-- Source: ${host}:${port}`)
808
+ lines.push(`-- Database: ${database}`)
809
+ lines.push(`-- Date: ${new Date().toISOString()}`)
810
+ lines.push('')
811
+
812
+ // Build connection args using --url to preserve auth/SSL settings
813
+ const connArgs = ['sql', '--url', connectionString]
814
+
815
+ // Only add --insecure for local dev or explicit sslmode=disable
816
+ if (isInsecureConnection(connectionString)) {
817
+ connArgs.push('--insecure')
818
+ }
819
+
820
+ // Get list of tables
821
+ const tablesQuery = `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`
822
+ const tablesResult = await this.execRemoteQuery(cockroach, connArgs, tablesQuery)
823
+ // Parse CSV output properly to handle quoted identifiers
824
+ const tableRecords = parseCsvRecords(tablesResult, true) // Skip header
825
+ const tables = tableRecords
826
+ .map((line) => {
827
+ const fields = parseCsvLine(line)
828
+ return fields.length > 0 ? fields[0].value : ''
829
+ })
830
+ .filter((t) => t)
831
+
832
+ logDebug(`Found ${tables.length} tables in database ${database}`)
833
+
834
+ for (const table of tables) {
835
+ // Table names from information_schema are safe (already unquoted by CSV parser)
836
+ // Only validate that we got a non-empty name
837
+ if (!table) {
838
+ continue
839
+ }
840
+
841
+ lines.push(`-- Table: ${table}`)
842
+ lines.push('')
843
+
844
+ // Get CREATE TABLE - use proper identifier escaping
845
+ try {
846
+ const createQuery = `SHOW CREATE TABLE ${escapeCockroachIdentifier(table)}`
847
+ const createResult = await this.execRemoteQuery(cockroach, connArgs, createQuery)
848
+ // Parse CSV output safely using record-aware parser
849
+ // Format is: table_name,create_statement (create statement may contain newlines)
850
+ const createRecords = parseCsvRecords(createResult, true) // Skip header
851
+ if (createRecords.length > 0) {
852
+ const columns = parseCsvLine(createRecords[0])
853
+ if (columns.length >= 2) {
854
+ // Second column is the CREATE TABLE statement
855
+ const createStatement = columns[1].value.trim()
856
+ lines.push(createStatement + ';')
857
+ } else {
858
+ logWarning(`Unexpected SHOW CREATE TABLE output for ${table}`)
859
+ }
860
+ }
861
+ lines.push('')
862
+ } catch (error) {
863
+ logWarning(`Could not get CREATE TABLE for ${table}: ${error}`)
864
+ continue
865
+ }
866
+
867
+ // Export table data
868
+ try {
869
+ // Get column names first
870
+ // Escape single quotes in table name for string literal comparison
871
+ const escapedTableForString = table.replace(/'/g, "''")
872
+ const columnsQuery = `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${escapedTableForString}' ORDER BY ordinal_position`
873
+ const columnsResult = await this.execRemoteQuery(cockroach, connArgs, columnsQuery)
874
+ // Parse each CSV record properly to handle quoted column names
875
+ const columnRecords = parseCsvRecords(columnsResult, true) // Skip header
876
+ const columns = columnRecords
877
+ .map((record) => {
878
+ const fields = parseCsvLine(record)
879
+ return fields.length > 0 ? fields[0].value.trim() : ''
880
+ })
881
+ .filter((c) => c)
882
+
883
+ if (columns.length === 0) {
884
+ logDebug(`No columns found for table ${table}, skipping data export`)
885
+ continue
886
+ }
887
+
888
+ // Get all rows - use proper identifier escaping
889
+ const dataQuery = `SELECT * FROM ${escapeCockroachIdentifier(table)}`
890
+ const dataResult = await this.execRemoteQuery(cockroach, connArgs, dataQuery)
891
+ // Use record-aware parser to handle fields with embedded newlines
892
+ const dataRecords = parseCsvRecords(dataResult, true) // Skip header
893
+
894
+ if (dataRecords.length > 0) {
895
+ lines.push(`-- Data for ${table}`)
896
+
897
+ for (const dataRecord of dataRecords) {
898
+ const fields = parseCsvLine(dataRecord)
899
+ if (fields.length !== columns.length) {
900
+ logWarning(
901
+ `Column count mismatch for table ${table}: expected ${columns.length}, got ${fields.length}`,
902
+ )
903
+ continue
904
+ }
905
+
906
+ const escapedCols = columns.map((c) => escapeCockroachIdentifier(c)).join(', ')
907
+ const escapedVals = fields
908
+ .map((f) => escapeSqlValue(f.value, f.wasQuoted))
909
+ .join(', ')
910
+ lines.push(
911
+ `INSERT INTO ${escapeCockroachIdentifier(table)} (${escapedCols}) VALUES (${escapedVals});`,
912
+ )
913
+ }
914
+ lines.push('')
915
+ }
916
+ } catch (error) {
917
+ logWarning(`Could not export data for table ${table}: ${error}`)
918
+ }
919
+ }
920
+
921
+ // Write to file
922
+ const content = lines.join('\n')
923
+ await writeFile(outputPath, content, 'utf-8')
924
+
925
+ return {
926
+ filePath: outputPath,
927
+ warnings:
928
+ tables.length === 0
929
+ ? [`Database '${database}' has no tables`]
930
+ : undefined,
931
+ }
932
+ }
933
+
934
+ // Helper to execute a query on a remote CockroachDB
935
+ private async execRemoteQuery(
936
+ cockroach: string,
937
+ connArgs: string[],
938
+ query: string,
939
+ ): Promise<string> {
940
+ return new Promise((resolve, reject) => {
941
+ const args = [...connArgs, '--execute', query, '--format=csv']
942
+
943
+ const proc = spawn(cockroach, args, {
944
+ stdio: ['ignore', 'pipe', 'pipe'],
945
+ })
946
+
947
+ let stdout = ''
948
+ let stderr = ''
949
+
950
+ proc.stdout.on('data', (data: Buffer) => {
951
+ stdout += data.toString()
952
+ })
953
+ proc.stderr.on('data', (data: Buffer) => {
954
+ stderr += data.toString()
955
+ })
956
+
957
+ proc.on('close', (code) => {
958
+ if (code === 0) {
959
+ resolve(stdout)
960
+ } else {
961
+ reject(new Error(stderr || `Exit code ${code}`))
962
+ }
963
+ })
964
+ proc.on('error', reject)
965
+ })
966
+ }
967
+
968
+ // Create a backup
969
+ async backup(
970
+ container: ContainerConfig,
971
+ outputPath: string,
972
+ options: BackupOptions,
973
+ ): Promise<BackupResult> {
974
+ return createBackup(container, outputPath, options)
975
+ }
976
+
977
+ // Run a SQL file or inline SQL statement
978
+ async runScript(
979
+ container: ContainerConfig,
980
+ options: { file?: string; sql?: string; database?: string },
981
+ ): Promise<void> {
982
+ const { port, version } = container
983
+ const db = options.database || container.database || 'defaultdb'
984
+
985
+ const cockroach = await this.getCockroachPath(version)
986
+
987
+ if (options.file) {
988
+ // Run SQL file
989
+ const args = [
990
+ 'sql',
991
+ '--insecure',
992
+ '--host',
993
+ `127.0.0.1:${port}`,
994
+ '--database',
995
+ db,
996
+ '--file',
997
+ options.file,
998
+ ]
999
+
1000
+ await new Promise<void>((resolve, reject) => {
1001
+ const proc = spawn(cockroach, args, {
1002
+ stdio: 'inherit',
1003
+ })
1004
+
1005
+ proc.on('error', reject)
1006
+ proc.on('close', (code) => {
1007
+ if (code === 0) {
1008
+ resolve()
1009
+ } else if (code === null) {
1010
+ reject(new Error('cockroach sql was terminated by a signal'))
1011
+ } else {
1012
+ reject(new Error(`cockroach sql exited with code ${code}`))
1013
+ }
1014
+ })
1015
+ })
1016
+ } else if (options.sql) {
1017
+ // Run inline SQL via stdin
1018
+ const args = [
1019
+ 'sql',
1020
+ '--insecure',
1021
+ '--host',
1022
+ `127.0.0.1:${port}`,
1023
+ '--database',
1024
+ db,
1025
+ ]
1026
+
1027
+ await new Promise<void>((resolve, reject) => {
1028
+ const proc = spawn(cockroach, args, {
1029
+ stdio: ['pipe', 'inherit', 'inherit'],
1030
+ })
1031
+
1032
+ proc.on('error', reject)
1033
+ proc.on('close', (code) => {
1034
+ if (code === 0) {
1035
+ resolve()
1036
+ } else if (code === null) {
1037
+ reject(new Error('cockroach sql was terminated by a signal'))
1038
+ } else {
1039
+ reject(new Error(`cockroach sql exited with code ${code}`))
1040
+ }
1041
+ })
1042
+
1043
+ proc.stdin?.write(options.sql)
1044
+ proc.stdin?.end()
1045
+ })
1046
+ } else {
1047
+ throw new Error('Either file or sql option must be provided')
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ export const cockroachdbEngine = new CockroachDBEngine()