pgserve 2.2.4 → 2.4.0
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/bin/pgserve-wrapper.cjs +5 -4
- package/bin/postgres-server.js +142 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +2 -2
- package/scripts/test-npx.sh +32 -10
- package/src/cli-install.cjs +147 -77
- package/src/commands/uninstall.js +241 -0
- package/src/index.js +11 -44
- package/src/lib/admin-json.js +202 -0
- package/src/lib/pm2-args.js +119 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/admin-client.js +0 -223
- package/src/audit.js +0 -168
- package/src/cluster.js +0 -654
- package/src/control-db.js +0 -330
- package/src/daemon-control.js +0 -468
- package/src/daemon-shared.js +0 -18
- package/src/daemon-tcp.js +0 -297
- package/src/daemon.js +0 -709
- package/src/dashboard.js +0 -217
- package/src/fingerprint.js +0 -479
- package/src/gc.js +0 -351
- package/src/pg-wire.js +0 -869
- package/src/protocol.js +0 -389
- package/src/restore.js +0 -574
- package/src/router.js +0 -546
- package/src/sdk.js +0 -137
- package/src/stats-collector.js +0 -453
- package/src/stats-dashboard.js +0 -401
- package/src/sync.js +0 -335
- package/src/tenancy.js +0 -75
- package/src/tokens.js +0 -102
package/src/router.js
DELETED
|
@@ -1,546 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-Tenant Router (TCP Proxy to Embedded PostgreSQL)
|
|
3
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* PERFORMANCE: Uses Bun.listen() and Bun.connect() for 2-3x throughput improvement
|
|
15
|
-
*
|
|
16
|
-
* v2 NOTE: The MultiTenantRouter is the **direct-embed** path — callers that
|
|
17
|
-
* spawn their own PostgresManager and bind a TCP port get a per-pid Unix
|
|
18
|
-
* socket from `pgManager.getSocketPath()` (preserved by PR #24). The new
|
|
19
|
-
* **daemon** path (`src/daemon.js`) binds a singleton control socket at
|
|
20
|
-
* `$XDG_RUNTIME_DIR/pgserve/control.sock` and is the v2 default for the
|
|
21
|
-
* `pgserve daemon` CLI subcommand. Both paths coexist; direct-embed callers
|
|
22
|
-
* are not affected by daemon mode.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import fs from 'fs';
|
|
26
|
-
import { PostgresManager } from './postgres.js';
|
|
27
|
-
import { SyncManager } from './sync.js';
|
|
28
|
-
import { RestoreManager } from './restore.js';
|
|
29
|
-
import { Dashboard } from './dashboard.js';
|
|
30
|
-
import { extractDatabaseName } from './protocol.js';
|
|
31
|
-
import { EventEmitter } from 'events';
|
|
32
|
-
import { createLogger } from './logger.js';
|
|
33
|
-
|
|
34
|
-
// PostgreSQL protocol constants
|
|
35
|
-
const PROTOCOL_VERSION_3 = 196608;
|
|
36
|
-
const SSL_REQUEST_CODE = 80877103;
|
|
37
|
-
const GSSAPI_REQUEST_CODE = 80877104;
|
|
38
|
-
const CANCEL_REQUEST_CODE = 80877102;
|
|
39
|
-
|
|
40
|
-
// Maximum size for the pre-handshake startup buffer. A legitimate PG
|
|
41
|
-
// startup message is at most a few hundred bytes; anything approaching
|
|
42
|
-
// 1 MiB is a runaway client or an attempted buffer-growth DoS. Bound
|
|
43
|
-
// this to stop the proxy from accumulating gigabytes of orphaned data
|
|
44
|
-
// when a client sends garbage and the handshake never completes.
|
|
45
|
-
// (Issue #18 root cause #2 — unbounded growth at state.buffer.)
|
|
46
|
-
const MAX_STARTUP_BUFFER_SIZE = 1024 * 1024; // 1 MiB
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Attempt to write a pending buffer to a target socket.
|
|
50
|
-
* Returns remaining unwritten bytes, or null if fully flushed.
|
|
51
|
-
*/
|
|
52
|
-
function flushPending(target, pending) {
|
|
53
|
-
const written = target.write(pending);
|
|
54
|
-
if (written === pending.byteLength) return null;
|
|
55
|
-
if (written === 0) return pending;
|
|
56
|
-
return pending.subarray(written);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Multi-Tenant Router Server
|
|
61
|
-
*/
|
|
62
|
-
export class MultiTenantRouter extends EventEmitter {
|
|
63
|
-
constructor(options = {}) {
|
|
64
|
-
super();
|
|
65
|
-
this.port = options.port || 8432;
|
|
66
|
-
this.host = options.host || '127.0.0.1';
|
|
67
|
-
this.baseDir = options.baseDir || null; // null = memory mode
|
|
68
|
-
this.memoryMode = !options.baseDir;
|
|
69
|
-
this.maxConnections = options.maxConnections || 1000;
|
|
70
|
-
this.autoProvision = options.autoProvision !== false;
|
|
71
|
-
|
|
72
|
-
// Internal PostgreSQL port (different from router port)
|
|
73
|
-
this.pgPort = options.pgPort || (this.port + 1000);
|
|
74
|
-
|
|
75
|
-
// Pino logger (ultra-fast structured logging)
|
|
76
|
-
const logLevel = options.logLevel || 'info';
|
|
77
|
-
this.logger = options.logger || createLogger({ level: logLevel });
|
|
78
|
-
|
|
79
|
-
// Sync options (async replication to real PostgreSQL)
|
|
80
|
-
this.syncTo = options.syncTo || null;
|
|
81
|
-
this.syncDatabases = options.syncDatabases
|
|
82
|
-
? options.syncDatabases.split(',').map(s => s.trim())
|
|
83
|
-
: [];
|
|
84
|
-
this.syncManager = null;
|
|
85
|
-
|
|
86
|
-
// PostgreSQL manager (with sync flag if needed)
|
|
87
|
-
this.pgManager = new PostgresManager({
|
|
88
|
-
dataDir: this.baseDir,
|
|
89
|
-
port: this.pgPort,
|
|
90
|
-
logger: this.logger.child({ component: 'postgres' }),
|
|
91
|
-
syncEnabled: !!this.syncTo, // Enable logical replication if sync is configured
|
|
92
|
-
useRam: options.useRam, // Use /dev/shm for true RAM storage (Linux only)
|
|
93
|
-
enablePgvector: options.enablePgvector // Auto-enable pgvector extension on new databases
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// TCP server
|
|
97
|
-
this.server = null;
|
|
98
|
-
this.connections = new Set();
|
|
99
|
-
|
|
100
|
-
// Performance: Reduce event listener overhead
|
|
101
|
-
this.setMaxListeners(this.maxConnections + 10);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Socket state storage for Bun TCP handler model
|
|
106
|
-
* Maps client socket to its state (buffer, pgSocket, dbName, etc.)
|
|
107
|
-
*/
|
|
108
|
-
socketState = new WeakMap();
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Start multi-tenant router
|
|
112
|
-
*/
|
|
113
|
-
async start() {
|
|
114
|
-
// Initialize dashboard for informative CLI output
|
|
115
|
-
const dashboard = new Dashboard();
|
|
116
|
-
dashboard.showHeader({
|
|
117
|
-
port: this.port,
|
|
118
|
-
host: this.host,
|
|
119
|
-
memoryMode: this.memoryMode,
|
|
120
|
-
syncTo: this.syncTo
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// Start PostgreSQL first
|
|
124
|
-
dashboard.stage('PostgreSQL binaries resolved');
|
|
125
|
-
await this.pgManager.start();
|
|
126
|
-
dashboard.stage('PostgreSQL started');
|
|
127
|
-
|
|
128
|
-
// Automatic restore from external PostgreSQL (if sync configured)
|
|
129
|
-
// This runs BEFORE SyncManager to restore data before enabling outbound sync
|
|
130
|
-
if (this.syncTo) {
|
|
131
|
-
const restoreManager = new RestoreManager({
|
|
132
|
-
sourceUrl: this.syncTo,
|
|
133
|
-
patterns: this.syncDatabases,
|
|
134
|
-
targetPort: this.pgPort,
|
|
135
|
-
targetSocketPath: this.pgManager.getSocketPath(),
|
|
136
|
-
logger: this.logger.child({ component: 'restore' }),
|
|
137
|
-
onProgress: (metrics) => dashboard.updateRestore(metrics)
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// Start restore progress display
|
|
141
|
-
dashboard.startRestore(restoreManager.totalDatabases || 1);
|
|
142
|
-
|
|
143
|
-
const restoreResult = await restoreManager.restore(this.pgManager);
|
|
144
|
-
|
|
145
|
-
if (restoreResult.success) {
|
|
146
|
-
dashboard.completeRestore(restoreResult.metrics);
|
|
147
|
-
this.logger.info({
|
|
148
|
-
databases: restoreResult.metrics.databasesRestored,
|
|
149
|
-
tables: restoreResult.metrics.tablesRestored,
|
|
150
|
-
rows: restoreResult.metrics.rowsRestored,
|
|
151
|
-
bytes: restoreResult.metrics.bytesTransferred,
|
|
152
|
-
durationMs: restoreResult.metrics.endTime - restoreResult.metrics.startTime
|
|
153
|
-
}, 'Restored from external PostgreSQL');
|
|
154
|
-
} else if (restoreResult.skipped) {
|
|
155
|
-
// No progress to complete - was skipped
|
|
156
|
-
} else {
|
|
157
|
-
dashboard.cleanup();
|
|
158
|
-
this.logger.warn({ error: restoreResult.error }, 'Restore failed (continuing without restored data)');
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Initialize SyncManager if configured (async replication)
|
|
163
|
-
if (this.syncTo) {
|
|
164
|
-
this.syncManager = new SyncManager({
|
|
165
|
-
targetUrl: this.syncTo,
|
|
166
|
-
databases: this.syncDatabases,
|
|
167
|
-
sourcePort: this.pgPort,
|
|
168
|
-
sourceSocketPath: this.pgManager.getSocketPath(),
|
|
169
|
-
logLevel: this.logger.level
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Wire SyncManager to PostgresManager for database creation hooks
|
|
173
|
-
this.pgManager.setSyncManager(this.syncManager);
|
|
174
|
-
|
|
175
|
-
// Initialize sync connections (non-blocking, runs in background)
|
|
176
|
-
this.syncManager.initialize(this.pgManager)
|
|
177
|
-
.then(() => {
|
|
178
|
-
dashboard.stage('Sync manager initialized');
|
|
179
|
-
this.logger.info('Sync manager initialized');
|
|
180
|
-
})
|
|
181
|
-
.catch(err => this.logger.warn({ err: err.message }, 'Sync manager initialization failed (non-fatal)'));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Create TCP server using Bun.listen() for 2-3x throughput
|
|
185
|
-
const router = this;
|
|
186
|
-
this.server = Bun.listen({
|
|
187
|
-
hostname: this.host,
|
|
188
|
-
port: this.port,
|
|
189
|
-
socket: {
|
|
190
|
-
// Called when data arrives on client socket
|
|
191
|
-
data(socket, data) {
|
|
192
|
-
router.handleSocketData(socket, data);
|
|
193
|
-
},
|
|
194
|
-
// Called when client connects
|
|
195
|
-
open(socket) {
|
|
196
|
-
router.handleSocketOpen(socket);
|
|
197
|
-
},
|
|
198
|
-
// Called when client disconnects
|
|
199
|
-
close(socket) {
|
|
200
|
-
router.handleSocketClose(socket);
|
|
201
|
-
},
|
|
202
|
-
// Called on socket error
|
|
203
|
-
error(socket, error) {
|
|
204
|
-
router.handleSocketError(socket, error);
|
|
205
|
-
},
|
|
206
|
-
// Called when client socket is ready to receive more data
|
|
207
|
-
drain(socket) {
|
|
208
|
-
const state = router.socketState.get(socket);
|
|
209
|
-
if (!state) return;
|
|
210
|
-
// Flush any pending PG→Client data
|
|
211
|
-
if (state.pendingToClient) {
|
|
212
|
-
state.pendingToClient = flushPending(socket, state.pendingToClient);
|
|
213
|
-
}
|
|
214
|
-
// If fully flushed, resume reading from PostgreSQL
|
|
215
|
-
if (!state.pendingToClient && state.pgSocket) {
|
|
216
|
-
state.pgSocket.resume();
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const socketPath = this.pgManager.getSocketPath();
|
|
223
|
-
|
|
224
|
-
dashboard.stage('TCP server listening');
|
|
225
|
-
dashboard.showReady({ port: this.port, host: this.host });
|
|
226
|
-
|
|
227
|
-
this.logger.info({
|
|
228
|
-
host: this.host,
|
|
229
|
-
port: this.port,
|
|
230
|
-
pgPort: this.pgPort,
|
|
231
|
-
pgSocketPath: socketPath || '(TCP)',
|
|
232
|
-
baseDir: this.memoryMode ? '(in-memory)' : this.baseDir,
|
|
233
|
-
memoryMode: this.memoryMode,
|
|
234
|
-
autoProvision: this.autoProvision,
|
|
235
|
-
maxConnections: this.maxConnections
|
|
236
|
-
}, 'Multi-tenant router started');
|
|
237
|
-
|
|
238
|
-
this.emit('listening');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Handle socket open (Bun TCP handler)
|
|
243
|
-
*/
|
|
244
|
-
handleSocketOpen(socket) {
|
|
245
|
-
// Initialize socket state
|
|
246
|
-
this.socketState.set(socket, {
|
|
247
|
-
buffer: null,
|
|
248
|
-
pgSocket: null,
|
|
249
|
-
dbName: null,
|
|
250
|
-
handshakeComplete: false,
|
|
251
|
-
// startupInProgress serializes processStartupMessage() against async
|
|
252
|
-
// reentrancy — without it, every data event fired while the previous
|
|
253
|
-
// processStartupMessage() is still awaiting createDatabase() would
|
|
254
|
-
// launch another async task on the same state, racing to overwrite
|
|
255
|
-
// state.pgSocket and leaking the losers (issue #18 root cause #1).
|
|
256
|
-
startupInProgress: false,
|
|
257
|
-
pendingToPg: null,
|
|
258
|
-
pendingToClient: null
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// Track connection
|
|
262
|
-
this.connections.add(socket);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Handle socket data (Bun TCP handler)
|
|
267
|
-
* Handles both handshake and proxying phases
|
|
268
|
-
*/
|
|
269
|
-
handleSocketData(socket, data) {
|
|
270
|
-
const state = this.socketState.get(socket);
|
|
271
|
-
if (!state) return;
|
|
272
|
-
|
|
273
|
-
// If handshake complete, forward to PostgreSQL
|
|
274
|
-
if (state.handshakeComplete && state.pgSocket) {
|
|
275
|
-
// If there's already pending data, append and re-pause.
|
|
276
|
-
// (Re-pause is defensive: client should already be paused from the
|
|
277
|
-
// earlier partial-write, but kernel-buffered data can still arrive
|
|
278
|
-
// before the pause takes effect — issue #18 root cause #3.)
|
|
279
|
-
if (state.pendingToPg) {
|
|
280
|
-
state.pendingToPg = Buffer.concat([state.pendingToPg, data]);
|
|
281
|
-
socket.pause();
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
const written = state.pgSocket.write(data);
|
|
285
|
-
if (written < data.byteLength) {
|
|
286
|
-
// Partial write — buffer remainder and pause client
|
|
287
|
-
state.pendingToPg = written === 0 ? Buffer.from(data) : Buffer.from(data.subarray(written));
|
|
288
|
-
socket.pause();
|
|
289
|
-
}
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Buffer data for startup message parsing.
|
|
294
|
-
// Bound the pre-handshake buffer so a client that never completes its
|
|
295
|
-
// startup (or sends garbage) cannot grow state.buffer without limit —
|
|
296
|
-
// the 74 GiB VmSize in the production deadlock report traces to this
|
|
297
|
-
// path (issue #18 root cause #2).
|
|
298
|
-
const incomingSize = state.buffer ? state.buffer.length + data.byteLength : data.byteLength;
|
|
299
|
-
if (incomingSize > MAX_STARTUP_BUFFER_SIZE) {
|
|
300
|
-
this.logger.warn(
|
|
301
|
-
{ incomingSize, limit: MAX_STARTUP_BUFFER_SIZE },
|
|
302
|
-
'Pre-handshake buffer exceeded limit — closing connection'
|
|
303
|
-
);
|
|
304
|
-
socket.end();
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (state.buffer) {
|
|
308
|
-
state.buffer = Buffer.concat([state.buffer, data]);
|
|
309
|
-
} else {
|
|
310
|
-
state.buffer = Buffer.from(data);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Try to parse startup message
|
|
314
|
-
this.processStartupMessage(socket, state);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Process PostgreSQL startup message and establish proxy connection.
|
|
319
|
-
*
|
|
320
|
-
* Guarded against async reentrancy: multiple data events arriving while
|
|
321
|
-
* the first processStartupMessage() is still awaiting createDatabase()
|
|
322
|
-
* or Bun.connect() must not launch concurrent tasks on the same state —
|
|
323
|
-
* they would race to assign state.pgSocket, leaking the losing sockets
|
|
324
|
-
* and double-writing the startup message (issue #18 root cause #1).
|
|
325
|
-
*/
|
|
326
|
-
async processStartupMessage(socket, state) {
|
|
327
|
-
if (state.startupInProgress) return;
|
|
328
|
-
|
|
329
|
-
const buffer = state.buffer;
|
|
330
|
-
if (!buffer || buffer.length < 8) return; // Need at least length + protocol
|
|
331
|
-
|
|
332
|
-
// Read message length (first 4 bytes, big-endian)
|
|
333
|
-
const messageLength = buffer.readUInt32BE(0);
|
|
334
|
-
if (buffer.length < messageLength) return; // Wait for complete message
|
|
335
|
-
|
|
336
|
-
// Read protocol version or request code (next 4 bytes)
|
|
337
|
-
const code = buffer.readUInt32BE(4);
|
|
338
|
-
|
|
339
|
-
// Handle SSL/GSSAPI/Cancel requests
|
|
340
|
-
if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
|
|
341
|
-
// Reject SSL/GSSAPI - send 'N' (not supported)
|
|
342
|
-
socket.write(Buffer.from('N'));
|
|
343
|
-
// Remove this request from buffer, wait for real startup
|
|
344
|
-
state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
|
|
345
|
-
if (state.buffer) await this.processStartupMessage(socket, state);
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (code === CANCEL_REQUEST_CODE) {
|
|
350
|
-
// Cancel request - just close connection
|
|
351
|
-
socket.end();
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Must be protocol version 3.0
|
|
356
|
-
if (code !== PROTOCOL_VERSION_3) {
|
|
357
|
-
this.logger.warn({ code }, 'Unsupported protocol version');
|
|
358
|
-
socket.end();
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Extract database name from startup message
|
|
363
|
-
const startupMessage = buffer.subarray(0, messageLength);
|
|
364
|
-
const dbName = extractDatabaseName(startupMessage);
|
|
365
|
-
state.dbName = dbName;
|
|
366
|
-
|
|
367
|
-
// Claim the reentrancy guard BEFORE the first await so subsequent data
|
|
368
|
-
// events (buffered into state.buffer by handleSocketData) cannot launch
|
|
369
|
-
// a second async task on the same state.
|
|
370
|
-
state.startupInProgress = true;
|
|
371
|
-
|
|
372
|
-
try {
|
|
373
|
-
// Auto-provision database if needed
|
|
374
|
-
if (this.autoProvision) {
|
|
375
|
-
await this.pgManager.createDatabase(dbName);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Connect to real PostgreSQL using Bun.connect()
|
|
379
|
-
const socketPath = this.pgManager.getSocketPath();
|
|
380
|
-
const router = this;
|
|
381
|
-
|
|
382
|
-
// Shared handler for pgSocket (used by both unix and TCP paths)
|
|
383
|
-
const pgHandler = {
|
|
384
|
-
data(_pgSocket, pgData) {
|
|
385
|
-
// Forward PostgreSQL response to client with backpressure.
|
|
386
|
-
// Re-pause defensively when pendingToClient already exists —
|
|
387
|
-
// kernel-buffered PG data can arrive before the earlier pause()
|
|
388
|
-
// takes effect (issue #18 root cause #3).
|
|
389
|
-
if (state.pendingToClient) {
|
|
390
|
-
state.pendingToClient = Buffer.concat([state.pendingToClient, pgData]);
|
|
391
|
-
_pgSocket.pause();
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
const written = socket.write(pgData);
|
|
395
|
-
if (written < pgData.byteLength) {
|
|
396
|
-
state.pendingToClient = written === 0 ? Buffer.from(pgData) : Buffer.from(pgData.subarray(written));
|
|
397
|
-
_pgSocket.pause();
|
|
398
|
-
}
|
|
399
|
-
},
|
|
400
|
-
open(pgSocket) {
|
|
401
|
-
pgSocket.write(startupMessage);
|
|
402
|
-
state.handshakeComplete = true;
|
|
403
|
-
},
|
|
404
|
-
close(_pgSocket) {
|
|
405
|
-
socket.end();
|
|
406
|
-
},
|
|
407
|
-
error(_pgSocket, error) {
|
|
408
|
-
router.logger.error({ dbName, err: error }, 'PostgreSQL socket error');
|
|
409
|
-
socket.end();
|
|
410
|
-
},
|
|
411
|
-
drain(_pgSocket) {
|
|
412
|
-
// Flush any pending Client→PG data
|
|
413
|
-
if (state.pendingToPg) {
|
|
414
|
-
state.pendingToPg = flushPending(_pgSocket, state.pendingToPg);
|
|
415
|
-
}
|
|
416
|
-
// If fully flushed, resume reading from client
|
|
417
|
-
if (!state.pendingToPg) {
|
|
418
|
-
socket.resume();
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
// Safety net for issue #24: if socketPath points to a directory that was
|
|
424
|
-
// cleaned up (e.g. pgManager was stopped+started, or the PG subprocess
|
|
425
|
-
// exited unexpectedly and socketDir was reset to null but a stale cached
|
|
426
|
-
// path is still hanging around), fall back to TCP instead of Bun.connect
|
|
427
|
-
// hanging on a missing unix socket.
|
|
428
|
-
const useUnix = socketPath && fs.existsSync(socketPath);
|
|
429
|
-
if (useUnix) {
|
|
430
|
-
state.pgSocket = await Bun.connect({ unix: socketPath, socket: pgHandler });
|
|
431
|
-
} else {
|
|
432
|
-
if (socketPath && !useUnix) {
|
|
433
|
-
this.logger.warn({ socketPath, dbName }, 'Unix socket path stale — falling back to TCP');
|
|
434
|
-
}
|
|
435
|
-
state.pgSocket = await Bun.connect({ hostname: '127.0.0.1', port: this.pgPort, socket: pgHandler });
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
this.emit('connection', { dbName, socket });
|
|
439
|
-
} catch (error) {
|
|
440
|
-
this.logger.error({ dbName, err: error }, 'Connection error');
|
|
441
|
-
socket.end();
|
|
442
|
-
this.emit('connection-error', { error, dbName });
|
|
443
|
-
} finally {
|
|
444
|
-
// Release the reentrancy guard whether handshake succeeded or not.
|
|
445
|
-
// If it succeeded, handshakeComplete is now true and further data
|
|
446
|
-
// events will bypass processStartupMessage anyway (handleSocketData
|
|
447
|
-
// takes the handshakeComplete path). If it failed, socket.end()
|
|
448
|
-
// has been called and the connection is tearing down.
|
|
449
|
-
state.startupInProgress = false;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Handle socket close (Bun TCP handler)
|
|
455
|
-
*/
|
|
456
|
-
handleSocketClose(socket) {
|
|
457
|
-
const state = this.socketState.get(socket);
|
|
458
|
-
if (state) {
|
|
459
|
-
state.pendingToPg = null;
|
|
460
|
-
state.pendingToClient = null;
|
|
461
|
-
if (state.pgSocket) state.pgSocket.end();
|
|
462
|
-
}
|
|
463
|
-
this.connections.delete(socket);
|
|
464
|
-
this.socketState.delete(socket);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Handle socket error (Bun TCP handler)
|
|
469
|
-
*/
|
|
470
|
-
handleSocketError(socket, error) {
|
|
471
|
-
const state = this.socketState.get(socket);
|
|
472
|
-
// Only log non-connection-reset errors
|
|
473
|
-
if (error.code !== 'ECONNRESET') {
|
|
474
|
-
this.logger.error({ err: error, dbName: state?.dbName }, 'Socket error');
|
|
475
|
-
}
|
|
476
|
-
if (state) {
|
|
477
|
-
state.pendingToPg = null;
|
|
478
|
-
state.pendingToClient = null;
|
|
479
|
-
if (state.pgSocket) state.pgSocket.end();
|
|
480
|
-
}
|
|
481
|
-
this.connections.delete(socket);
|
|
482
|
-
this.socketState.delete(socket);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Stop router (graceful shutdown)
|
|
487
|
-
*/
|
|
488
|
-
async stop() {
|
|
489
|
-
this.logger.info('Stopping multi-tenant router');
|
|
490
|
-
|
|
491
|
-
// Close all connections gracefully
|
|
492
|
-
const activeConns = this.connections.size;
|
|
493
|
-
for (const socket of this.connections) {
|
|
494
|
-
socket.end();
|
|
495
|
-
}
|
|
496
|
-
this.connections.clear();
|
|
497
|
-
|
|
498
|
-
// Close TCP server (Bun.listen returns a server with stop() method)
|
|
499
|
-
if (this.server) {
|
|
500
|
-
this.server.stop();
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Stop SyncManager first (before PostgreSQL)
|
|
504
|
-
if (this.syncManager) {
|
|
505
|
-
await this.syncManager.stop();
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Stop PostgreSQL
|
|
509
|
-
await this.pgManager.stop();
|
|
510
|
-
|
|
511
|
-
this.logger.info({
|
|
512
|
-
activeConnections: activeConns
|
|
513
|
-
}, 'Router stopped');
|
|
514
|
-
|
|
515
|
-
this.emit('stopped');
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Get router stats
|
|
520
|
-
*/
|
|
521
|
-
getStats() {
|
|
522
|
-
return {
|
|
523
|
-
port: this.port,
|
|
524
|
-
host: this.host,
|
|
525
|
-
pgPort: this.pgPort,
|
|
526
|
-
activeConnections: this.connections.size,
|
|
527
|
-
postgres: this.pgManager.getStats()
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* List all databases
|
|
533
|
-
*/
|
|
534
|
-
listDatabases() {
|
|
535
|
-
return this.pgManager.getStats().databases;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Start multi-tenant router (convenience function)
|
|
541
|
-
*/
|
|
542
|
-
export async function startMultiTenantServer(options = {}) {
|
|
543
|
-
const router = new MultiTenantRouter(options);
|
|
544
|
-
await router.start();
|
|
545
|
-
return router;
|
|
546
|
-
}
|
package/src/sdk.js
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public SDK helpers for applications that want to consume the singleton
|
|
3
|
-
* pgserve daemon without shelling out themselves.
|
|
4
|
-
*
|
|
5
|
-
* The intended flow is:
|
|
6
|
-
* 1. App calls ensureDaemon() during install/startup.
|
|
7
|
-
* 2. App connects with daemonClientOptions().
|
|
8
|
-
* 3. pgserve derives the app identity from the Unix-socket peer creds and
|
|
9
|
-
* routes it to that app's fingerprinted database.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { spawn } from 'child_process';
|
|
13
|
-
import fs from 'fs';
|
|
14
|
-
import path from 'path';
|
|
15
|
-
import { fileURLToPath } from 'url';
|
|
16
|
-
import {
|
|
17
|
-
isProcessAlive,
|
|
18
|
-
resolveControlSocketDir,
|
|
19
|
-
resolveControlSocketPath,
|
|
20
|
-
resolveLibpqCompatPath,
|
|
21
|
-
resolvePidLockPath,
|
|
22
|
-
} from './daemon.js';
|
|
23
|
-
|
|
24
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
|
|
26
|
-
export function probeDaemon({ controlSocketDir = resolveControlSocketDir() } = {}) {
|
|
27
|
-
const socketPath = resolveControlSocketPath(controlSocketDir);
|
|
28
|
-
const libpqSocketPath = resolveLibpqCompatPath(controlSocketDir);
|
|
29
|
-
const pidLockPath = resolvePidLockPath(controlSocketDir);
|
|
30
|
-
const socketPresent = fs.existsSync(socketPath);
|
|
31
|
-
const libpqSocketPresent = fs.existsSync(libpqSocketPath);
|
|
32
|
-
let pid = null;
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
const raw = fs.readFileSync(pidLockPath, 'utf8').trim();
|
|
36
|
-
const parsed = Number.parseInt(raw, 10);
|
|
37
|
-
if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
|
|
38
|
-
} catch {
|
|
39
|
-
// Missing/unreadable pid file means no live daemon can be trusted.
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const pidAlive = pid !== null && isProcessAlive(pid);
|
|
43
|
-
const running = pidAlive && socketPresent && libpqSocketPresent;
|
|
44
|
-
return {
|
|
45
|
-
running,
|
|
46
|
-
pid: pidAlive ? pid : null,
|
|
47
|
-
socketPresent,
|
|
48
|
-
libpqSocketPresent,
|
|
49
|
-
controlSocketDir,
|
|
50
|
-
controlSocketPath: socketPath,
|
|
51
|
-
libpqSocketPath,
|
|
52
|
-
pidLockPath,
|
|
53
|
-
reason: running ? null : explainProbeMiss({ pid, pidAlive, socketPresent, libpqSocketPresent }),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function explainProbeMiss({ pid, pidAlive, socketPresent, libpqSocketPresent }) {
|
|
58
|
-
if (pid === null && !socketPresent && !libpqSocketPresent) return 'no daemon';
|
|
59
|
-
if (pid !== null && !pidAlive) return 'stale pid';
|
|
60
|
-
if (!socketPresent) return 'control socket missing';
|
|
61
|
-
if (!libpqSocketPresent) return 'libpq socket missing';
|
|
62
|
-
return 'not running';
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function daemonClientOptions({
|
|
66
|
-
controlSocketDir = resolveControlSocketDir(),
|
|
67
|
-
database = 'postgres',
|
|
68
|
-
username = 'postgres',
|
|
69
|
-
} = {}) {
|
|
70
|
-
return {
|
|
71
|
-
host: controlSocketDir,
|
|
72
|
-
port: 5432,
|
|
73
|
-
database,
|
|
74
|
-
username,
|
|
75
|
-
password: '',
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function buildDaemonArgs({
|
|
80
|
-
dataDir,
|
|
81
|
-
ram = false,
|
|
82
|
-
logLevel,
|
|
83
|
-
noProvision = false,
|
|
84
|
-
listens = [],
|
|
85
|
-
pgvector = false,
|
|
86
|
-
} = {}) {
|
|
87
|
-
const args = ['daemon'];
|
|
88
|
-
if (dataDir) args.push('--data', dataDir);
|
|
89
|
-
if (ram) args.push('--ram');
|
|
90
|
-
if (logLevel) args.push('--log', logLevel);
|
|
91
|
-
if (noProvision) args.push('--no-provision');
|
|
92
|
-
if (pgvector) args.push('--pgvector');
|
|
93
|
-
for (const listen of Array.isArray(listens) ? listens : [listens]) {
|
|
94
|
-
if (listen) args.push('--listen', String(listen));
|
|
95
|
-
}
|
|
96
|
-
return args;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export async function ensureDaemon(options = {}) {
|
|
100
|
-
const controlSocketDir = options.controlSocketDir || resolveControlSocketDir();
|
|
101
|
-
const initial = probeDaemon({ controlSocketDir });
|
|
102
|
-
if (initial.running) return initial;
|
|
103
|
-
|
|
104
|
-
const bin = options.bin || resolveBundledCliBin();
|
|
105
|
-
const env = { ...process.env, ...envForControlSocketDir(controlSocketDir), ...(options.env || {}) };
|
|
106
|
-
const child = spawn(bin, buildDaemonArgs(options), {
|
|
107
|
-
detached: true,
|
|
108
|
-
stdio: 'ignore',
|
|
109
|
-
env,
|
|
110
|
-
});
|
|
111
|
-
child.unref();
|
|
112
|
-
|
|
113
|
-
const timeoutMs = options.timeoutMs || 16000;
|
|
114
|
-
const deadline = Date.now() + timeoutMs;
|
|
115
|
-
while (Date.now() < deadline) {
|
|
116
|
-
const state = probeDaemon({ controlSocketDir });
|
|
117
|
-
if (state.running) return state;
|
|
118
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const state = probeDaemon({ controlSocketDir });
|
|
122
|
-
const err = new Error(`pgserve daemon did not become ready within ${timeoutMs}ms (${state.reason})`);
|
|
123
|
-
err.code = 'EPGSERVE_DAEMON_TIMEOUT';
|
|
124
|
-
err.state = state;
|
|
125
|
-
throw err;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function resolveBundledCliBin() {
|
|
129
|
-
return path.join(__dirname, '..', 'bin', 'pgserve-wrapper.cjs');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function envForControlSocketDir(controlSocketDir) {
|
|
133
|
-
if (path.basename(controlSocketDir) !== 'pgserve') {
|
|
134
|
-
throw new Error('ensureDaemon: controlSocketDir must be a pgserve runtime directory ending in /pgserve');
|
|
135
|
-
}
|
|
136
|
-
return { XDG_RUNTIME_DIR: path.dirname(controlSocketDir) };
|
|
137
|
-
}
|