spindb 0.5.3 → 0.5.5

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/cli/ui/prompts.ts CHANGED
@@ -53,7 +53,7 @@ export async function promptEngine(): Promise<string> {
53
53
 
54
54
  // Build choices from available engines
55
55
  const choices = engines.map((e) => ({
56
- name: `${engineIcons[e.name] || '🗄️'} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
56
+ name: `${engineIcons[e.name] || ''} ${e.displayName} ${chalk.gray(`(versions: ${e.supportedVersions.join(', ')})`)}`,
57
57
  value: e.name,
58
58
  short: e.displayName,
59
59
  }))
@@ -227,7 +227,7 @@ export async function promptContainerSelect(
227
227
  name: 'container',
228
228
  message,
229
229
  choices: containers.map((c) => ({
230
- name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || '🗄️'} ${c.engine} ${c.version}, port ${c.port})`)} ${
230
+ name: `${c.name} ${chalk.gray(`(${engineIcons[c.engine] || ''} ${c.engine} ${c.version}, port ${c.port})`)} ${
231
231
  c.status === 'running'
232
232
  ? chalk.green('● running')
233
233
  : chalk.gray('○ stopped')
@@ -243,19 +243,25 @@ export async function promptContainerSelect(
243
243
 
244
244
  /**
245
245
  * Prompt for database name
246
+ * @param defaultName - Default value for the database name
247
+ * @param engine - Database engine (mysql shows "schema" terminology)
246
248
  */
247
249
  export async function promptDatabaseName(
248
250
  defaultName?: string,
251
+ engine?: string,
249
252
  ): Promise<string> {
253
+ // MySQL uses "schema" terminology (database and schema are synonymous)
254
+ const label = engine === 'mysql' ? 'Database (schema) name:' : 'Database name:'
255
+
250
256
  const { database } = await inquirer.prompt<{ database: string }>([
251
257
  {
252
258
  type: 'input',
253
259
  name: 'database',
254
- message: 'Database name:',
260
+ message: label,
255
261
  default: defaultName,
256
262
  validate: (input: string) => {
257
263
  if (!input) return 'Database name is required'
258
- // PostgreSQL database naming rules
264
+ // PostgreSQL database naming rules (also valid for MySQL)
259
265
  if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(input)) {
260
266
  return 'Database name must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens'
261
267
  }
@@ -270,6 +276,93 @@ export async function promptDatabaseName(
270
276
  return database
271
277
  }
272
278
 
279
+ /**
280
+ * Prompt to select a database from a list of databases in a container
281
+ */
282
+ export async function promptDatabaseSelect(
283
+ databases: string[],
284
+ message: string = 'Select database:',
285
+ ): Promise<string> {
286
+ if (databases.length === 0) {
287
+ throw new Error('No databases available to select')
288
+ }
289
+
290
+ if (databases.length === 1) {
291
+ return databases[0]
292
+ }
293
+
294
+ const { database } = await inquirer.prompt<{ database: string }>([
295
+ {
296
+ type: 'list',
297
+ name: 'database',
298
+ message,
299
+ choices: databases.map((db, index) => ({
300
+ name: index === 0 ? `${db} ${chalk.gray('(primary)')}` : db,
301
+ value: db,
302
+ short: db,
303
+ })),
304
+ },
305
+ ])
306
+
307
+ return database
308
+ }
309
+
310
+ /**
311
+ * Prompt for backup format selection
312
+ */
313
+ export async function promptBackupFormat(
314
+ engine: string,
315
+ ): Promise<'sql' | 'dump'> {
316
+ const sqlDescription =
317
+ engine === 'mysql'
318
+ ? 'Plain SQL - human-readable, larger file'
319
+ : 'Plain SQL - human-readable, larger file'
320
+ const dumpDescription =
321
+ engine === 'mysql'
322
+ ? 'Compressed SQL (.sql.gz) - smaller file'
323
+ : 'Custom format - smaller file, faster restore'
324
+
325
+ const { format } = await inquirer.prompt<{ format: 'sql' | 'dump' }>([
326
+ {
327
+ type: 'list',
328
+ name: 'format',
329
+ message: 'Select backup format:',
330
+ choices: [
331
+ { name: `.sql ${chalk.gray(`- ${sqlDescription}`)}`, value: 'sql' },
332
+ { name: `.dump ${chalk.gray(`- ${dumpDescription}`)}`, value: 'dump' },
333
+ ],
334
+ default: 'sql',
335
+ },
336
+ ])
337
+
338
+ return format
339
+ }
340
+
341
+ /**
342
+ * Prompt for backup filename
343
+ */
344
+ export async function promptBackupFilename(
345
+ defaultName: string,
346
+ ): Promise<string> {
347
+ const { filename } = await inquirer.prompt<{ filename: string }>([
348
+ {
349
+ type: 'input',
350
+ name: 'filename',
351
+ message: 'Backup filename (without extension):',
352
+ default: defaultName,
353
+ validate: (input: string) => {
354
+ if (!input) return 'Filename is required'
355
+ if (!/^[a-zA-Z0-9_-]+$/.test(input)) {
356
+ return 'Filename must contain only letters, numbers, underscores, and hyphens'
357
+ }
358
+ return true
359
+ },
360
+ },
361
+ ])
362
+
363
+ return filename
364
+ }
365
+
273
366
  export type CreateOptions = {
274
367
  name: string
275
368
  engine: string
@@ -282,12 +375,12 @@ export type CreateOptions = {
282
375
  * Full interactive create flow
283
376
  */
284
377
  export async function promptCreateOptions(): Promise<CreateOptions> {
285
- console.log(chalk.cyan('\n 🗄️ Create New Database Container\n'))
378
+ console.log(chalk.cyan('\n Create New Database Container\n'))
286
379
 
287
380
  const engine = await promptEngine()
288
381
  const version = await promptVersion(engine)
289
382
  const name = await promptContainerName()
290
- const database = await promptDatabaseName(name) // Default to container name
383
+ const database = await promptDatabaseName(name, engine) // Default to container name
291
384
 
292
385
  // Get engine-specific default port
293
386
  const engineDefaults = getEngineDefaults(engine)
package/cli/ui/theme.ts CHANGED
@@ -40,7 +40,7 @@ export const theme = {
40
40
  info: chalk.blue('ℹ'),
41
41
  arrow: chalk.cyan('→'),
42
42
  bullet: chalk.gray('•'),
43
- database: '🗄️',
43
+ database: '',
44
44
  postgres: '🐘',
45
45
  },
46
46
  }
@@ -155,3 +155,14 @@ export function connectionBox(
155
155
 
156
156
  return box(lines)
157
157
  }
158
+
159
+ /**
160
+ * Format bytes into human-readable format (B, KB, MB, GB)
161
+ */
162
+ export function formatBytes(bytes: number): string {
163
+ if (bytes === 0) return '0 B'
164
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
165
+ const i = Math.floor(Math.log(bytes) / Math.log(1024))
166
+ const value = bytes / Math.pow(1024, i)
167
+ return `${value.toFixed(1)} ${units[i]}`
168
+ }
@@ -289,6 +289,98 @@ const mysqlDependencies: EngineDependencies = {
289
289
  ],
290
290
  }
291
291
 
292
+ // =============================================================================
293
+ // Optional Tools (engine-agnostic)
294
+ // =============================================================================
295
+
296
+ /**
297
+ * usql - Universal SQL client
298
+ * Works with PostgreSQL, MySQL, SQLite, and 20+ other databases
299
+ * https://github.com/xo/usql
300
+ */
301
+ export const usqlDependency: Dependency = {
302
+ name: 'usql',
303
+ binary: 'usql',
304
+ description:
305
+ 'Universal SQL client with auto-completion, syntax highlighting, and multi-database support',
306
+ packages: {
307
+ brew: {
308
+ package: 'xo/xo/usql',
309
+ preInstall: ['brew tap xo/xo'],
310
+ },
311
+ // Note: usql is not in standard Linux package repos, must use manual install
312
+ },
313
+ manualInstall: {
314
+ darwin: [
315
+ 'Install Homebrew: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
316
+ 'Then run: brew tap xo/xo && brew install xo/xo/usql',
317
+ ],
318
+ linux: [
319
+ 'Download from GitHub releases: https://github.com/xo/usql/releases',
320
+ 'Extract and move to PATH: sudo mv usql /usr/local/bin/',
321
+ 'Or install via Go: go install github.com/xo/usql@latest',
322
+ ],
323
+ },
324
+ }
325
+
326
+ /**
327
+ * pgcli - PostgreSQL CLI with auto-completion and syntax highlighting
328
+ * https://github.com/dbcli/pgcli
329
+ */
330
+ export const pgcliDependency: Dependency = {
331
+ name: 'pgcli',
332
+ binary: 'pgcli',
333
+ description:
334
+ 'PostgreSQL CLI with intelligent auto-completion and syntax highlighting',
335
+ packages: {
336
+ brew: { package: 'pgcli' },
337
+ apt: { package: 'pgcli' },
338
+ dnf: { package: 'pgcli' },
339
+ yum: { package: 'pgcli' },
340
+ pacman: { package: 'pgcli' },
341
+ },
342
+ manualInstall: {
343
+ darwin: [
344
+ 'Install with Homebrew: brew install pgcli',
345
+ 'Or with pip: pip install pgcli',
346
+ ],
347
+ linux: [
348
+ 'Debian/Ubuntu: sudo apt install pgcli',
349
+ 'Fedora: sudo dnf install pgcli',
350
+ 'Or with pip: pip install pgcli',
351
+ ],
352
+ },
353
+ }
354
+
355
+ /**
356
+ * mycli - MySQL CLI with auto-completion and syntax highlighting
357
+ * https://github.com/dbcli/mycli
358
+ */
359
+ export const mycliDependency: Dependency = {
360
+ name: 'mycli',
361
+ binary: 'mycli',
362
+ description:
363
+ 'MySQL/MariaDB CLI with intelligent auto-completion and syntax highlighting',
364
+ packages: {
365
+ brew: { package: 'mycli' },
366
+ apt: { package: 'mycli' },
367
+ dnf: { package: 'mycli' },
368
+ yum: { package: 'mycli' },
369
+ pacman: { package: 'mycli' },
370
+ },
371
+ manualInstall: {
372
+ darwin: [
373
+ 'Install with Homebrew: brew install mycli',
374
+ 'Or with pip: pip install mycli',
375
+ ],
376
+ linux: [
377
+ 'Debian/Ubuntu: sudo apt install mycli',
378
+ 'Fedora: sudo dnf install mycli',
379
+ 'Or with pip: pip install mycli',
380
+ ],
381
+ },
382
+ }
383
+
292
384
  // =============================================================================
293
385
  // Registry
294
386
  // =============================================================================
@@ -54,26 +54,19 @@ export class BinaryManager {
54
54
  return version
55
55
  }
56
56
 
57
- /**
58
- * Get major version from any version string (e.g., "17.7.0" -> "17", "16" -> "16")
59
- * Used for directory naming to ensure one directory per major version.
60
- */
61
- getMajorVersion(version: string): string {
62
- return version.split('.')[0]
63
- }
64
-
65
57
  /**
66
58
  * Check if binaries for a specific version are already installed
59
+ * Uses full version for directory naming (e.g., postgresql-17.7.0-darwin-arm64)
67
60
  */
68
61
  async isInstalled(
69
62
  version: string,
70
63
  platform: string,
71
64
  arch: string,
72
65
  ): Promise<boolean> {
73
- const majorVersion = this.getMajorVersion(version)
66
+ const fullVersion = this.getFullVersion(version)
74
67
  const binPath = paths.getBinaryPath({
75
68
  engine: 'postgresql',
76
- version: majorVersion,
69
+ version: fullVersion,
77
70
  platform,
78
71
  arch,
79
72
  })
@@ -122,15 +115,15 @@ export class BinaryManager {
122
115
  arch: string,
123
116
  onProgress?: ProgressCallback,
124
117
  ): Promise<string> {
125
- const majorVersion = this.getMajorVersion(version)
118
+ const fullVersion = this.getFullVersion(version)
126
119
  const url = this.getDownloadUrl(version, platform, arch)
127
120
  const binPath = paths.getBinaryPath({
128
121
  engine: 'postgresql',
129
- version: majorVersion,
122
+ version: fullVersion,
130
123
  platform,
131
124
  arch,
132
125
  })
133
- const tempDir = join(paths.bin, `temp-${majorVersion}-${platform}-${arch}`)
126
+ const tempDir = join(paths.bin, `temp-${fullVersion}-${platform}-${arch}`)
134
127
  const jarFile = join(tempDir, 'postgres.jar')
135
128
 
136
129
  // Ensure directories exist
@@ -210,10 +203,10 @@ export class BinaryManager {
210
203
  platform: string,
211
204
  arch: string,
212
205
  ): Promise<boolean> {
213
- const majorVersion = this.getMajorVersion(version)
206
+ const fullVersion = this.getFullVersion(version)
214
207
  const binPath = paths.getBinaryPath({
215
208
  engine: 'postgresql',
216
- version: majorVersion,
209
+ version: fullVersion,
217
210
  platform,
218
211
  arch,
219
212
  })
@@ -267,10 +260,10 @@ export class BinaryManager {
267
260
  arch: string,
268
261
  binary: string,
269
262
  ): string {
270
- const majorVersion = this.getMajorVersion(version)
263
+ const fullVersion = this.getFullVersion(version)
271
264
  const binPath = paths.getBinaryPath({
272
265
  engine: 'postgresql',
273
- version: majorVersion,
266
+ version: fullVersion,
274
267
  platform,
275
268
  arch,
276
269
  })
@@ -286,7 +279,7 @@ export class BinaryManager {
286
279
  arch: string,
287
280
  onProgress?: ProgressCallback,
288
281
  ): Promise<string> {
289
- const majorVersion = this.getMajorVersion(version)
282
+ const fullVersion = this.getFullVersion(version)
290
283
  if (await this.isInstalled(version, platform, arch)) {
291
284
  onProgress?.({
292
285
  stage: 'cached',
@@ -294,7 +287,7 @@ export class BinaryManager {
294
287
  })
295
288
  return paths.getBinaryPath({
296
289
  engine: 'postgresql',
297
- version: majorVersion,
290
+ version: fullVersion,
298
291
  platform,
299
292
  arch,
300
293
  })
@@ -18,6 +18,32 @@ const DEFAULT_CONFIG: SpinDBConfig = {
18
18
  binaries: {},
19
19
  }
20
20
 
21
+ // Cache staleness threshold (7 days in milliseconds)
22
+ const CACHE_STALENESS_MS = 7 * 24 * 60 * 60 * 1000
23
+
24
+ // All tools organized by category
25
+ const POSTGRESQL_TOOLS: BinaryTool[] = [
26
+ 'psql',
27
+ 'pg_dump',
28
+ 'pg_restore',
29
+ 'pg_basebackup',
30
+ ]
31
+
32
+ const MYSQL_TOOLS: BinaryTool[] = [
33
+ 'mysql',
34
+ 'mysqldump',
35
+ 'mysqladmin',
36
+ 'mysqld',
37
+ ]
38
+
39
+ const ENHANCED_SHELLS: BinaryTool[] = ['pgcli', 'mycli', 'usql']
40
+
41
+ const ALL_TOOLS: BinaryTool[] = [
42
+ ...POSTGRESQL_TOOLS,
43
+ ...MYSQL_TOOLS,
44
+ ...ENHANCED_SHELLS,
45
+ ]
46
+
21
47
  export class ConfigManager {
22
48
  private config: SpinDBConfig | null = null
23
49
 
@@ -170,44 +196,55 @@ export class ConfigManager {
170
196
  }
171
197
 
172
198
  /**
173
- * Get common installation paths for PostgreSQL client tools
199
+ * Get common installation paths for database tools
174
200
  */
175
201
  private getCommonBinaryPaths(tool: BinaryTool): string[] {
176
- const paths: string[] = []
177
-
178
- // Homebrew (macOS)
179
- paths.push(`/opt/homebrew/bin/${tool}`)
180
- paths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
181
- paths.push(`/usr/local/bin/${tool}`)
182
- paths.push(`/usr/local/opt/libpq/bin/${tool}`)
183
-
184
- // Postgres.app (macOS)
185
- paths.push(
186
- `/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
187
- )
188
-
189
- // Linux common paths
190
- paths.push(`/usr/bin/${tool}`)
191
- paths.push(`/usr/lib/postgresql/16/bin/${tool}`)
192
- paths.push(`/usr/lib/postgresql/15/bin/${tool}`)
193
- paths.push(`/usr/lib/postgresql/14/bin/${tool}`)
194
-
195
- return paths
202
+ const commonPaths: string[] = []
203
+
204
+ // Homebrew (macOS ARM)
205
+ commonPaths.push(`/opt/homebrew/bin/${tool}`)
206
+ // Homebrew (macOS Intel)
207
+ commonPaths.push(`/usr/local/bin/${tool}`)
208
+
209
+ // PostgreSQL-specific paths
210
+ if (POSTGRESQL_TOOLS.includes(tool) || tool === 'pgcli') {
211
+ commonPaths.push(`/opt/homebrew/opt/libpq/bin/${tool}`)
212
+ commonPaths.push(`/usr/local/opt/libpq/bin/${tool}`)
213
+ // Postgres.app (macOS)
214
+ commonPaths.push(
215
+ `/Applications/Postgres.app/Contents/Versions/latest/bin/${tool}`,
216
+ )
217
+ // Linux PostgreSQL paths
218
+ commonPaths.push(`/usr/lib/postgresql/17/bin/${tool}`)
219
+ commonPaths.push(`/usr/lib/postgresql/16/bin/${tool}`)
220
+ commonPaths.push(`/usr/lib/postgresql/15/bin/${tool}`)
221
+ commonPaths.push(`/usr/lib/postgresql/14/bin/${tool}`)
222
+ }
223
+
224
+ // MySQL-specific paths
225
+ if (MYSQL_TOOLS.includes(tool) || tool === 'mycli') {
226
+ commonPaths.push(`/opt/homebrew/opt/mysql/bin/${tool}`)
227
+ commonPaths.push(`/opt/homebrew/opt/mysql-client/bin/${tool}`)
228
+ commonPaths.push(`/usr/local/opt/mysql/bin/${tool}`)
229
+ commonPaths.push(`/usr/local/opt/mysql-client/bin/${tool}`)
230
+ // Linux MySQL/MariaDB paths
231
+ commonPaths.push(`/usr/bin/${tool}`)
232
+ commonPaths.push(`/usr/sbin/${tool}`)
233
+ }
234
+
235
+ // General Linux paths
236
+ commonPaths.push(`/usr/bin/${tool}`)
237
+
238
+ return commonPaths
196
239
  }
197
240
 
198
241
  /**
199
242
  * Detect all available client tools on the system
200
243
  */
201
244
  async detectAllTools(): Promise<Map<BinaryTool, string>> {
202
- const tools: BinaryTool[] = [
203
- 'psql',
204
- 'pg_dump',
205
- 'pg_restore',
206
- 'pg_basebackup',
207
- ]
208
245
  const found = new Map<BinaryTool, string>()
209
246
 
210
- for (const tool of tools) {
247
+ for (const tool of ALL_TOOLS) {
211
248
  const path = await this.detectSystemBinary(tool)
212
249
  if (path) {
213
250
  found.set(tool, path)
@@ -219,18 +256,19 @@ export class ConfigManager {
219
256
 
220
257
  /**
221
258
  * Initialize config by detecting all available tools
259
+ * Groups results by category for better display
222
260
  */
223
- async initialize(): Promise<{ found: BinaryTool[]; missing: BinaryTool[] }> {
224
- const tools: BinaryTool[] = [
225
- 'psql',
226
- 'pg_dump',
227
- 'pg_restore',
228
- 'pg_basebackup',
229
- ]
261
+ async initialize(): Promise<{
262
+ found: BinaryTool[]
263
+ missing: BinaryTool[]
264
+ postgresql: { found: BinaryTool[]; missing: BinaryTool[] }
265
+ mysql: { found: BinaryTool[]; missing: BinaryTool[] }
266
+ enhanced: { found: BinaryTool[]; missing: BinaryTool[] }
267
+ }> {
230
268
  const found: BinaryTool[] = []
231
269
  const missing: BinaryTool[] = []
232
270
 
233
- for (const tool of tools) {
271
+ for (const tool of ALL_TOOLS) {
234
272
  const path = await this.getBinaryPath(tool)
235
273
  if (path) {
236
274
  found.push(tool)
@@ -239,7 +277,57 @@ export class ConfigManager {
239
277
  }
240
278
  }
241
279
 
242
- return { found, missing }
280
+ return {
281
+ found,
282
+ missing,
283
+ postgresql: {
284
+ found: found.filter((t) => POSTGRESQL_TOOLS.includes(t)),
285
+ missing: missing.filter((t) => POSTGRESQL_TOOLS.includes(t)),
286
+ },
287
+ mysql: {
288
+ found: found.filter((t) => MYSQL_TOOLS.includes(t)),
289
+ missing: missing.filter((t) => MYSQL_TOOLS.includes(t)),
290
+ },
291
+ enhanced: {
292
+ found: found.filter((t) => ENHANCED_SHELLS.includes(t)),
293
+ missing: missing.filter((t) => ENHANCED_SHELLS.includes(t)),
294
+ },
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Check if the config cache is stale (older than 7 days)
300
+ */
301
+ async isStale(): Promise<boolean> {
302
+ const config = await this.load()
303
+ if (!config.updatedAt) {
304
+ return true
305
+ }
306
+
307
+ const updatedAt = new Date(config.updatedAt).getTime()
308
+ const now = Date.now()
309
+ return now - updatedAt > CACHE_STALENESS_MS
310
+ }
311
+
312
+ /**
313
+ * Refresh all tool paths if cache is stale
314
+ * Returns true if refresh was performed
315
+ */
316
+ async refreshIfStale(): Promise<boolean> {
317
+ if (await this.isStale()) {
318
+ await this.refreshAllBinaries()
319
+ return true
320
+ }
321
+ return false
322
+ }
323
+
324
+ /**
325
+ * Force refresh all binary paths
326
+ * Re-detects all tools and updates versions
327
+ */
328
+ async refreshAllBinaries(): Promise<void> {
329
+ await this.clearAllBinaries()
330
+ await this.initialize()
243
331
  }
244
332
 
245
333
  /**
@@ -269,3 +357,11 @@ export class ConfigManager {
269
357
  }
270
358
 
271
359
  export const configManager = new ConfigManager()
360
+
361
+ // Export tool categories for use in commands
362
+ export {
363
+ POSTGRESQL_TOOLS,
364
+ MYSQL_TOOLS,
365
+ ENHANCED_SHELLS,
366
+ ALL_TOOLS,
367
+ }
@@ -51,6 +51,7 @@ export class ContainerManager {
51
51
  version,
52
52
  port,
53
53
  database,
54
+ databases: [database],
54
55
  created: new Date().toISOString(),
55
56
  status: 'created',
56
57
  }
@@ -63,6 +64,7 @@ export class ContainerManager {
63
64
  /**
64
65
  * Get container configuration
65
66
  * If engine is not provided, searches all engine directories
67
+ * Automatically migrates old schemas to include databases array
66
68
  */
67
69
  async getConfig(
68
70
  name: string,
@@ -77,7 +79,8 @@ export class ContainerManager {
77
79
  return null
78
80
  }
79
81
  const content = await readFile(configPath, 'utf8')
80
- return JSON.parse(content) as ContainerConfig
82
+ const config = JSON.parse(content) as ContainerConfig
83
+ return this.migrateConfig(config)
81
84
  }
82
85
 
83
86
  // Search all engine directories
@@ -86,13 +89,43 @@ export class ContainerManager {
86
89
  const configPath = paths.getContainerConfigPath(name, { engine: eng })
87
90
  if (existsSync(configPath)) {
88
91
  const content = await readFile(configPath, 'utf8')
89
- return JSON.parse(content) as ContainerConfig
92
+ const config = JSON.parse(content) as ContainerConfig
93
+ return this.migrateConfig(config)
90
94
  }
91
95
  }
92
96
 
93
97
  return null
94
98
  }
95
99
 
100
+ /**
101
+ * Migrate old container configs to include databases array
102
+ * Ensures primary database is always in the databases array
103
+ */
104
+ private async migrateConfig(
105
+ config: ContainerConfig,
106
+ ): Promise<ContainerConfig> {
107
+ let needsSave = false
108
+
109
+ // If databases array is missing, create it with the primary database
110
+ if (!config.databases) {
111
+ config.databases = [config.database]
112
+ needsSave = true
113
+ }
114
+
115
+ // Ensure primary database is in the array
116
+ if (!config.databases.includes(config.database)) {
117
+ config.databases = [config.database, ...config.databases]
118
+ needsSave = true
119
+ }
120
+
121
+ // Save if we made changes
122
+ if (needsSave) {
123
+ await this.saveConfig(config.name, { engine: config.engine }, config)
124
+ }
125
+
126
+ return config
127
+ }
128
+
96
129
  /**
97
130
  * Save container configuration
98
131
  */
@@ -333,6 +366,47 @@ export class ContainerManager {
333
366
  return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
334
367
  }
335
368
 
369
+ /**
370
+ * Add a database to the container's databases array
371
+ */
372
+ async addDatabase(containerName: string, database: string): Promise<void> {
373
+ const config = await this.getConfig(containerName)
374
+ if (!config) {
375
+ throw new Error(`Container "${containerName}" not found`)
376
+ }
377
+
378
+ // Ensure databases array exists
379
+ if (!config.databases) {
380
+ config.databases = [config.database]
381
+ }
382
+
383
+ // Add if not already present
384
+ if (!config.databases.includes(database)) {
385
+ config.databases.push(database)
386
+ await this.saveConfig(containerName, { engine: config.engine }, config)
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Remove a database from the container's databases array
392
+ */
393
+ async removeDatabase(containerName: string, database: string): Promise<void> {
394
+ const config = await this.getConfig(containerName)
395
+ if (!config) {
396
+ throw new Error(`Container "${containerName}" not found`)
397
+ }
398
+
399
+ // Don't remove the primary database from the array
400
+ if (database === config.database) {
401
+ throw new Error(`Cannot remove primary database "${database}" from tracking`)
402
+ }
403
+
404
+ if (config.databases) {
405
+ config.databases = config.databases.filter((db) => db !== database)
406
+ await this.saveConfig(containerName, { engine: config.engine }, config)
407
+ }
408
+ }
409
+
336
410
  /**
337
411
  * Get connection string for a container
338
412
  * Delegates to the appropriate engine