pgserve 2.3.0 → 2.4.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.
@@ -1,708 +1,219 @@
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 { createLogger } from '../src/logger.js';
32
21
 
33
- // Global error handlers
34
- process.on('unhandledRejection', (reason, _promise) => {
22
+ // Global error handlers — surface unhandled rejections + uncaught errors
23
+ // loud so a process supervisor (pm2 / systemd-user / launchd) restarts the
24
+ // postmaster cleanly instead of leaving us silently stuck.
25
+ process.on('unhandledRejection', (reason) => {
35
26
  console.error('Unhandled Promise Rejection:', reason);
36
27
  });
37
-
38
28
  process.on('uncaughtException', (error) => {
39
29
  console.error('Uncaught Exception:', error);
40
30
  process.exit(1);
41
31
  });
42
32
 
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
33
  const args = process.argv.slice(2);
47
34
 
48
- if (args[0] === 'daemon') {
49
- await runDaemonSubcommand(args.slice(1));
35
+ if (args[0] === 'postmaster') {
36
+ await runPostmasterSubcommand(args.slice(1));
37
+ } else if (args[0] === 'serve') {
38
+ // Alias `serve` → `postmaster` for symmetry with the v2.3 alias surface.
39
+ // Operators land on the same direct-supervision path either way.
40
+ await runPostmasterSubcommand(args.slice(1));
41
+ } else {
42
+ printHelp();
43
+ process.exit(args.length === 0 || args[0] === '--help' || args[0] === 'help' ? 0 : 1);
50
44
  }
51
45
 
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})` : ''}`);
46
+ /**
47
+ * `pgserve postmaster` — supervises an embedded PostgreSQL postmaster
48
+ * directly. No router, no bun proxy, no daemon control socket.
49
+ *
50
+ * The postmaster listens on the canonical Unix socket at
51
+ * `<socketDir>/.s.PGSQL.<port>` AND TCP `<port>` (postgres' default
52
+ * `listen_addresses = 'localhost'`). Operators connect with either:
53
+ *
54
+ * psql -h $XDG_RUNTIME_DIR/pgserve # Unix socket (no -p)
55
+ * psql -h 127.0.0.1 -p 5432 # canonical TCP
56
+ */
57
+ async function runPostmasterSubcommand(postmasterArgs) {
58
+ const opts = parsePostmasterArgs(postmasterArgs);
59
+ const logger = createLogger({ level: opts.logLevel });
60
+
61
+ // Resolve and ensure the socket directory before postgres tries to bind.
62
+ // Surfaces "not writable" / "wrong owner" failures here with a clear
63
+ // diagnostic instead of leaving the operator to chase a libpq error.
64
+ let socketDir;
65
+ try {
66
+ socketDir = ensureSocketDir(opts.socketDir);
67
+ } catch (err) {
68
+ console.error(err.message);
72
69
  process.exit(1);
73
70
  }
74
71
 
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);
72
+ const manager = new PostgresManager({
73
+ dataDir: opts.dataDir,
74
+ port: opts.port,
75
+ socketDir,
76
+ useRam: opts.useRam,
77
+ enablePgvector: opts.enablePgvector,
78
+ logger: logger.child({ component: 'postgres' }),
79
+ });
87
80
 
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.`
81
+ // Surface unexpected backend death so pm2 can restart us cleanly.
82
+ // Mirrors the daemon-mode contract from PR #49 (issue #45).
83
+ manager.on('backendDiedUnexpectedly', ({ code }) => {
84
+ logger.error(
85
+ { code },
86
+ 'pgserve postmaster: postgres backend exited unexpectedly; exiting so the supervisor can restart',
97
87
  );
98
88
  process.exit(1);
99
89
  });
100
90
 
101
91
  try {
102
- await daemon.start();
92
+ await manager.start();
103
93
  } 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);
94
+ logger.error({ err: err.message }, 'pgserve postmaster: failed to start');
109
95
  process.exit(1);
110
96
  }
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
97
 
119
- Connect: psql 'host=${dir} dbname=mydb'
98
+ logger.info(
99
+ { port: opts.port, socketDir, dataDir: opts.dataDir },
100
+ 'pgserve postmaster: ready (Unix socket + TCP)',
101
+ );
120
102
 
121
- Press Ctrl+C or send SIGTERM to stop.
122
- `);
103
+ const shutdown = async (signal) => {
104
+ logger.info({ signal }, 'pgserve postmaster: stopping');
105
+ try {
106
+ await manager.stop();
107
+ } catch (err) {
108
+ logger.warn({ err: err.message }, 'pgserve postmaster: error during shutdown');
109
+ }
110
+ process.exit(0);
111
+ };
112
+ process.on('SIGINT', () => shutdown('SIGINT'));
113
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
123
114
 
124
- // Daemon installs its own SIGTERM/SIGINT handlers; just wait forever.
115
+ // Park forever; the supervisor decides when to stop us.
125
116
  await new Promise(() => {});
126
117
  }
127
118
 
