spindb 0.1.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/.claude/settings.local.json +20 -0
- package/.env.example +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +6 -0
- package/CLAUDE.md +162 -0
- package/README.md +204 -0
- package/TODO.md +66 -0
- package/bin/cli.js +7 -0
- package/eslint.config.js +18 -0
- package/package.json +52 -0
- package/seeds/mysql/sample-db.sql +22 -0
- package/seeds/postgres/sample-db.sql +27 -0
- package/src/bin/cli.ts +8 -0
- package/src/cli/commands/clone.ts +101 -0
- package/src/cli/commands/config.ts +215 -0
- package/src/cli/commands/connect.ts +106 -0
- package/src/cli/commands/create.ts +148 -0
- package/src/cli/commands/delete.ts +94 -0
- package/src/cli/commands/list.ts +69 -0
- package/src/cli/commands/menu.ts +675 -0
- package/src/cli/commands/restore.ts +161 -0
- package/src/cli/commands/start.ts +95 -0
- package/src/cli/commands/stop.ts +91 -0
- package/src/cli/index.ts +38 -0
- package/src/cli/ui/prompts.ts +197 -0
- package/src/cli/ui/spinner.ts +94 -0
- package/src/cli/ui/theme.ts +113 -0
- package/src/config/defaults.ts +49 -0
- package/src/config/paths.ts +53 -0
- package/src/core/binary-manager.ts +239 -0
- package/src/core/config-manager.ts +259 -0
- package/src/core/container-manager.ts +234 -0
- package/src/core/port-manager.ts +84 -0
- package/src/core/process-manager.ts +353 -0
- package/src/engines/base-engine.ts +103 -0
- package/src/engines/index.ts +46 -0
- package/src/engines/postgresql/binary-urls.ts +52 -0
- package/src/engines/postgresql/index.ts +298 -0
- package/src/engines/postgresql/restore.ts +173 -0
- package/src/types/index.ts +97 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContainerConfig,
|
|
3
|
+
ProgressCallback,
|
|
4
|
+
BackupFormat,
|
|
5
|
+
RestoreResult,
|
|
6
|
+
StatusResult,
|
|
7
|
+
} from '@/types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base class for database engines
|
|
11
|
+
* All engines (PostgreSQL, MySQL, SQLite) should extend this class
|
|
12
|
+
*/
|
|
13
|
+
export abstract class BaseEngine {
|
|
14
|
+
abstract name: string
|
|
15
|
+
abstract displayName: string
|
|
16
|
+
abstract defaultPort: number
|
|
17
|
+
abstract supportedVersions: string[]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the download URL for binaries
|
|
21
|
+
*/
|
|
22
|
+
abstract getBinaryUrl(version: string, platform: string, arch: string): string
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Verify that the binaries are working correctly
|
|
26
|
+
*/
|
|
27
|
+
abstract verifyBinary(binPath: string): Promise<boolean>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize a new data directory
|
|
31
|
+
*/
|
|
32
|
+
abstract initDataDir(
|
|
33
|
+
containerName: string,
|
|
34
|
+
version: string,
|
|
35
|
+
options?: Record<string, unknown>,
|
|
36
|
+
): Promise<string>
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start the database server
|
|
40
|
+
*/
|
|
41
|
+
abstract start(
|
|
42
|
+
container: ContainerConfig,
|
|
43
|
+
onProgress?: ProgressCallback,
|
|
44
|
+
): Promise<{ port: number; connectionString: string }>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stop the database server
|
|
48
|
+
*/
|
|
49
|
+
abstract stop(container: ContainerConfig): Promise<void>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the status of the database server
|
|
53
|
+
*/
|
|
54
|
+
abstract status(container: ContainerConfig): Promise<StatusResult>
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Detect the format of a backup file
|
|
58
|
+
*/
|
|
59
|
+
abstract detectBackupFormat(filePath: string): Promise<BackupFormat>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Restore a backup to the database
|
|
63
|
+
*/
|
|
64
|
+
abstract restore(
|
|
65
|
+
container: ContainerConfig,
|
|
66
|
+
backupPath: string,
|
|
67
|
+
options?: Record<string, unknown>,
|
|
68
|
+
): Promise<RestoreResult>
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the connection string for a container
|
|
72
|
+
*/
|
|
73
|
+
abstract getConnectionString(
|
|
74
|
+
container: ContainerConfig,
|
|
75
|
+
database?: string,
|
|
76
|
+
): string
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open an interactive shell/CLI connection
|
|
80
|
+
*/
|
|
81
|
+
abstract connect(container: ContainerConfig, database?: string): Promise<void>
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a new database within the container
|
|
85
|
+
*/
|
|
86
|
+
abstract createDatabase(
|
|
87
|
+
container: ContainerConfig,
|
|
88
|
+
database: string,
|
|
89
|
+
): Promise<void>
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if binaries are installed
|
|
93
|
+
*/
|
|
94
|
+
abstract isBinaryInstalled(version: string): Promise<boolean>
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Ensure binaries are available, downloading if necessary
|
|
98
|
+
*/
|
|
99
|
+
abstract ensureBinaries(
|
|
100
|
+
version: string,
|
|
101
|
+
onProgress?: ProgressCallback,
|
|
102
|
+
): Promise<string>
|
|
103
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { postgresqlEngine } from '@/engines/postgresql'
|
|
2
|
+
import type { BaseEngine } from '@/engines/base-engine'
|
|
3
|
+
import type { EngineInfo } from '@/types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registry of available database engines
|
|
7
|
+
*/
|
|
8
|
+
export const engines: Record<string, BaseEngine> = {
|
|
9
|
+
postgresql: postgresqlEngine,
|
|
10
|
+
postgres: postgresqlEngine, // Alias
|
|
11
|
+
pg: postgresqlEngine, // Alias
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get an engine by name
|
|
16
|
+
*/
|
|
17
|
+
export function getEngine(name: string): BaseEngine {
|
|
18
|
+
const engine = engines[name.toLowerCase()]
|
|
19
|
+
if (!engine) {
|
|
20
|
+
const available = [...new Set(Object.values(engines))].map((e) => e.name)
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Unknown engine "${name}". Available: ${available.join(', ')}`,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
return engine
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* List all available engines
|
|
30
|
+
*/
|
|
31
|
+
export function listEngines(): EngineInfo[] {
|
|
32
|
+
// Return unique engines (filter out aliases)
|
|
33
|
+
const seen = new Set<BaseEngine>()
|
|
34
|
+
return Object.entries(engines)
|
|
35
|
+
.filter(([, engine]) => {
|
|
36
|
+
if (seen.has(engine)) return false
|
|
37
|
+
seen.add(engine)
|
|
38
|
+
return true
|
|
39
|
+
})
|
|
40
|
+
.map(([, engine]) => ({
|
|
41
|
+
name: engine.name,
|
|
42
|
+
displayName: engine.displayName,
|
|
43
|
+
defaultPort: engine.defaultPort,
|
|
44
|
+
supportedVersions: engine.supportedVersions,
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defaults } from '@/config/defaults'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map major versions to latest stable patch versions
|
|
5
|
+
*/
|
|
6
|
+
export const VERSION_MAP: Record<string, string> = {
|
|
7
|
+
'14': '14.15.0',
|
|
8
|
+
'15': '15.10.0',
|
|
9
|
+
'16': '16.6.0',
|
|
10
|
+
'17': '17.2.0',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the zonky.io platform identifier
|
|
15
|
+
*/
|
|
16
|
+
export function getZonkyPlatform(
|
|
17
|
+
platform: string,
|
|
18
|
+
arch: string,
|
|
19
|
+
): string | undefined {
|
|
20
|
+
const key = `${platform}-${arch}`
|
|
21
|
+
return defaults.platformMappings[key]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the download URL for PostgreSQL binaries from zonky.io
|
|
26
|
+
*/
|
|
27
|
+
export function getBinaryUrl(
|
|
28
|
+
version: string,
|
|
29
|
+
platform: string,
|
|
30
|
+
arch: string,
|
|
31
|
+
): string {
|
|
32
|
+
const zonkyPlatform = getZonkyPlatform(platform, arch)
|
|
33
|
+
if (!zonkyPlatform) {
|
|
34
|
+
throw new Error(`Unsupported platform: ${platform}-${arch}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const fullVersion = VERSION_MAP[version]
|
|
38
|
+
if (!fullVersion) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unsupported PostgreSQL version: ${version}. Supported: ${Object.keys(VERSION_MAP).join(', ')}`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return `https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-${zonkyPlatform}/${fullVersion}/embedded-postgres-binaries-${zonkyPlatform}-${fullVersion}.jar`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the full version string for a major version
|
|
49
|
+
*/
|
|
50
|
+
export function getFullVersion(majorVersion: string): string | null {
|
|
51
|
+
return VERSION_MAP[majorVersion] || null
|
|
52
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { platform, arch } from 'os'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { spawn, exec } from 'child_process'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
import { BaseEngine } from '@/engines/base-engine'
|
|
6
|
+
import { binaryManager } from '@/core/binary-manager'
|
|
7
|
+
import { processManager } from '@/core/process-manager'
|
|
8
|
+
import { configManager } from '@/core/config-manager'
|
|
9
|
+
import { paths } from '@/config/paths'
|
|
10
|
+
import { defaults } from '@/config/defaults'
|
|
11
|
+
import { getBinaryUrl, VERSION_MAP } from './binary-urls'
|
|
12
|
+
import { detectBackupFormat, restoreBackup } from './restore'
|
|
13
|
+
import type {
|
|
14
|
+
ContainerConfig,
|
|
15
|
+
ProgressCallback,
|
|
16
|
+
BackupFormat,
|
|
17
|
+
RestoreResult,
|
|
18
|
+
StatusResult,
|
|
19
|
+
} from '@/types'
|
|
20
|
+
|
|
21
|
+
const execAsync = promisify(exec)
|
|
22
|
+
|
|
23
|
+
export class PostgreSQLEngine extends BaseEngine {
|
|
24
|
+
name = 'postgresql'
|
|
25
|
+
displayName = 'PostgreSQL'
|
|
26
|
+
defaultPort = 5432
|
|
27
|
+
supportedVersions = Object.keys(VERSION_MAP)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get current platform info
|
|
31
|
+
*/
|
|
32
|
+
getPlatformInfo(): { platform: string; arch: string } {
|
|
33
|
+
return {
|
|
34
|
+
platform: platform(),
|
|
35
|
+
arch: arch(),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get binary path for current platform
|
|
41
|
+
*/
|
|
42
|
+
getBinaryPath(version: string): string {
|
|
43
|
+
const { platform: p, arch: a } = this.getPlatformInfo()
|
|
44
|
+
return paths.getBinaryPath('postgresql', version, p, a)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get binary download URL
|
|
49
|
+
*/
|
|
50
|
+
getBinaryUrl(version: string, plat: string, arc: string): string {
|
|
51
|
+
return getBinaryUrl(version, plat, arc)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Verify binary installation
|
|
56
|
+
*/
|
|
57
|
+
async verifyBinary(binPath: string): Promise<boolean> {
|
|
58
|
+
const { platform: p, arch: a } = this.getPlatformInfo()
|
|
59
|
+
// Extract version from path
|
|
60
|
+
const parts = binPath.split('-')
|
|
61
|
+
const version = parts[1]
|
|
62
|
+
return binaryManager.verify(version, p, a)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ensure PostgreSQL binaries are available
|
|
67
|
+
*/
|
|
68
|
+
async ensureBinaries(
|
|
69
|
+
version: string,
|
|
70
|
+
onProgress?: ProgressCallback,
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
const { platform: p, arch: a } = this.getPlatformInfo()
|
|
73
|
+
return binaryManager.ensureInstalled(version, p, a, onProgress)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if binaries are installed
|
|
78
|
+
*/
|
|
79
|
+
async isBinaryInstalled(version: string): Promise<boolean> {
|
|
80
|
+
const { platform: p, arch: a } = this.getPlatformInfo()
|
|
81
|
+
return binaryManager.isInstalled(version, p, a)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Initialize a new PostgreSQL data directory
|
|
86
|
+
*/
|
|
87
|
+
async initDataDir(
|
|
88
|
+
containerName: string,
|
|
89
|
+
version: string,
|
|
90
|
+
options: Record<string, unknown> = {},
|
|
91
|
+
): Promise<string> {
|
|
92
|
+
const binPath = this.getBinaryPath(version)
|
|
93
|
+
const initdbPath = join(binPath, 'bin', 'initdb')
|
|
94
|
+
const dataDir = paths.getContainerDataPath(containerName)
|
|
95
|
+
|
|
96
|
+
await processManager.initdb(initdbPath, dataDir, {
|
|
97
|
+
superuser: (options.superuser as string) || defaults.superuser,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return dataDir
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start PostgreSQL server
|
|
105
|
+
*/
|
|
106
|
+
async start(
|
|
107
|
+
container: ContainerConfig,
|
|
108
|
+
onProgress?: ProgressCallback,
|
|
109
|
+
): Promise<{ port: number; connectionString: string }> {
|
|
110
|
+
const { name, version, port } = container
|
|
111
|
+
const binPath = this.getBinaryPath(version)
|
|
112
|
+
const pgCtlPath = join(binPath, 'bin', 'pg_ctl')
|
|
113
|
+
const dataDir = paths.getContainerDataPath(name)
|
|
114
|
+
const logFile = paths.getContainerLogPath(name)
|
|
115
|
+
|
|
116
|
+
onProgress?.({ stage: 'starting', message: 'Starting PostgreSQL...' })
|
|
117
|
+
|
|
118
|
+
await processManager.start(pgCtlPath, dataDir, {
|
|
119
|
+
port,
|
|
120
|
+
logFile,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
port,
|
|
125
|
+
connectionString: this.getConnectionString(container),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Stop PostgreSQL server
|
|
131
|
+
*/
|
|
132
|
+
async stop(container: ContainerConfig): Promise<void> {
|
|
133
|
+
const { name, version } = container
|
|
134
|
+
const binPath = this.getBinaryPath(version)
|
|
135
|
+
const pgCtlPath = join(binPath, 'bin', 'pg_ctl')
|
|
136
|
+
const dataDir = paths.getContainerDataPath(name)
|
|
137
|
+
|
|
138
|
+
await processManager.stop(pgCtlPath, dataDir)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get PostgreSQL server status
|
|
143
|
+
*/
|
|
144
|
+
async status(container: ContainerConfig): Promise<StatusResult> {
|
|
145
|
+
const { name, version } = container
|
|
146
|
+
const binPath = this.getBinaryPath(version)
|
|
147
|
+
const pgCtlPath = join(binPath, 'bin', 'pg_ctl')
|
|
148
|
+
const dataDir = paths.getContainerDataPath(name)
|
|
149
|
+
|
|
150
|
+
return processManager.status(pgCtlPath, dataDir)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Detect backup format
|
|
155
|
+
*/
|
|
156
|
+
async detectBackupFormat(filePath: string): Promise<BackupFormat> {
|
|
157
|
+
return detectBackupFormat(filePath)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Restore a backup
|
|
162
|
+
*/
|
|
163
|
+
async restore(
|
|
164
|
+
container: ContainerConfig,
|
|
165
|
+
backupPath: string,
|
|
166
|
+
options: Record<string, unknown> = {},
|
|
167
|
+
): Promise<RestoreResult> {
|
|
168
|
+
const { version, port } = container
|
|
169
|
+
const binPath = this.getBinaryPath(version)
|
|
170
|
+
const database = (options.database as string) || container.name
|
|
171
|
+
|
|
172
|
+
// First create the database if it doesn't exist
|
|
173
|
+
if (options.createDatabase !== false) {
|
|
174
|
+
await this.createDatabase(container, database)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return restoreBackup(binPath, backupPath, {
|
|
178
|
+
port,
|
|
179
|
+
database,
|
|
180
|
+
user: defaults.superuser,
|
|
181
|
+
...(options as { format?: string }),
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get connection string
|
|
187
|
+
*/
|
|
188
|
+
getConnectionString(container: ContainerConfig, database?: string): string {
|
|
189
|
+
const { port } = container
|
|
190
|
+
const db = database || 'postgres'
|
|
191
|
+
return `postgresql://${defaults.superuser}@localhost:${port}/${db}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get path to psql, using config manager to find it
|
|
196
|
+
*/
|
|
197
|
+
async getPsqlPath(): Promise<string> {
|
|
198
|
+
const psqlPath = await configManager.getBinaryPath('psql')
|
|
199
|
+
if (!psqlPath) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
'psql not found. Install PostgreSQL client tools:\n' +
|
|
202
|
+
' macOS: brew install libpq && brew link --force libpq\n' +
|
|
203
|
+
' Ubuntu/Debian: apt install postgresql-client\n\n' +
|
|
204
|
+
'Or configure manually: spindb config set psql /path/to/psql',
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
return psqlPath
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get path to pg_restore, using config manager to find it
|
|
212
|
+
*/
|
|
213
|
+
async getPgRestorePath(): Promise<string> {
|
|
214
|
+
const pgRestorePath = await configManager.getBinaryPath('pg_restore')
|
|
215
|
+
if (!pgRestorePath) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'pg_restore not found. Install PostgreSQL client tools:\n' +
|
|
218
|
+
' macOS: brew install libpq && brew link --force libpq\n' +
|
|
219
|
+
' Ubuntu/Debian: apt install postgresql-client\n\n' +
|
|
220
|
+
'Or configure manually: spindb config set pg_restore /path/to/pg_restore',
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
return pgRestorePath
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get path to pg_dump, using config manager to find it
|
|
228
|
+
*/
|
|
229
|
+
async getPgDumpPath(): Promise<string> {
|
|
230
|
+
const pgDumpPath = await configManager.getBinaryPath('pg_dump')
|
|
231
|
+
if (!pgDumpPath) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
'pg_dump not found. Install PostgreSQL client tools:\n' +
|
|
234
|
+
' macOS: brew install libpq && brew link --force libpq\n' +
|
|
235
|
+
' Ubuntu/Debian: apt install postgresql-client\n\n' +
|
|
236
|
+
'Or configure manually: spindb config set pg_dump /path/to/pg_dump',
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
return pgDumpPath
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Open psql interactive shell
|
|
244
|
+
*/
|
|
245
|
+
async connect(container: ContainerConfig, database?: string): Promise<void> {
|
|
246
|
+
const { port } = container
|
|
247
|
+
const db = database || 'postgres'
|
|
248
|
+
const psqlPath = await this.getPsqlPath()
|
|
249
|
+
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
const proc = spawn(
|
|
252
|
+
psqlPath,
|
|
253
|
+
[
|
|
254
|
+
'-h',
|
|
255
|
+
'127.0.0.1',
|
|
256
|
+
'-p',
|
|
257
|
+
String(port),
|
|
258
|
+
'-U',
|
|
259
|
+
defaults.superuser,
|
|
260
|
+
'-d',
|
|
261
|
+
db,
|
|
262
|
+
],
|
|
263
|
+
{ stdio: 'inherit' },
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
proc.on('error', (err: NodeJS.ErrnoException) => {
|
|
267
|
+
reject(err)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
proc.on('close', () => resolve())
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Create a new database
|
|
276
|
+
*/
|
|
277
|
+
async createDatabase(
|
|
278
|
+
container: ContainerConfig,
|
|
279
|
+
database: string,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const { port } = container
|
|
282
|
+
const psqlPath = await this.getPsqlPath()
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await execAsync(
|
|
286
|
+
`"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -c 'CREATE DATABASE "${database}"'`,
|
|
287
|
+
)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const err = error as Error
|
|
290
|
+
// Ignore "database already exists" error
|
|
291
|
+
if (!err.message.includes('already exists')) {
|
|
292
|
+
throw error
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const postgresqlEngine = new PostgreSQLEngine()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises'
|
|
2
|
+
import { exec } from 'child_process'
|
|
3
|
+
import { promisify } from 'util'
|
|
4
|
+
import { configManager } from '@/core/config-manager'
|
|
5
|
+
import type { BackupFormat, RestoreResult } from '@/types'
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detect the format of a PostgreSQL backup file
|
|
11
|
+
*/
|
|
12
|
+
export async function detectBackupFormat(
|
|
13
|
+
filePath: string,
|
|
14
|
+
): Promise<BackupFormat> {
|
|
15
|
+
// Read the first few bytes to detect format
|
|
16
|
+
const file = await readFile(filePath)
|
|
17
|
+
const buffer = Buffer.alloc(16)
|
|
18
|
+
|
|
19
|
+
// Copy first bytes
|
|
20
|
+
file.copy(buffer, 0, 0, Math.min(16, file.length))
|
|
21
|
+
|
|
22
|
+
// Check for PostgreSQL custom format magic number
|
|
23
|
+
// Custom format starts with "PGDMP"
|
|
24
|
+
if (buffer.toString('ascii', 0, 5) === 'PGDMP') {
|
|
25
|
+
return {
|
|
26
|
+
format: 'custom',
|
|
27
|
+
description: 'PostgreSQL custom format (pg_dump -Fc)',
|
|
28
|
+
restoreCommand: 'pg_restore',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check for tar format (directory dumps are usually tar)
|
|
33
|
+
// Tar files have "ustar" at offset 257
|
|
34
|
+
if (file.length > 262) {
|
|
35
|
+
const tarMagic = file.toString('ascii', 257, 262)
|
|
36
|
+
if (tarMagic === 'ustar') {
|
|
37
|
+
return {
|
|
38
|
+
format: 'tar',
|
|
39
|
+
description: 'PostgreSQL tar format (pg_dump -Ft)',
|
|
40
|
+
restoreCommand: 'pg_restore',
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check for gzip compression
|
|
46
|
+
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
|
|
47
|
+
return {
|
|
48
|
+
format: 'compressed',
|
|
49
|
+
description: 'Gzip compressed (likely SQL or custom format)',
|
|
50
|
+
restoreCommand: 'auto',
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check if it looks like SQL (starts with common SQL statements)
|
|
55
|
+
const textStart = buffer.toString('utf8', 0, 16).toLowerCase()
|
|
56
|
+
if (
|
|
57
|
+
textStart.startsWith('--') ||
|
|
58
|
+
textStart.startsWith('/*') ||
|
|
59
|
+
textStart.startsWith('set ') ||
|
|
60
|
+
textStart.startsWith('create') ||
|
|
61
|
+
textStart.startsWith('drop') ||
|
|
62
|
+
textStart.startsWith('begin') ||
|
|
63
|
+
textStart.startsWith('pg_dump')
|
|
64
|
+
) {
|
|
65
|
+
return {
|
|
66
|
+
format: 'sql',
|
|
67
|
+
description: 'Plain SQL format (pg_dump -Fp)',
|
|
68
|
+
restoreCommand: 'psql',
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Default to trying custom format
|
|
73
|
+
return {
|
|
74
|
+
format: 'unknown',
|
|
75
|
+
description: 'Unknown format - will attempt custom format restore',
|
|
76
|
+
restoreCommand: 'pg_restore',
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RestoreOptions {
|
|
81
|
+
port: number
|
|
82
|
+
database: string
|
|
83
|
+
user?: string
|
|
84
|
+
format?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get psql path from config, with helpful error message
|
|
89
|
+
*/
|
|
90
|
+
async function getPsqlPath(): Promise<string> {
|
|
91
|
+
const psqlPath = await configManager.getBinaryPath('psql')
|
|
92
|
+
if (!psqlPath) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'psql not found. Install PostgreSQL client tools:\n' +
|
|
95
|
+
' macOS: brew install libpq && brew link --force libpq\n' +
|
|
96
|
+
' Ubuntu/Debian: apt install postgresql-client\n\n' +
|
|
97
|
+
'Or configure manually: spindb config set psql /path/to/psql',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return psqlPath
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get pg_restore path from config, with helpful error message
|
|
105
|
+
*/
|
|
106
|
+
async function getPgRestorePath(): Promise<string> {
|
|
107
|
+
const pgRestorePath = await configManager.getBinaryPath('pg_restore')
|
|
108
|
+
if (!pgRestorePath) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'pg_restore not found. Install PostgreSQL client tools:\n' +
|
|
111
|
+
' macOS: brew install libpq && brew link --force libpq\n' +
|
|
112
|
+
' Ubuntu/Debian: apt install postgresql-client\n\n' +
|
|
113
|
+
'Or configure manually: spindb config set pg_restore /path/to/pg_restore',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
return pgRestorePath
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Restore a backup to a PostgreSQL database
|
|
121
|
+
*/
|
|
122
|
+
export async function restoreBackup(
|
|
123
|
+
_binPath: string, // Not used - using config manager instead
|
|
124
|
+
backupPath: string,
|
|
125
|
+
options: RestoreOptions,
|
|
126
|
+
): Promise<RestoreResult> {
|
|
127
|
+
const { port, database, user = 'postgres', format } = options
|
|
128
|
+
|
|
129
|
+
const detectedFormat = format || (await detectBackupFormat(backupPath)).format
|
|
130
|
+
|
|
131
|
+
if (detectedFormat === 'sql') {
|
|
132
|
+
const psqlPath = await getPsqlPath()
|
|
133
|
+
|
|
134
|
+
const result = await execAsync(
|
|
135
|
+
`"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${user} -d ${database} -f "${backupPath}"`,
|
|
136
|
+
{ maxBuffer: 50 * 1024 * 1024 }, // 50MB buffer for large dumps
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
format: 'sql',
|
|
141
|
+
...result,
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const pgRestorePath = await getPgRestorePath()
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const formatFlag =
|
|
148
|
+
detectedFormat === 'custom'
|
|
149
|
+
? '-Fc'
|
|
150
|
+
: detectedFormat === 'tar'
|
|
151
|
+
? '-Ft'
|
|
152
|
+
: ''
|
|
153
|
+
const result = await execAsync(
|
|
154
|
+
`"${pgRestorePath}" -h 127.0.0.1 -p ${port} -U ${user} -d ${database} --no-owner --no-privileges ${formatFlag} "${backupPath}"`,
|
|
155
|
+
{ maxBuffer: 50 * 1024 * 1024 },
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
format: detectedFormat,
|
|
160
|
+
...result,
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const e = err as Error & { stdout?: string; stderr?: string }
|
|
164
|
+
// pg_restore often returns non-zero even on partial success
|
|
165
|
+
return {
|
|
166
|
+
format: detectedFormat,
|
|
167
|
+
stdout: e.stdout || '',
|
|
168
|
+
stderr: e.stderr || e.message,
|
|
169
|
+
code: 1,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|