spindb 0.30.6 → 0.31.0

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/README.md CHANGED
@@ -386,6 +386,36 @@ Generated files:
386
386
  - `entrypoint.sh` - Startup script
387
387
  - `README.md` - Instructions
388
388
 
389
+ ### Deploying Your Container
390
+
391
+ **SpinDB doesn't require Docker for local development**, but it can repackage your database as a Docker image for deployment to cloud servers, EC2 instances, Kubernetes clusters, or any Docker-compatible environment.
392
+
393
+ ```bash
394
+ # Export your local database to Docker
395
+ spindb export docker mydb -o ./mydb-deploy
396
+
397
+ # Build and run
398
+ cd ./mydb-deploy
399
+ docker compose build --no-cache
400
+ docker compose up -d
401
+
402
+ # Connect from host (credentials in .env)
403
+ source .env
404
+ psql "postgresql://$SPINDB_USER:$SPINDB_PASSWORD@localhost:$PORT/$DATABASE"
405
+ ```
406
+
407
+ **Schema-only vs Full Data:**
408
+ ```bash
409
+ spindb export docker mydb # Include all data (default)
410
+ spindb export docker mydb --no-data # Schema only (empty tables)
411
+ ```
412
+
413
+ > **Development Tool Notice:** SpinDB is currently a development tool. While Docker exports include TLS encryption and authentication, they are intended for staging and testing—not production workloads. For production databases, consider managed services.
414
+
415
+ **Future Export Options:** Additional export targets are planned for future releases, including direct deployment to managed database services like Neon, Supabase, and PlanetScale.
416
+
417
+ See [DEPLOY.md](DEPLOY.md) for comprehensive deployment documentation.
418
+
389
419
  ### Container Management
390
420
 
