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/router.js CHANGED
@@ -1,19 +1,22 @@
1
1
  /**
2
- * Multi-Tenant Router (Performance Optimized)
2
+ * Multi-Tenant Router (TCP Proxy to Embedded PostgreSQL)
3
3
  *
4
- * Single TCP server that routes connections to different PGlite instances
5
- * based on database name from PostgreSQL connection string
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.
6
7
  *
7
- * Performance Optimizations:
8
- * - Pino logger (5x faster than console.log)
9
- * - TCP socket optimizations (nodelay, keepalive)
10
- * - Minimal event emitter overhead
11
- * - Optimized connection tracking
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
12
13
  */
13
14
 
14
15
  import net from 'net';
15
- import { PGLiteSocketHandler } from '@electric-sql/pglite-socket';
16
- import { InstancePool } from './pool.js';
16
+ import { PostgresManager } from './postgres.js';
17
+ import { SyncManager } from './sync.js';
18
+ import { RestoreManager } from './restore.js';
19
+ import { Dashboard } from './dashboard.js';
17
20
  import { extractDatabaseNameFromSocket } from './protocol.js';
18
21
  import { EventEmitter } from 'events';
19
22
  import pino from 'pino';
@@ -24,13 +27,15 @@ import pino from 'pino';
24
27
  export class MultiTenantRouter extends EventEmitter {
25
28
  constructor(options = {}) {
26
29
  super();
27
- this.port = options.port || 8432;
30
+ this.port = options.port || 5432;
28
31
  this.host = options.host || '127.0.0.1';
29
- this.baseDir = options.baseDir || './data';
30
- this.memoryMode = options.memoryMode || false;
31
- this.maxInstances = options.maxInstances || 100;
32
+ this.baseDir = options.baseDir || null; // null = memory mode
33
+ this.memoryMode = !options.baseDir;
34
+ this.maxConnections = options.maxConnections || 1000;
32
35
  this.autoProvision = options.autoProvision !== false;
33
- this.inspect = options.inspect || false;
36
+
37
+ // Internal PostgreSQL port (different from router port)
38
+ this.pgPort = options.pgPort || (this.port + 1000);
34
39
 
35
40
  // Pino logger (ultra-fast structured logging)
36
41
  const logLevel = options.logLevel || 'info';
@@ -42,13 +47,19 @@ export class MultiTenantRouter extends EventEmitter {
42
47
  } : undefined
43
48
  });
44
49
 
