spindb 0.31.4 → 0.33.1

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 (64) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +107 -826
  3. package/cli/commands/create.ts +5 -1
  4. package/cli/commands/engines.ts +256 -1
  5. package/cli/commands/menu/backup-handlers.ts +16 -0
  6. package/cli/commands/menu/container-handlers.ts +170 -17
  7. package/cli/commands/menu/engine-handlers.ts +6 -0
  8. package/cli/commands/menu/settings-handlers.ts +6 -0
  9. package/cli/commands/menu/shell-handlers.ts +74 -14
  10. package/cli/commands/menu/sql-handlers.ts +8 -50
  11. package/cli/commands/menu/validators.ts +8 -0
  12. package/cli/commands/users.ts +264 -0
  13. package/cli/constants.ts +8 -0
  14. package/cli/helpers.ts +140 -0
  15. package/cli/index.ts +2 -0
  16. package/cli/ui/prompts.ts +24 -20
  17. package/config/backup-formats.ts +28 -0
  18. package/config/engine-defaults.ts +26 -0
  19. package/config/engines-registry.ts +1 -0
  20. package/config/engines.json +50 -0
  21. package/config/engines.schema.json +6 -1
  22. package/core/base-binary-manager.ts +6 -1
  23. package/core/config-manager.ts +20 -0
  24. package/core/credential-manager.ts +257 -0
  25. package/core/dependency-manager.ts +5 -0
  26. package/core/docker-exporter.ts +30 -0
  27. package/core/error-handler.ts +19 -0
  28. package/engines/base-engine.ts +32 -1
  29. package/engines/clickhouse/index.ts +99 -3
  30. package/engines/cockroachdb/index.ts +69 -2
  31. package/engines/couchdb/index.ts +149 -1
  32. package/engines/ferretdb/README.md +4 -0
  33. package/engines/ferretdb/index.ts +342 -13
  34. package/engines/index.ts +8 -0
  35. package/engines/influxdb/README.md +180 -0
  36. package/engines/influxdb/api-client.ts +64 -0
  37. package/engines/influxdb/backup.ts +160 -0
  38. package/engines/influxdb/binary-manager.ts +110 -0
  39. package/engines/influxdb/binary-urls.ts +69 -0
  40. package/engines/influxdb/hostdb-releases.ts +23 -0
  41. package/engines/influxdb/index.ts +1227 -0
  42. package/engines/influxdb/restore.ts +417 -0
  43. package/engines/influxdb/version-maps.ts +75 -0
  44. package/engines/influxdb/version-validator.ts +128 -0
  45. package/engines/mariadb/index.ts +96 -1
  46. package/engines/meilisearch/index.ts +97 -1
  47. package/engines/mongodb/index.ts +82 -0
  48. package/engines/mysql/index.ts +105 -1
  49. package/engines/postgresql/index.ts +92 -0
  50. package/engines/qdrant/index.ts +107 -2
  51. package/engines/redis/index.ts +106 -12
  52. package/engines/surrealdb/index.ts +102 -2
  53. package/engines/typedb/backup.ts +167 -0
  54. package/engines/typedb/binary-manager.ts +200 -0
  55. package/engines/typedb/binary-urls.ts +38 -0
  56. package/engines/typedb/cli-utils.ts +210 -0
  57. package/engines/typedb/hostdb-releases.ts +118 -0
  58. package/engines/typedb/index.ts +1275 -0
  59. package/engines/typedb/restore.ts +377 -0
  60. package/engines/typedb/version-maps.ts +48 -0
  61. package/engines/typedb/version-validator.ts +127 -0
  62. package/engines/valkey/index.ts +70 -2
  63. package/package.json +4 -1
  64. package/types/index.ts +37 -0
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Credential Manager
3
+ *
4
+ * Manages saved database credentials on disk.
5
+ * Credentials are stored as .env files in the container's credentials/ directory.
6
+ */
7
+
8
+ import { existsSync } from 'fs'
9
+ import { readFile, writeFile, readdir, mkdir, chmod } from 'fs/promises'
10
+ import { join } from 'path'
11
+ import { paths } from '../config/paths'
12
+ import { Engine, type UserCredentials } from '../types'
13
+ import { isValidUsername } from './error-handler'
14
+
15
+ /**
16
+ * Get the credentials directory for a container.
17
+ */
18
+ function getCredentialsDir(containerName: string, engine: Engine): string {
19
+ const containerPath = paths.getContainerPath(containerName, { engine })
20
+ return join(containerPath, 'credentials')
21
+ }
22
+
23
+ /**
24
+ * Get the credential file path for a specific username.
25
+ * Validates the username to prevent path traversal (same rules as assertValidUsername).
26
+ */
27
+ function getCredentialFilePath(
28
+ containerName: string,
29
+ engine: Engine,
30
+ username: string,
31
+ ): string {
32
+ if (!isValidUsername(username)) {
33
+ throw new Error(
34
+ `Invalid username for credential file: "${username}". Must match ^[a-zA-Z][a-zA-Z0-9_]{0,62}$`,
35
+ )
36
+ }
37
+ return join(getCredentialsDir(containerName, engine), `.env.${username}`)
38
+ }
39
+
40
+ /**
41
+ * Format credentials as .env file content.
42
+ */
43
+ function encodeEnvValue(value: string): string {
44
+ if (/[\n\r=\\]/.test(value)) {
45
+ return JSON.stringify(value)
46
+ }
47
+ return value
48
+ }
49
+
50
+ function decodeEnvValue(raw: string): string {
51
+ if (raw.startsWith('"') && raw.endsWith('"')) {
52
+ try {
53
+ return JSON.parse(raw) as string
54
+ } catch {
55
+ return raw
56
+ }
57
+ }
58
+ return raw
59
+ }
60
+
61
+ function formatCredentials(credentials: UserCredentials): string {
62
+ const lines: string[] = []
63
+
64
+ if (credentials.apiKey) {
65
+ lines.push(`API_KEY_NAME=${encodeEnvValue(credentials.username)}`)
66
+ lines.push(`API_KEY=${encodeEnvValue(credentials.apiKey)}`)
67
+ lines.push(`API_URL=${encodeEnvValue(credentials.connectionString)}`)
68
+ } else {
69
+ lines.push(`DB_USER=${encodeEnvValue(credentials.username)}`)
70
+ lines.push(`DB_PASSWORD=${encodeEnvValue(credentials.password)}`)
71
+ // Extract host and port from the connection string.
72
+ // Use URL parsing when possible; fall back to a regex targeting host:port.
73
+ let extractedHost: string | undefined
74
+ let extractedPort: string | undefined
75
+ try {
76
+ const url = new URL(credentials.connectionString)
77
+ if (url.hostname) {
78
+ extractedHost = url.hostname
79
+ }
80
+ if (url.port) {
81
+ extractedPort = url.port
82
+ }
83
+ } catch {
84
+ // Not a valid URL (e.g. custom scheme). Use regex targeting host:port segment.
85
+ const hostPortMatch = credentials.connectionString.match(
86
+ /@(\[[^\]]+\]|[^:/?#]+):(\d+)(?:\/|$)/,
87
+ )
88
+ if (hostPortMatch) {
89
+ extractedHost = hostPortMatch[1].replace(/^\[|\]$/g, '')
90
+ extractedPort = hostPortMatch[2]
91
+ }
92
+ }
93
+ lines.push(`DB_HOST=${extractedHost || '127.0.0.1'}`)
94
+ if (extractedPort) {
95
+ lines.push(`DB_PORT=${extractedPort}`)
96
+ }
97
+ if (credentials.database) {
98
+ lines.push(`DB_NAME=${encodeEnvValue(credentials.database)}`)
99
+ }
100
+ lines.push(`DB_URL=${encodeEnvValue(credentials.connectionString)}`)
101
+ }
102
+
103
+ return lines.join('\n') + '\n'
104
+ }
105
+
106
+ /**
107
+ * Parse a .env credential file back into UserCredentials.
108
+ */
109
+ function parseCredentialFile(
110
+ content: string,
111
+ containerName: string,
112
+ engine: Engine,
113
+ ): UserCredentials {
114
+ const vars: Record<string, string> = {}
115
+ for (const line of content.split('\n')) {
116
+ const trimmed = line.trim()
117
+ if (!trimmed || trimmed.startsWith('#')) continue
118
+ const eqIdx = trimmed.indexOf('=')
119
+ if (eqIdx === -1) continue
120
+ const key = trimmed.slice(0, eqIdx).trim()
121
+ const rawValue = trimmed.slice(eqIdx + 1).trim()
122
+ vars[key] = decodeEnvValue(rawValue)
123
+ }
124
+
125
+ // API key credentials: password is intentionally empty (auth uses API key, not password)
126
+ if (vars.API_KEY) {
127
+ if (!vars.API_KEY_NAME || !vars.API_URL) {
128
+ throw new Error(
129
+ `Corrupt credential file for container "${containerName}": missing API_KEY_NAME or API_URL`,
130
+ )
131
+ }
132
+ return {
133
+ username: vars.API_KEY_NAME,
134
+ password: '', // API-based auth does not use a password
135
+ connectionString: vars.API_URL,
136
+ engine,
137
+ container: containerName,
138
+ apiKey: vars.API_KEY,
139
+ }
140
+ }
141
+
142
+ // Empty string DB_PASSWORD is intentionally allowed (some DBs permit passwordless connections)
143
+ if (!vars.DB_USER || vars.DB_PASSWORD === undefined || !vars.DB_URL) {
144
+ throw new Error(
145
+ `Corrupt credential file for container "${containerName}": missing DB_USER, DB_PASSWORD, or DB_URL`,
146
+ )
147
+ }
148
+
149
+ return {
150
+ username: vars.DB_USER,
151
+ password: vars.DB_PASSWORD,
152
+ connectionString: vars.DB_URL,
153
+ engine,
154
+ container: containerName,
155
+ database: vars.DB_NAME,
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Save credentials to disk as a .env file.
161
+ * Creates the credentials/ directory if it doesn't exist.
162
+ * @returns The path to the saved credential file.
163
+ */
164
+ export async function saveCredentials(
165
+ containerName: string,
166
+ engine: Engine,
167
+ credentials: UserCredentials,
168
+ ): Promise<string> {
169
+ const credDir = getCredentialsDir(containerName, engine)
170
+ if (!existsSync(credDir)) {
171
+ await mkdir(credDir, { recursive: true, mode: 0o700 })
172
+ }
173
+
174
+ const filePath = getCredentialFilePath(
175
+ containerName,
176
+ engine,
177
+ credentials.username,
178
+ )
179
+ await writeFile(filePath, formatCredentials(credentials), {
180
+ encoding: 'utf-8',
181
+ mode: 0o600,
182
+ })
183
+
184
+ // POSIX file permissions are no-ops on Windows
185
+ if (process.platform !== 'win32') {
186
+ await chmod(credDir, 0o700)
187
+ await chmod(filePath, 0o600)
188
+ }
189
+ return filePath
190
+ }
191
+
192
+ /**
193
+ * Load credentials for a specific username from disk.
194
+ * Returns null if the credential file doesn't exist.
195
+ */
196
+ export async function loadCredentials(
197
+ containerName: string,
198
+ engine: Engine,
199
+ username: string,
200
+ ): Promise<UserCredentials | null> {
201
+ const filePath = getCredentialFilePath(containerName, engine, username)
202
+ try {
203
+ const content = await readFile(filePath, 'utf-8')
204
+ return parseCredentialFile(content, containerName, engine)
205
+ } catch (error) {
206
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
207
+ return null
208
+ }
209
+ throw error
210
+ }
211
+ }
212
+
213
+ /**
214
+ * List all saved credential usernames for a container.
215
+ * Returns an empty array if no credentials directory exists.
216
+ */
217
+ export async function listCredentials(
218
+ containerName: string,
219
+ engine: Engine,
220
+ ): Promise<string[]> {
221
+ const credDir = getCredentialsDir(containerName, engine)
222
+ if (!existsSync(credDir)) {
223
+ return []
224
+ }
225
+
226
+ const files = await readdir(credDir)
227
+ return files
228
+ .filter((f) => f.startsWith('.env.'))
229
+ .map((f) => f.slice(5)) // Remove '.env.' prefix
230
+ .sort()
231
+ }
232
+
233
+ /**
234
+ * Check if credentials exist for a specific username.
235
+ */
236
+ export function credentialsExist(
237
+ containerName: string,
238
+ engine: Engine,
239
+ username: string,
240
+ ): boolean {
241
+ return existsSync(getCredentialFilePath(containerName, engine, username))
242
+ }
243
+
244
+ /**
245
+ * Get the default username for a given engine.
246
+ * API key engines use 'search_key' or 'api_key', all others use 'spindb'.
247
+ */
248
+ export function getDefaultUsername(engine: Engine): string {
249
+ switch (engine) {
250
+ case Engine.Meilisearch:
251
+ return 'search_key'
252
+ case Engine.Qdrant:
253
+ return 'api_key'
254
+ default:
255
+ return 'spindb'
256
+ }
257
+ }
@@ -81,6 +81,11 @@ const KNOWN_BINARY_TOOLS: readonly BinaryTool[] = [
81
81
  'surreal',
82
82
  // QuestDB
83
83
  'questdb',
84
+ // TypeDB
85
+ 'typedb',
86
+ 'typedb_console_bin',
87
+ // InfluxDB
88
+ 'influxdb3',
84
89
  // Enhanced shells (optional)
85
90
  'pgcli',
86
91
  'mycli',
@@ -70,6 +70,8 @@ function getEngineDisplayName(engine: Engine): string {
70
70
  [Engine.CockroachDB]: 'CockroachDB',
71
71
  [Engine.SurrealDB]: 'SurrealDB',
72
72
  [Engine.QuestDB]: 'QuestDB',
73
+ [Engine.TypeDB]: 'TypeDB',
74
+ [Engine.InfluxDB]: 'InfluxDB',
73
75
  }
74
76
  return displayNames[engine] || engine
75
77
  }
@@ -137,6 +139,12 @@ const _ENGINE_BINARY_CONFIG: Record<
137
139
  [Engine.QuestDB]: {
138
140
  primaryBinaries: [], // Uses psql from PostgreSQL for connections
139
141
  },
142
+ [Engine.TypeDB]: {
143
+ primaryBinaries: ['typedb', 'typedb_console_bin'],
144
+ },
145
+ [Engine.InfluxDB]: {
146
+ primaryBinaries: [], // REST API only, no CLI tools
147
+ },
140
148
  }
141
149
 
142
150
  /**
@@ -189,6 +197,7 @@ function getConnectionStringTemplate(
189
197
  return useTLS ? `https://<host>:${port}` : `http://<host>:${port}`
190
198
 
191
199
  case Engine.Meilisearch:
200
+ case Engine.InfluxDB:
192
201
  return useTLS ? `https://<host>:${port}` : `http://<host>:${port}`
193
202
 
194
203
  case Engine.CouchDB:
@@ -201,6 +210,9 @@ function getConnectionStringTemplate(
201
210
  ? `wss://\${SPINDB_USER}:\${SPINDB_PASSWORD}@<host>:${port}`
202
211
  : `ws://\${SPINDB_USER}:\${SPINDB_PASSWORD}@<host>:${port}`
203
212
 
213
+ case Engine.TypeDB:
214
+ return `typedb://<host>:${port}`
215
+
204
216
  case Engine.SQLite:
205
217
  case Engine.DuckDB:
206
218
  return `File-based database (no network connection)`
@@ -485,6 +497,20 @@ echo "User configured via server settings"
485
497
  userCreationCommands = `
486
498
  # API key is configured at server start
487
499
  echo "API key configured via server settings"
500
+ `
501
+ break
502
+
503
+ case Engine.InfluxDB:
504
+ userCreationCommands = `
505
+ # InfluxDB 3.x local dev runs without authentication
506
+ echo "No authentication required for local InfluxDB 3.x"
507
+ `
508
+ break
509
+
510
+ case Engine.TypeDB:
511
+ userCreationCommands = `
512
+ # TypeDB community edition does not support user management
513
+ echo "No authentication required"
488
514
  `
489
515
  break
490
516
 
@@ -1270,6 +1296,7 @@ export async function getDockerConnectionString(
1270
1296
  return `http://${host}:${port}`
1271
1297
 
1272
1298
  case Engine.Meilisearch:
1299
+ case Engine.InfluxDB:
1273
1300
  return `http://${host}:${port}`
1274
1301
 
1275
1302
  case Engine.CouchDB:
@@ -1278,6 +1305,9 @@ export async function getDockerConnectionString(
1278
1305
  case Engine.SurrealDB:
1279
1306
  return `ws://${username}:${password}@${host}:${port}`
1280
1307
 
1308
+ case Engine.TypeDB:
1309
+ return `typedb://${host}:${port}`
1310
+
1281
1311
  case Engine.SQLite:
1282
1312
  case Engine.DuckDB:
1283
1313
  return `File-based database (no network connection)`
@@ -57,6 +57,8 @@ export const ErrorCodes = {
57
57
  INIT_FAILED: 'INIT_FAILED',
58
58
  DATABASE_CREATE_FAILED: 'DATABASE_CREATE_FAILED',
59
59
  INVALID_DATABASE_NAME: 'INVALID_DATABASE_NAME',
60
+ INVALID_USERNAME: 'INVALID_USERNAME',
61
+ USER_ALREADY_EXISTS: 'USER_ALREADY_EXISTS',
60
62
 
61
63
  // Dependency errors
62
64
  DEPENDENCY_MISSING: 'DEPENDENCY_MISSING',
@@ -333,6 +335,23 @@ export function assertValidDatabaseName(name: string): void {
333
335
  }
334
336
  }
335
337
 
338
+ // Validates a username to prevent SQL injection.
339
+ export function isValidUsername(name: string): boolean {
340
+ return /^[a-zA-Z][a-zA-Z0-9_]{0,62}$/.test(name)
341
+ }
342
+
343
+ export function assertValidUsername(name: string): void {
344
+ if (!isValidUsername(name)) {
345
+ throw new SpinDBError(
346
+ ErrorCodes.INVALID_USERNAME,
347
+ `Invalid username: "${name}"`,
348
+ 'error',
349
+ 'Usernames must start with a letter, contain only letters, numbers, and underscores, and be at most 63 characters',
350
+ { username: name },
351
+ )
352
+ }
353
+ }
354
+
336
355
  /**
337
356
  * Check if the current process is running in an interactive terminal.
338
357
  * Returns true if stdin is a TTY (user can interact with prompts).
@@ -9,6 +9,8 @@ import type {
9
9
  StatusResult,
10
10
  QueryResult,
11
11
  QueryOptions,
12
+ CreateUserOptions,
13
+ UserCredentials,
12
14
  } from '../types'
13
15
  import { UnsupportedOperationError } from '../core/error-handler'
14
16
 
@@ -150,6 +152,14 @@ export abstract class BaseEngine {
150
152
  throw new Error('surreal not found')
151
153
  }
152
154
 
155
+ /**
156
+ * Get the path to the typedb console binary if available
157
+ * Default implementation throws; TypeDB engine overrides this method.
158
+ */
159
+ async getTypeDBConsolePath(_version?: string): Promise<string> {
160
+ throw new Error('typedb_console_bin not found')
161
+ }
162
+
153
163
  /**
154
164
  * Get the path to the sqlite3 client if available
155
165
  * Default implementation returns null; SQLite engine overrides this method.
@@ -235,7 +245,12 @@ export abstract class BaseEngine {
235
245
  */
236
246
  abstract runScript(
237
247
  container: ContainerConfig,
238
- options: { file?: string; sql?: string; database?: string },
248
+ options: {
249
+ file?: string
250
+ sql?: string
251
+ database?: string
252
+ transactionType?: 'read' | 'write' | 'schema'
253
+ },
239
254
  ): Promise<void>
240
255
 
241
256
  /**
@@ -281,4 +296,20 @@ export abstract class BaseEngine {
281
296
  async listDatabases(_container: ContainerConfig): Promise<string[]> {
282
297
  throw new UnsupportedOperationError('listDatabases', this.displayName)
283
298
  }
299
+
300
+ /**
301
+ * Create a database user with the given credentials.
302
+ * Returns credentials including connection string.
303
+ *
304
+ * @param container - The container configuration
305
+ * @param options - Username, password, and optional target database
306
+ * @returns UserCredentials with connection info
307
+ * @throws UnsupportedOperationError for engines that don't support users (SQLite, DuckDB, QuestDB)
308
+ */
309
+ async createUser(
310
+ _container: ContainerConfig,
311
+ _options: CreateUserOptions,
312
+ ): Promise<UserCredentials> {
313
+ throw new UnsupportedOperationError('createUser', this.displayName)
314
+ }
284
315
  }
