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.
- package/LICENSE +8 -0
- package/README.md +107 -826
- package/cli/commands/create.ts +5 -1
- package/cli/commands/engines.ts +256 -1
- package/cli/commands/menu/backup-handlers.ts +16 -0
- package/cli/commands/menu/container-handlers.ts +170 -17
- package/cli/commands/menu/engine-handlers.ts +6 -0
- package/cli/commands/menu/settings-handlers.ts +6 -0
- package/cli/commands/menu/shell-handlers.ts +74 -14
- package/cli/commands/menu/sql-handlers.ts +8 -50
- package/cli/commands/menu/validators.ts +8 -0
- package/cli/commands/users.ts +264 -0
- package/cli/constants.ts +8 -0
- package/cli/helpers.ts +140 -0
- package/cli/index.ts +2 -0
- package/cli/ui/prompts.ts +24 -20
- package/config/backup-formats.ts +28 -0
- package/config/engine-defaults.ts +26 -0
- package/config/engines-registry.ts +1 -0
- package/config/engines.json +50 -0
- package/config/engines.schema.json +6 -1
- package/core/base-binary-manager.ts +6 -1
- package/core/config-manager.ts +20 -0
- package/core/credential-manager.ts +257 -0
- package/core/dependency-manager.ts +5 -0
- package/core/docker-exporter.ts +30 -0
- package/core/error-handler.ts +19 -0
- package/engines/base-engine.ts +32 -1
- package/engines/clickhouse/index.ts +99 -3
- package/engines/cockroachdb/index.ts +69 -2
- package/engines/couchdb/index.ts +149 -1
- package/engines/ferretdb/README.md +4 -0
- package/engines/ferretdb/index.ts +342 -13
- package/engines/index.ts +8 -0
- package/engines/influxdb/README.md +180 -0
- package/engines/influxdb/api-client.ts +64 -0
- package/engines/influxdb/backup.ts +160 -0
- package/engines/influxdb/binary-manager.ts +110 -0
- package/engines/influxdb/binary-urls.ts +69 -0
- package/engines/influxdb/hostdb-releases.ts +23 -0
- package/engines/influxdb/index.ts +1227 -0
- package/engines/influxdb/restore.ts +417 -0
- package/engines/influxdb/version-maps.ts +75 -0
- package/engines/influxdb/version-validator.ts +128 -0
- package/engines/mariadb/index.ts +96 -1
- package/engines/meilisearch/index.ts +97 -1
- package/engines/mongodb/index.ts +82 -0
- package/engines/mysql/index.ts +105 -1
- package/engines/postgresql/index.ts +92 -0
- package/engines/qdrant/index.ts +107 -2
- package/engines/redis/index.ts +106 -12
- package/engines/surrealdb/index.ts +102 -2
- package/engines/typedb/backup.ts +167 -0
- package/engines/typedb/binary-manager.ts +200 -0
- package/engines/typedb/binary-urls.ts +38 -0
- package/engines/typedb/cli-utils.ts +210 -0
- package/engines/typedb/hostdb-releases.ts +118 -0
- package/engines/typedb/index.ts +1275 -0
- package/engines/typedb/restore.ts +377 -0
- package/engines/typedb/version-maps.ts +48 -0
- package/engines/typedb/version-validator.ts +127 -0
- package/engines/valkey/index.ts +70 -2
- package/package.json +4 -1
- package/types/index.ts +37 -0
package/engines/qdrant/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type SpawnOptions } from 'child_process'
|
|
2
2
|
import { createWriteStream, existsSync } from 'fs'
|
|
3
|
-
import { mkdir, writeFile, readFile, unlink } from 'fs/promises'
|
|
3
|
+
import { chmod, mkdir, writeFile, readFile, unlink } from 'fs/promises'
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import { Readable } from 'stream'
|
|
6
6
|
import { pipeline } from 'stream/promises'
|
|
@@ -9,7 +9,11 @@ import { paths } from '../../config/paths'
|
|
|
9
9
|
import { getEngineDefaults } from '../../config/defaults'
|
|
10
10
|
import { platformService, isWindows } from '../../core/platform-service'
|
|
11
11
|
import { configManager } from '../../core/config-manager'
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
logDebug,
|
|
14
|
+
logWarning,
|
|
15
|
+
assertValidUsername,
|
|
16
|
+
} from '../../core/error-handler'
|
|
13
17
|
import { processManager } from '../../core/process-manager'
|
|
14
18
|
import { portManager } from '../../core/port-manager'
|
|
15
19
|
import { qdrantBinaryManager } from './binary-manager'
|
|
@@ -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 { parseRESTAPIResult } from '../../core/query-parser'
|
|
44
50
|
|
|
@@ -1202,6 +1208,105 @@ export class QdrantEngine extends BaseEngine {
|
|
|
1202
1208
|
// Return the container's configured database
|
|
1203
1209
|
return [container.database]
|
|
1204
1210
|
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Create/update the global API key for Qdrant.
|
|
1214
|
+
*
|
|
1215
|
+
* Qdrant supports only a single global API key (set in config.yaml).
|
|
1216
|
+
* Calling createUser multiple times will overwrite the previous key.
|
|
1217
|
+
* The caller-provided username is stored in the credential file for
|
|
1218
|
+
* bookkeeping but has no effect on Qdrant itself — authentication
|
|
1219
|
+
* is solely via the api-key header.
|
|
1220
|
+
*/
|
|
1221
|
+
async createUser(
|
|
1222
|
+
container: ContainerConfig,
|
|
1223
|
+
options: CreateUserOptions,
|
|
1224
|
+
): Promise<UserCredentials> {
|
|
1225
|
+
const { username, password } = options
|
|
1226
|
+
assertValidUsername(username)
|
|
1227
|
+
const { port, name } = container
|
|
1228
|
+
|
|
1229
|
+
// Qdrant uses a single global API key in config.yaml.
|
|
1230
|
+
// Read current config, set/replace api_key, write back, and restart.
|
|
1231
|
+
const containerDir = paths.getContainerPath(name, { engine: ENGINE })
|
|
1232
|
+
const configPath = join(containerDir, 'config.yaml')
|
|
1233
|
+
|
|
1234
|
+
const currentConfig = await readFile(configPath, 'utf-8')
|
|
1235
|
+
|
|
1236
|
+
// Parse YAML config line-by-line to find service section and api_key.
|
|
1237
|
+
// Assumes 2-space indentation, no inline comments on api_key lines, and simple scalar values.
|
|
1238
|
+
// This is a lightweight string-edit approach; use a YAML parser if those cases must be supported.
|
|
1239
|
+
const lines = currentConfig.split('\n')
|
|
1240
|
+
const serviceIdx = lines.findIndex((l) => /^service:/.test(l))
|
|
1241
|
+
|
|
1242
|
+
if (serviceIdx < 0) {
|
|
1243
|
+
throw new Error('Could not find service section in Qdrant config')
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// Scan the service section for existing api_key and last property line
|
|
1247
|
+
let apiKeyIdx = -1
|
|
1248
|
+
let lastServicePropIdx = serviceIdx
|
|
1249
|
+
for (let i = serviceIdx + 1; i < lines.length; i++) {
|
|
1250
|
+
if (/^\s+\S/.test(lines[i])) {
|
|
1251
|
+
lastServicePropIdx = i
|
|
1252
|
+
if (/^\s+api_key:/.test(lines[i])) {
|
|
1253
|
+
apiKeyIdx = i
|
|
1254
|
+
}
|
|
1255
|
+
} else if (/^\S/.test(lines[i]) && lines[i].trim() !== '') {
|
|
1256
|
+
break // Next top-level section
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const yamlSafePassword = JSON.stringify(password)
|
|
1261
|
+
if (apiKeyIdx >= 0) {
|
|
1262
|
+
lines[apiKeyIdx] = ` api_key: ${yamlSafePassword}`
|
|
1263
|
+
} else {
|
|
1264
|
+
lines.splice(lastServicePropIdx + 1, 0, ` api_key: ${yamlSafePassword}`)
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const updatedConfig = lines.join('\n')
|
|
1268
|
+
|
|
1269
|
+
// Validate the modified config is structurally sound before writing
|
|
1270
|
+
// Check that service section and api_key line are present
|
|
1271
|
+
const updatedLines = updatedConfig.split('\n')
|
|
1272
|
+
const hasService = updatedLines.some((l) => /^service:/.test(l))
|
|
1273
|
+
const hasApiKey = updatedLines.some((l) => /^\s+api_key:/.test(l))
|
|
1274
|
+
if (!hasService || !hasApiKey) {
|
|
1275
|
+
throw new Error(
|
|
1276
|
+
'Failed to update Qdrant config: modified YAML is structurally invalid. ' +
|
|
1277
|
+
'The service section or api_key entry is missing after modification.',
|
|
1278
|
+
)
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Only restart if the container is currently running
|
|
1282
|
+
const statusResult = await this.status(container)
|
|
1283
|
+
if (statusResult.running) {
|
|
1284
|
+
logWarning(
|
|
1285
|
+
`Restarting Qdrant container "${name}" to apply API key change. ` +
|
|
1286
|
+
'Active client connections will be disconnected.',
|
|
1287
|
+
)
|
|
1288
|
+
await this.stop(container)
|
|
1289
|
+
await writeFile(configPath, updatedConfig)
|
|
1290
|
+
await chmod(configPath, 0o600)
|
|
1291
|
+
await this.start(container)
|
|
1292
|
+
} else {
|
|
1293
|
+
await writeFile(configPath, updatedConfig)
|
|
1294
|
+
await chmod(configPath, 0o600)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
logDebug(`Configured Qdrant global API key (credential label: ${username})`)
|
|
1298
|
+
|
|
1299
|
+
const connectionString = `http://127.0.0.1:${port}`
|
|
1300
|
+
|
|
1301
|
+
return {
|
|
1302
|
+
username,
|
|
1303
|
+
password: '',
|
|
1304
|
+
connectionString,
|
|
1305
|
+
engine: container.engine,
|
|
1306
|
+
container: container.name,
|
|
1307
|
+
apiKey: password,
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1205
1310
|
}
|
|
1206
1311
|
|
|
1207
1312
|
export const qdrantEngine = new QdrantEngine()
|
package/engines/redis/index.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { paths } from '../../config/paths'
|
|
|
8
8
|
import { getEngineDefaults } from '../../config/defaults'
|
|
9
9
|
import { platformService, isWindows } from '../../core/platform-service'
|
|
10
10
|
import { configManager } from '../../core/config-manager'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
logDebug,
|
|
13
|
+
logWarning,
|
|
14
|
+
assertValidUsername,
|
|
15
|
+
} from '../../core/error-handler'
|
|
12
16
|
import { processManager } from '../../core/process-manager'
|
|
13
17
|
import { redisBinaryManager } from './binary-manager'
|
|
14
18
|
import { getBinaryUrl } from './binary-urls'
|
|
@@ -37,6 +41,8 @@ import {
|
|
|
37
41
|
type StatusResult,
|
|
38
42
|
type QueryResult,
|
|
39
43
|
type QueryOptions,
|
|
44
|
+
type CreateUserOptions,
|
|
45
|
+
type UserCredentials,
|
|
40
46
|
} from '../../types'
|
|
41
47
|
import { parseRedisResult } from '../../core/query-parser'
|
|
42
48
|
|
|
@@ -232,6 +238,11 @@ dbfilename dump.rdb
|
|
|
232
238
|
|
|
233
239
|
# Append Only File (disabled for local dev)
|
|
234
240
|
appendonly no
|
|
241
|
+
|
|
242
|
+
# Suppress ARM64 copy-on-write warning with Transparent Huge Pages.
|
|
243
|
+
# Redis refuses to start on ARM64 with THP enabled unless this is set.
|
|
244
|
+
# Safe for local development (SpinDB's use case).
|
|
245
|
+
ignore-warnings ARM64-COW-BUG
|
|
235
246
|
`
|
|
236
247
|
}
|
|
237
248
|
|
|
@@ -658,18 +669,45 @@ export class RedisEngine extends BaseEngine {
|
|
|
658
669
|
reject(new Error(portError))
|
|
659
670
|
return
|
|
660
671
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
672
|
+
|
|
673
|
+
// Include log content in error for CI debugging
|
|
674
|
+
let logContent = ''
|
|
675
|
+
try {
|
|
676
|
+
logContent = await readFile(logFile, 'utf-8')
|
|
677
|
+
} catch {
|
|
678
|
+
logContent = '(log file not found or empty)'
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const errorDetails = [
|
|
682
|
+
'Redis failed to start within timeout.',
|
|
683
|
+
`Binary: ${redisServer}`,
|
|
684
|
+
`Log file: ${logFile}`,
|
|
685
|
+
`Log content:\n${logContent || '(empty)'}`,
|
|
686
|
+
stderr ? `Stderr:\n${stderr}` : '',
|
|
687
|
+
stdout ? `Stdout:\n${stdout}` : '',
|
|
688
|
+
]
|
|
689
|
+
.filter(Boolean)
|
|
690
|
+
.join('\n')
|
|
691
|
+
|
|
692
|
+
reject(new Error(errorDetails))
|
|
666
693
|
}
|
|
667
694
|
} else {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
)
|
|
672
|
-
|
|
695
|
+
// Include log content for non-zero exit codes too
|
|
696
|
+
let logContent = ''
|
|
697
|
+
try {
|
|
698
|
+
logContent = await readFile(logFile, 'utf-8')
|
|
699
|
+
} catch {
|
|
700
|
+
logContent = ''
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const errorDetails = [
|
|
704
|
+
stderr || stdout || `redis-server exited with code ${code}`,
|
|
705
|
+
logContent ? `Log content:\n${logContent}` : '',
|
|
706
|
+
]
|
|
707
|
+
.filter(Boolean)
|
|
708
|
+
.join('\n')
|
|
709
|
+
|
|
710
|
+
reject(new Error(errorDetails))
|
|
673
711
|
}
|
|
674
712
|
})
|
|
675
713
|
})
|
|
@@ -679,7 +717,7 @@ export class RedisEngine extends BaseEngine {
|
|
|
679
717
|
private async waitForReady(
|
|
680
718
|
port: number,
|
|
681
719
|
version: string,
|
|
682
|
-
timeoutMs =
|
|
720
|
+
timeoutMs = 60000,
|
|
683
721
|
): Promise<boolean> {
|
|
684
722
|
const startTime = Date.now()
|
|
685
723
|
const checkInterval = 500
|
|
@@ -1434,6 +1472,62 @@ export class RedisEngine extends BaseEngine {
|
|
|
1434
1472
|
// Return the container's configured database
|
|
1435
1473
|
return [container.database]
|
|
1436
1474
|
}
|
|
1475
|
+
|
|
1476
|
+
async createUser(
|
|
1477
|
+
container: ContainerConfig,
|
|
1478
|
+
options: CreateUserOptions,
|
|
1479
|
+
): Promise<UserCredentials> {
|
|
1480
|
+
const { username, password } = options
|
|
1481
|
+
assertValidUsername(username)
|
|
1482
|
+
const { port } = container
|
|
1483
|
+
const db = options.database ?? container.database ?? '0'
|
|
1484
|
+
const redisCli = await this.getRedisCliPath(container.version)
|
|
1485
|
+
|
|
1486
|
+
// Reject passwords with characters that break ACL SETUSER syntax:
|
|
1487
|
+
// '>' sets password, '#' sets hash, '<' removes password — all are ACL delimiters.
|
|
1488
|
+
// Whitespace and newlines would split the command unexpectedly.
|
|
1489
|
+
if (/[>#<\s\n\r]/.test(password)) {
|
|
1490
|
+
throw new Error(
|
|
1491
|
+
'Password contains invalid characters for Redis ACL. Passwords must not contain ">", "#", "<", whitespace, or newlines.',
|
|
1492
|
+
)
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// ACL SETUSER is idempotent - sets user with full access
|
|
1496
|
+
// Pass the full ACL command via stdin to avoid exposing the password in argv
|
|
1497
|
+
const connArgs = ['-h', '127.0.0.1', '-p', String(port), '-n', db]
|
|
1498
|
+
|
|
1499
|
+
await new Promise<void>((resolve, reject) => {
|
|
1500
|
+
const proc = spawn(redisCli, connArgs, {
|
|
1501
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
let stderr = ''
|
|
1505
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
1506
|
+
stderr += data.toString()
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
proc.stdin?.write(`ACL SETUSER ${username} on >${password} ~* &* +@all\n`)
|
|
1510
|
+
proc.stdin?.end()
|
|
1511
|
+
|
|
1512
|
+
proc.on('close', (code) => {
|
|
1513
|
+
if (code === 0) resolve()
|
|
1514
|
+
else reject(new Error(`Failed to create user: ${stderr}`))
|
|
1515
|
+
})
|
|
1516
|
+
proc.on('error', reject)
|
|
1517
|
+
})
|
|
1518
|
+
logDebug(`Created Redis user: ${username}`)
|
|
1519
|
+
|
|
1520
|
+
const connectionString = `redis://${encodeURIComponent(username)}:${encodeURIComponent(password)}@127.0.0.1:${port}/${db}`
|
|
1521
|
+
|
|
1522
|
+
return {
|
|
1523
|
+
username,
|
|
1524
|
+
password,
|
|
1525
|
+
connectionString,
|
|
1526
|
+
engine: container.engine,
|
|
1527
|
+
container: container.name,
|
|
1528
|
+
database: db,
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1437
1531
|
}
|
|
1438
1532
|
|
|
1439
1533
|
export const redisEngine = new RedisEngine()
|
|
@@ -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 {
|
|
25
|
+
import {
|
|
26
|
+
logDebug,
|
|
27
|
+
logWarning,
|
|
28
|
+
assertValidUsername,
|
|
29
|
+
} from '../../core/error-handler'
|
|
26
30
|
import { processManager } from '../../core/process-manager'
|
|
27
31
|
import { surrealdbBinaryManager } from './binary-manager'
|
|
28
32
|
import { getBinaryUrl } from './binary-urls'
|
|
@@ -51,6 +55,8 @@ import {
|
|
|
51
55
|
type StatusResult,
|
|
52
56
|
type QueryResult,
|
|
53
57
|
type QueryOptions,
|
|
58
|
+
type CreateUserOptions,
|
|
59
|
+
type UserCredentials,
|
|
54
60
|
} from '../../types'
|
|
55
61
|
import { parseSurrealDBResult } from '../../core/query-parser'
|
|
56
62
|
|
|
@@ -394,7 +400,7 @@ export class SurrealDBEngine extends BaseEngine {
|
|
|
394
400
|
private async waitForReady(
|
|
395
401
|
port: number,
|
|
396
402
|
version: string,
|
|
397
|
-
timeoutMs =
|
|
403
|
+
timeoutMs = 60000,
|
|
398
404
|
): Promise<boolean> {
|
|
399
405
|
logDebug(`waitForReady called for port ${port}, version ${version}`)
|
|
400
406
|
const startTime = Date.now()
|
|
@@ -1141,6 +1147,100 @@ export class SurrealDBEngine extends BaseEngine {
|
|
|
1141
1147
|
})
|
|
1142
1148
|
})
|
|
1143
1149
|
}
|
|
1150
|
+
|
|
1151
|
+
async createUser(
|
|
1152
|
+
container: ContainerConfig,
|
|
1153
|
+
options: CreateUserOptions,
|
|
1154
|
+
): Promise<UserCredentials> {
|
|
1155
|
+
const { username, password, database } = options
|
|
1156
|
+
assertValidUsername(username)
|
|
1157
|
+
const { port, version, name } = container
|
|
1158
|
+
const namespace = name.replace(/-/g, '_')
|
|
1159
|
+
const db = database || container.database || 'default'
|
|
1160
|
+
|
|
1161
|
+
const surreal = await this.getSurrealPath(version)
|
|
1162
|
+
const containerDir = paths.getContainerPath(name, { engine: ENGINE })
|
|
1163
|
+
|
|
1164
|
+
// DEFINE USER OVERWRITE with EDITOR role (idempotent)
|
|
1165
|
+
// Scope to database when options.database is provided, otherwise namespace-level
|
|
1166
|
+
// Escape backslashes first, then single quotes for SurrealQL string literals
|
|
1167
|
+
const escapedPass = password.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
1168
|
+
const scopeClause = database
|
|
1169
|
+
? `ON DATABASE ${escapeSurrealIdentifier(database)}`
|
|
1170
|
+
: 'ON NAMESPACE'
|
|
1171
|
+
const sql = `DEFINE USER OVERWRITE ${escapeSurrealIdentifier(username)} ${scopeClause} PASSWORD '${escapedPass}' ROLES EDITOR;`
|
|
1172
|
+
|
|
1173
|
+
const args = [
|
|
1174
|
+
'sql',
|
|
1175
|
+
'--endpoint',
|
|
1176
|
+
`ws://127.0.0.1:${port}`,
|
|
1177
|
+
'--user',
|
|
1178
|
+
'root',
|
|
1179
|
+
'--pass',
|
|
1180
|
+
'root',
|
|
1181
|
+
'--ns',
|
|
1182
|
+
namespace,
|
|
1183
|
+
'--db',
|
|
1184
|
+
db,
|
|
1185
|
+
'--hide-welcome',
|
|
1186
|
+
]
|
|
1187
|
+
|
|
1188
|
+
const timeoutMs = 15000
|
|
1189
|
+
await new Promise<void>((resolve, reject) => {
|
|
1190
|
+
const proc = spawn(surreal, args, {
|
|
1191
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1192
|
+
cwd: containerDir,
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
let stderr = ''
|
|
1196
|
+
let settled = false
|
|
1197
|
+
const timeoutId = setTimeout(() => {
|
|
1198
|
+
if (settled) return
|
|
1199
|
+
settled = true
|
|
1200
|
+
proc.kill()
|
|
1201
|
+
reject(
|
|
1202
|
+
new Error(
|
|
1203
|
+
`Timed out creating SurrealDB user "${username}" after ${timeoutMs}ms`,
|
|
1204
|
+
),
|
|
1205
|
+
)
|
|
1206
|
+
}, timeoutMs)
|
|
1207
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
1208
|
+
stderr += data.toString()
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
proc.stdin?.write(sql + '\n')
|
|
1212
|
+
proc.stdin?.end()
|
|
1213
|
+
|
|
1214
|
+
proc.on('close', (code) => {
|
|
1215
|
+
if (settled) return
|
|
1216
|
+
settled = true
|
|
1217
|
+
clearTimeout(timeoutId)
|
|
1218
|
+
if (code === 0) {
|
|
1219
|
+
logDebug(`Created SurrealDB user: ${username}`)
|
|
1220
|
+
resolve()
|
|
1221
|
+
} else {
|
|
1222
|
+
reject(new Error(`Failed to create user: ${stderr}`))
|
|
1223
|
+
}
|
|
1224
|
+
})
|
|
1225
|
+
proc.on('error', (error) => {
|
|
1226
|
+
if (settled) return
|
|
1227
|
+
settled = true
|
|
1228
|
+
clearTimeout(timeoutId)
|
|
1229
|
+
reject(error)
|
|
1230
|
+
})
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
const connectionString = `ws://127.0.0.1:${port}/rpc`
|
|
1234
|
+
|
|
1235
|
+
return {
|
|
1236
|
+
username,
|
|
1237
|
+
password,
|
|
1238
|
+
connectionString,
|
|
1239
|
+
engine: container.engine,
|
|
1240
|
+
container: container.name,
|
|
1241
|
+
database: db,
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1144
1244
|
}
|
|
1145
1245
|
|
|
1146
1246
|
export const surrealdbEngine = new SurrealDBEngine()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeDB backup module
|
|
3
|
+
*
|
|
4
|
+
* TypeDB exports databases as two files: schema (.typeql) and data (.typeql).
|
|
5
|
+
* We use the console's `database export` command which creates both files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process'
|
|
9
|
+
import { mkdir } from 'fs/promises'
|
|
10
|
+
import { stat } from 'fs/promises'
|
|
11
|
+
import { dirname } from 'path'
|
|
12
|
+
import { logDebug } from '../../core/error-handler'
|
|
13
|
+
import { requireTypeDBConsolePath, getConsoleBaseArgs } from './cli-utils'
|
|
14
|
+
import type { ContainerConfig, BackupOptions, BackupResult } from '../../types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a TypeQL backup using typedb console export
|
|
18
|
+
*
|
|
19
|
+
* TypeDB export creates two files derived from outputPath:
|
|
20
|
+
* - {base}-schema.typeql (schema definitions)
|
|
21
|
+
* - {base}-data.typeql (data inserts)
|
|
22
|
+
*
|
|
23
|
+
* The returned BackupResult.path is the original outputPath (base path),
|
|
24
|
+
* NOT a single backup file. Callers (e.g., restore) must derive the actual
|
|
25
|
+
* file paths using the same `-schema.typeql` / `-data.typeql` convention.
|
|
26
|
+
* See also: restore.ts restoreTypeQLBackup() which mirrors this derivation.
|
|
27
|
+
*/
|
|
28
|
+
async function createTypeQLBackup(
|
|
29
|
+
container: ContainerConfig,
|
|
30
|
+
outputPath: string,
|
|
31
|
+
database: string,
|
|
32
|
+
): Promise<BackupResult> {
|
|
33
|
+
const consolePath = await requireTypeDBConsolePath(container.version)
|
|
34
|
+
const { port } = container
|
|
35
|
+
|
|
36
|
+
// Ensure output directory exists
|
|
37
|
+
await mkdir(dirname(outputPath), { recursive: true })
|
|
38
|
+
|
|
39
|
+
// Derive schema and data paths from output path
|
|
40
|
+
const schemaPath = outputPath.endsWith('.typeql')
|
|
41
|
+
? outputPath.replace(/\.typeql$/, '-schema.typeql')
|
|
42
|
+
: outputPath + '-schema.typeql'
|
|
43
|
+
const dataPath = outputPath.endsWith('.typeql')
|
|
44
|
+
? outputPath.replace(/\.typeql$/, '-data.typeql')
|
|
45
|
+
: outputPath + '-data.typeql'
|
|
46
|
+
|
|
47
|
+
const args = [
|
|
48
|
+
...getConsoleBaseArgs(port),
|
|
49
|
+
'--command',
|
|
50
|
+
`database export ${database} ${schemaPath} ${dataPath}`,
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const sanitizedArgs = args.map((a, i) =>
|
|
54
|
+
args[i - 1] === '--password' ? '***' : a,
|
|
55
|
+
)
|
|
56
|
+
logDebug(`Running: typedb_console_bin ${sanitizedArgs.join(' ')}`)
|
|
57
|
+
|
|
58
|
+
return new Promise<BackupResult>((resolve, reject) => {
|
|
59
|
+
const proc = spawn(consolePath, args, {
|
|
60
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
let stdout = ''
|
|
64
|
+
let stderr = ''
|
|
65
|
+
|
|
66
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
67
|
+
stdout += data.toString()
|
|
68
|
+
})
|
|
69
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
70
|
+
stderr += data.toString()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
proc.on('error', reject)
|
|
74
|
+
|
|
75
|
+
proc.on('close', async (code) => {
|
|
76
|
+
if (code === 0) {
|
|
77
|
+
try {
|
|
78
|
+
// Calculate total size of both files (schema + data)
|
|
79
|
+
let schemaSize: number | null = null
|
|
80
|
+
let dataSize: number | null = null
|
|
81
|
+
const errors: string[] = []
|
|
82
|
+
try {
|
|
83
|
+
const schemaStats = await stat(schemaPath)
|
|
84
|
+
schemaSize = schemaStats.size
|
|
85
|
+
} catch (err) {
|
|
86
|
+
errors.push(`schema(${schemaPath}): ${err}`)
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const dataStats = await stat(dataPath)
|
|
90
|
+
dataSize = dataStats.size
|
|
91
|
+
} catch (err) {
|
|
92
|
+
errors.push(`data(${dataPath}): ${err}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
errors.length > 0 ||
|
|
97
|
+
schemaSize === null ||
|
|
98
|
+
dataSize === null ||
|
|
99
|
+
schemaSize === 0 ||
|
|
100
|
+
dataSize === 0
|
|
101
|
+
) {
|
|
102
|
+
reject(
|
|
103
|
+
new Error(
|
|
104
|
+
`Backup produced empty or missing files: schema=${schemaPath}, data=${dataPath}` +
|
|
105
|
+
(errors.length > 0
|
|
106
|
+
? `. Stat errors: ${errors.join('; ')}`
|
|
107
|
+
: ''),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// path is the base outputPath; actual files are schemaPath and dataPath
|
|
114
|
+
resolve({
|
|
115
|
+
path: outputPath,
|
|
116
|
+
format: 'typeql',
|
|
117
|
+
size: schemaSize + dataSize,
|
|
118
|
+
})
|
|
119
|
+
} catch (error) {
|
|
120
|
+
reject(new Error(`Backup files not created: ${error}`))
|
|
121
|
+
}
|
|
122
|
+
} else if (code === null) {
|
|
123
|
+
const detail = stderr || stdout
|
|
124
|
+
reject(
|
|
125
|
+
new Error(
|
|
126
|
+
`typedb console export was terminated by signal${detail ? `: ${detail}` : ''}`,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
} else {
|
|
130
|
+
const detail = stderr || stdout
|
|
131
|
+
reject(
|
|
132
|
+
new Error(
|
|
133
|
+
`typedb console export exited with code ${code}${detail ? `: ${detail}` : ''}`,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a backup
|
|
143
|
+
*
|
|
144
|
+
* @param container - Container configuration
|
|
145
|
+
* @param outputPath - Path to write backup file
|
|
146
|
+
* @param options - Backup options
|
|
147
|
+
*/
|
|
148
|
+
export async function createBackup(
|
|
149
|
+
container: ContainerConfig,
|
|
150
|
+
outputPath: string,
|
|
151
|
+
options: BackupOptions,
|
|
152
|
+
): Promise<BackupResult> {
|
|
153
|
+
const database = options.database || container.database
|
|
154
|
+
|
|
155
|
+
return createTypeQLBackup(container, outputPath, database)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create a backup for cloning purposes
|
|
160
|
+
* Uses TypeQL format for reliability
|
|
161
|
+
*/
|
|
162
|
+
export async function createCloneBackup(
|
|
163
|
+
container: ContainerConfig,
|
|
164
|
+
outputPath: string,
|
|
165
|
+
): Promise<BackupResult> {
|
|
166
|
+
return createTypeQLBackup(container, outputPath, container.database)
|
|
167
|
+
}
|