pgserve 0.1.5 → 1.0.1
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/README.md +281 -256
- package/bin/pglite-server.js +212 -395
- package/package.json +13 -10
- package/src/cluster.js +322 -0
- package/src/index.js +8 -171
- package/src/postgres.js +479 -0
- package/src/protocol.js +31 -6
- package/src/router.js +117 -114
- package/src/sync.js +344 -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/sync.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncManager - Async replication from pgserve to real PostgreSQL
|
|
3
|
+
*
|
|
4
|
+
* Uses PostgreSQL's native logical replication for ZERO hot-path impact.
|
|
5
|
+
* All replication is handled by PostgreSQL's WAL writer process, not Node.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import pg from 'pg';
|
|
9
|
+
import pino from 'pino';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Match database name against patterns (supports wildcards)
|
|
13
|
+
* @param {string} dbName - Database name to check
|
|
14
|
+
* @param {string[]} patterns - Array of patterns (supports * wildcard)
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
function matchesPattern(dbName, patterns) {
|
|
18
|
+
if (!patterns || patterns.length === 0) return true; // No filter = sync all
|
|
19
|
+
|
|
20
|
+
return patterns.some(pattern => {
|
|
21
|
+
if (pattern.includes('*')) {
|
|
22
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
23
|
+
return regex.test(dbName);
|
|
24
|
+
}
|
|
25
|
+
return dbName === pattern;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* SyncManager - Handles async replication to target PostgreSQL
|
|
31
|
+
*/
|
|
32
|
+
export class SyncManager {
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.targetUrl = options.targetUrl; // Real PostgreSQL connection string
|
|
35
|
+
this.databases = options.databases || []; // Patterns: ["myapp", "tenant_*"]
|
|
36
|
+
this.sourcePort = options.sourcePort; // pgserve PostgreSQL port
|
|
37
|
+
this.sourceSocketPath = options.sourceSocketPath; // pgserve socket path (optional)
|
|
38
|
+
|
|
39
|
+
this.logger = pino({ level: options.logLevel || 'info' }).child({ component: 'sync' });
|
|
40
|
+
|
|
41
|
+
this.sourcePool = null; // Connection to pgserve's PostgreSQL
|
|
42
|
+
this.targetPool = null; // Connection to real PostgreSQL
|
|
43
|
+
this.syncedDatabases = new Set();
|
|
44
|
+
this.initialized = false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Initialize the SyncManager after PostgreSQL is ready
|
|
49
|
+
* @param {Object} pgManager - PostgresManager instance
|
|
50
|
+
*/
|
|
51
|
+
async initialize(pgManager) {
|
|
52
|
+
if (!this.targetUrl) {
|
|
53
|
+
throw new Error('SyncManager requires targetUrl');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.logger.info({ target: this.targetUrl.replace(/:[^:@]+@/, ':***@') }, 'Initializing sync manager');
|
|
57
|
+
|
|
58
|
+
// Create connection pool to source (pgserve's embedded PostgreSQL)
|
|
59
|
+
const sourceConfig = this.sourceSocketPath
|
|
60
|
+
? {
|
|
61
|
+
host: this.sourceSocketPath.replace(/\/\.s\.PGSQL\.\d+$/, ''),
|
|
62
|
+
port: this.sourcePort,
|
|
63
|
+
database: 'postgres',
|
|
64
|
+
user: 'postgres',
|
|
65
|
+
password: 'postgres'
|
|
66
|
+
}
|
|
67
|
+
: {
|
|
68
|
+
host: '127.0.0.1',
|
|
69
|
+
port: this.sourcePort,
|
|
70
|
+
database: 'postgres',
|
|
71
|
+
user: 'postgres',
|
|
72
|
+
password: 'postgres'
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.sourcePool = new pg.Pool({
|
|
76
|
+
...sourceConfig,
|
|
77
|
+
max: 3, // Low pool size - replication is async, not latency-sensitive
|
|
78
|
+
idleTimeoutMillis: 30000
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Create connection pool to target (real PostgreSQL)
|
|
82
|
+
this.targetPool = new pg.Pool({
|
|
83
|
+
connectionString: this.targetUrl,
|
|
84
|
+
max: 3,
|
|
85
|
+
idleTimeoutMillis: 30000
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Test connections
|
|
89
|
+
try {
|
|
90
|
+
await this.sourcePool.query('SELECT 1');
|
|
91
|
+
this.logger.debug('Source pool connected');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
this.logger.error({ err }, 'Failed to connect to source PostgreSQL');
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await this.targetPool.query('SELECT 1');
|
|
99
|
+
this.logger.debug('Target pool connected');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
this.logger.error({ err }, 'Failed to connect to target PostgreSQL');
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.initialized = true;
|
|
106
|
+
this.logger.info('Sync manager initialized');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a database should be synced based on patterns
|
|
111
|
+
* @param {string} dbName
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
shouldSync(dbName) {
|
|
115
|
+
// Skip system databases
|
|
116
|
+
if (['postgres', 'template0', 'template1'].includes(dbName)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return matchesPattern(dbName, this.databases);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Setup replication for a specific database
|
|
124
|
+
* Called when a new database is created in pgserve
|
|
125
|
+
*
|
|
126
|
+
* This is NON-BLOCKING - runs in background, doesn't affect hot path
|
|
127
|
+
*
|
|
128
|
+
* @param {string} dbName - Name of the database to sync
|
|
129
|
+
*/
|
|
130
|
+
async setupDatabaseSync(dbName) {
|
|
131
|
+
if (!this.initialized) {
|
|
132
|
+
this.logger.warn({ dbName }, 'Sync manager not initialized, skipping sync setup');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!this.shouldSync(dbName)) {
|
|
137
|
+
this.logger.debug({ dbName }, 'Database does not match sync patterns, skipping');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this.syncedDatabases.has(dbName)) {
|
|
142
|
+
this.logger.debug({ dbName }, 'Database already synced');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.logger.info({ dbName }, 'Setting up database sync');
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
// Step 1: Create database on target if it doesn't exist
|
|
150
|
+
await this.ensureTargetDatabase(dbName);
|
|
151
|
+
|
|
152
|
+
// Step 2: Setup publication on source (pgserve)
|
|
153
|
+
await this.setupPublication(dbName);
|
|
154
|
+
|
|
155
|
+
// Step 3: Setup subscription on target
|
|
156
|
+
await this.setupSubscription(dbName);
|
|
157
|
+
|
|
158
|
+
this.syncedDatabases.add(dbName);
|
|
159
|
+
this.logger.info({ dbName }, 'Database sync established');
|
|
160
|
+
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Non-fatal - sync failure doesn't affect main server operation
|
|
163
|
+
this.logger.error({ dbName, err }, 'Failed to setup database sync');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Ensure the database exists on target PostgreSQL
|
|
169
|
+
* @param {string} dbName
|
|
170
|
+
*/
|
|
171
|
+
async ensureTargetDatabase(dbName) {
|
|
172
|
+
const client = await this.targetPool.connect();
|
|
173
|
+
try {
|
|
174
|
+
const result = await client.query(
|
|
175
|
+
'SELECT 1 FROM pg_database WHERE datname = $1',
|
|
176
|
+
[dbName]
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (result.rows.length === 0) {
|
|
180
|
+
// CREATE DATABASE cannot run in transaction
|
|
181
|
+
await client.query(`CREATE DATABASE "${dbName}"`);
|
|
182
|
+
this.logger.debug({ dbName }, 'Created database on target');
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
client.release();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Setup publication on source (pgserve's PostgreSQL)
|
|
191
|
+
* @param {string} dbName
|
|
192
|
+
*/
|
|
193
|
+
async setupPublication(dbName) {
|
|
194
|
+
// Connect to the specific database on source
|
|
195
|
+
const sourceDbPool = new pg.Pool({
|
|
196
|
+
host: this.sourceSocketPath
|
|
197
|
+
? this.sourceSocketPath.replace(/\/\.s\.PGSQL\.\d+$/, '')
|
|
198
|
+
: '127.0.0.1',
|
|
199
|
+
port: this.sourcePort,
|
|
200
|
+
database: dbName,
|
|
201
|
+
user: 'postgres',
|
|
202
|
+
password: 'postgres',
|
|
203
|
+
max: 1
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const pubName = `pgserve_pub_${dbName.replace(/[^a-z0-9_]/gi, '_')}`;
|
|
208
|
+
|
|
209
|
+
// Check if publication exists
|
|
210
|
+
const result = await sourceDbPool.query(
|
|
211
|
+
'SELECT 1 FROM pg_publication WHERE pubname = $1',
|
|
212
|
+
[pubName]
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (result.rows.length === 0) {
|
|
216
|
+
// Create publication for all tables
|
|
217
|
+
await sourceDbPool.query(`CREATE PUBLICATION "${pubName}" FOR ALL TABLES`);
|
|
218
|
+
this.logger.debug({ dbName, pubName }, 'Created publication on source');
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
await sourceDbPool.end();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Setup subscription on target PostgreSQL
|
|
227
|
+
* @param {string} dbName
|
|
228
|
+
*/
|
|
229
|
+
async setupSubscription(dbName) {
|
|
230
|
+
// Connect to the specific database on target
|
|
231
|
+
const targetUrl = new URL(this.targetUrl);
|
|
232
|
+
targetUrl.pathname = `/${dbName}`;
|
|
233
|
+
|
|
234
|
+
const targetDbPool = new pg.Pool({
|
|
235
|
+
connectionString: targetUrl.toString(),
|
|
236
|
+
max: 1
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const subName = `pgserve_sub_${dbName.replace(/[^a-z0-9_]/gi, '_')}`;
|
|
241
|
+
const pubName = `pgserve_pub_${dbName.replace(/[^a-z0-9_]/gi, '_')}`;
|
|
242
|
+
|
|
243
|
+
// Check if subscription exists
|
|
244
|
+
const result = await targetDbPool.query(
|
|
245
|
+
'SELECT 1 FROM pg_subscription WHERE subname = $1',
|
|
246
|
+
[subName]
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (result.rows.length === 0) {
|
|
250
|
+
// Build connection string to source - ALWAYS use TCP for cross-container compatibility
|
|
251
|
+
// (Unix sockets won't work when target is in Docker or remote)
|
|
252
|
+
const sourceConnStr = `host=127.0.0.1 port=${this.sourcePort} dbname=${dbName} user=postgres password=postgres`;
|
|
253
|
+
|
|
254
|
+
// Create subscription
|
|
255
|
+
await targetDbPool.query(`
|
|
256
|
+
CREATE SUBSCRIPTION "${subName}"
|
|
257
|
+
CONNECTION '${sourceConnStr}'
|
|
258
|
+
PUBLICATION "${pubName}"
|
|
259
|
+
WITH (copy_data = true, create_slot = true)
|
|
260
|
+
`);
|
|
261
|
+
this.logger.debug({ dbName, subName }, 'Created subscription on target');
|
|
262
|
+
}
|
|
263
|
+
} finally {
|
|
264
|
+
await targetDbPool.end();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get replication status for all synced databases
|
|
270
|
+
* @returns {Promise<Object>}
|
|
271
|
+
*/
|
|
272
|
+
async getReplicationStatus() {
|
|
273
|
+
if (!this.initialized) {
|
|
274
|
+
return { initialized: false, databases: [] };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const status = {
|
|
278
|
+
initialized: true,
|
|
279
|
+
targetUrl: this.targetUrl.replace(/:[^:@]+@/, ':***@'),
|
|
280
|
+
databases: [],
|
|
281
|
+
replicationSlots: []
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
// Query replication slots from source
|
|
286
|
+
const slotsResult = await this.sourcePool.query(`
|
|
287
|
+
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn
|
|
288
|
+
FROM pg_replication_slots
|
|
289
|
+
WHERE slot_type = 'logical'
|
|
290
|
+
`);
|
|
291
|
+
|
|
292
|
+
status.replicationSlots = slotsResult.rows.map(row => ({
|
|
293
|
+
name: row.slot_name,
|
|
294
|
+
active: row.active,
|
|
295
|
+
restartLsn: row.restart_lsn,
|
|
296
|
+
confirmedFlushLsn: row.confirmed_flush_lsn
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
// Query replication lag
|
|
300
|
+
const lagResult = await this.sourcePool.query(`
|
|
301
|
+
SELECT
|
|
302
|
+
slot_name,
|
|
303
|
+
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) as lag_bytes
|
|
304
|
+
FROM pg_replication_slots
|
|
305
|
+
WHERE slot_type = 'logical'
|
|
306
|
+
`);
|
|
307
|
+
|
|
308
|
+
for (const row of lagResult.rows) {
|
|
309
|
+
const slot = status.replicationSlots.find(s => s.name === row.slot_name);
|
|
310
|
+
if (slot) {
|
|
311
|
+
slot.lagBytes = parseInt(row.lag_bytes) || 0;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
status.databases = Array.from(this.syncedDatabases);
|
|
316
|
+
|
|
317
|
+
} catch (err) {
|
|
318
|
+
this.logger.error({ err }, 'Failed to get replication status');
|
|
319
|
+
status.error = err.message;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return status;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Graceful shutdown
|
|
327
|
+
*/
|
|
328
|
+
async stop() {
|
|
329
|
+
this.logger.info('Stopping sync manager');
|
|
330
|
+
|
|
331
|
+
if (this.sourcePool) {
|
|
332
|
+
await this.sourcePool.end();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (this.targetPool) {
|
|
336
|
+
await this.targetPool.end();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.initialized = false;
|
|
340
|
+
this.logger.info('Sync manager stopped');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export default SyncManager;
|