spindb 0.31.2 → 0.31.3

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.
@@ -437,6 +437,90 @@ databasesCommand
437
437
  },
438
438
  )
439
439
 
440
+ // Refresh databases from server - queries the actual database server
441
+ databasesCommand
442
+ .command('refresh')
443
+ .description(
444
+ 'Refresh tracking by querying the database server for actual databases',
445
+ )
446
+ .argument('<container>', 'Container name')
447
+ .option('-j, --json', 'Output as JSON')
448
+ .action(async (container: string, options: { json?: boolean }) => {
449
+ try {
450
+ const config = await containerManager.getConfig(container)
451
+ if (!config) {
452
+ if (options.json) {
453
+ console.log(
454
+ JSON.stringify(
455
+ { error: `Container "${container}" not found` },
456
+ null,
457
+ 2,
458
+ ),
459
+ )
460
+ } else {
461
+ console.error(uiError(`Container "${container}" not found`))
462
+ }
463
+ process.exit(1)
464
+ }
465
+
466
+ const beforeDatabases = config.databases || [config.database]
467
+ const afterDatabases = await containerManager.syncDatabases(container)
468
+
469
+ // Calculate changes
470
+ const added = afterDatabases.filter((db) => !beforeDatabases.includes(db))
471
+ const removed = beforeDatabases.filter(
472
+ (db) => !afterDatabases.includes(db),
473
+ )
474
+
475
+ if (options.json) {
476
+ console.log(
477
+ JSON.stringify(
478
+ {
479
+ success: true,
480
+ container,
481
+ databases: afterDatabases,
482
+ changes: {
483
+ added: added.length > 0 ? added : undefined,
484
+ removed: removed.length > 0 ? removed : undefined,
485
+ },
486
+ },
487
+ null,
488
+ 2,
489
+ ),
490
+ )
491
+ } else {
492
+ if (added.length === 0 && removed.length === 0) {
493
+ console.log(chalk.gray(`Registry already in sync for "${container}"`))
494
+ } else {
495
+ console.log(
496
+ uiSuccess(`Refreshed database tracking for "${container}"`),
497
+ )
498
+ if (added.length > 0) {
499
+ console.log(chalk.green(` Added: ${added.join(', ')}`))
500
+ }
501
+ if (removed.length > 0) {
502
+ console.log(chalk.yellow(` Removed: ${removed.join(', ')}`))
503
+ }
504
+ }
505
+ console.log()
506
+ console.log(chalk.bold('Current databases:'))
507
+ for (const db of afterDatabases) {
508
+ const isPrimary = db === config.database
509
+ const label = isPrimary ? chalk.gray(' (primary)') : ''
510
+ console.log(` ${chalk.cyan(db)}${label}`)
511
+ }
512
+ }
513
+ } catch (error) {
514
+ const e = error as Error
515
+ if (options.json) {
516
+ console.log(JSON.stringify({ error: e.message }, null, 2))
517
+ } else {
518
+ console.error(uiError(e.message))
519
+ }
520
+ process.exit(1)
521
+ }
522
+ })
523
+
440
524
  // Set the default/primary database for a container
441
525
  databasesCommand
442
526
  .command('set-default')
@@ -9,8 +9,8 @@ import { getEngineDefaults } from '../../config/defaults'
9
9
  import { promptContainerSelect, promptConfirm } from '../ui/prompts'
10
10
  import { createSpinner } from '../ui/spinner'
11
11
  import { uiWarning } from '../ui/theme'
12
- import { Engine } from '../../types'
13
- import { exitWithError } from '../../core/error-handler'
12
+ import { Engine, isFileBasedEngine } from '../../types'
13
+ import { exitWithError, logDebug } from '../../core/error-handler'
14
14
 
15
15
  export const startCommand = new Command('start')
16
16
  .description('Start a container')
@@ -174,6 +174,18 @@ export const startCommand = new Command('start')
174
174
  }
175
175
  }
176
176
 
177
+ // Sync database registry with actual server state (silent, non-blocking)
178
+ if (!isFileBasedEngine(config.engine)) {
179
+ try {
180
+ await containerManager.syncDatabases(containerName)
181
+ } catch (syncError) {
182
+ // Don't fail start if sync fails - just log for debugging
183
+ logDebug(
184
+ `Failed to sync databases for ${containerName}: ${syncError}`,
185
+ )
186
+ }
187
+ }
188
+
177
189
  const connectionString = engine.getConnectionString(config)
178
190
 
