pgserve 2.3.0 → 2.5.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.
Files changed (44) hide show
  1. package/bin/pgserve-wrapper.cjs +9 -4
  2. package/bin/postgres-server.js +170 -631
  3. package/config/logrotate.d/pgserve +47 -0
  4. package/config/pgaudit.conf +31 -0
  5. package/package.json +3 -2
  6. package/scripts/audit-redaction-lint.js +349 -0
  7. package/scripts/test-npx.sh +32 -10
  8. package/src/audit/audit.js +134 -0
  9. package/src/cli-install.cjs +340 -100
  10. package/src/commands/uninstall.js +241 -0
  11. package/src/commands/verify.js +360 -0
  12. package/src/cosign/cache-token.js +328 -0
  13. package/src/cosign/schema.js +97 -0
  14. package/src/cosign/trust-list.js +81 -0
  15. package/src/cosign/verify-binary.js +277 -0
  16. package/src/index.js +11 -44
  17. package/src/lib/admin-json.js +202 -0
  18. package/src/lib/pm2-args.js +119 -0
  19. package/src/lib/runtime-json.js +181 -0
  20. package/src/lib/socket-dir.js +69 -0
  21. package/src/postgres.js +64 -5
  22. package/src/upgrade/index.js +5 -0
  23. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
  24. package/src/admin-client.js +0 -223
  25. package/src/audit.js +0 -168
  26. package/src/cluster.js +0 -654
  27. package/src/control-db.js +0 -330
  28. package/src/daemon-control.js +0 -468
  29. package/src/daemon-shared.js +0 -18
  30. package/src/daemon-tcp.js +0 -297
  31. package/src/daemon.js +0 -709
  32. package/src/dashboard.js +0 -217
  33. package/src/fingerprint.js +0 -479
  34. package/src/gc.js +0 -351
  35. package/src/pg-wire.js +0 -869
  36. package/src/protocol.js +0 -389
  37. package/src/restore.js +0 -574
  38. package/src/router.js +0 -546
  39. package/src/sdk.js +0 -137
  40. package/src/stats-collector.js +0 -453
  41. package/src/stats-dashboard.js +0 -401
  42. package/src/sync.js +0 -335
  43. package/src/tenancy.js +0 -75
  44. package/src/tokens.js +0 -102
@@ -1,708 +1,247 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  /**
4
- * pgserve - Embedded PostgreSQL Server
4
+ * pgserve Embedded PostgreSQL Server (singleton, v2.4+)
5
5
  *
6
- * True concurrent connections, zero config, auto-provision databases.
7
- * Uses embedded-postgres (real PostgreSQL binaries).
6
+ * Direct postmaster supervision. The bun proxy data plane was removed in
7
+ * the `pgserve-singleton-no-proxy` wish (Group 2). All long-running
8
+ * lifecycles flow through the `postmaster` subcommand: pm2 (Tier A) and
9
+ * systemd-user / launchd (Tier B, separate wish) invoke this script with
10
+ * `pgserve postmaster --port <n> --data <dir> --socket-dir <dir>`. There
11
+ * is no router, no libpq protocol rewriting, no SO_PEERCRED-based
12
+ * startup-message rewriting, and no always-on daemon control socket.
13
+ *
14
+ * For one-off operations (`pgserve install`, `pgserve url`, …) see
15
+ * `bin/pgserve-wrapper.cjs` + `src/cli-install.cjs`.
8
16
  */
9
17
 
10
- import { fileURLToPath } from 'url';
11
- import path from 'path';
12
- import os from 'os';
13
- import { startMultiTenantServer } from '../src/index.js';
14
- import { startClusterServer } from '../src/cluster.js';
15
- import { loadEffectiveConfig as loadAutopgConfig } from '../src/settings-loader.cjs';
16
- import {
17
- PgserveDaemon,
18
- stopDaemon,
19
- resolveControlSocketDir,
20
- resolveControlSocketPath,
21
- } from '../src/daemon.js';
22
- import { createAdminClient, readAdminDiscovery } from '../src/admin-client.js';
23
- import {
24
- ensureMetaSchema,
25
- addAllowedToken,
26
- revokeAllowedToken,
27
- } from '../src/control-db.js';
28
- import { mintToken } from '../src/tokens.js';
29
- import { audit, AUDIT_EVENTS } from '../src/audit.js';
30
-
31
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ import { PostgresManager } from '../src/postgres.js';
19
+ import { resolveSocketDir, ensureSocketDir } from '../src/lib/socket-dir.js';
20
+ import { writeRuntimeJson, clearRuntimeJson } from '../src/lib/runtime-json.js';
21
+ import { createLogger } from '../src/logger.js';
32
22
 