@@ -1,13 +1,17 @@
1
1
  import { spawn, type SpawnOptions } from 'child_process'
2
2
  import { existsSync } from 'fs'
3
- import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
3
+ import { mkdir, writeFile, readFile, unlink, chmod } from 'fs/promises'
4
4
  import { join } from 'path'
5
5
  import { BaseEngine } from '../base-engine'
6
6
  import { paths } from '../../config/paths'
7
7
  import { getEngineDefaults } from '../../config/defaults'
8
8
  import { platformService } from '../../core/platform-service'
9
9
  import { configManager } from '../../core/config-manager'
10
- import { logDebug, logWarning } from '../../core/error-handler'
10
+ import {
11
+ logDebug,
12
+ logWarning,
13
+ assertValidUsername,
14
+ } from '../../core/error-handler'
11
15
  import { processManager } from '../../core/process-manager'
12
16
  import { clickhouseBinaryManager } from './binary-manager'
13
17
  import { getBinaryUrl } from './binary-urls'
@@ -39,6 +43,8 @@ import {
39
43
  type StatusResult,
40
44
  type QueryResult,
41
45
  type QueryOptions,
46
+ type CreateUserOptions,
47
+ type UserCredentials,
42
48
  } from '../../types'
43
49
  import { parseClickHouseJSONResult } from '../../core/query-parser'
