pgserve 0.1.5 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +11 -0
- package/.husky/pre-commit +2 -0
- package/README.md +281 -256
- package/bin/pglite-server.js +214 -396
- package/eslint.config.js +54 -0
- package/knip.json +8 -0
- package/package.json +32 -11
- package/src/cluster.js +320 -0
- package/src/dashboard.js +211 -0
- package/src/index.js +10 -171
- package/src/postgres.js +478 -0
- package/src/protocol.js +31 -7
- package/src/restore.js +587 -0
- package/src/router.js +172 -115
- package/src/sync.js +342 -0
- package/tests/benchmarks/runner.js +300 -155
- package/tests/sync-perf-test.js +150 -0
- package/src/detector.js +0 -105
- package/src/pool.js +0 -320
- package/src/ports.js +0 -114
- package/src/registry.js +0 -134
- package/src/server.js +0 -265
package/src/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
|
|
@@ -134,36 +133,59 @@ export function extractDatabaseName(data) {
|
|
|
134
133
|
}
|
|
135
134
|
}
|
|
136
135
|
|
|
136
|
+
// Pre-allocated buffer pool for startup message parsing (avoids allocation per connection)
|
|
137
|
+
const STARTUP_BUFFER_SIZE = 8192; // Max startup message is typically < 1KB
|
|
138
|
+
const bufferPool = [];
|
|
139
|
+
const MAX_POOL_SIZE = 100;
|
|
140
|
+
|
|
141
|
+
function acquireBuffer() {
|
|
142
|
+
return bufferPool.pop() || Buffer.allocUnsafe(STARTUP_BUFFER_SIZE);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function releaseBuffer(buf) {
|
|
146
|
+
if (bufferPool.length < MAX_POOL_SIZE) {
|
|
147
|
+
bufferPool.push(buf);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
137
151
|
/**
|
|
138
152
|
* Read startup message from socket and buffer it
|
|
153
|
+
* OPTIMIZED: Uses pre-allocated buffer pool to avoid allocation per connection
|
|
139
154
|
*
|
|
140
155
|
* @param {net.Socket} socket - TCP socket
|
|
141
156
|
* @returns {Promise<{message: Buffer, allData: Buffer}>} Startup message and all buffered data
|
|
142
157
|
*/
|
|
143
158
|
export async function readStartupMessage(socket) {
|
|
144
159
|
return new Promise((resolve, reject) => {
|
|
145
|
-
|
|
160
|
+
const buffer = acquireBuffer();
|
|
161
|
+
let offset = 0;
|
|
146
162
|
let expectedLength = null;
|
|
147
163
|
let resolved = false;
|
|
148
164
|
|
|
149
165
|
const onData = (chunk) => {
|
|
150
166
|
if (resolved) return;
|
|
151
167
|
|
|
152
|
-
buffer
|
|
168
|
+
// Copy chunk into pre-allocated buffer (avoids Buffer.concat allocation)
|
|
169
|
+
const copyLen = Math.min(chunk.length, STARTUP_BUFFER_SIZE - offset);
|
|
170
|
+
chunk.copy(buffer, offset, 0, copyLen);
|
|
171
|
+
offset += copyLen;
|
|
153
172
|
|
|
154
173
|
// Read expected length from first 4 bytes
|
|
155
|
-
if (expectedLength === null &&
|
|
174
|
+
if (expectedLength === null && offset >= 4) {
|
|
156
175
|
expectedLength = buffer.readInt32BE(0);
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
// Check if we have full message
|
|
160
|
-
if (expectedLength !== null &&
|
|
179
|
+
if (expectedLength !== null && offset >= expectedLength) {
|
|
161
180
|
resolved = true;
|
|
162
181
|
socket.removeListener('data', onData);
|
|
163
182
|
socket.removeListener('error', onError);
|
|
164
183
|
|
|
165
|
-
|
|
166
|
-
|
|
184
|
+
// Create result buffers (need to copy since we're reusing pool buffer)
|
|
185
|
+
const message = Buffer.from(buffer.subarray(0, expectedLength));
|
|
186
|
+
const allData = Buffer.from(buffer.subarray(0, offset));
|
|
187
|
+
releaseBuffer(buffer);
|
|
188
|
+
resolve({ message, allData });
|
|
167
189
|
}
|
|
168
190
|
};
|
|
169
191
|
|
|
@@ -172,6 +194,7 @@ export async function readStartupMessage(socket) {
|
|
|
172
194
|
resolved = true;
|
|
173
195
|
socket.removeListener('data', onData);
|
|
174
196
|
socket.removeListener('error', onError);
|
|
197
|
+
releaseBuffer(buffer);
|
|
175
198
|
reject(error);
|
|
176
199
|
};
|
|
177
200
|
|
|
@@ -187,6 +210,7 @@ export async function readStartupMessage(socket) {
|
|
|
187
210
|
resolved = true;
|
|
188
211
|
socket.removeListener('data', onData);
|
|
189
212
|
socket.removeListener('error', onError);
|
|
213
|
+
releaseBuffer(buffer);
|
|
190
214
|
reject(new Error('Timeout reading startup message'));
|
|
191
215
|
}, 2000);
|
|
192
216
|
});
|
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
|
+
}
|