spindb 0.7.5 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/cli/commands/create.ts +8 -0
- package/cli/commands/edit.ts +180 -12
- package/config/engine-defaults.ts +4 -0
- package/engines/mysql/index.ts +1 -0
- package/engines/postgresql/index.ts +63 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -217,6 +217,7 @@ spindb create mydb --from "postgresql://user:pass@host:5432/production"
|
|
|
217
217
|
| `--version`, `-v` | Engine version |
|
|
218
218
|
| `--port`, `-p` | Port number |
|
|
219
219
|
| `--database`, `-d` | Primary database name |
|
|
220
|
+
| `--max-connections` | Maximum database connections (default: 200) |
|
|
220
221
|
| `--from` | Restore from backup file or connection string |
|
|
221
222
|
| `--no-start` | Create without starting |
|
|
222
223
|
|
|
@@ -332,12 +333,13 @@ spindb clone source-db new-db
|
|
|
332
333
|
spindb start new-db
|
|
333
334
|
```
|
|
334
335
|
|
|
335
|
-
#### `edit` - Rename or
|
|
336
|
+
#### `edit` - Rename, change port, or edit database config
|
|
336
337
|
|
|
337
338
|
```bash
|
|
338
|
-
spindb edit mydb --name newname
|
|
339
|
+
spindb edit mydb --name newname # Must be stopped
|
|
339
340
|
spindb edit mydb --port 5433
|
|
340
|
-
spindb edit mydb
|
|
341
|
+
spindb edit mydb --set-config max_connections=300 # PostgreSQL config
|
|
342
|
+
spindb edit mydb # Interactive mode
|
|
341
343
|
```
|
|
342
344
|
|
|
343
345
|
#### `logs` - View container logs
|
package/cli/commands/create.ts
CHANGED
|
@@ -51,6 +51,10 @@ export const createCommand = new Command('create')
|
|
|
51
51
|
.option('-v, --version <version>', 'Database version')
|
|
52
52
|
.option('-d, --database <database>', 'Database name')
|
|
53
53
|
.option('-p, --port <port>', 'Port number')
|
|
54
|
+
.option(
|
|
55
|
+
'--max-connections <number>',
|
|
56
|
+
'Maximum number of database connections (default: 200)',
|
|
57
|
+
)
|
|
54
58
|
.option('--no-start', 'Do not start the container after creation')
|
|
55
59
|
.option(
|
|
56
60
|
'--from <location>',
|
|
@@ -64,6 +68,7 @@ export const createCommand = new Command('create')
|
|
|
64
68
|
version?: string
|
|
65
69
|
database?: string
|
|
66
70
|
port?: string
|
|
71
|
+
maxConnections?: string
|
|
67
72
|
start: boolean
|
|
68
73
|
from?: string
|
|
69
74
|
},
|
|
@@ -256,6 +261,9 @@ export const createCommand = new Command('create')
|
|
|
256
261
|
try {
|
|
257
262
|
await dbEngine.initDataDir(containerName, version, {
|
|
258
263
|
superuser: engineDefaults.superuser,
|
|
264
|
+
maxConnections: options.maxConnections
|
|
265
|
+
? parseInt(options.maxConnections, 10)
|
|
266
|
+
: undefined,
|
|
259
267
|
})
|
|
260
268
|
initSpinner.succeed('Database cluster initialized')
|
|
261
269
|
} catch (err) {
|
package/cli/commands/edit.ts
CHANGED
|
@@ -4,30 +4,45 @@ import inquirer from 'inquirer'
|
|
|
4
4
|
import { containerManager } from '../../core/container-manager'
|
|
5
5
|
import { processManager } from '../../core/process-manager'
|
|
6
6
|
import { portManager } from '../../core/port-manager'
|
|
7
|
+
import { getEngine } from '../../engines'
|
|
8
|
+
import { paths } from '../../config/paths'
|
|
7
9
|
import { promptContainerSelect } from '../ui/prompts'
|
|
8
10
|
import { createSpinner } from '../ui/spinner'
|
|
9
|
-
import { error, warning, success } from '../ui/theme'
|
|
11
|
+
import { error, warning, success, info } from '../ui/theme'
|
|
10
12
|
|
|
11
13
|
function isValidName(name: string): boolean {
|
|
12
14
|
return /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Prompt for what to edit when no options provided
|
|
19
|
+
*/
|
|
20
|
+
async function promptEditAction(
|
|
21
|
+
engine: string,
|
|
22
|
+
): Promise<'name' | 'port' | 'config' | null> {
|
|
23
|
+
const choices = [
|
|
24
|
+
{ name: 'Rename container', value: 'name' },
|
|
25
|
+
{ name: 'Change port', value: 'port' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
// Only show config option for engines that support it
|
|
29
|
+
if (engine === 'postgresql') {
|
|
30
|
+
choices.push({ name: 'Edit database config (postgresql.conf)', value: 'config' })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
choices.push({ name: chalk.gray('Cancel'), value: 'cancel' })
|
|
34
|
+
|
|
16
35
|
const { action } = await inquirer.prompt<{ action: string }>([
|
|
17
36
|
{
|
|
18
37
|
type: 'list',
|
|
19
38
|
name: 'action',
|
|
20
39
|
message: 'What would you like to edit?',
|
|
21
|
-
choices
|
|
22
|
-
{ name: 'Rename container', value: 'name' },
|
|
23
|
-
{ name: 'Change port', value: 'port' },
|
|
24
|
-
{ name: chalk.gray('Cancel'), value: 'cancel' },
|
|
25
|
-
],
|
|
40
|
+
choices,
|
|
26
41
|
},
|
|
27
42
|
])
|
|
28
43
|
|
|
29
44
|
if (action === 'cancel') return null
|
|
30
|
-
return action as 'name' | 'port'
|
|
45
|
+
return action as 'name' | 'port' | 'config'
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
async function promptNewName(currentName: string): Promise<string | null> {
|
|
@@ -55,6 +70,74 @@ async function promptNewName(currentName: string): Promise<string | null> {
|
|
|
55
70
|
return newName
|
|
56
71
|
}
|
|
57
72
|
|
|
73
|
+
// Common PostgreSQL config settings that users might want to edit
|
|
74
|
+
const COMMON_PG_SETTINGS = [
|
|
75
|
+
{ name: 'max_connections', description: 'Maximum concurrent connections', default: '200' },
|
|
76
|
+
{ name: 'shared_buffers', description: 'Memory for shared buffers', default: '128MB' },
|
|
77
|
+
{ name: 'work_mem', description: 'Memory per operation', default: '4MB' },
|
|
78
|
+
{ name: 'maintenance_work_mem', description: 'Memory for maintenance ops', default: '64MB' },
|
|
79
|
+
{ name: 'effective_cache_size', description: 'Planner cache size estimate', default: '4GB' },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Prompt for PostgreSQL config setting to edit
|
|
84
|
+
*/
|
|
85
|
+
async function promptConfigSetting(): Promise<{ key: string; value: string } | null> {
|
|
86
|
+
const choices = COMMON_PG_SETTINGS.map((s) => ({
|
|
87
|
+
name: `${s.name.padEnd(25)} ${chalk.gray(s.description)}`,
|
|
88
|
+
value: s.name,
|
|
89
|
+
}))
|
|
90
|
+
choices.push({ name: chalk.cyan('Custom setting...'), value: '__custom__' })
|
|
91
|
+
choices.push({ name: chalk.gray('Cancel'), value: '__cancel__' })
|
|
92
|
+
|
|
93
|
+
const { setting } = await inquirer.prompt<{ setting: string }>([
|
|
94
|
+
{
|
|
95
|
+
type: 'list',
|
|
96
|
+
name: 'setting',
|
|
97
|
+
message: 'Select setting to edit:',
|
|
98
|
+
choices,
|
|
99
|
+
},
|
|
100
|
+
])
|
|
101
|
+
|
|
102
|
+
if (setting === '__cancel__') return null
|
|
103
|
+
|
|
104
|
+
let key = setting
|
|
105
|
+
if (setting === '__custom__') {
|
|
106
|
+
const { customKey } = await inquirer.prompt<{ customKey: string }>([
|
|
107
|
+
{
|
|
108
|
+
type: 'input',
|
|
109
|
+
name: 'customKey',
|
|
110
|
+
message: 'Setting name:',
|
|
111
|
+
validate: (input: string) => {
|
|
112
|
+
if (!input.trim()) return 'Setting name is required'
|
|
113
|
+
if (!/^[a-z_]+$/.test(input)) return 'Setting names are lowercase with underscores'
|
|
114
|
+
return true
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
])
|
|
118
|
+
key = customKey
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const defaultValue = COMMON_PG_SETTINGS.find((s) => s.name === key)?.default || ''
|
|
122
|
+
const { value } = await inquirer.prompt<{ value: string }>([
|
|
123
|
+
{
|
|
124
|
+
type: 'input',
|
|
125
|
+
name: 'value',
|
|
126
|
+
message: `Value for ${key}:`,
|
|
127
|
+
default: defaultValue,
|
|
128
|
+
validate: (input: string) => {
|
|
129
|
+
if (!input.trim()) return 'Value is required'
|
|
130
|
+
return true
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
return { key, value }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Prompt for new port
|
|
140
|
+
*/
|
|
58
141
|
async function promptNewPort(currentPort: number): Promise<number | null> {
|
|
59
142
|
const { newPort } = await inquirer.prompt<{ newPort: number }>([
|
|
60
143
|
{
|
|
@@ -91,14 +174,18 @@ async function promptNewPort(currentPort: number): Promise<number | null> {
|
|
|
91
174
|
}
|
|
92
175
|
|
|
93
176
|
export const editCommand = new Command('edit')
|
|
94
|
-
.description('Edit container properties (rename or
|
|
177
|
+
.description('Edit container properties (rename, port, or database config)')
|
|
95
178
|
.argument('[name]', 'Container name')
|
|
96
179
|
.option('-n, --name <newName>', 'New container name')
|
|
97
180
|
.option('-p, --port <port>', 'New port number', parseInt)
|
|
181
|
+
.option(
|
|
182
|
+
'--set-config <setting>',
|
|
183
|
+
'Set a database config value (e.g., max_connections=200)',
|
|
184
|
+
)
|
|
98
185
|
.action(
|
|
99
186
|
async (
|
|
100
187
|
name: string | undefined,
|
|
101
|
-
options: { name?: string; port?: number },
|
|
188
|
+
options: { name?: string; port?: number; setConfig?: string },
|
|
102
189
|
) => {
|
|
103
190
|
try {
|
|
104
191
|
let containerName = name
|
|
@@ -125,8 +212,13 @@ export const editCommand = new Command('edit')
|
|
|
125
212
|
process.exit(1)
|
|
126
213
|
}
|
|
127
214
|
|
|
128
|
-
|
|
129
|
-
|
|
215
|
+
// If no options provided, prompt for what to edit
|
|
216
|
+
if (
|
|
217
|
+
options.name === undefined &&
|
|
218
|
+
options.port === undefined &&
|
|
219
|
+
options.setConfig === undefined
|
|
220
|
+
) {
|
|
221
|
+
const action = await promptEditAction(config.engine)
|
|
130
222
|
if (!action) return
|
|
131
223
|
|
|
132
224
|
if (action === 'name') {
|
|
@@ -143,6 +235,13 @@ export const editCommand = new Command('edit')
|
|
|
143
235
|
} else {
|
|
144
236
|
return
|
|
145
237
|
}
|
|
238
|
+
} else if (action === 'config') {
|
|
239
|
+
const configSetting = await promptConfigSetting()
|
|
240
|
+
if (configSetting) {
|
|
241
|
+
options.setConfig = `${configSetting.key}=${configSetting.value}`
|
|
242
|
+
} else {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
146
245
|
}
|
|
147
246
|
}
|
|
148
247
|
|
|
@@ -218,6 +317,75 @@ export const editCommand = new Command('edit')
|
|
|
218
317
|
)
|
|
219
318
|
}
|
|
220
319
|
|
|
320
|
+
// Handle config change
|
|
321
|
+
if (options.setConfig) {
|
|
322
|
+
// Only PostgreSQL supports config editing for now
|
|
323
|
+
if (config.engine !== 'postgresql') {
|
|
324
|
+
console.error(
|
|
325
|
+
error(`Config editing is only supported for PostgreSQL containers`),
|
|
326
|
+
)
|
|
327
|
+
process.exit(1)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Parse the setting (key=value format)
|
|
331
|
+
const match = options.setConfig.match(/^([a-z_]+)=(.+)$/)
|
|
332
|
+
if (!match) {
|
|
333
|
+
console.error(
|
|
334
|
+
error(
|
|
335
|
+
'Invalid config format. Use: --set-config key=value (e.g., max_connections=200)',
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
process.exit(1)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const [, configKey, configValue] = match
|
|
342
|
+
|
|
343
|
+
// Get the PostgreSQL engine to update config
|
|
344
|
+
const engine = getEngine(config.engine)
|
|
345
|
+
const dataDir = paths.getContainerDataPath(containerName, {
|
|
346
|
+
engine: config.engine,
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
const spinner = createSpinner(
|
|
350
|
+
`Setting ${configKey} = ${configValue}...`,
|
|
351
|
+
)
|
|
352
|
+
spinner.start()
|
|
353
|
+
|
|
354
|
+
// Use the PostgreSQL engine's setConfigValue method
|
|
355
|
+
if ('setConfigValue' in engine) {
|
|
356
|
+
await (engine as { setConfigValue: (dataDir: string, key: string, value: string) => Promise<void> }).setConfigValue(
|
|
357
|
+
dataDir,
|
|
358
|
+
configKey,
|
|
359
|
+
configValue,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
spinner.succeed(`Set ${configKey} = ${configValue}`)
|
|
364
|
+
|
|
365
|
+
// Check if container is running and warn about restart
|
|
366
|
+
const running = await processManager.isRunning(containerName, {
|
|
367
|
+
engine: config.engine,
|
|
368
|
+
})
|
|
369
|
+
if (running) {
|
|
370
|
+
console.log(
|
|
371
|
+
info(
|
|
372
|
+
' Note: Restart the container for changes to take effect.',
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
console.log(
|
|
376
|
+
chalk.gray(
|
|
377
|
+
` spindb stop ${containerName} && spindb start ${containerName}`,
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
} else {
|
|
381
|
+
console.log(
|
|
382
|
+
chalk.gray(
|
|
383
|
+
' Config change will take effect on next container start.',
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
221
389
|
console.log()
|
|
222
390
|
console.log(success('Container updated successfully'))
|
|
223
391
|
} catch (err) {
|
|
@@ -26,6 +26,8 @@ export type EngineDefaults = {
|
|
|
26
26
|
dataSubdir: string
|
|
27
27
|
/** Client tools required for this engine */
|
|
28
28
|
clientTools: string[]
|
|
29
|
+
/** Default max connections (higher than PostgreSQL default of 100 for parallel builds) */
|
|
30
|
+
maxConnections: number
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export const engineDefaults: Record<string, EngineDefaults> = {
|
|
@@ -41,6 +43,7 @@ export const engineDefaults: Record<string, EngineDefaults> = {
|
|
|
41
43
|
pidFileName: 'postmaster.pid',
|
|
42
44
|
dataSubdir: 'data',
|
|
43
45
|
clientTools: ['psql', 'pg_dump', 'pg_restore', 'pg_basebackup'],
|
|
46
|
+
maxConnections: 200, // Higher than default 100 for parallel builds (Next.js, etc.)
|
|
44
47
|
},
|
|
45
48
|
mysql: {
|
|
46
49
|
defaultVersion: '9.0',
|
|
@@ -54,6 +57,7 @@ export const engineDefaults: Record<string, EngineDefaults> = {
|
|
|
54
57
|
pidFileName: 'mysql.pid',
|
|
55
58
|
dataSubdir: 'data',
|
|
56
59
|
clientTools: ['mysql', 'mysqldump', 'mysqlpump'],
|
|
60
|
+
maxConnections: 200, // Higher than default 151 for parallel builds
|
|
57
61
|
},
|
|
58
62
|
}
|
|
59
63
|
|
package/engines/mysql/index.ts
CHANGED
|
@@ -274,6 +274,7 @@ export class MySQLEngine extends BaseEngine {
|
|
|
274
274
|
`--pid-file=${pidFile}`,
|
|
275
275
|
`--log-error=${logFile}`,
|
|
276
276
|
'--bind-address=127.0.0.1',
|
|
277
|
+
`--max-connections=${engineDef.maxConnections}`, // Higher than default 151 for parallel builds
|
|
277
278
|
]
|
|
278
279
|
|
|
279
280
|
return new Promise((resolve, reject) => {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { join } from 'path'
|
|
2
2
|
import { spawn, exec } from 'child_process'
|
|
3
3
|
import { promisify } from 'util'
|
|
4
|
+
import { readFile, writeFile } from 'fs/promises'
|
|
4
5
|
import { BaseEngine } from '../base-engine'
|
|
5
6
|
import { binaryManager } from '../../core/binary-manager'
|
|
6
7
|
import { processManager } from '../../core/process-manager'
|
|
7
8
|
import { configManager } from '../../core/config-manager'
|
|
8
9
|
import { platformService } from '../../core/platform-service'
|
|
9
10
|
import { paths } from '../../config/paths'
|
|
10
|
-
import { defaults } from '../../config/defaults'
|
|
11
|
+
import { defaults, getEngineDefaults } from '../../config/defaults'
|
|
11
12
|
import {
|
|
12
13
|
getBinaryUrl,
|
|
13
14
|
SUPPORTED_MAJOR_VERSIONS,
|
|
@@ -151,9 +152,70 @@ export class PostgreSQLEngine extends BaseEngine {
|
|
|
151
152
|
superuser: (options.superuser as string) || defaults.superuser,
|
|
152
153
|
})
|
|
153
154
|
|
|
155
|
+
// Configure max_connections after initdb creates postgresql.conf
|
|
156
|
+
const maxConnections =
|
|
157
|
+
(options.maxConnections as number) || getEngineDefaults('postgresql').maxConnections
|
|
158
|
+
await this.setConfigValue(dataDir, 'max_connections', String(maxConnections))
|
|
159
|
+
|
|
154
160
|
return dataDir
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Get the path to postgresql.conf for a container
|
|
165
|
+
*/
|
|
166
|
+
getConfigPath(containerName: string): string {
|
|
167
|
+
const dataDir = paths.getContainerDataPath(containerName, {
|
|
168
|
+
engine: this.name,
|
|
169
|
+
})
|
|
170
|
+
return join(dataDir, 'postgresql.conf')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Set a configuration value in postgresql.conf
|
|
175
|
+
* If the setting exists (commented or not), it updates the line.
|
|
176
|
+
* If not found, appends it to the end of the file.
|
|
177
|
+
*/
|
|
178
|
+
async setConfigValue(
|
|
179
|
+
dataDir: string,
|
|
180
|
+
key: string,
|
|
181
|
+
value: string,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const configPath = join(dataDir, 'postgresql.conf')
|
|
184
|
+
let content = await readFile(configPath, 'utf8')
|
|
185
|
+
|
|
186
|
+
// Match both commented (#key = ...) and uncommented (key = ...) lines
|
|
187
|
+
const regex = new RegExp(`^#?\\s*${key}\\s*=.*$`, 'm')
|
|
188
|
+
|
|
189
|
+
if (regex.test(content)) {
|
|
190
|
+
// Update existing line (commented or not)
|
|
191
|
+
content = content.replace(regex, `${key} = ${value}`)
|
|
192
|
+
} else {
|
|
193
|
+
// Append to end of file
|
|
194
|
+
content = content.trimEnd() + `\n${key} = ${value}\n`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await writeFile(configPath, content, 'utf8')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get a configuration value from postgresql.conf
|
|
202
|
+
* Returns null if not found or commented out
|
|
203
|
+
*/
|
|
204
|
+
async getConfigValue(dataDir: string, key: string): Promise<string | null> {
|
|
205
|
+
const configPath = join(dataDir, 'postgresql.conf')
|
|
206
|
+
const content = await readFile(configPath, 'utf8')
|
|
207
|
+
|
|
208
|
+
// Match only uncommented lines
|
|
209
|
+
const regex = new RegExp(`^${key}\\s*=\\s*(.+?)\\s*(?:#.*)?$`, 'm')
|
|
210
|
+
const match = content.match(regex)
|
|
211
|
+
|
|
212
|
+
if (match) {
|
|
213
|
+
// Remove quotes if present
|
|
214
|
+
return match[1].replace(/^['"]|['"]$/g, '')
|
|
215
|
+
}
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
/**
|
|
158
220
|
* Start PostgreSQL server
|
|
159
221
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spindb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Spin up local database containers without Docker. A DBngin-like CLI for PostgreSQL and MySQL.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"test:integration": "pnpm test:pg && pnpm test:mysql",
|
|
17
17
|
"test:all": "pnpm test:unit && pnpm test:integration",
|
|
18
18
|
"format": "prettier --write .",
|
|
19
|
-
"lint": "tsc --noEmit && eslint ."
|
|
19
|
+
"lint": "tsc --noEmit && eslint .",
|
|
20
|
+
"prepare": "husky"
|
|
20
21
|
},
|
|
21
22
|
"keywords": [
|
|
22
23
|
"postgres",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"@types/inquirer": "^9.0.7",
|
|
55
56
|
"@types/node": "^20.10.0",
|
|
56
57
|
"eslint": "^9.39.1",
|
|
58
|
+
"husky": "^9.1.7",
|
|
57
59
|
"prettier": "^3.6.2",
|
|
58
60
|
"typescript": "^5.3.0",
|
|
59
61
|
"typescript-eslint": "^8.48.0"
|