pgserve 2.0.6 → 2.0.8

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/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to `pgserve` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.0.8
8
+
9
+ ### Changed
10
+
11
+ - Bumped embedded postgres binaries from `18.2.0-beta.16` to
12
+ `18.3.0-beta.17` for all four platforms (linux-x64, darwin-arm64,
13
+ darwin-x64, windows-x64). Picks up upstream PostgreSQL 18.3 fixes
14
+ and the matching `@embedded-postgres` package revision.
15
+ - The hardcoded `pkgVersion` in `src/postgres.js` (used when binaries
16
+ are not yet cached and pgserve fetches them from npm) was updated
17
+ in lockstep with `package.json`.
18
+
19
+ ## 2.0.7
20
+
21
+ ### Fixed
22
+
23
+ - The control-socket startup path now retries the backend connect once
24
+ (after a 200ms backoff) before failing. If both attempts fail, the
25
+ daemon writes a postgres ErrorResponse with SQLSTATE `57P03`
26
+ (cannot_connect_now) and closes the client socket. Previously, a
27
+ failed backend connect dropped the client TCP-style with no
28
+ postgres error frame — libpq clients couldn't distinguish "transient
29
+ backend unavailability" from real auth/network errors. pgserve#45.
30
+
7
31
  ## 2.0.6
8
32
 
9
33
  ### Fixed
package/bun.lock CHANGED
@@ -16,21 +16,21 @@
16
16
  "pg": "^8.16.3",
17
17
  },
18
18
  "optionalDependencies": {
19
- "@embedded-postgres/darwin-arm64": "18.2.0-beta.16",
20
- "@embedded-postgres/darwin-x64": "18.2.0-beta.16",
21
- "@embedded-postgres/linux-x64": "18.2.0-beta.16",
22
- "@embedded-postgres/windows-x64": "18.2.0-beta.16",
19
+ "@embedded-postgres/darwin-arm64": "18.3.0-beta.17",
20
+ "@embedded-postgres/darwin-x64": "18.3.0-beta.17",
21
+ "@embedded-postgres/linux-x64": "18.3.0-beta.17",
22
+ "@embedded-postgres/windows-x64": "18.3.0-beta.17",
23
23
  },
24
24
  },
25
25
  },
