pgserve 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/restore.js DELETED
@@ -1,574 +0,0 @@
1
- /**
2
- * RestoreManager - Automatic restore from external PostgreSQL on startup
3
- *
4
- * High-performance restore using:
5
- * - Parallel database restore (Promise.all)
6
- * - Native wire protocol for bulk COPY transfer (pg-wire.js)
7
- * - Unix sockets for local connections (~30% faster)
8
- * - Binary format COPY (~2x faster than text)
9
- *
10
- * 100% Bun-native: Uses PgWirePool for all database operations.
11
- */
12
-
13
- import { PgWirePool } from './pg-wire.js';
14
- import { createLogger } from './logger.js';
15
-
16
- /**
17
- * Match database name against patterns (supports wildcards)
18
- * Reused from sync.js for consistency
19
- * @param {string} dbName - Database name to check
20
- * @param {string[]} patterns - Array of patterns (supports * wildcard)
21
- * @returns {boolean}
22
- */
23
- function matchesPattern(dbName, patterns) {
24
- if (!patterns || patterns.length === 0) return true; // No filter = restore all
25
-
26
- return patterns.some(pattern => {
27
- if (pattern.includes('*')) {
28
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
29
- return regex.test(dbName);
30
- }
31
- return dbName === pattern;
32
- });
33
- }
34
-
35
- /**
36
- * RestoreManager - Handles automatic restore from external PostgreSQL
37
- */
38
- export class RestoreManager {
39
- constructor(options = {}) {
40
- this.sourceUrl = options.sourceUrl; // External PostgreSQL URL
41
- this.patterns = options.patterns || []; // Database patterns ["myapp", "tenant_*"]
42
- this.targetPort = options.targetPort; // Local embedded PostgreSQL port
43
- this.targetSocketPath = options.targetSocketPath; // Unix socket path (optional)
44
-
45
- this.logger = options.logger || createLogger({ level: options.logLevel || 'info', component: 'restore' });
46
-
47
- // Connection pools (lazy initialized)
48
- this.sourcePool = null;
49
-
50
- // Performance tuning - parallel restore limits
51
- this.maxParallelDatabases = options.maxParallelDatabases || 4;
52
- this.maxParallelTables = options.maxParallelTables || 8;
53
-
54
- // Timeout handling
55
- this.restoreTimeout = options.restoreTimeout || 60000; // 60s default
56
-
57
- // Progress callback for dashboard
58
- this.onProgress = options.onProgress || (() => {});
59
-
60
- // Totals for progress tracking
61
- this.totalDatabases = 0;
62
- this.totalTables = 0;
63
- this.totalBytes = 0;
64
-
65
- // Metrics collection
66
- this.metrics = {
67
- startTime: 0,
68
- endTime: 0,
69
- databasesRestored: 0,
70
- tablesRestored: 0,
71
- rowsRestored: 0,
72
- bytesTransferred: 0,
73
- errors: []
74
- };
75
- }
76
-
77
- /**
78
- * Main entry point - restore databases from external PostgreSQL
79
- * Called from router.js after pgManager.start(), before SyncManager
80
- *
81
- * @param {PostgresManager} pgManager - Local PostgreSQL manager
82
- * @returns {Promise<Object>} Restore result with metrics
83
- */
84
- async restore(pgManager) {
85
- if (!this.sourceUrl) {
86
- return { skipped: true, reason: 'no sourceUrl configured' };
87
- }
88
-
89
- this.metrics.startTime = Date.now();
90
- this.logger.info({ source: this.sourceUrl.replace(/:[^:@]+@/, ':***@') }, 'Starting automatic restore from external PostgreSQL');
91
-
92
- try {
93
- // Initialize connection to external PostgreSQL
94
- const connected = await this._initSourcePool();
95
- if (!connected) {
96
- return { skipped: true, reason: 'external PostgreSQL unreachable' };
97
- }
98
-
99
- // Discover databases matching patterns
100
- const databases = await this._discoverDatabases();
101
-
102
- if (databases.length === 0) {
103
- this.logger.info('No databases found matching sync patterns on external PostgreSQL');
104
- return { skipped: true, reason: 'no matching databases found' };
105
- }
106
-
107
- this.logger.info({ count: databases.length, databases }, 'Found databases to restore');
108
-
109
- // Set totals for progress tracking
110
- this.totalDatabases = databases.length;
111
-
112
- // Restore databases in parallel (with controlled concurrency)
113
- await this._restoreDatabasesParallel(databases, pgManager);
114
-
115
- this.metrics.endTime = Date.now();
116
- const duration = this.metrics.endTime - this.metrics.startTime;
117
-
118
- this.logger.info({
119
- databasesRestored: this.metrics.databasesRestored,
120
- tablesRestored: this.metrics.tablesRestored,
121
- rowsRestored: this.metrics.rowsRestored,
122
- bytesTransferred: this.metrics.bytesTransferred,
123
- throughputMBps: ((this.metrics.bytesTransferred / 1024 / 1024) / (duration / 1000)).toFixed(2),
124
- durationMs: duration,
125
- errors: this.metrics.errors.length
126
- }, 'Restore completed');
127
-
128
- return {
129
- success: true,
130
- metrics: { ...this.metrics }
131
- };
132
-
133
- } catch (error) {
134
- this.logger.error({ err: error }, 'Restore failed');
135
- return { success: false, error: error.message };
136
- } finally {
137
- await this._closeSourcePool();
138
- }
139
- }
140
-
141
- /**
142
- * Initialize connection pool to external PostgreSQL
143
- * @returns {Promise<boolean>} true if connected successfully
144
- */
145
- async _initSourcePool() {
146
- try {
147
- const url = new URL(this.sourceUrl);
148
- this.sourcePool = new PgWirePool({
149
- hostname: url.hostname,
150
- port: parseInt(url.port) || 5432,
151
- database: url.pathname.slice(1) || 'postgres',
152
- username: url.username || 'postgres',
153
- password: url.password || 'postgres',
154
- max: 3 // Small pool - just for discovery
155
- });
156
-
157
- // Test connection
158
- await this.sourcePool.query('SELECT 1');
159
- this.logger.debug('Connected to external PostgreSQL');
160
- return true;
161
-
162
- } catch (error) {
163
- if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
164
- this.logger.warn({ err: error.message }, 'External PostgreSQL unreachable, skipping restore');
165
- return false;
166
- }
167
- throw error;
168
- }
169
- }
170
-
171
- /**
172
- * Close source connection pool
173
- */
174
- async _closeSourcePool() {
175
- if (this.sourcePool) {
176
- await this.sourcePool.end();
177
- this.sourcePool = null;
178
- }
179
- }
180
-
181
- /**
182
- * Discover databases on external PostgreSQL matching patterns
183
- * @returns {Promise<string[]>} List of database names
184
- */
185
- async _discoverDatabases() {
186
- const result = await this.sourcePool.query(`
187
- SELECT datname FROM pg_database
188
- WHERE datistemplate = false
189
- AND datname NOT IN ('postgres', 'template0', 'template1')
190
- ORDER BY datname
191
- `);
192
-
193
- // Filter by patterns
194
- return result.rows
195
- .map(r => r.datname)
196
- .filter(name => matchesPattern(name, this.patterns));
197
- }
198
-
199
- /**
200
- * Restore databases in parallel with controlled concurrency
201
- * @param {string[]} databases - Database names to restore
202
- * @param {PostgresManager} pgManager - Local PostgreSQL manager
203
- */
204
- async _restoreDatabasesParallel(databases, pgManager) {
205
- // Batch databases to limit concurrency
206
- const batches = [];
207
- for (let i = 0; i < databases.length; i += this.maxParallelDatabases) {
208
- batches.push(databases.slice(i, i + this.maxParallelDatabases));
209
- }
210
-
211
- for (const batch of batches) {
212
- const results = await Promise.allSettled(
213
- batch.map(dbName => this._restoreDatabase(dbName, pgManager))
214
- );
215
-
216
- // Track failures but continue
217
- for (let i = 0; i < results.length; i++) {
218
- if (results[i].status === 'rejected') {
219
- this.metrics.errors.push({
220
- database: batch[i],
221
- error: results[i].reason.message
222
- });
223
- }
224
- }
225
- }
226
-
227
- if (this.metrics.errors.length > 0) {
228
- this.logger.warn({
229
- failedCount: this.metrics.errors.length,
230
- totalCount: databases.length
231
- }, 'Some databases failed to restore');
232
- }
233
- }
234
-
235
- /**
236
- * Restore a single database: create DB, schema, data
237
- * @param {string} dbName - Database name
238
- * @param {PostgresManager} pgManager - Local PostgreSQL manager
239
- */
240
- async _restoreDatabase(dbName, pgManager) {
241
- this.logger.info({ dbName }, 'Restoring database');
242
- const startTime = Date.now();
243
-
244
- // Step 1: Create database locally
245
- await pgManager.createDatabase(dbName);
246
-
247
- // Step 2: Create connection pools for this specific database
248
- const sourceDbPool = await this._createSourceDbPool(dbName);
249
- const targetDbPool = await this._createTargetDbPool(dbName);
250
-
251
- try {
252
- // Step 3: Restore schema (types, tables, indexes, FKs)
253
- await this._restoreSchema(sourceDbPool, targetDbPool, dbName);
254
-
255
- // Step 4: Discover tables and copy data in parallel
256
- const tables = await this._discoverTables(sourceDbPool);
257
- this.totalTables += tables.length; // Track total for progress
258
- if (tables.length > 0) {
259
- await this._restoreTablesParallel(sourceDbPool, targetDbPool, tables);
260
- }
261
-
262
- // Step 5: Restore sequences (after data for correct values)
263
- await this._restoreSequences(sourceDbPool, targetDbPool);
264
-
265
- this.metrics.databasesRestored++;
266
- const duration = Date.now() - startTime;
267
- this.logger.info({ dbName, durationMs: duration }, 'Database restored successfully');
268
-
269
- } finally {
270
- await sourceDbPool.end();
271
- await targetDbPool.end();
272
- }
273
- }
274
-
275
- /**
276
- * Create connection pool to specific database on external PostgreSQL
277
- * @param {string} dbName - Database name
278
- * @returns {Promise<PgWirePool>}
279
- */
280
- async _createSourceDbPool(dbName) {
281
- const url = new URL(this.sourceUrl);
282
-
283
- return new PgWirePool({
284
- hostname: url.hostname,
285
- port: parseInt(url.port) || 5432,
286
- database: dbName,
287
- username: url.username || 'postgres',
288
- password: url.password || 'postgres',
289
- max: this.maxParallelTables
290
- });
291
- }
292
-
293
- /**
294
- * Create connection pool to specific database on local embedded PostgreSQL
295
- * Uses Unix socket when available for ~30% faster connections
296
- * @param {string} dbName - Database name
297
- * @returns {Promise<PgWirePool>}
298
- */
299
- async _createTargetDbPool(dbName) {
300
- const config = {
301
- database: dbName,
302
- username: 'postgres',
303
- password: 'postgres',
304
- max: this.maxParallelTables
305
- };
306
-
307
- // Prefer Unix socket for faster local connections
308
- if (this.targetSocketPath) {
309
- config.unix = this.targetSocketPath;
310
- } else {
311
- config.hostname = '127.0.0.1';
312
- config.port = this.targetPort;
313
- }
314
-
315
- return new PgWirePool(config);
316
- }
317
-
318
- /**
319
- * Restore schema: ENUMs, tables, indexes, foreign keys
320
- * Order matters: types → tables → indexes → FKs
321
- * @param {PgWirePool} sourcePool - External database pool
322
- * @param {PgWirePool} targetPool - Local database pool
323
- * @param {string} dbName - Database name (for logging)
324
- */
325
- async _restoreSchema(sourcePool, targetPool, dbName) {
326
- // 1. Restore ENUM types
327
- await this._restoreEnums(sourcePool, targetPool);
328
-
329
- // 2. Restore tables (structure only, no data yet)
330
- await this._restoreTables(sourcePool, targetPool);
331
-
332
- this.logger.debug({ dbName }, 'Schema restored');
333
- }
334
-
335
- /**
336
- * Restore ENUM types from external database
337
- */
338
- async _restoreEnums(sourcePool, targetPool) {
339
- const result = await sourcePool.query(`
340
- SELECT n.nspname as schema, t.typname as name,
341
- array_agg(e.enumlabel ORDER BY e.enumsortorder) as values
342
- FROM pg_type t
343
- JOIN pg_enum e ON t.oid = e.enumtypid
344
- JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
345
- WHERE n.nspname = 'public'
346
- GROUP BY n.nspname, t.typname
347
- `);
348
-
349
- for (const enumType of result.rows) {
350
- const values = enumType.values.map(v => `'${v.replace(/'/g, "''")}'`).join(', ');
351
- const createSql = `CREATE TYPE "${enumType.name}" AS ENUM (${values})`;
352
-
353
- try {
354
- await targetPool.query(createSql);
355
- } catch (err) {
356
- if (err.code !== '42710') throw err; // 42710 = type already exists
357
- }
358
- }
359
- }
360
-
361
- /**
362
- * Restore table structures from external database
363
- */
364
- async _restoreTables(sourcePool, targetPool) {
365
- // Get table list
366
- const tablesResult = await sourcePool.query(`
367
- SELECT table_name FROM information_schema.tables
368
- WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
369
- ORDER BY table_name
370
- `);
371
-
372
- for (const row of tablesResult.rows) {
373
- const tableName = row.table_name;
374
- const createSql = await this._getTableCreateStatement(sourcePool, tableName);
375
-
376
- try {
377
- await targetPool.query(createSql);
378
- } catch (err) {
379
- if (err.code !== '42P07') throw err; // 42P07 = table already exists
380
- }
381
- }
382
- }
383
-
384
- /**
385
- * Generate CREATE TABLE statement from information_schema
386
- * @param {PgWirePool} sourcePool - Source database pool
387
- * @param {string} tableName - Table name
388
- * @returns {Promise<string>} CREATE TABLE SQL
389
- */
390
- async _getTableCreateStatement(sourcePool, tableName) {
391
- // Get columns
392
- const columnsResult = await sourcePool.query(`
393
- SELECT column_name, data_type, udt_name, character_maximum_length,
394
- column_default, is_nullable, numeric_precision, numeric_scale
395
- FROM information_schema.columns
396
- WHERE table_schema = 'public' AND table_name = $1
397
- ORDER BY ordinal_position
398
- `, [tableName]);
399
-
400
- const columns = columnsResult.rows.map(col => {
401
- let type = col.data_type;
402
-
403
- // Handle special types
404
- if (type === 'USER-DEFINED') {
405
- type = `"${col.udt_name}"`; // ENUM or custom type
406
- } else if (type === 'character varying' && col.character_maximum_length) {
407
- type = `varchar(${col.character_maximum_length})`;
408
- } else if (type === 'character' && col.character_maximum_length) {
409
- type = `char(${col.character_maximum_length})`;
410
- } else if (type === 'numeric' && col.numeric_precision) {
411
- type = col.numeric_scale
412
- ? `numeric(${col.numeric_precision},${col.numeric_scale})`
413
- : `numeric(${col.numeric_precision})`;
414
- } else if (type === 'ARRAY') {
415
- type = `${col.udt_name.replace(/^_/, '')}[]`;
416
- }
417
-
418
- let colDef = `"${col.column_name}" ${type}`;
419
-
420
- if (col.column_default) {
421
- colDef += ` DEFAULT ${col.column_default}`;
422
- }
423
-
424
- if (col.is_nullable === 'NO') {
425
- colDef += ' NOT NULL';
426
- }
427
-
428
- return colDef;
429
- });
430
-
431
- // Get primary key
432
- const pkResult = await sourcePool.query(`
433
- SELECT a.attname
434
- FROM pg_index i
435
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
436
- WHERE i.indrelid = $1::regclass AND i.indisprimary
437
- ORDER BY array_position(i.indkey, a.attnum)
438
- `, [tableName]);
439
-
440
- if (pkResult.rows.length > 0) {
441
- const pkCols = pkResult.rows.map(r => `"${r.attname}"`).join(', ');
442
- columns.push(`PRIMARY KEY (${pkCols})`);
443
- }
444
-
445
- return `CREATE TABLE "${tableName}" (\n ${columns.join(',\n ')}\n)`;
446
- }
447
-
448
- /**
449
- * Discover tables in the database
450
- * @param {PgWirePool} sourcePool - Source database pool
451
- * @returns {Promise<string[]>} Table names
452
- */
453
- async _discoverTables(sourcePool) {
454
- const result = await sourcePool.query(`
455
- SELECT table_name FROM information_schema.tables
456
- WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
457
- ORDER BY table_name
458
- `);
459
-
460
- return result.rows.map(r => r.table_name);
461
- }
462
-
463
- /**
464
- * Restore table data in parallel using COPY protocol
465
- * @param {PgWirePool} sourcePool - Source database pool
466
- * @param {PgWirePool} targetPool - Target database pool
467
- * @param {string[]} tables - Table names
468
- */
469
- async _restoreTablesParallel(sourcePool, targetPool, tables) {
470
- // Batch tables to limit concurrency
471
- const batches = [];
472
- for (let i = 0; i < tables.length; i += this.maxParallelTables) {
473
- batches.push(tables.slice(i, i + this.maxParallelTables));
474
- }
475
-
476
- for (const batch of batches) {
477
- await Promise.all(
478
- batch.map(table => this._copyTableData(sourcePool, targetPool, table))
479
- );
480
- }
481
- }
482
-
483
- /**
484
- * Copy table data using binary COPY protocol (high performance)
485
- * Uses async generator pattern for streaming data between connections.
486
- * @param {PgWirePool} sourcePool - Source database pool
487
- * @param {PgWirePool} targetPool - Target database pool
488
- * @param {string} tableName - Table name
489
- */
490
- async _copyTableData(sourcePool, targetPool, tableName) {
491
- // Get row count first (for metrics)
492
- const countResult = await sourcePool.query(
493
- `SELECT COUNT(*)::int as count FROM "${tableName}"`
494
- );
495
- const rowCount = countResult.rows[0].count;
496
-
497
- if (rowCount === 0) {
498
- this.logger.debug({ tableName, rows: 0 }, 'Skipping empty table');
499
- return;
500
- }
501
-
502
- // Get connections for COPY streaming
503
- const sourceClient = await sourcePool.connect();
504
- const targetClient = await targetPool.connect();
505
-
506
- let bytesTransferred = 0;
507
-
508
- try {
509
- // Create byte-tracking wrapper for the async generator
510
- async function* trackBytes(source) {
511
- for await (const chunk of source) {
512
- bytesTransferred += chunk.length;
513
- yield chunk;
514
- }
515
- }
516
-
517
- // Stream COPY: source → target using async generators
518
- const copyToSql = `COPY "${tableName}" TO STDOUT WITH (FORMAT binary)`;
519
- const copyFromSql = `COPY "${tableName}" FROM STDIN WITH (FORMAT binary)`;
520
-
521
- const sourceStream = sourceClient.copyTo(copyToSql);
522
- await targetClient.copyFrom(copyFromSql, trackBytes(sourceStream));
523
-
524
- // Update metrics
525
- this.metrics.bytesTransferred += bytesTransferred;
526
- this.metrics.rowsRestored += rowCount;
527
- this.metrics.tablesRestored++;
528
-
529
- this.logger.debug({ tableName, rows: rowCount, bytes: bytesTransferred }, 'Table data copied');
530
-
531
- // Emit progress for dashboard
532
- this.onProgress({
533
- databasesRestored: this.metrics.databasesRestored,
534
- totalDatabases: this.totalDatabases,
535
- tablesRestored: this.metrics.tablesRestored,
536
- totalTables: this.totalTables,
537
- bytesTransferred: this.metrics.bytesTransferred,
538
- totalBytes: this.totalBytes
539
- });
540
-
541
- } finally {
542
- sourcePool.release(sourceClient);
543
- targetPool.release(targetClient);
544
- }
545
- }
546
-
547
- /**
548
- * Restore sequences to correct values (after data restore)
549
- * @param {PgWirePool} sourcePool - Source database pool
550
- * @param {PgWirePool} targetPool - Target database pool
551
- */
552
- async _restoreSequences(sourcePool, targetPool) {
553
- // Get all sequences
554
- const seqResult = await sourcePool.query(`
555
- SELECT sequence_name FROM information_schema.sequences
556
- WHERE sequence_schema = 'public'
557
- `);
558
-
559
- for (const seq of seqResult.rows) {
560
- const seqName = seq.sequence_name;
561
-
562
- // Get current value from source
563
- const valueResult = await sourcePool.query(`SELECT last_value FROM "${seqName}"`);
564
- const lastValue = valueResult.rows[0].last_value;
565
-
566
- // Set on target
567
- try {
568
- await targetPool.query(`SELECT setval($1, $2, true)`, [seqName, lastValue]);
569
- } catch (err) {
570
- this.logger.warn({ sequence: seqName, err: err.message }, 'Failed to restore sequence');
571
- }
572
- }
573
- }
574
- }