391
421
  ```bash
@@ -1047,6 +1077,8 @@ See [CLAUDE.md](CLAUDE.md) for AI-assisted development context.
1047
1077
 
1048
1078
  See [ENGINE_CHECKLIST.md](ENGINE_CHECKLIST.md) for adding new database engines.
1049
1079
 
1080
+ See [USE_CASES.md](USE_CASES.md) for detailed use cases and infrastructure opportunities.
1081
+
1050
1082
  ---
1051
1083
 
1052
1084
  ## Acknowledgments
@@ -5,7 +5,14 @@ import { containerManager } from '../../core/container-manager'
5
5
  import { processManager } from '../../core/process-manager'
6
6
  import { getEngine } from '../../engines'
7
7
  import { platformService } from '../../core/platform-service'
8
- import { exportToDocker, getExportBackupPath } from '../../core/docker-exporter'
8
+ import {
9
+ exportToDocker,
10
+ getExportBackupPath,
11
+ dockerExportExists,
12
+ getDockerConnectionString,
13
+ getDockerCredentials,
14
+ getDefaultDockerExportPath,
15
+ } from '../../core/docker-exporter'
9
16
  import { promptContainerSelect, promptConfirm } from '../ui/prompts'
10
17
  import { createSpinner } from '../ui/spinner'
11
18
  import { uiSuccess, uiError, uiWarning, box, formatBytes } from '../ui/theme'
@@ -360,3 +367,178 @@ export const exportCommand = new Command('export')
360
367
  },
361
368
  ),
362
369
  )
370
+ .addCommand(
371
+ new Command('docker-url')
372
+ .description('Get connection string for an existing Docker export')
373
+ .argument('[container]', 'Container name')
374
+ .option('-c, --copy', 'Copy connection string to clipboard')
375
+ .option('-j, --json', 'Output result as JSON')
376
+ .option(
377
+ '--host <hostname>',
378
+ 'Override hostname in connection string',
379
+ 'localhost',
380
+ )
381
+ .action(
382
+ async (
383
+ containerArg: string | undefined,
384
+ options: {
385
+ copy?: boolean
386
+ json?: boolean
387
+ host?: string
388
+ },
389
+ ) => {
390
+ try {
391
+ let containerName = containerArg
392
+
393
+ // Select container if not provided
394
+ if (!containerName) {
395
+ if (options.json) {
396
+ console.log(
397
+ JSON.stringify({ error: 'Container name is required' }),
398
+ )
399
+ process.exit(1)
400
+ }
401
+
402
+ const containers = await containerManager.list()
403
+
404
+ if (containers.length === 0) {
405
+ console.log(
406
+ uiWarning(
407
+ 'No containers found. Create one with: spindb create',
408
+ ),
409
+ )
410
+ return
411
+ }
412
+
413
+ // Filter to containers with Docker exports
414
+ const containersWithExports = containers.filter((c) =>
415
+ dockerExportExists(c.name, c.engine),
416
+ )
417
+
418
+ if (containersWithExports.length === 0) {
419
+ console.log(
420
+ uiWarning(
421
+ 'No Docker exports found. Export a container first with: spindb export docker <container>',
422
+ ),
423
+ )
424
+ return
425
+ }
426
+
427
+ const selected = await promptContainerSelect(
428
+ containersWithExports,
429
+ 'Select container:',
430
+ )
431
+ if (!selected) return
432
+ containerName = selected
433
+ }
434
+
435
+ // Get container config
436
+ const config = await containerManager.getConfig(containerName)
437
+ if (!config) {
438
+ if (options.json) {
439
+ console.log(
440
+ JSON.stringify({
441
+ error: `Container "${containerName}" not found`,
442
+ }),
443
+ )
444
+ } else {
445
+ console.error(uiError(`Container "${containerName}" not found`))
446
+ }
447
+ process.exit(1)
448
+ }
449
+
450
+ // Check if Docker export exists
451
+ if (!dockerExportExists(containerName, config.engine)) {
452
+ const exportPath = getDefaultDockerExportPath(
453
+ containerName,
454
+ config.engine,
455
+ )
456
+ if (options.json) {
457
+ console.log(
458
+ JSON.stringify({
459
+ error: `No Docker export found for "${containerName}". Export first with: spindb export docker ${containerName}`,
460
+ exportPath,
461
+ }),
462
+ )
463
+ } else {
464
+ console.error(
465
+ uiError(
466
+ `No Docker export found for "${containerName}".\nExport first with: spindb export docker ${containerName}`,
467
+ ),
468
+ )
469
+ }
470
+ process.exit(1)
471
+ }
472
+
473
+ // Get credentials and connection string
474
+ const credentials = await getDockerCredentials(
475
+ containerName,
476
+ config.engine,
477
+ )
478
+ const connectionString = await getDockerConnectionString(
479
+ containerName,
480
+ config.engine,
481
+ { host: options.host },
482
+ )
483
+
484
+ if (!connectionString || !credentials) {
485
+ if (options.json) {
486
+ console.log(
487
+ JSON.stringify({
488
+ error: 'Could not read Docker export credentials',
489
+ }),
490
+ )
491
+ } else {
492
+ console.error(
493
+ uiError('Could not read Docker export credentials'),
494
+ )
495
+ }
496
+ process.exit(1)
497
+ }
498
+
499
+ // Copy to clipboard if requested
500
+ if (options.copy) {
501
+ const copied =
502
+ await platformService.copyToClipboard(connectionString)
503
+ if (!options.json) {
504
+ if (copied) {
505
+ console.log(
506
+ uiSuccess('Connection string copied to clipboard'),
507
+ )
508
+ } else {
509
+ console.log(uiWarning('Could not copy to clipboard'))
510
+ }
511
+ }
512
+ }
513
+
514
+ // Output
515
+ if (options.json) {
516
+ console.log(
517
+ JSON.stringify({
518
+ connectionString,
519
+ username: credentials.username,
520
+ password: credentials.password,
521
+ host: options.host || 'localhost',
522
+ port: credentials.port,
523
+ database: credentials.database,
524
+ engine: credentials.engine,
525
+ version: credentials.version,
526
+ }),
527
+ )
528
+ } else if (!options.copy) {
529
+ // Only print connection string if not copying (to allow piping)
530
+ console.log(connectionString)
531
+ }
532
+ } catch (error) {
533
+ const e = error as Error
534
+
535
+ if (options.json) {
536
+ console.log(JSON.stringify({ error: e.message }))
537
+ } else {
538
+ console.error(uiError(e.message))
539
+ }
540
+ process.exit(1)
541
+ }
542
+ },
543
+ ),
544
+ )
@@ -60,6 +60,8 @@ import {
60
60
  import {
61
61
  exportToDocker,
62
62
  getExportBackupPath,
63
+ dockerExportExists,
64
+ getDockerConnectionString,
63
65
  } from '../../../core/docker-exporter'
64
66
  import { getDefaultFormat } from '../../../config/backup-formats'
65
67
  import { Engine, isFileBasedEngine } from '../../../types'
@@ -573,7 +575,11 @@ export async function handleList(
573
575
  const allChoices: (FilterableChoice | inquirer.Separator)[] = [
574
576
  // Show toggle hint at top when server-based containers exist
575
577
  ...(hasServerContainers
576
- ? [new inquirer.Separator(chalk.cyan('── [Shift+Tab] toggle start/stop ──'))]
578
+ ? [
579
+ new inquirer.Separator(
580
+ chalk.cyan('── [Shift+Tab] toggle start/stop ──'),
581
+ ),
582
+ ]
577
583
  : []),
578
584
  ...containerChoices,
579
585
  new inquirer.Separator(),
@@ -820,8 +826,8 @@ export async function showContainerSubmenu(
820
826
  // Copy connection string - requires database selection for multi-db containers
821
827
  actionChoices.push(
822
828
  canDoDbAction
823
- ? { name: `${chalk.magenta('')} Copy connection string`, value: 'copy' }
824
- : disabledItem('', 'Copy connection string'),
829
+ ? { name: `${chalk.green('')} Copy connection string`, value: 'copy' }
830
+ : disabledItem('', 'Copy connection string'),
825
831
  )
826
832
 
827
833
  // Backup - requires database selection for multi-db containers
@@ -1746,30 +1752,85 @@ async function handleDelete(containerName: string): Promise<void> {
1746
1752
  deleteSpinner.succeed(`Container "${containerName}" deleted`)
1747
1753
  }
1748
1754
 
1755
+ async function isDockerContainerRunning(
1756
+ containerName: string,
1757
+ ): Promise<boolean> {
1758
+ try {
1759
+ const { execSync } = await import('child_process')
1760
+ const result = execSync(
1761
+ `docker ps --filter "name=spindb-${containerName}" --format "{{.Names}}"`,
1762
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
1763
+ )
1764
+ return result.trim().includes(`spindb-${containerName}`)
1765
+ } catch {
1766
+ return false
1767
+ }
1768
+ }
1769
+
1749
1770
  async function handleExportSubmenu(
1750
1771
  containerName: string,
1751
1772
  databases: string[],
1752
1773
  showMainMenu: () => Promise<void>,
1753
1774
  ): Promise<void> {
1775
+ const config = await containerManager.getConfig(containerName)
1776
+ if (!config) {
1777
+ console.log(uiError(`Container "${containerName}" not found`))
1778
+ await pressEnterToContinue()
1779
+ return
1780
+ }
1781
+
1782
+ // Check if Docker export already exists
1783
+ const hasDockerExport = dockerExportExists(containerName, config.engine)
1784
+
1785
+ // Check if Docker container is running (only if export exists)
1786
+ let dockerRunning = false
1787
+ if (hasDockerExport) {
1788
+ dockerRunning = await isDockerContainerRunning(containerName)
1789
+ }
1790
+
1754
1791
  console.log()
1755
1792
  console.log(header('Export'))
1756
1793
  console.log()
1757
1794
 
1795
+ // Build choices based on whether export exists
1796
+ const choices: MenuChoice[] = []
1797
+
1798
+ if (hasDockerExport) {
1799
+ // Export exists: show option to get connection string with running status
1800
+ const runningStatus = dockerRunning
1801
+ ? chalk.green('running')
1802
+ : chalk.gray('not running')
1803
+ choices.push({
1804
+ name: `${chalk.green('⎘')} Get Docker connection string ${chalk.gray(`(${runningStatus})`)}`,
1805
+ value: 'docker-url',
1806
+ })
1807
+ choices.push({
1808
+ name: `${chalk.cyan('▣')} Docker ${chalk.gray('(Re-export - invalidates original credentials)')}`,
1809
+ value: 'docker',
1810
+ })
1811
+ } else {
1812
+ // No export: just show Docker option
1813
+ choices.push({ name: `${chalk.cyan('▣')} Docker`, value: 'docker' })
1814
+ }
1815
+
1816
+ choices.push(new inquirer.Separator())
1817
+ choices.push({ name: `${chalk.blue('←')} Back`, value: 'back' })
1818
+ choices.push({ name: `${chalk.blue('⌂')} Back to main menu`, value: 'home' })
1819
+
1758
1820
  const { action } = await escapeablePrompt<{ action: string }>([
1759
1821
  {
1760
1822
  type: 'list',
1761
1823
  name: 'action',
1762
1824
  message: 'Export format:',
1763
- choices: [
1764
- { name: `${chalk.cyan('▣')} Docker`, value: 'docker' },
1765
- new inquirer.Separator(),
1766
- { name: `${chalk.blue('←')} Back`, value: 'back' },
1767
- { name: `${chalk.blue('⌂')} Back to main menu`, value: 'home' },
1768
- ],
1825
+ choices,
1769
1826
  },
1770
1827
  ])
1771
1828
 
1772
1829
  switch (action) {
1830
+ case 'docker-url':
1831
+ await handleGetDockerConnectionString(containerName, config.engine)
1832
+ await handleExportSubmenu(containerName, databases, showMainMenu)
1833
+ return
1773
1834
  case 'docker':
1774
1835
  await handleExportDocker(containerName, databases, showMainMenu)
1775
1836
  return
@@ -1782,6 +1843,39 @@ async function handleExportSubmenu(
1782
1843
  }
1783
1844
  }
1784
1845
 
1846
+ async function handleGetDockerConnectionString(
1847
+ containerName: string,
1848
+ engine: Engine,
1849
+ ): Promise<void> {
1850
+ const connectionString = await getDockerConnectionString(
1851
+ containerName,
1852
+ engine,
1853
+ )
1854
+
1855
+ if (!connectionString) {
1856
+ console.log()
1857
+ console.log(uiError('Could not read Docker export credentials'))
1858
+ await pressEnterToContinue()
1859
+ return
1860
+ }
1861
+
1862
+ // Copy to clipboard
1863
+ const copied = await platformService.copyToClipboard(connectionString)
1864
+
1865
+ console.log()
1866
+ if (copied) {
1867
+ console.log(uiSuccess('Connection string copied to clipboard'))
1868
+ } else {
1869
+ console.log(uiWarning('Could not copy to clipboard'))
1870
+ }
1871
+ console.log()
1872
+ console.log(chalk.gray(' Connection string:'))
1873
+ console.log(chalk.cyan(` ${connectionString}`))
1874
+ console.log()
1875
+
1876
+ await pressEnterToContinue()
1877
+ }
1878
+
1785
1879
  async function handleExportDocker(
1786
1880
  containerName: string,
1787
1881
  databases: string[],
@@ -2,7 +2,10 @@ import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import inquirer from 'inquirer'
4
4
  import { containerManager } from '../../../core/container-manager'
5
- import { updateManager, type UpdateCheckResult } from '../../../core/update-manager'
5
+ import {
6
+ updateManager,
7
+ type UpdateCheckResult,
8
+ } from '../../../core/update-manager'
6
9
  import {
7
10
  promptInstallDependencies,
8
11
  enableGlobalEscape,
@@ -158,9 +161,13 @@ async function handleUpdate(): Promise<void> {
158
161
  return
159
162
  }
160
163
 
161
- console.log(chalk.gray(` Current version: ${cachedUpdateResult.currentVersion}`))
162
164
  console.log(
163
- chalk.gray(` Latest version: ${chalk.green(cachedUpdateResult.latestVersion)}`),
165
+ chalk.gray(` Current version: ${cachedUpdateResult.currentVersion}`),
166
+ )
167
+ console.log(
168
+ chalk.gray(
169
+ ` Latest version: ${chalk.green(cachedUpdateResult.latestVersion)}`,
170
+ ),
164
171
  )
165
172
  console.log()
166
173
 
@@ -173,7 +180,9 @@ async function handleUpdate(): Promise<void> {
173
180
  spinner.succeed('Update complete')
174
181
  console.log()
175
182
  console.log(
176
- uiSuccess(`Updated from ${result.previousVersion} to ${result.newVersion}`),
183
+ uiSuccess(
184
+ `Updated from ${result.previousVersion} to ${result.newVersion}`,
185
+ ),
177
186
  )
178
187
  console.log()
179
188
  if (result.previousVersion !== result.newVersion) {
@@ -189,7 +198,9 @@ async function handleUpdate(): Promise<void> {
189
198
  console.log(uiError(result.error || 'Unknown error'))
190
199
  console.log()
191
200
  const pm = await updateManager.detectPackageManager()
192
- console.log(chalk.gray(` Manual update: ${updateManager.getInstallCommand(pm)}`))
201
+ console.log(
202
+ chalk.gray(` Manual update: ${updateManager.getInstallCommand(pm)}`),
203
+ )
193
204
  }
194
205
 
195
206
  await pressEnterToContinue()
@@ -4,7 +4,11 @@ import { configManager } from '../../../core/config-manager'
4
4
  import { updateManager } from '../../../core/update-manager'
5
5
  import { escapeablePrompt } from '../../ui/prompts'
6
6
  import { header, uiSuccess, uiInfo } from '../../ui/theme'
7
- import { setCachedIconMode, ENGINE_BRAND_COLORS, getPageSize } from '../../constants'
7
+ import {
8
+ setCachedIconMode,
9
+ ENGINE_BRAND_COLORS,
10
+ getPageSize,
11
+ } from '../../constants'
8
12
  import { hasAnyInstalledEngines } from '../../helpers'
9
13
  import { Engine, type IconMode } from '../../../types'
10
14
  import { type MenuChoice, pressEnterToContinue } from './shared'
@@ -8,9 +8,10 @@
8
8
  * using the same hostdb binaries as local development.
9
9
  */
10
10
 
11
- import { mkdir, writeFile, copyFile, rm, readdir } from 'fs/promises'
11
+ import { mkdir, writeFile, copyFile, rm, readdir, readFile } from 'fs/promises'
12
12
  import { join, basename } from 'path'
13
13
  import { existsSync } from 'fs'
14
+ import { homedir } from 'os'
14
15
  import {
15
16
  type ContainerConfig,
16
17
  Engine,
@@ -73,6 +74,71 @@ function getEngineDisplayName(engine: Engine): string {
73
74
  return displayNames[engine] || engine
74
75
  }
75
76
 
77
+ /**
78
+ * Engine binary configuration for Docker exports
79
+ *
80
+ * Defines primary binaries per engine for PATH setup and documentation.
81
+ * This structure supports future enhancements:
82
+ * - excludedBinaries: binaries to omit from PATH (e.g., internal tools)
83
+ * - renamedBinaries: map of original -> renamed (e.g., for collision avoidance)
84
+ * - priority: for multi-engine containers, which engine's binary wins
85
+ */
86
+ const _ENGINE_BINARY_CONFIG: Record<
87
+ Engine,
88
+ {
89
+ primaryBinaries: string[]
90
+ }
91
+ > = {
92
+ [Engine.PostgreSQL]: {
93
+ primaryBinaries: ['psql', 'pg_dump', 'pg_restore', 'createdb', 'dropdb'],
94
+ },
95
+ [Engine.MySQL]: {
96
+ primaryBinaries: ['mysql', 'mysqldump', 'mysqladmin'],
97
+ },
98
+ [Engine.MariaDB]: {
99
+ primaryBinaries: ['mariadb', 'mariadb-dump', 'mariadb-admin'],
100
+ },
101
+ [Engine.SQLite]: {
102
+ primaryBinaries: ['sqlite3'],
103
+ },
104
+ [Engine.DuckDB]: {
105
+ primaryBinaries: ['duckdb'],
106
+ },
107
+ [Engine.MongoDB]: {
108
+ primaryBinaries: ['mongosh', 'mongodump', 'mongorestore'],
109
+ },
110
+ [Engine.FerretDB]: {
111
+ primaryBinaries: ['mongosh', 'psql'], // FerretDB uses mongosh + PostgreSQL backend
112
+ },
113
+ [Engine.Redis]: {
114
+ primaryBinaries: ['redis-cli', 'redis-server'],
115
+ },
116
+ [Engine.Valkey]: {
117
+ primaryBinaries: ['valkey-cli', 'valkey-server'],
118
+ },
119
+ [Engine.ClickHouse]: {
120
+ primaryBinaries: ['clickhouse', 'clickhouse-client'],
121
+ },
122
+ [Engine.Qdrant]: {
123
+ primaryBinaries: [], // REST API only, no CLI tools
124
+ },
125
+ [Engine.Meilisearch]: {
126
+ primaryBinaries: [], // REST API only, no CLI tools
127
+ },
128
+ [Engine.CouchDB]: {
129
+ primaryBinaries: [], // REST API only, no CLI tools
130
+ },
131
+ [Engine.CockroachDB]: {
132
+ primaryBinaries: ['cockroach'],
133
+ },
134
+ [Engine.SurrealDB]: {
135
+ primaryBinaries: ['surreal'],
136
+ },
137
+ [Engine.QuestDB]: {
138
+ primaryBinaries: [], // Uses psql from PostgreSQL for connections
139
+ },
140
+ }
141
+
76
142
  /**
77
143
  * Get the connection string template for an engine
78
144
  * Includes placeholders for credentials and optionally TLS
@@ -181,6 +247,12 @@ ENV DEBIAN_FRONTEND=noninteractive
181
247
 
182
248
  # Install base dependencies
183
249
  # libnuma1: Required by PostgreSQL binaries
250
+ # libxml2: XML library required by PostgreSQL
251
+ # libicu70: ICU library required by PostgreSQL (Ubuntu 22.04 ships ICU 70)
252
+ # libaio1: Async I/O library required by MySQL
253
+ # libncurses6: Terminal library required by MariaDB
254
+ # locales: Needed for PostgreSQL locale configuration
255
+ # lsof: Needed by SpinDB's findProcessByPort()
184
256
  # gosu: For running commands as non-root user
185
257
  RUN apt-get update && apt-get install -y \\
186
258
  curl \\
@@ -188,9 +260,20 @@ RUN apt-get update && apt-get install -y \\
188
260
  ca-certificates \\
189
261
  gnupg \\
190
262
  libnuma1 \\
263
+ libxml2 \\
264
+ libicu70 \\
265
+ libaio1 \\
266
+ libncurses6 \\
267
+ locales \\
268
+ lsof \\
191
269
  gosu \\
270
+ && locale-gen en_US.UTF-8 \\
192
271
  && rm -rf /var/lib/apt/lists/*
193
272
 
273
+ # Set locale environment variables
274
+ ENV LANG=en_US.UTF-8
275
+ ENV LC_ALL=en_US.UTF-8
276
+
194
277
  # Install Node.js 22 LTS (matches SpinDB's engine requirements)
195
278
  RUN mkdir -p /etc/apt/keyrings \\
196
279
  && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \\
@@ -286,7 +369,7 @@ fi
286
369
  case Engine.PostgreSQL:
287
370
  userCreationCommands = `