128
- function parseDaemonArgs(daemonArgs) {
119
+ function parsePostmasterArgs(postmasterArgs) {
129
120
  const opts = {
130
- baseDir: null,
131
- useRam: false,
121
+ port: 5432,
122
+ dataDir: null,
123
+ socketDir: undefined, // resolved via resolveSocketDir() if unset
132
124
  logLevel: 'info',
133
- autoProvision: true,
134
- tcpListens: [],
125
+ useRam: false,
135
126
  enablePgvector: false,
136
- maxConnections: null,
137
127
  };
138
- for (let i = 0; i < daemonArgs.length; i++) {
139
- const arg = daemonArgs[i];
128
+ for (let i = 0; i < postmasterArgs.length; i++) {
129
+ const arg = postmasterArgs[i];
140
130
  switch (arg) {
131
+ case '--port':
132
+ case '-p': {
133
+ const raw = postmasterArgs[++i];
134
+ const parsed = Number.parseInt(raw, 10);
135
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
136
+ console.error(`pgserve postmaster: invalid --port "${raw}"`);
137
+ process.exit(1);
138
+ }
139
+ opts.port = parsed;
140
+ break;
141
+ }
141
142
  case '--data':
142
143
  case '-d':
143
- opts.baseDir = daemonArgs[++i];
144
+ opts.dataDir = postmasterArgs[++i];
144
145
  break;
145
- case '--ram':
146
- opts.useRam = true;
146
+ case '--socket-dir':
147
+ case '-k':
148
+ opts.socketDir = postmasterArgs[++i];
147
149
  break;
148
150
  case '--log':
149
151
  case '-l':
150
- opts.logLevel = daemonArgs[++i];
151
- break;
152
- case '--no-provision':
153
- opts.autoProvision = false;
152
+ opts.logLevel = postmasterArgs[++i];
154
153
  break;
155
- case '--listen':
156
- opts.tcpListens.push(daemonArgs[++i]);
154
+ case '--ram':
155
+ opts.useRam = true;
157
156
  break;
158
157
  case '--pgvector':
159
158
  opts.enablePgvector = true;
160
159
  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
160
  case '--help':
177
- console.log(`
178
- pgserve daemon — singleton control-socket mode
161
+ process.stdout.write(`
162
+ pgserve postmasterdirect embedded PostgreSQL supervision (singleton v2.4)
179
163
 
180
164
  USAGE:
181
- pgserve daemon [options]
182
- pgserve daemon stop
183
- pgserve daemon issue-token --fingerprint <hex>
184
- pgserve daemon revoke-token <id>
165
+ pgserve postmaster [options]
185
166
 
186
167
  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>".
168
+ --port, -p <n> TCP port (default: 5432)
169
+ --data, -d <path> Data directory (required for persistence)
170
+ --socket-dir, -k <p> Unix socket dir (default: $XDG_RUNTIME_DIR/pgserve)
171
+ --log, -l <level> Log level: error|warn|info|debug (default: info)
172
+ --ram Use /dev/shm (Linux only)
173
+ --pgvector Auto-enable pgvector on new databases
174
+ --help Show this help
175
+
176
+ The postmaster binds <socket-dir>/.s.PGSQL.<port> and TCP <port> on
177
+ localhost. This entry point is invoked by pm2/systemd-user/launchd; it has
178
+ no router, no bun proxy, no daemon control socket.
203
179
  `);
204
180
  process.exit(0);
205
181
  // falls through (unreachable)
206
182
  default:
207
183
  if (arg.startsWith('-')) {
208
- console.error(`Unknown daemon option: ${arg}`);
184
+ console.error(`pgserve postmaster: unknown option "${arg}"`);
209
185
  process.exit(1);
210
186
  }
211
187
  }
212
188
  }
189
+ if (opts.socketDir === undefined) opts.socketDir = resolveSocketDir();
213
190
  return opts;
214
191
  }
215
192
 
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
193
  function printHelp() {
324
- console.log(`
325
- pgserve - Embedded PostgreSQL Server
326
- =====================================
194
+ process.stdout.write(`
195
+ pgserve Embedded PostgreSQL Server (singleton, v2.4+)
196
+ =======================================================
327
197
 
328
- True concurrent connections, zero config, auto-provision databases.
198
+ True concurrent connections, zero config, auto-provision databases. The
199
+ postmaster is supervised directly (pm2 Tier A, systemd-user / launchd Tier
200
+ B); there is no bun proxy or daemon control socket.
329
201
 
330
202
  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_*"
203
+ pgserve install [--port N] [--no-pm2] # one-shot register under pm2
204
+ pgserve uninstall # one-shot tear-down
205
+ pgserve url | port | status [--json] # discovery / health
206
+ pgserve config <subcommand> # ~/.autopg/settings.json
207
+ pgserve restart # pm2 restart
208
+ pgserve ui # autopg console UI
209
+ pgserve postmaster [options] # long-running supervisor entry
210
+ pgserve serve [options] # alias for \`postmaster\`
211
+
212
+ For postmaster options: pgserve postmaster --help
213
+ For install options: pgserve install --help (-> handled by wrapper)
377
214
 
378
215
  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)
388
- `);
389
- }
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
216
+ postgres://localhost:5432/mydb # canonical TCP
217
+ psql -h $XDG_RUNTIME_DIR/pgserve mydb # Unix socket
611
218
  `);
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
219
  }
707
-
708
- main();