179
191
  if (options.json) {
@@ -13,13 +13,13 @@ import { paths } from '../config/paths'
13
13
  import { processManager } from './process-manager'
14
14
  import { portManager } from './port-manager'
15
15
  import { isWindows } from './platform-service'
16
- import { logDebug } from './error-handler'
16
+ import { logDebug, UnsupportedOperationError } from './error-handler'
17
17
  import { getEngineDefaults, getSupportedEngines } from '../config/defaults'
18
18
  import { getEngine } from '../engines'
19
19
  import { sqliteRegistry } from '../engines/sqlite/registry'
20
20
  import { duckdbRegistry } from '../engines/duckdb/registry'
21
21
  import type { ContainerConfig } from '../types'
22
- import { Engine } from '../types'
22
+ import { Engine, isFileBasedEngine } from '../types'
23
23
 
24
24
  export type CreateOptions = {
25
25
  engine: Engine
@@ -712,6 +712,72 @@ export class ContainerManager {
712
712
  }
713
713
  }
714
714
 
715
+ /**
716
+ * Sync the databases array with the actual databases on the server.
717
+ * Queries the database server for all user databases and updates the registry.
718
+ *
719
+ * @param containerName - The container to sync
720
+ * @returns The updated list of databases
721
+ * @throws Error if the container is not running or doesn't support listing databases
722
+ */
723
+ async syncDatabases(containerName: string): Promise<string[]> {
724
+ const config = await this.getConfig(containerName)
725
+ if (!config) {
726
+ throw new Error(`Container "${containerName}" not found`)
727
+ }
728
+
729
+ // File-based engines don't have multiple databases to sync
730
+ if (isFileBasedEngine(config.engine)) {
731
+ return config.databases || [config.database]
732
+ }
733
+
734
+ // Container must be running to query databases
735
+ const running = await processManager.isRunning(containerName, {
736
+ engine: config.engine,
737
+ })
738
+ if (!running) {
739
+ throw new Error(
740
+ `Container "${containerName}" is not running. Start it first to sync databases.`,
741
+ )
742
+ }
743
+
744
+ const engine = getEngine(config.engine)
745
+
746
+ // Query the actual database server for all databases
747
+ let actualDatabases: string[]
748
+ try {
749
+ actualDatabases = await engine.listDatabases(config)
750
+ } catch (error) {
751
+ // If the engine doesn't support listDatabases, return current registry
752
+ if (error instanceof UnsupportedOperationError) {
753
+ logDebug(
754
+ `listDatabases not supported for ${config.engine}, skipping sync`,
755
+ )
756
+ return config.databases || [config.database]
757
+ }
758
+ throw error
759
+ }
760
+
761
+ // Ensure primary database is always included
762
+ if (!actualDatabases.includes(config.database)) {
763
+ actualDatabases = [config.database, ...actualDatabases]
764
+ }
765
+
766
+ // Sort for consistent ordering (primary database first, then alphabetical)
767
+ const sortedDatabases = [
768
+ config.database,
769
+ ...actualDatabases
770
+ .filter((db) => db !== config.database)
771
+ .sort((a, b) => a.localeCompare(b)),
772
+ ]
773
+
774
+ // Update the registry
775
+ config.databases = sortedDatabases
776
+ await this.saveConfig(containerName, { engine: config.engine }, config)
777
+
778
+ return sortedDatabases
779
+ }
780
+
715
781
  getConnectionString(config: ContainerConfig, database?: string): string {
716
782
  const engine = getEngine(config.engine)
717
783
  return engine.getConnectionString(config, database)
@@ -136,6 +136,27 @@ export class MissingToolError extends Error {
136
136
  }
137
137
  }
138
138
 
139
+ /**
140
+ * Error thrown when an operation is not supported by an engine.
141
+ * For example, listDatabases on engines that don't have the concept of databases.
142
+ */
143
+ export class UnsupportedOperationError extends Error {
144
+ public readonly operation: string
145
+ public readonly engine: string
146
+
147
+ constructor(operation: string, engine: string, message?: string) {
148
+ super(
149
+ message ??
150
+ `${operation} is not supported for ${engine}. ` +
151
+ `This engine may use a different concept (collections, indexes, etc.).`,
152
+ )
153
+ this.name = 'UnsupportedOperationError'
154
+ this.operation = operation
155
+ this.engine = engine
156
+ Error.captureStackTrace(this, UnsupportedOperationError)
157
+ }
158
+ }
159
+
139
160
  function getLogPath(): string {
140
161
  return join(getSpinDBRoot(), 'spindb.log')
141
162
  }
@@ -123,7 +123,7 @@ export class PullManager {
123
123
  // Track whether to keep backup in final result (user didn't specify --no-backup)
124
124
  const keepBackup = !options.noBackup
125
125
 
126
- return withTransaction(async (tx) => {
126
+ const result = await withTransaction(async (tx) => {
127
127
  // --- BACKUP ORIGINAL (always if post-script, otherwise if not --no-backup) ---
128
128
  if (needsBackup) {
129
129
  // Step 1: Create backup database
@@ -217,12 +217,7 @@ export class PullManager {
217
217
  createDatabase: false,
218
218
  })
219
219
 
220
- // Step 9: Update registry (add backup if we're keeping it)
221
- if (keepBackup) {
222
- await containerManager.addDatabase(config.name, backupDatabase)
223
- }
224
-
225
- // Step 10: Cleanup temp files
220
+ // Step 9: Cleanup temp files
226
221
  try {
227
222
  await unlink(tempOriginalDump)
228
223
  } catch {
@@ -234,7 +229,7 @@ export class PullManager {
234
229
  // Ignore errors
235
230
  }
236
231
 
237
- // Step 11: Run post-script if provided
232
+ // Step 10: Run post-script if provided
238
233
  if (options.postScript) {
239
234
  const context: PullContext = {
240
235
  container: config.name,
@@ -278,6 +273,19 @@ export class PullManager {
278
273
  : `Pulled remote data into "${targetDatabase}"`,
279
274
  }
280
275
  })
276
+
277
+ // Sync registry with actual databases on server after transaction commits
278
+ // This captures the backup database (if kept) and any other databases
279
+ // Wrapped in try/catch to avoid affecting the main pull result on transient failures
280
+ try {
281
+ await containerManager.syncDatabases(config.name)
282
+ } catch (error) {
283
+ logDebug(
284
+ `Failed to sync databases for "${config.name}": ${error instanceof Error ? error.message : error}`,
285
+ )
286
+ }
287
+
288
+ return result
281
289
  }
282
290
 
283
291
  private async executeCloneMode(
@@ -335,17 +343,14 @@ export class PullManager {
335
343
  createDatabase: false,
336
344
  })
337
345
 
338
- // Step 5: Update registry
339
- await containerManager.addDatabase(config.name, targetDatabase)
340
-
341
- // Step 6: Cleanup
346
+ // Step 5: Cleanup
342
347
  try {
343
348
  await unlink(tempRemoteDump)
344
349
  } catch {
345
350
  // Ignore errors
346
351
  }
347
352
 
348
- // Step 7: Run post-script if provided
353
+ // Step 6: Run post-script if provided
349
354
  if (options.postScript) {
350
355
  const context: PullContext = {
351
356
  container: config.name,
@@ -360,6 +365,16 @@ export class PullManager {
360
365
  await this.runPostScript(options.postScript, context)
361
366
  }
362
367
 
368
+ // Step 7: Sync registry with actual databases on server
369
+ // Wrapped in try/catch to avoid rolling back a successful clone on transient failures
370
+ try {
371
+ await containerManager.syncDatabases(config.name)
372
+ } catch (error) {
373
+ logDebug(
374
+ `Failed to sync databases for "${config.name}": ${error instanceof Error ? error.message : error}`,
375
+ )
376
+ }
377
+
363
378
  return {
364
379
  success: true,
365
380
  mode: 'clone' as const,
@@ -10,6 +10,7 @@ import type {
10
10
  QueryResult,
11
11
  QueryOptions,
12
12
  } from '../types'
13
+ import { UnsupportedOperationError } from '../core/error-handler'
13
14
 
14
15
  /**
15
16
  * Base class for database engines
@@ -263,4 +264,21 @@ export abstract class BaseEngine {
263
264
  query: string,
264
265
  options?: QueryOptions,
265
266
  ): Promise<QueryResult>
267
+
268
+ /**
269
+ * List all user databases on the server, excluding system databases.
270
+ * Used to sync the registry with actual databases on the server.
271
+ *
272
+ * System databases excluded by default:
273
+ * - PostgreSQL: template0, template1, postgres
274
+ * - MySQL/MariaDB: information_schema, mysql, performance_schema, sys
275
+ * - CockroachDB: defaultdb, postgres, system
276
+ *
277
+ * @param container - The container configuration
278
+ * @returns Array of database names (excluding system databases)
279
+ * @throws Error if the engine doesn't support multiple databases or listing
280
+ */
281
+ async listDatabases(_container: ContainerConfig): Promise<string[]> {
282
+ throw new UnsupportedOperationError('listDatabases', this.displayName)
283
+ }
266
284
  }
@@ -1185,6 +1185,65 @@ export class ClickHouseEngine extends BaseEngine {
1185
1185
  })
1186
1186
  })
1187
1187
  }
1188
+
1189
+ /**
1190
+ * List all user databases, excluding system databases (system, information_schema, INFORMATION_SCHEMA).
1191
+ */
1192
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1193
+ const { port, version } = container
1194
+ const clickhouse = await this.getClickHouseClientPath(version)
1195
+
1196
+ logDebug(`Listing databases on port ${port} with version ${version}`)
1197
+
1198
+ return new Promise((resolve, reject) => {
1199
+ const args = [
1200
+ 'client',
1201
+ '--host',
1202
+ '127.0.0.1',
1203
+ '--port',
1204
+ String(port),
1205
+ '--query',
1206
+ 'SHOW DATABASES',
1207
+ ]
1208
+
1209
+ const proc = spawn(clickhouse, args, {
1210
+ stdio: ['ignore', 'pipe', 'pipe'],
1211
+ })
1212
+
1213
+ let stdout = ''
1214
+ let stderr = ''
1215
+
1216
+ proc.stdout?.on('data', (data: Buffer) => {
1217
+ stdout += data.toString()
1218
+ })
1219
+ proc.stderr?.on('data', (data: Buffer) => {
1220
+ stderr += data.toString()
1221
+ })
1222
+
1223
+ proc.on('error', reject)
1224
+
1225
+ proc.on('close', (code) => {
1226
+ if (code !== 0) {
1227
+ reject(new Error(stderr || `clickhouse exited with code ${code}`))
1228
+ return
1229
+ }
1230
+
1231
+ // Parse output (one database per line)
1232
+ const systemDatabases = [
1233
+ 'system',
1234
+ 'information_schema',
1235
+ 'INFORMATION_SCHEMA',
1236
+ ]
1237
+ const databases = stdout
1238
+ .trim()
1239
+ .split('\n')
1240
+ .map((db) => db.trim())
1241
+ .filter((db) => db.length > 0 && !systemDatabases.includes(db))
1242
+
1243
+ resolve(databases)
1244
+ })
1245
+ })
1246
+ }
1188
1247
  }
1189
1248
 
1190
1249
  export const clickhouseEngine = new ClickHouseEngine()
@@ -1148,6 +1148,59 @@ export class CockroachDBEngine extends BaseEngine {
1148
1148
  })
1149
1149
  })