44
50
 
@@ -86,6 +92,15 @@ function generateClickHouseConfig(options: {
86
92
 
87
93
  <mark_cache_size>5368709120</mark_cache_size>
88
94
  <max_concurrent_queries>100</max_concurrent_queries>
95
+
96
+ <user_directories>
97
+ <users_xml>
98
+ <path>users.xml</path>
99
+ </users_xml>
100
+ <local_directory>
101
+ <path>${dataDir}/access/</path>
102
+ </local_directory>
103
+ </user_directories>
89
104
  </clickhouse>
90
105
  `
91
106
  }
@@ -297,6 +312,11 @@ export class ClickHouseEngine extends BaseEngine {
297
312
  await mkdir(dataDir, { recursive: true })
298
313
  await mkdir(tmpDir, { recursive: true })
299
314
  await mkdir(join(dataDir, 'user_files'), { recursive: true })
315
+ const accessDir = join(dataDir, 'access')
316
+ await mkdir(accessDir, { recursive: true, mode: 0o700 })
317
+ await chmod(accessDir, 0o700).catch((err) => {
318
+ logDebug(`Failed to chmod ${accessDir}: ${err}`)
319
+ })
300
320
 
301
321
  logDebug(`Created ClickHouse data directory: ${dataDir}`)
302
322
 
@@ -338,6 +358,18 @@ export class ClickHouseEngine extends BaseEngine {
338
358
  const tmpDir = join(dataDir, 'tmp')
339
359
  const httpPort = port + 1
340
360
 
361
+ const accessDir = join(dataDir, 'access')
362
+ try {
363
+ await mkdir(accessDir, { recursive: true, mode: 0o700 })
364
+ await chmod(accessDir, 0o700).catch((err) => {
365
+ logDebug(`Failed to chmod ${accessDir}: ${err}`)
366
+ })
367
+ } catch (error) {
368
+ logWarning(
369
+ `Failed to create ClickHouse access directory ${accessDir}: ${error}`,
370
+ )
371
+ }
372
+
341
373
  const configPath = join(containerDir, 'config.xml')
342
374
  const pidFile = join(containerDir, engineDef.pidFileName)
343
375
  const configContent = generateClickHouseConfig({
@@ -528,7 +560,7 @@ export class ClickHouseEngine extends BaseEngine {
528
560
  private async waitForReady(
529
561
  port: number,
530
562
  version: string,
531
- timeoutMs = 90000,
563
+ timeoutMs = 120000,
532
564
  ): Promise<boolean> {
533
565
  logDebug(`waitForReady called for port ${port}, version ${version}`)
534
566
  const startTime = Date.now()
@@ -1244,6 +1276,70 @@ export class ClickHouseEngine extends BaseEngine {
1244
1276
  })
1245
1277
  })
1246
1278
  }
1279
+
1280
+ async createUser(
1281
+ container: ContainerConfig,
1282
+ options: CreateUserOptions,
1283
+ ): Promise<UserCredentials> {
1284
+ const { username, password, database } = options
1285
+ assertValidUsername(username)
1286
+ const { port, version } = container
1287
+ const db = database || container.database || 'default'
1288
+
1289
+ validateClickHouseIdentifier(username, 'username')
1290
+ validateClickHouseIdentifier(db, 'database')
1291
+ const escapedUser = escapeClickHouseIdentifier(username)
1292
+ const escapedDb = escapeClickHouseIdentifier(db)
1293
+
1294
+ const clickhouse = await this.getClickHouseClientPath(version)
1295
+
1296
+ const escapedPass = password.replace(/\\/g, '\\\\').replace(/'/g, "''")
1297
+ const sql = `CREATE USER IF NOT EXISTS ${escapedUser} IDENTIFIED BY '${escapedPass}'; ALTER USER ${escapedUser} IDENTIFIED BY '${escapedPass}'; GRANT ALL ON ${escapedDb}.* TO ${escapedUser};`
1298
+
1299
+ const args = [
1300
+ 'client',
1301
+ '--host',
1302
+ '127.0.0.1',
1303
+ '--port',
1304
+ String(port),
1305
+ '--multiquery',
1306
+ ]
1307
+
1308
+ await new Promise<void>((resolve, reject) => {
1309
+ const proc = spawn(clickhouse, args, {
1310
+ stdio: ['pipe', 'pipe', 'pipe'],
1311
+ })
1312
+
1313
+ let stderr = ''
1314
+ proc.stderr?.on('data', (data: Buffer) => {
1315
+ stderr += data.toString()
1316
+ })
1317
+
1318
+ proc.on('close', (code) => {
1319
+ if (code === 0) {
1320
+ logDebug(`Created ClickHouse user: ${username}`)
1321
+ resolve()
1322
+ } else {
1323
+ reject(new Error(`Failed to create user: ${stderr}`))
1324
+ }
1325
+ })
1326
+ proc.on('error', reject)
1327
+
1328
+ proc.stdin?.write(sql)
1329
+ proc.stdin?.end()
1330
+ })
1331
+
1332
+ const connectionString = `clickhouse://${encodeURIComponent(username)}:${encodeURIComponent(password)}@127.0.0.1:${port}/${db}`
1333
+
1334
+ return {
1335
+ username,
1336
+ password,
1337
+ connectionString,
1338
+ engine: container.engine,
1339
+ container: container.name,
1340
+ database: db,
1341
+ }
1342
+ }
1247
1343
  }
