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/.claude/settings.local.json +11 -0
- package/.husky/pre-commit +2 -0
- package/README.md +281 -256
- package/bin/pglite-server.js +214 -396
- package/eslint.config.js +54 -0
- package/knip.json +8 -0
- package/package.json +32 -11
- package/src/cluster.js +320 -0
- package/src/dashboard.js +211 -0
- package/src/index.js +10 -171
- package/src/postgres.js +478 -0
- package/src/protocol.js +31 -7
- package/src/restore.js +587 -0
- package/src/router.js +172 -115
- package/src/sync.js +342 -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/index.js
CHANGED
|
@@ -1,177 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pgserve - PostgreSQL
|
|
2
|
+
* pgserve - Embedded PostgreSQL Server
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* True concurrent connections, zero config, auto-provision databases.
|
|
5
|
+
* Uses embedded-postgres (real PostgreSQL binaries).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
//
|
|
8
|
+
// Main exports
|
|
9
9
|
export { MultiTenantRouter, startMultiTenantServer } from './router.js';
|
|
10
|
-
export {
|
|
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
|
-
//
|
|
13
|
-
|
|
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';
|
package/src/postgres.js
ADDED
|
@@ -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
|
+
}
|