1150
1150
  }
1151
+
1152
+ /**
1153
+ * List all user databases, excluding system databases (defaultdb, postgres, system).
1154
+ */
1155
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1156
+ const { port, version } = container
1157
+ const cockroach = await this.getCockroachPath(version)
1158
+
1159
+ return new Promise((resolve, reject) => {
1160
+ const args = [
1161
+ 'sql',
1162
+ '--insecure',
1163
+ '--host',
1164
+ `127.0.0.1:${port}`,
1165
+ '--execute',
1166
+ `SHOW DATABASES`,
1167
+ '--format=csv',
1168
+ ]
1169
+
1170
+ const proc = spawn(cockroach, args, {
1171
+ stdio: ['ignore', 'pipe', 'pipe'],
1172
+ })
1173
+
1174
+ let stdout = ''
1175
+ let stderr = ''
1176
+
1177
+ proc.stdout?.on('data', (data: Buffer) => {
1178
+ stdout += data.toString()
1179
+ })
1180
+ proc.stderr?.on('data', (data: Buffer) => {
1181
+ stderr += data.toString()
1182
+ })
1183
+
1184
+ proc.on('error', reject)
1185
+
1186
+ proc.on('close', (code) => {
1187
+ if (code !== 0) {
1188
+ reject(new Error(stderr || `cockroach sql exited with code ${code}`))
1189
+ return
1190
+ }
1191
+
1192
+ // Parse CSV output (first column is database_name, skip header)
1193
+ const systemDatabases = ['defaultdb', 'postgres', 'system']
1194
+ const lines = stdout.trim().split('\n')
1195
+ const databases = lines
1196
+ .slice(1) // Skip header
1197
+ .map((line) => line.split(',')[0].trim())
1198
+ .filter((db) => db.length > 0 && !systemDatabases.includes(db))
1199
+
1200
+ resolve(databases)
1201
+ })
1202
+ })
1203
+ }
1151
1204
  }
