pgserve 1.1.9 → 1.2.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.
@@ -161,6 +161,170 @@ test('Multi-tenant router - multiple databases isolated', async () => {
161
161
  cleanup();
162
162
  });
163
163
 
164
+ test('Router - pre-handshake buffer is bounded (issue #18 root cause #2)', async () => {
165
+ // Regression test for issue #18: without a bound on state.buffer, a
166
+ // client that sends garbage and never completes the PG startup would
167
+ // grow router memory unbounded (traced to the production 74 GiB VmSize).
168
+ // After fix, the router must close the connection once the buffer
169
+ // exceeds MAX_STARTUP_BUFFER_SIZE (1 MiB).
170
+ cleanup();
171
+
172
+ const router = await startMultiTenantServer({
173
+ port: 15546,
174
+ baseDir: testDataDir,
175
+ logLevel: 'warn',
176
+ });
177
+
178
+ const net = await import('net');
179
+ const sock = net.connect(15546, '127.0.0.1');
180
+ await new Promise((resolve) => sock.once('connect', resolve));
181
+
182
+ const garbage = Buffer.alloc(256 * 1024, 0x41); // 256 KiB of 'A'
183
+ let closed = false;
184
+ sock.on('close', () => { closed = true; });
185
+
186
+ // Send 5 × 256 KiB = 1.25 MiB, exceeding the 1 MiB cap.
187
+ for (let i = 0; i < 5 && !closed; i++) {
188
+ await new Promise((resolve) => sock.write(garbage, resolve));
189
+ }
190
+
191
+ // Wait up to 2s for the proxy to close the connection.
192
+ const deadline = Date.now() + 2000;
193
+ while (!closed && Date.now() < deadline) {
194
+ await new Promise((r) => setTimeout(r, 50));
195
+ }
196
+
197
+ expect(closed).toBe(true);
198
+ sock.destroy();
199
+
200
+ await router.stop();
201
+ cleanup();
202
+ });
203
+
204
+ test('Router - socket state has startupInProgress flag (issue #18 root cause #1)', async () => {
205
+ // White-box regression test for the reentrancy guard. Without
206
+ // state.startupInProgress, two data events arriving during the first
207
+ // processStartupMessage() await would launch concurrent async tasks
208
+ // that race to assign state.pgSocket, leaking the loser. This test
209
+ // verifies the flag is wired into the state object.
210
+ cleanup();
211
+
212
+ const router = await startMultiTenantServer({
213
+ port: 15547,
214
+ baseDir: testDataDir,
215
+ logLevel: 'warn',
216
+ });
217
+
218
+ const net = await import('net');
219
+ const sock = net.connect(15547, '127.0.0.1');
220
+ await new Promise((resolve) => sock.once('connect', resolve));
221
+
222
+ // Router tracks client sockets in this.connections; introspect to pull
223
+ // the state object and confirm the flag exists and defaults to false.
224
+ // On some platforms (macOS in particular) the client-side 'connect'
225
+ // event fires slightly before the server-side handleSocketOpen runs,
226
+ // so poll until the router has registered the connection rather than
227
+ // asserting synchronously.
228
+ const deadline = Date.now() + 2000;
229
+ while (router.connections.size === 0 && Date.now() < deadline) {
230
+ await new Promise((r) => setTimeout(r, 20));
231
+ }
232
+ expect(router.connections.size).toBeGreaterThan(0);
233
+ const bunSocket = [...router.connections][0];
234
+ const state = router.socketState.get(bunSocket);
235
+ expect(state).toBeDefined();
236
+ expect(state.startupInProgress).toBe(false);
237
+
238
+ sock.destroy();
239
+ await router.stop();
240
+ cleanup();
241
+ });
242
+
243
+ test('PostgresManager - stop() nulls socketDir/databaseDir (issue #24)', async () => {
244
+ // Regression test for issue #24: router used to cache stale socketPath
245
+ // pointing to a directory that stop() had already rmSync'd. After fix,
246
+ // stop() nulls socketDir/databaseDir UNCONDITIONALLY so subsequent
247
+ // getSocketPath() returns null (forcing TCP fallback in the router).
248
+ cleanup();
249
+
250
+ const { PostgresManager } = await import('../src/postgres.js');
251
+ const { createLogger } = await import('../src/logger.js');
252
+ const pg = new PostgresManager({
253
+ port: 15543,
254
+ logger: createLogger({ level: 'warn' }),
255
+ });
256
+
257
+ await pg.start();
258
+ const socketPathBeforeStop = pg.getSocketPath();
259
+ expect(socketPathBeforeStop).not.toBeNull();
260
+ expect(fs.existsSync(pg.socketDir)).toBe(true);
261
+
262
+ await pg.stop();
263
+
264
+ // CORE ASSERTION: socketDir must be nulled after stop
265
+ expect(pg.socketDir).toBeNull();
266
+ expect(pg.getSocketPath()).toBeNull();
267
+ // databaseDir nulled only in memory mode (persistent mode keeps user-owned path)
268
+ expect(pg.databaseDir).toBeNull();
269
+ // And the dir on disk must actually be gone
270
+ // (socketPathBeforeStop points inside the deleted socketDir)
271
+ const staleSocketDir = path.dirname(socketPathBeforeStop);
272
+ expect(fs.existsSync(staleSocketDir)).toBe(false);
273
+ });
274
+
275
+ test('PostgresManager - start()+stop()+start() yields fresh socketDir (issue #24)', async () => {
276
+ // Regression test for issue #24: pgManager.start() called after stop()
277
+ // must produce a FRESH socketDir (different path). Without the fix, a
278
+ // re-entry guard was missing and socketDir could leak across restarts.
279
+ cleanup();
280
+
281
+ const { PostgresManager } = await import('../src/postgres.js');
282
+ const { createLogger } = await import('../src/logger.js');
283
+ const pg = new PostgresManager({
284
+ port: 15544,
285
+ logger: createLogger({ level: 'warn' }),
286
+ });
287
+
288
+ await pg.start();
289
+ const socketDir1 = pg.socketDir;
290
+ expect(socketDir1).not.toBeNull();
291
+
292
+ await pg.stop();
293
+ expect(pg.socketDir).toBeNull();
294
+
295
+ await pg.start();
296
+ const socketDir2 = pg.socketDir;
297
+ expect(socketDir2).not.toBeNull();
298
+ expect(socketDir2).not.toBe(socketDir1);
299
+ expect(fs.existsSync(socketDir2)).toBe(true);
300
+
301
+ await pg.stop();
302
+ });
303
+
304
+ test('PostgresManager - double start() is a no-op (issue #24 re-entry guard)', async () => {
305
+ // Without the guard, a second start() would overwrite socketDir/databaseDir
306
+ // and leak the previous tmp dir (the "1,457 stale sock dirs" symptom).
307
+ cleanup();
308
+
309
+ const { PostgresManager } = await import('../src/postgres.js');
310
+ const { createLogger } = await import('../src/logger.js');
311
+ const pg = new PostgresManager({
312
+ port: 15545,
313
+ logger: createLogger({ level: 'warn' }),
314
+ });
315
+
316
+ await pg.start();
317
+ const socketDir1 = pg.socketDir;
318
+
319
+ // Second start() should silently return the same instance without
320
+ // reassigning socketDir/databaseDir.
321
+ const result = await pg.start();
322
+ expect(result).toBe(pg);
323
+ expect(pg.socketDir).toBe(socketDir1);
324
+
325
+ await pg.stop();
326
+ });
327
+
164
328
  test('Multi-tenant router - instance reuse', async () => {
165
329
  cleanup();
166
330
 
@@ -1,30 +0,0 @@
1
- # GitHub Release Notes Configuration
2
- # Automatically generates release notes from merged PRs
3
- # See: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
4
-
5
- changelog:
6
- exclude:
7
- labels:
8
- - ignore-for-changelog
9
- - internal
10
- - bot
11
- authors:
12
- - github-actions[bot]
13
- categories:
14
- - title: "Features"
15
- labels:
16
- - feature
17
- - enhancement
18
- - title: "Bug Fixes"
19
- labels:
20
- - bug
21
- - fix
22
- - title: "Breaking Changes"
23
- labels:
24
- - breaking
25
- - title: "Dependencies"
26
- labels:
27
- - dependencies
28
- - title: "Other Changes"
29
- labels:
30
- - "*"
@@ -1,198 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Release Script for pgserve
5
- *
6
- * Usage:
7
- * node scripts/release.cjs --action bump-rc|promote [--dry-run]
8
- *
9
- * Actions:
10
- * bump-rc - Bump RC version (1.0.8 -> 1.0.9-rc.1, or 1.0.9-rc.1 -> 1.0.9-rc.2)
11
- * promote - Promote RC to stable (1.0.9-rc.2 -> 1.0.9)
12
- *
13
- * Outputs (for GitHub Actions):
14
- * version - New version number
15
- * tag - Git tag (v1.0.9-rc.1)
16
- * npm_tag - npm dist-tag (next or latest)
17
- * is_promote - true if this is a promotion (skip build)
18
- */
19
-
20
- const { execSync } = require('child_process');
21
- const fs = require('fs');
22
- const path = require('path');
23
-
24
- const ROOT = path.join(__dirname, '..');
25
- const PACKAGE_JSON = path.join(ROOT, 'package.json');
26
-
27
- // Parse arguments
28
- function parseArgs(args) {
29
- const opts = {};
30
- for (let i = 0; i < args.length; i++) {
31
- const arg = args[i];
32
- if (arg.startsWith('--')) {
33
- const key = arg.replace(/^--/, '');
34
- const nextArg = args[i + 1];
35
- if (nextArg && !nextArg.startsWith('--')) {
36
- opts[key] = nextArg;
37
- i++;
38
- } else {
39
- opts[key] = true;
40
- }
41
- }
42
- }
43
- return opts;
44
- }
45
-
46
- const opts = parseArgs(process.argv.slice(2));
47
- const dryRun = opts['dry-run'] || false;
48
-
49
- function log(msg) {
50
- console.log(`[release] ${msg}`);
51
- }
52
-
53
- function exec(cmd, options = {}) {
54
- if (dryRun && !options.readOnly) {
55
- log(`[dry-run] Would execute: ${cmd}`);
56
- return '';
57
- }
58
- return execSync(cmd, { encoding: 'utf8', cwd: ROOT, ...options }).trim();
59
- }
60
-
61
- function getCurrentVersion() {
62
- const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8'));
63
- return pkg.version;
64
- }
65
-
66
- function updateVersion(newVersion) {
67
- const pkg = JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8'));
68
- pkg.version = newVersion;
69
- if (!dryRun) {
70
- fs.writeFileSync(PACKAGE_JSON, JSON.stringify(pkg, null, 2) + '\n');
71
- }
72
- log(`Updated package.json version: ${newVersion}`);
73
- }
74
-
75
- function bumpRcVersion(currentVersion) {
76
- // Check if already an RC: 1.0.9-rc.1 -> 1.0.9-rc.2
77
- const rcMatch = currentVersion.match(/^(\d+\.\d+\.\d+)-rc\.(\d+)$/);
78
- if (rcMatch) {
79
- const [, base, rcNum] = rcMatch;
80
- return `${base}-rc.${parseInt(rcNum) + 1}`;
81
- }
82
-
83
- // Not an RC, bump patch and start at rc.1: 1.0.8 -> 1.0.9-rc.1
84
- const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
85
- if (!match) throw new Error(`Invalid version format: ${currentVersion}`);
86
-
87
- const [, major, minor, patch] = match;
88
- return `${major}.${minor}.${parseInt(patch) + 1}-rc.1`;
89
- }
90
-
91
- function promoteToStable(currentVersion) {
92
- // If RC version: 1.0.9-rc.2 -> 1.0.9
93
- const rcMatch = currentVersion.match(/^(\d+\.\d+\.\d+)-rc\.\d+$/);
94
- if (rcMatch) {
95
- return rcMatch[1];
96
- }
97
-
98
- // If already stable: 1.1.1 -> 1.1.2 (bump patch for new stable release)
99
- const stableMatch = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)$/);
100
- if (stableMatch) {
101
- const [, major, minor, patch] = stableMatch;
102
- return `${major}.${minor}.${parseInt(patch) + 1}`;
103
- }
104
-
105
- throw new Error(`Invalid version format: ${currentVersion}`);
106
- }
107
-
108
- function createTag(version) {
109
- const tag = `v${version}`;
110
- const commitMsg = `chore: release ${tag}`;
111
-
112
- if (dryRun) {
113
- log(`[dry-run] Would commit: "${commitMsg}"`);
114
- log(`[dry-run] Would create tag: ${tag}`);
115
- return tag;
116
- }
117
-
118
- // Stage and commit
119
- exec('git add package.json');
120
- try {
121
- exec(`git commit -m "${commitMsg}"`);
122
- } catch (e) {
123
- log('Nothing to commit (version already matches)');
124
- }
125
-
126
- // Create annotated tag
127
- exec(`git tag -a ${tag} -m "Release ${tag}"`);
128
- log(`Created tag: ${tag}`);
129
-
130
- return tag;
131
- }
132
-
133
- function outputForGitHubActions(version, tag, isPromote) {
134
- const output = process.env.GITHUB_OUTPUT;
135
- if (output) {
136
- const npmTag = version.includes('-rc.') ? 'next' : 'latest';
137
- fs.appendFileSync(output, `version=${version}\n`);
138
- fs.appendFileSync(output, `tag=${tag}\n`);
139
- fs.appendFileSync(output, `npm_tag=${npmTag}\n`);
140
- fs.appendFileSync(output, `is_promote=${isPromote}\n`);
141
- log(`GitHub Actions outputs set: version=${version}, tag=${tag}, npm_tag=${npmTag}, is_promote=${isPromote}`);
142
- }
143
- }
144
-
145
- async function main() {
146
- const action = opts['action'];
147
-
148
- if (!action) {
149
- console.error('Usage: node scripts/release.cjs --action bump-rc|promote [--dry-run]');
150
- console.error('');
151
- console.error('Actions:');
152
- console.error(' bump-rc - Create new RC version');
153
- console.error(' promote - Promote current RC to stable');
154
- process.exit(1);
155
- }
156
-
157
- const currentVersion = getCurrentVersion();
158
- log(`Current version: ${currentVersion}`);
159
-
160
- let newVersion;
161
- let isPromote = false;
162
-
163
- switch (action) {
164
- case 'bump-rc':
165
- newVersion = bumpRcVersion(currentVersion);
166
- break;
167
- case 'promote':
168
- newVersion = promoteToStable(currentVersion);
169
- isPromote = true;
170
- break;
171
- default:
172
- console.error(`Unknown action: ${action}`);
173
- console.error('Valid actions: bump-rc, promote');
174
- process.exit(1);
175
- }
176
-
177
- log(`New version: ${newVersion}`);
178
-
179
- // Update package.json
180
- updateVersion(newVersion);
181
-
182
- // Create git tag
183
- const tag = createTag(newVersion);
184
-
185
- // Output for GitHub Actions
186
- // Note: Release notes are auto-generated by GitHub via .github/release.yml
187
- outputForGitHubActions(newVersion, tag, isPromote);
188
-
189
- log(`Release ${tag} prepared!`);
190
- if (!dryRun) {
191
- log('Push with: git push && git push --tags');
192
- }
193
- }
194
-
195
- main().catch(e => {
196
- console.error(`[release] Error: ${e.message}`);
197
- process.exit(1);
198
- });