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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "0.1.4",
4
- "description": "Multi-instance PostgreSQL embedded server using PGlite - zero config, auto-port allocation, perfect for development and embedded apps",
3
+ "version": "1.0.1",
4
+ "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "bin": {
@@ -12,14 +12,12 @@
12
12
  "test": "node --test tests/**/*.test.js"
13
13
  },
14
14
  "keywords": [
15
- "pglite",
16
15
  "postgresql",
17
16
  "embedded",
18
17
  "database",
19
- "wasm",
20
- "electron",
18
+ "postgres",
21
19
  "development",
22
- "multi-instance"
20
+ "multi-tenant"
23
21
  ],
24
22
  "author": "Namastex Labs <labs@namastex.com>",
25
23
  "license": "MIT",
@@ -32,14 +30,19 @@
32
30
  },
33
31
  "homepage": "https://github.com/namastexlabs/pgserve#readme",
34
32
  "dependencies": {
35
- "@electric-sql/pglite": "^0.3.14",
36
- "@electric-sql/pglite-socket": "^0.0.19",
33
+ "pg": "^8.16.3",
37
34
  "pino": "^10.1.0",
38
35
  "pino-pretty": "^13.1.2"
39
36
  },
37
+ "optionalDependencies": {
38
+ "@embedded-postgres/linux-x64": "17.7.0-beta.15",
39
+ "@embedded-postgres/darwin-arm64": "17.7.0-beta.15",
40
+ "@embedded-postgres/darwin-x64": "17.7.0-beta.15",
41
+ "@embedded-postgres/win32-x64": "17.7.0-beta.15"
42
+ },
40
43
  "devDependencies": {
41
- "better-sqlite3": "^11.10.0",
42
- "pg": "^8.16.3"
44
+ "better-sqlite3": "^11.7.0",
45
+ "@electric-sql/pglite": "^0.2.17"
43
46
  },