288
371
  # Create user with password
289
- echo "Creating database user..."
372
+ echo "[$(date '+%H:%M:%S')] Creating database user '$SPINDB_USER'..."
290
373
  cat > /tmp/create-user.sql <<EOSQL
291
374
  DO \\$\\$
292
375
  BEGIN
@@ -299,8 +382,13 @@ END
299
382
  \\$\\$;
300
383
  GRANT ALL PRIVILEGES ON DATABASE "$DATABASE" TO "$SPINDB_USER";
301
384
  EOSQL
302
- run_as_spindb spindb run "$CONTAINER_NAME" /tmp/create-user.sql --database postgres
385
+ if ! run_as_spindb spindb run "$CONTAINER_NAME" /tmp/create-user.sql --database postgres; then
386
+ echo "[$(date '+%H:%M:%S')] ERROR: Failed to create database user"
387
+ rm -f /tmp/create-user.sql
388
+ exit 1
389
+ fi
303
390
  rm -f /tmp/create-user.sql
391
+ echo "[$(date '+%H:%M:%S')] User '$SPINDB_USER' created successfully"
304
392
  `
305
393
  break
306
394
 
@@ -431,27 +519,83 @@ fi
431
519
  : databases.length > 1
432
520
  ? `
433
521
  # Restore data for all databases