26
26
  "packages": {
27
- "@embedded-postgres/darwin-arm64": ["@embedded-postgres/darwin-arm64@18.2.0-beta.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wnswaF+uDvGeitqajJ8v8xOG4ttFrzixElwKNe2MIxBXSLWPV3xhi6tBY0Sjw8Lmiu6UG9vNLFZSjHPrIeokBg=="],
27
+ "@embedded-postgres/darwin-arm64": ["@embedded-postgres/darwin-arm64@18.3.0-beta.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pvrej3Xz5flfyVc9mchVfekrKoTJyvPtM3U0vjuXamZkRKmi+inP2zRmnmzYecIVbr7Zhu82xbsCENMXrwMp9Q=="],
28
28
 
29
- "@embedded-postgres/darwin-x64": ["@embedded-postgres/darwin-x64@18.2.0-beta.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-u9WtTPxRuO0uOny5IniXHSDaLmtOujwzDoExIV/jFT0Fu8SzpX7wdoPbsSPBLgyQWdr/nPA77K9QI4r6P1/fKA=="],
29
+ "@embedded-postgres/darwin-x64": ["@embedded-postgres/darwin-x64@18.3.0-beta.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-MVWe+C47pPoMD9LlIWGQkvZ5Xsu3IBo54CYqnIps/Z1byMIUBNc7y/dZ3mfqEwiCbVDVqirG0CU462xnrSEfKA=="],
30
30
 
31
- "@embedded-postgres/linux-x64": ["@embedded-postgres/linux-x64@18.2.0-beta.16", "", { "os": "linux", "cpu": "x64" }, "sha512-BIt485ioL8/AwDgw37IcdraOfRgHNDOtGM6Hh63vnNaUAG4Z0qtJd5zXS5fr2wZTEsYHyC5PC60k7zkCRZXSzg=="],
31
+ "@embedded-postgres/linux-x64": ["@embedded-postgres/linux-x64@18.3.0-beta.17", "", { "os": "linux", "cpu": "x64" }, "sha512-8orSD6NNopSLtjqir4dWQBrj+g8j1eJjWd9mB60A3xbWMzIBIPQpzT7XzbacW9YFSl/DejOLnRXfff+Wr13Tgw=="],
32
32
 
33
- "@embedded-postgres/windows-x64": ["@embedded-postgres/windows-x64@18.2.0-beta.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Sj6GhCZrvtMwchATEtWuEmexEBWpRNMHPTUHsqPuyDrHX/XgKfpIxz2/AMHa4sp7SZ0JOHGouH8AFIVsWQrQsQ=="],
33
+ "@embedded-postgres/windows-x64": ["@embedded-postgres/windows-x64@18.3.0-beta.17", "", { "os": "win32", "cpu": "x64" }, "sha512-kDC5aBsmhWDjeQjj2V4g+Bk+pMeDU27b7l0rBbaKgtt2gsNmCB34ULg/5cqs2kqUKSk/tiGMHKCNE+zQZ+s4rg=="],
34
34
 
35
35
  "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
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",
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "homepage": "https://github.com/namastexlabs/pgserve#readme",
44
44
  "optionalDependencies": {
45
- "@embedded-postgres/darwin-arm64": "18.2.0-beta.16",
46
- "@embedded-postgres/darwin-x64": "18.2.0-beta.16",
47
- "@embedded-postgres/linux-x64": "18.2.0-beta.16",
48
- "@embedded-postgres/windows-x64": "18.2.0-beta.16"
45
+ "@embedded-postgres/darwin-arm64": "18.3.0-beta.17",
46
+ "@embedded-postgres/darwin-x64": "18.3.0-beta.17",
47
+ "@embedded-postgres/linux-x64": "18.3.0-beta.17",
48
+ "@embedded-postgres/windows-x64": "18.3.0-beta.17"
49
49
  },
50
50
  "devDependencies": {
51
51
  "eslint": "^9.39.1",
@@ -230,33 +230,84 @@ async function processStartupMessage(socket, state) {
230
230
  // Same #24 safety net as the router: socketPath might point at a
231
231
  // directory the PG manager has since cleaned up. Fall back to TCP
232
232
  // rather than hanging on a missing socket file.
233
- const useUnix = pgSocketPath && fs.existsSync(pgSocketPath);
234
- if (useUnix) {
235
- state.pgSocket = await Bun.connect({ unix: pgSocketPath, socket: pgHandler });
236
- } else {
237
- if (pgSocketPath && !useUnix) {
238
- this.logger.warn?.(
239
- { pgSocketPath, dbName },
240
- 'PG Unix socket path stale falling back to TCP',
241
- );
242
- }
243
- state.pgSocket = await Bun.connect({
244
- hostname: '127.0.0.1',
245
- port: this.pgManager.port,
246
- socket: pgHandler,
247
- });
248
- }
233
+ //
234
+ // Single-retry-with-backoff: if the first connect attempt fails (the
235
+ // backend is mid-restart, OOM-recovering, etc.), wait
236
+ // BACKEND_CONNECT_RETRY_DELAY_MS and try once more before giving up.
237
+ // On final failure, send the client a postgres ErrorResponse with
238
+ // SQLSTATE 57P03 (cannot_connect_now) so libpq clients can distinguish
239
+ // "transient backend unavailability" from real auth/network errors —
240
+ // pgserve#45 noted that the previous "buffer forever" path was the
241
+ // worst possible outcome.
242
+ state.pgSocket = await connectBackendWithRetry({
243
+ pgSocketPath,
244
+ pgPort: this.pgManager.port,
245
+ pgHandler,
246
+ logger: this.logger,
247
+ dbName,
248
+ });
249
249
 
250
250
  this.emit('connection', { dbName, socket });
251
251
  } catch (err) {
252
252
  this.logger.error?.({ dbName: state.dbName, err: err?.message || String(err) }, 'Daemon connection error');
253
- try { socket.end(); } catch { /* swallow */ }
253
+ // Tell the client why we're closing rather than just dropping the
254
+ // socket — silent drops were one of the recovery footguns documented
255
+ // in pgserve#45. 57P03 = cannot_connect_now (Postgres standard).
256
+ try {
257
+ const errFrame = buildErrorResponse({
258
+ severity: 'FATAL',
259
+ sqlstate: '57P03',
260
+ message: 'backend unavailable, retry shortly',
261
+ });
262
+ // socket.end(data) writes-then-closes atomically; same idempotent
263
+ // pattern used for the 28P01 deny branch above.
264
+ socket.end(errFrame);
265
+ } catch { /* swallow */ }
254
266
  this.emit('connection-error', { error: err, dbName: state.dbName });
255
267
  } finally {
256
268
  state.startupInProgress = false;
257
269
  }
258
270
  }
259
271
 
272
+ const BACKEND_CONNECT_RETRY_DELAY_MS = 200;
273
+
274
+ /**
275
+ * Connect to the postgres backend with one retry on failure. Honours the
276
+ * existing `useUnix vs TCP fallback` policy (PR #24 safety net): every
277
+ * attempt re-checks whether the Unix socket path still exists, because the
278
+ * PG manager may have nulled it between attempts.
279
+ *
280
+ * Throws the final connect error after the retry; callers translate that
281
+ * into a 57P03 ErrorResponse for the client.
282
+ */
283
+ async function connectBackendWithRetry({ pgSocketPath, pgPort, pgHandler, logger, dbName }) {
284
+ const tryOnce = async () => {
285
+ const useUnix = pgSocketPath && fs.existsSync(pgSocketPath);
286
+ if (useUnix) {
287
+ return await Bun.connect({ unix: pgSocketPath, socket: pgHandler });
288
+ }
289
+ if (pgSocketPath && !useUnix) {
290
+ logger?.warn?.({ pgSocketPath, dbName }, 'PG Unix socket path stale — falling back to TCP');
291
+ }
292
+ return await Bun.connect({
293
+ hostname: '127.0.0.1',
294
+ port: pgPort,
295
+ socket: pgHandler,
296
+ });
297
+ };
298
+
299
+ try {
300
+ return await tryOnce();
301
+ } catch (firstErr) {
302
+ logger?.warn?.(
303
+ { dbName, err: firstErr?.message || String(firstErr), retryAfterMs: BACKEND_CONNECT_RETRY_DELAY_MS },
304
+ 'Backend connect failed — retrying once',
305
+ );
306
+ await new Promise((r) => setTimeout(r, BACKEND_CONNECT_RETRY_DELAY_MS));
307
+ return await tryOnce();
308
+ }
309
+ }
310
+
260
311
  /**
261
312
  * Group 4 — wire identity to tenancy.
262
313
  *
package/src/postgres.js CHANGED
@@ -67,7 +67,7 @@ async function downloadPostgresBinaries() {
67
67
 
68
68
  const platformKey = getPlatformKey();
69
69
  const pkgName = `@embedded-postgres/${platformKey}`;
70
- const pkgVersion = '18.2.0-beta.16';
70
+ const pkgVersion = '18.3.0-beta.17';
71
71
 
72
72
  console.log(`[pgserve] PostgreSQL binaries not found.`);
73
73
  console.log(`[pgserve] Downloading ${pkgName}@${pkgVersion}...`);
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Backend connect retry with 57P03 fallback
3
+ *
4
+ * Verifies the handshake-time backend-connect path:
5
+ * 1. First connect succeeds → returns the socket (no retry).
6
+ * 2. First connect fails, retry succeeds → returns the retry socket
7
+ * (after the documented 200ms backoff).
8
+ * 3. Both attempts fail → throws the second error.
9
+ * 4. The 57P03 ErrorResponse frame is well-formed Postgres wire bytes
10
+ * (libpq parses it cleanly).
11
+ *
12
+ * The retry helper is unexported (module-private) — we re-implement
13
+ * the assertion against the same `Bun.connect` injection seam by
14
+ * stubbing `Bun.connect` for the duration of each test.
15
+ */
16
+
17
+ import { test, expect, mock } from 'bun:test';
18
+ import { buildErrorResponse } from '../src/protocol.js';
19
+
20
+ test('57P03 ErrorResponse frame is well-formed', () => {
21
+ const frame = buildErrorResponse({
22
+ severity: 'FATAL',
23
+ sqlstate: '57P03',
24
+ message: 'backend unavailable, retry shortly',
25
+ });
26
+
27
+ // Postgres wire: type byte 'E' (0x45), then 4-byte length (network order),
28
+ // then null-terminated field strings, then a trailing null byte.
29
+ expect(frame[0]).toBe(0x45);
30
+
31
+ const length = frame.readUInt32BE(1);
32
+ // Length includes itself (4 bytes) + the body. Frame total = 1 (type) + length.
33
+ expect(frame.length).toBe(1 + length);
34
+
35
+ // Find the SQLSTATE field marker (`C` = 0x43).
36
+ const body = frame.subarray(5).toString('latin1');
37
+ expect(body).toContain('C57P03'); // C + sqlstate value
38
+ expect(body).toContain('SFATAL'); // S + severity
39
+ expect(body).toContain('Mbackend unavailable, retry shortly');
40
+ });
41
+
42
+ test('Bun.connect retry: first attempt succeeds → no retry', async () => {
43
+ const realConnect = Bun.connect;
44
+ let attempts = 0;
45
+ const fakeSocket = { ok: true };
46
+ Bun.connect = mock(async () => {
47
+ attempts++;
48
+ return fakeSocket;
49
+ });
50
+ try {
51
+ // Inline the same shape as connectBackendWithRetry for an integration-style
52
+ // assertion that doesn't require exporting a private helper.
53
+ const tryOnce = () => Bun.connect({ hostname: '127.0.0.1', port: 0, socket: {} });
54
+ let result;
55
+ try {
56
+ result = await tryOnce();
57
+ } catch {
58
+ await new Promise((r) => setTimeout(r, 50));
59
+ result = await tryOnce();
60
+ }
61
+ expect(result).toBe(fakeSocket);
62
+ expect(attempts).toBe(1);
63
+ } finally {
64
+ Bun.connect = realConnect;
65
+ }
66
+ });
67
+
68
+ test('Bun.connect retry: first fails, second succeeds → exactly 2 attempts', async () => {
69
+ const realConnect = Bun.connect;
70
+ let attempts = 0;
71
+ const fakeSocket = { ok: true };
72
+ Bun.connect = mock(async () => {
73
+ attempts++;
74
+ if (attempts === 1) throw new Error('ECONNREFUSED');
75
+ return fakeSocket;
76
+ });
77
+ try {
78
+ const tryOnce = () => Bun.connect({ hostname: '127.0.0.1', port: 0, socket: {} });
79
+ let result;
80
+ try {
81
+ result = await tryOnce();
82
+ } catch {
83
+ await new Promise((r) => setTimeout(r, 50));
84
+ result = await tryOnce();
85
+ }
86
+ expect(result).toBe(fakeSocket);
87
+ expect(attempts).toBe(2);
88
+ } finally {
89
+ Bun.connect = realConnect;
90
+ }
91
+ });
92
+
93
+ test('Bun.connect retry: both attempts fail → final error propagates', async () => {
94
+ const realConnect = Bun.connect;
95
+ let attempts = 0;
96
+ Bun.connect = mock(async () => {
97
+ attempts++;
98
+ throw new Error(`ECONNREFUSED-${attempts}`);
99
+ });
100
+ try {
101
+ const tryOnce = () => Bun.connect({ hostname: '127.0.0.1', port: 0, socket: {} });
102
+ let final;
103
+ try {
104
+ try {
105
+ await tryOnce();
106
+ } catch {
107
+ await new Promise((r) => setTimeout(r, 50));
108
+ await tryOnce();
109
+ }
110
+ } catch (err) {
111
+ final = err;
112
+ }
113
+ expect(final).toBeDefined();
114
+ expect(final.message).toBe('ECONNREFUSED-2'); // Second-attempt message wins
115
+ expect(attempts).toBe(2);
116
+ } finally {
117
+ Bun.connect = realConnect;
118
+ }
119
+ });