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/.claude/settings.local.json +11 -0
- package/README.md +281 -256
- package/bin/pglite-server.js +212 -395
- package/package.json +13 -10
- package/src/cluster.js +322 -0
- package/src/index.js +8 -171
- package/src/postgres.js +479 -0
- package/src/protocol.js +49 -10
- package/src/router.js +117 -114
- package/src/sync.js +344 -0
- package/tests/benchmarks/runner.js +300 -155
- package/tests/sync-perf-test.js +150 -0
- package/src/detector.js +0 -105
- package/src/pool.js +0 -320
- package/src/ports.js +0 -114
- package/src/registry.js +0 -134
- package/src/server.js +0 -265
package/src/postgres.js
ADDED
|
@@ -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
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const PROTOCOL_VERSION_3 = 196608;
|
|
14
|
-
const SSL_REQUEST_CODE = 80877103;
|
|
14
|
+
const SSL_REQUEST_CODE = 80877103; // PostgreSQL SSL negotiation request
|
|
15
|
+
const GSSAPI_REQUEST_CODE = 80877104; // PostgreSQL GSSAPI encryption request
|
|
16
|
+
const CANCEL_REQUEST_CODE = 80877102; // PostgreSQL cancel request
|
|
15
17
|
const DATABASE_KEY = Buffer.from('database\0');
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -132,36 +134,59 @@ export function extractDatabaseName(data) {
|
|
|
132
134
|
}
|
|
133
135
|
}
|
|
134
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
|
+
|
|
135
152
|
/**
|
|
136
153
|
* Read startup message from socket and buffer it
|
|
154
|
+
* OPTIMIZED: Uses pre-allocated buffer pool to avoid allocation per connection
|
|
137
155
|
*
|
|
138
156
|
* @param {net.Socket} socket - TCP socket
|
|
139
157
|
* @returns {Promise<{message: Buffer, allData: Buffer}>} Startup message and all buffered data
|
|
140
158
|
*/
|
|
141
159
|
export async function readStartupMessage(socket) {
|
|
142
160
|
return new Promise((resolve, reject) => {
|
|
143
|
-
|
|
161
|
+
const buffer = acquireBuffer();
|
|
162
|
+
let offset = 0;
|
|
144
163
|
let expectedLength = null;
|
|
145
164
|
let resolved = false;
|
|
146
165
|
|
|
147
166
|
const onData = (chunk) => {
|
|
148
167
|
if (resolved) return;
|
|
149
168
|
|
|
150
|
-
buffer
|
|
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;
|
|
151
173
|
|
|
152
174
|
// Read expected length from first 4 bytes
|
|
153
|
-
if (expectedLength === null &&
|
|
175
|
+
if (expectedLength === null && offset >= 4) {
|
|
154
176
|
expectedLength = buffer.readInt32BE(0);
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
// Check if we have full message
|
|
158
|
-
if (expectedLength !== null &&
|
|
180
|
+
if (expectedLength !== null && offset >= expectedLength) {
|
|
159
181
|
resolved = true;
|
|
160
182
|
socket.removeListener('data', onData);
|
|
161
183
|
socket.removeListener('error', onError);
|
|
162
184
|
|
|
163
|
-
|
|
164
|
-
|
|
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 });
|
|
165
190
|
}
|
|
166
191
|
};
|
|
167
192
|
|
|
@@ -170,6 +195,7 @@ export async function readStartupMessage(socket) {
|
|
|
170
195
|
resolved = true;
|
|
171
196
|
socket.removeListener('data', onData);
|
|
172
197
|
socket.removeListener('error', onError);
|
|
198
|
+
releaseBuffer(buffer);
|
|
173
199
|
reject(error);
|
|
174
200
|
};
|
|
175
201
|
|
|
@@ -179,14 +205,15 @@ export async function readStartupMessage(socket) {
|
|
|
179
205
|
// Resume socket AFTER listeners are set up (prevents race condition)
|
|
180
206
|
socket.resume();
|
|
181
207
|
|
|
182
|
-
// Timeout after
|
|
208
|
+
// Timeout after 2 seconds (reduced from 5s for faster probe connection handling)
|
|
183
209
|
setTimeout(() => {
|
|
184
210
|
if (resolved) return;
|
|
185
211
|
resolved = true;
|
|
186
212
|
socket.removeListener('data', onData);
|
|
187
213
|
socket.removeListener('error', onError);
|
|
214
|
+
releaseBuffer(buffer);
|
|
188
215
|
reject(new Error('Timeout reading startup message'));
|
|
189
|
-
},
|
|
216
|
+
}, 2000);
|
|
190
217
|
});
|
|
191
218
|
}
|
|
192
219
|
|
|
@@ -199,7 +226,7 @@ export async function readStartupMessage(socket) {
|
|
|
199
226
|
export async function extractDatabaseNameFromSocket(socket) {
|
|
200
227
|
let { message, allData } = await readStartupMessage(socket);
|
|
201
228
|
|
|
202
|
-
// Check if this is
|
|
229
|
+
// Check if this is a protocol negotiation request (SSL, GSSAPI, Cancel)
|
|
203
230
|
if (message.length >= 8) {
|
|
204
231
|
const version = message.readInt32BE(4);
|
|
205
232
|
|
|
@@ -211,6 +238,18 @@ export async function extractDatabaseNameFromSocket(socket) {
|
|
|
211
238
|
const result = await readStartupMessage(socket);
|
|
212
239
|
message = result.message;
|
|
213
240
|
allData = result.allData;
|
|
241
|
+
} else if (version === GSSAPI_REQUEST_CODE) {
|
|
242
|
+
// Respond with 'N' (no GSSAPI support)
|
|
243
|
+
socket.write(Buffer.from('N'));
|
|
244
|
+
|
|
245
|
+
// Read the actual startup message
|
|
246
|
+
const result = await readStartupMessage(socket);
|
|
247
|
+
message = result.message;
|
|
248
|
+
allData = result.allData;
|
|
249
|
+
} else if (version === CANCEL_REQUEST_CODE) {
|
|
250
|
+
// Cancel request - PGlite doesn't support query cancellation
|
|
251
|
+
// Just close gracefully (cancel requests don't expect a response)
|
|
252
|
+
throw new Error('Cancel request received (not supported)');
|
|
214
253
|
}
|
|
215
254
|
}
|
|
216
255
|
|