1152
1205
 
1153
1206
  export const cockroachdbEngine = new CockroachDBEngine()
@@ -1253,6 +1253,29 @@ export class CouchDBEngine extends BaseEngine {
1253
1253
 
1254
1254
  return parseRESTAPIResult(JSON.stringify(response.data))
1255
1255
  }
1256
+
1257
+ /**
1258
+ * List all user databases, excluding system databases (_users, _replicator, _global_changes).
1259
+ */
1260
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1261
+ const { port } = container
1262
+
1263
+ const response = await couchdbApiRequest(port, 'GET', '/_all_dbs')
1264
+
1265
+ if (response.status >= 400) {
1266
+ throw new Error(
1267
+ `CouchDB API error (${response.status}): ${JSON.stringify(response.data)}`,
1268
+ )
1269
+ }
1270
+
1271
+ const allDatabases = response.data as string[]
1272
+ const systemDatabases = ['_users', '_replicator', '_global_changes']
1273
+ const databases = allDatabases.filter(
1274
+ (db) => !systemDatabases.includes(db) && !db.startsWith('_'),
1275
+ )
1276
+
1277
+ return databases
1278
+ }
1256
1279
  }
1257
1280
 
1258
1281
  export const couchdbEngine = new CouchDBEngine()
@@ -804,6 +804,16 @@ export class DuckDBEngine extends BaseEngine {
804
804
  })
