spindb 0.9.3 → 0.10.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.
@@ -4,7 +4,7 @@
4
4
  * Handles detecting backup formats and restoring MySQL dumps.
5
5
  */
6
6
 
7
- import { spawn } from 'child_process'
7
+ import { spawn, type SpawnOptions } from 'child_process'
8
8
  import { createReadStream } from 'fs'
9
9
  import { open } from 'fs/promises'
10
10
  import { createGunzip } from 'zlib'
@@ -202,12 +202,20 @@ export async function restoreBackup(
202
202
  // Restore using mysql client
203
203
  // CLI: mysql -h 127.0.0.1 -P {port} -u root {db} < {file}
204
204
  // For compressed files: gunzip -c {file} | mysql ...
205
+
206
+ // Use plain spawn (no shell) so the executable path can be quoted when
207
+ // it contains spaces (e.g., 'C:\Program Files\...'). Using shell:true
208
+ // previously caused quoting issues on Windows.
209
+ const spawnOptions: SpawnOptions = {
210
+ stdio: ['pipe', 'pipe', 'pipe'],
211
+ }
212
+
205
213
  return new Promise((resolve, reject) => {
206
214
  const args = ['-h', '127.0.0.1', '-P', String(port), '-u', user, database]
207
215
 
208
- const proc = spawn(mysql, args, {
209
- stdio: ['pipe', 'pipe', 'pipe'],
210
- })
216
+ logDebug('Restoring backup with mysql', { mysql, args, spawnOptions })
217
+
218
+ const proc = spawn(mysql, args, spawnOptions)
211
219
 
212
220
  // Pipe backup file to stdin, decompressing if necessary
213
221
  const fileStream = createReadStream(backupPath)
@@ -215,6 +223,10 @@ export async function restoreBackup(
215
223
  if (format.format === 'compressed') {
216
224
  // Decompress gzipped file before piping to mysql
217
225
  const gunzip = createGunzip()
226
+ if (!proc.stdin) {
227
+ reject(new Error('MySQL process stdin is not available, cannot restore backup'))
228
+ return
229
+ }
218
230
  fileStream.pipe(gunzip).pipe(proc.stdin)
219
231
 
220
232
  // Handle gunzip errors
@@ -222,16 +234,20 @@ export async function restoreBackup(
222
234
  reject(new Error(`Failed to decompress backup file: ${err.message}`))
223
235
  })
224
236
  } else {
237
+ if (!proc.stdin) {
238
+ reject(new Error('MySQL process stdin is not available, cannot restore backup'))
239
+ return
240
+ }
225
241
  fileStream.pipe(proc.stdin)
226
242
  }
227
243
 
228
244
  let stdout = ''
229
245
  let stderr = ''
230
246
 
231
- proc.stdout.on('data', (data: Buffer) => {
247
+ proc.stdout?.on('data', (data: Buffer) => {
232
248
  stdout += data.toString()
233
249
  })
234
- proc.stderr.on('data', (data: Buffer) => {
250
+ proc.stderr?.on('data', (data: Buffer) => {
235
251
  stderr += data.toString()
236
252
  })
237
253
 
@@ -4,9 +4,10 @@
4
4
  * Creates database backups in SQL or custom (.dump) format using pg_dump.
5
5
  */
6
6
 
7
- import { spawn } from 'child_process'
7
+ import { spawn, type SpawnOptions } from 'child_process'
8
8
  import { stat } from 'fs/promises'
9
9
  import { configManager } from '../../core/config-manager'
10
+ import { getWindowsSpawnOptions } from '../../core/platform-service'
10
11
  import { defaults } from '../../config/defaults'
11
12
  import type { ContainerConfig, BackupOptions, BackupResult } from '../../types'
12
13
 
@@ -61,9 +62,12 @@ export async function createBackup(
61
62
  outputPath,
62
63
  ]
63
64
 
64
- const proc = spawn(pgDumpPath, args, {
65
+ const spawnOptions: SpawnOptions = {
65
66
  stdio: ['pipe', 'pipe', 'pipe'],
66
- })
67
+ ...getWindowsSpawnOptions(),
68
+ }
69
+
70
+ const proc = spawn(pgDumpPath, args, spawnOptions)
67
71
 
68
72
  let stderr = ''
69
73
 
@@ -12,6 +12,7 @@ import {
12
12
  import { getEngineDependencies } from '../../config/os-dependencies'
13
13
  import { getPostgresHomebrewPackage } from '../../config/engine-defaults'
14
14
  import { logDebug } from '../../core/error-handler'
15
+ import { isWindows, platformService } from '../../core/platform-service'
15
16
 
16
17
  const execAsync = promisify(exec)
17
18
 
@@ -112,7 +113,7 @@ export async function getPostgresVersion(
112
113
  */
113
114
  export async function findBinaryPath(binary: string): Promise<string | null> {
114
115
  try {
115
- const command = process.platform === 'win32' ? 'where' : 'which'
116
+ const command = isWindows() ? 'where' : 'which'
116
117
  const { stdout } = await execAsync(`${command} ${binary}`)
117
118
  return stdout.trim().split('\n')[0] || null
118
119
  } catch (error) {
@@ -133,12 +134,18 @@ export async function findBinaryPathFresh(
133
134
  const path = await findBinaryPath(binary)
134
135
  if (path) return path
135
136
 
136
- // If not found, try to refresh PATH cache (especially after package updates)
137
+ // Windows doesn't need shell refresh - PATH is always current
138
+ if (isWindows()) {
139
+ return null
140
+ }
141
+
142
+ // If not found on Unix, try to refresh PATH cache (especially after package updates)
137
143
  try {
138
144
  // Force shell to re-evaluate PATH
139
145
  const shell = process.env.SHELL || '/bin/bash'
146
+ const shellConfig = shell.endsWith('zsh') ? '.zshrc' : '.bashrc'
140
147
  const { stdout } = await execWithTimeout(
141
- `${shell} -c 'source ~/.${shell.endsWith('zsh') ? 'zshrc' : 'bashrc'} && which ${binary}'`,
148
+ `${shell} -c 'source ~/${shellConfig} && which ${binary}'`,
142
149
  )
143
150
  return stdout.trim().split('\n')[0] || null
144
151
  } catch (error) {
@@ -171,7 +178,7 @@ async function execWithTimeout(
171
178
 
172
179
  // Additional timeout safety
173
180
  setTimeout(() => {
174
- child.kill('SIGTERM')
181
+ child.kill() // Cross-platform process termination
175
182
  reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
176
183
  }, timeoutMs)
177
184
  })
@@ -179,36 +186,44 @@ async function execWithTimeout(
179
186
 
180
187
  /**
181
188
  * Parse dump file to get required PostgreSQL version
189
+ * Cross-platform: reads file header directly using Node.js
182
190
  */
183
191
  export async function getDumpRequiredVersion(
184
192
  dumpPath: string,
185
193
  ): Promise<string | null> {
186
194
  try {
187
- // Try to read pg_dump custom format header
188
- const { stdout } = await execAsync(`file "${dumpPath}"`)
189
- if (stdout.includes('PostgreSQL custom database dump')) {
190
- // For custom format, we need to check the version in the dump
191
- try {
192
- const { stdout: hexdump } = await execAsync(
193
- `hexdump -C "${dumpPath}" | head -5`,
194
- )
195
- // Look for version info in the header (simplified approach)
196
- const versionMatch = hexdump.match(/(\d+)\.(\d+)/)
197
- if (versionMatch) {
198
- // If it's a recent dump, assume it needs the latest PostgreSQL
199
- const majorVersion = parseInt(versionMatch[1])
200
- if (majorVersion >= 15) {
201
- return '15.0' // Minimum version for recent dumps
202
- }
203
- }
204
- } catch (error) {
205
- // If hexdump fails, fall back to checking error patterns
206
- logDebug(`hexdump failed for ${dumpPath}`, {
207
- error: error instanceof Error ? error.message : String(error),
208
- })
195
+ // Read first 32 bytes of the file to check for PostgreSQL custom dump format
196
+ // Custom dump magic: "PGDMP" followed by version info
197
+ const { open } = await import('fs/promises')
198
+ const fileHandle = await open(dumpPath, 'r')
199
+ const buffer = Buffer.alloc(32)
200
+ await fileHandle.read(buffer, 0, 32, 0)
201
+ await fileHandle.close()
202
+
203
+ // Check for PostgreSQL custom dump format magic bytes: "PGDMP"
204
+ const header = buffer.toString('ascii', 0, 5)
205
+ if (header === 'PGDMP') {
206
+ // Bytes 5-6 contain version info in custom format
207
+ // Byte 5: major version, Byte 6: minor version, Byte 7: revision
208
+ const dumpMajor = buffer[5]
209
+ const dumpMinor = buffer[6]
210
+
211
+ logDebug(`Detected pg_dump custom format version: ${dumpMajor}.${dumpMinor}`)
212
+
213
+ // pg_dump version typically maps to PostgreSQL version
214
+ // If it's a recent dump format, require a recent PostgreSQL
215
+ if (dumpMajor >= 1 && dumpMinor >= 14) {
216
+ return '15.0' // Modern dump format needs modern PostgreSQL
209
217
  }
210
218
  }
211
219
 
220
+ // For plain SQL dumps or older formats, check if file looks like SQL
221
+ const textHeader = buffer.toString('utf-8', 0, 20)
222
+ if (textHeader.includes('--') || textHeader.includes('pg_dump')) {
223
+ // Plain SQL dump - any PostgreSQL version should work
224
+ return null
225
+ }
226
+
212
227
  // Fallback: if we can't determine, assume it needs a recent version
213
228
  return '15.0'
214
229
  } catch (error) {
@@ -229,9 +244,8 @@ export function isVersionCompatible(
229
244
  const current = parseFloat(currentVersion)
230
245
  const required = parseFloat(requiredVersion)
231
246
 
232
- // Current version should be >= required version
233
- // But not too far ahead (major version compatibility)
234
- return current >= required && Math.floor(current) === Math.floor(required)
247
+ // PostgreSQL is forward-compatible: newer pg_restore can restore older dumps
248
+ return current >= required
235
249
  }
236
250
 
237
251
  /**
@@ -264,8 +278,9 @@ export async function getBinaryInfo(
264
278
 
265
279
  // Try to detect which package manager installed this binary
266
280
  let packageManager: string | undefined
281
+ const { platform } = platformService.getPlatformInfo()
267
282
  try {
268
- if (process.platform === 'darwin') {
283
+ if (platform === 'darwin') {
269
284
  // On macOS, check if it's from Homebrew
270
285
  const { stdout } = await execAsync(
271
286
  'brew list postgresql@* 2>/dev/null || brew list libpq 2>/dev/null || true',
@@ -273,7 +288,7 @@ export async function getBinaryInfo(
273
288
  if (stdout.includes('postgresql') || stdout.includes('libpq')) {
274
289
  packageManager = 'brew'
275
290
  }
276
- } else {
291
+ } else if (platform === 'linux') {
277
292
  // On Linux, check common package managers
278
293
  try {
279
294
  await execAsync('dpkg -S $(which pg_restore) 2>/dev/null')
@@ -287,6 +302,7 @@ export async function getBinaryInfo(
287
302
  }
288
303
  }
289
304
  }
305
+ // On Windows (win32), we don't detect package managers - user installs via choco/scoop/winget
290
306
  } catch {
291
307
  // Could not determine package manager
292
308
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * EDB Binary URL Builder for Windows
3
+ *
4
+ * EnterpriseDB (EDB) provides official PostgreSQL binaries for Windows.
5
+ * Unlike zonky.io which uses predictable Maven URLs, EDB uses file IDs.
6
+ *
7
+ * Download page: https://www.enterprisedb.com/download-postgresql-binaries
8
+ */
9
+
10
+ import {
11
+ POSTGRESQL_VERSION_MAP,
12
+ SUPPORTED_MAJOR_VERSIONS,
13
+ } from './version-maps'
14
+
15
+ /**
16
+ * Mapping of PostgreSQL versions to EDB file IDs
17
+ * These IDs are from the EDB download page and need to be updated when new versions are released.
18
+ *
19
+ * Format: 'version' -> 'fileId'
20
+ *
21
+ * IMPORTANT: When updating POSTGRESQL_VERSION_MAP in version-maps.ts,
22
+ * also update this map with the corresponding EDB file IDs.
23
+ */
24
+ export const EDB_FILE_IDS: Record<string, string> = {
25
+ // PostgreSQL 17.x
26
+ '17.7.0': '1259911',
27
+ '17': '1259911', // Alias for latest 17.x
28
+
29
+ // PostgreSQL 16.x
30
+ '16.11.0': '1259906',
31
+ '16': '1259906', // Alias for latest 16.x
32
+
33
+ // PostgreSQL 15.x
34
+ '15.15.0': '1259903',
35
+ '15': '1259903', // Alias for latest 15.x
36
+
37
+ // PostgreSQL 14.x
38
+ '14.20.0': '1259900',
39
+ '14': '1259900', // Alias for latest 14.x
40
+ }
41
+
42
+ // Re-export for backwards compatibility
43
+ export { SUPPORTED_MAJOR_VERSIONS as WINDOWS_SUPPORTED_VERSIONS }
44
+
45
+ /**
46
+ * Get the EDB download URL for a PostgreSQL version on Windows
47
+ *
48
+ * @param version - PostgreSQL version (e.g., '17', '17.7.0')
49
+ * @returns Download URL for the Windows binary ZIP
50
+ * @throws Error if version is not supported
51
+ */
52
+ export function getEDBBinaryUrl(version: string): string {
53
+ // Try direct lookup first
54
+ let fileId = EDB_FILE_IDS[version]
55
+
56
+ // If not found, try to normalize version
57
+ if (!fileId) {
58
+ // Try major version
59
+ const major = version.split('.')[0]
60
+ const fullVersion = POSTGRESQL_VERSION_MAP[major]
61
+ if (fullVersion) {
62
+ fileId = EDB_FILE_IDS[fullVersion]
63
+ }
64
+ }
65
+
66
+ if (!fileId) {
67
+ throw new Error(
68
+ `Unsupported PostgreSQL version for Windows: ${version}. ` +
69
+ `Supported versions: ${SUPPORTED_MAJOR_VERSIONS.join(', ')}`,
70
+ )
71
+ }
72
+
73
+ return `https://sbp.enterprisedb.com/getfile.jsp?fileid=${fileId}`
74
+ }
75
+
76
+ /**
77
+ * Get the full version string for a major version on Windows
78
+ *
79
+ * @param majorVersion - Major version (e.g., '17')
80
+ * @returns Full version string (e.g., '17.7.0') or null if not supported
81
+ */
82
+ export function getWindowsFullVersion(majorVersion: string): string | null {
83
+ return POSTGRESQL_VERSION_MAP[majorVersion] || null
84
+ }
85
+
86
+ /**
87
+ * Check if a version is supported on Windows
88
+ *
89
+ * @param version - Version to check (major or full)
90
+ * @returns true if the version is supported AND has an EDB file ID
91
+ */
92
+ export function isWindowsVersionSupported(version: string): boolean {
93
+ // Check if we have a file ID for this version directly
94
+ if (EDB_FILE_IDS[version]) {
95
+ return true
96
+ }
97
+
98
+ // Check if it's a major version that maps to a full version with a file ID
99
+ const major = version.split('.')[0]
100
+ const fullVersion = POSTGRESQL_VERSION_MAP[major]
101
+ if (fullVersion && EDB_FILE_IDS[fullVersion]) {
102
+ return true
103
+ }
104
+
105
+ return false
106
+ }
107
+
108
+ /**
109
+ * Get available Windows versions (for display purposes)
110
+ *
111
+ * @returns Record of major versions to their full versions
112
+ */
113
+ export function getAvailableWindowsVersions(): Record<string, string[]> {
114
+ const grouped: Record<string, string[]> = {}
115
+ for (const major of SUPPORTED_MAJOR_VERSIONS) {
116
+ const fullVersion = POSTGRESQL_VERSION_MAP[major]
117
+ // Only include if we have a file ID for it
118
+ if (fullVersion && EDB_FILE_IDS[fullVersion]) {
119
+ grouped[major] = [fullVersion]
120
+ }
121
+ }
122
+ return grouped
123
+ }
@@ -1,12 +1,17 @@
1
1
  import { join } from 'path'
2
- import { spawn, exec } from 'child_process'
2
+ import { spawn, exec, type SpawnOptions } from 'child_process'
3
3
  import { promisify } from 'util'
4
+ import { existsSync } from 'fs'
4
5
  import { readFile, writeFile } from 'fs/promises'
5
6
  import { BaseEngine } from '../base-engine'
6
7
  import { binaryManager } from '../../core/binary-manager'
7
8
  import { processManager } from '../../core/process-manager'
8
9
  import { configManager } from '../../core/config-manager'
9
- import { platformService } from '../../core/platform-service'
10
+ import {
11
+ platformService,
12
+ isWindows,
13
+ getWindowsSpawnOptions,
14
+ } from '../../core/platform-service'
10
15
  import { paths } from '../../config/paths'
11
16
  import { defaults, getEngineDefaults } from '../../config/defaults'
12
17
  import {
@@ -32,6 +37,34 @@ import type {
32
37
 
33
38
  const execAsync = promisify(exec)
34
39
 
40
+ /**
41
+ * Build a Windows-safe psql command string for either a file or inline SQL.
42
+ * This is exported for unit testing.
43
+ */
44
+ export function buildWindowsPsqlCommand(
45
+ psqlPath: string,
46
+ port: number,
47
+ user: string,
48
+ db: string,
49
+ options: { file?: string; sql?: string },
50
+ ): string {
51
+ if (!options.file && !options.sql) {
52
+ throw new Error('Either file or sql option must be provided')
53
+ }
54
+
55
+ let cmd = `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${user} -d ${db}`
56
+
57
+ if (options.file) {
58
+ cmd += ` -f "${options.file}"`
59
+ } else if (options.sql) {
60
+ // Escape double quotes in the SQL so the outer double quotes are preserved
61
+ const escaped = options.sql.replace(/"/g, '\\"')
62
+ cmd += ` -c "${escaped}"`
63
+ }
64
+
65
+ return cmd
66
+ }
67
+
35
68
  export class PostgreSQLEngine extends BaseEngine {
36
69
  name = 'postgresql'
37
70
  displayName = 'PostgreSQL'
@@ -118,13 +151,27 @@ export class PostgreSQLEngine extends BaseEngine {
118
151
 
119
152
  /**
120
153
  * Ensure PostgreSQL binaries are available
154
+ * Also registers client tools (psql, pg_dump, etc.) in config after download
121
155
  */
122
156
  async ensureBinaries(
123
157
  version: string,
124
158
  onProgress?: ProgressCallback,
125
159
  ): Promise<string> {
126
160
  const { platform: p, arch: a } = this.getPlatformInfo()
127
- return binaryManager.ensureInstalled(version, p, a, onProgress)
161
+ const binPath = await binaryManager.ensureInstalled(version, p, a, onProgress)
162
+
163
+ // Register client tools from downloaded binaries in config
164
+ // This ensures dependency checks find them without requiring system installation
165
+ const ext = platformService.getExecutableExtension()
166
+ const clientTools = ['psql', 'pg_dump', 'pg_restore', 'pg_basebackup'] as const
167
+ for (const tool of clientTools) {
168
+ const toolPath = join(binPath, 'bin', `${tool}${ext}`)
169
+ if (existsSync(toolPath)) {
170
+ await configManager.setBinaryPath(tool, toolPath, 'bundled')
171
+ }
172
+ }
173
+
174
+ return binPath
128
175
  }
129
176
 
130
177
  /**
@@ -144,7 +191,8 @@ export class PostgreSQLEngine extends BaseEngine {
144
191
  options: Record<string, unknown> = {},
145
192
  ): Promise<string> {
146
193
  const binPath = this.getBinaryPath(version)
147
- const initdbPath = join(binPath, 'bin', 'initdb')
194
+ const ext = platformService.getExecutableExtension()
195
+ const initdbPath = join(binPath, 'bin', `initdb${ext}`)
148
196
  const dataDir = paths.getContainerDataPath(containerName, {
149
197
  engine: this.name,
150
198
  })
@@ -231,7 +279,8 @@ export class PostgreSQLEngine extends BaseEngine {
231
279
  ): Promise<{ port: number; connectionString: string }> {
232
280
  const { name, version, port } = container
233
281
  const binPath = this.getBinaryPath(version)
234
- const pgCtlPath = join(binPath, 'bin', 'pg_ctl')
282
+ const ext = platformService.getExecutableExtension()
283
+ const pgCtlPath = join(binPath, 'bin', `pg_ctl${ext}`)
235
284
  const dataDir = paths.getContainerDataPath(name, { engine: this.name })
236
285
  const logFile = paths.getContainerLogPath(name, { engine: this.name })
237
286
 
@@ -254,7 +303,8 @@ export class PostgreSQLEngine extends BaseEngine {
254
303
  async stop(container: ContainerConfig): Promise<void> {
255
304
  const { name, version } = container
256
305
  const binPath = this.getBinaryPath(version)
257
- const pgCtlPath = join(binPath, 'bin', 'pg_ctl')
306
+ const ext = platformService.getExecutableExtension()
307
+ const pgCtlPath = join(binPath, 'bin', `pg_ctl${ext}`)
258
308
  const dataDir = paths.getContainerDataPath(name, { engine: this.name })
259
309
 
260
310
  await processManager.stop(pgCtlPath, dataDir)
@@ -266,7 +316,8 @@ export class PostgreSQLEngine extends BaseEngine {
266
316
  async status(container: ContainerConfig): Promise<StatusResult> {
267
317
  const { name, version } = container
268
318
  const binPath = this.getBinaryPath(version)
269
- const pgCtlPath = join(binPath, 'bin', 'pg_ctl')
319
+ const ext = platformService.getExecutableExtension()
320
+ const pgCtlPath = join(binPath, 'bin', `pg_ctl${ext}`)
270
321
  const dataDir = paths.getContainerDataPath(name, { engine: this.name })
271
322
 
272
323
  return processManager.status(pgCtlPath, dataDir)
@@ -370,6 +421,11 @@ export class PostgreSQLEngine extends BaseEngine {
370
421
  const db = database || 'postgres'
371
422
  const psqlPath = await this.getPsqlPath()
372
423
 
424
+ const spawnOptions: SpawnOptions = {
425
+ stdio: 'inherit',
426
+ ...getWindowsSpawnOptions(),
427
+ }
428
+
373
429
  return new Promise((resolve, reject) => {
374
430
  const proc = spawn(
375
431
  psqlPath,
@@ -383,7 +439,7 @@ export class PostgreSQLEngine extends BaseEngine {
383
439
  '-d',
384
440
  db,
385
441
  ],
386
- { stdio: 'inherit' },
442
+ spawnOptions,
387
443
  )
388
444
 
389
445
  proc.on('error', (err: NodeJS.ErrnoException) => {
@@ -405,10 +461,14 @@ export class PostgreSQLEngine extends BaseEngine {
405
461
  const { port } = container
406
462
  const psqlPath = await this.getPsqlPath()
407
463
 
464
+ // On Windows, single quotes don't work in cmd.exe - use double quotes and escape inner quotes
465
+ const sql = `CREATE DATABASE "${database}"`
466
+ const cmd = isWindows()
467
+ ? `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -c "${sql.replace(/"/g, '\\"')}"`
468
+ : `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -c '${sql}'`
469
+
408
470
  try {
409
- await execAsync(
410
- `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -c 'CREATE DATABASE "${database}"'`,
411
- )
471
+ await execAsync(cmd)
412
472
  } catch (error) {
413
473
  const err = error as Error
414
474
  // Ignore "database already exists" error
@@ -429,10 +489,14 @@ export class PostgreSQLEngine extends BaseEngine {
429
489
  const { port } = container
430
490
  const psqlPath = await this.getPsqlPath()
431
491
 
492
+ // On Windows, single quotes don't work in cmd.exe - use double quotes and escape inner quotes
493
+ const sql = `DROP DATABASE IF EXISTS "${database}"`
494
+ const cmd = isWindows()
495
+ ? `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -c "${sql.replace(/"/g, '\\"')}"`
496
+ : `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -c '${sql}'`
497
+
432
498
  try {
433
- await execAsync(
434
- `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -c 'DROP DATABASE IF EXISTS "${database}"'`,
435
- )
499
+ await execAsync(cmd)
436
500
  } catch (error) {
437
501
  const err = error as Error
438
502
  // Ignore "database does not exist" error
@@ -457,9 +521,12 @@ export class PostgreSQLEngine extends BaseEngine {
457
521
  try {
458
522
  const psqlPath = await this.getPsqlPath()
459
523
  // Query pg_database_size for the specific database
460
- const { stdout } = await execAsync(
461
- `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -t -A -c "SELECT pg_database_size('${db}')"`,
462
- )
524
+ // On Windows, use escaped double quotes; on Unix, use single quotes
525
+ const sql = `SELECT pg_database_size('${db}')`
526
+ const cmd = isWindows()
527
+ ? `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -t -A -c "${sql.replace(/'/g, "''")}"`
528
+ : `"${psqlPath}" -h 127.0.0.1 -p ${port} -U ${defaults.superuser} -d postgres -t -A -c "${sql}"`
529
+ const { stdout } = await execAsync(cmd)
463
530
  const size = parseInt(stdout.trim(), 10)
464
531
  return isNaN(size) ? null : size
465
532
  } catch {
@@ -480,13 +547,15 @@ export class PostgreSQLEngine extends BaseEngine {
480
547
  ): Promise<DumpResult> {
481
548
  const pgDumpPath = await this.getPgDumpPath()
482
549
 
550
+ const spawnOptions: SpawnOptions = {
551
+ stdio: ['pipe', 'pipe', 'pipe'],
552
+ ...getWindowsSpawnOptions(),
553
+ }
554
+
483
555
  return new Promise((resolve, reject) => {
484
- // Use custom format (-Fc) for best compatibility and compression
485
556
  const args = [connectionString, '-Fc', '-f', outputPath]
486
557
 
487
- const proc = spawn(pgDumpPath, args, {
488
- stdio: ['pipe', 'pipe', 'pipe'],
489
- })
558
+ const proc = spawn(pgDumpPath, args, spawnOptions)
490
559
 
491
560
  let stdout = ''
492
561
  let stderr = ''
@@ -544,6 +613,20 @@ export class PostgreSQLEngine extends BaseEngine {
544
613
  const db = options.database || container.database || 'postgres'
545
614
  const psqlPath = await this.getPsqlPath()
546
615
 
616
+ // On Windows, build a single command string and use exec to avoid
617
+ // passing an args array with shell:true (DEP0190 and quoting issues).
618
+ if (isWindows()) {
619
+ const cmd = buildWindowsPsqlCommand(psqlPath, port, defaults.superuser, db, options)
620
+ try {
621
+ await execAsync(cmd)
622
+ return
623
+ } catch (error) {
624
+ const err = error as Error
625
+ throw new Error(`psql failed: ${err.message}`)
626
+ }
627
+ }
628
+
629
+ // Non-Windows: spawn directly with args (no shell)
547
630
  const args = [
548
631
  '-h',
549
632
  '127.0.0.1',
@@ -563,8 +646,12 @@ export class PostgreSQLEngine extends BaseEngine {
563
646
  throw new Error('Either file or sql option must be provided')
564
647
  }
565
648
 
649
+ const spawnOptions: SpawnOptions = {
650
+ stdio: 'inherit',
651
+ }
652
+
566
653
  return new Promise((resolve, reject) => {
567
- const proc = spawn(psqlPath, args, { stdio: 'inherit' })
654
+ const proc = spawn(psqlPath, args, spawnOptions)
568
655
 
569
656
  proc.on('error', (err: NodeJS.ErrnoException) => {
570
657
  reject(err)
@@ -0,0 +1,63 @@
1
+ /**
2
+ * PostgreSQL Version Maps
3
+ *
4
+ * Shared version mappings used by both zonky.io (macOS/Linux) and EDB (Windows).
5
+ * Both sources use the same PostgreSQL releases.
6
+ *
7
+ * When updating versions:
8
+ * 1. Check zonky.io Maven Central for new versions:
9
+ * https://repo1.maven.org/maven2/io/zonky/test/postgres/embedded-postgres-binaries-darwin-arm64v8/
10
+ * 2. Check EDB download page for matching Windows versions:
11
+ * https://www.enterprisedb.com/download-postgresql-binaries
12
+ * 3. Update POSTGRESQL_VERSION_MAP with new full versions
13
+ * 4. Update EDB_FILE_IDS in edb-binary-urls.ts with new file IDs
14
+ */
15
+
16
+ /**
17
+ * Map of major PostgreSQL versions to their latest stable patch versions.
18
+ * Used for both zonky.io (macOS/Linux) and EDB (Windows) binaries.
19
+ */
20
+ export const POSTGRESQL_VERSION_MAP: Record<string, string> = {
21
+ '14': '14.20.0',
22
+ '15': '15.15.0',
23
+ '16': '16.11.0',
24
+ '17': '17.7.0',
25
+ }
26
+
27
+ /**
28
+ * Supported major PostgreSQL versions.
29
+ * Derived from POSTGRESQL_VERSION_MAP keys.
30
+ */
31
+ export const SUPPORTED_MAJOR_VERSIONS = Object.keys(POSTGRESQL_VERSION_MAP)
32
+
33
+ /**
34
+ * Get the full version string for a major version.
35
+ *
36
+ * @param majorVersion - Major version (e.g., '17')
37
+ * @returns Full version string (e.g., '17.7.0') or null if not supported
38
+ */
39
+ export function getFullVersion(majorVersion: string): string | null {
40
+ return POSTGRESQL_VERSION_MAP[majorVersion] || null
41
+ }
42
+
43
+ /**
44
+ * Normalize a version string to X.Y.Z format.
45
+ *
46
+ * @param version - Version string (e.g., '17', '17.7', '17.7.0')
47
+ * @returns Normalized version (e.g., '17.7.0')
48
+ */
49
+ export function normalizeVersion(version: string): string {
50
+ // If it's a major version only, use the map
51
+ const fullVersion = POSTGRESQL_VERSION_MAP[version]
52
+ if (fullVersion) {
53
+ return fullVersion
54
+ }
55
+
56
+ // Normalize to X.Y.Z format
57
+ const parts = version.split('.')
58
+ if (parts.length === 2) {
59
+ return `${version}.0`
60
+ }
61
+
62
+ return version
63
+ }