522
+ echo "[$(date '+%H:%M:%S')] Checking for backup files in ${initDir}..."
523
+ ls -la ${initDir}/ 2>/dev/null || echo " (directory empty or not found)"
434
524
  DATABASES="${databases.join(' ')}"
435
525
  for DB in $DATABASES; do
436
526
  # Find backup file for this database (pattern: containerName-dbName.*)
437
527
  BACKUP_FILE=$(ls ${initDir}/${containerName}-$DB.* 2>/dev/null | head -1)
438
528
  if [ -n "$BACKUP_FILE" ]; then
439
- echo "Restoring database: $DB"
529
+ echo "[$(date '+%H:%M:%S')] Found backup for database '$DB': $BACKUP_FILE"
440
530
  # Add database to tracking if not already tracked
441
531
  run_as_spindb spindb databases add "$CONTAINER_NAME" "$DB" 2>/dev/null || true
442
- run_as_spindb spindb restore "$CONTAINER_NAME" "$BACKUP_FILE" --database "$DB" --force || echo "Restore of $DB completed with warnings"
532
+ if ! run_as_spindb spindb restore "$CONTAINER_NAME" "$BACKUP_FILE" --database "$DB" --force; then
533
+ echo "[$(date '+%H:%M:%S')] ERROR: Restore of '$DB' failed"
534
+ exit 1
535
+ fi
536
+ echo "[$(date '+%H:%M:%S')] Restore of '$DB' completed successfully"
537
+ else
538
+ echo "[$(date '+%H:%M:%S')] WARNING: No backup file found for database '$DB'"
443
539
  fi