805
805
  })
806
806
  }
807
+
808
+ /**
809
+ * List databases for DuckDB.
810
+ * DuckDB is file-based with one database per file.
811
+ * Returns the configured database (file path) as a single-item array.
812
+ */
813
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
814
+ // DuckDB is file-based, one database per file
815
+ return [container.database]
816
+ }
807
817
  }
808
818
 
809
819
  export const duckdbEngine = new DuckDBEngine()
@@ -1283,6 +1283,63 @@ export class FerretDBEngine extends BaseEngine {
1283
1283
  })
1284
1284
  })
1285
1285
  }
1286
+
1287
+ /**
1288
+ * List all user databases, excluding system databases (admin, config, local).
1289
+ * FerretDB uses MongoDB protocol, so same approach as MongoDB.
1290
+ */
1291
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1292
+ const { port } = container
1293
+ const mongosh = await this.getMongoshPath()
1294
+
1295
+ return new Promise((resolve, reject) => {
1296
+ // Use JSON output for reliable parsing
1297
+ const script = `JSON.stringify(db.adminCommand({listDatabases: 1}).databases.map(d => d.name))`
1298
+ const args = [
1299
+ '--quiet',
1300
+ '--host',
1301
+ '127.0.0.1',
1302
+ '--port',
1303
+ String(port),
1304
+ '--eval',
1305
+ script,
1306
+ ]
1307
+
1308
+ const proc = spawn(mongosh, args, {
1309
+ stdio: ['ignore', 'pipe', 'pipe'],
1310
+ })
1311
+
1312
+ let stdout = ''
1313
+ let stderr = ''
1314
+
1315
+ proc.stdout?.on('data', (data: Buffer) => {
1316
+ stdout += data.toString()
1317
+ })
1318
+ proc.stderr?.on('data', (data: Buffer) => {
1319
+ stderr += data.toString()
1320
+ })
1321
+
1322
+ proc.on('error', reject)
1323
+
1324
+ proc.on('close', (code) => {
1325
+ if (code !== 0) {
1326
+ reject(new Error(stderr || `mongosh exited with code ${code}`))
1327
+ return
1328
+ }
1329
+
1330
+ try {
1331
+ const allDatabases = JSON.parse(stdout.trim()) as string[]
1332
+ const systemDatabases = ['admin', 'config', 'local']
1333
+ const databases = allDatabases.filter(
1334
+ (db) => !systemDatabases.includes(db),
1335
+ )
1336
+ resolve(databases)
1337
+ } catch (error) {
1338
+ reject(new Error(`Failed to parse database list: ${error}`))
1339
+ }
1340
+ })
1341
+ })
1342
+ }
1286
1343
  }
1287
1344
 
1288
1345
  export const ferretdbEngine = new FerretDBEngine()
@@ -1112,6 +1112,62 @@ export class MariaDBEngine extends BaseEngine {
1112
1112
  })
1113
1113
  })
1114
1114
  }
1115
+
1116
+ /**
1117
+ * List all user databases, excluding system databases
1118
+ * (information_schema, mysql, performance_schema, sys).
1119
+ */
1120
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1121
+ const { port } = container
1122
+ const mariadb = await this.getMariadbClientPath()
1123
+
1124
+ // Query for all non-system databases
1125
+ const sql = `SHOW DATABASES WHERE \`Database\` NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')`
1126
+
1127
+ const args = [
1128
+ '-h',
1129
+ '127.0.0.1',
1130
+ '-P',
1131
+ String(port),
1132
+ '-u',
1133
+ engineDef.superuser,
1134
+ '-N', // Skip column names
1135
+ '-B', // Batch mode (no formatting)
1136
+ '-e',
1137
+ sql,
1138
+ ]
1139
+
1140
+ return new Promise((resolve, reject) => {
1141
+ const proc = spawn(mariadb, args, {
1142
+ stdio: ['pipe', 'pipe', 'pipe'],
1143
+ })
1144
+
1145
+ let stdout = ''
1146
+ let stderr = ''
1147
+
1148
+ proc.stdout?.on('data', (data: Buffer) => {
1149
+ stdout += data.toString()
1150
+ })
1151
+ proc.stderr?.on('data', (data: Buffer) => {
1152
+ stderr += data.toString()
1153
+ })
1154
+
1155
+ proc.on('error', reject)
1156
+
1157
+ proc.on('close', (code) => {
1158
+ if (code === 0) {
1159
+ const databases = stdout
1160
+ .trim()
1161
+ .split('\n')
1162
+ .map((db) => db.trim())
1163
+ .filter((db) => db.length > 0)
1164
+ resolve(databases)
1165
+ } else {
1166
+ reject(new Error(stderr || `mariadb exited with code ${code}`))
1167
+ }
1168
+ })
1169
+ })
1170
+ }
1115
1171
  }
