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 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 change port
336
+ #### `edit` - Rename, change port, or edit database config
336
337
 
337
338
  ```bash
338
- spindb edit mydb --name newname # Must be stopped
339
+ spindb edit mydb --name newname # Must be stopped
339
340
  spindb edit mydb --port 5433
340
- spindb edit mydb # Interactive mode
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
@@ -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) {
@@ -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
- async function promptEditAction(): Promise<'name' | 'port' | null> {
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 change port)')
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
- if (options.name === undefined && options.port === undefined) {
129
- const action = await promptEditAction()
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
 
@@ -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.7.5",
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"