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/eslint.config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import unusedImports from 'eslint-plugin-unused-imports';
|
|
2
|
+
|
|
3
|
+
export default [
|
|
4
|
+
{
|
|
5
|
+
files: ['src/**/*.js', 'bin/**/*.js', 'tests/**/*.js'],
|
|
6
|
+
plugins: {
|
|
7
|
+
'unused-imports': unusedImports,
|
|
8
|
+
},
|
|
9
|
+
languageOptions: {
|
|
10
|
+
ecmaVersion: 2022,
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
globals: {
|
|
13
|
+
console: 'readonly',
|
|
14
|
+
process: 'readonly',
|
|
15
|
+
Buffer: 'readonly',
|
|
16
|
+
setTimeout: 'readonly',
|
|
17
|
+
clearTimeout: 'readonly',
|
|
18
|
+
setInterval: 'readonly',
|
|
19
|
+
clearInterval: 'readonly',
|
|
20
|
+
URL: 'readonly',
|
|
21
|
+
__dirname: 'readonly',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
rules: {
|
|
25
|
+
// Dead imports - ERROR
|
|
26
|
+
'unused-imports/no-unused-imports': 'error',
|
|
27
|
+
'unused-imports/no-unused-vars': [
|
|
28
|
+
'error',
|
|
29
|
+
{
|
|
30
|
+
vars: 'all',
|
|
31
|
+
varsIgnorePattern: '^_',
|
|
32
|
+
args: 'after-used',
|
|
33
|
+
argsIgnorePattern: '^_',
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
// Code quality
|
|
38
|
+
'no-unused-vars': 'off', // Handled by unused-imports
|
|
39
|
+
'no-console': 'off', // We use console in CLI
|
|
40
|
+
'no-undef': 'error',
|
|
41
|
+
'no-unreachable': 'error',
|
|
42
|
+
'no-constant-condition': 'warn',
|
|
43
|
+
'no-empty': 'warn',
|
|
44
|
+
'no-duplicate-case': 'error',
|
|
45
|
+
'no-fallthrough': 'warn',
|
|
46
|
+
'eqeqeq': ['warn', 'always', { null: 'ignore' }],
|
|
47
|
+
'no-var': 'error',
|
|
48
|
+
'prefer-const': 'warn',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
ignores: ['node_modules/', 'data/', 'test-data-*/', '.genie/'],
|
|
53
|
+
},
|
|
54
|
+
];
|
package/knip.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://unpkg.com/knip@5/schema.json",
|
|
3
|
+
"entry": ["src/index.js", "bin/pglite-server.js"],
|
|
4
|
+
"project": ["src/**/*.js", "bin/**/*.js"],
|
|
5
|
+
"ignore": ["tests/**", "helpers/**"],
|
|
6
|
+
"ignoreDependencies": ["pino-pretty"],
|
|
7
|
+
"ignoreExportsUsedInFile": true
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pgserve",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"bench": "node tests/benchmarks/runner.js",
|
|
12
|
-
"test": "node --test tests/**/*.test.js"
|
|
12
|
+
"test": "node --test tests/**/*.test.js",
|
|
13
|
+
"lint": "eslint src/ bin/",
|
|
14
|
+
"lint:fix": "eslint src/ bin/ --fix",
|
|
15
|
+
"deadcode": "knip",
|
|
16
|
+
"prepare": "husky"
|
|
13
17
|
},
|
|
14
18
|
"keywords": [
|
|
15
|
-
"pglite",
|
|
16
19
|
"postgresql",
|
|
17
20
|
"embedded",
|
|
18
21
|
"database",
|
|
19
|
-
"
|
|
20
|
-
"electron",
|
|
22
|
+
"postgres",
|
|
21
23
|
"development",
|
|
22
|
-
"multi-
|
|
24
|
+
"multi-tenant"
|
|
23
25
|
],
|
|
24
26
|
"author": "Namastex Labs <labs@namastex.com>",
|
|
25
27
|
"license": "MIT",
|
|
@@ -32,16 +34,35 @@
|
|
|
32
34
|
},
|
|
33
35
|
"homepage": "https://github.com/namastexlabs/pgserve#readme",
|
|
34
36
|
"dependencies": {
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
+
"pg": "^8.16.3",
|
|
38
|
+
"pg-copy-streams": "^7.0.0",
|
|
37
39
|
"pino": "^10.1.0",
|
|
38
40
|
"pino-pretty": "^13.1.2"
|
|
39
41
|
},
|
|
42
|
+
"optionalDependencies": {
|
|
43
|
+
"@embedded-postgres/darwin-arm64": "17.7.0-beta.15",
|
|
44
|
+
"@embedded-postgres/darwin-x64": "17.7.0-beta.15",
|
|
45
|
+
"@embedded-postgres/linux-x64": "17.7.0-beta.15",
|
|
46
|
+
"@embedded-postgres/win32-x64": "17.7.0-beta.15"
|
|
47
|
+
},
|
|
40
48
|
"devDependencies": {
|
|
41
|
-
"
|
|
42
|
-
"
|
|
49
|
+
"@electric-sql/pglite": "^0.2.17",
|
|
50
|
+
"better-sqlite3": "^11.7.0",
|
|
51
|
+
"eslint": "^9.39.1",
|
|
52
|
+
"eslint-plugin-unused-imports": "^4.3.0",
|
|
53
|
+
"husky": "^9.1.7",
|
|
54
|
+
"knip": "^5.71.0",
|
|
55
|
+
"lint-staged": "^16.2.7"
|
|
43
56
|
},
|
|
44
57
|
"engines": {
|
|
45
58
|
"node": ">=18.0.0"
|
|
59
|
+
},
|
|
60
|
+
"lint-staged": {
|
|
61
|
+
"src/**/*.js": [
|
|
62
|
+
"eslint --fix"
|
|
63
|
+
],
|
|
64
|
+
"bin/**/*.js": [
|
|
65
|
+
"eslint --fix"
|
|
66
|
+
]
|
|
46
67
|
}
|
|
47
68
|
}
|
package/src/cluster.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster Mode for pgserve
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* - PRIMARY process: Runs single embedded PostgreSQL instance
|
|
6
|
+
* - WORKER processes: Only run TCP routing to PRIMARY's PostgreSQL
|
|
7
|
+
*
|
|
8
|
+
* This enables multi-core scaling (3-5x throughput on multi-core systems)
|
|
9
|
+
* while maintaining a single PostgreSQL instance.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import cluster from 'cluster';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import net from 'net';
|
|
15
|
+
import pg from 'pg';
|
|
16
|
+
import pino from 'pino';
|
|
17
|
+
import { PostgresManager } from './postgres.js';
|
|
18
|
+
import { extractDatabaseNameFromSocket } from './protocol.js';
|
|
19
|
+
import { EventEmitter } from 'events';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* ClusterRouter - Lightweight TCP router for worker processes
|
|
23
|
+
* Does NOT start PostgreSQL - connects to PRIMARY's PostgreSQL via Unix socket
|
|
24
|
+
*/
|
|
25
|
+
class ClusterRouter extends EventEmitter {
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
super();
|
|
28
|
+
this.port = options.port || 5432;
|
|
29
|
+
this.host = options.host || '127.0.0.1';
|
|
30
|
+
this.pgSocketPath = options.pgSocketPath; // From PRIMARY
|
|
31
|
+
this.pgPort = options.pgPort;
|
|
32
|
+
this.pgUser = options.pgUser || 'postgres';
|
|
33
|
+
this.pgPassword = options.pgPassword || 'postgres';
|
|
34
|
+
this.autoProvision = options.autoProvision !== false;
|
|
35
|
+
this.maxConnections = options.maxConnections || 1000;
|
|
36
|
+
|
|
37
|
+
this.logger = pino({ level: options.logLevel || 'info' });
|
|
38
|
+
this.adminClient = null;
|
|
39
|
+
this.server = null;
|
|
40
|
+
this.connections = new Set();
|
|
41
|
+
this.setMaxListeners(this.maxConnections + 10);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
optimizeSocket(socket) {
|
|
45
|
+
socket.setNoDelay(true);
|
|
46
|
+
socket.setKeepAlive(true, 60000);
|
|
47
|
+
socket.setTimeout(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async start() {
|
|
51
|
+
// Admin connection for auto-provisioning databases
|
|
52
|
+
if (this.autoProvision) {
|
|
53
|
+
let connectionConfig;
|
|
54
|
+
if (this.pgSocketPath) {
|
|
55
|
+
// pg library expects socket DIRECTORY as host, it appends .s.PGSQL.<port>
|
|
56
|
+
// Socket path format: /tmp/pgserve-sock-xxx/.s.PGSQL.<port>
|
|
57
|
+
// Extract directory by removing the socket file suffix
|
|
58
|
+
const socketDir = this.pgSocketPath.replace(/\/\.s\.PGSQL\.\d+$/, '');
|
|
59
|
+
connectionConfig = {
|
|
60
|
+
host: socketDir,
|
|
61
|
+
port: this.pgPort,
|
|
62
|
+
database: 'postgres',
|
|
63
|
+
user: this.pgUser,
|
|
64
|
+
password: this.pgPassword
|
|
65
|
+
};
|
|
66
|
+
} else {
|
|
67
|
+
connectionConfig = {
|
|
68
|
+
host: '127.0.0.1',
|
|
69
|
+
port: this.pgPort,
|
|
70
|
+
database: 'postgres',
|
|
71
|
+
user: this.pgUser,
|
|
72
|
+
password: this.pgPassword
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.adminClient = new pg.Client(connectionConfig);
|
|
77
|
+
await this.adminClient.connect();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return new Promise((resolve, _reject) => {
|
|
81
|
+
this.server = net.createServer({
|
|
82
|
+
allowHalfOpen: false,
|
|
83
|
+
pauseOnConnect: true
|
|
84
|
+
}, (socket) => this.handleConnection(socket));
|
|
85
|
+
|
|
86
|
+
this.server.maxConnections = this.maxConnections;
|
|
87
|
+
|
|
88
|
+
this.server.on('error', (error) => {
|
|
89
|
+
this.logger.error({ err: error }, 'Router error');
|
|
90
|
+
this.emit('error', error);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
this.server.listen(this.port, this.host, () => {
|
|
94
|
+
this.emit('listening');
|
|
95
|
+
resolve();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async createDatabase(dbName) {
|
|
101
|
+
if (!this.autoProvision || !this.adminClient) return;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const result = await this.adminClient.query(
|
|
105
|
+
'SELECT 1 FROM pg_database WHERE datname = $1',
|
|
106
|
+
[dbName]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (result.rows.length === 0) {
|
|
110
|
+
await this.adminClient.query(`CREATE DATABASE "${dbName}"`);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// Ignore "already exists" (race condition between workers)
|
|
114
|
+
if (!error.message.includes('already exists')) {
|
|
115
|
+
this.logger.error({ database: dbName, err: error }, 'Failed to create database');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async handleConnection(socket) {
|
|
121
|
+
this.connections.add(socket);
|
|
122
|
+
this.optimizeSocket(socket);
|
|
123
|
+
|
|
124
|
+
let dbName = null;
|
|
125
|
+
let pgSocket = null;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const { dbName: extractedDbName, buffered } = await extractDatabaseNameFromSocket(socket);
|
|
129
|
+
dbName = extractedDbName;
|
|
130
|
+
|
|
131
|
+
await this.createDatabase(dbName);
|
|
132
|
+
|
|
133
|
+
// Connect to PRIMARY's PostgreSQL
|
|
134
|
+
if (this.pgSocketPath) {
|
|
135
|
+
pgSocket = net.connect({ path: this.pgSocketPath });
|
|
136
|
+
} else {
|
|
137
|
+
pgSocket = net.connect({ host: '127.0.0.1', port: this.pgPort });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await new Promise((resolve, reject) => {
|
|
141
|
+
pgSocket.once('connect', resolve);
|
|
142
|
+
pgSocket.once('error', reject);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
this.optimizeSocket(pgSocket);
|
|
146
|
+
pgSocket.write(buffered);
|
|
147
|
+
socket.resume();
|
|
148
|
+
|
|
149
|
+
// Bidirectional pipe
|
|
150
|
+
socket.pipe(pgSocket);
|
|
151
|
+
pgSocket.pipe(socket);
|
|
152
|
+
|
|
153
|
+
const cleanup = () => {
|
|
154
|
+
this.connections.delete(socket);
|
|
155
|
+
if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
|
|
156
|
+
if (socket && !socket.destroyed) socket.destroy();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
socket.once('close', cleanup);
|
|
160
|
+
socket.once('error', cleanup);
|
|
161
|
+
pgSocket.once('close', () => {
|
|
162
|
+
if (socket && !socket.destroyed) socket.destroy();
|
|
163
|
+
});
|
|
164
|
+
pgSocket.once('error', cleanup);
|
|
165
|
+
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.logger.error({ dbName, err: error }, 'Connection error');
|
|
168
|
+
if (pgSocket && !pgSocket.destroyed) pgSocket.destroy();
|
|
169
|
+
socket.destroy();
|
|
170
|
+
this.connections.delete(socket);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async stop() {
|
|
175
|
+
for (const socket of this.connections) {
|
|
176
|
+
socket.end();
|
|
177
|
+
}
|
|
178
|
+
this.connections.clear();
|
|
179
|
+
|
|
180
|
+
if (this.adminClient) {
|
|
181
|
+
await this.adminClient.end();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (this.server) {
|
|
185
|
+
await new Promise((resolve) => this.server.close(resolve));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Start pgserve in cluster mode
|
|
192
|
+
*/
|
|
193
|
+
export async function startClusterServer(options = {}) {
|
|
194
|
+
const numWorkers = options.workers || os.cpus().length;
|
|
195
|
+
const port = options.port || 5432;
|
|
196
|
+
const host = options.host || '127.0.0.1';
|
|
197
|
+
const pgPort = options.pgPort || (port + 1000);
|
|
198
|
+
|
|
199
|
+
if (cluster.isPrimary) {
|
|
200
|
+
console.log(`[pgserve] Cluster mode: ${numWorkers} workers`);
|
|
201
|
+
|
|
202
|
+
// PRIMARY: Start our embedded PostgreSQL (single instance)
|
|
203
|
+
const logger = pino({ level: options.logLevel || 'info' });
|
|
204
|
+
const pgManager = new PostgresManager({
|
|
205
|
+
dataDir: options.baseDir,
|
|
206
|
+
port: pgPort,
|
|
207
|
+
logger: logger.child({ component: 'postgres' })
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await pgManager.start();
|
|
211
|
+
const pgSocketPath = pgManager.getSocketPath();
|
|
212
|
+
|
|
213
|
+
console.log(`[pgserve] Embedded PostgreSQL started`);
|
|
214
|
+
console.log(`[pgserve] Socket: ${pgSocketPath || `TCP port ${pgPort}`}`);
|
|
215
|
+
|
|
216
|
+
const workers = new Map();
|
|
217
|
+
|
|
218
|
+
// Fork workers with PostgreSQL connection info
|
|
219
|
+
for (let i = 0; i < numWorkers; i++) {
|
|
220
|
+
const worker = cluster.fork({
|
|
221
|
+
PGSERVE_WORKER: 'true',
|
|
222
|
+
PGSERVE_PORT: String(port),
|
|
223
|
+
PGSERVE_HOST: host,
|
|
224
|
+
PGSERVE_PG_SOCKET: pgSocketPath || '',
|
|
225
|
+
PGSERVE_PG_PORT: String(pgPort),
|
|
226
|
+
PGSERVE_PG_USER: 'postgres',
|
|
227
|
+
PGSERVE_PG_PASSWORD: 'postgres',
|
|
228
|
+
PGSERVE_LOG_LEVEL: options.logLevel || 'info',
|
|
229
|
+
PGSERVE_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false'
|
|
230
|
+
});
|
|
231
|
+
workers.set(worker.id, worker);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Restart dead workers
|
|
235
|
+
cluster.on('exit', (worker, code, signal) => {
|
|
236
|
+
console.log(`[pgserve] Worker ${worker.id} died (${signal || code}), restarting...`);
|
|
237
|
+
workers.delete(worker.id);
|
|
238
|
+
|
|
239
|
+
const newWorker = cluster.fork({
|
|
240
|
+
PGSERVE_WORKER: 'true',
|
|
241
|
+
PGSERVE_PORT: String(port),
|
|
242
|
+
PGSERVE_HOST: host,
|
|
243
|
+
PGSERVE_PG_SOCKET: pgSocketPath || '',
|
|
244
|
+
PGSERVE_PG_PORT: String(pgPort),
|
|
245
|
+
PGSERVE_PG_USER: 'postgres',
|
|
246
|
+
PGSERVE_PG_PASSWORD: 'postgres',
|
|
247
|
+
PGSERVE_LOG_LEVEL: options.logLevel || 'info',
|
|
248
|
+
PGSERVE_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false'
|
|
249
|
+
});
|
|
250
|
+
workers.set(newWorker.id, newWorker);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Wait for workers to be ready
|
|
254
|
+
let readyCount = 0;
|
|
255
|
+
await new Promise((resolve) => {
|
|
256
|
+
cluster.on('message', (worker, message) => {
|
|
257
|
+
if (message.type === 'ready') {
|
|
258
|
+
readyCount++;
|
|
259
|
+
if (readyCount === numWorkers) resolve();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
console.log(`[pgserve] All ${numWorkers} workers ready`);
|
|
265
|
+
console.log(`[pgserve] Listening on ${host}:${port}`);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
workers,
|
|
269
|
+
pgPort,
|
|
270
|
+
pgSocketPath,
|
|
271
|
+
stop: async () => {
|
|
272
|
+
console.log('[pgserve] Stopping cluster...');
|
|
273
|
+
for (const worker of workers.values()) {
|
|
274
|
+
worker.send({ type: 'shutdown' });
|
|
275
|
+
}
|
|
276
|
+
await new Promise((resolve) => {
|
|
277
|
+
const check = setInterval(() => {
|
|
278
|
+
if (workers.size === 0) {
|
|
279
|
+
clearInterval(check);
|
|
280
|
+
resolve();
|
|
281
|
+
}
|
|
282
|
+
}, 100);
|
|
283
|
+
});
|
|
284
|
+
await pgManager.stop();
|
|
285
|
+
console.log('[pgserve] Cluster stopped');
|
|
286
|
+
},
|
|
287
|
+
getStats: () => ({
|
|
288
|
+
workers: workers.size,
|
|
289
|
+
pids: Array.from(workers.values()).map(w => w.process.pid)
|
|
290
|
+
})
|
|
291
|
+
};
|
|
292
|
+
} else {
|
|
293
|
+
// WORKER: Only run TCP routing, connect to PRIMARY's PostgreSQL
|
|
294
|
+
const router = new ClusterRouter({
|
|
295
|
+
port: parseInt(process.env.PGSERVE_PORT) || 5432,
|
|
296
|
+
host: process.env.PGSERVE_HOST || '127.0.0.1',
|
|
297
|
+
pgSocketPath: process.env.PGSERVE_PG_SOCKET || null,
|
|
298
|
+
pgPort: parseInt(process.env.PGSERVE_PG_PORT) || 6432,
|
|
299
|
+
pgUser: process.env.PGSERVE_PG_USER || 'postgres',
|
|
300
|
+
pgPassword: process.env.PGSERVE_PG_PASSWORD || 'postgres',
|
|
301
|
+
logLevel: process.env.PGSERVE_LOG_LEVEL || 'info',
|
|
302
|
+
autoProvision: process.env.PGSERVE_AUTO_PROVISION === 'true'
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await router.start();
|
|
306
|
+
|
|
307
|
+
// Tell PRIMARY we're ready
|
|
308
|
+
process.send({ type: 'ready' });
|
|
309
|
+
|
|
310
|
+
// Handle shutdown
|
|
311
|
+
process.on('message', async (message) => {
|
|
312
|
+
if (message.type === 'shutdown') {
|
|
313
|
+
await router.stop();
|
|
314
|
+
process.exit(0);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return router;
|
|
319
|
+
}
|
|
320
|
+
}
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard - Informative CLI startup display
|
|
3
|
+
*
|
|
4
|
+
* Hybrid approach:
|
|
5
|
+
* - Scrolling stages (preserved history)
|
|
6
|
+
* - In-place progress updates (only during restore)
|
|
7
|
+
* - Non-TTY fallback (works in pipes/CI)
|
|
8
|
+
*
|
|
9
|
+
* Zero external dependencies - pure Node.js ANSI codes
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'fs';
|
|
13
|
+
import { join, dirname } from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
// Get package version
|
|
17
|
+
let version = '1.0.0';
|
|
18
|
+
try {
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
21
|
+
version = pkg.version;
|
|
22
|
+
} catch {
|
|
23
|
+
// Fallback version
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ANSI escape codes
|
|
27
|
+
const ANSI = {
|
|
28
|
+
CLEAR_LINE: '\x1B[2K',
|
|
29
|
+
MOVE_UP: (n) => `\x1B[${n}A`,
|
|
30
|
+
MOVE_DOWN: (n) => `\x1B[${n}B`,
|
|
31
|
+
HIDE_CURSOR: '\x1B[?25l',
|
|
32
|
+
SHOW_CURSOR: '\x1B[?25h',
|
|
33
|
+
GREEN: '\x1B[32m',
|
|
34
|
+
YELLOW: '\x1B[33m',
|
|
35
|
+
CYAN: '\x1B[36m',
|
|
36
|
+
DIM: '\x1B[2m',
|
|
37
|
+
RESET: '\x1B[0m',
|
|
38
|
+
BOLD: '\x1B[1m'
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Dashboard class for CLI output
|
|
43
|
+
*/
|
|
44
|
+
export class Dashboard {
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.enabled = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
47
|
+
this.updateInterval = options.updateInterval || 200; // Throttle updates
|
|
48
|
+
this.lastUpdate = 0;
|
|
49
|
+
this.progressLines = 0; // Track lines to overwrite
|
|
50
|
+
this.restoreStartTime = 0;
|
|
51
|
+
this.config = options.config || {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Show the startup header
|
|
56
|
+
*/
|
|
57
|
+
showHeader(config = {}) {
|
|
58
|
+
const mode = config.memoryMode ? 'In-memory' : 'Persistent';
|
|
59
|
+
const port = config.port || 5432;
|
|
60
|
+
const host = config.host || '127.0.0.1';
|
|
61
|
+
const syncTo = config.syncTo ? ` → ${this._maskUrl(config.syncTo)}` : '';
|
|
62
|
+
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log(`${ANSI.BOLD}pgserve v${version}${ANSI.RESET} - Embedded PostgreSQL Server`);
|
|
65
|
+
console.log(`${ANSI.DIM}MODE: ${mode} | PORT: ${port} | HOST: ${host}${syncTo}${ANSI.RESET}`);
|
|
66
|
+
console.log('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Log a stage completion
|
|
71
|
+
*/
|
|
72
|
+
stage(name, status = 'done') {
|
|
73
|
+
const icon = status === 'done' ? `${ANSI.GREEN}[✓]${ANSI.RESET}` :
|
|
74
|
+
status === 'error' ? `${ANSI.YELLOW}[✗]${ANSI.RESET}` :
|
|
75
|
+
`${ANSI.DIM}[○]${ANSI.RESET}`;
|
|
76
|
+
console.log(`${icon} ${name}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Start restore progress section
|
|
81
|
+
*/
|
|
82
|
+
startRestore(totalDatabases, totalTables = 0, totalBytes = 0) {
|
|
83
|
+
this.restoreStartTime = Date.now();
|
|
84
|
+
this.totalDatabases = totalDatabases;
|
|
85
|
+
this.totalTables = totalTables || totalDatabases * 10; // Estimate
|
|
86
|
+
this.totalBytes = totalBytes;
|
|
87
|
+
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(`${ANSI.CYAN}Restoring from external PostgreSQL...${ANSI.RESET}`);
|
|
90
|
+
|
|
91
|
+
if (this.enabled) {
|
|
92
|
+
// Reserve lines for progress
|
|
93
|
+
console.log(' Databases: 0/0 [░░░░░░░░░░░░░░░░] 0%');
|
|
94
|
+
console.log(' Tables: 0/0 [░░░░░░░░░░░░░░░░] 0%');
|
|
95
|
+
console.log(' Speed: 0.0 MB/s | ETA: calculating...');
|
|
96
|
+
this.progressLines = 3;
|
|
97
|
+
process.stdout.write(ANSI.HIDE_CURSOR);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Update restore progress (in-place)
|
|
103
|
+
*/
|
|
104
|
+
updateRestore(metrics) {
|
|
105
|
+
if (!this.enabled) return;
|
|
106
|
+
|
|
107
|
+
// Throttle updates
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
if (now - this.lastUpdate < this.updateInterval) return;
|
|
110
|
+
this.lastUpdate = now;
|
|
111
|
+
|
|
112
|
+
const {
|
|
113
|
+
databasesRestored = 0,
|
|
114
|
+
totalDatabases = this.totalDatabases || 1,
|
|
115
|
+
tablesRestored = 0,
|
|
116
|
+
totalTables = this.totalTables || 1,
|
|
117
|
+
bytesTransferred = 0
|
|
118
|
+
} = metrics;
|
|
119
|
+
|
|
120
|
+
// Calculate throughput and ETA
|
|
121
|
+
const elapsed = (now - this.restoreStartTime) / 1000;
|
|
122
|
+
const throughputMBps = elapsed > 0 ? (bytesTransferred / 1024 / 1024) / elapsed : 0;
|
|
123
|
+
const bytesRemaining = this.totalBytes - bytesTransferred;
|
|
124
|
+
const eta = throughputMBps > 0 ? Math.ceil(bytesRemaining / (throughputMBps * 1024 * 1024)) : 0;
|
|
125
|
+
|
|
126
|
+
// Format progress
|
|
127
|
+
const dbPct = Math.round((databasesRestored / totalDatabases) * 100);
|
|
128
|
+
const tablePct = Math.round((tablesRestored / totalTables) * 100);
|
|
129
|
+
const dbBar = this._progressBar(databasesRestored, totalDatabases);
|
|
130
|
+
const tableBar = this._progressBar(tablesRestored, totalTables);
|
|
131
|
+
const etaStr = eta > 0 ? `~${eta}s` : 'finishing...';
|
|
132
|
+
|
|
133
|
+
// Move up and overwrite
|
|
134
|
+
process.stdout.write(ANSI.MOVE_UP(this.progressLines));
|
|
135
|
+
|
|
136
|
+
process.stdout.write(ANSI.CLEAR_LINE);
|
|
137
|
+
console.log(` Databases: ${String(databasesRestored).padStart(2)}/${totalDatabases} ${dbBar} ${String(dbPct).padStart(3)}%`);
|
|
138
|
+
|
|
139
|
+
process.stdout.write(ANSI.CLEAR_LINE);
|
|
140
|
+
console.log(` Tables: ${String(tablesRestored).padStart(3)}/${totalTables} ${tableBar} ${String(tablePct).padStart(3)}%`);
|
|
141
|
+
|
|
142
|
+
process.stdout.write(ANSI.CLEAR_LINE);
|
|
143
|
+
console.log(` Speed: ${throughputMBps.toFixed(1)} MB/s | ETA: ${etaStr}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Complete restore progress (replace with summary)
|
|
148
|
+
*/
|
|
149
|
+
completeRestore(metrics) {
|
|
150
|
+
if (this.enabled && this.progressLines > 0) {
|
|
151
|
+
// Move up and clear progress lines
|
|
152
|
+
process.stdout.write(ANSI.MOVE_UP(this.progressLines));
|
|
153
|
+
for (let i = 0; i < this.progressLines; i++) {
|
|
154
|
+
process.stdout.write(ANSI.CLEAR_LINE + '\n');
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write(ANSI.MOVE_UP(this.progressLines));
|
|
157
|
+
process.stdout.write(ANSI.SHOW_CURSOR);
|
|
158
|
+
this.progressLines = 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const duration = ((metrics.endTime || Date.now()) - this.restoreStartTime) / 1000;
|
|
162
|
+
const mb = (metrics.bytesTransferred / 1024 / 1024).toFixed(1);
|
|
163
|
+
|
|
164
|
+
console.log(`${ANSI.GREEN}[✓]${ANSI.RESET} Restored ${metrics.databasesRestored} database${metrics.databasesRestored !== 1 ? 's' : ''} (${mb} MB in ${duration.toFixed(1)}s)`);
|
|
165
|
+
console.log('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Show final ready message
|
|
170
|
+
*/
|
|
171
|
+
showReady(config = {}) {
|
|
172
|
+
const port = config.port || 5432;
|
|
173
|
+
const host = config.host || '127.0.0.1';
|
|
174
|
+
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(`${ANSI.GREEN}${ANSI.BOLD}✨ READY${ANSI.RESET}: postgresql://${host}:${port}/<database>`);
|
|
177
|
+
console.log(`${ANSI.DIM}Press Ctrl+C to stop${ANSI.RESET}`);
|
|
178
|
+
console.log('');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Generate progress bar
|
|
183
|
+
*/
|
|
184
|
+
_progressBar(current, total, width = 16) {
|
|
185
|
+
const pct = total > 0 ? current / total : 0;
|
|
186
|
+
const filled = Math.round(pct * width);
|
|
187
|
+
const empty = width - filled;
|
|
188
|
+
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Mask sensitive URL parts
|
|
193
|
+
*/
|
|
194
|
+
_maskUrl(url) {
|
|
195
|
+
try {
|
|
196
|
+
const u = new URL(url);
|
|
197
|
+
return `${u.protocol}//${u.host}/${u.pathname.split('/')[1] || ''}`;
|
|
198
|
+
} catch {
|
|
199
|
+
return url.replace(/:[^:@]+@/, ':***@');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Cleanup (show cursor if hidden)
|
|
205
|
+
*/
|
|
206
|
+
cleanup() {
|
|
207
|
+
if (this.enabled) {
|
|
208
|
+
process.stdout.write(ANSI.SHOW_CURSOR);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|