1248
1344
 
1249
1345
  export const clickhouseEngine = new ClickHouseEngine()
@@ -22,7 +22,11 @@ import { paths } from '../../config/paths'
22
22
  import { getEngineDefaults } from '../../config/defaults'
23
23
  import { platformService } from '../../core/platform-service'
24
24
  import { configManager } from '../../core/config-manager'
25
- import { logDebug, logWarning } from '../../core/error-handler'
25
+ import {
26
+ logDebug,
27
+ logWarning,
28
+ assertValidUsername,
29
+ } from '../../core/error-handler'
26
30
  import { findBinary } from '../../core/dependency-manager'
27
31
  import { processManager } from '../../core/process-manager'
28
32
  import { cockroachdbBinaryManager } from './binary-manager'
@@ -59,6 +63,8 @@ import {
59
63
  type StatusResult,
60
64
  type QueryResult,
61
65
  type QueryOptions,
66
+ type CreateUserOptions,
67
+ type UserCredentials,
62
68
  } from '../../types'
63
69
  import { parseCSVToQueryResult } from '../../core/query-parser'
64
70
 
@@ -347,7 +353,7 @@ export class CockroachDBEngine extends BaseEngine {
347
353
 
348
354
  // Wait for server to be ready
349
355
  // Windows needs a longer timeout since CockroachDB initialization takes more time
350
- const timeout = isWindows ? 90000 : 60000
356
+ const timeout = isWindows ? 120000 : 60000
351
357
  logDebug(
352
358
  `Waiting for CockroachDB server to be ready on port ${port}... (timeout: ${timeout}ms)`,
353
359
  )
@@ -1201,6 +1207,67 @@ export class CockroachDBEngine extends BaseEngine {
1201
1207
  })
