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.
- package/README.md +53 -14
- package/cli/commands/engines.ts +89 -1
- package/cli/commands/menu/backup-handlers.ts +19 -0
- package/cli/commands/menu/container-handlers.ts +4 -2
- package/cli/commands/menu/shell-handlers.ts +52 -2
- package/cli/commands/menu/sql-handlers.ts +7 -1
- package/cli/constants.ts +4 -0
- package/cli/helpers.ts +144 -0
- package/cli/index.ts +1 -1
- package/config/backup-formats.ts +28 -0
- package/config/engine-defaults.ts +26 -0
- package/config/engines.json +32 -0
- package/core/config-manager.ts +5 -0
- package/core/container-manager.ts +10 -4
- package/core/dependency-manager.ts +4 -0
- package/engines/base-engine.ts +16 -0
- package/engines/cockroachdb/backup.ts +363 -0
- package/engines/cockroachdb/binary-manager.ts +45 -0
- package/engines/cockroachdb/binary-urls.ts +37 -0
- package/engines/cockroachdb/cli-utils.ts +384 -0
- package/engines/cockroachdb/hostdb-releases.ts +111 -0
- package/engines/cockroachdb/index.ts +1052 -0
- package/engines/cockroachdb/restore.ts +448 -0
- package/engines/cockroachdb/version-maps.ts +42 -0
- package/engines/index.ts +8 -0
- package/engines/surrealdb/backup.ts +122 -0
- package/engines/surrealdb/binary-manager.ts +45 -0
- package/engines/surrealdb/binary-urls.ts +37 -0
- package/engines/surrealdb/cli-utils.ts +175 -0
- package/engines/surrealdb/hostdb-releases.ts +111 -0
- package/engines/surrealdb/index.ts +949 -0
- package/engines/surrealdb/restore.ts +297 -0
- package/engines/surrealdb/version-maps.ts +41 -0
- package/package.json +3 -1
- 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()
|