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,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sync Performance Impact Test
4
+ *
5
+ * Verifies that enabling sync has ZERO impact on hot path performance.
6
+ * Runs identical workloads with and without sync, compares results.
7
+ */
8
+
9
+ import { startMultiTenantServer } from '../src/index.js';
10
+ import pg from 'pg';
11
+
12
+ const ITERATIONS = 1000;
13
+ const WARMUP = 100;
14
+
15
+ async function runWorkload(port, label) {
16
+ const client = new pg.Client({
17
+ host: 'localhost',
18
+ port,
19
+ database: 'perftest',
20
+ user: 'postgres',
21
+ password: 'postgres'
22
+ });
23
+
24
+ await client.connect();
25
+
26
+ // Create table
27
+ await client.query('CREATE TABLE IF NOT EXISTS bench (id SERIAL PRIMARY KEY, data TEXT)');
28
+ await client.query('TRUNCATE bench');
29
+
30
+ // Warmup
31
+ for (let i = 0; i < WARMUP; i++) {
32
+ await client.query('INSERT INTO bench (data) VALUES ($1)', [`warmup-${i}`]);
33
+ }
34
+ await client.query('TRUNCATE bench');
35
+
36
+ // Benchmark
37
+ const start = process.hrtime.bigint();
38
+
39
+ for (let i = 0; i < ITERATIONS; i++) {
40
+ await client.query('INSERT INTO bench (data) VALUES ($1)', [`row-${i}`]);
41
+ }
42
+
43
+ const insertEnd = process.hrtime.bigint();
44
+ const insertMs = Number(insertEnd - start) / 1_000_000;
45
+
46
+ // Read benchmark
47
+ const readStart = process.hrtime.bigint();
48
+ for (let i = 0; i < ITERATIONS; i++) {
49
+ await client.query('SELECT * FROM bench WHERE id = $1', [i % ITERATIONS + 1]);
50
+ }
51
+ const readEnd = process.hrtime.bigint();
52
+ const readMs = Number(readEnd - readStart) / 1_000_000;
53
+
54
+ await client.end();
55
+
56
+ return {
57
+ label,
58
+ inserts: {
59
+ total: insertMs,
60
+ perOp: insertMs / ITERATIONS,
61
+ opsPerSec: Math.round(ITERATIONS / (insertMs / 1000))
62
+ },
63
+ reads: {
64
+ total: readMs,
65
+ perOp: readMs / ITERATIONS,
66
+ opsPerSec: Math.round(ITERATIONS / (readMs / 1000))
67
+ }
68
+ };
69
+ }
70
+
71
+ async function main() {
72
+ console.log('='.repeat(60));
73
+ console.log('SYNC PERFORMANCE IMPACT TEST');
74
+ console.log('='.repeat(60));
75
+ console.log(`Iterations: ${ITERATIONS} | Warmup: ${WARMUP}`);
76
+ console.log();
77
+
78
+ // Test 1: Without sync
79
+ console.log('[1/2] Starting pgserve WITHOUT sync...');
80
+ const serverNoSync = await startMultiTenantServer({
81
+ port: 18001,
82
+ logLevel: 'error'
83
+ });
84
+
85
+ await new Promise(r => setTimeout(r, 2000)); // Wait for server
86
+ const resultNoSync = await runWorkload(18001, 'NO SYNC');
87
+ await serverNoSync.stop();
88
+
89
+ console.log(' Done.');
90
+ console.log();
91
+
92
+ // Test 2: With sync enabled (failing target - simulates sync overhead)
93
+ console.log('[2/2] Starting pgserve WITH sync (failing target)...');
94
+ const serverWithSync = await startMultiTenantServer({
95
+ port: 18002,
96
+ logLevel: 'error',
97
+ syncTo: 'postgresql://dummy:dummy@localhost:59999/fake', // Intentionally failing
98
+ syncDatabases: 'perftest'
99
+ });
100
+
101
+ await new Promise(r => setTimeout(r, 2000)); // Wait for server
102
+ const resultWithSync = await runWorkload(18002, 'WITH SYNC');
103
+ await serverWithSync.stop();
104
+
105
+ console.log(' Done.');
106
+ console.log();
107
+
108
+ // Results
109
+ console.log('='.repeat(60));
110
+ console.log('RESULTS');
111
+ console.log('='.repeat(60));
112
+ console.log();
113
+
114
+ console.log('INSERT PERFORMANCE:');
115
+ console.log(` Without Sync: ${resultNoSync.inserts.opsPerSec.toLocaleString()} ops/sec (${resultNoSync.inserts.perOp.toFixed(2)} ms/op)`);
116
+ console.log(` With Sync: ${resultWithSync.inserts.opsPerSec.toLocaleString()} ops/sec (${resultWithSync.inserts.perOp.toFixed(2)} ms/op)`);
117
+
118
+ const insertDiff = ((resultWithSync.inserts.opsPerSec - resultNoSync.inserts.opsPerSec) / resultNoSync.inserts.opsPerSec * 100).toFixed(2);
119
+ console.log(` Difference: ${insertDiff > 0 ? '+' : ''}${insertDiff}%`);
120
+ console.log();
121
+
122
+ console.log('READ PERFORMANCE:');
123
+ console.log(` Without Sync: ${resultNoSync.reads.opsPerSec.toLocaleString()} ops/sec (${resultNoSync.reads.perOp.toFixed(2)} ms/op)`);
124
+ console.log(` With Sync: ${resultWithSync.reads.opsPerSec.toLocaleString()} ops/sec (${resultWithSync.reads.perOp.toFixed(2)} ms/op)`);
125
+
126
+ const readDiff = ((resultWithSync.reads.opsPerSec - resultNoSync.reads.opsPerSec) / resultNoSync.reads.opsPerSec * 100).toFixed(2);
127
+ console.log(` Difference: ${readDiff > 0 ? '+' : ''}${readDiff}%`);
128
+ console.log();
129
+
130
+ // Verdict
131
+ console.log('='.repeat(60));
132
+ const threshold = 5; // 5% tolerance
133
+ const insertPass = Math.abs(parseFloat(insertDiff)) < threshold;
134
+ const readPass = Math.abs(parseFloat(readDiff)) < threshold;
135
+
136
+ if (insertPass && readPass) {
137
+ console.log('VERDICT: ✅ PASS - ZERO PERFORMANCE IMPACT');
138
+ console.log(` (within ${threshold}% tolerance)`);
139
+ } else {
140
+ console.log('VERDICT: ❌ FAIL - PERFORMANCE REGRESSION DETECTED');
141
+ }
142
+ console.log('='.repeat(60));
143
+
144
+ process.exit(insertPass && readPass ? 0 : 1);
145
+ }
146
+
147
+ main().catch(e => {
148
+ console.error('Test failed:', e);
149
+ process.exit(1);
150
+ });
package/src/detector.js DELETED
@@ -1,105 +0,0 @@
1
- import net from 'net';
2
-
3
- /**
4
- * Check if PostgreSQL connection URL is valid and reachable
5
- */
6
- export async function canConnectToPostgres(connectionUrl, timeout = 5000) {
7
- if (!connectionUrl || !connectionUrl.startsWith('postgresql://')) {
8
- return false;
9
- }
10
-
11
- try {
12
- // Parse URL to extract host and port
13
- const url = new URL(connectionUrl);
14
- const host = url.hostname;
15
- const port = parseInt(url.port || '5432', 10);
16
-
17
- return await checkTcpConnection(host, port, timeout);
18
- } catch (error) {
19
- console.warn('Invalid PostgreSQL URL:', error.message);
20
- return false;
21
- }
22
- }
23
-
24
- /**
25
- * Check if TCP connection can be established
26
- */
27
- function checkTcpConnection(host, port, timeout) {
28
- return new Promise((resolve) => {
29
- const socket = new net.Socket();
30
-
31
- const cleanup = () => {
32
- socket.destroy();
33
- };
34
-
35
- const timer = setTimeout(() => {
36
- cleanup();
37
- resolve(false);
38
- }, timeout);
39
-
40
- socket.once('connect', () => {
41
- clearTimeout(timer);
42
- cleanup();
43
- resolve(true);
44
- });
45
-
46
- socket.once('error', () => {
47
- clearTimeout(timer);
48
- cleanup();
49
- resolve(false);
50
- });
51
-
52
- socket.connect(port, host);
53
- });
54
- }
55
-
56
- /**
57
- * Auto-detect database URL
58
- *
59
- * Priority:
60
- * 1. External PostgreSQL (if reachable)
61
- * 2. Embedded PGlite server (start if needed)
62
- */
63
- export async function autoDetect({
64
- externalUrl,
65
- embeddedDataDir,
66
- embeddedPort = null,
67
- timeout = 5000
68
- }) {
69
- // Try external PostgreSQL first
70
- if (externalUrl && externalUrl.startsWith('postgresql://')) {
71
- console.log('🔍 Checking external PostgreSQL connection...');
72
-
73
- if (await canConnectToPostgres(externalUrl, timeout)) {
74
- console.log('✅ Using external PostgreSQL');
75
- return {
76
- type: 'external',
77
- url: externalUrl,
78
- embedded: false
79
- };
80
- }
81
-
82
- console.warn('⚠️ External PostgreSQL unreachable, falling back to embedded');
83
- }
84
-
85
- // Start embedded server
86
- console.log('🚀 Starting embedded PGlite server...');
87
-
88
- const { getOrStart } = await import('./index.js');
89
-
90
- const instance = await getOrStart({
91
- dataDir: embeddedDataDir,
92
- port: embeddedPort,
93
- autoPort: true
94
- });
95
-
96
- console.log(`✅ Using embedded PGlite on port ${instance.port}`);
97
-
98
- return {
99
- type: 'embedded',
100
- url: instance.connectionUrl,
101
- embedded: true,
102
- port: instance.port,
103
- dataDir: instance.dataDir
104
- };
105
- }
package/src/pool.js DELETED
@@ -1,320 +0,0 @@
1
- /**
2
- * PGlite Instance Pool (Performance Optimized)
3
- *
4
- * Manages multiple PGlite instances (one per database)
5
- * Handles lazy initialization, connection locking, and cleanup
6
- *
7
- * Performance Optimizations:
8
- * - Fast Map-based lookups (O(1) access)
9
- * - Minimal memory overhead per instance
10
- * - Pino structured logging
11
- * - Proper event listener cleanup
12
- */
13
-
14
- import { PGlite } from '@electric-sql/pglite';
15
- import path from 'path';
16
- import fs from 'fs';
17
- import { EventEmitter } from 'events';
18
-
19
- /**
20
- * Wrapper for PGlite instance with connection management
21
- */
22
- class ManagedInstance extends EventEmitter {
23
- constructor(dbName, dataDir, logger, memoryMode = false) {
24
- super();
25
- this.dbName = dbName;
26
- this.dataDir = dataDir;
27
- this.memoryMode = memoryMode;
28
- this.logger = logger; // Pino logger
29
- this.db = null;
30
- this.locked = false;
31
- this.activeSocket = null;
32
- this.lockTimer = null; // Safety timeout for locks
33
- this.queue = [];
34
- this.createdAt = Date.now();
35
- this.lastAccess = Date.now();
36
-
37
- // Performance: Limit max listeners
38
- this.setMaxListeners(10);
39
- }
40
-
41
- /**
42
- * Initialize PGlite instance (lazy)
43
- */
44
- async initialize() {
45
- if (this.db) {
46
- return this.db;
47
- }
48
-
49
- const initStart = Date.now();
50
-
51
- if (this.memoryMode) {
52
- // Use in-memory database (unique per instance)
53
- this.logger.debug({ dbName: this.dbName, mode: 'memory' }, 'Initializing in-memory PGlite instance');
54
- this.db = new PGlite();
55
- } else {
56
- // Ensure directory exists for file-based storage
57
- if (!fs.existsSync(this.dataDir)) {
58
- fs.mkdirSync(this.dataDir, { recursive: true });
59
- }
60
-
61
- this.logger.debug({ dbName: this.dbName, dataDir: this.dataDir }, 'Initializing PGlite instance');
62
- this.db = new PGlite(this.dataDir);
63
- }
64
-
65
- await this.db.waitReady;
66
-
67
- const initTime = Date.now() - initStart;
68
- this.logger.info({
69
- dbName: this.dbName,
70
- dataDir: this.memoryMode ? '(in-memory)' : this.dataDir,
71
- memoryMode: this.memoryMode,
72
- initTimeMs: initTime
73
- }, 'PGlite instance initialized');
74
-
75
- this.emit('initialized', this.dbName);
76
- return this.db;
77
- }
78
-
79
- /**
80
- * Lock instance to a socket
81
- * @param {net.Socket} socket - TCP socket to lock to
82
- * @param {number} timeout - Safety timeout in ms (default 5 minutes)
83
- */
84
- lock(socket, timeout = 300000) {
85
- if (this.locked) {
86
- throw new Error(`Instance ${this.dbName} is already locked`);
87
- }
88
-
89
- this.locked = true;
90
- this.activeSocket = socket;
91
- this.lastAccess = Date.now();
92
-
93
- // Safety net: auto-unlock after timeout (prevents permanent locks)
94
- this.lockTimer = setTimeout(() => {
95
- this.logger.warn({ dbName: this.dbName }, 'Lock timeout - forcing unlock');
96
- this.unlock();
97
- }, timeout);
98
-
99
- // Only attach event listeners if socket is provided
100
- if (socket) {
101
- socket.on('close', () => this.unlock());
102
- socket.on('error', () => this.unlock());
103
- }
104
-
105
- this.emit('locked', this.dbName, socket);
106
- }
107
-
108
- /**
109
- * Unlock instance (connection closed)
110
- */
111
- unlock() {
112
- // Clear safety timeout if it exists
113
- if (this.lockTimer) {
114
- clearTimeout(this.lockTimer);
115
- this.lockTimer = null;
116
- }
117
-
118
- this.locked = false;
119
- this.activeSocket = null;
120
- this.lastAccess = Date.now();
121
-
122
- this.emit('unlocked', this.dbName);
123
-
124
- // Resolve one waiting promise (it will lock, then when it unlocks, the next will be resolved)
125
- if (this.queue.length > 0) {
126
- const { resolve } = this.queue.shift();
127
- // Don't lock here - let the acquire() caller handle locking with their socket
128
- resolve(this);
129
- }
130
- }
131
-
132
- /**
133
- * Wait for instance to be free
134
- */
135
- async waitForFree(timeout = 30000) {
136
- if (!this.locked) {
137
- return this;
138
- }
139
-
140
- return new Promise((resolve, reject) => {
141
- this.queue.push({ socket: null, resolve, reject });
142
-
143
- const timer = setTimeout(() => {
144
- const index = this.queue.findIndex((item) => item.resolve === resolve);
145
- if (index !== -1) {
146
- this.queue.splice(index, 1);
147
- }
148
- reject(new Error(`Timeout waiting for database ${this.dbName}`));
149
- }, timeout);
150
-
151
- // Clear timeout on resolve
152
- this.once('unlocked', () => clearTimeout(timer));
153
- });
154
- }
155
-
156
- /**
157
- * Close PGlite instance
158
- */
159
- async close() {
160
- if (this.db) {
161
- try {
162
- await this.db.close();
163
- } catch (error) {
164
- // Ignore ExitStatus errors (normal WASM cleanup)
165
- if (error.name !== 'ExitStatus') {
166
- console.error(`Error closing instance ${this.dbName}:`, error.message);
167
- }
168
- }
169
- }
170
-
171
- this.db = null;
172
- this.emit('closed', this.dbName);
173
- }
174
-
175
- /**
176
- * Get instance stats
177
- */
178
- getStats() {
179
- return {
180
- dbName: this.dbName,
181
- locked: this.locked,
182
- queueLength: this.queue.length,
183
- uptime: Date.now() - this.createdAt,
184
- lastAccess: Date.now() - this.lastAccess
185
- };
186
- }
187
- }
188
-
189
- /**
190
- * PGlite Instance Pool
191
- */
192
- export class InstancePool extends EventEmitter {
193
- constructor(options = {}) {
194
- super();
195
- this.baseDir = options.baseDir || './data';
196
- this.memoryMode = options.memoryMode || false;
197
- this.maxInstances = options.maxInstances || 100;
198
- this.autoProvision = options.autoProvision !== false; // Default true
199
- this.instances = new Map(); // dbName -> ManagedInstance (O(1) lookups)
200
- this.logger = options.logger; // Pino logger
201
-
202
- // Performance: Set max listeners based on max instances
203
- this.setMaxListeners(this.maxInstances + 10);
204
- }
205
-
206
- /**
207
- * Get or create PGlite instance for database (Performance Optimized)
208
- */
209
- async getOrCreate(dbName) {
210
- // Fast path: Check cache first (O(1) lookup)
211
- let instance = this.instances.get(dbName);
212
-
213
- if (!instance) {
214
- // Check max instances limit
215
- if (this.instances.size >= this.maxInstances) {
216
- this.logger.error({
217
- dbName,
218
- currentInstances: this.instances.size,
219
- maxInstances: this.maxInstances
220
- }, 'Maximum instances limit reached');
221
-
222
- throw new Error(
223
- `Maximum instances limit reached (${this.maxInstances}). ` +
224
- `Cannot create database: ${dbName}`
225
- );
226
- }
227
-
228
- if (!this.autoProvision) {
229
- this.logger.warn({ dbName }, 'Database does not exist (auto-provision disabled)');
230
- throw new Error(`Database ${dbName} does not exist (auto-provision disabled)`);
231
- }
232
-
233
- // Create new instance
234
- const dataDir = this.memoryMode ? null : path.join(this.baseDir, dbName);
235
- instance = new ManagedInstance(
236
- dbName,
237
- dataDir,
238
- this.logger.child({ dbName }), // Child logger with context
239
- this.memoryMode
240
- );
241
-
242
- // Forward events (use once() where appropriate for performance)
243
- instance.on('initialized', (name) => this.emit('instance-created', name));
244
- instance.on('locked', (name) => this.emit('instance-locked', name));
245
- instance.on('unlocked', (name) => this.emit('instance-unlocked', name));
246
- instance.on('closed', (name) => this.emit('instance-closed', name));
247
-
248
- // Add to cache BEFORE initialization (prevents race conditions)
249
- this.instances.set(dbName, instance);
250
- }
251
-
252
- // Lazy initialize (async, may already be initialized)
253
- await instance.initialize();
254
-
255
- return instance;
256
- }
257
-
258
- /**
259
- * Acquire instance (lock to socket)
260
- */
261
- async acquire(dbName, socket, timeout = 30000) {
262
- const instance = await this.getOrCreate(dbName);
263
-
264
- // If locked, wait for it to be free
265
- if (instance.locked) {
266
- console.log(`⏳ Database ${dbName} is busy, queuing connection...`);
267
- await instance.waitForFree(timeout);
268
- }
269
-
270
- // Lock to this socket
271
- instance.lock(socket);
272
-
273
- return instance;
274
- }
275
-
276
- /**
277
- * Get instance (without locking)
278
- */
279
- get(dbName) {
280
- return this.instances.get(dbName);
281
- }
282
-
283
- /**
284
- * List all instances
285
- */
286
- list() {
287
- return Array.from(this.instances.values()).map((instance) => instance.getStats());
288
- }
289
-
290
- /**
291
- * Close specific instance
292
- */
293
- async closeInstance(dbName) {
294
- const instance = this.instances.get(dbName);
295
- if (instance) {
296
- await instance.close();
297
- this.instances.delete(dbName);
298
- }
299
- }
300
-
301
- /**
302
- * Close all instances
303
- */
304
- async closeAll() {
305
- const promises = Array.from(this.instances.values()).map((instance) => instance.close());
306
- await Promise.all(promises);
307
- this.instances.clear();
308
- }
309
-
310
- /**
311
- * Get pool stats
312
- */
313
- getStats() {
314
- return {
315
- totalInstances: this.instances.size,
316
- maxInstances: this.maxInstances,
317
- instances: this.list()
318
- };
319
- }
320
- }
package/src/ports.js DELETED
@@ -1,114 +0,0 @@
1
- import net from 'net';
2
- import { loadRegistry } from './registry.js';
3
-
4
- const PORT_RANGE_START = 12000;
5
- const PORT_RANGE_END = 12999;
6
-
7
- /**
8
- * Check if a port is available
9
- */
10
- export function isPortFree(port) {
11
- return new Promise((resolve) => {
12
- const server = net.createServer();
13
-
14
- server.once('error', (err) => {
15
- if (err.code === 'EADDRINUSE') {
16
- resolve(false);
17
- } else {
18
- resolve(false);
19
- }
20
- });
21
-
22
- server.once('listening', () => {
23
- server.close();
24
- resolve(true);
25
- });
26
-
27
- server.listen(port, '127.0.0.1');
28
- });
29
- }
30
-
31
- /**
32
- * Check if port is in registry (even if process is dead)
33
- */
34
- function isPortInRegistry(port) {
35
- const registry = loadRegistry();
36
-
37
- for (const instance of Object.values(registry.instances)) {
38
- if (instance.port === port) {
39
- return true;
40
- }
41
- }
42
-
43
- return false;
44
- }
45
-
46
- /**
47
- * Allocate a port for a data directory
48
- *
49
- * Priority:
50
- * 1. If instance already running, return its port
51
- * 2. Try preferred port (if provided)
52
- * 3. Find next available port in range
53
- */
54
- export async function allocatePort(dataDir, preferredPort = null) {
55
- const registry = loadRegistry();
56
-
57
- // Check if instance already exists for this dataDir
58
- const existing = registry.instances[dataDir];
59
- if (existing) {
60
- // Verify process is still running
61
- try {
62
- process.kill(existing.pid, 0);
63
- console.log(`Instance already running for ${dataDir} on port ${existing.port}`);
64
- return existing.port;
65
- } catch {
66
- // Process dead, continue allocation
67
- console.log(`Stale instance found for ${dataDir}, reallocating port`);
68
- }
69
- }
70
-
71
- // Try preferred port first
72
- if (preferredPort !== null) {
73
- if (preferredPort < PORT_RANGE_START || preferredPort > PORT_RANGE_END) {
74
- throw new Error(
75
- `Preferred port ${preferredPort} outside allowed range ${PORT_RANGE_START}-${PORT_RANGE_END}`
76
- );
77
- }
78
-
79
- if (await isPortFree(preferredPort) && !isPortInRegistry(preferredPort)) {
80
- return preferredPort;
81
- }
82
-
83
- console.warn(`Preferred port ${preferredPort} unavailable, auto-allocating...`);
84
- }
85
-
86
- // Find next available port
87
- for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
88
- if ((await isPortFree(port)) && !isPortInRegistry(port)) {
89
- return port;
90
- }
91
- }
92
-
93
- throw new Error(
94
- `No available ports in range ${PORT_RANGE_START}-${PORT_RANGE_END}. ` +
95
- `Stop unused instances with 'pglite-server stop --all'`
96
- );
97
- }
98
-
99
- /**
100
- * Get port range info
101
- */
102
- export function getPortRangeInfo() {
103
- const registry = loadRegistry();
104
- const usedPorts = Object.values(registry.instances).map((i) => i.port);
105
-
106
- return {
107
- start: PORT_RANGE_START,
108
- end: PORT_RANGE_END,
109
- total: PORT_RANGE_END - PORT_RANGE_START + 1,
110
- used: usedPorts.length,
111
- available: PORT_RANGE_END - PORT_RANGE_START + 1 - usedPorts.length,
112
- usedPorts
113
- };
114
- }