pgserve 1.1.2 → 1.1.3-rc.11

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.
@@ -38,26 +38,23 @@ concurrency:
38
38
  jobs:
39
39
  build:
40
40
  name: Build ${{ matrix.platform }}
41
- runs-on: ubuntu-latest
41
+ runs-on: ${{ matrix.os }}
42
42
  strategy:
43
43
  fail-fast: false
44
44
  matrix:
45
45
  include:
46
- - target: bun-linux-x64
47
- platform: linux-x64
46
+ # Linux x64
47
+ - platform: linux-x64
48
48
  output: pgserve-linux-x64
49
- - target: bun-linux-arm64
50
- platform: linux-arm64
51
- output: pgserve-linux-arm64
52
- - target: bun-darwin-x64
53
- platform: darwin-x64
54
- output: pgserve-darwin-x64
55
- - target: bun-darwin-arm64
56
- platform: darwin-arm64
49
+ os: ubuntu-latest
50
+ # macOS ARM64 (Apple Silicon)
51
+ - platform: darwin-arm64
57
52
  output: pgserve-darwin-arm64
58
- - target: bun-windows-x64
59
- platform: windows-x64
53
+ os: macos-latest
54
+ # Windows x64 - must build on Windows for --windows-icon
55
+ - platform: windows-x64
60
56
  output: pgserve-windows-x64.exe
57
+ os: windows-latest
61
58
 
62
59
  steps:
63
60
  - name: Checkout
@@ -73,10 +70,32 @@ jobs:
73
70
  - name: Install dependencies
74
71
  run: bun install
75
72
 