444
540
  done
445
541
  `
446
542
  : `
447
543
  # Restore data if backup exists
544
+ echo "[$(date '+%H:%M:%S')] Checking for backup files in ${initDir}..."
545
+ ls -la ${initDir}/ 2>/dev/null || echo " (directory empty or not found)"
448
546
  if ls ${initDir}/* 1> /dev/null 2>&1; then
449
- echo "Restoring data from backup..."
450
547
  BACKUP_FILE=$(ls ${initDir}/* | head -1)
451
- run_as_spindb spindb restore "$CONTAINER_NAME" "$BACKUP_FILE" --database "$DATABASE" --force || echo "Restore completed with warnings"
548
+ echo "[$(date '+%H:%M:%S')] Found backup file: $BACKUP_FILE"
549
+ echo "[$(date '+%H:%M:%S')] Restoring to database '$DATABASE'..."
550
+ if ! run_as_spindb spindb restore "$CONTAINER_NAME" "$BACKUP_FILE" --database "$DATABASE" --force; then
551
+ echo "[$(date '+%H:%M:%S')] ERROR: Restore failed with exit code $?"
552
+ exit 1
553
+ fi
554
+ echo "[$(date '+%H:%M:%S')] Restore completed successfully"
555
+ else
556
+ echo "[$(date '+%H:%M:%S')] WARNING: No backup files found in ${initDir}"
452
557
  fi
453
558
  `
454
559
 
560
+ // Post-restore commands - grant table/sequence permissions to the spindb user
561
+ // Tables created during restore are owned by postgres, so spindb user needs grants
562
+ let postRestoreCommands = ''
563
+
564
+ switch (engine) {
565
+ case Engine.PostgreSQL:
566
+ case Engine.CockroachDB:
567
+ postRestoreCommands = `
568
+ # Grant table and sequence permissions to spindb user
569
+ # (Tables from restore are owned by postgres, spindb user needs access)
570
+ echo "[$(date '+%H:%M:%S')] Granting table permissions to '$SPINDB_USER'..."
571
+ cat > /tmp/grant-permissions.sql <<EOSQL
572
+ GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "$SPINDB_USER";
573
+ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO "$SPINDB_USER";
574
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "$SPINDB_USER";
575
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "$SPINDB_USER";
576
+ EOSQL
577
+ if ! run_as_spindb spindb run "$CONTAINER_NAME" /tmp/grant-permissions.sql --database "$DATABASE"; then
578
+ echo "[$(date '+%H:%M:%S')] ERROR: Failed to grant permissions"
579
+ rm -f /tmp/grant-permissions.sql
580
+ exit 1
581
+ fi
582
+ rm -f /tmp/grant-permissions.sql
583
+ echo "[$(date '+%H:%M:%S')] Permissions granted successfully"
584
+ `
585
+ break
586
+
587
+ case Engine.MySQL:
588
+ case Engine.MariaDB:
589
+ // MySQL grants are already handled by GRANT ALL ON database.* in user creation
590
+ postRestoreCommands = ''
591
+ break
592
+
593
+ default:
594
+ // Other engines don't need post-restore permission grants
595
+ postRestoreCommands = ''
596
+ break
597
+ }
598
+
455
599
  return `#!/bin/bash
456
600
  set -e
457
601
 
@@ -483,6 +627,8 @@ FILE_DB_PATH="/home/spindb/.spindb/containers/${engine}/\${CONTAINER_NAME}/\${CO
483
627
  # Export environment variables for the spindb user
484
628
  export SPINDB_CONTAINER SPINDB_DATABASE SPINDB_ENGINE SPINDB_VERSION SPINDB_PORT SPINDB_USER SPINDB_PASSWORD
485
629
 
630
+ # PATH will be updated after spindb downloads engine binaries
631
+
486
632
  # Fix permissions on mounted volume (may have been created with root ownership)
487
633
  echo "Setting up directories..."
488
634
  chown -R spindb:spindb /home/spindb/.spindb 2>/dev/null || true
@@ -502,16 +648,52 @@ run_as_spindb() {
502
648
  }
503
649
 
504
650
  # Check if container already exists
505
- if run_as_spindb spindb list --json 2>/dev/null | grep -q '"name":"'"$CONTAINER_NAME"'"'; then
506
- echo "Container '$CONTAINER_NAME' already exists"
651
+ if run_as_spindb spindb list --json 2>/dev/null | grep -q '"name": "'"$CONTAINER_NAME"'"'; then
652
+ echo "[$(date '+%H:%M:%S')] Container '$CONTAINER_NAME' already exists"
653
+ ${
654
+ isFileBased
655
+ ? `# File-based database: no server to start`
656
+ : `# Check if database is running, start if not (handles Docker restart)
657
+ if ! run_as_spindb spindb list --json 2>/dev/null | grep -q '"status":.*"running"'; then
658
+ echo "[$(date '+%H:%M:%S')] Database not running, starting..."
659
+ if ! run_as_spindb spindb start "$CONTAINER_NAME"; then
660
+ echo "[$(date '+%H:%M:%S')] ERROR: Failed to start database"
661
+ exit 1
662
+ fi
663
+ fi`
664
+ }
507
665
  else
508
- echo "Creating container '$CONTAINER_NAME'..."
666
+ echo "[$(date '+%H:%M:%S')] Creating container '$CONTAINER_NAME'..."
509
667
  ${
510
668
  isFileBased
511
669
  ? `# File-based database: use deterministic path for database file
512
- run_as_spindb spindb create "$CONTAINER_NAME" --engine "$ENGINE" --db-version "$VERSION" --path "$FILE_DB_PATH" --force`
513
- : `run_as_spindb spindb create "$CONTAINER_NAME" --engine "$ENGINE" --db-version "$VERSION" --port "$PORT" --database "$DATABASE" --force`
670
+ if ! run_as_spindb spindb create "$CONTAINER_NAME" --engine "$ENGINE" --db-version "$VERSION" --path "$FILE_DB_PATH" --force; then
671
+ echo "[$(date '+%H:%M:%S')] ERROR: Failed to create container"
672
+ exit 1
673
+ fi`
674
+ : `# Use --start to ensure database is created (non-TTY defaults to no-start)
675
+ if ! run_as_spindb spindb create "$CONTAINER_NAME" --engine "$ENGINE" --db-version "$VERSION" --port "$PORT" --database "$DATABASE" --force --start; then
676
+ echo "[$(date '+%H:%M:%S')] ERROR: Failed to create container"
677
+ exit 1
678
+ fi`
514
679
  }
680
+ echo "[$(date '+%H:%M:%S')] Container created successfully"
681
+ fi
682
+
683
+ # Add engine binary directory to PATH (idempotent - only adds if not present)
684
+ # This allows users to run psql, mysql, etc. directly in the container
685
+ # Note: We add the actual bin directory to PATH instead of creating symlinks
686
+ # because some binaries (like psql) are wrapper scripts that use relative paths
687
+ echo "Setting up database binaries in PATH..."
688
+ BIN_DIR=$(ls -d /home/spindb/.spindb/bin/\${ENGINE}-*/bin 2>/dev/null | head -1)
689
+ if [ -d "$BIN_DIR" ]; then
690
+ export PATH="$BIN_DIR:$PATH"
691
+ # Create/overwrite script in /etc/profile.d for system-wide access (idempotent)
692
+ # This ensures PATH is set for login shells without duplication
693
+ echo "export PATH=\\"$BIN_DIR:\\$PATH\\"" > /etc/profile.d/spindb-bins.sh
694
+ echo "Binaries available in PATH: $(ls "$BIN_DIR" | tr '\\n' ' ')"
695
+ else
696
+ echo "Warning: No engine binaries found"
515
697
  fi
