pgserve 1.2.0 → 2.0.1

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.
Files changed (45) hide show
  1. package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
  2. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
  3. package/.genie/wishes/pgserve-v2/WISH.md +442 -0
  4. package/.genie/wishes/release-system-genie-pattern/WISH.md +9 -9
  5. package/.genie/wishes/release-system-genie-pattern/validation.md +43 -10
  6. package/.github/workflows/ci.yml +10 -6
  7. package/.github/workflows/release.yml +1 -1
  8. package/.github/workflows/version.yml +4 -4
  9. package/CHANGELOG.md +150 -0
  10. package/Makefile +12 -12
  11. package/README.md +216 -10
  12. package/bin/pgserve-wrapper.cjs +3 -3
  13. package/bin/{pglite-server.js → postgres-server.js} +258 -1
  14. package/bun.lock +0 -3
  15. package/ecosystem.config.cjs +3 -3
  16. package/eslint.config.js +2 -0
  17. package/knip.json +1 -1
  18. package/package.json +4 -5
  19. package/scripts/test-bun-self-heal.sh +10 -10
  20. package/src/admin-client.js +171 -0
  21. package/src/audit.js +168 -0
  22. package/src/control-db.js +313 -0
  23. package/src/daemon-control.js +408 -0
  24. package/src/daemon-shared.js +18 -0
  25. package/src/daemon-tcp.js +296 -0
  26. package/src/daemon.js +629 -0
  27. package/src/fingerprint.js +453 -0
  28. package/src/gc.js +351 -0
  29. package/src/index.js +31 -0
  30. package/src/protocol.js +131 -0
  31. package/src/router.js +8 -0
  32. package/src/sdk.js +137 -0
  33. package/src/tenancy.js +75 -0
  34. package/src/tokens.js +102 -0
  35. package/tests/audit.test.js +189 -0
  36. package/tests/benchmarks/runner.js +430 -754
  37. package/tests/control-db.test.js +285 -0
  38. package/tests/daemon-fingerprint-integration.test.js +111 -0
  39. package/tests/daemon-pr24-regression.test.js +198 -0
  40. package/tests/fingerprint.test.js +249 -0
  41. package/tests/fixtures/240-orphan-seed.sql +30 -0
  42. package/tests/orphan-cleanup.test.js +390 -0
  43. package/tests/sdk.test.js +71 -0
  44. package/tests/tcp-listen.test.js +368 -0
  45. package/tests/tenancy.test.js +403 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Tests for src/control-db.js — pgserve_meta schema + accessors.
