pgserve 0.1.4 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * PostgreSQL Manager (Direct Binary Execution)
3
+ *
4
+ * Manages an embedded PostgreSQL instance with true concurrent connections.
5
+ * Directly executes PostgreSQL binaries from embedded-postgres packages,
6
+ * bypassing the embedded-postgres library's locale-dependent initialization.
7
+ *
8
+ * Features:
9
+ * - Uses embedded-postgres binaries (auto-downloaded via npm)
10
+ * - Memory mode (default) or persistent storage
11
+ * - True concurrent connections (native PostgreSQL process forking)
12
+ * - Auto-provision databases on demand
13
+ * - No locale dependency (works on any system)
14
+ */
15
+
16
+ import { spawn } from 'child_process';
17
+ import os from 'os';
18
+ import path from 'path';
19
+ import fs from 'fs';
20
+ import crypto from 'crypto';
21
+ import net from 'net';
22
+
23
+ // Resolve binary paths from embedded-postgres platform packages
24
+ function getBinaryPaths() {
25
+ const platform = os.platform();
26
+ const arch = os.arch();
27
+
28
+ let pkgName;
29
+ if (platform === 'linux' && arch === 'x64') {
30
+ pkgName = '@embedded-postgres/linux-x64';
31
+ } else if (platform === 'darwin' && arch === 'arm64') {
32
+ pkgName = '@embedded-postgres/darwin-arm64';
33
+ } else if (platform === 'darwin' && arch === 'x64') {
34
+ pkgName = '@embedded-postgres/darwin-x64';
35
+ } else if (platform === 'win32' && arch === 'x64') {
36
+ pkgName = '@embedded-postgres/win32-x64';
37
+ } else {
38
+ throw new Error(`Unsupported platform: ${platform}-${arch}`);
39
+ }
40
+
41
+ // Find the package in node_modules
42
+ const possiblePaths = [
43
+ path.join(process.cwd(), 'node_modules', pkgName, 'native', 'bin'),
44
+ path.join(import.meta.dirname, '..', 'node_modules', pkgName, 'native', 'bin'),
45
+ ];
46
+
47
+ for (const binDir of possiblePaths) {
48
+ const initdb = path.join(binDir, platform === 'win32' ? 'initdb.exe' : 'initdb');
49
+ const postgres = path.join(binDir, platform === 'win32' ? 'postgres.exe' : 'postgres');
50
+ if (fs.existsSync(initdb) && fs.existsSync(postgres)) {
51
+ return { initdb, postgres, binDir };
52
+ }
53
+ }
54
+
55
+ throw new Error(`Could not find PostgreSQL binaries. Please run: npm install ${pkgName}`);
56
+ }
57
+
58
+ export class PostgresManager {
59
+ constructor(options = {}) {
60
+ this.dataDir = options.dataDir || null; // null = memory mode (temp dir)
61
+ this.port = options.port || 5433; // Internal PG port (router listens on different port)
62
+ this.user = options.user || 'postgres';
63
+ this.password = options.password || 'postgres';
64
+ this.logger = options.logger;
65
+ this.process = null;
66
+ this.databaseDir = null;
67
+ this.persistent = !!options.dataDir;
68
+ this.createdDatabases = new Set();
69
+ this.binaries = null;
70
+ this.creatingDatabases = new Map(); // Track in-progress creations
71
+ this.socketDir = null; // Unix socket directory for faster local connections
72
+ this.adminPool = null; // Connection pool for database admin operations
73
+
74
+ // Sync/Replication options (for async sync to real PostgreSQL)
75
+ this.syncEnabled = options.syncEnabled || false;
76
+ this.syncManager = null; // Will be set via setSyncManager()
77
+ }
78
+
79
+ /**
80
+ * Set the SyncManager for async replication
81
+ * Called after PostgresManager is created but before start()
82
+ * @param {SyncManager} syncManager
83
+ */
84
+ setSyncManager(syncManager) {
85
+ this.syncManager = syncManager;
86
+ this.syncEnabled = !!syncManager;
87
+ }
88
+
89
+ /**
90
+ * Start the embedded PostgreSQL instance
91
+ */
92
+ async start() {
93
+ // Get binary paths
94
+ this.binaries = getBinaryPaths();
95
+
96
+ // Make binaries executable
97
+ await fs.promises.chmod(this.binaries.initdb, '755');
98
+ await fs.promises.chmod(this.binaries.postgres, '755');
99
+
100
+ // Determine data directory
101
+ if (this.persistent) {
102
+ this.databaseDir = this.dataDir;
103
+ // Ensure directory exists
104
+ if (!fs.existsSync(this.databaseDir)) {
105
+ fs.mkdirSync(this.databaseDir, { recursive: true });
106
+ }
107
+ } else {
108
+ // Memory mode: use temp directory with unique suffix
109
+ this.databaseDir = path.join(os.tmpdir(), `pgserve-${process.pid}-${Date.now()}`);
110
+ // Clean up if exists from a previous failed run
111
+ if (fs.existsSync(this.databaseDir)) {
112
+ fs.rmSync(this.databaseDir, { recursive: true, force: true });
113
+ }
114
+ }
115
+
116
+ // Create Unix socket directory (Linux/macOS only, Windows uses TCP)
117
+ if (os.platform() !== 'win32') {
118
+ this.socketDir = path.join(os.tmpdir(), `pgserve-sock-${process.pid}-${Date.now()}`);
119
+ if (!fs.existsSync(this.socketDir)) {
120
+ fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
121
+ }
122
+ }
123
+
124
+ this.logger.info({
125
+ databaseDir: this.databaseDir,
126
+ persistent: this.persistent,
127
+ port: this.port
128
+ }, 'Starting embedded PostgreSQL');
129
+
130
+ // Check if data directory is already initialized
131
+ const pgVersionFile = path.join(this.databaseDir, 'PG_VERSION');
132
+ if (!fs.existsSync(pgVersionFile)) {
133
+ await this._runInitDb();
134
+ } else {
135
+ this.logger.debug({ databaseDir: this.databaseDir }, 'Using existing data directory');
136
+ }
137
+
138
+ // Start PostgreSQL server
139
+ await this._startPostgres();
140
+
141
+ // Initialize admin connection pool (for database creation operations)
142
+ await this._initAdminPool();
143
+
144
+ this.logger.info({
145
+ databaseDir: this.databaseDir,
146
+ port: this.port,
147
+ socketDir: this.socketDir,
148
+ persistent: this.persistent
149
+ }, 'PostgreSQL started successfully');
150
+
151
+ return this;
152
+ }
153
+
154
+ /**
155
+ * Run initdb to initialize the data directory
156
+ */
157
+ async _runInitDb() {
158
+ // Create password file
159
+ const randomId = crypto.randomBytes(6).toString('hex');
160
+ const passwordFile = path.join(os.tmpdir(), `pg-password-${randomId}`);
161
+ await fs.promises.writeFile(passwordFile, this.password + '\n');
162
+
163
+ this.logger.debug({ databaseDir: this.databaseDir }, 'Initializing PostgreSQL data directory');
164
+
165
+ return new Promise((resolve, reject) => {
166
+ const proc = spawn(this.binaries.initdb, [
167
+ `--pgdata=${this.databaseDir}`,
168
+ '--auth=password',
169
+ `--username=${this.user}`,
170
+ `--pwfile=${passwordFile}`,
171
+ ], {
172
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' }
173
+ });
174
+
175
+ let stdout = '';
176
+ let stderr = '';
177
+
178
+ proc.stdout.on('data', (data) => {
179
+ stdout += data.toString();
180
+ });
181
+
182
+ proc.stderr.on('data', (data) => {
183
+ stderr += data.toString();
184
+ });
185
+
186
+ proc.on('close', async (code) => {
187
+ // Clean up password file
188
+ try {
189
+ await fs.promises.unlink(passwordFile);
190
+ } catch (e) {
191
+ // Ignore
192
+ }
193
+
194
+ if (code === 0) {
195
+ this.logger.debug('initdb completed successfully');
196
+ resolve();
197
+ } else {
198
+ this.logger.error({ code, stdout, stderr }, 'initdb failed');
199
+ reject(new Error(`initdb failed with code ${code}: ${stderr || stdout}`));
200
+ }
201
+ });
202
+
203
+ proc.on('error', (err) => {
204
+ reject(new Error(`Failed to spawn initdb: ${err.message}`));
205
+ });
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Initialize admin connection pool for database operations
211
+ * Uses Unix socket when available for faster connections
212
+ */
213
+ async _initAdminPool() {
214
+ const { default: pg } = await import('pg');
215
+
216
+ // Pool config - use Unix socket when available
217
+ const poolConfig = {
218
+ user: this.user,
219
+ password: this.password,
220
+ database: 'postgres',
221
+ max: 5, // Small pool - only for CREATE DATABASE operations
222
+ idleTimeoutMillis: 30000,
223
+ connectionTimeoutMillis: 5000,
224
+ };
225
+
226
+ // Use Unix socket for faster local connections (Linux/macOS)
227
+ // Note: pg library needs both host (socket dir) AND port to find the socket file
228
+ if (this.socketDir) {
229
+ poolConfig.host = this.socketDir;
230
+ poolConfig.port = this.port; // Required for Unix socket path construction
231
+ } else {
232
+ poolConfig.host = '127.0.0.1';
233
+ poolConfig.port = this.port;
234
+ }
235
+
236
+ this.adminPool = new pg.Pool(poolConfig);
237
+
238
+ // Verify pool is working
239
+ const client = await this.adminPool.connect();
240
+ client.release();
241
+
242
+ this.logger.debug({
243
+ host: poolConfig.host,
244
+ maxConnections: poolConfig.max
245
+ }, 'Admin connection pool initialized');
246
+ }
247
+
248
+ /**
249
+ * Start the PostgreSQL server process
250
+ */
251
+ async _startPostgres() {
252
+ return new Promise((resolve, reject) => {
253
+ // Build PostgreSQL arguments
254
+ const pgArgs = [
255
+ '-D', this.databaseDir,
256
+ '-p', this.port.toString(),
257
+ ];
258
+
259
+ // Enable Unix socket for faster local connections (Linux/macOS)
260
+ // Windows falls back to TCP only
261
+ if (this.socketDir) {
262
+ pgArgs.push('-k', this.socketDir);
263
+ } else {
264
+ pgArgs.push('-k', ''); // Disable Unix socket on Windows
265
+ }
266
+
267
+ // Add logical replication settings when sync is enabled
268
+ // These settings enable PostgreSQL's native WAL-based replication
269
+ // with ZERO hot path impact (handled by PostgreSQL's WAL writer process)
270
+ if (this.syncEnabled) {
271
+ pgArgs.push(
272
+ '-c', 'wal_level=logical', // Enable logical decoding
273
+ '-c', 'max_replication_slots=10', // Support multiple subscriptions
274
+ '-c', 'max_wal_senders=10', // Parallel replication streams
275
+ '-c', 'wal_keep_size=512MB', // Retain WAL for catchup
276
+ );
277
+ this.logger.info('Logical replication enabled for sync');
278
+ }
279
+
280
+ this.process = spawn(this.binaries.postgres, pgArgs, {
281
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' }
282
+ });
283
+
284
+ let started = false;
285
+ let startupOutput = '';
286
+
287
+ const onData = (data) => {
288
+ const message = data.toString();
289
+ startupOutput += message;
290
+ this.logger.debug({ pgOutput: message.trim() }, 'PostgreSQL output');
291
+
292
+ // Check for ready message
293
+ if (message.includes('database system is ready to accept connections') ||
294
+ message.includes('ready to accept connections')) {
295
+ started = true;
296
+ resolve();
297
+ }
298
+ };
299
+
300
+ this.process.stderr.on('data', onData);
301
+ this.process.stdout.on('data', onData);
302
+
303
+ this.process.on('close', (code) => {
304
+ if (!started) {
305
+ reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
306
+ }
307
+ this.process = null;
308
+ });
309
+
310
+ this.process.on('error', (err) => {
311
+ reject(new Error(`Failed to spawn postgres: ${err.message}`));
312
+ });
313
+
314
+ // Timeout after 30 seconds
315
+ setTimeout(() => {
316
+ if (!started) {
317
+ reject(new Error(`PostgreSQL startup timed out after 30s. Output: ${startupOutput}`));
318
+ }
319
+ }, 30000);
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Create a database if it doesn't exist
325
+ * Uses a promise-based lock to prevent race conditions
326
+ * @param {string} dbName - Database name to create
327
+ */
328
+ async createDatabase(dbName) {
329
+ // Skip if already created this session
330
+ if (this.createdDatabases.has(dbName)) {
331
+ return;
332
+ }
333
+
334
+ // Skip 'postgres' database - it always exists
335
+ if (dbName === 'postgres') {
336
+ this.createdDatabases.add(dbName);
337
+ return;
338
+ }
339
+
340
+ // Check if creation is already in progress for this database
341
+ // If so, wait for it to complete
342
+ if (this.creatingDatabases.has(dbName)) {
343
+ await this.creatingDatabases.get(dbName);
344
+ return;
345
+ }
346
+
347
+ // Create a promise that other concurrent requests will wait on
348
+ let resolveCreation;
349
+ const creationPromise = new Promise((resolve) => {
350
+ resolveCreation = resolve;
351
+ });
352
+ this.creatingDatabases.set(dbName, creationPromise);
353
+
354
+ // Use pooled connection for faster database creation
355
+ let createError = null;
356
+ const client = await this.adminPool.connect();
357
+ try {
358
+ await client.query(`CREATE DATABASE ${client.escapeIdentifier(dbName)}`);
359
+ this.createdDatabases.add(dbName);
360
+ this.logger.info({ dbName }, 'Database created');
361
+
362
+ // Trigger async sync setup (non-blocking, doesn't affect hot path)
363
+ if (this.syncManager) {
364
+ this.syncManager.setupDatabaseSync(dbName)
365
+ .catch(err => this.logger.warn({ dbName, err: err.message }, 'Sync setup failed (non-fatal)'));
366
+ }
367
+ } catch (error) {
368
+ // Database might already exist (from previous persistent session or race condition)
369
+ // 42P04 = duplicate_database, 23505 = unique_violation
370
+ if (error.code === '42P04' || error.code === '23505') {
371
+ this.createdDatabases.add(dbName);
372
+ this.logger.debug({ dbName }, 'Database already exists');
373
+ } else {
374
+ createError = error;
375
+ }
376
+ } finally {
377
+ client.release();
378
+ // Signal completion to waiting requests
379
+ this.creatingDatabases.delete(dbName);
380
+ resolveCreation();
381
+ }
382
+
383
+ if (createError) {
384
+ throw new Error(`Failed to create database '${dbName}': ${createError.message}`);
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Check if a database exists
390
+ * @param {string} dbName - Database name to check
391
+ */
392
+ async databaseExists(dbName) {
393
+ return this.createdDatabases.has(dbName);
394
+ }
395
+
396
+ /**
397
+ * Stop the PostgreSQL instance
398
+ */
399
+ async stop() {
400
+ // Close admin pool first
401
+ if (this.adminPool) {
402
+ await this.adminPool.end();
403
+ this.adminPool = null;
404
+ }
405
+
406
+ if (this.process) {
407
+ this.logger.info('Stopping PostgreSQL');
408
+
409
+ return new Promise((resolve) => {
410
+ this.process.on('close', () => {
411
+ this.process = null;
412
+
413
+ // Clean up temp directory in memory mode
414
+ if (!this.persistent && this.databaseDir) {
415
+ try {
416
+ fs.rmSync(this.databaseDir, { recursive: true, force: true });
417
+ this.logger.debug({ databaseDir: this.databaseDir }, 'Cleaned up temp directory');
418
+ } catch (error) {
419
+ this.logger.warn({ error: error.message }, 'Failed to clean up temp directory');
420
+ }
421
+ }
422
+
423
+ // Clean up socket directory
424
+ if (this.socketDir) {
425
+ try {
426
+ fs.rmSync(this.socketDir, { recursive: true, force: true });
427
+ this.logger.debug({ socketDir: this.socketDir }, 'Cleaned up socket directory');
428
+ } catch (error) {
429
+ this.logger.warn({ error: error.message }, 'Failed to clean up socket directory');
430
+ }
431
+ }
432
+
433
+ resolve();
434
+ });
435
+
436
+ // Send SIGINT for graceful shutdown
437
+ this.process.kill('SIGINT');
438
+
439
+ // Force kill after 5 seconds
440
+ setTimeout(() => {
441
+ if (this.process) {
442
+ this.process.kill('SIGKILL');
443
+ }
444
+ }, 5000);
445
+ });
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Get the Unix socket path for PostgreSQL connections
451
+ * Returns null on Windows (use TCP instead)
452
+ */
453
+ getSocketPath() {
454
+ if (!this.socketDir) return null;
455
+ return path.join(this.socketDir, `.s.PGSQL.${this.port}`);
456
+ }
457
+
458
+ /**
459
+ * Get connection URL for a specific database
460
+ * @param {string} dbName - Database name
461
+ */
462
+ getConnectionUrl(dbName = 'postgres') {
463
+ return `postgresql://${this.user}:${this.password}@127.0.0.1:${this.port}/${dbName}`;
464
+ }
465
+
466
+ /**
467
+ * Get manager stats
468
+ */
469
+ getStats() {
470
+ return {
471
+ port: this.port,
472
+ databaseDir: this.databaseDir,
473
+ socketDir: this.socketDir,
474
+ socketPath: this.getSocketPath(),
475
+ persistent: this.persistent,
476
+ databases: Array.from(this.createdDatabases)
477
+ };
478
+ }
479
+ }
package/src/protocol.js CHANGED
@@ -11,7 +11,9 @@
11
11
  */
12
12
 
13
13
  const PROTOCOL_VERSION_3 = 196608;
14
- const SSL_REQUEST_CODE = 80877103; // PostgreSQL SSL negotiation request
14
+ const SSL_REQUEST_CODE = 80877103; // PostgreSQL SSL negotiation request
15
+ const GSSAPI_REQUEST_CODE = 80877104; // PostgreSQL GSSAPI encryption request
16
+ const CANCEL_REQUEST_CODE = 80877102; // PostgreSQL cancel request
15
17
  const DATABASE_KEY = Buffer.from('database\0');
16
18
 
17
19
  /**
@@ -132,36 +134,59 @@ export function extractDatabaseName(data) {
132
134
  }
133
135
  }
134
136
 
137
+ // Pre-allocated buffer pool for startup message parsing (avoids allocation per connection)
138
+ const STARTUP_BUFFER_SIZE = 8192; // Max startup message is typically < 1KB
139
+ const bufferPool = [];
140
+ const MAX_POOL_SIZE = 100;
141
+
142
+ function acquireBuffer() {
143
+ return bufferPool.pop() || Buffer.allocUnsafe(STARTUP_BUFFER_SIZE);
144
+ }
145
+
146
+ function releaseBuffer(buf) {
147
+ if (bufferPool.length < MAX_POOL_SIZE) {
148
+ bufferPool.push(buf);
149
+ }
150
+ }
151
+
135
152
  /**
136
153
  * Read startup message from socket and buffer it
154
+ * OPTIMIZED: Uses pre-allocated buffer pool to avoid allocation per connection
137
155
  *
138
156
  * @param {net.Socket} socket - TCP socket
139
157
  * @returns {Promise<{message: Buffer, allData: Buffer}>} Startup message and all buffered data
140
158
  */
141
159
  export async function readStartupMessage(socket) {
142
160
  return new Promise((resolve, reject) => {
143
- let buffer = Buffer.alloc(0);
161
+ const buffer = acquireBuffer();
162
+ let offset = 0;
144
163
  let expectedLength = null;
145
164
  let resolved = false;
146
165
 
147
166
  const onData = (chunk) => {
148
167
  if (resolved) return;
149
168
 
150
- buffer = Buffer.concat([buffer, chunk]);
169
+ // Copy chunk into pre-allocated buffer (avoids Buffer.concat allocation)
170
+ const copyLen = Math.min(chunk.length, STARTUP_BUFFER_SIZE - offset);
171
+ chunk.copy(buffer, offset, 0, copyLen);
172
+ offset += copyLen;
151
173
 
152
174
  // Read expected length from first 4 bytes
153
- if (expectedLength === null && buffer.length >= 4) {
175
+ if (expectedLength === null && offset >= 4) {
154
176
  expectedLength = buffer.readInt32BE(0);
155
177
  }
156
178
 
157
179
  // Check if we have full message
158
- if (expectedLength !== null && buffer.length >= expectedLength) {
180
+ if (expectedLength !== null && offset >= expectedLength) {
159
181
  resolved = true;
160
182
  socket.removeListener('data', onData);
161
183
  socket.removeListener('error', onError);
162
184
 
163
- const message = buffer.slice(0, expectedLength);
164
- resolve({ message, allData: buffer });
185
+ // Create result buffers (need to copy since we're reusing pool buffer)
186
+ const message = Buffer.from(buffer.subarray(0, expectedLength));
187
+ const allData = Buffer.from(buffer.subarray(0, offset));
188
+ releaseBuffer(buffer);
189
+ resolve({ message, allData });
165
190
  }
166
191
  };
167
192
 
@@ -170,6 +195,7 @@ export async function readStartupMessage(socket) {
170
195
  resolved = true;
171
196
  socket.removeListener('data', onData);
172
197
  socket.removeListener('error', onError);
198
+ releaseBuffer(buffer);
173
199
  reject(error);
174
200
  };
175
201
 
@@ -179,14 +205,15 @@ export async function readStartupMessage(socket) {
179
205
  // Resume socket AFTER listeners are set up (prevents race condition)
180
206
  socket.resume();
181
207
 
182
- // Timeout after 5 seconds
208
+ // Timeout after 2 seconds (reduced from 5s for faster probe connection handling)
183
209
  setTimeout(() => {
184
210
  if (resolved) return;
185
211
  resolved = true;
186
212
  socket.removeListener('data', onData);
187
213
  socket.removeListener('error', onError);
214
+ releaseBuffer(buffer);
188
215
  reject(new Error('Timeout reading startup message'));
189
- }, 5000);
216
+ }, 2000);
190
217
  });
191
218
  }
192
219
 
@@ -199,7 +226,7 @@ export async function readStartupMessage(socket) {
199
226
  export async function extractDatabaseNameFromSocket(socket) {
200
227
  let { message, allData } = await readStartupMessage(socket);
201
228
 
202
- // Check if this is an SSL request
229
+ // Check if this is a protocol negotiation request (SSL, GSSAPI, Cancel)
203
230
  if (message.length >= 8) {
204
231
  const version = message.readInt32BE(4);
205
232
 
@@ -211,6 +238,18 @@ export async function extractDatabaseNameFromSocket(socket) {
211
238
  const result = await readStartupMessage(socket);
212
239
  message = result.message;
213
240
  allData = result.allData;
241
+ } else if (version === GSSAPI_REQUEST_CODE) {
242
+ // Respond with 'N' (no GSSAPI support)
243
+ socket.write(Buffer.from('N'));
244
+
245
+ // Read the actual startup message
246
+ const result = await readStartupMessage(socket);
247
+ message = result.message;
248
+ allData = result.allData;
249
+ } else if (version === CANCEL_REQUEST_CODE) {
250
+ // Cancel request - PGlite doesn't support query cancellation
251
+ // Just close gracefully (cancel requests don't expect a response)
252
+ throw new Error('Cancel request received (not supported)');
214
253
  }
215
254
  }
216
255