pgserve 0.1.5 → 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
@@ -134,36 +134,59 @@ export function extractDatabaseName(data) {
134
134
  }
135
135
  }
136
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
+
137
152
  /**
138
153
  * Read startup message from socket and buffer it
154
+ * OPTIMIZED: Uses pre-allocated buffer pool to avoid allocation per connection
139
155
  *
140
156
  * @param {net.Socket} socket - TCP socket
141
157
  * @returns {Promise<{message: Buffer, allData: Buffer}>} Startup message and all buffered data
142
158
  */
143
159
  export async function readStartupMessage(socket) {
144
160
  return new Promise((resolve, reject) => {
145
- let buffer = Buffer.alloc(0);
161
+ const buffer = acquireBuffer();
162
+ let offset = 0;
146
163
  let expectedLength = null;
147
164
  let resolved = false;
148
165
 
149
166
  const onData = (chunk) => {
150
167
  if (resolved) return;
151
168
 
152
- 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;
153
173
 
154
174
  // Read expected length from first 4 bytes
155
- if (expectedLength === null && buffer.length >= 4) {
175
+ if (expectedLength === null && offset >= 4) {
156
176
  expectedLength = buffer.readInt32BE(0);
157
177
  }
158
178
 
159
179
  // Check if we have full message
160
- if (expectedLength !== null && buffer.length >= expectedLength) {
180
+ if (expectedLength !== null && offset >= expectedLength) {
161
181
  resolved = true;
162
182
  socket.removeListener('data', onData);
163
183
  socket.removeListener('error', onError);
164
184
 
165
- const message = buffer.slice(0, expectedLength);
166
- 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 });
167
190
  }
168
191
  };
169
192
 
@@ -172,6 +195,7 @@ export async function readStartupMessage(socket) {
172
195
  resolved = true;
173
196
  socket.removeListener('data', onData);
174
197
  socket.removeListener('error', onError);
198
+ releaseBuffer(buffer);
175
199
  reject(error);
176
200
  };
177
201
 
@@ -187,6 +211,7 @@ export async function readStartupMessage(socket) {
187
211
  resolved = true;
188
212
  socket.removeListener('data', onData);
189
213
  socket.removeListener('error', onError);
214
+ releaseBuffer(buffer);
190
215
  reject(new Error('Timeout reading startup message'));
191
216
  }, 2000);
192
217
  });