3
+ *
4
+ * Boots an ephemeral pgserve router (memory mode), connects via node-pg
5
+ * to the default `postgres` database, and exercises every exported function.
6
+ */
7
+
8
+ import { test, expect, beforeAll, afterAll } from 'bun:test';
9
+ import fs from 'fs';
10
+ import pg from 'pg';
11
+ import { startMultiTenantServer } from '../src/index.js';
12
+ import {
13
+ ensureMetaSchema,
14
+ recordDbCreated,
15
+ touchLastConnection,
16
+ markPersist,
17
+ forEachReapable,
18
+ deleteMetaRow,
19
+ addAllowedToken,
20
+ revokeAllowedToken,
21
+ verifyToken,
22
+ findRowByFingerprint,
23
+ } from '../src/control-db.js';
24
+
25
+ const { Client } = pg;
26
+
27
+ const TEST_DATA_DIR = './test-data-control-db';
28
+ const PORT = 15561;
29
+
30
+ let router;
31
+ let client;
32
+
33
+ function cleanupDataDir() {
34
+ if (fs.existsSync(TEST_DATA_DIR)) {
35
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ beforeAll(async () => {
40
+ cleanupDataDir();
41
+ router = await startMultiTenantServer({
42
+ port: PORT,
43
+ baseDir: TEST_DATA_DIR,
44
+ logLevel: 'warn',
45
+ });
46
+
47
+ client = new Client({
48
+ host: '127.0.0.1',
49
+ port: PORT,
50
+ database: 'postgres',
51
+ user: 'postgres',
52
+ password: 'postgres',
53
+ });
54
+ await client.connect();
55
+ await client.query('DROP TABLE IF EXISTS pgserve_meta');
56
+ });
57
+
58
+ afterAll(async () => {
59
+ try { await client.end(); } catch { /* noop */ }
60
+ try { await router.stop(); } catch { /* noop */ }
61
+ cleanupDataDir();
62
+ });
63
+
64
+ test('ensureMetaSchema creates table on first call', async () => {
65
+ await ensureMetaSchema(client);
66
+ const r = await client.query(`
67
+ SELECT column_name FROM information_schema.columns
68
+ WHERE table_name = 'pgserve_meta'
69
+ ORDER BY ordinal_position
70
+ `);
71
+ const columns = r.rows.map(row => row.column_name);
72
+ expect(columns).toEqual([
73
+ 'database_name',
74
+ 'fingerprint',
75
+ 'peer_uid',
76
+ 'package_realpath',
77
+ 'created_at',
78
+ 'last_connection_at',
79
+ 'liveness_pid',
80
+ 'persist',
81
+ 'allowed_tokens',
82
+ ]);
83
+ });
84
+
85
+ test('ensureMetaSchema is idempotent', async () => {
86
+ await ensureMetaSchema(client);
87
+ await ensureMetaSchema(client);
88
+ // No throw — schema unchanged.
89
+ const r = await client.query(`SELECT count(*)::int AS n FROM pgserve_meta`);
90
+ expect(r.rows[0].n).toBe(0);
91
+ });
92
+
93
+ test('recordDbCreated inserts a row + select round-trip', async () => {
94
+ await client.query('TRUNCATE pgserve_meta');
95
+ await recordDbCreated(client, {
96
+ databaseName: 'app_demo_abc123def456',
97
+ fingerprint: 'abc123def456',
98
+ peerUid: 1000,
99
+ packageRealpath: '/home/me/proj/package.json',
100
+ livenessPid: 4242,
101
+ persist: false,
102
+ });
103
+ const r = await client.query(`SELECT * FROM pgserve_meta WHERE database_name = $1`, [
104
+ 'app_demo_abc123def456',
105
+ ]);
106
+ expect(r.rows.length).toBe(1);
107
+ const row = r.rows[0];
108
+ expect(row.fingerprint).toBe('abc123def456');
109
+ expect(row.peer_uid).toBe(1000);
110
+ expect(row.package_realpath).toBe('/home/me/proj/package.json');
111
+ expect(row.liveness_pid).toBe(4242);
112
+ expect(row.persist).toBe(false);
113
+ expect(row.created_at).toBeInstanceOf(Date);
114
+ expect(row.last_connection_at).toBeInstanceOf(Date);
115
+ });
116
+
117
+ test('recordDbCreated upserts on conflict (database_name PK)', async () => {
118
+ await client.query('TRUNCATE pgserve_meta');
119
+ await recordDbCreated(client, {
120
+ databaseName: 'app_demo_abc123def456',
121
+ fingerprint: 'abc123def456',
122
+ peerUid: 1000,
123
+ packageRealpath: '/home/me/proj/package.json',
124
+ livenessPid: 4242,
125
+ });
126
+ // Re-insert with new peerUid + livenessPid → must upsert.
127
+ await recordDbCreated(client, {
128
+ databaseName: 'app_demo_abc123def456',
129
+ fingerprint: 'abc123def456',
130
+ peerUid: 1001,
131
+ packageRealpath: '/home/me/proj/package.json',
132
+ livenessPid: 9999,
133
+ persist: true,
134
+ });
135
+ const r = await client.query(`SELECT peer_uid, liveness_pid, persist FROM pgserve_meta`);
136
+ expect(r.rows.length).toBe(1);
137
+ expect(r.rows[0].peer_uid).toBe(1001);
138
+ expect(r.rows[0].liveness_pid).toBe(9999);
139
+ expect(r.rows[0].persist).toBe(true);
140
+ });
141
+
142
+ test('touchLastConnection bumps last_connection_at and liveness_pid', async () => {
143
+ await client.query('TRUNCATE pgserve_meta');
144
+ await recordDbCreated(client, {
145
+ databaseName: 'app_x_111111111111',
146
+ fingerprint: '111111111111',
147
+ peerUid: 1000,
148
+ livenessPid: 100,
149
+ });
150
+ const before = await client.query(
151
+ `SELECT last_connection_at, liveness_pid FROM pgserve_meta WHERE database_name = $1`,
152
+ ['app_x_111111111111'],
153
+ );
154
+ // Sleep briefly so now() advances visibly.
155
+ await new Promise(r => setTimeout(r, 50));
156
+
157
+ await touchLastConnection(client, {
158
+ databaseName: 'app_x_111111111111',
159
+ livenessPid: 200,
160
+ });
161
+ const after = await client.query(
162
+ `SELECT last_connection_at, liveness_pid FROM pgserve_meta WHERE database_name = $1`,
163
+ ['app_x_111111111111'],
164
+ );
165
+ expect(after.rows[0].liveness_pid).toBe(200);
166
+ expect(after.rows[0].last_connection_at.getTime()).toBeGreaterThan(
167
+ before.rows[0].last_connection_at.getTime(),
168
+ );
169
+ });
170
+
171
+ test('markPersist toggles persist flag', async () => {
172
+ await client.query('TRUNCATE pgserve_meta');
173
+ await recordDbCreated(client, {
174
+ databaseName: 'app_p_222222222222',
175
+ fingerprint: '222222222222',
176
+ peerUid: 1000,
177
+ });
178
+ await markPersist(client, 'app_p_222222222222', true);
179
+ let r = await client.query(`SELECT persist FROM pgserve_meta WHERE database_name = $1`, [
180
+ 'app_p_222222222222',
181
+ ]);
182
+ expect(r.rows[0].persist).toBe(true);
183
+
184
+ await markPersist(client, 'app_p_222222222222', false);
185
+ r = await client.query(`SELECT persist FROM pgserve_meta WHERE database_name = $1`, [
186
+ 'app_p_222222222222',
187
+ ]);
188
+ expect(r.rows[0].persist).toBe(false);
189
+ });
190
+
191
+ test('forEachReapable yields only persist=false rows in last_connection_at order', async () => {
192
+ await client.query('TRUNCATE pgserve_meta');
193
+ // Older row first, newer row second; persistent row separately.
194
+ await client.query(
195
+ `INSERT INTO pgserve_meta (database_name, fingerprint, peer_uid, last_connection_at, persist)
196
+ VALUES
197
+ ('app_a_aaaaaaaaaaaa', 'aaaaaaaaaaaa', 1000, now() - interval '2 hours', false),
198
+ ('app_b_bbbbbbbbbbbb', 'bbbbbbbbbbbb', 1000, now() - interval '1 hour', false),
199
+ ('app_c_cccccccccccc', 'cccccccccccc', 1000, now(), true)`,
200
+ );
201
+
202
+ const seen = [];
203
+ for await (const row of forEachReapable(client, { now: new Date() })) {
204
+ seen.push(row.databaseName);
205
+ }
206
+ expect(seen).toEqual(['app_a_aaaaaaaaaaaa', 'app_b_bbbbbbbbbbbb']);
207
+ });
208
+
209
+ test('deleteMetaRow removes the row', async () => {
210
+ await client.query('TRUNCATE pgserve_meta');
211
+ await recordDbCreated(client, {
212
+ databaseName: 'app_del_333333333333',
213
+ fingerprint: '333333333333',
214
+ peerUid: 1000,
215
+ });
216
+ await deleteMetaRow(client, 'app_del_333333333333');
217
+ const r = await client.query(`SELECT count(*)::int AS n FROM pgserve_meta`);
218
+ expect(r.rows[0].n).toBe(0);
219
+ });
220
+
221
+ test('recordDbCreated rejects bad input', async () => {
222
+ await expect(recordDbCreated(client, { fingerprint: 'x', peerUid: 1 })).rejects.toThrow(
223
+ /databaseName required/,
224
+ );
225
+ await expect(recordDbCreated(client, { databaseName: 'd', peerUid: 1 })).rejects.toThrow(
226
+ /fingerprint required/,
227
+ );
228
+ await expect(
229
+ recordDbCreated(client, { databaseName: 'd', fingerprint: 'f', peerUid: 'nope' }),
230
+ ).rejects.toThrow(/peerUid must be number/);
231
+ });
232
+
233
+ test('addAllowedToken refuses unknown fingerprint', async () => {
234
+ await client.query('TRUNCATE pgserve_meta');
235
+ await expect(
236
+ addAllowedToken(client, { fingerprint: 'deadbeef0000', tokenId: 'tk1', tokenHash: 'h1' }),
237
+ ).rejects.toThrow(/no pgserve_meta row/);
238
+ });
239
+
240
+ test('addAllowedToken appends, verifyToken finds it, revokeAllowedToken removes it', async () => {
241
+ await client.query('TRUNCATE pgserve_meta');
242
+ await recordDbCreated(client, {
243
+ databaseName: 'app_demo_4444aabbccdd',
244
+ fingerprint: '4444aabbccdd',
245
+ peerUid: 1000,
246
+ });
247
+ await addAllowedToken(client, {
248
+ fingerprint: '4444aabbccdd',
249
+ tokenId: 'aaaa1111',
250
+ tokenHash: 'hash-1',
251
+ });
252
+ await addAllowedToken(client, {
253
+ fingerprint: '4444aabbccdd',
254
+ tokenId: 'bbbb2222',
255
+ tokenHash: 'hash-2',
256
+ });
257
+
258
+ const row = await findRowByFingerprint(client, '4444aabbccdd');
259
+ expect(row).not.toBeNull();
260
+ expect(row.allowedTokens.length).toBe(2);
261
+ expect(row.allowedTokens.map(t => t.id).sort()).toEqual(['aaaa1111', 'bbbb2222']);
262
+
263
+ const ok = await verifyToken(client, { fingerprint: '4444aabbccdd', tokenHash: 'hash-2' });
264
+ expect(ok).toEqual({ tokenId: 'bbbb2222', databaseName: 'app_demo_4444aabbccdd' });
265
+
266
+ const miss = await verifyToken(client, { fingerprint: '4444aabbccdd', tokenHash: 'no-such' });
267
+ expect(miss).toBeNull();
268
+
269
+ const affected = await revokeAllowedToken(client, 'aaaa1111');
270
+ expect(affected).toBe(1);
271
+
272
+ const after = await findRowByFingerprint(client, '4444aabbccdd');
273
+ expect(after.allowedTokens.map(t => t.id)).toEqual(['bbbb2222']);
274
+ });
275
+
276
+ test('revokeAllowedToken returns 0 for unknown id', async () => {
277
+ await client.query('TRUNCATE pgserve_meta');
278
+ await recordDbCreated(client, {
279
+ databaseName: 'app_x_5555aabbccdd',
280
+ fingerprint: '5555aabbccdd',
281
+ peerUid: 1000,
282
+ });
283
+ const affected = await revokeAllowedToken(client, 'nonexistent');
284
+ expect(affected).toBe(0);
285
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Daemon × fingerprint integration test (Group 3, deliverable 2).
3
+ *
4
+ * Verifies that PgserveDaemon.handleSocketOpen calls handleControlAccept on
5
+ * every accept, producing a `connection_routed` audit entry whose fingerprint
6
+ * is the documented 12-hex blob.
7
+ *
8
+ * Boots a real daemon (with isolated controlSocketDir + auditLogFile), dials
9
+ * the control socket via Bun.connect, and tails the audit log.
10
+ */
11
+
12
+ import { describe, test, expect } from 'bun:test';
13
+ import fs from 'fs';
14
+ import os from 'os';
15
+ import path from 'path';
16
+
17
+ import {
18
+ PgserveDaemon,
19
+ resolveControlSocketPath,
20
+ resolvePidLockPath,
21
+ } from '../src/daemon.js';
22
+ import { createLogger } from '../src/logger.js';
23
+ import { AUDIT_EVENTS, configureAudit } from '../src/audit.js';
24
+
25
+ function silentLogger() {
26
+ return createLogger({ level: 'warn' });
27
+ }
28
+
29
+ function makeIsolated(tag) {
30
+ return fs.mkdtempSync(path.join('/tmp', `pgs-fp-${tag}-`));
31
+ }
32
+
33
+ function readAuditLines(logFile) {
34
+ if (!fs.existsSync(logFile)) return [];
35
+ return fs.readFileSync(logFile, 'utf8')
36
+ .split('\n')
37
+ .filter(Boolean)
38
+ .map((l) => JSON.parse(l));
39
+ }
40
+
41
+ describe('Group 3 — daemon emits connection_routed on accept', () => {
42
+ test('handleSocketOpen derives fingerprint and audits connection_routed', async () => {
43
+ const dir = makeIsolated('routed');
44
+ const auditLogFile = path.join(dir, 'audit.log');
45
+
46
+ const daemon = new PgserveDaemon({
47
+ controlSocketDir: dir,
48
+ controlSocketPath: resolveControlSocketPath(dir),
49
+ pidLockPath: resolvePidLockPath(dir),
50
+ pgPort: 16100,
51
+ auditLogFile,
52
+ auditTarget: 'file',
53
+ logger: silentLogger(),
54
+ _fingerprintAcceptOpts: () => ({
55
+ cwdOverride: dir,
56
+ cmdlineOverride: [process.execPath, import.meta.url],
57
+ }),
58
+ });
59
+ await daemon.start();
60
+
61
+ try {
62
+ // Dial the control socket. We don't need to push a real PG startup
63
+ // message — the accept hook fires the moment the connection opens,
64
+ // before any handshake bytes are needed.
65
+ const acceptedFingerprint = await new Promise((resolve, reject) => {
66
+ const timer = setTimeout(() => reject(new Error('timeout waiting for accept')), 2000);
67
+ daemon.once('accept', ({ fingerprint }) => {
68
+ clearTimeout(timer);
69
+ resolve(fingerprint);
70
+ });
71
+ Bun.connect({
72
+ unix: daemon.controlSocketPath,
73
+ socket: {
74
+ open(s) { s.end(); },
75
+ data() {},
76
+ close() {},
77
+ error(_s, err) { clearTimeout(timer); reject(err); },
78
+ },
79
+ }).catch((err) => { clearTimeout(timer); reject(err); });
80
+ });
81
+
82
+ expect(acceptedFingerprint).toBeDefined();
83
+ expect(acceptedFingerprint.fingerprint).toMatch(/^[0-9a-f]{12}$/);
84
+
85
+ // Allow the audit appendFileSync to flush. Poll briefly.
86
+ const deadline = Date.now() + 1000;
87
+ let entries = [];
88
+ while (Date.now() < deadline) {
89
+ entries = readAuditLines(auditLogFile);
90
+ if (entries.length > 0) break;
91
+ await new Promise((r) => setTimeout(r, 25));
92
+ }
93
+ expect(entries.length).toBeGreaterThan(0);
94
+ const routed = entries.find((e) => e.event === AUDIT_EVENTS.CONNECTION_ROUTED);
95
+ expect(routed).toBeDefined();
96
+ expect(routed.fingerprint).toMatch(/^[0-9a-f]{12}$/);
97
+ expect(routed.fingerprint).toBe(acceptedFingerprint.fingerprint);
98
+ expect(routed.peer_uid).toBe(process.getuid());
99
+ expect(typeof routed.peer_pid).toBe('number');
100
+ expect(['package', 'script']).toContain(routed.mode);
101
+ } finally {
102
+ await daemon.stop();
103
+ // Reset audit module's mutable defaults so other tests aren't affected.
104
+ configureAudit({
105
+ logFile: path.join(os.homedir(), '.pgserve', 'audit.log'),
106
+ target: process.env.PGSERVE_AUDIT_TARGET || 'file',
107
+ });
108
+ fs.rmSync(dir, { recursive: true, force: true });
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * PR #24 regression tests for the v2 daemon.
3
+ *
4
+ * The daemon (src/daemon.js) shares a PostgresManager lifecycle with the
5
+ * v1 router (src/router.js). PR #24's fixes for issue #24 (stale socketDir
6
+ * leaks across stop/start cycles) must remain in force after the v2 cut.
7
+ *
8
+ * Coverage:
9
+ * 1. PostgresManager.stop() nulls socketDir/databaseDir.
10
+ * 2. start() + stop() + start() yields a fresh socketDir (no leak).
11
+ * 3. Double start() is a no-op (re-entry guard).
12
+ * 4. Daemon mode does NOT introduce a new socketDir leak path under
13
+ * abnormal exit (kill -9): orphaned socket file + pid lock are cleaned
14
+ * up by the next `PgserveDaemon.start()` boot via stale-pid detection.
15
+ */
16
+
17
+ import { describe, test, expect } from 'bun:test';
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+
21
+ import { PostgresManager } from '../src/postgres.js';
22
+ import { createLogger } from '../src/logger.js';
23
+ import {
24
+ PgserveDaemon,
25
+ acquirePidLock,
26
+ resolveControlSocketPath,
27
+ resolvePidLockPath,
28
+ isProcessAlive,
29
+ } from '../src/daemon.js';
30
+
31
+ function silentLogger() {
32
+ return createLogger({ level: 'warn' });
33
+ }
34
+
35
+ // Each test uses a unique controlSocketDir under tmp so concurrent runs
36
+ // (and the existing host's real /run/user/<uid>/pgserve) cannot collide.
37
+ function makeDaemonDirs(tag) {
38
+ return fs.mkdtempSync(path.join('/tmp', `pgs-${tag}-`));
39
+ }
40
+
41
+ describe('PR #24 regression — PostgresManager lifecycle', () => {
42
+ test('stop() nulls socketDir/databaseDir', async () => {
43
+ const pg = new PostgresManager({ port: 16001, logger: silentLogger() });
44
+ await pg.start();
45
+ expect(pg.socketDir).not.toBeNull();
46
+ expect(fs.existsSync(pg.socketDir)).toBe(true);
47
+ const stale = pg.socketDir;
48
+
49
+ await pg.stop();
50
+
51
+ expect(pg.socketDir).toBeNull();
52
+ expect(pg.databaseDir).toBeNull();
53
+ expect(pg.getSocketPath()).toBeNull();
54
+ expect(fs.existsSync(stale)).toBe(false);
55
+ });
56
+
57
+ test('start()+stop()+start() yields fresh socketDir, no leak', async () => {
58
+ const pg = new PostgresManager({ port: 16002, logger: silentLogger() });
59
+
60
+ await pg.start();
61
+ const dirA = pg.socketDir;
62
+ expect(dirA).not.toBeNull();
63
+
64
+ await pg.stop();
65
+ expect(pg.socketDir).toBeNull();
66
+
67
+ await pg.start();
68
+ const dirB = pg.socketDir;
69
+ expect(dirB).not.toBeNull();
70
+ expect(dirB).not.toBe(dirA);
71
+ expect(fs.existsSync(dirB)).toBe(true);
72
+ // Old dir must be gone; PR #24 guarantees no leak across cycles.
73
+ expect(fs.existsSync(dirA)).toBe(false);
74
+
75
+ await pg.stop();
76
+ });
77
+
78
+ test('double start() is a no-op (re-entry guard preserved)', async () => {
79
+ const pg = new PostgresManager({ port: 16003, logger: silentLogger() });
80
+ await pg.start();
81
+ const before = pg.socketDir;
82
+
83
+ const result = await pg.start();
84
+ expect(result).toBe(pg);
85
+ expect(pg.socketDir).toBe(before);
86
+
87
+ await pg.stop();
88
+ });
89
+ });
90
+
91
+ describe('PR #24 regression — daemon does not leak under abnormal exit', () => {
92
+ test('stale pid lock + orphaned socket are cleaned up by next daemon boot', async () => {
93
+ const dir = makeDaemonDirs('stale');
94
+ const socketPath = resolveControlSocketPath(dir);
95
+ const pidLockPath = resolvePidLockPath(dir);
96
+
97
+ // Simulate kill -9: write a pid file pointing at a guaranteed-dead pid
98
+ // and create a fake stale socket file beside it. PID 1 is always alive
99
+ // on Unix, so we manufacture a dead one by reading max_pid + 1 (Linux)
100
+ // or just using a high value not currently in use.
101
+ const deadPid = pickDeadPid();
102
+ expect(isProcessAlive(deadPid)).toBe(false);
103
+
104
+ fs.writeFileSync(pidLockPath, String(deadPid), { mode: 0o600 });
105
+ fs.writeFileSync(socketPath, ''); // stand-in for an orphaned socket file
106
+ expect(fs.existsSync(pidLockPath)).toBe(true);
107
+ expect(fs.existsSync(socketPath)).toBe(true);
108
+
109
+ const lock = acquirePidLock({
110
+ pidLockPath,
111
+ socketPath,
112
+ logger: silentLogger(),
113
+ });
114
+ expect(lock.acquired).toBe(true);
115
+
116
+ // The lock file now belongs to *us* (this test's process pid), and the
117
+ // orphaned socket placeholder must have been removed during stale-pid
118
+ // cleanup so the daemon can bind a fresh socket on the same path.
119
+ expect(fs.existsSync(pidLockPath)).toBe(true);
120
+ expect(fs.readFileSync(pidLockPath, 'utf8').trim()).toBe(String(process.pid));
121
+ expect(fs.existsSync(socketPath)).toBe(false);
122
+
123
+ // Cleanup the test's lock so we don't leak between tests.
124
+ fs.unlinkSync(pidLockPath);
125
+ fs.rmSync(dir, { recursive: true, force: true });
126
+ });
127
+
128
+ test('PgserveDaemon.start refuses second invocation while first is alive', async () => {
129
+ const dir = makeDaemonDirs('singleton');
130
+ const d1 = new PgserveDaemon({
131
+ controlSocketDir: dir,
132
+ controlSocketPath: resolveControlSocketPath(dir),
133
+ pidLockPath: resolvePidLockPath(dir),
134
+ pgPort: 16010,
135
+ logger: silentLogger(),
136
+ });
137
+ await d1.start();
138
+
139
+ const d2 = new PgserveDaemon({
140
+ controlSocketDir: dir,
141
+ controlSocketPath: resolveControlSocketPath(dir),
142
+ pidLockPath: resolvePidLockPath(dir),
143
+ pgPort: 16011,
144
+ logger: silentLogger(),
145
+ });
146
+
147
+ let captured;
148
+ try {
149
+ await d2.start();
150
+ } catch (err) {
151
+ captured = err;
152
+ }
153
+ expect(captured).toBeDefined();
154
+ expect(captured.code).toBe('EALREADYRUNNING');
155
+ expect(captured.pid).toBe(process.pid);
156
+
157
+ await d1.stop();
158
+ expect(fs.existsSync(d1.controlSocketPath)).toBe(false);
159
+ expect(fs.existsSync(d1.pidLockPath)).toBe(false);
160
+ fs.rmSync(dir, { recursive: true, force: true });
161
+ });
162
+
163
+ test('PgserveDaemon.stop unlinks both socket and pid lock', async () => {
164
+ const dir = makeDaemonDirs('cleanup');
165
+ const d = new PgserveDaemon({
166
+ controlSocketDir: dir,
167
+ controlSocketPath: resolveControlSocketPath(dir),
168
+ pidLockPath: resolvePidLockPath(dir),
169
+ pgPort: 16020,
170
+ logger: silentLogger(),
171
+ });
172
+ await d.start();
173
+ expect(fs.existsSync(d.controlSocketPath)).toBe(true);
174
+ expect(fs.existsSync(d.pidLockPath)).toBe(true);
175
+
176
+ await d.stop();
177
+ expect(fs.existsSync(d.controlSocketPath)).toBe(false);
178
+ expect(fs.existsSync(d.pidLockPath)).toBe(false);
179
+
180
+ // PR #24 invariant carries through: PostgresManager nulled its paths.
181
+ expect(d.pgManager.socketDir).toBeNull();
182
+
183
+ fs.rmSync(dir, { recursive: true, force: true });
184
+ });
185
+ });
186
+
187
+ /**
188
+ * Pick a pid that is reasonably guaranteed not to be alive. We try a high
189
+ * pid first (most kernels recycle low pids), then walk down until we find
190
+ * one that is dead. As a final fallback we use 999999.
191
+ */
192
+ function pickDeadPid() {
193
+ const candidates = [987654, 765432, 543210, 321098, 109876];
194
+ for (const pid of candidates) {
195
+ if (!isProcessAlive(pid)) return pid;
196
+ }
197
+ return 999999;
198
+ }