73
+ # Windows: native build with icon and proper metadata
74
+ - name: Build for Windows (with branding)
75
+ if: matrix.platform == 'windows-x64'
76
+ run: |
77
+ New-Item -ItemType Directory -Force -Path dist | Out-Null
78
+ $RAW_VERSION = node -p "require('./package.json').version"
79
+ # Convert 1.1.3-rc.10 to 1.1.3.10 (Windows requires X.Y.Z.W format)
80
+ $WIN_VERSION = $RAW_VERSION -replace '-rc\.', '.'
81
+ Write-Host "Raw version: $RAW_VERSION -> Windows version: $WIN_VERSION"
82
+ bun build --compile `
83
+ --windows-icon=assets/icon.ico `
84
+ --windows-title="pgserve" `
85
+ --windows-publisher="Namastex Labs" `
86
+ --windows-description="Embedded PostgreSQL Server - Zero config, auto-provision, unlimited connections" `
87
+ --windows-version="$WIN_VERSION" `
88
+ --windows-copyright="Copyright (c) 2025 Namastex Labs" `
89
+ bin/pglite-server.js --outfile dist/${{ matrix.output }}
90
+ Get-ChildItem dist/
91
+ shell: pwsh
92
+
93
+ # Linux/macOS: native build
76
94
  - name: Build for ${{ matrix.platform }}
95
+ if: matrix.platform != 'windows-x64'
77
96
  run: |
78
97
  mkdir -p dist
79
- bun build --compile --target=${{ matrix.target }} bin/pglite-server.js --outfile dist/${{ matrix.output }}
98
+ bun build --compile bin/pglite-server.js --outfile dist/${{ matrix.output }}
80
99
  ls -lh dist/
81
100
 
82
101
  - name: Upload artifact
@@ -129,7 +148,8 @@ jobs:
129
148
  ls -la dist/
130
149
 
131
150
  MISSING=""
132
- for platform in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
151
+ # Supported platforms: linux-x64, darwin-arm64, windows-x64
152
+ for platform in linux-x64 darwin-arm64; do
133
153
  if [ ! -f "dist/pgserve-$platform" ]; then
134
154
  MISSING="$MISSING pgserve-$platform"
135
155
  fi
package/README.md CHANGED
@@ -99,6 +99,17 @@ npm install pgserve
99
99
 
100
100
  > PostgreSQL binaries are automatically downloaded on first run (~100MB).
101
101
 
102
+ ### Windows
103
+
104
+ Download `pgserve-windows-x64.exe` from [GitHub Releases](https://github.com/namastexlabs/pgserve/releases).
105
+
106
+ Double-click to run, or use CLI:
107
+
108
+ ```cmd
109
+ pgserve-windows-x64.exe --port 5432
110
+ pgserve-windows-x64.exe --data C:\pgserve-data
111
+ ```
112
+
102
113
  <br>
103
114
 
104
115
  ## CLI Reference
Binary file
@@ -5,9 +5,12 @@
5
5
  * This wrapper enables `npx pgserve` to work without requiring
6
6
  * users to install bun globally. The bun runtime is bundled as
7
7
  * an npm dependency and this wrapper finds and invokes it.
8
+ *
9
+ * Windows EBUSY fix: Uses synchronous waiting and taskkill for
10
+ * reliable process termination and file handle cleanup.
8
11
  */
9
12
 
10
- const { spawn } = require('child_process');
13
+ const { spawn, execSync } = require('child_process');
11
14
  const path = require('path');
12
15
  const fs = require('fs');
13
16
 
@@ -52,30 +55,120 @@ if (!bunPath) {
52
55
 
53
56
  const scriptPath = path.join(__dirname, 'pglite-server.js');
54
57
 
55
- // Spawn bun with the actual script, inherit all stdio
56
- const child = spawn(bunPath, [scriptPath, ...process.argv.slice(2)], {
57
- stdio: 'inherit',
58
- windowsHide: true
59
- });
58
+ // Platform-specific spawning strategy:
59
+ // - Windows: Use pipes for explicit handle control (prevents EBUSY errors)
60
+ // - Unix: Use inherit for simplicity (works fine)
60
61
 
61
- child.on('error', (err) => {
62
- console.error('Failed to start pgserve:', err.message);
63
- process.exit(1);
64
- });
62
+ if (isWindows) {
63
+ // WINDOWS PATH: Explicit pipe control to prevent EBUSY errors
64
+ // Using stdio: 'inherit' causes file handle inheritance that we cannot release,
65
+ // leading to npm cleanup failures. With pipes, we control when handles are destroyed.
66
+
67
+ const child = spawn(bunPath, [scriptPath, ...process.argv.slice(2)], {
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ windowsHide: true
70
+ });
71
+
72
+ // Manually pipe stdio - we now control the handles
73
+ // Handle stdin errors gracefully (may not be connected in some environments)
74
+ process.stdin.on('error', () => {});
75
+ child.stdin.on('error', () => {});
65
76
 
66
- child.on('exit', (code, signal) => {
67
- if (signal) {
68
- process.kill(process.pid, signal);
69
- } else {
70
- process.exit(code ?? 0);
77
+ // Only pipe stdin if it's readable
78
+ if (process.stdin.readable) {
79
+ process.stdin.pipe(child.stdin);
71
80
  }
72
- });
81
+ child.stdout.pipe(process.stdout);
82
+ child.stderr.pipe(process.stderr);
83
+
84
+ child.on('error', (err) => {
85
+ console.error('Failed to start pgserve:', err.message);
86
+ process.exit(1);
87
+ });
88
+
89
+ child.on('close', (code, signal) => {
90
+ // CRITICAL: Explicitly destroy ALL streams to release file handles
91
+ // This must happen BEFORE process.exit() to prevent EBUSY
92
+ try {
93
+ if (process.stdin.readable) {
94
+ process.stdin.unpipe(child.stdin);
95
+ }
96
+ child.stdin.destroy();
97
+ child.stdout.destroy();
98
+ child.stderr.destroy();
99
+ } catch {
100
+ // Ignore stream destruction errors
101
+ }
102
+
103
+ // Remove all listeners to prevent memory leaks
104
+ child.removeAllListeners();
105
+
106
+ // Use setImmediate to ensure stream destruction completes before exit
107
+ // This gives the event loop one tick to process pending I/O cleanup
108
+ setImmediate(() => {
109
+ process.exit(signal ? 1 : (code ?? 0));
110
+ });
111
+ });
73
112
 
74
- // Forward signals to child process
75
- ['SIGINT', 'SIGTERM', 'SIGHUP'].forEach(signal => {
76
- process.on(signal, () => {
113
+ // Windows: use taskkill for reliable process termination
114
+ // process.kill(pid, 'SIGINT') does NOT work properly on Windows
115
+ process.on('SIGINT', () => {
77
116
  if (child.pid) {
78
- process.kill(child.pid, signal);
117
+ try {
118
+ // /T = terminate child processes (tree), /F = force
119
+ execSync(`taskkill /PID ${child.pid} /T /F`, {
120
+ stdio: 'ignore',
121
+ windowsHide: true
122
+ });
123
+ } catch {
124
+ // Process may have already exited, ignore errors
125
+ }
79
126
  }
80
127
  });
81
- });
128
+
129
+ // Handle Ctrl+C via readline for Windows terminal compatibility
130
+ // Some Windows terminals don't emit SIGINT properly
131
+ const readline = require('readline');
132
+ if (process.stdin.isTTY) {
133
+ const rl = readline.createInterface({
134
+ input: process.stdin,
135
+ output: process.stdout
136
+ });
137
+ rl.on('SIGINT', () => {
138
+ process.emit('SIGINT');
139
+ });
140
+ // Clean up readline on close
141
+ child.on('close', () => {
142
+ rl.close();
143
+ });
144
+ }
145
+
146
+ } else {
147
+ // UNIX PATH: Simple stdio inheritance (works fine, no EBUSY issues)
148
+ const child = spawn(bunPath, [scriptPath, ...process.argv.slice(2)], {
149
+ stdio: 'inherit',
150
+ windowsHide: true
151
+ });
152
+
153
+ child.on('error', (err) => {
154
+ console.error('Failed to start pgserve:', err.message);
155
+ process.exit(1);
156
+ });
157
+
158
+ child.on('close', (code, signal) => {
159
+ if (signal) {
160
+ process.kill(process.pid, signal);
161
+ } else {
162
+ process.exit(code ?? 0);
163
+ }
164
+ });
165
+
166
+ // Unix: forward signals to child process normally
167
+ ['SIGINT', 'SIGTERM', 'SIGHUP'].forEach(sig => {
168
+ process.on(sig, () => {
169
+ if (child.pid) {
170
+ process.kill(child.pid, sig);
171
+ }
172
+ });
173
+ });
174
+ }
package/bun.lock CHANGED
@@ -20,7 +20,7 @@
20
20
  "@embedded-postgres/darwin-arm64": "17.7.0-beta.15",
21
21
  "@embedded-postgres/darwin-x64": "17.7.0-beta.15",
22
22
  "@embedded-postgres/linux-x64": "17.7.0-beta.15",
23
- "@embedded-postgres/win32-x64": "17.7.0-beta.15",
23
+ "@embedded-postgres/windows-x64": "17.7.0-beta.15",
24
24
  },
25
25
  },
26
26
  },
@@ -33,6 +33,8 @@
33
33
 
34
34
  "@embedded-postgres/linux-x64": ["@embedded-postgres/linux-x64@17.7.0-beta.15", "", { "os": "linux", "cpu": "x64" }, "sha512-HeaxSHsw6ccVh8l5iC4OgXqvaaCGWnnZR9CpgNgrAfnKPPGiEhUPBmO2XhEsFQIhc+ad/+36h0NTvKo4bdi40w=="],
35
35
 
36
+ "@embedded-postgres/windows-x64": ["@embedded-postgres/windows-x64@17.7.0-beta.15", "", { "os": "win32", "cpu": "x64" }, "sha512-Oq11yyKxISjefuYdKljcp3Q+uxx237zn9YpP9hO43+6Feorq7USuMIDqk5ofLSQ30FAnVyTqaIQK8ZIjW+tQXQ=="],
37
+
36
38
  "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
37
39
 
38
40
  "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "1.1.2",
3
+ "version": "1.1.3-rc.11",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  "@embedded-postgres/darwin-arm64": "17.7.0-beta.15",
45
45
  "@embedded-postgres/darwin-x64": "17.7.0-beta.15",
46
46
  "@embedded-postgres/linux-x64": "17.7.0-beta.15",
47
- "@embedded-postgres/win32-x64": "17.7.0-beta.15"
47
+ "@embedded-postgres/windows-x64": "17.7.0-beta.15"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@electric-sql/pglite": "^0.2.17",
package/src/postgres.js CHANGED
@@ -13,11 +13,162 @@
13
13
  * - No locale dependency (works on any system)
14
14
  */
15
15
 
16
+ /* global fetch, Bun */
16
17
  import os from 'os';
17
18
  import path from 'path';
18
19
  import fs from 'fs';
19
20
  import crypto from 'crypto';
20
21
 
22
+ /**
23
+ * Get platform key for binary lookup (e.g., 'windows-x64', 'linux-x64', 'darwin-arm64')
24
+ * @returns {string} Platform key
25
+ */
26
+ function getPlatformKey() {
27
+ const platform = os.platform();
28
+ const arch = os.arch();
29
+
30
+ if (platform === 'win32') return 'windows-x64';
31
+ if (platform === 'linux' && arch === 'x64') return 'linux-x64';
32
+ if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64';
33
+ if (platform === 'darwin' && arch === 'x64') return 'darwin-x64';
34
+ if (platform === 'linux' && arch === 'arm64') return 'linux-arm64';
35
+
36
+ throw new Error(`Unsupported platform: ${platform}-${arch}`);
37
+ }
38
+
39
+ /**
40
+ * Get the directory where extracted binaries are cached
41
+ * @returns {string} Cache directory path
42
+ */
43
+ function getBinaryCacheDir() {
44
+ const platformKey = getPlatformKey();
45
+ return path.join(os.homedir(), '.pgserve', 'bin', platformKey);
46
+ }
47
+
48
+ /**
49
+ * Download and extract PostgreSQL binaries on first run.
50
+ * Downloads from npm registry (@embedded-postgres packages).
51
+ *
52
+ * @returns {Promise<string>} Path to extracted directory
53
+ */
54
+ async function downloadPostgresBinaries() {
55
+ const platform = os.platform();
56
+ const cacheDir = getBinaryCacheDir();
57
+ const cacheBinDir = path.join(cacheDir, 'bin');
58
+ const initdbName = platform === 'win32' ? 'initdb.exe' : 'initdb';
59
+ const postgresName = platform === 'win32' ? 'postgres.exe' : 'postgres';
60
+
61
+ // Check if already downloaded
62
+ if (fs.existsSync(path.join(cacheBinDir, initdbName)) &&
63
+ fs.existsSync(path.join(cacheBinDir, postgresName))) {
64
+ return cacheDir;
65
+ }
66
+
67
+ const platformKey = getPlatformKey();
68
+ const pkgName = `@embedded-postgres/${platformKey}`;
69
+ const pkgVersion = '17.7.0-beta.15';
70
+
71
+ console.log(`[pgserve] PostgreSQL binaries not found.`);
72
+ console.log(`[pgserve] Downloading ${pkgName}@${pkgVersion}...`);
73
+
74
+ // Get tarball URL from npm registry
75
+ const registryUrl = `https://registry.npmjs.org/${pkgName}`;
76
+ const registryRes = await fetch(registryUrl);
77
+ if (!registryRes.ok) {
78
+ throw new Error(`Failed to fetch package info: ${registryRes.status}`);
79
+ }
80
+ const pkgInfo = await registryRes.json();
81
+ const tarballUrl = pkgInfo.versions[pkgVersion]?.dist?.tarball;
82
+
83
+ if (!tarballUrl) {
84
+ throw new Error(`Version ${pkgVersion} not found for ${pkgName}`);
85
+ }
86
+
87
+ // Download tarball
88
+ console.log(`[pgserve] Downloading from npm registry...`);
89
+ const tarballRes = await fetch(tarballUrl);
90
+ if (!tarballRes.ok) {
91
+ throw new Error(`Failed to download tarball: ${tarballRes.status}`);
92
+ }
93
+
94
+ const tarballBuffer = await tarballRes.arrayBuffer();
95
+ const tarballSize = (tarballBuffer.byteLength / 1024 / 1024).toFixed(1);
96
+ console.log(`[pgserve] Downloaded ${tarballSize} MB`);
97
+
98
+ // Create temp file for tarball
99
+ const tempDir = path.join(os.tmpdir(), `pgserve-download-${Date.now()}`);
100
+ fs.mkdirSync(tempDir, { recursive: true });
101
+ const tarballPath = path.join(tempDir, 'package.tgz');
102
+ fs.writeFileSync(tarballPath, Buffer.from(tarballBuffer));
103
+
104
+ // Extract tarball using tar (available on all platforms via bun/node)
105
+ console.log(`[pgserve] Extracting binaries...`);
106
+ fs.mkdirSync(cacheDir, { recursive: true });
107
+
108
+ // Use Bun.spawn for extraction
109
+ const extractProc = Bun.spawn(['tar', '-xzf', tarballPath, '-C', tempDir], {
110
+ stdout: 'pipe',
111
+ stderr: 'pipe'
112
+ });
113
+ await extractProc.exited;
114
+
115
+ // Copy native/* to cache dir
116
+ const nativeDir = path.join(tempDir, 'package', 'native');
117
+ if (!fs.existsSync(nativeDir)) {
118
+ throw new Error('Extracted package does not contain native/ directory');
119
+ }
120
+
121
+ // Copy files recursively
122
+ await copyDirRecursive(nativeDir, cacheDir);
123
+
124
+ // Make executables executable (Unix only)
125
+ if (platform !== 'win32') {
126
+ const binDir = path.join(cacheDir, 'bin');
127
+ if (fs.existsSync(binDir)) {
128
+ const files = fs.readdirSync(binDir);
129
+ for (const file of files) {
130
+ const filePath = path.join(binDir, file);
131
+ try {
132
+ fs.chmodSync(filePath, 0o755);
133
+ } catch {
134
+ // Ignore
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ // Cleanup temp
141
+ try {
142
+ fs.rmSync(tempDir, { recursive: true, force: true });
143
+ } catch {
144
+ // Ignore cleanup errors
145
+ }
146
+
147
+ console.log(`[pgserve] PostgreSQL binaries installed to ${cacheDir}`);
148
+ return cacheDir;
149
+ }
150
+
151
+ /**
152
+ * Recursively copy a directory
153
+ * @param {string} src - Source directory
154
+ * @param {string} dest - Destination directory
155
+ */
156
+ async function copyDirRecursive(src, dest) {
157
+ const entries = fs.readdirSync(src, { withFileTypes: true });
158
+
159
+ for (const entry of entries) {
160
+ const srcPath = path.join(src, entry.name);
161
+ const destPath = path.join(dest, entry.name);
162
+
163
+ if (entry.isDirectory()) {
164
+ fs.mkdirSync(destPath, { recursive: true });
165
+ await copyDirRecursive(srcPath, destPath);
166
+ } else {
167
+ fs.copyFileSync(srcPath, destPath);
168
+ }
169
+ }
170
+ }
171
+
21
172
  /**
22
173
  * Ensure library symlinks exist in the lib directory.
23
174
  * The @embedded-postgres package ships versioned libraries but binaries look for soname versions.
@@ -94,10 +245,45 @@ function ensureLibrarySymlinks(libDir, platform) {
94
245
  }
95
246
 
96
247
  // Resolve binary paths from embedded-postgres platform packages
97
- function getBinaryPaths() {
248
+ // Now async to support bundled binary extraction
249
+ async function getBinaryPaths() {
98
250
  const platform = os.platform();
99
251
  const arch = os.arch();
252
+ const exeSuffix = platform === 'win32' ? '.exe' : '';
253
+
254
+ // Priority 1: Check extracted cache directory (standalone exe mode)
255
+ // This is where bundled binaries are extracted on first run
256
+ const cacheDir = getBinaryCacheDir();
257
+ const cacheBinDir = path.join(cacheDir, 'bin');
258
+ const cachedInitdb = path.join(cacheBinDir, 'initdb' + exeSuffix);
259
+ const cachedPostgres = path.join(cacheBinDir, 'postgres' + exeSuffix);
260
+
261
+ if (fs.existsSync(cachedInitdb) && fs.existsSync(cachedPostgres)) {
262
+ const libDir = path.join(cacheDir, 'lib');
263
+ if ((platform === 'linux' || platform === 'darwin') && fs.existsSync(libDir)) {
264
+ ensureLibrarySymlinks(libDir, platform);
265
+ }
266
+ return { initdb: cachedInitdb, postgres: cachedPostgres, binDir: cacheBinDir, libDir };
267
+ }
268
+
269
+ // Priority 2: Download binaries if not found (standalone exe mode)
270
+ // This downloads from npm registry on first run
271
+ const downloadedDir = await downloadPostgresBinaries();
272
+ if (downloadedDir) {
273
+ const downloadedBinDir = path.join(downloadedDir, 'bin');
274
+ const downloadedInitdb = path.join(downloadedBinDir, 'initdb' + exeSuffix);
275
+ const downloadedPostgres = path.join(downloadedBinDir, 'postgres' + exeSuffix);
100
276
 
277
+ if (fs.existsSync(downloadedInitdb) && fs.existsSync(downloadedPostgres)) {
278
+ const libDir = path.join(downloadedDir, 'lib');
279
+ if ((platform === 'linux' || platform === 'darwin') && fs.existsSync(libDir)) {
280
+ ensureLibrarySymlinks(libDir, platform);
281
+ }
282
+ return { initdb: downloadedInitdb, postgres: downloadedPostgres, binDir: downloadedBinDir, libDir };
283
+ }
284
+ }
285
+
286
+ // Priority 3: Find the package in node_modules (npm install case)
101
287
  let pkgName;
102
288
  if (platform === 'linux' && arch === 'x64') {
103
289
  pkgName = '@embedded-postgres/linux-x64';
@@ -106,12 +292,11 @@ function getBinaryPaths() {
106
292
  } else if (platform === 'darwin' && arch === 'x64') {
107
293
  pkgName = '@embedded-postgres/darwin-x64';
108
294
  } else if (platform === 'win32' && arch === 'x64') {
109
- pkgName = '@embedded-postgres/win32-x64';
295
+ pkgName = '@embedded-postgres/windows-x64';
110
296
  } else {
111
297
  throw new Error(`Unsupported platform: ${platform}-${arch}`);
112
298
  }
113
299
 
114
- // Find the package in node_modules (check multiple locations for npx/pnpm/npm compatibility)
115
300
  const possiblePaths = [
116
301
  path.join(process.cwd(), 'node_modules', pkgName, 'native', 'bin'),
117
302
  path.join(import.meta.dirname, '..', 'node_modules', pkgName, 'native', 'bin'),
@@ -120,8 +305,8 @@ function getBinaryPaths() {
120
305
  ];
121
306
 
122
307
  for (const binDir of possiblePaths) {
123
- const initdb = path.join(binDir, platform === 'win32' ? 'initdb.exe' : 'initdb');
124
- const postgres = path.join(binDir, platform === 'win32' ? 'postgres.exe' : 'postgres');
308
+ const initdb = path.join(binDir, 'initdb' + exeSuffix);
309
+ const postgres = path.join(binDir, 'postgres' + exeSuffix);
125
310
  if (fs.existsSync(initdb) && fs.existsSync(postgres)) {
126
311
  // Resolve the actual binary paths (handles symlinks from package managers)
127
312
  const realInitdb = fs.realpathSync(initdb);
@@ -237,8 +422,8 @@ export class PostgresManager {
237
422
  * Start the embedded PostgreSQL instance
238
423
  */
239
424
  async start() {
240
- // Get binary paths
241
- this.binaries = getBinaryPaths();
425
+ // Get binary paths (may extract bundled binaries on first run)
426
+ this.binaries = await getBinaryPaths();
242
427
 
243
428
  // Make binaries executable
244
429
  await fs.promises.chmod(this.binaries.initdb, '755');
@@ -308,6 +493,12 @@ export class PostgresManager {
308
493
  // Initialize admin connection pool (for database creation operations)
309
494
  await this._initAdminPool();
310
495
 
496
+ // For persistent mode, load existing databases into createdDatabases
497
+ // This prevents "database already exists" errors when reusing data directories
498
+ if (this.persistent) {
499
+ await this._loadExistingDatabases();
500
+ }
501
+
311
502
  this.logger.info({
312
503
  databaseDir: this.databaseDir,
313
504
  port: this.port,
@@ -407,6 +598,33 @@ export class PostgresManager {
407
598
  }, 'Admin connection pool initialized (Bun.sql)');
408
599
  }
409
600
 
601
+ /**
602
+ * Load existing databases into createdDatabases Set (for persistent mode)
603
+ * This allows pgserve to reuse existing data directories without
604
+ * attempting to CREATE DATABASE for databases that already exist.
605
+ */
606
+ async _loadExistingDatabases() {
607
+ try {
608
+ const result = await this.adminPool`
609
+ SELECT datname FROM pg_database
610
+ WHERE datistemplate = false
611
+ AND datname NOT IN ('postgres', 'template0', 'template1')
612
+ `;
613
+
614
+ for (const row of result) {
615
+ this.createdDatabases.add(row.datname);
616
+ }
617
+
618
+ this.logger.info({
619
+ databases: Array.from(this.createdDatabases),
620
+ count: this.createdDatabases.size
621
+ }, 'Loaded existing databases from persistent storage');
622
+ } catch (error) {
623
+ // Non-fatal - if we can't load existing DBs, createDatabase will handle it
624
+ this.logger.warn({ error: error.message }, 'Failed to load existing databases');
625
+ }
626
+ }
627
+
410
628
  /**
411
629
  * Start the PostgreSQL server process
412
630
  * Uses Bun.spawn() for ~40% faster process startup
@@ -449,8 +667,24 @@ export class PostgresManager {
449
667
 
450
668
  let started = false;
451
669
  let startupOutput = '';
670
+ let processExited = false;
671
+ let portBindingSeen = false;
672
+ const portStr = this.port.toString();
673
+
674
+ // Hybrid startup detection:
675
+ // 1. TCP connection polling (works on Linux/macOS)
676
+ // 2. Log-based detection (fallback for Windows where Bun.connect may fail)
677
+ // Whichever succeeds first wins
678
+
679
+ const markReady = (method) => {
680
+ if (!started) {
681
+ started = true;
682
+ this.logger.info({ port: this.port, method }, 'PostgreSQL ready');
683
+ resolve();
684
+ }
685
+ };
452
686
 
453
- // Read stderr in streaming fashion to detect startup
687
+ // Read stderr - detect port binding in logs (locale-independent: just look for port number)
454
688
  const readStream = async (stream) => {
455
689
  const reader = stream.getReader();
456
690
  const decoder = new TextDecoder();
@@ -462,11 +696,16 @@ export class PostgresManager {
462
696
  startupOutput += message;
463
697
  this.logger.debug({ pgOutput: message.trim() }, 'PostgreSQL output');
464
698
 
465
- // Check for ready message
466
- if (!started && (message.includes('database system is ready to accept connections') ||
467
- message.includes('ready to accept connections'))) {
468
- started = true;
469
- resolve();
699
+ // Detect port binding - look for our port number in log output
700
+ // This is locale-independent (numbers are universal)
701
+ if (!portBindingSeen && message.includes(portStr)) {
702
+ portBindingSeen = true;
703
+ // Give PostgreSQL 500ms after port binding to finish startup
704
+ setTimeout(() => {
705
+ if (!started && !processExited) {
706
+ markReady('log-port-binding');
707
+ }
708
+ }, 500);
470
709
  }
471
710
  }
472
711
  } catch {
@@ -480,16 +719,91 @@ export class PostgresManager {
480
719
 
481
720
  // Handle process exit
482
721
  this.process.exited.then((code) => {
722
+ processExited = true;
483
723
  if (!started) {
484
724
  reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
485
725
  }
486
726
  this.process = null;
487
727
  });
488
728
 
489
- // Timeout after 30 seconds
729
+ // Method 1: TCP connection polling (preferred, works on Linux/macOS)
730
+ const tryConnect = () => {
731
+ return new Promise((resolveConn, rejectConn) => {
732
+ let resolved = false;
733
+ const timeout = setTimeout(() => {
734
+ if (!resolved) {
735
+ resolved = true;
736
+ rejectConn(new Error('Connection timeout'));
737
+ }
738
+ }, 500);
739
+
740
+ Bun.connect({
741
+ hostname: '127.0.0.1',
742
+ port: this.port,
743
+ socket: {
744
+ open(socket) {
745
+ if (!resolved) {
746
+ resolved = true;
747
+ clearTimeout(timeout);
748
+ socket.end();
749
+ resolveConn(true);
750
+ }
751
+ },
752
+ connectError(_socket, error) {
753
+ if (!resolved) {
754
+ resolved = true;
755
+ clearTimeout(timeout);
756
+ rejectConn(error);
757
+ }
758
+ },
759
+ error(_socket, error) {
760
+ if (!resolved) {
761
+ resolved = true;
762
+ clearTimeout(timeout);
763
+ rejectConn(error);
764
+ }
765
+ },
766
+ data() {},
767
+ close() {},
768
+ },
769
+ }).catch((err) => {
770
+ if (!resolved) {
771
+ resolved = true;
772
+ clearTimeout(timeout);
773
+ rejectConn(err);
774
+ }
775
+ });
776
+ });
777
+ };
778
+
779
+ const pollConnection = async () => {
780
+ const startTime = Date.now();
781
+ const timeoutMs = 30000;
782
+
783
+ while (Date.now() - startTime < timeoutMs && !started) {
784
+ if (processExited) return;
785
+
786
+ try {
787
+ await tryConnect();
788
+ markReady('tcp');
789
+ return;
790
+ } catch {
791
+ await Bun.sleep(200);
792
+ }
793
+ }
794
+ };
795
+
796
+ // Start TCP polling (log detection is handled inline above via setTimeout)
797
+ pollConnection();
798
+
799
+ // Overall timeout with helpful error for Windows firewall
490
800
  setTimeout(() => {
491
- if (!started) {
492
- reject(new Error(`PostgreSQL startup timed out after 30s. Output: ${startupOutput}`));
801
+ if (!started && !processExited) {
802
+ const isWindows = process.platform === 'win32';
803
+ const hint = isWindows
804
+ ? '\n\nOn Windows, this may be caused by Windows Firewall blocking localhost connections.\nTry: netsh advfirewall firewall add rule name="pgserve" dir=in action=allow protocol=TCP localport=' + this.port
805
+ : '';
806
+ reject(new Error(`PostgreSQL startup timed out after 30s.${hint}\n\nOutput: ${startupOutput}`));
493
807
  }
494
808
  }, 30000);
495
809
  });
@@ -543,7 +857,11 @@ export class PostgresManager {
543
857
  } catch (error) {
544
858
  // Database might already exist (from previous persistent session or race condition)
545
859
  // 42P04 = duplicate_database, 23505 = unique_violation
546
- if (error.code === '42P04' || error.code === '23505') {
860
+ // Also check error.message for Bun.sql compatibility (may not expose SQLSTATE codes)
861
+ const isAlreadyExists = error.code === '42P04' ||
862
+ error.code === '23505' ||
863
+ error.message?.includes('already exists');
864
+ if (isAlreadyExists) {
547
865
  this.createdDatabases.add(dbName);
548
866
  this.logger.debug({ dbName }, 'Database already exists');
549
867
  } else {