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.
- package/.genie/AGENTS.md +2 -0
- package/.genie/code/AGENTS.md +2 -0
- package/.genie/wishes/release-system-genie-pattern/WISH.md +268 -0
- package/.genie/wishes/release-system-genie-pattern/validation.md +172 -0
- package/.github/workflows/release.yml +233 -111
- package/.github/workflows/{build-all-platforms.yml → version.yml} +30 -6
- package/AGENTS.md +11 -7
- package/Makefile +18 -41
- package/SECURITY.md +109 -0
- package/bin/pgserve-wrapper.cjs +105 -0
- package/knip.json +1 -1
- package/package.json +3 -2
- package/scripts/test-bun-self-heal.sh +163 -0
- package/src/postgres.js +54 -0
- package/src/router.js +70 -5
- package/tests/multi-tenant.test.js +164 -0
- package/.github/release.yml +0 -30
- package/scripts/release.cjs +0 -198
|
@@ -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
|
|
package/.github/release.yml
DELETED
|
@@ -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
|
-
- "*"
|
package/scripts/release.cjs
DELETED
|
@@ -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
|
-
});
|