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.
- package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
- package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
- package/.genie/wishes/pgserve-v2/WISH.md +442 -0
- package/.genie/wishes/release-system-genie-pattern/WISH.md +9 -9
- package/.genie/wishes/release-system-genie-pattern/validation.md +43 -10
- package/.github/workflows/ci.yml +10 -6
- package/.github/workflows/release.yml +1 -1
- package/.github/workflows/version.yml +4 -4
- package/CHANGELOG.md +150 -0
- package/Makefile +12 -12
- package/README.md +216 -10
- package/bin/pgserve-wrapper.cjs +3 -3
- package/bin/{pglite-server.js → postgres-server.js} +258 -1
- package/bun.lock +0 -3
- package/ecosystem.config.cjs +3 -3
- package/eslint.config.js +2 -0
- package/knip.json +1 -1
- package/package.json +4 -5
- package/scripts/test-bun-self-heal.sh +10 -10
- package/src/admin-client.js +171 -0
- package/src/audit.js +168 -0
- package/src/control-db.js +313 -0
- package/src/daemon-control.js +408 -0
- package/src/daemon-shared.js +18 -0
- package/src/daemon-tcp.js +296 -0
- package/src/daemon.js +629 -0
- package/src/fingerprint.js +453 -0
- package/src/gc.js +351 -0
- package/src/index.js +31 -0
- package/src/protocol.js +131 -0
- package/src/router.js +8 -0
- package/src/sdk.js +137 -0
- package/src/tenancy.js +75 -0
- package/src/tokens.js +102 -0
- package/tests/audit.test.js +189 -0
- package/tests/benchmarks/runner.js +430 -754
- package/tests/control-db.test.js +285 -0
- package/tests/daemon-fingerprint-integration.test.js +111 -0
- package/tests/daemon-pr24-regression.test.js +198 -0
- package/tests/fingerprint.test.js +249 -0
- package/tests/fixtures/240-orphan-seed.sql +30 -0
- package/tests/orphan-cleanup.test.js +390 -0
- package/tests/sdk.test.js +71 -0
- package/tests/tcp-listen.test.js +368 -0
- package/tests/tenancy.test.js +403 -0
|
@@ -12,6 +12,20 @@ import path from 'path';
|
|
|
12
12
|
import os from 'os';
|
|
13
13
|
import { startMultiTenantServer } from '../src/index.js';
|
|
14
14
|
import { startClusterServer } from '../src/cluster.js';
|
|
15
|
+
import {
|
|
16
|
+
PgserveDaemon,
|
|
17
|
+
stopDaemon,
|
|
18
|
+
resolveControlSocketDir,
|
|
19
|
+
resolveControlSocketPath,
|
|
20
|
+
} from '../src/daemon.js';
|
|
21
|
+
import { createAdminClient, readAdminDiscovery } from '../src/admin-client.js';
|
|
22
|
+
import {
|
|
23
|
+
ensureMetaSchema,
|
|
24
|
+
addAllowedToken,
|
|
25
|
+
revokeAllowedToken,
|
|
26
|
+
} from '../src/control-db.js';
|
|
27
|
+
import { mintToken } from '../src/tokens.js';
|
|
28
|
+
import { audit, AUDIT_EVENTS } from '../src/audit.js';
|
|
15
29
|
|
|
16
30
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
31
|
|
|
@@ -25,9 +39,252 @@ process.on('uncaughtException', (error) => {
|
|
|
25
39
|
process.exit(1);
|
|
26
40
|
});
|
|
27
41
|
|
|
28
|
-
// Parse CLI arguments
|
|
42
|
+
// Parse CLI arguments — `pgserve daemon [stop]` is dispatched before the
|
|
43
|
+
// classic `pgserve [options]` parser so daemon-mode flags do not collide
|
|
44
|
+
// with router flags.
|
|
29
45
|
const args = process.argv.slice(2);
|
|
30
46
|
|
|
47
|
+
if (args[0] === 'daemon') {
|
|
48
|
+
await runDaemonSubcommand(args.slice(1));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runDaemonSubcommand(daemonArgs) {
|
|
52
|
+
if (daemonArgs[0] === 'stop') {
|
|
53
|
+
const result = stopDaemon();
|
|
54
|
+
if (result.stopped) {
|
|
55
|
+
console.log(`pgserve daemon stopped (pid ${result.pid})`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
if (result.reason === 'no-pid-file') {
|
|
59
|
+
console.error('pgserve daemon: no PID file found — is the daemon running?');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
if (result.reason === 'stale-pid' || result.reason === 'invalid-pid-file') {
|
|
63
|
+
console.log(`pgserve daemon: cleaned up stale lock (pid ${result.pid ?? '?'})`);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
if (result.reason === 'timeout') {
|
|
67
|
+
console.error(`pgserve daemon: pid ${result.pid} did not exit within timeout`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
console.error(`pgserve daemon stop: ${result.reason}${result.error ? ` (${result.error})` : ''}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (daemonArgs[0] === 'issue-token') {
|
|
75
|
+
await runIssueTokenSubcommand(daemonArgs.slice(1));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (daemonArgs[0] === 'revoke-token') {
|
|
79
|
+
await runRevokeTokenSubcommand(daemonArgs.slice(1));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `pgserve daemon` (long-running)
|
|
84
|
+
const opts = parseDaemonArgs(daemonArgs);
|
|
85
|
+
const daemon = new PgserveDaemon(opts);
|
|
86
|
+
try {
|
|
87
|
+
await daemon.start();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err.code === 'EALREADYRUNNING') {
|
|
90
|
+
console.error(`pgserve daemon: already running, pid ${err.pid}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.error('pgserve daemon: failed to start:', err.message);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
const dir = resolveControlSocketDir();
|
|
97
|
+
console.log(`
|
|
98
|
+
pgserve daemon — singleton mode
|
|
99
|
+
|
|
100
|
+
Control socket: ${resolveControlSocketPath(dir)}
|
|
101
|
+
PID lock: ${path.join(dir, 'pgserve.pid')}
|
|
102
|
+
PG socket: ${daemon.pgManager.getSocketPath() || '(TCP fallback)'}
|
|
103
|
+
|
|
104
|
+
Connect: psql 'host=${dir} dbname=mydb'
|
|
105
|
+
|
|
106
|
+
Press Ctrl+C or send SIGTERM to stop.
|
|
107
|
+
`);
|
|
108
|
+
|
|
109
|
+
// Daemon installs its own SIGTERM/SIGINT handlers; just wait forever.
|
|
110
|
+
await new Promise(() => {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseDaemonArgs(daemonArgs) {
|
|
114
|
+
const opts = {
|
|
115
|
+
baseDir: null,
|
|
116
|
+
useRam: false,
|
|
117
|
+
logLevel: 'info',
|
|
118
|
+
autoProvision: true,
|
|
119
|
+
tcpListens: [],
|
|
120
|
+
enablePgvector: false,
|
|
121
|
+
};
|
|
122
|
+
for (let i = 0; i < daemonArgs.length; i++) {
|
|
123
|
+
const arg = daemonArgs[i];
|
|
124
|
+
switch (arg) {
|
|
125
|
+
case '--data':
|
|
126
|
+
case '-d':
|
|
127
|
+
opts.baseDir = daemonArgs[++i];
|
|
128
|
+
break;
|
|
129
|
+
case '--ram':
|
|
130
|
+
opts.useRam = true;
|
|
131
|
+
break;
|
|
132
|
+
case '--log':
|
|
133
|
+
case '-l':
|
|
134
|
+
opts.logLevel = daemonArgs[++i];
|
|
135
|
+
break;
|
|
136
|
+
case '--no-provision':
|
|
137
|
+
opts.autoProvision = false;
|
|
138
|
+
break;
|
|
139
|
+
case '--listen':
|
|
140
|
+
opts.tcpListens.push(daemonArgs[++i]);
|
|
141
|
+
break;
|
|
142
|
+
case '--pgvector':
|
|
143
|
+
opts.enablePgvector = true;
|
|
144
|
+
break;
|
|
145
|
+
case '--help':
|
|
146
|
+
console.log(`
|
|
147
|
+
pgserve daemon — singleton control-socket mode
|
|
148
|
+
|
|
149
|
+
USAGE:
|
|
150
|
+
pgserve daemon [options]
|
|
151
|
+
pgserve daemon stop
|
|
152
|
+
pgserve daemon issue-token --fingerprint <hex>
|
|
153
|
+
pgserve daemon revoke-token <id>
|
|
154
|
+
|
|
155
|
+
OPTIONS:
|
|
156
|
+
--data <path> Persistent data directory (default: in-memory)
|
|
157
|
+
--ram Use /dev/shm storage (Linux only)
|
|
158
|
+
--log <level> Log level: error|warn|info|debug (default: info)
|
|
159
|
+
--no-provision Disable auto-provisioning of databases
|
|
160
|
+
--listen [host:]port Bind opt-in TCP listener (repeatable)
|
|
161
|
+
--pgvector Auto-enable pgvector extension on new databases
|
|
162
|
+
--help Show this help
|
|
163
|
+
|
|
164
|
+
The daemon binds $XDG_RUNTIME_DIR/pgserve/control.sock (fallback /tmp/pgserve/control.sock).
|
|
165
|
+
A second invocation while the first is running exits with "already running".
|
|
166
|
+
|
|
167
|
+
TCP peers (--listen) MUST authenticate via libpq application_name shaped
|
|
168
|
+
"?fingerprint=<12hex>&token=<bearer>". Issue tokens with
|
|
169
|
+
"pgserve daemon issue-token --fingerprint <hex>". Revoke with
|
|
170
|
+
"pgserve daemon revoke-token <id>".
|
|
171
|
+
`);
|
|
172
|
+
process.exit(0);
|
|
173
|
+
// falls through (unreachable)
|
|
174
|
+
default:
|
|
175
|
+
if (arg.startsWith('-')) {
|
|
176
|
+
console.error(`Unknown daemon option: ${arg}`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return opts;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function runIssueTokenSubcommand(args) {
|
|
185
|
+
let fingerprint = null;
|
|
186
|
+
for (let i = 0; i < args.length; i++) {
|
|
187
|
+
const arg = args[i];
|
|
188
|
+
if (arg === '--fingerprint') fingerprint = args[++i];
|
|
189
|
+
else if (arg === '--help') {
|
|
190
|
+
console.log(`
|
|
191
|
+
pgserve daemon issue-token --fingerprint <12hex>
|
|
192
|
+
|
|
193
|
+
Issues a fresh bearer token for an existing fingerprint. Prints the token
|
|
194
|
+
to stdout exactly once; only the sha256 hash is persisted. Use the printed
|
|
195
|
+
value in libpq application_name shaped "?fingerprint=<hex>&token=<bearer>".
|
|
196
|
+
`);
|
|
197
|
+
process.exit(0);
|
|
198
|
+
} else {
|
|
199
|
+
console.error(`Unknown option: ${arg}`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!fingerprint || !/^[0-9a-f]{12}$/.test(fingerprint)) {
|
|
204
|
+
console.error('issue-token: --fingerprint <12hex> required');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let admin;
|
|
209
|
+
try {
|
|
210
|
+
const dir = resolveControlSocketDir();
|
|
211
|
+
const disc = readAdminDiscovery(dir);
|
|
212
|
+
admin = await createAdminClient({ socketDir: disc.socketDir, port: disc.port });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error('issue-token: cannot reach running daemon admin socket:', err.message);
|
|
215
|
+
console.error('Hint: start the daemon first with `pgserve daemon`.');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await ensureMetaSchema(admin);
|
|
221
|
+
const { id, cleartext, hash } = mintToken();
|
|
222
|
+
const result = await addAllowedToken(admin, {
|
|
223
|
+
fingerprint,
|
|
224
|
+
tokenId: id,
|
|
225
|
+
tokenHash: hash,
|
|
226
|
+
});
|
|
227
|
+
audit(AUDIT_EVENTS.TCP_TOKEN_ISSUED, {
|
|
228
|
+
fingerprint,
|
|
229
|
+
token_id: id,
|
|
230
|
+
database: result.databaseName,
|
|
231
|
+
});
|
|
232
|
+
console.log('Token issued. Save the bearer value below — it will not be shown again:');
|
|
233
|
+
console.log('');
|
|
234
|
+
console.log(` id: ${id}`);
|
|
235
|
+
console.log(` fingerprint: ${fingerprint}`);
|
|
236
|
+
console.log(` database: ${result.databaseName}`);
|
|
237
|
+
console.log(` token: ${cleartext}`);
|
|
238
|
+
console.log('');
|
|
239
|
+
console.log('Use as libpq application_name:');
|
|
240
|
+
console.log(` application_name='?fingerprint=${fingerprint}&token=${cleartext}'`);
|
|
241
|
+
process.exit(0);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (err.code === 'EUNKNOWNFINGERPRINT') {
|
|
244
|
+
console.error(`issue-token: fingerprint ${fingerprint} not provisioned yet.`);
|
|
245
|
+
console.error('Connect once via Unix socket so pgserve creates the database first.');
|
|
246
|
+
process.exit(2);
|
|
247
|
+
}
|
|
248
|
+
console.error('issue-token failed:', err.message);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
} finally {
|
|
251
|
+
try { await admin.end(); } catch { /* swallow */ }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function runRevokeTokenSubcommand(args) {
|
|
256
|
+
if (args.length === 0 || args[0] === '--help') {
|
|
257
|
+
console.log('Usage: pgserve daemon revoke-token <id>');
|
|
258
|
+
process.exit(args.length === 0 ? 1 : 0);
|
|
259
|
+
}
|
|
260
|
+
const tokenId = args[0];
|
|
261
|
+
|
|
262
|
+
let admin;
|
|
263
|
+
try {
|
|
264
|
+
const dir = resolveControlSocketDir();
|
|
265
|
+
const disc = readAdminDiscovery(dir);
|
|
266
|
+
admin = await createAdminClient({ socketDir: disc.socketDir, port: disc.port });
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error('revoke-token: cannot reach running daemon admin socket:', err.message);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const affected = await revokeAllowedToken(admin, tokenId);
|
|
274
|
+
if (affected === 0) {
|
|
275
|
+
console.error(`revoke-token: no token with id ${tokenId} found`);
|
|
276
|
+
process.exit(2);
|
|
277
|
+
}
|
|
278
|
+
console.log(`Token ${tokenId} revoked (affected ${affected} row${affected === 1 ? '' : 's'})`);
|
|
279
|
+
process.exit(0);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.error('revoke-token failed:', err.message);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
} finally {
|
|
284
|
+
try { await admin.end(); } catch { /* swallow */ }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
31
288
|
/**
|
|
32
289
|
* Print usage help
|
|
33
290
|
*/
|
package/bun.lock
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"bun": "^1.3.4",
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
|
-
"@electric-sql/pglite": "^0.2.17",
|
|
12
11
|
"eslint": "^9.39.1",
|
|
13
12
|
"eslint-plugin-unused-imports": "^4.3.0",
|
|
14
13
|
"husky": "^9.1.7",
|
|
@@ -25,8 +24,6 @@
|
|
|
25
24
|
},
|
|
26
25
|
},
|
|
27
26
|
"packages": {
|
|
28
|
-
"@electric-sql/pglite": ["@electric-sql/pglite@0.2.17", "", {}, "sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw=="],
|
|
29
|
-
|
|
30
27
|
"@embedded-postgres/darwin-arm64": ["@embedded-postgres/darwin-arm64@18.2.0-beta.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wnswaF+uDvGeitqajJ8v8xOG4ttFrzixElwKNe2MIxBXSLWPV3xhi6tBY0Sjw8Lmiu6UG9vNLFZSjHPrIeokBg=="],
|
|
31
28
|
|
|
32
29
|
"@embedded-postgres/darwin-x64": ["@embedded-postgres/darwin-x64@18.2.0-beta.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-u9WtTPxRuO0uOny5IniXHSDaLmtOujwzDoExIV/jFT0Fu8SzpX7wdoPbsSPBLgyQWdr/nPA77K9QI4r6P1/fKA=="],
|
package/ecosystem.config.cjs
CHANGED
|
@@ -2,7 +2,7 @@ module.exports = {
|
|
|
2
2
|
apps: [
|
|
3
3
|
{
|
|
4
4
|
name: 'pgserve',
|
|
5
|
-
script: './bin/
|
|
5
|
+
script: './bin/postgres-server.js',
|
|
6
6
|
args: 'router --port 8432',
|
|
7
7
|
cwd: '/home/namastex/dev/pgserve',
|
|
8
8
|
interpreter: 'node',
|
|
@@ -13,8 +13,8 @@ module.exports = {
|
|
|
13
13
|
env: {
|
|
14
14
|
NODE_ENV: 'production'
|
|
15
15
|
},
|
|
16
|
-
error_file: '/home/namastex/logs/
|
|
17
|
-
out_file: '/home/namastex/logs/
|
|
16
|
+
error_file: '/home/namastex/logs/postgres-server-error.log',
|
|
17
|
+
out_file: '/home/namastex/logs/postgres-server-out.log',
|
|
18
18
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
|
19
19
|
merge_logs: true,
|
|
20
20
|
time: true
|
package/eslint.config.js
CHANGED
package/knip.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://unpkg.com/knip@5/schema.json",
|
|
3
|
-
"entry": ["src/index.js", "bin/
|
|
3
|
+
"entry": ["src/index.js", "bin/postgres-server.js", "bin/pgserve-wrapper.cjs"],
|
|
4
4
|
"project": ["src/**/*.js", "bin/**/*.js", "bin/**/*.cjs"],
|
|
5
5
|
"ignore": ["tests/**", "helpers/**", "scripts/**"],
|
|
6
6
|
"ignoreBinaries": ["scripts/test-npx.sh", "scripts/test-bun-self-heal.sh", "make"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pgserve",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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",
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"bench": "bun tests/benchmarks/runner.js",
|
|
12
12
|
"test": "bun test tests/**/*.test.js",
|
|
13
13
|
"test:watch": "bun test --watch tests/**/*.test.js",
|
|
14
|
-
"dev": "bun --watch bin/
|
|
15
|
-
"start": "bun bin/
|
|
16
|
-
"build": "bun build --compile bin/
|
|
14
|
+
"dev": "bun --watch bin/postgres-server.js",
|
|
15
|
+
"start": "bun bin/postgres-server.js",
|
|
16
|
+
"build": "bun build --compile bin/postgres-server.js --outfile dist/pgserve",
|
|
17
17
|
"build:all": "make build-all",
|
|
18
18
|
"lint": "eslint src/ bin/",
|
|
19
19
|
"lint:fix": "eslint src/ bin/ --fix",
|
|
@@ -48,7 +48,6 @@
|
|
|
48
48
|
"@embedded-postgres/windows-x64": "18.2.0-beta.16"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@electric-sql/pglite": "^0.2.17",
|
|
52
51
|
"eslint": "^9.39.1",
|
|
53
52
|
"eslint-plugin-unused-imports": "^4.3.0",
|
|
54
53
|
"husky": "^9.1.7",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# pgserve-wrapper.cjs must detect this and self-heal via `node install.js`.
|
|
8
8
|
#
|
|
9
9
|
# This test stages a synthetic broken install tree, runs the wrapper, and
|
|
10
|
-
# asserts that it recovers and spawns
|
|
10
|
+
# asserts that it recovers and spawns postgres-server.
|
|
11
11
|
|
|
12
12
|
set -e
|
|
13
13
|
|
|
@@ -37,10 +37,10 @@ mkdir -p "$FIXTURE/node_modules/pgserve/bin"
|
|
|
37
37
|
|
|
38
38
|
cp "$WRAPPER" "$FIXTURE/node_modules/pgserve/bin/pgserve-wrapper.cjs"
|
|
39
39
|
|
|
40
|
-
# Stub
|
|
40
|
+
# Stub postgres-server so we can detect a successful spawn without needing
|
|
41
41
|
# postgres binaries in the fixture.
|
|
42
|
-
cat > "$FIXTURE/node_modules/pgserve/bin/
|
|
43
|
-
console.log("
|
|
42
|
+
cat > "$FIXTURE/node_modules/pgserve/bin/postgres-server.js" <<'EOF'
|
|
43
|
+
console.log("postgres-server-spawned");
|
|
44
44
|
process.exit(0);
|
|
45
45
|
EOF
|
|
46
46
|
|
|
@@ -97,13 +97,13 @@ if ! echo "$OUTPUT" | grep -q "bun runtime recovered"; then
|
|
|
97
97
|
exit 1
|
|
98
98
|
fi
|
|
99
99
|
|
|
100
|
-
if ! echo "$OUTPUT" | grep -q "
|
|
101
|
-
echo "✗
|
|
100
|
+
if ! echo "$OUTPUT" | grep -q "postgres-server-spawned"; then
|
|
101
|
+
echo "✗ postgres-server was not spawned after self-heal"
|
|
102
102
|
echo "$OUTPUT"
|
|
103
103
|
exit 1
|
|
104
104
|
fi
|
|
105
105
|
|
|
106
|
-
echo "✓ self-heal path: wrapper detected, repaired, and spawned
|
|
106
|
+
echo "✓ self-heal path: wrapper detected, repaired, and spawned postgres-server"
|
|
107
107
|
|
|
108
108
|
echo ""
|
|
109
109
|
echo "=== Testing healthy path is unaffected ==="
|
|
@@ -122,13 +122,13 @@ if echo "$OUTPUT" | grep -q "self-heal\|recovered"; then
|
|
|
122
122
|
exit 1
|
|
123
123
|
fi
|
|
124
124
|
|
|
125
|
-
if ! echo "$OUTPUT" | grep -q "
|
|
126
|
-
echo "✗
|
|
125
|
+
if ! echo "$OUTPUT" | grep -q "postgres-server-spawned"; then
|
|
126
|
+
echo "✗ postgres-server was not spawned on healthy path"
|
|
127
127
|
echo "$OUTPUT"
|
|
128
128
|
exit 1
|
|
129
129
|
fi
|
|
130
130
|
|
|
131
|
-
echo "✓ healthy path: wrapper was silent and spawned
|
|
131
|
+
echo "✓ healthy path: wrapper was silent and spawned postgres-server directly"
|
|
132
132
|
|
|
133
133
|
echo ""
|
|
134
134
|
echo "=== Testing non-postinstall errors surface raw ==="
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin DB client — small Bun.SQL wrapper exposing the `{query, end}`
|
|
3
|
+
* surface that `src/control-db.js` expects.
|
|
4
|
+
*
|
|
5
|
+
* The daemon and the `pgserve daemon issue-token / revoke-token` CLI
|
|
6
|
+
* subcommands both need a privileged connection to the underlying
|
|
7
|
+
* Postgres instance owned by `PostgresManager`. The pg npm module is
|
|
8
|
+
* a devDependency only (it backs the test harness); rather than promote
|
|
9
|
+
* it to runtime we wrap Bun.SQL — which is shipped with the runtime —
|
|
10
|
+
* in the parameterised-query interface control-db.js documents.
|
|
11
|
+
*
|
|
12
|
+
* Connection target:
|
|
13
|
+
* - Local Unix socket when `socketDir` is provided (the daemon's
|
|
14
|
+
* hot path) — drops the bytes onto the kernel-local socket.
|
|
15
|
+
* - TCP fallback when `socketDir` is null (e.g. CI hosts without
|
|
16
|
+
* the embedded socket directory present).
|
|
17
|
+
*
|
|
18
|
+
* The CLI side reads the daemon's discovery file at
|
|
19
|
+
* `${controlSocketDir}/admin.json` to learn `{socketDir, port}`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { SQL } from 'bun';
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} args
|
|
28
|
+
* @param {string|null} [args.socketDir] — accepted for parity with the
|
|
29
|
+
* embedded-postgres callers but unused; Bun.SQL's startup auth path
|
|
30
|
+
* does not currently traverse `pg_hba.conf` Unix-socket trust rules
|
|
31
|
+
* against `embedded-postgres`, so we always go TCP for admin work.
|
|
32
|
+
* Keeping the parameter avoids a churning call-site signature.
|
|
33
|
+
* @param {string} [args.host='127.0.0.1']
|
|
34
|
+
* @param {number} args.port
|
|
35
|
+
* @param {string} [args.database='postgres']
|
|
36
|
+
* @param {string} [args.user='postgres']
|
|
37
|
+
* @param {string} [args.password='postgres']
|
|
38
|
+
* @param {number} [args.max=2]
|
|
39
|
+
* @returns {Promise<{query: (text: string, params?: any[]) => Promise<{rows: any[], rowCount: number}>, end: () => Promise<void>, sql: any}>}
|
|
40
|
+
*/
|
|
41
|
+
export async function createAdminClient({
|
|
42
|
+
socketDir: _socketDir = null,
|
|
43
|
+
host = '127.0.0.1',
|
|
44
|
+
port,
|
|
45
|
+
database = 'postgres',
|
|
46
|
+
user = 'postgres',
|
|
47
|
+
password = 'postgres',
|
|
48
|
+
max = 2,
|
|
49
|
+
} = {}) {
|
|
50
|
+
if (typeof port !== 'number') throw new Error('createAdminClient: port required');
|
|
51
|
+
const sql = new SQL({
|
|
52
|
+
hostname: host,
|
|
53
|
+
port,
|
|
54
|
+
database,
|
|
55
|
+
username: user,
|
|
56
|
+
password,
|
|
57
|
+
max,
|
|
58
|
+
// TODO #38: investigate GC perf for 240-orphan sweep on shared CI runners;
|
|
59
|
+
// bumped 10s→30s during Felipe deadline 2026-04-29 to unblock pgserve v2.0 ship.
|
|
60
|
+
idleTimeout: 30,
|
|
61
|
+
});
|
|
62
|
+
// Light probe so a misconfigured daemon fails loudly here rather than at
|
|
63
|
+
// first query.
|
|
64
|
+
await sql`SELECT 1`;
|
|
65
|
+
return {
|
|
66
|
+
sql,
|
|
67
|
+
async query(text, params = []) {
|
|
68
|
+
// control-db.js is written for the pg npm module's contract, which
|
|
69
|
+
// requires JSON-stringified payloads bound to JSONB parameters.
|
|
70
|
+
// Bun.SQL goes the other way: it stringifies JS objects when they
|
|
71
|
+
// hit JSONB columns, but a JS string headed for `::jsonb` is sent
|
|
72
|
+
// as a JSON string literal (i.e. `"\"..."\"` rather than the array
|
|
73
|
+
// it represents). Bridge the impedance mismatch here so the same
|
|
74
|
+
// call sites work against either driver.
|
|
75
|
+
const adapted = params.map(coerceJsonbParam);
|
|
76
|
+
const rows = await sql.unsafe(text, adapted);
|
|
77
|
+
// Bun returns an Array of plain objects with `count` set on it; turn
|
|
78
|
+
// JSONB columns back into JS values so control-db.js's parseTokens
|
|
79
|
+
// sees the array-of-objects shape it would receive from pg.
|
|
80
|
+
const out = Array.from(rows).map(decodeJsonColumns);
|
|
81
|
+
return { rows: out, rowCount: rows.count ?? rows.length ?? 0 };
|
|
82
|
+
},
|
|
83
|
+
async end() {
|
|
84
|
+
try { await sql.close(); } catch { /* swallow */ }
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Strings shaped like a JSON array or object are unwrapped so Bun.SQL's
|
|
91
|
+
* automatic JSONB serialiser sees the JS value (not a quoted JSON string).
|
|
92
|
+
* Anything else is passed through untouched. This mirrors what node-pg
|
|
93
|
+
* does implicitly when the column type is JSONB.
|
|
94
|
+
*/
|
|
95
|
+
function coerceJsonbParam(p) {
|
|
96
|
+
if (typeof p !== 'string') return p;
|
|
97
|
+
const trimmed = p.trim();
|
|
98
|
+
if (trimmed.length === 0) return p;
|
|
99
|
+
const first = trimmed[0];
|
|
100
|
+
if (first !== '[' && first !== '{') return p;
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(p);
|
|
103
|
+
} catch {
|
|
104
|
+
return p;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Bun.SQL returns JSONB values as the JSON text rather than parsed JS.
|
|
110
|
+
* Re-parse the obvious cases so callers expecting node-pg's auto-decoded
|
|
111
|
+
* shape get arrays/objects.
|
|
112
|
+
*/
|
|
113
|
+
function decodeJsonColumns(row) {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const key of Object.keys(row)) {
|
|
116
|
+
const v = row[key];
|
|
117
|
+
if (typeof v === 'string' && (v.startsWith('[') || v.startsWith('{'))) {
|
|
118
|
+
try { out[key] = JSON.parse(v); } catch { out[key] = v; }
|
|
119
|
+
} else {
|
|
120
|
+
out[key] = v;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Daemon-side: write a small JSON file that issue-token / revoke-token
|
|
128
|
+
* subcommands read to find the admin socket.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} args
|
|
131
|
+
* @param {string} args.controlSocketDir
|
|
132
|
+
* @param {string|null} args.socketDir — PG socket directory (nullable on Windows)
|
|
133
|
+
* @param {number} args.port
|
|
134
|
+
* @returns {string} the absolute path to the discovery file
|
|
135
|
+
*/
|
|
136
|
+
export function writeAdminDiscovery({ controlSocketDir, socketDir, port }) {
|
|
137
|
+
const file = path.join(controlSocketDir, 'admin.json');
|
|
138
|
+
const payload = {
|
|
139
|
+
socketDir,
|
|
140
|
+
port,
|
|
141
|
+
host: socketDir ? null : '127.0.0.1',
|
|
142
|
+
pid: process.pid,
|
|
143
|
+
written_at: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync(file, JSON.stringify(payload), { mode: 0o600 });
|
|
146
|
+
return file;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* CLI-side: read the daemon's discovery file.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} controlSocketDir
|
|
153
|
+
* @returns {{socketDir: string|null, port: number, host: string|null}}
|
|
154
|
+
*/
|
|
155
|
+
export function readAdminDiscovery(controlSocketDir) {
|
|
156
|
+
const file = path.join(controlSocketDir, 'admin.json');
|
|
157
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
158
|
+
return JSON.parse(raw);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* CLI-side: best-effort cleanup at daemon shutdown.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} controlSocketDir
|
|
165
|
+
*/
|
|
166
|
+
export function removeAdminDiscovery(controlSocketDir) {
|
|
167
|
+
const file = path.join(controlSocketDir, 'admin.json');
|
|
168
|
+
try { fs.unlinkSync(file); } catch (e) {
|
|
169
|
+
if (e.code !== 'ENOENT') throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|