1116
1172
 
1117
1173
  export const mariadbEngine = new MariaDBEngine()
@@ -1166,6 +1166,31 @@ export class MeilisearchEngine extends BaseEngine {
1166
1166
 
1167
1167
  return parseRESTAPIResult(JSON.stringify(response.data))
1168
1168
  }
1169
+
1170
+ /**
1171
+ * List databases for Meilisearch.
1172
+ * Meilisearch uses indexes, so we query the /indexes endpoint.
1173
+ */
1174
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1175
+ const { port } = container
1176
+
1177
+ try {
1178
+ const response = await meilisearchApiRequest(port, 'GET', '/indexes')
1179
+ if (response.status === 200 && response.data) {
1180
+ // Response is { results: [{ uid: "index_name", ... }, ...], ... }
1181
+ const data = response.data as { results?: Array<{ uid: string }> }
1182
+ if (Array.isArray(data.results)) {
1183
+ const indexes = data.results.map((index) => index.uid)
1184
+ return indexes.length > 0 ? indexes : [container.database]
1185
+ }
1186
+ }
1187
+ // Fall back to configured database
1188
+ return [container.database]
1189
+ } catch {
1190
+ // On error, fall back to configured database
1191
+ return [container.database]
1192
+ }
1193
+ }
1169
1194
  }
1170
1195
 
1171
1196
  export const meilisearchEngine = new MeilisearchEngine()
@@ -983,6 +983,54 @@ export class MongoDBEngine extends BaseEngine {
983
983
 
984
984
  return parseMongoDBResult(jsonMatch[0])
985
985
  }
986
+
987
+ /**
988
+ * List all user databases, excluding system databases (admin, config, local).
989
+ */
990
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
991
+ const { port } = container
992
+ const mongosh = await this.getMongoshPath()
993
+
994
+ return new Promise((resolve, reject) => {
995
+ // Use JSON output for reliable parsing
996
+ const script = `JSON.stringify(db.adminCommand({listDatabases: 1}).databases.map(d => d.name))`
997
+ const args = ['--quiet', '--host', `127.0.0.1:${port}`, '--eval', script]
998
+
999
+ const proc = spawn(mongosh, args, {
1000
+ stdio: ['ignore', 'pipe', 'pipe'],
1001
+ })
1002
+
1003
+ let stdout = ''
1004
+ let stderr = ''
1005
+
1006
+ proc.stdout?.on('data', (data: Buffer) => {
1007
+ stdout += data.toString()
1008
+ })
1009
+ proc.stderr?.on('data', (data: Buffer) => {
1010
+ stderr += data.toString()
1011
+ })
1012
+
1013
+ proc.on('error', reject)
1014
+
1015
+ proc.on('close', (code) => {
1016
+ if (code !== 0) {
1017
+ reject(new Error(stderr || `mongosh exited with code ${code}`))
1018
+ return
1019
+ }
1020
+
1021
+ try {
1022
+ const allDatabases = JSON.parse(stdout.trim()) as string[]
1023
+ const systemDatabases = ['admin', 'config', 'local']
1024
+ const databases = allDatabases.filter(
1025
+ (db) => !systemDatabases.includes(db),
1026
+ )
1027
+ resolve(databases)
1028
+ } catch (error) {
1029
+ reject(new Error(`Failed to parse database list: ${error}`))
1030
+ }
1031
+ })
1032
+ })
1033
+ }
986
1034
  }
987
1035
 
988
1036
  export const mongodbEngine = new MongoDBEngine()
@@ -1196,6 +1196,62 @@ export class MySQLEngine extends BaseEngine {
1196
1196
  })
1197
1197
  })
1198
1198
  }