45
- // Instance pool
46
- this.pool = new InstancePool({
47
- baseDir: this.baseDir,
48
- memoryMode: this.memoryMode,
49
- maxInstances: this.maxInstances,
50
- autoProvision: this.autoProvision,
51
- logger: this.logger.child({ component: 'pool' })
50
+ // Sync options (async replication to real PostgreSQL)
51
+ this.syncTo = options.syncTo || null;
52
+ this.syncDatabases = options.syncDatabases
53
+ ? options.syncDatabases.split(',').map(s => s.trim())
54
+ : [];
55
+ this.syncManager = null;
56
+
57
+ // PostgreSQL manager (with sync flag if needed)
58
+ this.pgManager = new PostgresManager({
59
+ dataDir: this.baseDir,
60
+ port: this.pgPort,
61
+ logger: this.logger.child({ component: 'postgres' }),
62
+ syncEnabled: !!this.syncTo // Enable logical replication if sync is configured
52
63
  });
53
64
 
54
65
  // TCP server
@@ -56,21 +67,7 @@ export class MultiTenantRouter extends EventEmitter {
56
67
  this.connections = new Set();
57
68
 
58
69
  // Performance: Reduce event listener overhead
59
- this.setMaxListeners(this.maxInstances + 10);
60
-
61
- // Forward pool events (optimized logging)
62
- this.pool.on('instance-created', (dbName) => {
63
- this.logger.info({ dbName }, 'Database created');
64
- this.emit('database-created', dbName);
65
- });
66
-
67
- this.pool.on('instance-locked', (dbName) => {
68
- this.logger.debug({ dbName }, 'Database locked');
69
- });
70
-
71
- this.pool.on('instance-unlocked', (dbName) => {
72
- this.logger.debug({ dbName }, 'Database unlocked');
73
- });
70
+ this.setMaxListeners(this.maxConnections + 10);
74
71
  }
75
72
 
76
73
  /**
@@ -84,16 +81,6 @@ export class MultiTenantRouter extends EventEmitter {
84
81
  // Enable TCP keepalive (detect dead connections)
85
82
  socket.setKeepAlive(true, 60000); // 60s initial delay
86
83
 
87
- // Increase socket buffer sizes for better throughput
88
- // Note: These are hints to OS, actual values may differ
89
- try {
90
- socket.setRecvBufferSize && socket.setRecvBufferSize(128 * 1024); // 128KB
91
- socket.setSendBufferSize && socket.setSendBufferSize(128 * 1024); // 128KB
92
- } catch (err) {
93
- // Ignore if not supported
94
- this.logger.debug({ err }, 'Could not set socket buffer sizes');
95
- }
96
-
97
84
  // Prevent socket timeout during long-running queries
98
85
  socket.setTimeout(0);
99
86
  }
@@ -102,19 +89,87 @@ export class MultiTenantRouter extends EventEmitter {
102
89
  * Start multi-tenant router
103
90
  */
104
91
  async start() {
105
- return new Promise((resolve, reject) => {
106
- // Create TCP server with optimizations
92
+ // Initialize dashboard for informative CLI output
93
+ const dashboard = new Dashboard();
94
+ dashboard.showHeader({
95
+ port: this.port,
96
+ host: this.host,
97
+ memoryMode: this.memoryMode,
98
+ syncTo: this.syncTo
99
+ });
100
+
101
+ // Start PostgreSQL first
102
+ dashboard.stage('PostgreSQL binaries resolved');
103
+ await this.pgManager.start();
104
+ dashboard.stage('PostgreSQL started');
105
+
106
+ // Automatic restore from external PostgreSQL (if sync configured)
107
+ // This runs BEFORE SyncManager to restore data before enabling outbound sync
108
+ if (this.syncTo) {
109
+ const restoreManager = new RestoreManager({
110
+ sourceUrl: this.syncTo,
111
+ patterns: this.syncDatabases,
112
+ targetPort: this.pgPort,
113
+ targetSocketPath: this.pgManager.getSocketPath(),
114
+ logger: this.logger.child({ component: 'restore' }),
115
+ onProgress: (metrics) => dashboard.updateRestore(metrics)
116
+ });
117
+
118
+ // Start restore progress display
119
+ dashboard.startRestore(restoreManager.totalDatabases || 1);
120
+
121
+ const restoreResult = await restoreManager.restore(this.pgManager);
122
+
123
+ if (restoreResult.success) {
124
+ dashboard.completeRestore(restoreResult.metrics);
125
+ this.logger.info({
126
+ databases: restoreResult.metrics.databasesRestored,
127
+ tables: restoreResult.metrics.tablesRestored,
128
+ rows: restoreResult.metrics.rowsRestored,
129
+ bytes: restoreResult.metrics.bytesTransferred,
130
+ durationMs: restoreResult.metrics.endTime - restoreResult.metrics.startTime
131
+ }, 'Restored from external PostgreSQL');
132
+ } else if (restoreResult.skipped) {
133
+ // No progress to complete - was skipped
134
+ } else {
135
+ dashboard.cleanup();
136
+ this.logger.warn({ error: restoreResult.error }, 'Restore failed (continuing without restored data)');
137
+ }
138
+ }
139
+
140
+ // Initialize SyncManager if configured (async replication)
141
+ if (this.syncTo) {
142
+ this.syncManager = new SyncManager({
143
+ targetUrl: this.syncTo,
144
+ databases: this.syncDatabases,
145
+ sourcePort: this.pgPort,
146
+ sourceSocketPath: this.pgManager.getSocketPath(),
147
+ logLevel: this.logger.level
148
+ });
149
+
150
+ // Wire SyncManager to PostgresManager for database creation hooks
151
+ this.pgManager.setSyncManager(this.syncManager);
152
+
153
+ // Initialize sync connections (non-blocking, runs in background)
154
+ this.syncManager.initialize(this.pgManager)
155
+ .then(() => {
156
+ dashboard.stage('Sync manager initialized');
157
+ this.logger.info('Sync manager initialized');
158
+ })
159
+ .catch(err => this.logger.warn({ err: err.message }, 'Sync manager initialization failed (non-fatal)'));
160
+ }
161
+
162
+ return new Promise((resolve, _reject) => {
163
+ // Create TCP server
107
164
  this.server = net.createServer({
108
- // Performance: Allow half-open sockets (faster cleanup)
109
165
  allowHalfOpen: false,
110
- // Performance: Pause on connect (manual resume after setup)
111
166
  pauseOnConnect: true
112
167
  }, async (socket) => {
113
168
  await this.handleConnection(socket);
114
169
  });
115
170
 
116
- // Set max connections (system limit)
117
- this.server.maxConnections = this.maxInstances * 2;
171
+ // Set max connections
172
+ this.server.maxConnections = this.maxConnections;
118
173
 
119
174
  // Error handling
120
175
  this.server.on('error', (error) => {
@@ -124,13 +179,20 @@ export class MultiTenantRouter extends EventEmitter {
124
179
 
125
180
  // Start listening
126
181
  this.server.listen(this.port, this.host, () => {
182
+ const socketPath = this.pgManager.getSocketPath();
183
+
184
+ dashboard.stage('TCP server listening');
185
+ dashboard.showReady({ port: this.port, host: this.host });
186
+
127
187
  this.logger.info({
128
188
  host: this.host,
129
189
  port: this.port,
190
+ pgPort: this.pgPort,
191
+ pgSocketPath: socketPath || '(TCP)',
130
192
  baseDir: this.memoryMode ? '(in-memory)' : this.baseDir,
131
193
  memoryMode: this.memoryMode,
132
194
  autoProvision: this.autoProvision,
133
- maxInstances: this.maxInstances
195
+ maxConnections: this.maxConnections
134
196
  }, 'Multi-tenant router started');
135
197
 
136
198
  this.emit('listening');
@@ -140,91 +202,81 @@ export class MultiTenantRouter extends EventEmitter {
140
202
  }
141
203
 
142
204
  /**
143
- * Handle incoming connection (Performance Optimized)
205
+ * Handle incoming connection (TCP Proxy)
206
+ * OPTIMIZED: Removed hot path logging for performance
144
207
  */
145
208
  async handleConnection(socket) {
146
- const connId = `${socket.remoteAddress}:${socket.remotePort}`;
147
- const startTime = Date.now();
148
-
149
- // Optimize socket BEFORE any I/O
150
- this.optimizeSocket(socket);
151
-
152
209
  // Track connection
153
210
  this.connections.add(socket);
154
211
 
155
- // NOTE: Don't resume here - let readStartupMessage() resume after setting up listeners
156
- // This prevents race condition where data arrives before listener is attached
212
+ // Optimize socket BEFORE any I/O
213
+ this.optimizeSocket(socket);
157
214
 
158
215
  let dbName = null;
159
- let handler = null;
216
+ let pgSocket = null;
160
217
 
161
218
  try {
162
219
  // Extract database name from PostgreSQL handshake
163
- this.logger.debug({ connId }, 'Reading startup message');
164
220
  const { dbName: extractedDbName, buffered } = await extractDatabaseNameFromSocket(socket);
165
221
  dbName = extractedDbName;
166
222
 
167
- this.logger.info({ dbName, connId }, 'Connection request');
223
+ // Auto-provision database if needed
224
+ if (this.autoProvision) {
225
+ await this.pgManager.createDatabase(dbName);
226
+ }
168
227
 
169
- // Get or create PGlite instance (with locking)
170
- const instance = await this.pool.acquire(dbName, socket);
228
+ // Connect to real PostgreSQL (prefer Unix socket for speed)
229
+ const socketPath = this.pgManager.getSocketPath();
230
+ if (socketPath) {
231
+ // Unix socket connection (Linux/macOS) - ~30% faster than TCP
232
+ pgSocket = net.connect({ path: socketPath });
233
+ } else {
234
+ // TCP fallback (Windows)
235
+ pgSocket = net.connect({ host: '127.0.0.1', port: this.pgPort });
236
+ }
171
237
 
172
- const routingTime = Date.now() - startTime;
173
- this.logger.info({
174
- dbName,
175
- connId,
176
- dataDir: instance.dataDir,
177
- routingTimeMs: routingTime
178
- }, 'Routed to database');
238
+ // Wait for PostgreSQL connection
239
+ await new Promise((resolve, reject) => {
240
+ pgSocket.once('connect', resolve);
241
+ pgSocket.once('error', reject);
242
+ });
179
243
 
180
- // Push buffered data back to socket for handler to read
181
- socket.unshift(buffered);
244
+ this.optimizeSocket(pgSocket);
182
245
 
183
- // Create handler for this connection
184
- handler = new PGLiteSocketHandler({
185
- db: instance.db,
186
- closeOnDetach: true,
187
- inspect: this.inspect
188
- });
246
+ // Send the buffered startup message to PostgreSQL
247
+ pgSocket.write(buffered);
189
248
 
190
- // Attach socket to handler
191
- await handler.attach(socket);
249
+ // Resume client socket (was paused on connect)
250
+ socket.resume();
192
251
 
193
- this.logger.debug({ dbName, connId }, 'Socket attached');
252
+ // Bidirectional pipe (TRUE proxy)
253
+ socket.pipe(pgSocket);
254
+ pgSocket.pipe(socket);
194
255
 
195
- // Handle socket close (cleanup)
256
+ // Handle cleanup - optimized: single handler, no logging in hot path
196
257
  const cleanup = () => {
197
- this.logger.debug({ dbName, connId }, 'Connection closed');
198
- if (handler) {
199
- handler.detach();
200
- }
201
258
  this.connections.delete(socket);
202
- // Note: Don't call socket.removeAllListeners() here as it removes
203
- // the pool's unlock handlers before they can fire, causing stuck locks
259
+ if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
260
+ if (socket && !socket.destroyed) socket.destroy();
204
261
  };
205
262
 
206
263
  socket.once('close', cleanup);
207
- socket.once('error', (error) => {
208
- this.logger.warn({ dbName, connId, err: error }, 'Socket error');
209
- cleanup();
264
+ socket.once('error', cleanup);
265
+ pgSocket.once('close', () => {
266
+ if (socket && !socket.destroyed) socket.destroy();
210
267
  });
268
+ pgSocket.once('error', cleanup);
211
269
 
212
- this.emit('connection', { dbName, socket, connId });
270
+ this.emit('connection', { dbName, socket });
213
271
  } catch (error) {
214
- this.logger.error({ dbName, connId, err: error }, 'Connection error');
272
+ // Only log actual errors
273
+ this.logger.error({ dbName, err: error }, 'Connection error');
215
274
 
216
275
  // Cleanup
217
- if (handler) {
218
- try {
219
- handler.detach();
220
- } catch (detachErr) {
221
- this.logger.debug({ err: detachErr }, 'Error detaching handler');
222
- }
223
- }
224
-
225
- socket.destroy(); // Force close on error
276
+ if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
277
+ socket.destroy();
226
278
  this.connections.delete(socket);
227
- this.emit('connection-error', { error, dbName, connId });
279
+ this.emit('connection-error', { error, dbName });
228
280
  }
229
281
  }
230
282
 
@@ -237,7 +289,7 @@ export class MultiTenantRouter extends EventEmitter {
237
289
  // Close all connections gracefully
238
290
  const activeConns = this.connections.size;
239
291
  for (const socket of this.connections) {
240
- socket.end(); // Graceful close (vs destroy())
292
+ socket.end();
241
293
  }
242
294
  this.connections.clear();
243
295
 
@@ -248,12 +300,16 @@ export class MultiTenantRouter extends EventEmitter {
248
300
  });
249
301
  }
250
302
 
251
- // Close all PGlite instances
252
- await this.pool.closeAll();
303
+ // Stop SyncManager first (before PostgreSQL)
304
+ if (this.syncManager) {
305
+ await this.syncManager.stop();
306
+ }
307
+
308
+ // Stop PostgreSQL
309
+ await this.pgManager.stop();
253
310
 
254
311
  this.logger.info({
255
- activeConnections: activeConns,
256
- closedInstances: this.pool.instances.size
312
+ activeConnections: activeConns
257
313
  }, 'Router stopped');
258
314
 
259
315
  this.emit('stopped');
@@ -266,8 +322,9 @@ export class MultiTenantRouter extends EventEmitter {
266
322
  return {
267
323
  port: this.port,
268
324
  host: this.host,
325
+ pgPort: this.pgPort,
269
326
  activeConnections: this.connections.size,
270
- pool: this.pool.getStats()
327
+ postgres: this.pgManager.getStats()
271
328
  };
272
329
  }
273
330
 
@@ -275,7 +332,7 @@ export class MultiTenantRouter extends EventEmitter {
275
332
  * List all databases
276
333
  */
277
334
  listDatabases() {
278
- return this.pool.list();
335
+ return this.pgManager.getStats().databases;
279
336
  }
280
337
  }
281
338