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.
package/src/router.js DELETED
@@ -1,546 +0,0 @@
1
- /**
2
- * Multi-Tenant Router (TCP Proxy to Embedded PostgreSQL)
3
- *
4
- * Single TCP server that routes connections to an embedded PostgreSQL instance.
5
- * Extracts database name from PostgreSQL startup message, auto-creates database,
6
- * then proxies the connection to real PostgreSQL.
7
- *
8
- * Features:
9
- * - TRUE concurrent connections (native PostgreSQL)
10
- * - Auto-provision databases on first connection
11
- * - Zero configuration required
12
- * - Memory mode (default) or persistent storage
13
- *
14
- * PERFORMANCE: Uses Bun.listen() and Bun.connect() for 2-3x throughput improvement
15
- *
16
- * v2 NOTE: The MultiTenantRouter is the **direct-embed** path — callers that
17
- * spawn their own PostgresManager and bind a TCP port get a per-pid Unix
18
- * socket from `pgManager.getSocketPath()` (preserved by PR #24). The new
19
- * **daemon** path (`src/daemon.js`) binds a singleton control socket at
20
- * `$XDG_RUNTIME_DIR/pgserve/control.sock` and is the v2 default for the
21
- * `pgserve daemon` CLI subcommand. Both paths coexist; direct-embed callers
22
- * are not affected by daemon mode.
23
- */
24
-
25
- import fs from 'fs';
26
- import { PostgresManager } from './postgres.js';
27
- import { SyncManager } from './sync.js';
28
- import { RestoreManager } from './restore.js';
29
- import { Dashboard } from './dashboard.js';
30
- import { extractDatabaseName } from './protocol.js';
31
- import { EventEmitter } from 'events';
32
- import { createLogger } from './logger.js';
33
-
34
- // PostgreSQL protocol constants
35
- const PROTOCOL_VERSION_3 = 196608;
36
- const SSL_REQUEST_CODE = 80877103;
37
- const GSSAPI_REQUEST_CODE = 80877104;
38
- const CANCEL_REQUEST_CODE = 80877102;
39
-
40
- // Maximum size for the pre-handshake startup buffer. A legitimate PG
41
- // startup message is at most a few hundred bytes; anything approaching
42
- // 1 MiB is a runaway client or an attempted buffer-growth DoS. Bound
43
- // this to stop the proxy from accumulating gigabytes of orphaned data
44
- // when a client sends garbage and the handshake never completes.
45
- // (Issue #18 root cause #2 — unbounded growth at state.buffer.)
46
- const MAX_STARTUP_BUFFER_SIZE = 1024 * 1024; // 1 MiB
47
-
48
- /**
49
- * Attempt to write a pending buffer to a target socket.
50
- * Returns remaining unwritten bytes, or null if fully flushed.
51
- */
52
- function flushPending(target, pending) {
53
- const written = target.write(pending);
54
- if (written === pending.byteLength) return null;
55
- if (written === 0) return pending;
56
- return pending.subarray(written);
57
- }
58
-
59
- /**
60
- * Multi-Tenant Router Server
61
- */
62
- export class MultiTenantRouter extends EventEmitter {
63
- constructor(options = {}) {
64
- super();
65
- this.port = options.port || 8432;
66
- this.host = options.host || '127.0.0.1';
67
- this.baseDir = options.baseDir || null; // null = memory mode
68
- this.memoryMode = !options.baseDir;
69
- this.maxConnections = options.maxConnections || 1000;
70
- this.autoProvision = options.autoProvision !== false;
71
-
72
- // Internal PostgreSQL port (different from router port)
73
- this.pgPort = options.pgPort || (this.port + 1000);
74
-
75
- // Pino logger (ultra-fast structured logging)
76
- const logLevel = options.logLevel || 'info';
77
- this.logger = options.logger || createLogger({ level: logLevel });
78
-
79
- // Sync options (async replication to real PostgreSQL)
80
- this.syncTo = options.syncTo || null;
81
- this.syncDatabases = options.syncDatabases
82
- ? options.syncDatabases.split(',').map(s => s.trim())
83
- : [];
84
- this.syncManager = null;
85
-
86
- // PostgreSQL manager (with sync flag if needed)
87
- this.pgManager = new PostgresManager({
88
- dataDir: this.baseDir,
89
- port: this.pgPort,
90
- logger: this.logger.child({ component: 'postgres' }),
91
- syncEnabled: !!this.syncTo, // Enable logical replication if sync is configured
92
- useRam: options.useRam, // Use /dev/shm for true RAM storage (Linux only)
93
- enablePgvector: options.enablePgvector // Auto-enable pgvector extension on new databases
94
- });
95
-
96
- // TCP server
97
- this.server = null;
98
- this.connections = new Set();
99
-
100
- // Performance: Reduce event listener overhead
101
- this.setMaxListeners(this.maxConnections + 10);
102
- }
103
-
104
- /**
105
- * Socket state storage for Bun TCP handler model
106
- * Maps client socket to its state (buffer, pgSocket, dbName, etc.)
107
- */
108
- socketState = new WeakMap();
109
-
110
- /**
111
- * Start multi-tenant router
112
- */
113
- async start() {
114
- // Initialize dashboard for informative CLI output
115
- const dashboard = new Dashboard();
116
- dashboard.showHeader({
117
- port: this.port,
118
- host: this.host,
119
- memoryMode: this.memoryMode,
120
- syncTo: this.syncTo
121
- });
122
-
123
- // Start PostgreSQL first
124
- dashboard.stage('PostgreSQL binaries resolved');
125
- await this.pgManager.start();
126
- dashboard.stage('PostgreSQL started');
127
-
128
- // Automatic restore from external PostgreSQL (if sync configured)
129
- // This runs BEFORE SyncManager to restore data before enabling outbound sync
130
- if (this.syncTo) {
131
- const restoreManager = new RestoreManager({
132
- sourceUrl: this.syncTo,
133
- patterns: this.syncDatabases,
134
- targetPort: this.pgPort,
135
- targetSocketPath: this.pgManager.getSocketPath(),
136
- logger: this.logger.child({ component: 'restore' }),
137
- onProgress: (metrics) => dashboard.updateRestore(metrics)
138
- });
139
-
140
- // Start restore progress display
141
- dashboard.startRestore(restoreManager.totalDatabases || 1);
142
-
143
- const restoreResult = await restoreManager.restore(this.pgManager);
144
-
145
- if (restoreResult.success) {
146
- dashboard.completeRestore(restoreResult.metrics);
147
- this.logger.info({
148
- databases: restoreResult.metrics.databasesRestored,
149
- tables: restoreResult.metrics.tablesRestored,
150
- rows: restoreResult.metrics.rowsRestored,
151
- bytes: restoreResult.metrics.bytesTransferred,
152
- durationMs: restoreResult.metrics.endTime - restoreResult.metrics.startTime
153
- }, 'Restored from external PostgreSQL');
154
- } else if (restoreResult.skipped) {
155
- // No progress to complete - was skipped
156
- } else {
157
- dashboard.cleanup();
158
- this.logger.warn({ error: restoreResult.error }, 'Restore failed (continuing without restored data)');
159
- }
160
- }
161
-
162
- // Initialize SyncManager if configured (async replication)
163
- if (this.syncTo) {
164
- this.syncManager = new SyncManager({
165
- targetUrl: this.syncTo,
166
- databases: this.syncDatabases,
167
- sourcePort: this.pgPort,
168
- sourceSocketPath: this.pgManager.getSocketPath(),
169
- logLevel: this.logger.level
170
- });
171
-
172
- // Wire SyncManager to PostgresManager for database creation hooks
173
- this.pgManager.setSyncManager(this.syncManager);
174
-
175
- // Initialize sync connections (non-blocking, runs in background)
176
- this.syncManager.initialize(this.pgManager)
177
- .then(() => {
178
- dashboard.stage('Sync manager initialized');
179
- this.logger.info('Sync manager initialized');
180
- })
181
- .catch(err => this.logger.warn({ err: err.message }, 'Sync manager initialization failed (non-fatal)'));
182
- }
183
-
184
- // Create TCP server using Bun.listen() for 2-3x throughput
185
- const router = this;
186
- this.server = Bun.listen({
187
- hostname: this.host,
188
- port: this.port,
189
- socket: {
190
- // Called when data arrives on client socket
191
- data(socket, data) {
192
- router.handleSocketData(socket, data);
193
- },
194
- // Called when client connects
195
- open(socket) {
196
- router.handleSocketOpen(socket);
197
- },
198
- // Called when client disconnects
199
- close(socket) {
200
- router.handleSocketClose(socket);
201
- },
202
- // Called on socket error
203
- error(socket, error) {
204
- router.handleSocketError(socket, error);
205
- },
206
- // Called when client socket is ready to receive more data
207
- drain(socket) {
208
- const state = router.socketState.get(socket);
209
- if (!state) return;
210
- // Flush any pending PG→Client data
211
- if (state.pendingToClient) {
212
- state.pendingToClient = flushPending(socket, state.pendingToClient);
213
- }
214
- // If fully flushed, resume reading from PostgreSQL
215
- if (!state.pendingToClient && state.pgSocket) {
216
- state.pgSocket.resume();
217
- }
218
- }
219
- }
220
- });
221
-
222
- const socketPath = this.pgManager.getSocketPath();
223
-
224
- dashboard.stage('TCP server listening');
225
- dashboard.showReady({ port: this.port, host: this.host });
226
-
227
- this.logger.info({
228
- host: this.host,
229
- port: this.port,
230
- pgPort: this.pgPort,
231
- pgSocketPath: socketPath || '(TCP)',
232
- baseDir: this.memoryMode ? '(in-memory)' : this.baseDir,
233
- memoryMode: this.memoryMode,
234
- autoProvision: this.autoProvision,
235
- maxConnections: this.maxConnections
236
- }, 'Multi-tenant router started');
237
-
238
- this.emit('listening');
239
- }
240
-
241
- /**
242
- * Handle socket open (Bun TCP handler)
243
- */
244
- handleSocketOpen(socket) {
245
- // Initialize socket state
246
- this.socketState.set(socket, {
247
- buffer: null,
248
- pgSocket: null,
249
- dbName: null,
250
- handshakeComplete: false,
251
- // startupInProgress serializes processStartupMessage() against async
252
- // reentrancy — without it, every data event fired while the previous
253
- // processStartupMessage() is still awaiting createDatabase() would
254
- // launch another async task on the same state, racing to overwrite
255
- // state.pgSocket and leaking the losers (issue #18 root cause #1).
256
- startupInProgress: false,
257
- pendingToPg: null,
258
- pendingToClient: null
259
- });
260
-
261
- // Track connection
262
- this.connections.add(socket);
263
- }
264
-
265
- /**
266
- * Handle socket data (Bun TCP handler)
267
- * Handles both handshake and proxying phases
268
- */
269
- handleSocketData(socket, data) {
270
- const state = this.socketState.get(socket);
271
- if (!state) return;
272
-
273
- // If handshake complete, forward to PostgreSQL
274
- if (state.handshakeComplete && state.pgSocket) {
275
- // If there's already pending data, append and re-pause.
276
- // (Re-pause is defensive: client should already be paused from the
277
- // earlier partial-write, but kernel-buffered data can still arrive
278
- // before the pause takes effect — issue #18 root cause #3.)
279
- if (state.pendingToPg) {
280
- state.pendingToPg = Buffer.concat([state.pendingToPg, data]);
281
- socket.pause();
282
- return;
283
- }
284
- const written = state.pgSocket.write(data);
285
- if (written < data.byteLength) {
286
- // Partial write — buffer remainder and pause client
287
- state.pendingToPg = written === 0 ? Buffer.from(data) : Buffer.from(data.subarray(written));
288
- socket.pause();
289
- }
290
- return;
291
- }
292
-
293
- // Buffer data for startup message parsing.
294
- // Bound the pre-handshake buffer so a client that never completes its
295
- // startup (or sends garbage) cannot grow state.buffer without limit —
296
- // the 74 GiB VmSize in the production deadlock report traces to this
297
- // path (issue #18 root cause #2).
298
- const incomingSize = state.buffer ? state.buffer.length + data.byteLength : data.byteLength;
299
- if (incomingSize > MAX_STARTUP_BUFFER_SIZE) {
300
- this.logger.warn(
301
- { incomingSize, limit: MAX_STARTUP_BUFFER_SIZE },
302
- 'Pre-handshake buffer exceeded limit — closing connection'
303
- );
304
- socket.end();
305
- return;
306
- }
307
- if (state.buffer) {
308
- state.buffer = Buffer.concat([state.buffer, data]);
309
- } else {
310
- state.buffer = Buffer.from(data);
311
- }
312
-
313
- // Try to parse startup message
314
- this.processStartupMessage(socket, state);
315
- }
316
-
317
- /**
318
- * Process PostgreSQL startup message and establish proxy connection.
319
- *
320
- * Guarded against async reentrancy: multiple data events arriving while
321
- * the first processStartupMessage() is still awaiting createDatabase()
322
- * or Bun.connect() must not launch concurrent tasks on the same state —
323
- * they would race to assign state.pgSocket, leaking the losing sockets
324
- * and double-writing the startup message (issue #18 root cause #1).
325
- */
326
- async processStartupMessage(socket, state) {
327
- if (state.startupInProgress) return;
328
-
329
- const buffer = state.buffer;
330
- if (!buffer || buffer.length < 8) return; // Need at least length + protocol
331
-
332
- // Read message length (first 4 bytes, big-endian)
333
- const messageLength = buffer.readUInt32BE(0);
334
- if (buffer.length < messageLength) return; // Wait for complete message
335
-
336
- // Read protocol version or request code (next 4 bytes)
337
- const code = buffer.readUInt32BE(4);
338
-
339
- // Handle SSL/GSSAPI/Cancel requests
340
- if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
341
- // Reject SSL/GSSAPI - send 'N' (not supported)
342
- socket.write(Buffer.from('N'));
343
- // Remove this request from buffer, wait for real startup
344
- state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
345
- if (state.buffer) await this.processStartupMessage(socket, state);
346
- return;
347
- }
348
-
349
- if (code === CANCEL_REQUEST_CODE) {
350
- // Cancel request - just close connection
351
- socket.end();
352
- return;
353
- }
354
-
355
- // Must be protocol version 3.0
356
- if (code !== PROTOCOL_VERSION_3) {
357
- this.logger.warn({ code }, 'Unsupported protocol version');
358
- socket.end();
359
- return;
360
- }
361
-
362
- // Extract database name from startup message
363
- const startupMessage = buffer.subarray(0, messageLength);
364
- const dbName = extractDatabaseName(startupMessage);
365
- state.dbName = dbName;
366
-
367
- // Claim the reentrancy guard BEFORE the first await so subsequent data
368
- // events (buffered into state.buffer by handleSocketData) cannot launch
369
- // a second async task on the same state.
370
- state.startupInProgress = true;
371
-
372
- try {
373
- // Auto-provision database if needed
374
- if (this.autoProvision) {
375
- await this.pgManager.createDatabase(dbName);
376
- }
377
-
378
- // Connect to real PostgreSQL using Bun.connect()
379
- const socketPath = this.pgManager.getSocketPath();
380
- const router = this;
381
-
382
- // Shared handler for pgSocket (used by both unix and TCP paths)
383
- const pgHandler = {
384
- data(_pgSocket, pgData) {
385
- // Forward PostgreSQL response to client with backpressure.
386
- // Re-pause defensively when pendingToClient already exists —
387
- // kernel-buffered PG data can arrive before the earlier pause()
388
- // takes effect (issue #18 root cause #3).
389
- if (state.pendingToClient) {
390
- state.pendingToClient = Buffer.concat([state.pendingToClient, pgData]);
391
- _pgSocket.pause();
392
- return;
393
- }
394
- const written = socket.write(pgData);
395
- if (written < pgData.byteLength) {
396
- state.pendingToClient = written === 0 ? Buffer.from(pgData) : Buffer.from(pgData.subarray(written));
397
- _pgSocket.pause();
398
- }
399
- },
400
- open(pgSocket) {
401
- pgSocket.write(startupMessage);
402
- state.handshakeComplete = true;
403
- },
404
- close(_pgSocket) {
405
- socket.end();
406
- },
407
- error(_pgSocket, error) {
408
- router.logger.error({ dbName, err: error }, 'PostgreSQL socket error');
409
- socket.end();
410
- },
411
- drain(_pgSocket) {
412
- // Flush any pending Client→PG data
413
- if (state.pendingToPg) {
414
- state.pendingToPg = flushPending(_pgSocket, state.pendingToPg);
415
- }
416
- // If fully flushed, resume reading from client
417
- if (!state.pendingToPg) {
418
- socket.resume();
419
- }
420
- }
421
- };
422
-
423
- // Safety net for issue #24: if socketPath points to a directory that was
424
- // cleaned up (e.g. pgManager was stopped+started, or the PG subprocess
425
- // exited unexpectedly and socketDir was reset to null but a stale cached
426
- // path is still hanging around), fall back to TCP instead of Bun.connect
427
- // hanging on a missing unix socket.
428
- const useUnix = socketPath && fs.existsSync(socketPath);
429
- if (useUnix) {
430
- state.pgSocket = await Bun.connect({ unix: socketPath, socket: pgHandler });
431
- } else {
432
- if (socketPath && !useUnix) {
433
- this.logger.warn({ socketPath, dbName }, 'Unix socket path stale — falling back to TCP');
434
- }
435
- state.pgSocket = await Bun.connect({ hostname: '127.0.0.1', port: this.pgPort, socket: pgHandler });
436
- }
437
-
438
- this.emit('connection', { dbName, socket });
439
- } catch (error) {
440
- this.logger.error({ dbName, err: error }, 'Connection error');
441
- socket.end();
442
- this.emit('connection-error', { error, dbName });
443
- } finally {
444
- // Release the reentrancy guard whether handshake succeeded or not.
445
- // If it succeeded, handshakeComplete is now true and further data
446
- // events will bypass processStartupMessage anyway (handleSocketData
447
- // takes the handshakeComplete path). If it failed, socket.end()
448
- // has been called and the connection is tearing down.
449
- state.startupInProgress = false;
450
- }
451
- }
452
-
453
- /**
454
- * Handle socket close (Bun TCP handler)
455
- */
456
- handleSocketClose(socket) {
457
- const state = this.socketState.get(socket);
458
- if (state) {
459
- state.pendingToPg = null;
460
- state.pendingToClient = null;
461
- if (state.pgSocket) state.pgSocket.end();
462
- }
463
- this.connections.delete(socket);
464
- this.socketState.delete(socket);
465
- }
466
-
467
- /**
468
- * Handle socket error (Bun TCP handler)
469
- */
470
- handleSocketError(socket, error) {
471
- const state = this.socketState.get(socket);
472
- // Only log non-connection-reset errors
473
- if (error.code !== 'ECONNRESET') {
474
- this.logger.error({ err: error, dbName: state?.dbName }, 'Socket error');
475
- }
476
- if (state) {
477
- state.pendingToPg = null;
478
- state.pendingToClient = null;
479
- if (state.pgSocket) state.pgSocket.end();
480
- }
481
- this.connections.delete(socket);
482
- this.socketState.delete(socket);
483
- }
484
-
485
- /**
486
- * Stop router (graceful shutdown)
487
- */
488
- async stop() {
489
- this.logger.info('Stopping multi-tenant router');
490
-
491
- // Close all connections gracefully
492
- const activeConns = this.connections.size;
493
- for (const socket of this.connections) {
494
- socket.end();
495
- }
496
- this.connections.clear();
497
-
498
- // Close TCP server (Bun.listen returns a server with stop() method)
499
- if (this.server) {
500
- this.server.stop();
501
- }
502
-
503
- // Stop SyncManager first (before PostgreSQL)
504
- if (this.syncManager) {
505
- await this.syncManager.stop();
506
- }
507
-
508
- // Stop PostgreSQL
509
- await this.pgManager.stop();
510
-
511
- this.logger.info({
512
- activeConnections: activeConns
513
- }, 'Router stopped');
514
-
515
- this.emit('stopped');
516
- }
517
-
518
- /**
519
- * Get router stats
520
- */
521
- getStats() {
522
- return {
523
- port: this.port,
524
- host: this.host,
525
- pgPort: this.pgPort,
526
- activeConnections: this.connections.size,
527
- postgres: this.pgManager.getStats()
528
- };
529
- }
530
-
531
- /**
532
- * List all databases
533
- */
534
- listDatabases() {
535
- return this.pgManager.getStats().databases;
536
- }
537
- }
538
-
539
- /**
540
- * Start multi-tenant router (convenience function)
541
- */
542
- export async function startMultiTenantServer(options = {}) {
543
- const router = new MultiTenantRouter(options);
544
- await router.start();
545
- return router;
546
- }
package/src/sdk.js DELETED
@@ -1,137 +0,0 @@
1
- /**
2
- * Public SDK helpers for applications that want to consume the singleton
3
- * pgserve daemon without shelling out themselves.
4
- *
5
- * The intended flow is:
6
- * 1. App calls ensureDaemon() during install/startup.
7
- * 2. App connects with daemonClientOptions().
8
- * 3. pgserve derives the app identity from the Unix-socket peer creds and
9
- * routes it to that app's fingerprinted database.
10
- */
11
-
12
- import { spawn } from 'child_process';
13
- import fs from 'fs';
14
- import path from 'path';
15
- import { fileURLToPath } from 'url';
16
- import {
17
- isProcessAlive,
18
- resolveControlSocketDir,
19
- resolveControlSocketPath,
20
- resolveLibpqCompatPath,
21
- resolvePidLockPath,
22
- } from './daemon.js';
23
-
24
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
-
26
- export function probeDaemon({ controlSocketDir = resolveControlSocketDir() } = {}) {
27
- const socketPath = resolveControlSocketPath(controlSocketDir);
28
- const libpqSocketPath = resolveLibpqCompatPath(controlSocketDir);
29
- const pidLockPath = resolvePidLockPath(controlSocketDir);
30
- const socketPresent = fs.existsSync(socketPath);
31
- const libpqSocketPresent = fs.existsSync(libpqSocketPath);
32
- let pid = null;
33
-
34
- try {
35
- const raw = fs.readFileSync(pidLockPath, 'utf8').trim();
36
- const parsed = Number.parseInt(raw, 10);
37
- if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
38
- } catch {
39
- // Missing/unreadable pid file means no live daemon can be trusted.
40
- }
41
-
42
- const pidAlive = pid !== null && isProcessAlive(pid);
43
- const running = pidAlive && socketPresent && libpqSocketPresent;
44
- return {
45
- running,
46
- pid: pidAlive ? pid : null,
47
- socketPresent,
48
- libpqSocketPresent,
49
- controlSocketDir,
50
- controlSocketPath: socketPath,
51
- libpqSocketPath,
52
- pidLockPath,
53
- reason: running ? null : explainProbeMiss({ pid, pidAlive, socketPresent, libpqSocketPresent }),
54
- };
55
- }
56
-
57
- function explainProbeMiss({ pid, pidAlive, socketPresent, libpqSocketPresent }) {
58
- if (pid === null && !socketPresent && !libpqSocketPresent) return 'no daemon';
59
- if (pid !== null && !pidAlive) return 'stale pid';
60
- if (!socketPresent) return 'control socket missing';
61
- if (!libpqSocketPresent) return 'libpq socket missing';
62
- return 'not running';
63
- }
64
-
65
- export function daemonClientOptions({
66
- controlSocketDir = resolveControlSocketDir(),
67
- database = 'postgres',
68
- username = 'postgres',
69
- } = {}) {
70
- return {
71
- host: controlSocketDir,
72
- port: 5432,
73
- database,
74
- username,
75
- password: '',
76
- };
77
- }
78
-
79
- export function buildDaemonArgs({
80
- dataDir,
81
- ram = false,
82
- logLevel,
83
- noProvision = false,
84
- listens = [],
85
- pgvector = false,
86
- } = {}) {
87
- const args = ['daemon'];
88
- if (dataDir) args.push('--data', dataDir);
89
- if (ram) args.push('--ram');
90
- if (logLevel) args.push('--log', logLevel);
91
- if (noProvision) args.push('--no-provision');
92
- if (pgvector) args.push('--pgvector');
93
- for (const listen of Array.isArray(listens) ? listens : [listens]) {
94
- if (listen) args.push('--listen', String(listen));
95
- }
96
- return args;
97
- }
98
-
99
- export async function ensureDaemon(options = {}) {
100
- const controlSocketDir = options.controlSocketDir || resolveControlSocketDir();
101
- const initial = probeDaemon({ controlSocketDir });
102
- if (initial.running) return initial;
103
-
104
- const bin = options.bin || resolveBundledCliBin();
105
- const env = { ...process.env, ...envForControlSocketDir(controlSocketDir), ...(options.env || {}) };
106
- const child = spawn(bin, buildDaemonArgs(options), {
107
- detached: true,
108
- stdio: 'ignore',
109
- env,
110
- });
111
- child.unref();
112
-
113
- const timeoutMs = options.timeoutMs || 16000;
114
- const deadline = Date.now() + timeoutMs;
115
- while (Date.now() < deadline) {
116
- const state = probeDaemon({ controlSocketDir });
117
- if (state.running) return state;
118
- await new Promise((resolve) => setTimeout(resolve, 250));
119
- }
120
-
121
- const state = probeDaemon({ controlSocketDir });
122
- const err = new Error(`pgserve daemon did not become ready within ${timeoutMs}ms (${state.reason})`);
123
- err.code = 'EPGSERVE_DAEMON_TIMEOUT';
124
- err.state = state;
125
- throw err;
126
- }
127
-
128
- export function resolveBundledCliBin() {
129
- return path.join(__dirname, '..', 'bin', 'pgserve-wrapper.cjs');
130
- }
131
-
132
- function envForControlSocketDir(controlSocketDir) {
133
- if (path.basename(controlSocketDir) !== 'pgserve') {
134
- throw new Error('ensureDaemon: controlSocketDir must be a pgserve runtime directory ending in /pgserve');
135
- }
136
- return { XDG_RUNTIME_DIR: path.dirname(controlSocketDir) };
137
- }