1202
1208
  })
1203
1209
  }
1210
+
1211
+ async createUser(
1212
+ container: ContainerConfig,
1213
+ options: CreateUserOptions,
1214
+ ): Promise<UserCredentials> {
1215
+ const { username, password, database } = options
1216
+ assertValidUsername(username)
1217
+ const { port, version } = container
1218
+ const db = database || container.database || 'defaultdb'
1219
+
1220
+ validateCockroachIdentifier(username, 'user')
1221
+ validateCockroachIdentifier(db, 'database')
1222
+ const escapedUser = escapeCockroachIdentifier(username)
1223
+ const escapedDb = escapeCockroachIdentifier(db)
1224
+
1225
+ const cockroach = await this.getCockroachPath(version)
1226
+
1227
+ // CockroachDB in insecure mode doesn't support passwords.
1228
+ // Create user without password and grant privileges.
1229
+ // SQL is sent via stdin to avoid shell escaping issues with --execute.
1230
+ const sql = `CREATE USER IF NOT EXISTS ${escapedUser}; GRANT ALL ON DATABASE ${escapedDb} TO ${escapedUser};`
1231
+
1232
+ const args = ['sql', '--insecure', '--host', `127.0.0.1:${port}`]
1233
+
1234
+ await new Promise<void>((resolve, reject) => {
1235
+ const proc = spawn(cockroach, args, {
1236
+ stdio: ['pipe', 'pipe', 'pipe'],
1237
+ })
1238
+
1239
+ let stderr = ''
1240
+ proc.stderr?.on('data', (data: Buffer) => {
1241
+ stderr += data.toString()
1242
+ })
1243
+
1244
+ proc.on('close', (code) => {
1245
+ if (code === 0) {
1246
+ resolve()
1247
+ } else {
1248
+ reject(new Error(`Failed to create user: ${stderr}`))
1249
+ }
1250
+ })
1251
+ proc.on('error', reject)
1252
+
1253
+ proc.stdin?.write(sql)
1254
+ proc.stdin?.end()
1255
+ })
1256
+
1257
+ // In insecure mode, connections don't use passwords
1258
+ const connectionString = `postgresql://${encodeURIComponent(username)}@127.0.0.1:${port}/${db}?sslmode=disable`
1259
+
1260
+ return {
1261
+ username,
1262
+ // CockroachDB insecure mode does not enforce password authentication,
1263
+ // but we return the caller-provided password for credential file consistency.
1264
+ password,
1265
+ connectionString,
1266
+ engine: container.engine,
1267
+ container: container.name,
1268
+ database: db,
1269
+ }
1270
+ }
1204
1271
  }
1205
1272
 
1206
1273
  export const cockroachdbEngine = new CockroachDBEngine()