44
47
  "engines": {
45
48
  "node": ">=18.0.0"
package/src/cluster.js ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Cluster Mode for pgserve
3
+ *
4
+ * Architecture:
5
+ * - PRIMARY process: Runs single embedded PostgreSQL instance
6
+ * - WORKER processes: Only run TCP routing to PRIMARY's PostgreSQL
7
+ *
8
+ * This enables multi-core scaling (3-5x throughput on multi-core systems)
9
+ * while maintaining a single PostgreSQL instance.
10
+ */
11
+
12
+ import cluster from 'cluster';
13
+ import os from 'os';
14
+ import net from 'net';
15
+ import pg from 'pg';
16
+ import pino from 'pino';
17
+ import { PostgresManager } from './postgres.js';
18
+ import { extractDatabaseNameFromSocket } from './protocol.js';
19
+ import { EventEmitter } from 'events';
20
+
21
+ /**
22
+ * ClusterRouter - Lightweight TCP router for worker processes
23
+ * Does NOT start PostgreSQL - connects to PRIMARY's PostgreSQL via Unix socket
24
+ */
25
+ class ClusterRouter extends EventEmitter {
26
+ constructor(options = {}) {
27
+ super();
28
+ this.port = options.port || 5432;
29
+ this.host = options.host || '127.0.0.1';
30
+ this.pgSocketPath = options.pgSocketPath; // From PRIMARY
31
+ this.pgPort = options.pgPort;
32
+ this.pgUser = options.pgUser || 'postgres';
33
+ this.pgPassword = options.pgPassword || 'postgres';
34
+ this.autoProvision = options.autoProvision !== false;
35
+ this.maxConnections = options.maxConnections || 1000;
36
+
37
+ this.logger = pino({ level: options.logLevel || 'info' });
38
+ this.adminClient = null;
39
+ this.server = null;
40
+ this.connections = new Set();
41
+ this.setMaxListeners(this.maxConnections + 10);
42
+ }
43
+
44
+ optimizeSocket(socket) {
45
+ socket.setNoDelay(true);
46
+ socket.setKeepAlive(true, 60000);
47
+ socket.setTimeout(0);
48
+ }
49
+
50
+ async start() {
51
+ // Admin connection for auto-provisioning databases
52
+ if (this.autoProvision) {
53
+ let connectionConfig;
54
+ if (this.pgSocketPath) {
55
+ // pg library expects socket DIRECTORY as host, it appends .s.PGSQL.<port>
56
+ // Socket path format: /tmp/pgserve-sock-xxx/.s.PGSQL.<port>
57
+ // Extract directory by removing the socket file suffix
58
+ const socketDir = this.pgSocketPath.replace(/\/\.s\.PGSQL\.\d+$/, '');
59
+ connectionConfig = {
60
+ host: socketDir,
61
+ port: this.pgPort,
62
+ database: 'postgres',
63
+ user: this.pgUser,
64
+ password: this.pgPassword
65
+ };
66
+ } else {
67
+ connectionConfig = {
68
+ host: '127.0.0.1',
69
+ port: this.pgPort,
70
+ database: 'postgres',
71
+ user: this.pgUser,
72
+ password: this.pgPassword
73
+ };
74
+ }
75
+
76
+ this.adminClient = new pg.Client(connectionConfig);
77
+ await this.adminClient.connect();
78
+ }
79
+
80
+ return new Promise((resolve, reject) => {
81
+ this.server = net.createServer({
82
+ allowHalfOpen: false,
83
+ pauseOnConnect: true
84
+ }, (socket) => this.handleConnection(socket));
85
+
86
+ this.server.maxConnections = this.maxConnections;
87
+
88
+ this.server.on('error', (error) => {
89
+ this.logger.error({ err: error }, 'Router error');
90
+ this.emit('error', error);
91
+ });
92
+
93
+ this.server.listen(this.port, this.host, () => {
94
+ this.emit('listening');
95
+ resolve();
96
+ });
97
+ });
98
+ }
99
+
100
+ async createDatabase(dbName) {
101
+ if (!this.autoProvision || !this.adminClient) return;
102
+
103
+ try {
104
+ const result = await this.adminClient.query(
105
+ 'SELECT 1 FROM pg_database WHERE datname = $1',
106
+ [dbName]
107
+ );
108
+
109
+ if (result.rows.length === 0) {
110
+ await this.adminClient.query(`CREATE DATABASE "${dbName}"`);
111
+ }
112
+ } catch (error) {
113
+ // Ignore "already exists" (race condition between workers)
114
+ if (!error.message.includes('already exists')) {
115
+ this.logger.error({ database: dbName, err: error }, 'Failed to create database');
116
+ }
117
+ }
118
+ }
119
+
120
+ async handleConnection(socket) {
121
+ this.connections.add(socket);
122
+ this.optimizeSocket(socket);
123
+
124
+ let dbName = null;
125
+ let pgSocket = null;
126
+
127
+ try {
128
+ const { dbName: extractedDbName, buffered } = await extractDatabaseNameFromSocket(socket);
129
+ dbName = extractedDbName;
130
+
131
+ await this.createDatabase(dbName);
132
+
133
+ // Connect to PRIMARY's PostgreSQL
134
+ if (this.pgSocketPath) {
135
+ pgSocket = net.connect({ path: this.pgSocketPath });
136
+ } else {
137
+ pgSocket = net.connect({ host: '127.0.0.1', port: this.pgPort });
138
+ }
139
+
140
+ await new Promise((resolve, reject) => {
141
+ pgSocket.once('connect', resolve);
142
+ pgSocket.once('error', reject);
143
+ });
144
+
145
+ this.optimizeSocket(pgSocket);
146
+ pgSocket.write(buffered);
147
+ socket.resume();
148
+
149
+ // Bidirectional pipe
150
+ socket.pipe(pgSocket);
151
+ pgSocket.pipe(socket);
152
+
153
+ const cleanup = () => {
154
+ this.connections.delete(socket);
155
+ if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
156
+ if (socket && !socket.destroyed) socket.destroy();
157
+ };
158
+
159
+ socket.once('close', cleanup);
160
+ socket.once('error', cleanup);
161
+ pgSocket.once('close', () => {
162
+ if (socket && !socket.destroyed) socket.destroy();
163
+ });
164
+ pgSocket.once('error', cleanup);
165
+
166
+ } catch (error) {
167
+ this.logger.error({ dbName, err: error }, 'Connection error');
168
+ if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
169
+ socket.destroy();
170
+ this.connections.delete(socket);
171
+ }
172
+ }
173
+
174
+ async stop() {
175
+ for (const socket of this.connections) {
176
+ socket.end();
177
+ }
178
+ this.connections.clear();
179
+
180
+ if (this.adminClient) {
181
+ await this.adminClient.end();
182
+ }
183
+
184
+ if (this.server) {
185
+ await new Promise((resolve) => this.server.close(resolve));
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Start pgserve in cluster mode
192
+ */
193
+ export async function startClusterServer(options = {}) {
194
+ const numWorkers = options.workers || os.cpus().length;
195
+ const port = options.port || 5432;
196
+ const host = options.host || '127.0.0.1';
197
+ const pgPort = options.pgPort || (port + 1000);
198
+
199
+ if (cluster.isPrimary) {
200
+ console.log(`[pgserve] Cluster mode: ${numWorkers} workers`);
201
+
202
+ // PRIMARY: Start our embedded PostgreSQL (single instance)
203
+ const logger = pino({ level: options.logLevel || 'info' });
204
+ const pgManager = new PostgresManager({
205
+ dataDir: options.baseDir,
206
+ port: pgPort,
207
+ logger: logger.child({ component: 'postgres' })
208
+ });
209
+
210
+ await pgManager.start();
211
+ const pgSocketPath = pgManager.getSocketPath();
212
+
213
+ console.log(`[pgserve] Embedded PostgreSQL started`);
214
+ console.log(`[pgserve] Socket: ${pgSocketPath || `TCP port ${pgPort}`}`);
215
+
216
+ const workers = new Map();
217
+
218
+ // Fork workers with PostgreSQL connection info
219
+ for (let i = 0; i < numWorkers; i++) {
220
+ const worker = cluster.fork({
221
+ PGSERVE_WORKER: 'true',
222
+ PGSERVE_PORT: String(port),
223
+ PGSERVE_HOST: host,
224
+ PGSERVE_PG_SOCKET: pgSocketPath || '',
225
+ PGSERVE_PG_PORT: String(pgPort),
226
+ PGSERVE_PG_USER: 'postgres',
227
+ PGSERVE_PG_PASSWORD: 'postgres',
228
+ PGSERVE_LOG_LEVEL: options.logLevel || 'info',
229
+ PGSERVE_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false'
230
+ });
231
+ workers.set(worker.id, worker);
232
+ }
233
+
234
+ // Restart dead workers
235
+ cluster.on('exit', (worker, code, signal) => {
236
+ console.log(`[pgserve] Worker ${worker.id} died (${signal || code}), restarting...`);
237
+ workers.delete(worker.id);
238
+
239
+ const newWorker = cluster.fork({
240
+ PGSERVE_WORKER: 'true',
241
+ PGSERVE_PORT: String(port),
242
+ PGSERVE_HOST: host,
243
+ PGSERVE_PG_SOCKET: pgSocketPath || '',
244
+ PGSERVE_PG_PORT: String(pgPort),
245
+ PGSERVE_PG_USER: 'postgres',
246
+ PGSERVE_PG_PASSWORD: 'postgres',
247
+ PGSERVE_LOG_LEVEL: options.logLevel || 'info',
248
+ PGSERVE_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false'
249
+ });
250
+ workers.set(newWorker.id, newWorker);
251
+ });
252
+
253
+ // Wait for workers to be ready
254
+ let readyCount = 0;
255
+ await new Promise((resolve) => {
256
+ cluster.on('message', (worker, message) => {
257
+ if (message.type === 'ready') {
258
+ readyCount++;
259
+ if (readyCount === numWorkers) resolve();
260
+ }
261
+ });
262
+ });
263
+
264
+ console.log(`[pgserve] All ${numWorkers} workers ready`);
265
+ console.log(`[pgserve] Listening on ${host}:${port}`);
266
+
267
+ return {
268
+ workers,
269
+ pgPort,
270
+ pgSocketPath,
271
+ stop: async () => {
272
+ console.log('[pgserve] Stopping cluster...');
273
+ for (const worker of workers.values()) {
274
+ worker.send({ type: 'shutdown' });
275
+ }
276
+ await new Promise((resolve) => {
277
+ const check = setInterval(() => {
278
+ if (workers.size === 0) {
279
+ clearInterval(check);
280
+ resolve();
281
+ }
282
+ }, 100);
283
+ });
284
+ await pgManager.stop();
285
+ console.log('[pgserve] Cluster stopped');
286
+ },
287
+ getStats: () => ({
288
+ workers: workers.size,
289
+ pids: Array.from(workers.values()).map(w => w.process.pid)
290
+ })
291
+ };
292
+ } else {
293
+ // WORKER: Only run TCP routing, connect to PRIMARY's PostgreSQL
294
+ const router = new ClusterRouter({
295
+ port: parseInt(process.env.PGSERVE_PORT) || 5432,
296
+ host: process.env.PGSERVE_HOST || '127.0.0.1',
297
+ pgSocketPath: process.env.PGSERVE_PG_SOCKET || null,
298
+ pgPort: parseInt(process.env.PGSERVE_PG_PORT) || 6432,
299
+ pgUser: process.env.PGSERVE_PG_USER || 'postgres',
300
+ pgPassword: process.env.PGSERVE_PG_PASSWORD || 'postgres',
301
+ logLevel: process.env.PGSERVE_LOG_LEVEL || 'info',
302
+ autoProvision: process.env.PGSERVE_AUTO_PROVISION === 'true'
303
+ });
304
+
305
+ await router.start();
306
+
307
+ // Tell PRIMARY we're ready
308
+ process.send({ type: 'ready' });
309
+
310
+ // Handle shutdown
311
+ process.on('message', async (message) => {
312
+ if (message.type === 'shutdown') {
313
+ await router.stop();
314
+ process.exit(0);
315
+ }
316
+ });
317
+
318
+ return router;
319
+ }
320
+ }
321
+
322
+ export default startClusterServer;
package/src/index.js CHANGED
@@ -1,177 +1,14 @@
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';
11
12
 
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
- };
13
+ // Default export
14
+ export { startMultiTenantServer as default } from './router.js';