1199
+
1200
+ /**
1201
+ * List all user databases, excluding system databases
1202
+ * (information_schema, mysql, performance_schema, sys).
1203
+ */
1204
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1205
+ const { port } = container
1206
+ const mysql = await this.getMysqlClientPath()
1207
+
1208
+ // Query for all non-system databases
1209
+ const sql = `SHOW DATABASES WHERE \`Database\` NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys')`
1210
+
1211
+ const args = [
1212
+ '-h',
1213
+ '127.0.0.1',
1214
+ '-P',
1215
+ String(port),
1216
+ '-u',
1217
+ engineDef.superuser,
1218
+ '-N', // Skip column names
1219
+ '-B', // Batch mode (no formatting)
1220
+ '-e',
1221
+ sql,
1222
+ ]
1223
+
1224
+ return new Promise((resolve, reject) => {
1225
+ const proc = spawn(mysql, args, {
1226
+ stdio: ['pipe', 'pipe', 'pipe'],
1227
+ })
1228
+
1229
+ let stdout = ''
1230
+ let stderr = ''
1231
+
1232
+ proc.stdout?.on('data', (data: Buffer) => {
1233
+ stdout += data.toString()
1234
+ })
1235
+ proc.stderr?.on('data', (data: Buffer) => {
1236
+ stderr += data.toString()
1237
+ })
1238
+
1239
+ proc.on('error', reject)
1240
+
1241
+ proc.on('close', (code) => {
1242
+ if (code === 0) {
1243
+ const databases = stdout
1244
+ .trim()
1245
+ .split('\n')
1246
+ .map((db) => db.trim())
1247
+ .filter((db) => db.length > 0)
1248
+ resolve(databases)
1249
+ } else {
1250
+ reject(new Error(stderr || `mysql exited with code ${code}`))
1251
+ }
1252
+ })
1253
+ })
1254
+ }
1199
1255
  }
1200
1256
 
1201
1257
  export const mysqlEngine = new MySQLEngine()
@@ -983,6 +983,66 @@ export class PostgreSQLEngine extends BaseEngine {
983
983
  })
984
984
  })
985
985
  }
986
+
987
+ /**
988
+ * List all user databases, excluding system databases (template0, template1, postgres).
989
+ */
990
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
991
+ const { port } = container
992
+ const psqlPath = await this.getPsqlPath()
993
+
994
+ // Query pg_database for all non-system databases
995
+ const sql = `SELECT datname FROM pg_database WHERE datname NOT IN ('template0', 'template1', 'postgres') AND datistemplate = false ORDER BY datname`
996
+
997
+ const args = [
998
+ '-X', // Skip ~/.psqlrc
999
+ '-h',
1000
+ '127.0.0.1',
1001
+ '-p',
1002
+ String(port),
1003
+ '-U',
1004
+ defaults.superuser,
1005
+ '-d',
1006
+ 'postgres',
1007
+ '-t', // Tuples only (no headers)
1008
+ '-A', // Unaligned output
1009
+ '-c',
1010
+ sql,
1011
+ ]
1012
+
1013
+ return new Promise((resolve, reject) => {
1014
+ const proc = spawn(psqlPath, args, {
1015
+ stdio: ['pipe', 'pipe', 'pipe'],
1016
+ })
1017
+
1018
+ let stdout = ''
1019
+ let stderr = ''
1020
+
1021
+ proc.stdout?.on('data', (data: Buffer) => {
1022
+ stdout += data.toString()
1023
+ })
1024
+ proc.stderr?.on('data', (data: Buffer) => {
1025
+ stderr += data.toString()
1026
+ })
1027
+
1028
+ proc.on('error', (err: NodeJS.ErrnoException) => {
1029
+ reject(err)
1030
+ })
1031
+
1032
+ proc.on('close', (code) => {
1033
+ if (code === 0) {
1034
+ const databases = stdout
1035
+ .trim()
1036
+ .split('\n')
1037
+ .map((db) => db.trim())
1038
+ .filter((db) => db.length > 0)
1039
+ resolve(databases)
1040
+ } else {
1041
+ reject(new Error(stderr || `psql exited with code ${code}`))
1042
+ }
1043
+ })
1044
+ })
1045
+ }
986
1046
  }
987
1047
 
988
1048
  export const postgresqlEngine = new PostgreSQLEngine()
@@ -1192,6 +1192,16 @@ export class QdrantEngine extends BaseEngine {
1192
1192
 
1193
1193
  return parseRESTAPIResult(JSON.stringify(response.data))
1194
1194
  }
1195
+
1196
+ /**
1197
+ * List databases for Qdrant.
1198
+ * Qdrant uses collections, not databases. Returns the configured database.
1199
+ */
1200
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1201
+ // Qdrant uses collections, not databases
1202
+ // Return the container's configured database
1203
+ return [container.database]
1204
+ }
1195
1205
  }
1196
1206
 
1197
1207
  export const qdrantEngine = new QdrantEngine()
@@ -1008,6 +1008,16 @@ export class QuestDBEngine extends BaseEngine {
1008
1008
  })
1009
1009
  })
1010
1010
  }
1011
+
1012
+ /**
1013
+ * List databases for QuestDB.
1014
+ * QuestDB has a single database 'qdb'. Returns the configured database.
1015
+ */
1016
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1017
+ // QuestDB has a single database 'qdb'
1018
+ // Return the container's configured database
1019
+ return [container.database]
1020
+ }
1011
1021
  }
1012
1022
 
1013
1023
  export const questdbEngine = new QuestDBEngine()
@@ -1423,6 +1423,17 @@ export class RedisEngine extends BaseEngine {
1423
1423
  proc.stdin?.end()
1424
1424
  })
