spindb 0.15.2 → 0.17.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 +176 -81
- package/bin/cli.js +39 -10
- package/cli/commands/backup.ts +4 -1
- package/cli/commands/backups.ts +17 -23
- package/cli/commands/config.ts +1 -3
- package/cli/commands/connect.ts +16 -2
- package/cli/commands/create.ts +18 -9
- package/cli/commands/deps.ts +1 -3
- package/cli/commands/doctor.ts +29 -16
- package/cli/commands/edit.ts +4 -12
- package/cli/commands/engines.ts +730 -52
- package/cli/commands/list.ts +1 -3
- package/cli/commands/menu/backup-handlers.ts +36 -39
- package/cli/commands/menu/container-handlers.ts +6 -10
- package/cli/commands/menu/engine-handlers.ts +71 -639
- package/cli/commands/menu/shared.ts +2 -6
- package/cli/commands/menu/shell-handlers.ts +63 -16
- package/cli/commands/menu/sql-handlers.ts +10 -7
- package/cli/commands/run.ts +7 -2
- package/cli/commands/start.ts +10 -7
- package/cli/commands/stop.ts +9 -4
- package/cli/constants.ts +2 -1
- package/cli/helpers.ts +358 -357
- package/cli/ui/prompts.ts +8 -16
- package/cli/ui/spinner.ts +3 -9
- package/cli/utils/file-follower.ts +1 -3
- package/config/backup-formats.ts +42 -27
- package/config/engine-defaults.ts +25 -15
- package/config/engines-registry.ts +93 -0
- package/config/engines.json +109 -0
- package/config/engines.schema.json +117 -0
- package/config/os-dependencies.ts +72 -30
- package/config/paths.ts +7 -21
- package/core/backup-restore.ts +16 -32
- package/core/binary-manager.ts +165 -19
- package/core/config-manager.ts +187 -14
- package/core/dependency-manager.ts +13 -46
- package/core/error-handler.ts +1 -3
- package/core/homebrew-version-manager.ts +22 -22
- package/core/hostdb-client.ts +173 -0
- package/core/hostdb-metadata.ts +325 -0
- package/core/platform-service.ts +16 -72
- package/core/process-manager.ts +3 -1
- package/core/spawn-utils.ts +120 -0
- package/core/start-with-retry.ts +1 -3
- package/core/transaction-manager.ts +2 -6
- package/core/update-manager.ts +5 -15
- package/core/version-utils.ts +77 -0
- package/engines/base-engine.ts +29 -42
- package/engines/index.ts +6 -9
- package/engines/mariadb/backup.ts +8 -10
- package/engines/mariadb/binary-manager.ts +58 -138
- package/engines/mariadb/binary-urls.ts +3 -12
- package/engines/mariadb/hostdb-releases.ts +95 -180
- package/engines/mariadb/index.ts +24 -17
- package/engines/mariadb/restore.ts +10 -8
- package/engines/mariadb/version-maps.ts +21 -15
- package/engines/mariadb/version-validator.ts +12 -24
- package/engines/mongodb/backup.ts +2 -6
- package/engines/mongodb/binary-manager.ts +435 -0
- package/engines/mongodb/binary-urls.ts +71 -0
- package/engines/mongodb/cli-utils.ts +78 -0
- package/engines/mongodb/hostdb-releases.ts +182 -0
- package/engines/mongodb/index.ts +217 -152
- package/engines/mongodb/restore.ts +8 -16
- package/engines/mongodb/version-maps.ts +89 -0
- package/engines/mongodb/version-validator.ts +2 -6
- package/engines/mysql/backup.ts +13 -11
- package/engines/mysql/binary-detection.ts +116 -108
- package/engines/mysql/binary-manager.ts +395 -0
- package/engines/mysql/binary-urls.ts +122 -0
- package/engines/mysql/hostdb-releases.ts +199 -0
- package/engines/mysql/index.ts +356 -507
- package/engines/mysql/restore.ts +47 -13
- package/engines/mysql/version-maps.ts +88 -0
- package/engines/mysql/version-validator.ts +2 -6
- package/engines/postgresql/backup.ts +1 -3
- package/engines/postgresql/binary-manager.ts +63 -36
- package/engines/postgresql/binary-urls.ts +4 -15
- package/engines/postgresql/hostdb-releases.ts +101 -170
- package/engines/postgresql/index.ts +45 -70
- package/engines/postgresql/remote-version.ts +1 -3
- package/engines/postgresql/restore.ts +4 -12
- package/engines/postgresql/version-maps.ts +31 -8
- package/engines/postgresql/version-validator.ts +1 -3
- package/engines/redis/backup.ts +131 -100
- package/engines/redis/binary-manager.ts +471 -0
- package/engines/redis/binary-urls.ts +140 -0
- package/engines/redis/cli-utils.ts +44 -0
- package/engines/redis/hostdb-releases.ts +177 -0
- package/engines/redis/index.ts +285 -197
- package/engines/redis/restore.ts +54 -23
- package/engines/redis/version-maps.ts +80 -0
- package/engines/redis/version-validator.ts +1 -3
- package/engines/sqlite/binary-manager.ts +431 -0
- package/engines/sqlite/binary-urls.ts +120 -0
- package/engines/sqlite/index.ts +165 -64
- package/engines/sqlite/registry.ts +9 -27
- package/engines/sqlite/version-maps.ts +63 -0
- package/engines/valkey/backup.ts +389 -0
- package/engines/valkey/binary-manager.ts +473 -0
- package/engines/valkey/binary-urls.ts +143 -0
- package/engines/valkey/cli-utils.ts +44 -0
- package/engines/valkey/hostdb-releases.ts +182 -0
- package/engines/valkey/index.ts +1022 -0
- package/engines/valkey/restore.ts +415 -0
- package/engines/valkey/version-maps.ts +80 -0
- package/engines/valkey/version-validator.ts +131 -0
- package/package.json +11 -2
- package/types/index.ts +103 -21
- package/engines/mongodb/binary-detection.ts +0 -314
- package/engines/redis/binary-detection.ts +0 -442
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
import { spawn, exec, type SpawnOptions } from 'child_process'
|
|
2
|
+
import { promisify } from 'util'
|
|
3
|
+
import { existsSync } from 'fs'
|
|
4
|
+
import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { BaseEngine } from '../base-engine'
|
|
7
|
+
import { paths } from '../../config/paths'
|
|
8
|
+
import { getEngineDefaults } from '../../config/defaults'
|
|
9
|
+
import { platformService, isWindows } from '../../core/platform-service'
|
|
10
|
+
import { configManager } from '../../core/config-manager'
|
|
11
|
+
import { logDebug, logWarning } from '../../core/error-handler'
|
|
12
|
+
import { processManager } from '../../core/process-manager'
|
|
13
|
+
import { valkeyBinaryManager } from './binary-manager'
|
|
14
|
+
import { getBinaryUrl, VERSION_MAP } from './binary-urls'
|
|
15
|
+
import { normalizeVersion, SUPPORTED_MAJOR_VERSIONS } from './version-maps'
|
|
16
|
+
import { fetchAvailableVersions as fetchHostdbVersions } from './hostdb-releases'
|
|
17
|
+
import {
|
|
18
|
+
detectBackupFormat as detectBackupFormatImpl,
|
|
19
|
+
restoreBackup,
|
|
20
|
+
} from './restore'
|
|
21
|
+
import { createBackup } from './backup'
|
|
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 execAsync = promisify(exec)
|
|
34
|
+
|
|
35
|
+
const ENGINE = 'valkey'
|
|
36
|
+
const engineDef = getEngineDefaults(ENGINE)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Shell metacharacters that indicate potential command injection
|
|
40
|
+
* These patterns shouldn't appear in valid Valkey commands
|
|
41
|
+
*/
|
|
42
|
+
const SHELL_INJECTION_PATTERNS = [
|
|
43
|
+
/;\s*\S/, // Command chaining: ; followed by another command
|
|
44
|
+
/\$\(/, // Command substitution: $(...)
|
|
45
|
+
/\$\{/, // Variable substitution: ${...}
|
|
46
|
+
/`/, // Backtick command substitution
|
|
47
|
+
/&&/, // Logical AND chaining
|
|
48
|
+
/\|\|/, // Logical OR chaining
|
|
49
|
+
/\|\s*\S/, // Pipe to another command
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
// Validate that a command doesn't contain shell injection patterns
|
|
53
|
+
function validateCommand(command: string): void {
|
|
54
|
+
for (const pattern of SHELL_INJECTION_PATTERNS) {
|
|
55
|
+
if (pattern.test(command)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Command contains shell metacharacters that are not valid in Valkey commands. ` +
|
|
58
|
+
`If you need complex commands, use a script file instead.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convert a Windows path to Cygwin path format.
|
|
66
|
+
* Valkey Windows binaries are built with Cygwin runtime and expect paths
|
|
67
|
+
* in /cygdrive/c/... format when passed as command-line arguments.
|
|
68
|
+
*
|
|
69
|
+
* Example: C:\Users\foo\config.conf -> /cygdrive/c/Users/foo/config.conf
|
|
70
|
+
*/
|
|
71
|
+
function toCygwinPath(windowsPath: string): string {
|
|
72
|
+
// Match drive letter at start (e.g., C:\ or D:/)
|
|
73
|
+
const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\]/)
|
|
74
|
+
if (!driveMatch) {
|
|
75
|
+
// Not a Windows absolute path, return as-is with forward slashes
|
|
76
|
+
return windowsPath.replace(/\\/g, '/')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const driveLetter = driveMatch[1].toLowerCase()
|
|
80
|
+
const restOfPath = windowsPath.slice(3).replace(/\\/g, '/')
|
|
81
|
+
return `/cygdrive/${driveLetter}/${restOfPath}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build a valkey-cli command for inline command execution
|
|
85
|
+
export function buildValkeyCliCommand(
|
|
86
|
+
valkeyCliPath: string,
|
|
87
|
+
port: number,
|
|
88
|
+
command: string,
|
|
89
|
+
options?: { database?: string },
|
|
90
|
+
): string {
|
|
91
|
+
// Validate command doesn't contain shell injection patterns
|
|
92
|
+
validateCommand(command)
|
|
93
|
+
|
|
94
|
+
const db = options?.database || '0'
|
|
95
|
+
// Escape double quotes consistently on all platforms to prevent shell interpretation issues
|
|
96
|
+
const escaped = command.replace(/"/g, '\\"')
|
|
97
|
+
return `"${valkeyCliPath}" -h 127.0.0.1 -p ${port} -n ${db} ${escaped}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Generate Valkey configuration file content
|
|
101
|
+
function generateValkeyConfig(options: {
|
|
102
|
+
port: number
|
|
103
|
+
dataDir: string
|
|
104
|
+
logFile: string
|
|
105
|
+
pidFile: string
|
|
106
|
+
daemonize?: boolean
|
|
107
|
+
}): string {
|
|
108
|
+
// Windows Valkey doesn't support daemonize natively, use detached spawn instead
|
|
109
|
+
const daemonizeValue = options.daemonize ?? true
|
|
110
|
+
|
|
111
|
+
// Valkey config requires forward slashes even on Windows
|
|
112
|
+
const normalizePathForValkey = (p: string) => p.replace(/\\/g, '/')
|
|
113
|
+
|
|
114
|
+
return `# SpinDB generated Valkey configuration
|
|
115
|
+
port ${options.port}
|
|
116
|
+
bind 127.0.0.1
|
|
117
|
+
dir ${normalizePathForValkey(options.dataDir)}
|
|
118
|
+
daemonize ${daemonizeValue ? 'yes' : 'no'}
|
|
119
|
+
logfile ${normalizePathForValkey(options.logFile)}
|
|
120
|
+
pidfile ${normalizePathForValkey(options.pidFile)}
|
|
121
|
+
|
|
122
|
+
# Persistence - RDB snapshots
|
|
123
|
+
save 900 1
|
|
124
|
+
save 300 10
|
|
125
|
+
save 60 10000
|
|
126
|
+
dbfilename dump.rdb
|
|
127
|
+
|
|
128
|
+
# Append Only File (disabled for local dev)
|
|
129
|
+
appendonly no
|
|
130
|
+
`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class ValkeyEngine extends BaseEngine {
|
|
134
|
+
name = ENGINE
|
|
135
|
+
displayName = 'Valkey'
|
|
136
|
+
defaultPort = engineDef.defaultPort
|
|
137
|
+
supportedVersions = SUPPORTED_MAJOR_VERSIONS
|
|
138
|
+
|
|
139
|
+
// Get platform info for binary operations
|
|
140
|
+
getPlatformInfo(): { platform: string; arch: string } {
|
|
141
|
+
return platformService.getPlatformInfo()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Fetch available versions from hostdb (dynamically or from cache/fallback)
|
|
145
|
+
async fetchAvailableVersions(): Promise<Record<string, string[]>> {
|
|
146
|
+
return fetchHostdbVersions()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Get binary download URL from hostdb
|
|
150
|
+
getBinaryUrl(version: string, platform: string, arch: string): string {
|
|
151
|
+
return getBinaryUrl(version, platform, arch)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Resolves version string to full version (e.g., '8' -> '8.0.6')
|
|
155
|
+
resolveFullVersion(version: string): string {
|
|
156
|
+
// Check if already a full version (has at least two dots)
|
|
157
|
+
if (/^\d+\.\d+\.\d+$/.test(version)) {
|
|
158
|
+
return version
|
|
159
|
+
}
|
|
160
|
+
// It's a major version, resolve using version map
|
|
161
|
+
return VERSION_MAP[version] || `${version}.0.0`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Get the path where binaries for a version would be installed
|
|
165
|
+
getBinaryPath(version: string): string {
|
|
166
|
+
const fullVersion = this.resolveFullVersion(version)
|
|
167
|
+
const { platform: p, arch: a } = this.getPlatformInfo()
|
|
168
|
+
return paths.getBinaryPath({
|
|
169
|
+
engine: 'valkey',
|
|
170
|
+
version: fullVersion,
|
|
171
|
+
platform: p,
|
|
172
|
+
arch: a,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Verify that Valkey binaries are available
|
|
177
|
+
async verifyBinary(binPath: string): Promise<boolean> {
|
|
178
|
+
const ext = platformService.getExecutableExtension()
|
|
179
|
+
const serverPath = join(binPath, 'bin', `valkey-server${ext}`)
|
|
180
|
+
return existsSync(serverPath)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
//Check if a specific Valkey version is installed (downloaded)
|
|
184
|
+
async isBinaryInstalled(version: string): Promise<boolean> {
|
|
185
|
+
const { platform, arch } = this.getPlatformInfo()
|
|
186
|
+
return valkeyBinaryManager.isInstalled(version, platform, arch)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Ensure Valkey binaries are available for a specific version
|
|
191
|
+
* Downloads from hostdb if not already installed
|
|
192
|
+
* Returns the path to the bin directory
|
|
193
|
+
*/
|
|
194
|
+
async ensureBinaries(
|
|
195
|
+
version: string,
|
|
196
|
+
onProgress?: ProgressCallback,
|
|
197
|
+
): Promise<string> {
|
|
198
|
+
const { platform, arch } = this.getPlatformInfo()
|
|
199
|
+
|
|
200
|
+
const binPath = await valkeyBinaryManager.ensureInstalled(
|
|
201
|
+
version,
|
|
202
|
+
platform,
|
|
203
|
+
arch,
|
|
204
|
+
onProgress,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// Register binaries in config
|
|
208
|
+
const ext = platformService.getExecutableExtension()
|
|
209
|
+
const tools = ['valkey-server', 'valkey-cli'] as const
|
|
210
|
+
|
|
211
|
+
for (const tool of tools) {
|
|
212
|
+
const toolPath = join(binPath, 'bin', `${tool}${ext}`)
|
|
213
|
+
if (existsSync(toolPath)) {
|
|
214
|
+
await configManager.setBinaryPath(tool, toolPath, 'bundled')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return binPath
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Initialize a new Valkey data directory
|
|
223
|
+
* Creates the directory and generates valkey.conf
|
|
224
|
+
*/
|
|
225
|
+
async initDataDir(
|
|
226
|
+
containerName: string,
|
|
227
|
+
_version: string,
|
|
228
|
+
options: Record<string, unknown> = {},
|
|
229
|
+
): Promise<string> {
|
|
230
|
+
const dataDir = paths.getContainerDataPath(containerName, {
|
|
231
|
+
engine: ENGINE,
|
|
232
|
+
})
|
|
233
|
+
const containerDir = paths.getContainerPath(containerName, {
|
|
234
|
+
engine: ENGINE,
|
|
235
|
+
})
|
|
236
|
+
const logFile = paths.getContainerLogPath(containerName, { engine: ENGINE })
|
|
237
|
+
const pidFile = join(containerDir, 'valkey.pid')
|
|
238
|
+
const port = (options.port as number) || engineDef.defaultPort
|
|
239
|
+
|
|
240
|
+
// Create data directory if it doesn't exist
|
|
241
|
+
if (!existsSync(dataDir)) {
|
|
242
|
+
await mkdir(dataDir, { recursive: true })
|
|
243
|
+
logDebug(`Created Valkey data directory: ${dataDir}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Generate valkey.conf
|
|
247
|
+
const configPath = join(containerDir, 'valkey.conf')
|
|
248
|
+
const configContent = generateValkeyConfig({
|
|
249
|
+
port,
|
|
250
|
+
dataDir,
|
|
251
|
+
logFile,
|
|
252
|
+
pidFile,
|
|
253
|
+
})
|
|
254
|
+
await writeFile(configPath, configContent)
|
|
255
|
+
logDebug(`Generated Valkey config: ${configPath}`)
|
|
256
|
+
|
|
257
|
+
return dataDir
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Get the path to valkey-server for a version
|
|
261
|
+
async getValkeyServerPath(version: string): Promise<string> {
|
|
262
|
+
const { platform, arch } = this.getPlatformInfo()
|
|
263
|
+
const fullVersion = normalizeVersion(version)
|
|
264
|
+
const binPath = paths.getBinaryPath({
|
|
265
|
+
engine: 'valkey',
|
|
266
|
+
version: fullVersion,
|
|
267
|
+
platform,
|
|
268
|
+
arch,
|
|
269
|
+
})
|
|
270
|
+
const ext = platformService.getExecutableExtension()
|
|
271
|
+
const serverPath = join(binPath, 'bin', `valkey-server${ext}`)
|
|
272
|
+
if (existsSync(serverPath)) {
|
|
273
|
+
return serverPath
|
|
274
|
+
}
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Valkey ${version} is not installed. Run: spindb engines download valkey ${version}`,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Get the path to valkey-cli for a version
|
|
281
|
+
override async getValkeyCliPath(version?: string): Promise<string> {
|
|
282
|
+
// Check config cache first
|
|
283
|
+
const cached = await configManager.getBinaryPath('valkey-cli')
|
|
284
|
+
if (cached && existsSync(cached)) {
|
|
285
|
+
return cached
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// If version provided, use downloaded binary
|
|
289
|
+
if (version) {
|
|
290
|
+
const { platform, arch } = this.getPlatformInfo()
|
|
291
|
+
const fullVersion = normalizeVersion(version)
|
|
292
|
+
const binPath = paths.getBinaryPath({
|
|
293
|
+
engine: 'valkey',
|
|
294
|
+
version: fullVersion,
|
|
295
|
+
platform,
|
|
296
|
+
arch,
|
|
297
|
+
})
|
|
298
|
+
const ext = platformService.getExecutableExtension()
|
|
299
|
+
const cliPath = join(binPath, 'bin', `valkey-cli${ext}`)
|
|
300
|
+
if (existsSync(cliPath)) {
|
|
301
|
+
return cliPath
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
throw new Error(
|
|
306
|
+
'valkey-cli not found. Run: spindb engines download valkey <version>',
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Start Valkey server
|
|
312
|
+
* CLI wrapper: valkey-server /path/to/valkey.conf
|
|
313
|
+
*/
|
|
314
|
+
async start(
|
|
315
|
+
container: ContainerConfig,
|
|
316
|
+
onProgress?: ProgressCallback,
|
|
317
|
+
): Promise<{ port: number; connectionString: string }> {
|
|
318
|
+
const { name, port, version, binaryPath } = container
|
|
319
|
+
|
|
320
|
+
// Check if already running (idempotent behavior)
|
|
321
|
+
const alreadyRunning = await processManager.isRunning(name, {
|
|
322
|
+
engine: ENGINE,
|
|
323
|
+
})
|
|
324
|
+
if (alreadyRunning) {
|
|
325
|
+
return {
|
|
326
|
+
port,
|
|
327
|
+
connectionString: this.getConnectionString(container),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Use stored binary path if available (from container creation)
|
|
332
|
+
// This ensures version consistency - the container uses the same binary it was created with
|
|
333
|
+
let valkeyServer: string | null = null
|
|
334
|
+
|
|
335
|
+
if (binaryPath && existsSync(binaryPath)) {
|
|
336
|
+
// binaryPath is the directory (e.g., ~/.spindb/bin/valkey-8.0.6-linux-arm64)
|
|
337
|
+
// We need to construct the full path to valkey-server
|
|
338
|
+
const ext = platformService.getExecutableExtension()
|
|
339
|
+
const serverPath = join(binaryPath, 'bin', `valkey-server${ext}`)
|
|
340
|
+
if (existsSync(serverPath)) {
|
|
341
|
+
valkeyServer = serverPath
|
|
342
|
+
logDebug(`Using stored binary path: ${valkeyServer}`)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// If we didn't find the binary above, fall back to normal path
|
|
347
|
+
if (!valkeyServer) {
|
|
348
|
+
// Get binary from downloaded hostdb binaries
|
|
349
|
+
try {
|
|
350
|
+
valkeyServer = await this.getValkeyServerPath(version)
|
|
351
|
+
} catch (error) {
|
|
352
|
+
// Binary not downloaded yet - this is an orphaned container situation
|
|
353
|
+
const originalMessage =
|
|
354
|
+
error instanceof Error ? error.message : String(error)
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Valkey ${version} is not installed. Run: spindb engines download valkey ${version}\n` +
|
|
357
|
+
` Original error: ${originalMessage}`,
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
logDebug(`Using valkey-server for version ${version}: ${valkeyServer}`)
|
|
363
|
+
|
|
364
|
+
const containerDir = paths.getContainerPath(name, { engine: ENGINE })
|
|
365
|
+
const configPath = join(containerDir, 'valkey.conf')
|
|
366
|
+
const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
|
|
367
|
+
const logFile = paths.getContainerLogPath(name, { engine: ENGINE })
|
|
368
|
+
const pidFile = join(containerDir, 'valkey.pid')
|
|
369
|
+
|
|
370
|
+
// Windows Valkey doesn't support daemonize natively
|
|
371
|
+
// Use detached spawn on Windows instead, similar to MongoDB
|
|
372
|
+
const useDetachedSpawn = isWindows()
|
|
373
|
+
|
|
374
|
+
// Regenerate config with current port (in case it changed)
|
|
375
|
+
const configContent = generateValkeyConfig({
|
|
376
|
+
port,
|
|
377
|
+
dataDir,
|
|
378
|
+
logFile,
|
|
379
|
+
pidFile,
|
|
380
|
+
daemonize: !useDetachedSpawn, // Disable daemonize on Windows
|
|
381
|
+
})
|
|
382
|
+
await writeFile(configPath, configContent)
|
|
383
|
+
|
|
384
|
+
onProgress?.({ stage: 'starting', message: 'Starting Valkey...' })
|
|
385
|
+
|
|
386
|
+
logDebug(`Starting valkey-server with config: ${configPath}`)
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Check log file for port binding errors
|
|
390
|
+
* Returns error message if found, null otherwise
|
|
391
|
+
*/
|
|
392
|
+
const checkLogForPortError = async (): Promise<string | null> => {
|
|
393
|
+
try {
|
|
394
|
+
const logContent = await readFile(logFile, 'utf-8')
|
|
395
|
+
const recentLog = logContent.slice(-2000) // Last 2KB
|
|
396
|
+
|
|
397
|
+
if (
|
|
398
|
+
recentLog.includes('Address already in use') ||
|
|
399
|
+
recentLog.includes('bind: Address already in use')
|
|
400
|
+
) {
|
|
401
|
+
return `Port ${port} is already in use (address already in use)`
|
|
402
|
+
}
|
|
403
|
+
if (recentLog.includes('Failed listening on port')) {
|
|
404
|
+
return `Port ${port} is already in use`
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
// Log file might not exist yet
|
|
408
|
+
}
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (useDetachedSpawn) {
|
|
413
|
+
// Windows: spawn detached process with proper error handling
|
|
414
|
+
// This follows the pattern used by MySQL which works on Windows
|
|
415
|
+
return new Promise((resolve, reject) => {
|
|
416
|
+
const spawnOpts: SpawnOptions = {
|
|
417
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
418
|
+
detached: true,
|
|
419
|
+
windowsHide: true,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Convert Windows path to Cygwin format for Cygwin-built binaries
|
|
423
|
+
const cygwinConfigPath = toCygwinPath(configPath)
|
|
424
|
+
const proc = spawn(valkeyServer, [cygwinConfigPath], spawnOpts)
|
|
425
|
+
let settled = false
|
|
426
|
+
let stderrOutput = ''
|
|
427
|
+
let stdoutOutput = ''
|
|
428
|
+
|
|
429
|
+
// Handle spawn errors (binary not found, DLL issues, etc.)
|
|
430
|
+
proc.on('error', (err) => {
|
|
431
|
+
if (settled) return
|
|
432
|
+
settled = true
|
|
433
|
+
reject(new Error(`Failed to spawn Valkey server: ${err.message}`))
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
437
|
+
const str = data.toString()
|
|
438
|
+
stdoutOutput += str
|
|
439
|
+
logDebug(`valkey-server stdout: ${str}`)
|
|
440
|
+
})
|
|
441
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
442
|
+
const str = data.toString()
|
|
443
|
+
stderrOutput += str
|
|
444
|
+
logDebug(`valkey-server stderr: ${str}`)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// Detach the process so it continues running after parent exits
|
|
448
|
+
proc.unref()
|
|
449
|
+
|
|
450
|
+
// Give spawn a moment to fail if it's going to, then check readiness
|
|
451
|
+
setTimeout(async () => {
|
|
452
|
+
if (settled) return
|
|
453
|
+
|
|
454
|
+
// Verify process actually started
|
|
455
|
+
if (!proc.pid) {
|
|
456
|
+
settled = true
|
|
457
|
+
reject(new Error('Valkey server process failed to start (no PID)'))
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Write PID file for consistency with other engines
|
|
462
|
+
try {
|
|
463
|
+
await writeFile(pidFile, String(proc.pid))
|
|
464
|
+
} catch {
|
|
465
|
+
// Non-fatal - process is running, PID file is for convenience
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Wait for Valkey to be ready
|
|
469
|
+
const ready = await this.waitForReady(port, version)
|
|
470
|
+
if (settled) return
|
|
471
|
+
|
|
472
|
+
if (ready) {
|
|
473
|
+
settled = true
|
|
474
|
+
resolve({
|
|
475
|
+
port,
|
|
476
|
+
connectionString: this.getConnectionString(container),
|
|
477
|
+
})
|
|
478
|
+
} else {
|
|
479
|
+
settled = true
|
|
480
|
+
const portError = await checkLogForPortError()
|
|
481
|
+
|
|
482
|
+
// Read log file content for better error diagnostics
|
|
483
|
+
let logContent = ''
|
|
484
|
+
try {
|
|
485
|
+
logContent = await readFile(logFile, 'utf-8')
|
|
486
|
+
} catch {
|
|
487
|
+
logContent = '(log file not found or empty)'
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const errorDetails = [
|
|
491
|
+
portError || 'Valkey failed to start within timeout.',
|
|
492
|
+
`Binary: ${valkeyServer}`,
|
|
493
|
+
`Config: ${configPath}`,
|
|
494
|
+
`Log file: ${logFile}`,
|
|
495
|
+
`Log content:\n${logContent || '(empty)'}`,
|
|
496
|
+
stderrOutput ? `Stderr:\n${stderrOutput}` : '',
|
|
497
|
+
stdoutOutput ? `Stdout:\n${stdoutOutput}` : '',
|
|
498
|
+
]
|
|
499
|
+
.filter(Boolean)
|
|
500
|
+
.join('\n')
|
|
501
|
+
|
|
502
|
+
reject(new Error(errorDetails))
|
|
503
|
+
}
|
|
504
|
+
}, 500)
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Unix: Valkey with daemonize: yes handles its own forking
|
|
509
|
+
return new Promise((resolve, reject) => {
|
|
510
|
+
const proc = spawn(valkeyServer, [configPath], {
|
|
511
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
let stdout = ''
|
|
515
|
+
let stderr = ''
|
|
516
|
+
|
|
517
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
518
|
+
stdout += data.toString()
|
|
519
|
+
logDebug(`valkey-server stdout: ${data.toString()}`)
|
|
520
|
+
})
|
|
521
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
522
|
+
stderr += data.toString()
|
|
523
|
+
logDebug(`valkey-server stderr: ${data.toString()}`)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
proc.on('error', reject)
|
|
527
|
+
|
|
528
|
+
proc.on('close', async (code) => {
|
|
529
|
+
// Valkey with daemonize: yes exits immediately after forking
|
|
530
|
+
// Exit code 0 means the parent forked successfully, but the child may still fail
|
|
531
|
+
if (code === 0 || code === null) {
|
|
532
|
+
// Give the child process a moment to start (or fail)
|
|
533
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
534
|
+
|
|
535
|
+
// Check log for early startup failures (like port conflicts)
|
|
536
|
+
const earlyError = await checkLogForPortError()
|
|
537
|
+
if (earlyError) {
|
|
538
|
+
reject(new Error(earlyError))
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Wait for Valkey to be ready
|
|
543
|
+
const ready = await this.waitForReady(port, version)
|
|
544
|
+
if (ready) {
|
|
545
|
+
resolve({
|
|
546
|
+
port,
|
|
547
|
+
connectionString: this.getConnectionString(container),
|
|
548
|
+
})
|
|
549
|
+
} else {
|
|
550
|
+
// Check log again for errors if not ready
|
|
551
|
+
const portError = await checkLogForPortError()
|
|
552
|
+
if (portError) {
|
|
553
|
+
reject(new Error(portError))
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
reject(
|
|
557
|
+
new Error(
|
|
558
|
+
`Valkey failed to start within timeout. Check logs at: ${logFile}`,
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
reject(
|
|
564
|
+
new Error(
|
|
565
|
+
stderr || stdout || `valkey-server exited with code ${code}`,
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Wait for Valkey to be ready to accept connections
|
|
574
|
+
// TODO - consider copying the mongodb logic for this
|
|
575
|
+
private async waitForReady(
|
|
576
|
+
port: number,
|
|
577
|
+
version: string,
|
|
578
|
+
timeoutMs = 30000,
|
|
579
|
+
): Promise<boolean> {
|
|
580
|
+
const startTime = Date.now()
|
|
581
|
+
const checkInterval = 500
|
|
582
|
+
|
|
583
|
+
let valkeyCli: string
|
|
584
|
+
try {
|
|
585
|
+
valkeyCli = await this.getValkeyCliPathForVersion(version)
|
|
586
|
+
} catch {
|
|
587
|
+
logWarning(
|
|
588
|
+
'valkey-cli not found, cannot verify Valkey is ready. Assuming ready after brief delay.',
|
|
589
|
+
)
|
|
590
|
+
// Give Valkey a moment to start, then assume success
|
|
591
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
592
|
+
return true
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
596
|
+
try {
|
|
597
|
+
const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} PING`
|
|
598
|
+
const { stdout } = await execAsync(cmd, { timeout: 5000 })
|
|
599
|
+
if (stdout.trim() === 'PONG') {
|
|
600
|
+
logDebug(`Valkey ready on port ${port}`)
|
|
601
|
+
return true
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
await new Promise((resolve) => setTimeout(resolve, checkInterval))
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
logWarning(`Valkey did not become ready within ${timeoutMs}ms`)
|
|
609
|
+
return false
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Stop Valkey server
|
|
614
|
+
* Uses SHUTDOWN SAVE via valkey-cli to persist data before stopping
|
|
615
|
+
*/
|
|
616
|
+
async stop(container: ContainerConfig): Promise<void> {
|
|
617
|
+
const { name, port, version } = container
|
|
618
|
+
const containerDir = paths.getContainerPath(name, { engine: ENGINE })
|
|
619
|
+
const pidFile = join(containerDir, 'valkey.pid')
|
|
620
|
+
|
|
621
|
+
logDebug(`Stopping Valkey container "${name}" on port ${port}`)
|
|
622
|
+
|
|
623
|
+
// Try graceful shutdown via valkey-cli
|
|
624
|
+
const valkeyCli = await this.getValkeyCliPathForVersion(version)
|
|
625
|
+
if (valkeyCli) {
|
|
626
|
+
try {
|
|
627
|
+
const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} SHUTDOWN SAVE`
|
|
628
|
+
await execAsync(cmd, { timeout: 10000 })
|
|
629
|
+
logDebug('Valkey shutdown command sent')
|
|
630
|
+
// Wait a bit for process to exit
|
|
631
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
632
|
+
} catch (error) {
|
|
633
|
+
logDebug(`valkey-cli shutdown failed: ${error}`)
|
|
634
|
+
// Continue to PID-based shutdown
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Get PID and force kill if needed
|
|
639
|
+
let pid: number | null = null
|
|
640
|
+
|
|
641
|
+
if (existsSync(pidFile)) {
|
|
642
|
+
try {
|
|
643
|
+
const content = await readFile(pidFile, 'utf8')
|
|
644
|
+
pid = parseInt(content.trim(), 10)
|
|
645
|
+
} catch {
|
|
646
|
+
// Ignore
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Kill process if still running
|
|
651
|
+
if (pid && platformService.isProcessRunning(pid)) {
|
|
652
|
+
logDebug(`Killing Valkey process ${pid}`)
|
|
653
|
+
try {
|
|
654
|
+
await platformService.terminateProcess(pid, false)
|
|
655
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
656
|
+
|
|
657
|
+
if (platformService.isProcessRunning(pid)) {
|
|
658
|
+
logWarning(`Graceful termination failed, force killing ${pid}`)
|
|
659
|
+
await platformService.terminateProcess(pid, true)
|
|
660
|
+
}
|
|
661
|
+
} catch (error) {
|
|
662
|
+
logDebug(`Process termination error: ${error}`)
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Cleanup PID file
|
|
667
|
+
if (existsSync(pidFile)) {
|
|
668
|
+
try {
|
|
669
|
+
await unlink(pidFile)
|
|
670
|
+
} catch {
|
|
671
|
+
// Ignore
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
logDebug('Valkey stopped')
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Get Valkey server status
|
|
679
|
+
async status(container: ContainerConfig): Promise<StatusResult> {
|
|
680
|
+
const { name, port, version } = container
|
|
681
|
+
const containerDir = paths.getContainerPath(name, { engine: ENGINE })
|
|
682
|
+
const pidFile = join(containerDir, 'valkey.pid')
|
|
683
|
+
|
|
684
|
+
// Try pinging with valkey-cli
|
|
685
|
+
const valkeyCli = await this.getValkeyCliPathForVersion(version)
|
|
686
|
+
if (valkeyCli) {
|
|
687
|
+
try {
|
|
688
|
+
const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} PING`
|
|
689
|
+
const { stdout } = await execAsync(cmd, { timeout: 5000 })
|
|
690
|
+
if (stdout.trim() === 'PONG') {
|
|
691
|
+
return { running: true, message: 'Valkey is running' }
|
|
692
|
+
}
|
|
693
|
+
} catch {
|
|
694
|
+
// Not responding, check PID
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Check PID file
|
|
699
|
+
if (existsSync(pidFile)) {
|
|
700
|
+
try {
|
|
701
|
+
const content = await readFile(pidFile, 'utf8')
|
|
702
|
+
const pid = parseInt(content.trim(), 10)
|
|
703
|
+
if (!isNaN(pid) && pid > 0 && platformService.isProcessRunning(pid)) {
|
|
704
|
+
return {
|
|
705
|
+
running: true,
|
|
706
|
+
message: `Valkey is running (PID: ${pid})`,
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
} catch {
|
|
710
|
+
// Ignore
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return { running: false, message: 'Valkey is not running' }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Detect backup format
|
|
718
|
+
async detectBackupFormat(filePath: string): Promise<BackupFormat> {
|
|
719
|
+
return detectBackupFormatImpl(filePath)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Restore a backup
|
|
724
|
+
* IMPORTANT: Valkey must be stopped before restore
|
|
725
|
+
*/
|
|
726
|
+
async restore(
|
|
727
|
+
container: ContainerConfig,
|
|
728
|
+
backupPath: string,
|
|
729
|
+
options: { database?: string; flush?: boolean } = {},
|
|
730
|
+
): Promise<RestoreResult> {
|
|
731
|
+
const { name, port } = container
|
|
732
|
+
const dataDir = paths.getContainerDataPath(name, { engine: ENGINE })
|
|
733
|
+
|
|
734
|
+
return restoreBackup(backupPath, {
|
|
735
|
+
containerName: name,
|
|
736
|
+
dataDir,
|
|
737
|
+
port,
|
|
738
|
+
database: options.database || container.database || '0',
|
|
739
|
+
flush: options.flush,
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Get connection string
|
|
745
|
+
* Format: redis://127.0.0.1:PORT/DATABASE
|
|
746
|
+
* (Uses redis:// scheme for client compatibility)
|
|
747
|
+
*/
|
|
748
|
+
getConnectionString(container: ContainerConfig, database?: string): string {
|
|
749
|
+
const { port } = container
|
|
750
|
+
const db = database || container.database || '0'
|
|
751
|
+
return `redis://127.0.0.1:${port}/${db}`
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Get path to valkey-cli for a specific version
|
|
756
|
+
* @param version - Optional version (e.g., "8", "9"). If not provided, uses cached path.
|
|
757
|
+
* @deprecated Use getValkeyCliPath() instead
|
|
758
|
+
*/
|
|
759
|
+
async getValkeyCliPathForVersion(version?: string): Promise<string> {
|
|
760
|
+
return this.getValkeyCliPath(version)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Open valkey-cli interactive shell
|
|
764
|
+
async connect(container: ContainerConfig, database?: string): Promise<void> {
|
|
765
|
+
const { port, version } = container
|
|
766
|
+
const db = database || container.database || '0'
|
|
767
|
+
|
|
768
|
+
const valkeyCli = await this.getValkeyCliPathForVersion(version)
|
|
769
|
+
|
|
770
|
+
const spawnOptions: SpawnOptions = {
|
|
771
|
+
stdio: 'inherit',
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return new Promise((resolve, reject) => {
|
|
775
|
+
const proc = spawn(
|
|
776
|
+
valkeyCli,
|
|
777
|
+
['-h', '127.0.0.1', '-p', String(port), '-n', db],
|
|
778
|
+
spawnOptions,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
proc.on('error', reject)
|
|
782
|
+
proc.on('close', () => resolve())
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Get path to iredis (enhanced CLI) if installed
|
|
787
|
+
// Note: iredis works with Valkey since it's protocol-compatible
|
|
788
|
+
private async getIredisPath(): Promise<string | null> {
|
|
789
|
+
// Check config cache first
|
|
790
|
+
const cached = await configManager.getBinaryPath('iredis')
|
|
791
|
+
if (cached && existsSync(cached)) {
|
|
792
|
+
return cached
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Check system PATH
|
|
796
|
+
const systemPath = await platformService.findToolPath('iredis')
|
|
797
|
+
if (systemPath) {
|
|
798
|
+
return systemPath
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return null
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Connect with iredis (enhanced CLI)
|
|
805
|
+
async connectWithIredis(
|
|
806
|
+
container: ContainerConfig,
|
|
807
|
+
database?: string,
|
|
808
|
+
): Promise<void> {
|
|
809
|
+
const { port } = container
|
|
810
|
+
const db = database || container.database || '0'
|
|
811
|
+
|
|
812
|
+
const iredis = await this.getIredisPath()
|
|
813
|
+
if (!iredis) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
'iredis not found. Install it with:\n' +
|
|
816
|
+
' macOS: brew install iredis\n' +
|
|
817
|
+
' pip: pip install iredis',
|
|
818
|
+
)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const spawnOptions: SpawnOptions = {
|
|
822
|
+
stdio: 'inherit',
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return new Promise((resolve, reject) => {
|
|
826
|
+
const proc = spawn(
|
|
827
|
+
iredis,
|
|
828
|
+
['-h', '127.0.0.1', '-p', String(port), '-n', db],
|
|
829
|
+
spawnOptions,
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
proc.on('error', reject)
|
|
833
|
+
proc.on('close', () => resolve())
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Create a new database
|
|
839
|
+
* Valkey uses numbered databases (0-15), they always exist
|
|
840
|
+
* This is effectively a no-op
|
|
841
|
+
*/
|
|
842
|
+
async createDatabase(
|
|
843
|
+
_container: ContainerConfig,
|
|
844
|
+
database: string,
|
|
845
|
+
): Promise<void> {
|
|
846
|
+
const dbNum = parseInt(database, 10)
|
|
847
|
+
if (isNaN(dbNum) || dbNum < 0 || dbNum > 15) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
`Invalid Valkey database number: ${database}. Must be 0-15.`,
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
// No-op - Valkey databases always exist
|
|
853
|
+
logDebug(
|
|
854
|
+
`Valkey database ${database} is available (databases 0-15 always exist)`,
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Drop a database
|
|
860
|
+
* Uses FLUSHDB to clear all keys in the specified database
|
|
861
|
+
*/
|
|
862
|
+
async dropDatabase(
|
|
863
|
+
container: ContainerConfig,
|
|
864
|
+
database: string,
|
|
865
|
+
): Promise<void> {
|
|
866
|
+
const { port, version } = container
|
|
867
|
+
const dbNum = parseInt(database, 10)
|
|
868
|
+
if (isNaN(dbNum) || dbNum < 0 || dbNum > 15) {
|
|
869
|
+
throw new Error(
|
|
870
|
+
`Invalid Valkey database number: ${database}. Must be 0-15.`,
|
|
871
|
+
)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const valkeyCli = await this.getValkeyCliPathForVersion(version)
|
|
875
|
+
|
|
876
|
+
// SELECT the database and FLUSHDB
|
|
877
|
+
const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} -n ${database} FLUSHDB`
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
await execAsync(cmd, { timeout: 10000 })
|
|
881
|
+
logDebug(`Flushed Valkey database ${database}`)
|
|
882
|
+
} catch (error) {
|
|
883
|
+
const err = error as Error
|
|
884
|
+
logDebug(`FLUSHDB failed: ${err.message}`)
|
|
885
|
+
throw new Error(
|
|
886
|
+
`Failed to flush Valkey database ${database}: ${err.message}`,
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Get the memory usage of the Valkey server in bytes
|
|
893
|
+
*
|
|
894
|
+
* NOTE: Valkey does not provide per-database memory statistics.
|
|
895
|
+
* This returns the total server memory (used_memory from INFO memory),
|
|
896
|
+
* not the size of a specific numbered database (0-15).
|
|
897
|
+
* This is acceptable for SpinDB since each container runs one Valkey server.
|
|
898
|
+
*/
|
|
899
|
+
async getDatabaseSize(container: ContainerConfig): Promise<number | null> {
|
|
900
|
+
const { port, version } = container
|
|
901
|
+
|
|
902
|
+
try {
|
|
903
|
+
const valkeyCli = await this.getValkeyCliPathForVersion(version)
|
|
904
|
+
// INFO memory returns server-wide stats (database selection has no effect)
|
|
905
|
+
const cmd = `"${valkeyCli}" -h 127.0.0.1 -p ${port} INFO memory`
|
|
906
|
+
|
|
907
|
+
const { stdout } = await execAsync(cmd, { timeout: 10000 })
|
|
908
|
+
|
|
909
|
+
// Parse used_memory (total server memory) from INFO output
|
|
910
|
+
const match = stdout.match(/used_memory:(\d+)/)
|
|
911
|
+
if (match) {
|
|
912
|
+
return parseInt(match[1], 10)
|
|
913
|
+
}
|
|
914
|
+
return null
|
|
915
|
+
} catch {
|
|
916
|
+
return null
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Dump from a remote Valkey connection
|
|
922
|
+
* Valkey doesn't support remote dump like pg_dump/mongodump
|
|
923
|
+
* Throw an error with guidance
|
|
924
|
+
*/
|
|
925
|
+
async dumpFromConnectionString(
|
|
926
|
+
_connectionString: string,
|
|
927
|
+
_outputPath: string,
|
|
928
|
+
): Promise<DumpResult> {
|
|
929
|
+
throw new Error(
|
|
930
|
+
'Valkey does not support creating containers from remote connection strings.\n' +
|
|
931
|
+
'To migrate data from a remote Valkey instance:\n' +
|
|
932
|
+
' 1. On remote server: valkey-cli --rdb dump.rdb\n' +
|
|
933
|
+
' 2. Copy dump.rdb to local machine\n' +
|
|
934
|
+
' 3. spindb restore <container> dump.rdb',
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Create a backup
|
|
939
|
+
async backup(
|
|
940
|
+
container: ContainerConfig,
|
|
941
|
+
outputPath: string,
|
|
942
|
+
options: BackupOptions,
|
|
943
|
+
): Promise<BackupResult> {
|
|
944
|
+
return createBackup(container, outputPath, options)
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Run a Valkey command file or inline command
|
|
948
|
+
async runScript(
|
|
949
|
+
container: ContainerConfig,
|
|
950
|
+
options: { file?: string; sql?: string; database?: string },
|
|
951
|
+
): Promise<void> {
|
|
952
|
+
const { port, version } = container
|
|
953
|
+
const db = options.database || container.database || '0'
|
|
954
|
+
|
|
955
|
+
const valkeyCli = await this.getValkeyCliPathForVersion(version)
|
|
956
|
+
|
|
957
|
+
if (options.file) {
|
|
958
|
+
// Read file and pipe to valkey-cli via stdin (avoids shell interpolation issues)
|
|
959
|
+
const fileContent = await readFile(options.file, 'utf-8')
|
|
960
|
+
const args = ['-h', '127.0.0.1', '-p', String(port), '-n', db]
|
|
961
|
+
|
|
962
|
+
await new Promise<void>((resolve, reject) => {
|
|
963
|
+
const proc = spawn(valkeyCli, args, {
|
|
964
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
let rejected = false
|
|
968
|
+
|
|
969
|
+
proc.on('error', (err) => {
|
|
970
|
+
rejected = true
|
|
971
|
+
reject(err)
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
proc.on('close', (code) => {
|
|
975
|
+
if (rejected) return
|
|
976
|
+
if (code === 0 || code === null) {
|
|
977
|
+
resolve()
|
|
978
|
+
} else {
|
|
979
|
+
reject(new Error(`valkey-cli exited with code ${code}`))
|
|
980
|
+
}
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
// Write file content to stdin and close it
|
|
984
|
+
proc.stdin?.write(fileContent)
|
|
985
|
+
proc.stdin?.end()
|
|
986
|
+
})
|
|
987
|
+
} else if (options.sql) {
|
|
988
|
+
// Run inline command by piping to valkey-cli stdin (avoids shell quoting issues on Windows)
|
|
989
|
+
const args = ['-h', '127.0.0.1', '-p', String(port), '-n', db]
|
|
990
|
+
|
|
991
|
+
await new Promise<void>((resolve, reject) => {
|
|
992
|
+
const proc = spawn(valkeyCli, args, {
|
|
993
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
let rejected = false
|
|
997
|
+
|
|
998
|
+
proc.on('error', (err) => {
|
|
999
|
+
rejected = true
|
|
1000
|
+
reject(err)
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
proc.on('close', (code) => {
|
|
1004
|
+
if (rejected) return
|
|
1005
|
+
if (code === 0 || code === null) {
|
|
1006
|
+
resolve()
|
|
1007
|
+
} else {
|
|
1008
|
+
reject(new Error(`valkey-cli exited with code ${code}`))
|
|
1009
|
+
}
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
// Write command to stdin and close it
|
|
1013
|
+
proc.stdin?.write(options.sql + '\n')
|
|
1014
|
+
proc.stdin?.end()
|
|
1015
|
+
})
|
|
1016
|
+
} else {
|
|
1017
|
+
throw new Error('Either file or sql option must be provided')
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export const valkeyEngine = new ValkeyEngine()
|