pgserve 1.0.1 → 1.0.4
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/.husky/pre-commit +2 -0
- package/bin/pglite-server.js +2 -1
- package/eslint.config.js +54 -0
- package/knip.json +9 -0
- package/package.json +24 -4
- package/scripts/test-npx.sh +51 -0
- package/src/cluster.js +1 -3
- package/src/dashboard.js +211 -0
- package/src/index.js +2 -0
- package/src/postgres.js +5 -4
- package/src/protocol.js +0 -1
- package/src/restore.js +587 -0
- package/src/router.js +56 -2
- package/src/sync.js +2 -4
package/bin/pglite-server.js
CHANGED
|
@@ -16,7 +16,7 @@ import { startClusterServer } from '../src/cluster.js';
|
|
|
16
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
|
|
18
18
|
// Global error handlers
|
|
19
|
-
process.on('unhandledRejection', (reason,
|
|
19
|
+
process.on('unhandledRejection', (reason, _promise) => {
|
|
20
20
|
console.error('Unhandled Promise Rejection:', reason);
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -158,6 +158,7 @@ function parseArgs() {
|
|
|
158
158
|
case 'help':
|
|
159
159
|
printHelp();
|
|
160
160
|
process.exit(0);
|
|
161
|
+
// falls through (unreachable - exit above)
|
|
161
162
|
|
|
162
163
|
default:
|
|
163
164
|
if (arg.startsWith('-')) {
|
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,9 @@
|
|
|
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/**", "scripts/**"],
|
|
6
|
+
"ignoreDependencies": ["pino-pretty"],
|
|
7
|
+
"ignoreBinaries": ["scripts/test-npx.sh"],
|
|
8
|
+
"ignoreExportsUsedInFile": true
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pgserve",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -9,7 +9,13 @@
|
|
|
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
|
+
"test:npx": "scripts/test-npx.sh",
|
|
17
|
+
"prepublishOnly": "npm run lint && npm run deadcode && npm run test:npx",
|
|
18
|
+
"prepare": "husky"
|
|
13
19
|
},
|
|
14
20
|
"keywords": [
|
|
15
21
|
"postgresql",
|
|
@@ -31,20 +37,34 @@
|
|
|
31
37
|
"homepage": "https://github.com/namastexlabs/pgserve#readme",
|
|
32
38
|
"dependencies": {
|
|
33
39
|
"pg": "^8.16.3",
|
|
40
|
+
"pg-copy-streams": "^7.0.0",
|
|
34
41
|
"pino": "^10.1.0",
|
|
35
42
|
"pino-pretty": "^13.1.2"
|
|
36
43
|
},
|
|
37
44
|
"optionalDependencies": {
|
|
38
|
-
"@embedded-postgres/linux-x64": "17.7.0-beta.15",
|
|
39
45
|
"@embedded-postgres/darwin-arm64": "17.7.0-beta.15",
|
|
40
46
|
"@embedded-postgres/darwin-x64": "17.7.0-beta.15",
|
|
47
|
+
"@embedded-postgres/linux-x64": "17.7.0-beta.15",
|
|
41
48
|
"@embedded-postgres/win32-x64": "17.7.0-beta.15"
|
|
42
49
|
},
|
|
43
50
|
"devDependencies": {
|
|
51
|
+
"@electric-sql/pglite": "^0.2.17",
|
|
44
52
|
"better-sqlite3": "^11.7.0",
|
|
45
|
-
"
|
|
53
|
+
"eslint": "^9.39.1",
|
|
54
|
+
"eslint-plugin-unused-imports": "^4.3.0",
|
|
55
|
+
"husky": "^9.1.7",
|
|
56
|
+
"knip": "^5.71.0",
|
|
57
|
+
"lint-staged": "^16.2.7"
|
|
46
58
|
},
|
|
47
59
|
"engines": {
|
|
48
60
|
"node": ">=18.0.0"
|
|
61
|
+
},
|
|
62
|
+
"lint-staged": {
|
|
63
|
+
"src/**/*.js": [
|
|
64
|
+
"eslint --fix"
|
|
65
|
+
],
|
|
66
|
+
"bin/**/*.js": [
|
|
67
|
+
"eslint --fix"
|
|
68
|
+
]
|
|
49
69
|
}
|
|
50
70
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Test that the package works with npx (simulates fresh install)
|
|
3
|
+
# This catches path resolution issues that static analysis can't detect
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
echo "=== Testing npx compatibility ==="
|
|
8
|
+
|
|
9
|
+
# Create temp directory
|
|
10
|
+
TEST_DIR=$(mktemp -d)
|
|
11
|
+
trap "rm -rf $TEST_DIR" EXIT
|
|
12
|
+
|
|
13
|
+
# Pack the current package
|
|
14
|
+
echo "Packing package..."
|
|
15
|
+
PACK_FILE=$(npm pack --pack-destination "$TEST_DIR" 2>/dev/null | tail -1)
|
|
16
|
+
|
|
17
|
+
# Extract and install in isolated environment
|
|
18
|
+
echo "Installing in isolated environment..."
|
|
19
|
+
cd "$TEST_DIR"
|
|
20
|
+
npm init -y > /dev/null 2>&1
|
|
21
|
+
npm install "$PACK_FILE" > /dev/null 2>&1
|
|
22
|
+
|
|
23
|
+
# Test that it starts (with timeout)
|
|
24
|
+
echo "Testing server startup..."
|
|
25
|
+
timeout 8 npx pgserve --no-cluster --port 15432 > output.log 2>&1 &
|
|
26
|
+
PID=$!
|
|
27
|
+
|
|
28
|
+
# Wait for ready signal
|
|
29
|
+
for i in {1..20}; do
|
|
30
|
+
if grep -q "READY" output.log 2>/dev/null; then
|
|
31
|
+
echo "✓ Server started successfully"
|
|
32
|
+
kill $PID 2>/dev/null || true
|
|
33
|
+
wait $PID 2>/dev/null || true
|
|
34
|
+
echo "=== npx test PASSED ==="
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
if ! kill -0 $PID 2>/dev/null; then
|
|
38
|
+
echo "✗ Server exited unexpectedly"
|
|
39
|
+
cat output.log
|
|
40
|
+
echo "=== npx test FAILED ==="
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
sleep 0.5
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
# Timeout
|
|
47
|
+
kill $PID 2>/dev/null || true
|
|
48
|
+
echo "✗ Server did not start within timeout"
|
|
49
|
+
cat output.log
|
|
50
|
+
echo "=== npx test FAILED ==="
|
|
51
|
+
exit 1
|
package/src/cluster.js
CHANGED
|
@@ -77,7 +77,7 @@ class ClusterRouter extends EventEmitter {
|
|
|
77
77
|
await this.adminClient.connect();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
return new Promise((resolve,
|
|
80
|
+
return new Promise((resolve, _reject) => {
|
|
81
81
|
this.server = net.createServer({
|
|
82
82
|
allowHalfOpen: false,
|
|
83
83
|
pauseOnConnect: true
|
|
@@ -318,5 +318,3 @@ export async function startClusterServer(options = {}) {
|
|
|
318
318
|
return router;
|
|
319
319
|
}
|
|
320
320
|
}
|
|
321
|
-
|
|
322
|
-
export default startClusterServer;
|
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
|
+
}
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
export { MultiTenantRouter, startMultiTenantServer } from './router.js';
|
|
10
10
|
export { PostgresManager } from './postgres.js';
|
|
11
11
|
export { SyncManager } from './sync.js';
|
|
12
|
+
export { RestoreManager } from './restore.js';
|
|
13
|
+
export { Dashboard } from './dashboard.js';
|
|
12
14
|
|
|
13
15
|
// Default export
|
|
14
16
|
export { startMultiTenantServer as default } from './router.js';
|
package/src/postgres.js
CHANGED
|
@@ -18,7 +18,6 @@ import os from 'os';
|
|
|
18
18
|
import path from 'path';
|
|
19
19
|
import fs from 'fs';
|
|
20
20
|
import crypto from 'crypto';
|
|
21
|
-
import net from 'net';
|
|
22
21
|
|
|
23
22
|
// Resolve binary paths from embedded-postgres platform packages
|
|
24
23
|
function getBinaryPaths() {
|
|
@@ -38,10 +37,12 @@ function getBinaryPaths() {
|
|
|
38
37
|
throw new Error(`Unsupported platform: ${platform}-${arch}`);
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
// Find the package in node_modules
|
|
40
|
+
// Find the package in node_modules (check multiple locations for npx/pnpm/npm compatibility)
|
|
42
41
|
const possiblePaths = [
|
|
43
42
|
path.join(process.cwd(), 'node_modules', pkgName, 'native', 'bin'),
|
|
44
43
|
path.join(import.meta.dirname, '..', 'node_modules', pkgName, 'native', 'bin'),
|
|
44
|
+
path.join(import.meta.dirname, '..', '..', pkgName, 'native', 'bin'), // Hoisted (npx flat structure)
|
|
45
|
+
path.join(import.meta.dirname, '..', '..', '..', pkgName, 'native', 'bin'), // Extra level for some package managers
|
|
45
46
|
];
|
|
46
47
|
|
|
47
48
|
for (const binDir of possiblePaths) {
|
|
@@ -187,8 +188,8 @@ export class PostgresManager {
|
|
|
187
188
|
// Clean up password file
|
|
188
189
|
try {
|
|
189
190
|
await fs.promises.unlink(passwordFile);
|
|
190
|
-
} catch
|
|
191
|
-
// Ignore
|
|
191
|
+
} catch {
|
|
192
|
+
// Ignore cleanup errors
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
if (code === 0) {
|
package/src/protocol.js
CHANGED
|
@@ -14,7 +14,6 @@ const PROTOCOL_VERSION_3 = 196608;
|
|
|
14
14
|
const SSL_REQUEST_CODE = 80877103; // PostgreSQL SSL negotiation request
|
|
15
15
|
const GSSAPI_REQUEST_CODE = 80877104; // PostgreSQL GSSAPI encryption request
|
|
16
16
|
const CANCEL_REQUEST_CODE = 80877102; // PostgreSQL cancel request
|
|
17
|
-
const DATABASE_KEY = Buffer.from('database\0');
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
19
|
* Parse PostgreSQL startup message to extract connection parameters
|
package/src/restore.js
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RestoreManager - Automatic restore from external PostgreSQL on startup
|
|
3
|
+
*
|
|
4
|
+
* High-performance restore using:
|
|
5
|
+
* - Parallel database restore (Promise.all)
|
|
6
|
+
* - COPY protocol for bulk data transfer (pg-copy-streams)
|
|
7
|
+
* - Unix sockets for local connections (~30% faster)
|
|
8
|
+
* - Binary format COPY (~2x faster than text)
|
|
9
|
+
*
|
|
10
|
+
* Tech Council Design Principles:
|
|
11
|
+
* - nayr: Question assumptions, root cause focus
|
|
12
|
+
* - oettam: Benchmark-driven, measure p99 latency
|
|
13
|
+
* - jt: Ship simple, delete complexity
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import pg from 'pg';
|
|
17
|
+
import { from as copyFrom, to as copyTo } from 'pg-copy-streams';
|
|
18
|
+
import pino from 'pino';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Match database name against patterns (supports wildcards)
|
|
22
|
+
* Reused from sync.js for consistency
|
|
23
|
+
* @param {string} dbName - Database name to check
|
|
24
|
+
* @param {string[]} patterns - Array of patterns (supports * wildcard)
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function matchesPattern(dbName, patterns) {
|
|
28
|
+
if (!patterns || patterns.length === 0) return true; // No filter = restore all
|
|
29
|
+
|
|
30
|
+
return patterns.some(pattern => {
|
|
31
|
+
if (pattern.includes('*')) {
|
|
32
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
33
|
+
return regex.test(dbName);
|
|
34
|
+
}
|
|
35
|
+
return dbName === pattern;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* RestoreManager - Handles automatic restore from external PostgreSQL
|
|
41
|
+
*/
|
|
42
|
+
export class RestoreManager {
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
this.sourceUrl = options.sourceUrl; // External PostgreSQL URL
|
|
45
|
+
this.patterns = options.patterns || []; // Database patterns ["myapp", "tenant_*"]
|
|
46
|
+
this.targetPort = options.targetPort; // Local embedded PostgreSQL port
|
|
47
|
+
this.targetSocketPath = options.targetSocketPath; // Unix socket path (optional)
|
|
48
|
+
|
|
49
|
+
this.logger = options.logger || pino({ level: options.logLevel || 'info' }).child({ component: 'restore' });
|
|
50
|
+
|
|
51
|
+
// Connection pools (lazy initialized)
|
|
52
|
+
this.sourcePool = null;
|
|
53
|
+
|
|
54
|
+
// Performance tuning - parallel restore limits
|
|
55
|
+
this.maxParallelDatabases = options.maxParallelDatabases || 4;
|
|
56
|
+
this.maxParallelTables = options.maxParallelTables || 8;
|
|
57
|
+
|
|
58
|
+
// Timeout handling
|
|
59
|
+
this.restoreTimeout = options.restoreTimeout || 60000; // 60s default
|
|
60
|
+
|
|
61
|
+
// Progress callback for dashboard
|
|
62
|
+
this.onProgress = options.onProgress || (() => {});
|
|
63
|
+
|
|
64
|
+
// Totals for progress tracking
|
|
65
|
+
this.totalDatabases = 0;
|
|
66
|
+
this.totalTables = 0;
|
|
67
|
+
this.totalBytes = 0;
|
|
68
|
+
|
|
69
|
+
// Metrics collection
|
|
70
|
+
this.metrics = {
|
|
71
|
+
startTime: 0,
|
|
72
|
+
endTime: 0,
|
|
73
|
+
databasesRestored: 0,
|
|
74
|
+
tablesRestored: 0,
|
|
75
|
+
rowsRestored: 0,
|
|
76
|
+
bytesTransferred: 0,
|
|
77
|
+
errors: []
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Main entry point - restore databases from external PostgreSQL
|
|
83
|
+
* Called from router.js after pgManager.start(), before SyncManager
|
|
84
|
+
*
|
|
85
|
+
* @param {PostgresManager} pgManager - Local PostgreSQL manager
|
|
86
|
+
* @returns {Promise<Object>} Restore result with metrics
|
|
87
|
+
*/
|
|
88
|
+
async restore(pgManager) {
|
|
89
|
+
if (!this.sourceUrl) {
|
|
90
|
+
return { skipped: true, reason: 'no sourceUrl configured' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.metrics.startTime = Date.now();
|
|
94
|
+
this.logger.info({ source: this.sourceUrl.replace(/:[^:@]+@/, ':***@') }, 'Starting automatic restore from external PostgreSQL');
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Initialize connection to external PostgreSQL
|
|
98
|
+
const connected = await this._initSourcePool();
|
|
99
|
+
if (!connected) {
|
|
100
|
+
return { skipped: true, reason: 'external PostgreSQL unreachable' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Discover databases matching patterns
|
|
104
|
+
const databases = await this._discoverDatabases();
|
|
105
|
+
|
|
106
|
+
if (databases.length === 0) {
|
|
107
|
+
this.logger.info('No databases found matching sync patterns on external PostgreSQL');
|
|
108
|
+
return { skipped: true, reason: 'no matching databases found' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.logger.info({ count: databases.length, databases }, 'Found databases to restore');
|
|
112
|
+
|
|
113
|
+
// Set totals for progress tracking
|
|
114
|
+
this.totalDatabases = databases.length;
|
|
115
|
+
|
|
116
|
+
// Restore databases in parallel (with controlled concurrency)
|
|
117
|
+
await this._restoreDatabasesParallel(databases, pgManager);
|
|
118
|
+
|
|
119
|
+
this.metrics.endTime = Date.now();
|
|
120
|
+
const duration = this.metrics.endTime - this.metrics.startTime;
|
|
121
|
+
|
|
122
|
+
this.logger.info({
|
|
123
|
+
databasesRestored: this.metrics.databasesRestored,
|
|
124
|
+
tablesRestored: this.metrics.tablesRestored,
|
|
125
|
+
rowsRestored: this.metrics.rowsRestored,
|
|
126
|
+
bytesTransferred: this.metrics.bytesTransferred,
|
|
127
|
+
throughputMBps: ((this.metrics.bytesTransferred / 1024 / 1024) / (duration / 1000)).toFixed(2),
|
|
128
|
+
durationMs: duration,
|
|
129
|
+
errors: this.metrics.errors.length
|
|
130
|
+
}, 'Restore completed');
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
success: true,
|
|
134
|
+
metrics: { ...this.metrics }
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
} catch (error) {
|
|
138
|
+
this.logger.error({ err: error }, 'Restore failed');
|
|
139
|
+
return { success: false, error: error.message };
|
|
140
|
+
} finally {
|
|
141
|
+
await this._closeSourcePool();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Initialize connection pool to external PostgreSQL
|
|
147
|
+
* @returns {Promise<boolean>} true if connected successfully
|
|
148
|
+
*/
|
|
149
|
+
async _initSourcePool() {
|
|
150
|
+
try {
|
|
151
|
+
this.sourcePool = new pg.Pool({
|
|
152
|
+
connectionString: this.sourceUrl,
|
|
153
|
+
max: 3, // Small pool - just for discovery
|
|
154
|
+
connectionTimeoutMillis: 5000,
|
|
155
|
+
idleTimeoutMillis: 10000
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Test connection
|
|
159
|
+
await this.sourcePool.query('SELECT 1');
|
|
160
|
+
this.logger.debug('Connected to external PostgreSQL');
|
|
161
|
+
return true;
|
|
162
|
+
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
|
|
165
|
+
this.logger.warn({ err: error.message }, 'External PostgreSQL unreachable, skipping restore');
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Close source connection pool
|
|
174
|
+
*/
|
|
175
|
+
async _closeSourcePool() {
|
|
176
|
+
if (this.sourcePool) {
|
|
177
|
+
await this.sourcePool.end();
|
|
178
|
+
this.sourcePool = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Discover databases on external PostgreSQL matching patterns
|
|
184
|
+
* @returns {Promise<string[]>} List of database names
|
|
185
|
+
*/
|
|
186
|
+
async _discoverDatabases() {
|
|
187
|
+
const result = await this.sourcePool.query(`
|
|
188
|
+
SELECT datname FROM pg_database
|
|
189
|
+
WHERE datistemplate = false
|
|
190
|
+
AND datname NOT IN ('postgres', 'template0', 'template1')
|
|
191
|
+
ORDER BY datname
|
|
192
|
+
`);
|
|
193
|
+
|
|
194
|
+
// Filter by patterns
|
|
195
|
+
return result.rows
|
|
196
|
+
.map(r => r.datname)
|
|
197
|
+
.filter(name => matchesPattern(name, this.patterns));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Restore databases in parallel with controlled concurrency
|
|
202
|
+
* @param {string[]} databases - Database names to restore
|
|
203
|
+
* @param {PostgresManager} pgManager - Local PostgreSQL manager
|
|
204
|
+
*/
|
|
205
|
+
async _restoreDatabasesParallel(databases, pgManager) {
|
|
206
|
+
// Batch databases to limit concurrency
|
|
207
|
+
const batches = [];
|
|
208
|
+
for (let i = 0; i < databases.length; i += this.maxParallelDatabases) {
|
|
209
|
+
batches.push(databases.slice(i, i + this.maxParallelDatabases));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const batch of batches) {
|
|
213
|
+
const results = await Promise.allSettled(
|
|
214
|
+
batch.map(dbName => this._restoreDatabase(dbName, pgManager))
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Track failures but continue
|
|
218
|
+
for (let i = 0; i < results.length; i++) {
|
|
219
|
+
if (results[i].status === 'rejected') {
|
|
220
|
+
this.metrics.errors.push({
|
|
221
|
+
database: batch[i],
|
|
222
|
+
error: results[i].reason.message
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (this.metrics.errors.length > 0) {
|
|
229
|
+
this.logger.warn({
|
|
230
|
+
failedCount: this.metrics.errors.length,
|
|
231
|
+
totalCount: databases.length
|
|
232
|
+
}, 'Some databases failed to restore');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Restore a single database: create DB, schema, data
|
|
238
|
+
* @param {string} dbName - Database name
|
|
239
|
+
* @param {PostgresManager} pgManager - Local PostgreSQL manager
|
|
240
|
+
*/
|
|
241
|
+
async _restoreDatabase(dbName, pgManager) {
|
|
242
|
+
this.logger.info({ dbName }, 'Restoring database');
|
|
243
|
+
const startTime = Date.now();
|
|
244
|
+
|
|
245
|
+
// Step 1: Create database locally
|
|
246
|
+
await pgManager.createDatabase(dbName);
|
|
247
|
+
|
|
248
|
+
// Step 2: Create connection pools for this specific database
|
|
249
|
+
const sourceDbPool = await this._createSourceDbPool(dbName);
|
|
250
|
+
const targetDbPool = await this._createTargetDbPool(dbName);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
// Step 3: Restore schema (types, tables, indexes, FKs)
|
|
254
|
+
await this._restoreSchema(sourceDbPool, targetDbPool, dbName);
|
|
255
|
+
|
|
256
|
+
// Step 4: Discover tables and copy data in parallel
|
|
257
|
+
const tables = await this._discoverTables(sourceDbPool);
|
|
258
|
+
this.totalTables += tables.length; // Track total for progress
|
|
259
|
+
if (tables.length > 0) {
|
|
260
|
+
await this._restoreTablesParallel(sourceDbPool, targetDbPool, tables);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Step 5: Restore sequences (after data for correct values)
|
|
264
|
+
await this._restoreSequences(sourceDbPool, targetDbPool);
|
|
265
|
+
|
|
266
|
+
this.metrics.databasesRestored++;
|
|
267
|
+
const duration = Date.now() - startTime;
|
|
268
|
+
this.logger.info({ dbName, durationMs: duration }, 'Database restored successfully');
|
|
269
|
+
|
|
270
|
+
} finally {
|
|
271
|
+
await sourceDbPool.end();
|
|
272
|
+
await targetDbPool.end();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create connection pool to specific database on external PostgreSQL
|
|
278
|
+
* @param {string} dbName - Database name
|
|
279
|
+
* @returns {Promise<pg.Pool>}
|
|
280
|
+
*/
|
|
281
|
+
async _createSourceDbPool(dbName) {
|
|
282
|
+
const url = new URL(this.sourceUrl);
|
|
283
|
+
url.pathname = `/${dbName}`;
|
|
284
|
+
|
|
285
|
+
const pool = new pg.Pool({
|
|
286
|
+
connectionString: url.toString(),
|
|
287
|
+
max: this.maxParallelTables,
|
|
288
|
+
connectionTimeoutMillis: 5000,
|
|
289
|
+
idleTimeoutMillis: 10000
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return pool;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Create connection pool to specific database on local embedded PostgreSQL
|
|
297
|
+
* Uses Unix socket when available for ~30% faster connections
|
|
298
|
+
* @param {string} dbName - Database name
|
|
299
|
+
* @returns {Promise<pg.Pool>}
|
|
300
|
+
*/
|
|
301
|
+
async _createTargetDbPool(dbName) {
|
|
302
|
+
const config = {
|
|
303
|
+
database: dbName,
|
|
304
|
+
user: 'postgres',
|
|
305
|
+
password: 'postgres',
|
|
306
|
+
max: this.maxParallelTables,
|
|
307
|
+
connectionTimeoutMillis: 5000,
|
|
308
|
+
idleTimeoutMillis: 10000
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Prefer Unix socket for faster local connections
|
|
312
|
+
if (this.targetSocketPath) {
|
|
313
|
+
config.host = this.targetSocketPath.replace(/\/\.s\.PGSQL\.\d+$/, '');
|
|
314
|
+
config.port = this.targetPort;
|
|
315
|
+
} else {
|
|
316
|
+
config.host = '127.0.0.1';
|
|
317
|
+
config.port = this.targetPort;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return new pg.Pool(config);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Restore schema: ENUMs, tables, indexes, foreign keys
|
|
325
|
+
* Order matters: types → tables → indexes → FKs
|
|
326
|
+
* @param {pg.Pool} sourcePool - External database pool
|
|
327
|
+
* @param {pg.Pool} targetPool - Local database pool
|
|
328
|
+
* @param {string} dbName - Database name (for logging)
|
|
329
|
+
*/
|
|
330
|
+
async _restoreSchema(sourcePool, targetPool, dbName) {
|
|
331
|
+
// 1. Restore ENUM types
|
|
332
|
+
await this._restoreEnums(sourcePool, targetPool);
|
|
333
|
+
|
|
334
|
+
// 2. Restore tables (structure only, no data yet)
|
|
335
|
+
await this._restoreTables(sourcePool, targetPool);
|
|
336
|
+
|
|
337
|
+
this.logger.debug({ dbName }, 'Schema restored');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Restore ENUM types from external database
|
|
342
|
+
*/
|
|
343
|
+
async _restoreEnums(sourcePool, targetPool) {
|
|
344
|
+
const result = await sourcePool.query(`
|
|
345
|
+
SELECT n.nspname as schema, t.typname as name,
|
|
346
|
+
array_agg(e.enumlabel ORDER BY e.enumsortorder) as values
|
|
347
|
+
FROM pg_type t
|
|
348
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
349
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
350
|
+
WHERE n.nspname = 'public'
|
|
351
|
+
GROUP BY n.nspname, t.typname
|
|
352
|
+
`);
|
|
353
|
+
|
|
354
|
+
for (const enumType of result.rows) {
|
|
355
|
+
const values = enumType.values.map(v => `'${v.replace(/'/g, "''")}'`).join(', ');
|
|
356
|
+
const createSql = `CREATE TYPE "${enumType.name}" AS ENUM (${values})`;
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
await targetPool.query(createSql);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (err.code !== '42710') throw err; // 42710 = type already exists
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Restore table structures from external database
|
|
368
|
+
*/
|
|
369
|
+
async _restoreTables(sourcePool, targetPool) {
|
|
370
|
+
// Get table list
|
|
371
|
+
const tablesResult = await sourcePool.query(`
|
|
372
|
+
SELECT table_name FROM information_schema.tables
|
|
373
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
374
|
+
ORDER BY table_name
|
|
375
|
+
`);
|
|
376
|
+
|
|
377
|
+
for (const row of tablesResult.rows) {
|
|
378
|
+
const tableName = row.table_name;
|
|
379
|
+
const createSql = await this._getTableCreateStatement(sourcePool, tableName);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await targetPool.query(createSql);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
if (err.code !== '42P07') throw err; // 42P07 = table already exists
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Generate CREATE TABLE statement from information_schema
|
|
391
|
+
* @param {pg.Pool} sourcePool - Source database pool
|
|
392
|
+
* @param {string} tableName - Table name
|
|
393
|
+
* @returns {Promise<string>} CREATE TABLE SQL
|
|
394
|
+
*/
|
|
395
|
+
async _getTableCreateStatement(sourcePool, tableName) {
|
|
396
|
+
// Get columns
|
|
397
|
+
const columnsResult = await sourcePool.query(`
|
|
398
|
+
SELECT column_name, data_type, udt_name, character_maximum_length,
|
|
399
|
+
column_default, is_nullable, numeric_precision, numeric_scale
|
|
400
|
+
FROM information_schema.columns
|
|
401
|
+
WHERE table_schema = 'public' AND table_name = $1
|
|
402
|
+
ORDER BY ordinal_position
|
|
403
|
+
`, [tableName]);
|
|
404
|
+
|
|
405
|
+
const columns = columnsResult.rows.map(col => {
|
|
406
|
+
let type = col.data_type;
|
|
407
|
+
|
|
408
|
+
// Handle special types
|
|
409
|
+
if (type === 'USER-DEFINED') {
|
|
410
|
+
type = `"${col.udt_name}"`; // ENUM or custom type
|
|
411
|
+
} else if (type === 'character varying' && col.character_maximum_length) {
|
|
412
|
+
type = `varchar(${col.character_maximum_length})`;
|
|
413
|
+
} else if (type === 'character' && col.character_maximum_length) {
|
|
414
|
+
type = `char(${col.character_maximum_length})`;
|
|
415
|
+
} else if (type === 'numeric' && col.numeric_precision) {
|
|
416
|
+
type = col.numeric_scale
|
|
417
|
+
? `numeric(${col.numeric_precision},${col.numeric_scale})`
|
|
418
|
+
: `numeric(${col.numeric_precision})`;
|
|
419
|
+
} else if (type === 'ARRAY') {
|
|
420
|
+
type = `${col.udt_name.replace(/^_/, '')}[]`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let colDef = `"${col.column_name}" ${type}`;
|
|
424
|
+
|
|
425
|
+
if (col.column_default) {
|
|
426
|
+
colDef += ` DEFAULT ${col.column_default}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (col.is_nullable === 'NO') {
|
|
430
|
+
colDef += ' NOT NULL';
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return colDef;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Get primary key
|
|
437
|
+
const pkResult = await sourcePool.query(`
|
|
438
|
+
SELECT a.attname
|
|
439
|
+
FROM pg_index i
|
|
440
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
441
|
+
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
|
442
|
+
ORDER BY array_position(i.indkey, a.attnum)
|
|
443
|
+
`, [tableName]);
|
|
444
|
+
|
|
445
|
+
if (pkResult.rows.length > 0) {
|
|
446
|
+
const pkCols = pkResult.rows.map(r => `"${r.attname}"`).join(', ');
|
|
447
|
+
columns.push(`PRIMARY KEY (${pkCols})`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return `CREATE TABLE "${tableName}" (\n ${columns.join(',\n ')}\n)`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Discover tables in the database
|
|
455
|
+
* @param {pg.Pool} sourcePool - Source database pool
|
|
456
|
+
* @returns {Promise<string[]>} Table names
|
|
457
|
+
*/
|
|
458
|
+
async _discoverTables(sourcePool) {
|
|
459
|
+
const result = await sourcePool.query(`
|
|
460
|
+
SELECT table_name FROM information_schema.tables
|
|
461
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
462
|
+
ORDER BY table_name
|
|
463
|
+
`);
|
|
464
|
+
|
|
465
|
+
return result.rows.map(r => r.table_name);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Restore table data in parallel using COPY protocol
|
|
470
|
+
* @param {pg.Pool} sourcePool - Source database pool
|
|
471
|
+
* @param {pg.Pool} targetPool - Target database pool
|
|
472
|
+
* @param {string[]} tables - Table names
|
|
473
|
+
*/
|
|
474
|
+
async _restoreTablesParallel(sourcePool, targetPool, tables) {
|
|
475
|
+
// Batch tables to limit concurrency
|
|
476
|
+
const batches = [];
|
|
477
|
+
for (let i = 0; i < tables.length; i += this.maxParallelTables) {
|
|
478
|
+
batches.push(tables.slice(i, i + this.maxParallelTables));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const batch of batches) {
|
|
482
|
+
await Promise.all(
|
|
483
|
+
batch.map(table => this._copyTableData(sourcePool, targetPool, table))
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Copy table data using binary COPY protocol (high performance)
|
|
490
|
+
* @param {pg.Pool} sourcePool - Source database pool
|
|
491
|
+
* @param {pg.Pool} targetPool - Target database pool
|
|
492
|
+
* @param {string} tableName - Table name
|
|
493
|
+
*/
|
|
494
|
+
async _copyTableData(sourcePool, targetPool, tableName) {
|
|
495
|
+
// Get row count first (for metrics)
|
|
496
|
+
const countResult = await sourcePool.query(
|
|
497
|
+
`SELECT COUNT(*)::int as count FROM "${tableName}"`
|
|
498
|
+
);
|
|
499
|
+
const rowCount = countResult.rows[0].count;
|
|
500
|
+
|
|
501
|
+
if (rowCount === 0) {
|
|
502
|
+
this.logger.debug({ tableName, rows: 0 }, 'Skipping empty table');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Stream COPY: source → target
|
|
507
|
+
const sourceClient = await sourcePool.connect();
|
|
508
|
+
const targetClient = await targetPool.connect();
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const copyToStream = sourceClient.query(
|
|
512
|
+
copyTo(`COPY "${tableName}" TO STDOUT WITH (FORMAT binary)`)
|
|
513
|
+
);
|
|
514
|
+
const copyFromStream = targetClient.query(
|
|
515
|
+
copyFrom(`COPY "${tableName}" FROM STDIN WITH (FORMAT binary)`)
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
// Track bytes transferred
|
|
519
|
+
let bytesTransferred = 0;
|
|
520
|
+
|
|
521
|
+
await new Promise((resolve, reject) => {
|
|
522
|
+
copyToStream.on('error', reject);
|
|
523
|
+
copyFromStream.on('error', reject);
|
|
524
|
+
|
|
525
|
+
copyToStream.on('data', chunk => {
|
|
526
|
+
bytesTransferred += chunk.length;
|
|
527
|
+
copyFromStream.write(chunk);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
copyToStream.on('end', () => {
|
|
531
|
+
copyFromStream.end();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
copyFromStream.on('finish', () => {
|
|
535
|
+
this.metrics.bytesTransferred += bytesTransferred;
|
|
536
|
+
this.metrics.rowsRestored += rowCount;
|
|
537
|
+
this.metrics.tablesRestored++;
|
|
538
|
+
resolve();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
this.logger.debug({ tableName, rows: rowCount, bytes: bytesTransferred }, 'Table data copied');
|
|
543
|
+
|
|
544
|
+
// Emit progress for dashboard
|
|
545
|
+
this.onProgress({
|
|
546
|
+
databasesRestored: this.metrics.databasesRestored,
|
|
547
|
+
totalDatabases: this.totalDatabases,
|
|
548
|
+
tablesRestored: this.metrics.tablesRestored,
|
|
549
|
+
totalTables: this.totalTables,
|
|
550
|
+
bytesTransferred: this.metrics.bytesTransferred,
|
|
551
|
+
totalBytes: this.totalBytes
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
} finally {
|
|
555
|
+
sourceClient.release();
|
|
556
|
+
targetClient.release();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Restore sequences to correct values (after data restore)
|
|
562
|
+
* @param {pg.Pool} sourcePool - Source database pool
|
|
563
|
+
* @param {pg.Pool} targetPool - Target database pool
|
|
564
|
+
*/
|
|
565
|
+
async _restoreSequences(sourcePool, targetPool) {
|
|
566
|
+
// Get all sequences
|
|
567
|
+
const seqResult = await sourcePool.query(`
|
|
568
|
+
SELECT sequence_name FROM information_schema.sequences
|
|
569
|
+
WHERE sequence_schema = 'public'
|
|
570
|
+
`);
|
|
571
|
+
|
|
572
|
+
for (const seq of seqResult.rows) {
|
|
573
|
+
const seqName = seq.sequence_name;
|
|
574
|
+
|
|
575
|
+
// Get current value from source
|
|
576
|
+
const valueResult = await sourcePool.query(`SELECT last_value FROM "${seqName}"`);
|
|
577
|
+
const lastValue = valueResult.rows[0].last_value;
|
|
578
|
+
|
|
579
|
+
// Set on target
|
|
580
|
+
try {
|
|
581
|
+
await targetPool.query(`SELECT setval($1, $2, true)`, [seqName, lastValue]);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
this.logger.warn({ sequence: seqName, err: err.message }, 'Failed to restore sequence');
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
package/src/router.js
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import net from 'net';
|
|
16
16
|
import { PostgresManager } from './postgres.js';
|
|
17
17
|
import { SyncManager } from './sync.js';
|
|
18
|
+
import { RestoreManager } from './restore.js';
|
|
19
|
+
import { Dashboard } from './dashboard.js';
|
|
18
20
|
import { extractDatabaseNameFromSocket } from './protocol.js';
|
|
19
21
|
import { EventEmitter } from 'events';
|
|
20
22
|
import pino from 'pino';
|
|
@@ -87,8 +89,53 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
87
89
|
* Start multi-tenant router
|
|
88
90
|
*/
|
|
89
91
|
async start() {
|
|
92
|
+
// Initialize dashboard for informative CLI output
|
|
93
|
+
const dashboard = new Dashboard();
|
|
94
|
+
dashboard.showHeader({
|
|
95
|
+
port: this.port,
|
|
96
|
+
host: this.host,
|
|
97
|
+
memoryMode: this.memoryMode,
|
|
98
|
+
syncTo: this.syncTo
|
|
99
|
+
});
|
|
100
|
+
|
|
90
101
|
// Start PostgreSQL first
|
|
102
|
+
dashboard.stage('PostgreSQL binaries resolved');
|
|
91
103
|
await this.pgManager.start();
|
|
104
|
+
dashboard.stage('PostgreSQL started');
|
|
105
|
+
|
|
106
|
+
// Automatic restore from external PostgreSQL (if sync configured)
|
|
107
|
+
// This runs BEFORE SyncManager to restore data before enabling outbound sync
|
|
108
|
+
if (this.syncTo) {
|
|
109
|
+
const restoreManager = new RestoreManager({
|
|
110
|
+
sourceUrl: this.syncTo,
|
|
111
|
+
patterns: this.syncDatabases,
|
|
112
|
+
targetPort: this.pgPort,
|
|
113
|
+
targetSocketPath: this.pgManager.getSocketPath(),
|
|
114
|
+
logger: this.logger.child({ component: 'restore' }),
|
|
115
|
+
onProgress: (metrics) => dashboard.updateRestore(metrics)
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Start restore progress display
|
|
119
|
+
dashboard.startRestore(restoreManager.totalDatabases || 1);
|
|
120
|
+
|
|
121
|
+
const restoreResult = await restoreManager.restore(this.pgManager);
|
|
122
|
+
|
|
123
|
+
if (restoreResult.success) {
|
|
124
|
+
dashboard.completeRestore(restoreResult.metrics);
|
|
125
|
+
this.logger.info({
|
|
126
|
+
databases: restoreResult.metrics.databasesRestored,
|
|
127
|
+
tables: restoreResult.metrics.tablesRestored,
|
|
128
|
+
rows: restoreResult.metrics.rowsRestored,
|
|
129
|
+
bytes: restoreResult.metrics.bytesTransferred,
|
|
130
|
+
durationMs: restoreResult.metrics.endTime - restoreResult.metrics.startTime
|
|
131
|
+
}, 'Restored from external PostgreSQL');
|
|
132
|
+
} else if (restoreResult.skipped) {
|
|
133
|
+
// No progress to complete - was skipped
|
|
134
|
+
} else {
|
|
135
|
+
dashboard.cleanup();
|
|
136
|
+
this.logger.warn({ error: restoreResult.error }, 'Restore failed (continuing without restored data)');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
92
139
|
|
|
93
140
|
// Initialize SyncManager if configured (async replication)
|
|
94
141
|
if (this.syncTo) {
|
|
@@ -105,11 +152,14 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
105
152
|
|
|
106
153
|
// Initialize sync connections (non-blocking, runs in background)
|
|
107
154
|
this.syncManager.initialize(this.pgManager)
|
|
108
|
-
.then(() =>
|
|
155
|
+
.then(() => {
|
|
156
|
+
dashboard.stage('Sync manager initialized');
|
|
157
|
+
this.logger.info('Sync manager initialized');
|
|
158
|
+
})
|
|
109
159
|
.catch(err => this.logger.warn({ err: err.message }, 'Sync manager initialization failed (non-fatal)'));
|
|
110
160
|
}
|
|
111
161
|
|
|
112
|
-
return new Promise((resolve,
|
|
162
|
+
return new Promise((resolve, _reject) => {
|
|
113
163
|
// Create TCP server
|
|
114
164
|
this.server = net.createServer({
|
|
115
165
|
allowHalfOpen: false,
|
|
@@ -130,6 +180,10 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
130
180
|
// Start listening
|
|
131
181
|
this.server.listen(this.port, this.host, () => {
|
|
132
182
|
const socketPath = this.pgManager.getSocketPath();
|
|
183
|
+
|
|
184
|
+
dashboard.stage('TCP server listening');
|
|
185
|
+
dashboard.showReady({ port: this.port, host: this.host });
|
|
186
|
+
|
|
133
187
|
this.logger.info({
|
|
134
188
|
host: this.host,
|
|
135
189
|
port: this.port,
|
package/src/sync.js
CHANGED
|
@@ -46,9 +46,9 @@ export class SyncManager {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Initialize the SyncManager after PostgreSQL is ready
|
|
49
|
-
* @param {Object}
|
|
49
|
+
* @param {Object} _pgManager - PostgresManager instance (unused, reserved for future)
|
|
50
50
|
*/
|
|
51
|
-
async initialize(
|
|
51
|
+
async initialize(_pgManager) {
|
|
52
52
|
if (!this.targetUrl) {
|
|
53
53
|
throw new Error('SyncManager requires targetUrl');
|
|
54
54
|
}
|
|
@@ -340,5 +340,3 @@ export class SyncManager {
|
|
|
340
340
|
this.logger.info('Sync manager stopped');
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
|
-
|
|
344
|
-
export default SyncManager;
|