1425
1425
  }
1426
+
1427
+ /**
1428
+ * List databases for Redis.
1429
+ * Redis uses numbered databases (0-15 by default), not named databases.
1430
+ * Returns the configured database number as a single-item array.
1431
+ */
1432
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1433
+ // Redis has numbered databases, not named ones
1434
+ // Return the container's configured database
1435
+ return [container.database]
1436
+ }
1426
1437
  }
1427
1438
 
1428
1439
  export const redisEngine = new RedisEngine()
@@ -696,6 +696,16 @@ export class SQLiteEngine extends BaseEngine {
696
696
  })
697
697
  })
698
698
  }
699
+
700
+ /**
701
+ * List databases for SQLite.
702
+ * SQLite is file-based with one database per file.
703
+ * Returns the configured database (file path) as a single-item array.
704
+ */
705
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
706
+ // SQLite is file-based, one database per file
707
+ return [container.database]
708
+ }
699
709
  }
700
710
 
701
711
  export const sqliteEngine = new SQLiteEngine()
@@ -1070,6 +1070,77 @@ export class SurrealDBEngine extends BaseEngine {
1070
1070
  })
1071
1071
  })
1072
1072
  }
1073
+
1074
+ /**
1075
+ * List all databases in the container's namespace.
1076
+ * SurrealDB has a namespace > database hierarchy.
1077
+ */
1078
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1079
+ const { port, version, name } = container
1080
+ const surreal = await this.getSurrealPath(version)
1081
+ const namespace = name.replace(/-/g, '_')
1082
+
1083
+ return new Promise((resolve, reject) => {
1084
+ const args = [
1085
+ 'sql',
1086
+ '--endpoint',
1087
+ `ws://127.0.0.1:${port}`,
1088
+ '--user',
1089
+ 'root',
1090
+ '--pass',
1091
+ 'root',
1092
+ '--ns',
1093
+ namespace,
1094
+ '--db',
1095
+ container.database,
1096
+ '--hide-welcome',
1097
+ '--json',
1098
+ ]
1099
+
1100
+ const proc = spawn(surreal, args, {
1101
+ stdio: ['pipe', 'pipe', 'pipe'],
1102
+ cwd: paths.getContainerPath(name, { engine: ENGINE }),
1103
+ })
1104
+
1105
+ let stdout = ''
1106
+ let stderr = ''
1107
+
1108
+ proc.stdout?.on('data', (data: Buffer) => {
1109
+ stdout += data.toString()
1110
+ })
1111
+ proc.stderr?.on('data', (data: Buffer) => {
1112
+ stderr += data.toString()
1113
+ })
1114
+
1115
+ proc.on('error', reject)
1116
+
1117
+ // Send the INFO FOR NS query to list databases
1118
+ proc.stdin?.write('INFO FOR NS;\n')
1119
+ proc.stdin?.end()
1120
+
1121
+ proc.on('close', (code) => {
1122
+ if (code !== 0) {
1123
+ reject(new Error(stderr || `surreal sql exited with code ${code}`))
1124
+ return
1125
+ }
1126
+
1127
+ try {
1128
+ // Parse JSON output - INFO FOR NS returns database info
1129
+ const results = JSON.parse(stdout)
1130
+ if (Array.isArray(results) && results[0]?.result?.databases) {
1131
+ const databases = Object.keys(results[0].result.databases)
1132
+ resolve(databases)
1133
+ } else {
1134
+ // No databases found or different format
1135
+ resolve([container.database])
1136
+ }
1137
+ } catch {
1138
+ // If parsing fails, return the configured database
1139
+ resolve([container.database])
1140
+ }
1141
+ })
1142
+ })
1143
+ }
1073
1144
  }
1074
1145
 
1075
1146
  export const surrealdbEngine = new SurrealDBEngine()
@@ -1450,6 +1450,17 @@ export class ValkeyEngine extends BaseEngine {
1450
1450
  proc.stdin?.end()
1451
1451
  })
1452
1452
  }
1453
+
1454
+ /**
1455
+ * List databases for Valkey.
1456
+ * Valkey uses numbered databases (0-15 by default), not named databases.
1457
+ * Returns the configured database number as a single-item array.
1458
+ */
1459
+ async listDatabases(container: ContainerConfig): Promise<string[]> {
1460
+ // Valkey has numbered databases, not named ones
1461
+ // Return the container's configured database
1462
+ return [container.database]
1463
+ }
1453
1464
  }
1454
1465
 
1455
1466
  export const valkeyEngine = new ValkeyEngine()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.31.2",
3
+ "version": "0.31.3",
4
4
  "author": "Bob Bass <bob@bbass.co>",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "description": "Zero-config Docker-free local database containers. Create, backup, and clone a variety of popular databases.",