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
@@ -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 { logDebug, logWarning } from '../../core/error-handler'
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()
@@ -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 { logDebug, logWarning } from '../../core/error-handler'
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
- reject(
662
- new Error(
663
- `Redis failed to start within timeout. Check logs at: ${logFile}`,
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
- reject(
669
- new Error(
670
- stderr || stdout || `redis-server exited with code ${code}`,
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 = 30000,
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 { logDebug, logWarning } from '../../core/error-handler'
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 = 30000,
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
+ }