516
698
  ${networkConfig}${
517
699
  isFileBased
@@ -519,29 +701,41 @@ ${networkConfig}${
519
701
  # File-based database: no server to start, just verify file exists after restore
520
702
  `
521
703
  : `
522
- # Start the database
523
- echo "Starting database..."
524
- run_as_spindb spindb start "$CONTAINER_NAME"
525
-
526
- # Wait for database to be ready
527
- echo "Waiting for database to be ready..."
704
+ # Database was started by 'spindb create --start' above
705
+ # Wait for database to be fully ready for connections
706
+ echo "[$(date '+%H:%M:%S')] Waiting for database to be ready..."
528
707
  RETRIES=30
529
708
  until run_as_spindb spindb list --json 2>/dev/null | grep -q '"status":.*"running"' || [ $RETRIES -eq 0 ]; do
530
- echo "Waiting for database... ($RETRIES attempts remaining)"
709
+ echo "[$(date '+%H:%M:%S')] Waiting for database... ($RETRIES attempts remaining)"
531
710
  sleep 2
532
711
  RETRIES=$((RETRIES-1))
533
712
  done
534
713
 
535
714
  if [ $RETRIES -eq 0 ]; then
536
- echo "Error: Database failed to start"
715
+ echo "[$(date '+%H:%M:%S')] ERROR: Database failed to start"
537
716
  exit 1
538
717
  fi`
539
718
  }
540
719
 
541
- echo "Database is running!"
720
+ echo "[$(date '+%H:%M:%S')] Database is running!"
721
+
722
+ # Initialization marker file - ensures user creation and data restore only run once
723
+ INIT_MARKER="/home/spindb/.spindb/.initialized-$CONTAINER_NAME"
724
+ if [ ! -f "$INIT_MARKER" ]; then
725
+ echo "[$(date '+%H:%M:%S')] ======== FIRST-TIME INITIALIZATION ========"
542
726
  ${userCreationCommands}
543
727
  ${restoreSection}
728
+ ${postRestoreCommands}
729
+ # Mark initialization complete
730
+ touch "$INIT_MARKER"
731
+ chown spindb:spindb "$INIT_MARKER"
732
+ echo "[$(date '+%H:%M:%S')] ======== INITIALIZATION COMPLETE ========"
733
+ else
734
+ echo "[$(date '+%H:%M:%S')] Container already initialized, skipping data restore."
735
+ fi
736
+
544
737
  echo "========================================"
738
+ echo "SPINDB_READY"
545
739
  echo "SpinDB container ready!"
546
740
  echo ""
547
741
  echo "Connection: ${getConnectionStringTemplate(engine, port, database, useTLS).replace(/\$/g, '\\$')}"
@@ -948,3 +1142,150 @@ export function getExportBackupPath(
948
1142
  const extension = getBackupExtension(engine, format)
949
1143
  return join(outputDir, 'data', `${containerName}-${database}${extension}`)
950
1144
  }
1145
+
1146
+ /**
1147
+ * Get the default Docker export directory for a container
1148
+ */
1149
+ export function getDefaultDockerExportPath(
1150
+ containerName: string,
1151
+ engine: Engine,
1152
+ ): string {
1153
+ return join(
1154
+ homedir(),
1155
+ '.spindb',
1156
+ 'containers',
1157
+ engine,
1158
+ containerName,
1159
+ 'docker',
1160
+ )
1161
+ }
1162
+
1163
+ /**
1164
+ * Check if a Docker export already exists for a container
1165
+ */
1166
+ export function dockerExportExists(
1167
+ containerName: string,
1168
+ engine: Engine,
1169
+ ): boolean {
1170
+ const exportPath = getDefaultDockerExportPath(containerName, engine)
1171
+ const envPath = join(exportPath, '.env')
1172
+ return existsSync(envPath)
1173
+ }
1174
+
1175
+ export type DockerCredentials = {
1176
+ username: string
1177
+ password: string
1178
+ port: number
1179
+ database: string
1180
+ engine: string
1181
+ version: string
1182
+ containerName: string
1183
+ }
1184
+
1185
+ /**
1186
+ * Read Docker credentials from an existing export's .env file
1187
+ */
1188
+ export async function getDockerCredentials(
1189
+ containerName: string,
1190
+ engine: Engine,
1191
+ ): Promise<DockerCredentials | null> {
1192
+ const exportPath = getDefaultDockerExportPath(containerName, engine)
1193
+ const envPath = join(exportPath, '.env')
1194
+
1195
+ if (!existsSync(envPath)) {
1196
+ return null
1197
+ }
1198
+
1199
+ try {
1200
+ const envContent = await readFile(envPath, 'utf-8')
1201
+ const lines = envContent.split('\n')
1202
+
1203
+ const values: Record<string, string> = {}
1204
+ for (const line of lines) {
1205
+ const match = line.match(/^([A-Z_]+)=(.*)$/)
1206
+ if (match) {
1207
+ values[match[1]] = match[2]
1208
+ }
1209
+ }
1210
+
1211
+ return {
1212
+ username: values.SPINDB_USER || 'spindb',
1213
+ password: values.SPINDB_PASSWORD || '',
1214
+ port: parseInt(values.PORT || '0', 10),
1215
+ database: values.DATABASE || '',
1216
+ engine: values.ENGINE || engine,
1217
+ version: values.VERSION || '',
1218
+ containerName: values.CONTAINER_NAME || containerName,
1219
+ }
1220
+ } catch {
1221
+ return null
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Get the Docker connection string for an existing export
1227
+ * Returns the connection string with actual credentials substituted
1228
+ */
1229
+ export async function getDockerConnectionString(
1230
+ containerName: string,
1231
+ engine: Engine,
1232
+ options: { host?: string } = {},
1233
+ ): Promise<string | null> {
1234
+ const credentials = await getDockerCredentials(containerName, engine)
1235
+ if (!credentials) {
1236
+ return null
1237
+ }
1238
+
1239
+ const host = options.host || 'localhost'
1240
+ const { username, password, port, database } = credentials
1241
+
1242
+ // URL-encode credentials to escape reserved URI characters
1243
+ const encodedUsername = encodeURIComponent(username)
1244
+ const encodedPassword = encodeURIComponent(password)
1245
+ const encodedDatabase = encodeURIComponent(database)
1246
+
1247
+ // Build connection string based on engine type
1248
+ switch (engine) {
1249
+ case Engine.PostgreSQL:
1250
+ case Engine.CockroachDB:
1251
+ case Engine.QuestDB:
1252
+ return `postgresql://${encodedUsername}:${encodedPassword}@${host}:${port}/${encodedDatabase}`
1253
+
1254
+ case Engine.MySQL:
1255
+ case Engine.MariaDB:
1256
+ return `mysql://${encodedUsername}:${encodedPassword}@${host}:${port}/${encodedDatabase}`
1257
+
1258
+ case Engine.MongoDB:
1259
+ case Engine.FerretDB:
1260
+ return `mongodb://${encodedUsername}:${encodedPassword}@${host}:${port}/${encodedDatabase}`
1261
+
1262
+ case Engine.Redis:
1263
+ case Engine.Valkey:
1264
+ return `redis://:${encodedPassword}@${host}:${port}`
1265
+
1266
+ case Engine.ClickHouse:
1267
+ return `clickhouse://${encodedUsername}:${encodedPassword}@${host}:${port}/${encodedDatabase}`
1268
+
1269
+ case Engine.Qdrant:
1270
+ return `http://${host}:${port}`
1271
+
1272
+ case Engine.Meilisearch:
1273
+ return `http://${host}:${port}`
1274
+
1275
+ case Engine.CouchDB:
1276
+ return `http://${username}:${password}@${host}:${port}/${database}`
1277
+
1278
+ case Engine.SurrealDB:
1279
+ return `ws://${username}:${password}@${host}:${port}`
1280
+
1281
+ case Engine.SQLite:
1282
+ case Engine.DuckDB:
1283
+ return `File-based database (no network connection)`
1284
+
1285
+ default:
1286
+ assertExhaustive(
1287
+ engine,
1288
+ `Unhandled engine in getDockerConnectionString: ${engine}`,
1289
+ )
1290
+ }
1291
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spindb",
3
- "version": "0.30.6",
3
+ "version": "0.31.0",
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.",