33
- // Global error handlers
34
- process.on('unhandledRejection', (reason, _promise) => {
23
+ // Global error handlers — surface unhandled rejections + uncaught errors
24
+ // loud so a process supervisor (pm2 / systemd-user / launchd) restarts the
25
+ // postmaster cleanly instead of leaving us silently stuck.
26
+ process.on('unhandledRejection', (reason) => {
35
27
  console.error('Unhandled Promise Rejection:', reason);
36
28
  });
37
-
38
29
  process.on('uncaughtException', (error) => {
39
30
  console.error('Uncaught Exception:', error);
40
31
  process.exit(1);
41
32
  });
42
33
 
43
- // Parse CLI arguments — `pgserve daemon [stop]` is dispatched before the
44
- // classic `pgserve [options]` parser so daemon-mode flags do not collide
45
- // with router flags.
46
34
  const args = process.argv.slice(2);
47
35
 
48
- if (args[0] === 'daemon') {
49
- await runDaemonSubcommand(args.slice(1));
36
+ if (args[0] === 'postmaster') {
37
+ await runPostmasterSubcommand(args.slice(1));
38
+ } else if (args[0] === 'serve') {
39
+ // Alias `serve` → `postmaster` for symmetry with the v2.3 alias surface.
40
+ // Operators land on the same direct-supervision path either way.
41
+ await runPostmasterSubcommand(args.slice(1));
42
+ } else {
43
+ printHelp();
44
+ process.exit(args.length === 0 || args[0] === '--help' || args[0] === 'help' ? 0 : 1);
50
45
  }
51
46
 
52
- async function runDaemonSubcommand(daemonArgs) {
53
- if (daemonArgs[0] === 'stop') {
54
- const result = stopDaemon();
55
- if (result.stopped) {
56
- console.log(`pgserve daemon stopped (pid ${result.pid})`);
57
- process.exit(0);
58
- }
59
- if (result.reason === 'no-pid-file') {
60
- console.error('pgserve daemon: no PID file found is the daemon running?');
61
- process.exit(1);
62
- }
63
- if (result.reason === 'stale-pid' || result.reason === 'invalid-pid-file') {
64
- console.log(`pgserve daemon: cleaned up stale lock (pid ${result.pid ?? '?'})`);
65
- process.exit(0);
66
- }
67
- if (result.reason === 'timeout') {
68
- console.error(`pgserve daemon: pid ${result.pid} did not exit within timeout`);
69
- process.exit(1);
70
- }
71
- console.error(`pgserve daemon stop: ${result.reason}${result.error ? ` (${result.error})` : ''}`);
47
+ /**
48
+ * `pgserve postmaster` — supervises an embedded PostgreSQL postmaster
49
+ * directly. No router, no bun proxy, no daemon control socket.
50
+ *
51
+ * The postmaster listens on the canonical Unix socket at
52
+ * `<socketDir>/.s.PGSQL.<port>` AND TCP `<port>` (postgres' default
53
+ * `listen_addresses = 'localhost'`). Operators connect with either:
54
+ *
55
+ * psql -h $XDG_RUNTIME_DIR/pgserve # Unix socket (no -p)
56
+ * psql -h 127.0.0.1 -p 5432 # canonical TCP
57
+ */
58
+ async function runPostmasterSubcommand(postmasterArgs) {
59
+ const opts = parsePostmasterArgs(postmasterArgs);
60
+ const logger = createLogger({ level: opts.logLevel });
61
+
62
+ // Resolve and ensure the socket directory before postgres tries to bind.
63
+ // Surfaces "not writable" / "wrong owner" failures here with a clear
64
+ // diagnostic instead of leaving the operator to chase a libpq error.
65
+ let socketDir;
66
+ try {
67
+ socketDir = ensureSocketDir(opts.socketDir);
68
+ } catch (err) {
69
+ console.error(err.message);
72
70
  process.exit(1);
73
71
  }
74
72
 
75
- if (daemonArgs[0] === 'issue-token') {
76
- await runIssueTokenSubcommand(daemonArgs.slice(1));
77
- return;
78
- }
79
- if (daemonArgs[0] === 'revoke-token') {
80
- await runRevokeTokenSubcommand(daemonArgs.slice(1));
81
- return;
82
- }
83
-
84
- // `pgserve daemon` (long-running)
85
- const opts = parseDaemonArgs(daemonArgs);
86
- const daemon = new PgserveDaemon(opts);
73
+ const manager = new PostgresManager({
74
+ dataDir: opts.dataDir,
75
+ port: opts.port,
76
+ socketDir,
77
+ useRam: opts.useRam,
78
+ enablePgvector: opts.enablePgvector,
79
+ logger: logger.child({ component: 'postgres' }),
80
+ });
87
81
 
88
- // When the postgres backend dies on us (SIGKILL, OOM, segfault, anything
89
- // other than a clean stop()), exit non-zero so a process supervisor can
90
- // restart the daemon cleanly. Without this, the wrapper sat alive in
91
- // epoll_wait while postgres was dead, and clients got "control.sock
92
- // accepts but never replies" — pgserve#45.
93
- daemon.on('backendDiedUnexpectedly', ({ code }) => {
94
- console.error(
95
- `pgserve daemon: postgres backend exited unexpectedly (code=${code}); ` +
96
- `the wrapper is exiting so a process supervisor can restart it.`
82
+ // Surface unexpected backend death so pm2 can restart us cleanly.
83
+ // Mirrors the daemon-mode contract from PR #49 (issue #45).
84
+ manager.on('backendDiedUnexpectedly', ({ code }) => {
85
+ logger.error(
86
+ { code },
87
+ 'pgserve postmaster: postgres backend exited unexpectedly; exiting so the supervisor can restart',
97
88
  );
98
89
  process.exit(1);
99
90
  });
100
91
 
101
92
  try {
102
- await daemon.start();
93
+ await manager.start();
103
94
  } catch (err) {
104
- if (err.code === 'EALREADYRUNNING') {
105
- console.error(`pgserve daemon: already running, pid ${err.pid}`);
106
- process.exit(1);
107
- }
108
- console.error('pgserve daemon: failed to start:', err.message);
95
+ logger.error({ err: err.message }, 'pgserve postmaster: failed to start');
109
96
  process.exit(1);
110
97
  }
111
- const dir = resolveControlSocketDir();
112
- console.log(`
113
- pgserve daemon — singleton mode
114
-
115
- Control socket: ${resolveControlSocketPath(dir)}
116
- PID lock: ${path.join(dir, 'pgserve.pid')}
117
- PG socket: ${daemon.pgManager.getSocketPath() || '(TCP fallback)'}
118
98
 
119
- Connect: psql 'host=${dir} dbname=mydb'
99
+ // cutover G19: drop a runtime discovery file at <socketDir>/runtime.json
100
+ // so consumers' UDS-first probes find the live socket without globbing
101
+ // ephemeral pid-stamped dirs. The file is intentionally separate from
102
+ // ~/.autopg/admin.json (which records supervisor metadata, not live
103
+ // socket info) — that split lets the postmaster restart under a new
104
+ // pid without rewriting the supervisor record. NO `supervisor` key
105
+ // here; the writer rejects it.
106
+ try {
107
+ writeRuntimeJson({
108
+ socketDir,
109
+ port: opts.port,
110
+ pid: manager.process?.pid ?? process.pid,
111
+ autopgPid: process.pid,
112
+ });
113
+ } catch (err) {
114
+ logger.warn(
115
+ { err: err.message },
116
+ 'pgserve postmaster: runtime.json write failed; consumers will fall back to admin.json',
117
+ );
118
+ }
120
119
 
121
- Press Ctrl+C or send SIGTERM to stop.
122
- `);
120
+ logger.info(
121
+ { port: opts.port, socketDir, dataDir: opts.dataDir },
122
+ 'pgserve postmaster: ready (Unix socket + TCP)',
123
+ );
124
+
125
+ const shutdown = async (signal) => {
126
+ logger.info({ signal }, 'pgserve postmaster: stopping');
127
+ // Clear runtime.json BEFORE stopping the postmaster so the moment
128
+ // a graceful-shutdown signal lands, fresh consumers see "no live
129
+ // socket" instead of racing against a stale-pid record. On crash
130
+ // (uncaughtException, backend died) the file is left behind; the
131
+ // operator-facing detector is `process.kill(record.autopgPid, 0)`.
132
+ clearRuntimeJson(socketDir);
133
+ try {
134
+ await manager.stop();
135
+ } catch (err) {
136
+ logger.warn({ err: err.message }, 'pgserve postmaster: error during shutdown');
137
+ }
138
+ process.exit(0);
139
+ };
140
+ process.on('SIGINT', () => shutdown('SIGINT'));
141
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
123
142
 
124
- // Daemon installs its own SIGTERM/SIGINT handlers; just wait forever.
143
+ // Park forever; the supervisor decides when to stop us.
125
144
  await new Promise(() => {});
126
145
  }
127
146
 
128
- function parseDaemonArgs(daemonArgs) {
147
+ function parsePostmasterArgs(postmasterArgs) {
129
148
  const opts = {
130
- baseDir: null,
131
- useRam: false,
149
+ port: 5432,
150
+ dataDir: null,
151
+ socketDir: undefined, // resolved via resolveSocketDir() if unset
132
152
  logLevel: 'info',
133
- autoProvision: true,
134
- tcpListens: [],
153
+ useRam: false,
135
154
  enablePgvector: false,
136
- maxConnections: null,
137
155
  };
138
- for (let i = 0; i < daemonArgs.length; i++) {
139
- const arg = daemonArgs[i];
156
+ for (let i = 0; i < postmasterArgs.length; i++) {
157
+ const arg = postmasterArgs[i];
140
158
  switch (arg) {
159
+ case '--port':
160
+ case '-p': {
161
+ const raw = postmasterArgs[++i];
162
+ const parsed = Number.parseInt(raw, 10);
163
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
164
+ console.error(`pgserve postmaster: invalid --port "${raw}"`);
165
+ process.exit(1);
166
+ }
167
+ opts.port = parsed;
168
+ break;
169
+ }
141
170
  case '--data':
142
171
  case '-d':
143
- opts.baseDir = daemonArgs[++i];
172
+ opts.dataDir = postmasterArgs[++i];
144
173
  break;
145
- case '--ram':
146
- opts.useRam = true;
174
+ case '--socket-dir':
175
+ case '-k':
176
+ opts.socketDir = postmasterArgs[++i];
147
177
  break;
148
178
  case '--log':
149
179
  case '-l':
150
- opts.logLevel = daemonArgs[++i];
151
- break;
152
- case '--no-provision':
153
- opts.autoProvision = false;
180
+ opts.logLevel = postmasterArgs[++i];
154
181
  break;
155
- case '--listen':
156
- opts.tcpListens.push(daemonArgs[++i]);
182
+ case '--ram':
183
+ opts.useRam = true;
157
184
  break;
158
185
  case '--pgvector':
159
186
  opts.enablePgvector = true;
160
187
  break;
161
- case '--max-connections': {
162
- // Accept the same flag the foreground/router mode takes so callers
163
- // (genie's `getOrStartDaemon`, anything that spawns `pgserve daemon`
164
- // with a tuned cap) can override the postmaster's `max_connections`.
165
- // The `PgserveDaemon` constructor already honors `options.maxConnections`
166
- // (see src/daemon.js — defaults to 1000); we just plumb it through.
167
- const raw = daemonArgs[++i];
168
- const parsed = Number.parseInt(raw, 10);
169
- if (!Number.isFinite(parsed) || parsed <= 0) {
170
- console.error(`--max-connections: expected a positive integer, got "${raw}"`);
171
- process.exit(1);
172
- }
173
- opts.maxConnections = parsed;
174
- break;
175
- }
176
188
  case '--help':
177
- console.log(`
178
- pgserve daemon — singleton control-socket mode
189
+ process.stdout.write(`
190
+ pgserve postmasterdirect embedded PostgreSQL supervision (singleton v2.4)
179
191
 
180
192
  USAGE:
181
- pgserve daemon [options]
182
- pgserve daemon stop
183
- pgserve daemon issue-token --fingerprint <hex>
184
- pgserve daemon revoke-token <id>
193
+ pgserve postmaster [options]
185
194
 
186
195
  OPTIONS:
187
- --data <path> Persistent data directory (default: in-memory)
188
- --ram Use /dev/shm storage (Linux only)
189
- --log <level> Log level: error|warn|info|debug (default: info)
190
- --no-provision Disable auto-provisioning of databases
191
- --listen [host:]port Bind opt-in TCP listener (repeatable)
192
- --pgvector Auto-enable pgvector extension on new databases
193
- --max-connections <n> Override the postmaster's max_connections (default: 1000)
194
- --help Show this help
195
-
196
- The daemon binds $XDG_RUNTIME_DIR/pgserve/control.sock (fallback /tmp/pgserve/control.sock).
197
- A second invocation while the first is running exits with "already running".
198
-
199
- TCP peers (--listen) MUST authenticate via libpq application_name shaped
200
- "?fingerprint=<12hex>&token=<bearer>". Issue tokens with
201
- "pgserve daemon issue-token --fingerprint <hex>". Revoke with
202
- "pgserve daemon revoke-token <id>".
196
+ --port, -p <n> TCP port (default: 5432)
197
+ --data, -d <path> Data directory (required for persistence)
198
+ --socket-dir, -k <p> Unix socket dir (default: $XDG_RUNTIME_DIR/pgserve)
199
+ --log, -l <level> Log level: error|warn|info|debug (default: info)
200
+ --ram Use /dev/shm (Linux only)
201
+ --pgvector Auto-enable pgvector on new databases
202
+ --help Show this help
203
+
204
+ The postmaster binds <socket-dir>/.s.PGSQL.<port> and TCP <port> on
205
+ localhost. This entry point is invoked by pm2/systemd-user/launchd; it has
206
+ no router, no bun proxy, no daemon control socket.
203
207
  `);
204
208
  process.exit(0);
205
209
  // falls through (unreachable)
206
210
  default:
207
211
  if (arg.startsWith('-')) {
208
- console.error(`Unknown daemon option: ${arg}`);
212
+ console.error(`pgserve postmaster: unknown option "${arg}"`);
209
213
  process.exit(1);
210
214
  }
211
215
  }
212
216
  }
217
+ if (opts.socketDir === undefined) opts.socketDir = resolveSocketDir();
213
218
  return opts;
214
219
  }
215
220
 
216
- async function runIssueTokenSubcommand(args) {
217
- let fingerprint = null;
218
- for (let i = 0; i < args.length; i++) {
219
- const arg = args[i];
220
- if (arg === '--fingerprint') fingerprint = args[++i];
221
- else if (arg === '--help') {
222
- console.log(`
223
- pgserve daemon issue-token --fingerprint <12hex>
224
-
225
- Issues a fresh bearer token for an existing fingerprint. Prints the token
226
- to stdout exactly once; only the sha256 hash is persisted. Use the printed
227
- value in libpq application_name shaped "?fingerprint=<hex>&token=<bearer>".
228
- `);
229
- process.exit(0);
230
- } else {
231
- console.error(`Unknown option: ${arg}`);
232
- process.exit(1);
233
- }
234
- }
235
- if (!fingerprint || !/^[0-9a-f]{12}$/.test(fingerprint)) {
236
- console.error('issue-token: --fingerprint <12hex> required');
237
- process.exit(1);
238
- }
239
-
240
- let admin;
241
- try {
242
- const dir = resolveControlSocketDir();
243
- const disc = readAdminDiscovery(dir);
244
- admin = await createAdminClient({ socketDir: disc.socketDir, port: disc.port });
245
- } catch (err) {
246
- console.error('issue-token: cannot reach running daemon admin socket:', err.message);
247
- console.error('Hint: start the daemon first with `pgserve daemon`.');
248
- process.exit(1);
249
- }
250
-
251
- try {
252
- await ensureMetaSchema(admin);
253
- const { id, cleartext, hash } = mintToken();
254
- const result = await addAllowedToken(admin, {
255
- fingerprint,
256
- tokenId: id,
257
- tokenHash: hash,
258
- });
259
- audit(AUDIT_EVENTS.TCP_TOKEN_ISSUED, {
260
- fingerprint,
261
- token_id: id,
262
- database: result.databaseName,
263
- });
264
- console.log('Token issued. Save the bearer value below — it will not be shown again:');
265
- console.log('');
266
- console.log(` id: ${id}`);
267
- console.log(` fingerprint: ${fingerprint}`);
268
- console.log(` database: ${result.databaseName}`);
269
- console.log(` token: ${cleartext}`);
270
- console.log('');
271
- console.log('Use as libpq application_name:');
272
- console.log(` application_name='?fingerprint=${fingerprint}&token=${cleartext}'`);
273
- process.exit(0);
274
- } catch (err) {
275
- if (err.code === 'EUNKNOWNFINGERPRINT') {
276
- console.error(`issue-token: fingerprint ${fingerprint} not provisioned yet.`);
277
- console.error('Connect once via Unix socket so pgserve creates the database first.');
278
- process.exit(2);
279
- }
280
- console.error('issue-token failed:', err.message);
281
- process.exit(1);
282
- } finally {
283
- try { await admin.end(); } catch { /* swallow */ }
284
- }
285
- }
286
-
287
- async function runRevokeTokenSubcommand(args) {
288
- if (args.length === 0 || args[0] === '--help') {
289
- console.log('Usage: pgserve daemon revoke-token <id>');
290
- process.exit(args.length === 0 ? 1 : 0);
291
- }
292
- const tokenId = args[0];
293
-
294
- let admin;
295
- try {
296
- const dir = resolveControlSocketDir();
297
- const disc = readAdminDiscovery(dir);
298
- admin = await createAdminClient({ socketDir: disc.socketDir, port: disc.port });
299
- } catch (err) {
300
- console.error('revoke-token: cannot reach running daemon admin socket:', err.message);
301
- process.exit(1);
302
- }
303
-
304
- try {
305
- const affected = await revokeAllowedToken(admin, tokenId);
306
- if (affected === 0) {
307
- console.error(`revoke-token: no token with id ${tokenId} found`);
308
- process.exit(2);
309
- }
310
- console.log(`Token ${tokenId} revoked (affected ${affected} row${affected === 1 ? '' : 's'})`);
311
- process.exit(0);
312
- } catch (err) {
313
- console.error('revoke-token failed:', err.message);
314
- process.exit(1);
315
- } finally {
316
- try { await admin.end(); } catch { /* swallow */ }
317
- }
318
- }
319
-
320
- /**
321
- * Print usage help
322
- */
323
221
  function printHelp() {
324
- console.log(`
325
- pgserve - Embedded PostgreSQL Server
326
- =====================================
222
+ process.stdout.write(`
223
+ pgserve Embedded PostgreSQL Server (singleton, v2.4+)
224
+ =======================================================
327
225
 
328
- True concurrent connections, zero config, auto-provision databases.
226
+ True concurrent connections, zero config, auto-provision databases. The
227
+ postmaster is supervised directly (pm2 Tier A, systemd-user / launchd Tier
228
+ B); there is no bun proxy or daemon control socket.
329
229
 
330
230
  USAGE:
331
- pgserve [options] # foreground server
332
- pgserve install [--port N] # register under pm2 (idempotent)
333
- pgserve serve # alias for "pgserve daemon"
334
- pgserve status [--json] # report pm2 + config state
335
- pgserve url # print canonical postgres:// URL
336
- pgserve port # print canonical port
337
- pgserve uninstall # remove from pm2 (keep data)
338
- pgserve daemon [stop] # singleton daemon (Unix socket)
339
-
340
- OPTIONS:
341
- --port <number> PostgreSQL port (default: 8432)
342
- --data <path> Data directory for persistence (default: in-memory)
343
- --ram Use RAM storage via /dev/shm (Linux only, faster)
344
- --host <host> Host to bind to (default: 127.0.0.1)
345
- --log <level> Log level: error, warn, info, debug (default: info)
346
- --cluster Force cluster mode (auto-enabled on multi-core systems)
347
- --no-cluster Force single-process mode (disables auto-cluster)
348
- --workers <n> Number of worker processes (default: CPU cores)
349
- --no-provision Disable auto-provisioning of databases
350
- --sync-to <url> Sync to real PostgreSQL (async replication)
351
- --sync-databases Database patterns to sync (comma-separated, e.g. "myapp,tenant_*")
352
- --no-stats Disable real-time stats dashboard (enabled by default)
353
- --max-connections Max concurrent connections (default: 1000)
354
- --pgvector Auto-enable pgvector extension on new databases
355
- --help Show this help message
356
-
357
- MODES:
358
- In-memory (default): Ephemeral temp directory - data lost on restart
359
- RAM mode (--ram): True RAM via /dev/shm (Linux only, fastest)
360
- Persistent: Use --data to persist databases to disk
361
-
362
- EXAMPLES:
363
- # Start in memory mode (default, fast, ephemeral)
364
- pgserve
365
-
366
- # Start with persistent storage
367
- pgserve --data ./data
368
-
369
- # Custom port
370
- pgserve --port 5433
371
-
372
- # Sync to real PostgreSQL (async replication)
373
- pgserve --sync-to "postgresql://user:pass@host:5432/db"
374
-
375
- # Sync specific databases
376
- pgserve --sync-to "postgresql://..." --sync-databases "myapp,tenant_*"
231
+ pgserve install [--port N] [--no-pm2] # one-shot register under pm2
232
+ pgserve uninstall # one-shot tear-down
233
+ pgserve url | port | status [--json] # discovery / health
234
+ pgserve config <subcommand> # ~/.autopg/settings.json
235
+ pgserve restart # pm2 restart
236
+ pgserve ui # autopg console UI
237
+ pgserve postmaster [options] # long-running supervisor entry
238
+ pgserve serve [options] # alias for \`postmaster\`
239
+
240
+ For postmaster options: pgserve postmaster --help
241
+ For install options: pgserve install --help (-> handled by wrapper)
377
242
 
378
243
  CONNECTING:
379
- # Any PostgreSQL client works (psql, pg, Prisma, etc.)
380
- postgresql://localhost:5432/mydb # Auto-creates "mydb" database
381
- postgresql://localhost:5432/app123 # Auto-creates "app123" database
382
-
383
- FEATURES:
384
- - TRUE concurrent connections (native PostgreSQL)
385
- - Auto-provision databases on first connection
386
- - Zero configuration required
387
- - PostgreSQL 17 (native binaries, auto-downloaded)
244
+ postgres://localhost:5432/mydb # canonical TCP
245
+ psql -h $XDG_RUNTIME_DIR/pgserve mydb # Unix socket
388
246
  `);
389
247
  }
390
-
391
- /**
392
- * Pull daemon options from ~/.autopg/settings.json (with env overlay).
393
- * Returns a partial options patch — only keys that are present in the
394
- * settings file or env override the hardcoded defaults. CLI flags layer
395
- * on top of this in parseArgs().
396
- *
397
- * Failures (missing file, bad JSON) fall through to defaults silently —
398
- * the daemon must remain runnable on a brand-new install before
399
- * `autopg config init` has been called.
400
- */
401
- function loadSettingsOverlay() {
402
- try {
403
- const cpuCount = os.cpus().length;
404
- const isWindows = os.platform() === 'win32';
405
- const { settings } = loadAutopgConfig();
406
- const s = settings.server || {};
407
- const r = settings.runtime || {};
408
- const sy = settings.sync || {};
409
- const pg = settings.postgres || {};
410
- const overlay = {};
411
- if (typeof s.port === 'number') overlay.port = s.port;
412
- if (typeof s.host === 'string' && s.host) overlay.host = s.host;
413
- if (typeof r.dataDir === 'string' && r.dataDir) overlay.dataDir = r.dataDir;
414
- if (typeof r.ramMode === 'boolean') overlay.useRam = r.ramMode;
415
- if (typeof r.logLevel === 'string' && r.logLevel) overlay.logLevel = r.logLevel;
416
- if (typeof r.autoProvision === 'boolean') overlay.autoProvision = r.autoProvision;
417
- if (typeof r.cluster === 'string') {
418
- overlay.cluster = r.cluster === 'auto'
419
- ? (cpuCount > 1 && !isWindows)
420
- : r.cluster === 'on';
421
- }
422
- if (typeof r.workers === 'number' && r.workers > 0) overlay.workers = r.workers;
423
- if (typeof r.statsDashboard === 'boolean') overlay.showStats = r.statsDashboard;
424
- if (typeof r.enablePgvector === 'boolean') overlay.enablePgvector = r.enablePgvector;
425
- if (sy.enabled && typeof sy.url === 'string' && sy.url) overlay.syncTo = sy.url;
426
- if (sy.enabled && typeof sy.databases === 'string' && sy.databases) overlay.syncDatabases = sy.databases;
427
- // pgserve-side connection cap mirrors the postgres GUC unless the user
428
- // has explicitly diverged via CLI flag (handled in parseArgs).
429
- if (typeof pg.max_connections === 'number') overlay.maxConnections = pg.max_connections;
430
- return overlay;
431
- } catch {
432
- // First run, no settings.json yet, or file parse error. Hardcoded
433
- // defaults still produce a working daemon — nothing to do here.
434
- return {};
435
- }
436
- }
437
-
438
- /**
439
- * Parse command line arguments
440
- *
441
- * Precedence (lowest → highest):
442
- * 1. hardcoded defaults
443
- * 2. ~/.autopg/settings.json (with env overlay via loadEffectiveConfig)
444
- * 3. CLI flags ← explicit user intent always wins
445
- */
446
- function parseArgs() {
447
- // Auto-enable cluster mode on multi-core systems for best performance
448
- // Note: Cluster mode uses SO_REUSEPORT which is not supported on Windows
449
- const cpuCount = os.cpus().length;
450
- const isWindows = os.platform() === 'win32';
451
-
452
- const options = {
453
- port: 8432,
454
- host: '127.0.0.1',
455
- dataDir: null, // null = memory mode
456
- useRam: false, // Use /dev/shm for true RAM storage (Linux only)
457
- logLevel: 'info',
458
- autoProvision: true,
459
- cluster: cpuCount > 1 && !isWindows, // Auto-enable on multi-core (disabled on Windows - no SO_REUSEPORT)
460
- workers: null, // null = use CPU count
461
- syncTo: null, // Sync target PostgreSQL URL
462
- syncDatabases: null, // Database patterns to sync (comma-separated)
463
- showStats: true, // Show real-time stats dashboard (default: enabled)
464
- maxConnections: 1000, // Max concurrent connections (high default for multi-tenant)
465
- enablePgvector: false // Auto-enable pgvector extension on new databases
466
- };
467
-
468
- // Layer settings.json + env on top of defaults. CLI flags below win.
469
- Object.assign(options, loadSettingsOverlay());
470
-
471
- for (let i = 0; i < args.length; i++) {
472
- const arg = args[i];
473
-
474
- switch (arg) {
475
- case '--port':
476
- case '-p':
477
- options.port = parseInt(args[++i], 10);
478
- break;
479
-
480
- case '--data':
481
- case '-d':
482
- options.dataDir = args[++i];
483
- break;
484
-
485
- case '--ram':
486
- options.useRam = true;
487
- break;
488
-
489
- case '--host':
490
- case '-h':
491
- options.host = args[++i];
492
- break;
493
-
494
- case '--log':
495
- case '-l':
496
- options.logLevel = args[++i];
497
- break;
498
-
499
- case '--cluster':
500
- options.cluster = true;
501
- break;
502
-
503
- case '--no-cluster':
504
- options.cluster = false;
505
- break;
506
-
507
- case '--workers':
508
- options.workers = parseInt(args[++i], 10);
509
- break;
510
-
511
- case '--no-provision':
512
- options.autoProvision = false;
513
- break;
514
-
515
- case '--sync-to':
516
- options.syncTo = args[++i];
517
- break;
518
-
519
- case '--sync-databases':
520
- options.syncDatabases = args[++i];
521
- break;
522
-
523
- case '--stats':
524
- options.showStats = true;
525
- break;
526
-
527
- case '--no-stats':
528
- options.showStats = false;
529
- break;
530
-
531
- case '--max-connections':
532
- options.maxConnections = parseInt(args[++i], 10);
533
- break;
534
-
535
- case '--pgvector':
536
- options.enablePgvector = true;
537
- break;
538
-
539
- case '--help':
540
- case 'help':
541
- printHelp();
542
- process.exit(0);
543
- // falls through (unreachable - exit above)
544
-
545
- default:
546
- if (arg.startsWith('-')) {
547
- console.error(`Unknown option: ${arg}`);
548
- printHelp();
549
- process.exit(1);
550
- }
551
- }
552
- }
553
-
554
- return options;
555
- }
556
-
557
- /**
558
- * Main entry point
559
- */
560
- async function main() {
561
- const options = parseArgs();
562
- const memoryMode = !options.dataDir;
563
- const storageType = options.dataDir
564
- ? options.dataDir
565
- : (options.useRam ? '/dev/shm (RAM)' : '(temp directory)');
566
-
567
- // Only print header if not a cluster worker (workers get PGSERVE_WORKER env)
568
- if (!process.env.PGSERVE_WORKER) {
569
- console.log(`
570
- pgserve - Embedded PostgreSQL Server
571
- =====================================
572
- `);
573
- }
574
-
575
- try {
576
- let server;
577
-
578
- if (options.cluster) {
579
- // Cluster mode - multi-core scaling
580
- server = await startClusterServer({
581
- port: options.port,
582
- host: options.host,
583
- baseDir: options.dataDir,
584
- useRam: options.useRam,
585
- logLevel: options.logLevel,
586
- autoProvision: options.autoProvision,
587
- workers: options.workers,
588
- maxConnections: options.maxConnections,
589
- enablePgvector: options.enablePgvector
590
- });
591
-
592
- // Only primary process shows full startup message
593
- if (server.workers) {
594
- const stats = server.getStats();
595
-
596
- console.log(`
597
- Cluster started successfully!
598
-
599
- Endpoint: postgresql://${options.host}:${options.port}/<database>
600
- Mode: ${memoryMode ? (options.useRam ? 'RAM (/dev/shm)' : 'Ephemeral (temp)') : 'Persistent'} (Cluster)
601
- Workers: ${stats.workers} processes
602
- Data: ${storageType}
603
- Auto-create: ${options.autoProvision ? 'Enabled' : 'Disabled'}
604
- pgvector: ${options.enablePgvector ? 'Enabled (auto-installed on new DBs)' : 'Disabled (use --pgvector to enable)'}
605
-
606
- Examples:
607
- postgresql://${options.host}:${options.port}/myapp
608
- postgresql://${options.host}:${options.port}/testdb
609
-
610
- Press Ctrl+C to stop
611
- `);
612
- }
613
- } else {
614
- // Single process mode
615
- const router = await startMultiTenantServer({
616
- port: options.port,
617
- host: options.host,
618
- baseDir: options.dataDir,
619
- useRam: options.useRam,
620
- logLevel: options.logLevel,
621
- autoProvision: options.autoProvision,
622
- syncTo: options.syncTo,
623
- syncDatabases: options.syncDatabases,
624
- maxConnections: options.maxConnections,
625
- enablePgvector: options.enablePgvector
626
- });
627
-
628
- server = router;
629
-
630
- // Build sync status string
631
- const syncStatus = options.syncTo
632
- ? `Enabled → ${options.syncTo.replace(/:[^:@]+@/, ':***@')}`
633
- : 'Disabled';
634
-
635
- console.log(`
636
- Server started successfully!
637
-
638
- Endpoint: postgresql://${options.host}:${options.port}/<database>
639
- Mode: ${memoryMode ? (options.useRam ? 'RAM (/dev/shm)' : 'Ephemeral (temp)') : 'Persistent'}
640
- Data: ${storageType}
641
- PostgreSQL: Port ${router.pgPort} (internal)
642
- Auto-create: ${options.autoProvision ? 'Enabled' : 'Disabled'}
643
- pgvector: ${options.enablePgvector ? 'Enabled (auto-installed on new DBs)' : 'Disabled (use --pgvector to enable)'}
644
- Sync: ${syncStatus}${options.syncDatabases ? ` (${options.syncDatabases})` : ''}
645
-
646
- Examples:
647
- postgresql://${options.host}:${options.port}/myapp
648
- postgresql://${options.host}:${options.port}/testdb
649
-
650
- Press Ctrl+C to stop
651
- `);
652
- }
653
-
654
- // Start stats dashboard if requested (only for primary/single-process)
655
- let dashboard = null;
656
- if (options.showStats && !process.env.PGSERVE_WORKER) {
657
- const { StatsDashboard } = await import('../src/stats-dashboard.js');
658
- const { StatsCollector } = await import('../src/stats-collector.js');
659
-
660
- // Create stats collector with appropriate sources
661
- const collector = new StatsCollector({
662
- router: options.cluster ? null : server,
663
- pgManager: server.pgManager,
664
- clusterStats: options.cluster ? () => server.getStats() : null,
665
- logger: server.logger,
666
- port: options.port,
667
- host: options.host
668
- });
669
-
670
- dashboard = new StatsDashboard({
671
- refreshInterval: 2000, // 2 second refresh for real-time feel
672
- statsProvider: () => collector.collect()
673
- });
674
-
675
- dashboard.start();
676
- }
677
-
678
- // Graceful shutdown (only for primary/single-process, workers handle via IPC)
679
- if (!process.env.PGSERVE_WORKER) {
680
- const shutdown = async () => {
681
- // Stop dashboard first to restore cursor
682
- if (dashboard) {
683
- dashboard.stop();
684
- }
685
- console.log('\nShutting down...');
686
- try {
687
- await server.stop();
688
- console.log('Server stopped.');
689
- } catch (err) {
690
- console.error('Error during shutdown:', err.message);
691
- // Still exit - best effort cleanup
692
- }
693
- process.exit(0);
694
- };
695
-
696
- process.on('SIGINT', shutdown);
697
- process.on('SIGTERM', shutdown);
698
- }
699
-
700
- // Keep process alive
701
- await new Promise(() => {});
702
- } catch (error) {
703
- console.error(`Failed to start server:`, error);
704
- process.exit(1);
705
- }
706
- }
707
-
708
- main();