pgserve 0.1.5 → 1.0.2

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/index.js CHANGED
@@ -1,177 +1,16 @@
1
1
  /**
2
- * pgserve - PostgreSQL embedded server using PGlite
2
+ * pgserve - Embedded PostgreSQL Server
3
3
  *
4
- * Multi-tenant PostgreSQL router using PGlite
5
- * Single port, auto-provisioning, perfect for multi-user apps and AI agents
4
+ * True concurrent connections, zero config, auto-provision databases.
5
+ * Uses embedded-postgres (real PostgreSQL binaries).
6
6
  */
7
7
 
8
- // Multi-tenant mode (NEW - recommended)
8
+ // Main exports
9
9
  export { MultiTenantRouter, startMultiTenantServer } from './router.js';
10
- export { InstancePool } from './pool.js';
10
+ export { PostgresManager } from './postgres.js';
11
+ export { SyncManager } from './sync.js';
12
+ export { RestoreManager } from './restore.js';
13
+ export { Dashboard } from './dashboard.js';
11
14
 
12
- // Legacy single-instance mode (backwards compatible)
13
- import { startServer as _startServer, stopServer as _stopServer } from './server.js';
14
- import { allocatePort, getPortRangeInfo } from './ports.js';
15
- import {
16
- findInstanceByDataDir,
17
- findInstanceByPort,
18
- listInstances,
19
- cleanupStaleInstances
20
- } from './registry.js';
21
- import { autoDetect as _autoDetect } from './detector.js';
22
-
23
- /**
24
- * Start a new PGlite server instance
25
- *
26
- * @param {Object} options
27
- * @param {string} options.dataDir - Data directory for the database
28
- * @param {number} [options.port] - Specific port (optional, auto-allocated if not provided)
29
- * @param {boolean} [options.autoPort=true] - Auto-allocate port if specified port is unavailable
30
- * @param {string} [options.logLevel='info'] - Log level (error, warn, info, debug)
31
- * @returns {Promise<Object>} Server instance
32
- */
33
- export async function startServer({ dataDir, port, autoPort = true, logLevel = 'info' }) {
34
- // Allocate port (checks for existing instance, reuses if running)
35
- const allocatedPort = await allocatePort(dataDir, port);
36
-
37
- if (port && allocatedPort !== port && !autoPort) {
38
- throw new Error(
39
- `Port ${port} unavailable and autoPort is disabled. ` +
40
- `Use autoPort: true or choose a different port.`
41
- );
42
- }
43
-
44
- return _startServer({ dataDir, port: allocatedPort, logLevel });
45
- }
46
-
47
- /**
48
- * Stop a running server instance
49
- *
50
- * @param {Object} options
51
- * @param {string} [options.dataDir] - Data directory of the instance to stop
52
- * @param {number} [options.port] - Port of the instance to stop
53
- */
54
- export async function stopServer({ dataDir, port }) {
55
- return _stopServer({ dataDir, port });
56
- }
57
-
58
- /**
59
- * Get an existing instance or start a new one
60
- *
61
- * This is the recommended way to start a server, as it prevents
62
- * duplicate instances for the same data directory.
63
- *
64
- * @param {Object} options
65
- * @param {string} options.dataDir - Data directory for the database
66
- * @param {number} [options.port] - Preferred port (auto-allocated if unavailable)
67
- * @param {boolean} [options.autoPort=true] - Auto-allocate port
68
- * @param {string} [options.logLevel='info'] - Log level
69
- * @returns {Promise<Object>} Server instance (existing or new)
70
- */
71
- export async function getOrStart({ dataDir, port, autoPort = true, logLevel = 'info' }) {
72
- // Check if instance already running
73
- const existing = findInstanceByDataDir(dataDir);
74
-
75
- if (existing) {
76
- // Verify process is still alive
77
- try {
78
- process.kill(existing.pid, 0);
79
- console.log(`✅ Using existing instance on port ${existing.port}`);
80
-
81
- return {
82
- port: existing.port,
83
- dataDir,
84
- pid: existing.pid,
85
- connectionUrl: `postgresql://localhost:${existing.port}`,
86
- existing: true
87
- };
88
- } catch {
89
- // Process dead, cleanup and start new
90
- console.log('🔄 Stale instance found, starting fresh...');
91
- }
92
- }
93
-
94
- // Start new instance
95
- return startServer({ dataDir, port, autoPort, logLevel });
96
- }
97
-
98
- /**
99
- * Auto-detect database configuration
100
- *
101
- * Tries external PostgreSQL first, falls back to embedded PGlite
102
- *
103
- * @param {Object} options
104
- * @param {string} [options.externalUrl] - External PostgreSQL URL to try first
105
- * @param {string} options.embeddedDataDir - Data directory for embedded fallback
106
- * @param {number} [options.embeddedPort] - Preferred port for embedded server
107
- * @param {number} [options.timeout=5000] - Timeout for external connection test
108
- * @returns {Promise<Object>} Database configuration
109
- */
110
- export async function autoDetect({
111
- externalUrl,
112
- embeddedDataDir,
113
- embeddedPort,
114
- timeout = 5000
115
- }) {
116
- return _autoDetect({ externalUrl, embeddedDataDir, embeddedPort, timeout });
117
- }
118
-
119
- /**
120
- * List all running instances
121
- *
122
- * @returns {Array<Object>} Array of instance info
123
- */
124
- export function list() {
125
- return listInstances();
126
- }
127
-
128
- /**
129
- * Find instance by data directory
130
- *
131
- * @param {string} dataDir - Data directory path
132
- * @returns {Object|null} Instance info or null
133
- */
134
- export function findByDataDir(dataDir) {
135
- return findInstanceByDataDir(dataDir);
136
- }
137
-
138
- /**
139
- * Find instance by port
140
- *
141
- * @param {number} port - Port number
142
- * @returns {Object|null} Instance info or null
143
- */
144
- export function findByPort(port) {
145
- return findInstanceByPort(port);
146
- }
147
-
148
- /**
149
- * Get port range information
150
- *
151
- * @returns {Object} Port range stats
152
- */
153
- export function portInfo() {
154
- return getPortRangeInfo();
155
- }
156
-
157
- /**
158
- * Cleanup stale instances (dead processes)
159
- *
160
- * @returns {number} Number of instances cleaned up
161
- */
162
- export function cleanup() {
163
- return cleanupStaleInstances();
164
- }
165
-
166
- // Export all functions
167
- export default {
168
- startServer,
169
- stopServer,
170
- getOrStart,
171
- autoDetect,
172
- list,
173
- findByDataDir,
174
- findByPort,
175
- portInfo,
176
- cleanup
177
- };
15
+ // Default export
16
+ export { startMultiTenantServer as default } from './router.js';
@@ -0,0 +1,478 @@
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
+
22
+ // Resolve binary paths from embedded-postgres platform packages
23
+ function getBinaryPaths() {
24
+ const platform = os.platform();
25
+ const arch = os.arch();
26
+
27
+ let pkgName;
28
+ if (platform === 'linux' && arch === 'x64') {
29
+ pkgName = '@embedded-postgres/linux-x64';
30
+ } else if (platform === 'darwin' && arch === 'arm64') {
31
+ pkgName = '@embedded-postgres/darwin-arm64';
32
+ } else if (platform === 'darwin' && arch === 'x64') {
33
+ pkgName = '@embedded-postgres/darwin-x64';
34
+ } else if (platform === 'win32' && arch === 'x64') {
35
+ pkgName = '@embedded-postgres/win32-x64';
36
+ } else {
37
+ throw new Error(`Unsupported platform: ${platform}-${arch}`);
38
+ }
39
+
40
+ // Find the package in node_modules
41
+ const possiblePaths = [
42
+ path.join(process.cwd(), 'node_modules', pkgName, 'native', 'bin'),
43
+ path.join(import.meta.dirname, '..', 'node_modules', pkgName, 'native', 'bin'),
44
+ ];
45
+
46
+ for (const binDir of possiblePaths) {
47
+ const initdb = path.join(binDir, platform === 'win32' ? 'initdb.exe' : 'initdb');
48
+ const postgres = path.join(binDir, platform === 'win32' ? 'postgres.exe' : 'postgres');
49
+ if (fs.existsSync(initdb) && fs.existsSync(postgres)) {
50
+ return { initdb, postgres, binDir };
51
+ }
52
+ }
53
+
54
+ throw new Error(`Could not find PostgreSQL binaries. Please run: npm install ${pkgName}`);
55
+ }
56
+
57
+ export class PostgresManager {
58
+ constructor(options = {}) {
59
+ this.dataDir = options.dataDir || null; // null = memory mode (temp dir)
60
+ this.port = options.port || 5433; // Internal PG port (router listens on different port)
61
+ this.user = options.user || 'postgres';
62
+ this.password = options.password || 'postgres';
63
+ this.logger = options.logger;
64
+ this.process = null;
65
+ this.databaseDir = null;
66
+ this.persistent = !!options.dataDir;
67
+ this.createdDatabases = new Set();
68
+ this.binaries = null;
69
+ this.creatingDatabases = new Map(); // Track in-progress creations
70
+ this.socketDir = null; // Unix socket directory for faster local connections
71
+ this.adminPool = null; // Connection pool for database admin operations
72
+
73
+ // Sync/Replication options (for async sync to real PostgreSQL)
74
+ this.syncEnabled = options.syncEnabled || false;
75
+ this.syncManager = null; // Will be set via setSyncManager()
76
+ }
77
+
78
+ /**
79
+ * Set the SyncManager for async replication
80
+ * Called after PostgresManager is created but before start()
81
+ * @param {SyncManager} syncManager
82
+ */
83
+ setSyncManager(syncManager) {
84
+ this.syncManager = syncManager;
85
+ this.syncEnabled = !!syncManager;
86
+ }
87
+
88
+ /**
89
+ * Start the embedded PostgreSQL instance
90
+ */
91
+ async start() {
92
+ // Get binary paths
93
+ this.binaries = getBinaryPaths();
94
+
95
+ // Make binaries executable
96
+ await fs.promises.chmod(this.binaries.initdb, '755');
97
+ await fs.promises.chmod(this.binaries.postgres, '755');
98
+
99
+ // Determine data directory
100
+ if (this.persistent) {
101
+ this.databaseDir = this.dataDir;
102
+ // Ensure directory exists
103
+ if (!fs.existsSync(this.databaseDir)) {
104
+ fs.mkdirSync(this.databaseDir, { recursive: true });
105
+ }
106
+ } else {
107
+ // Memory mode: use temp directory with unique suffix
108
+ this.databaseDir = path.join(os.tmpdir(), `pgserve-${process.pid}-${Date.now()}`);
109
+ // Clean up if exists from a previous failed run
110
+ if (fs.existsSync(this.databaseDir)) {
111
+ fs.rmSync(this.databaseDir, { recursive: true, force: true });
112
+ }
113
+ }
114
+
115
+ // Create Unix socket directory (Linux/macOS only, Windows uses TCP)
116
+ if (os.platform() !== 'win32') {
117
+ this.socketDir = path.join(os.tmpdir(), `pgserve-sock-${process.pid}-${Date.now()}`);
118
+ if (!fs.existsSync(this.socketDir)) {
119
+ fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
120
+ }
121
+ }
122
+
123
+ this.logger.info({
124
+ databaseDir: this.databaseDir,
125
+ persistent: this.persistent,
126
+ port: this.port
127
+ }, 'Starting embedded PostgreSQL');
128
+
129
+ // Check if data directory is already initialized
130
+ const pgVersionFile = path.join(this.databaseDir, 'PG_VERSION');
131
+ if (!fs.existsSync(pgVersionFile)) {
132
+ await this._runInitDb();
133
+ } else {
134
+ this.logger.debug({ databaseDir: this.databaseDir }, 'Using existing data directory');
135
+ }
136
+
137
+ // Start PostgreSQL server
138
+ await this._startPostgres();
139
+
140
+ // Initialize admin connection pool (for database creation operations)
141
+ await this._initAdminPool();
142
+
143
+ this.logger.info({
144
+ databaseDir: this.databaseDir,
145
+ port: this.port,
146
+ socketDir: this.socketDir,
147
+ persistent: this.persistent
148
+ }, 'PostgreSQL started successfully');
149
+
150
+ return this;
151
+ }
152
+
153
+ /**
154
+ * Run initdb to initialize the data directory
155
+ */
156
+ async _runInitDb() {
157
+ // Create password file
158
+ const randomId = crypto.randomBytes(6).toString('hex');
159
+ const passwordFile = path.join(os.tmpdir(), `pg-password-${randomId}`);
160
+ await fs.promises.writeFile(passwordFile, this.password + '\n');
161
+
162
+ this.logger.debug({ databaseDir: this.databaseDir }, 'Initializing PostgreSQL data directory');
163
+
164
+ return new Promise((resolve, reject) => {
165
+ const proc = spawn(this.binaries.initdb, [
166
+ `--pgdata=${this.databaseDir}`,
167
+ '--auth=password',
168
+ `--username=${this.user}`,
169
+ `--pwfile=${passwordFile}`,
170
+ ], {
171
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' }
172
+ });
173
+
174
+ let stdout = '';
175
+ let stderr = '';
176
+
177
+ proc.stdout.on('data', (data) => {
178
+ stdout += data.toString();
179
+ });
180
+
181
+ proc.stderr.on('data', (data) => {
182
+ stderr += data.toString();
183
+ });
184
+
185
+ proc.on('close', async (code) => {
186
+ // Clean up password file
187
+ try {
188
+ await fs.promises.unlink(passwordFile);
189
+ } catch {
190
+ // Ignore cleanup errors
191
+ }
192
+
193
+ if (code === 0) {
194
+ this.logger.debug('initdb completed successfully');
195
+ resolve();
196
+ } else {
197
+ this.logger.error({ code, stdout, stderr }, 'initdb failed');
198
+ reject(new Error(`initdb failed with code ${code}: ${stderr || stdout}`));
199
+ }
200
+ });
201
+
202
+ proc.on('error', (err) => {
203
+ reject(new Error(`Failed to spawn initdb: ${err.message}`));
204
+ });
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Initialize admin connection pool for database operations
210
+ * Uses Unix socket when available for faster connections
211
+ */
212
+ async _initAdminPool() {
213
+ const { default: pg } = await import('pg');
214
+
215
+ // Pool config - use Unix socket when available
216
+ const poolConfig = {
217
+ user: this.user,
218
+ password: this.password,
219
+ database: 'postgres',
220
+ max: 5, // Small pool - only for CREATE DATABASE operations
221
+ idleTimeoutMillis: 30000,
222
+ connectionTimeoutMillis: 5000,
223
+ };
224
+
225
+ // Use Unix socket for faster local connections (Linux/macOS)
226
+ // Note: pg library needs both host (socket dir) AND port to find the socket file
227
+ if (this.socketDir) {
228
+ poolConfig.host = this.socketDir;
229
+ poolConfig.port = this.port; // Required for Unix socket path construction
230
+ } else {
231
+ poolConfig.host = '127.0.0.1';
232
+ poolConfig.port = this.port;
233
+ }
234
+
235
+ this.adminPool = new pg.Pool(poolConfig);
236
+
237
+ // Verify pool is working
238
+ const client = await this.adminPool.connect();
239
+ client.release();
240
+
241
+ this.logger.debug({
242
+ host: poolConfig.host,
243
+ maxConnections: poolConfig.max
244
+ }, 'Admin connection pool initialized');
245
+ }
246
+
247
+ /**
248
+ * Start the PostgreSQL server process
249
+ */
250
+ async _startPostgres() {
251
+ return new Promise((resolve, reject) => {
252
+ // Build PostgreSQL arguments
253
+ const pgArgs = [
254
+ '-D', this.databaseDir,
255
+ '-p', this.port.toString(),
256
+ ];
257
+
258
+ // Enable Unix socket for faster local connections (Linux/macOS)
259
+ // Windows falls back to TCP only
260
+ if (this.socketDir) {
261
+ pgArgs.push('-k', this.socketDir);
262
+ } else {
263
+ pgArgs.push('-k', ''); // Disable Unix socket on Windows
264
+ }
265
+
266
+ // Add logical replication settings when sync is enabled
267
+ // These settings enable PostgreSQL's native WAL-based replication
268
+ // with ZERO hot path impact (handled by PostgreSQL's WAL writer process)
269
+ if (this.syncEnabled) {
270
+ pgArgs.push(
271
+ '-c', 'wal_level=logical', // Enable logical decoding
272
+ '-c', 'max_replication_slots=10', // Support multiple subscriptions
273
+ '-c', 'max_wal_senders=10', // Parallel replication streams
274
+ '-c', 'wal_keep_size=512MB', // Retain WAL for catchup
275
+ );
276
+ this.logger.info('Logical replication enabled for sync');
277
+ }
278
+
279
+ this.process = spawn(this.binaries.postgres, pgArgs, {
280
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' }
281
+ });
282
+
283
+ let started = false;
284
+ let startupOutput = '';
285
+
286
+ const onData = (data) => {
287
+ const message = data.toString();
288
+ startupOutput += message;
289
+ this.logger.debug({ pgOutput: message.trim() }, 'PostgreSQL output');
290
+
291
+ // Check for ready message
292
+ if (message.includes('database system is ready to accept connections') ||
293
+ message.includes('ready to accept connections')) {
294
+ started = true;
295
+ resolve();
296
+ }
297
+ };
298
+
299
+ this.process.stderr.on('data', onData);
300
+ this.process.stdout.on('data', onData);
301
+
302
+ this.process.on('close', (code) => {
303
+ if (!started) {
304
+ reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
305
+ }
306
+ this.process = null;
307
+ });
308
+
309
+ this.process.on('error', (err) => {
310
+ reject(new Error(`Failed to spawn postgres: ${err.message}`));
311
+ });
312
+
313
+ // Timeout after 30 seconds
314
+ setTimeout(() => {
315
+ if (!started) {
316
+ reject(new Error(`PostgreSQL startup timed out after 30s. Output: ${startupOutput}`));
317
+ }
318
+ }, 30000);
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Create a database if it doesn't exist
324
+ * Uses a promise-based lock to prevent race conditions
325
+ * @param {string} dbName - Database name to create
326
+ */
327
+ async createDatabase(dbName) {
328
+ // Skip if already created this session
329
+ if (this.createdDatabases.has(dbName)) {
330
+ return;
331
+ }
332
+
333
+ // Skip 'postgres' database - it always exists
334
+ if (dbName === 'postgres') {
335
+ this.createdDatabases.add(dbName);
336
+ return;
337
+ }
338
+
339
+ // Check if creation is already in progress for this database
340
+ // If so, wait for it to complete
341
+ if (this.creatingDatabases.has(dbName)) {
342
+ await this.creatingDatabases.get(dbName);
343
+ return;
344
+ }
345
+
346
+ // Create a promise that other concurrent requests will wait on
347
+ let resolveCreation;
348
+ const creationPromise = new Promise((resolve) => {
349
+ resolveCreation = resolve;
350
+ });
351
+ this.creatingDatabases.set(dbName, creationPromise);
352
+
353
+ // Use pooled connection for faster database creation
354
+ let createError = null;
355
+ const client = await this.adminPool.connect();
356
+ try {
357
+ await client.query(`CREATE DATABASE ${client.escapeIdentifier(dbName)}`);
358
+ this.createdDatabases.add(dbName);
359
+ this.logger.info({ dbName }, 'Database created');
360
+
361
+ // Trigger async sync setup (non-blocking, doesn't affect hot path)
362
+ if (this.syncManager) {
363
+ this.syncManager.setupDatabaseSync(dbName)
364
+ .catch(err => this.logger.warn({ dbName, err: err.message }, 'Sync setup failed (non-fatal)'));
365
+ }
366
+ } catch (error) {
367
+ // Database might already exist (from previous persistent session or race condition)
368
+ // 42P04 = duplicate_database, 23505 = unique_violation
369
+ if (error.code === '42P04' || error.code === '23505') {
370
+ this.createdDatabases.add(dbName);
371
+ this.logger.debug({ dbName }, 'Database already exists');
372
+ } else {
373
+ createError = error;
374
+ }
375
+ } finally {
376
+ client.release();
377
+ // Signal completion to waiting requests
378
+ this.creatingDatabases.delete(dbName);
379
+ resolveCreation();
380
+ }
381
+
382
+ if (createError) {
383
+ throw new Error(`Failed to create database '${dbName}': ${createError.message}`);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Check if a database exists
389
+ * @param {string} dbName - Database name to check
390
+ */
391
+ async databaseExists(dbName) {
392
+ return this.createdDatabases.has(dbName);
393
+ }
394
+
395
+ /**
396
+ * Stop the PostgreSQL instance
397
+ */
398
+ async stop() {
399
+ // Close admin pool first
400
+ if (this.adminPool) {
401
+ await this.adminPool.end();
402
+ this.adminPool = null;
403
+ }
404
+
405
+ if (this.process) {
406
+ this.logger.info('Stopping PostgreSQL');
407
+
408
+ return new Promise((resolve) => {
409
+ this.process.on('close', () => {
410
+ this.process = null;
411
+
412
+ // Clean up temp directory in memory mode
413
+ if (!this.persistent && this.databaseDir) {
414
+ try {
415
+ fs.rmSync(this.databaseDir, { recursive: true, force: true });
416
+ this.logger.debug({ databaseDir: this.databaseDir }, 'Cleaned up temp directory');
417
+ } catch (error) {
418
+ this.logger.warn({ error: error.message }, 'Failed to clean up temp directory');
419
+ }
420
+ }
421
+
422
+ // Clean up socket directory
423
+ if (this.socketDir) {
424
+ try {
425
+ fs.rmSync(this.socketDir, { recursive: true, force: true });
426
+ this.logger.debug({ socketDir: this.socketDir }, 'Cleaned up socket directory');
427
+ } catch (error) {
428
+ this.logger.warn({ error: error.message }, 'Failed to clean up socket directory');
429
+ }
430
+ }
431
+
432
+ resolve();
433
+ });
434
+
435
+ // Send SIGINT for graceful shutdown
436
+ this.process.kill('SIGINT');
437
+
438
+ // Force kill after 5 seconds
439
+ setTimeout(() => {
440
+ if (this.process) {
441
+ this.process.kill('SIGKILL');
442
+ }
443
+ }, 5000);
444
+ });
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Get the Unix socket path for PostgreSQL connections
450
+ * Returns null on Windows (use TCP instead)
451
+ */
452
+ getSocketPath() {
453
+ if (!this.socketDir) return null;
454
+ return path.join(this.socketDir, `.s.PGSQL.${this.port}`);
455
+ }
456
+
457
+ /**
458
+ * Get connection URL for a specific database
459
+ * @param {string} dbName - Database name
460
+ */
461
+ getConnectionUrl(dbName = 'postgres') {
462
+ return `postgresql://${this.user}:${this.password}@127.0.0.1:${this.port}/${dbName}`;
463
+ }
464
+
465
+ /**
466
+ * Get manager stats
467
+ */
468
+ getStats() {
469
+ return {
470
+ port: this.port,
471
+ databaseDir: this.databaseDir,
472
+ socketDir: this.socketDir,
473
+ socketPath: this.getSocketPath(),
474
+ persistent: this.persistent,
475
+ databases: Array.from(this.createdDatabases)
476
+ };
477
+ }
478
+ }