orez 0.0.18 → 0.0.19
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/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +36 -3
- package/dist/index.js.map +1 -1
- package/dist/mutex.d.ts +8 -0
- package/dist/mutex.d.ts.map +1 -0
- package/dist/mutex.js +26 -0
- package/dist/mutex.js.map +1 -0
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +19 -33
- package/dist/pg-proxy.js.map +1 -1
- package/dist/replication/change-tracker.d.ts +7 -0
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +40 -3
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +136 -59
- package/dist/replication/handler.js.map +1 -1
- package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
- package/dist/replication/pgoutput-encoder.js +11 -1
- package/dist/replication/pgoutput-encoder.js.map +1 -1
- package/package.json +3 -3
- package/src/cli.ts +1 -1
- package/src/config.ts +1 -1
- package/src/index.ts +35 -3
- package/src/integration/integration.test.ts +24 -14
- package/src/mutex.ts +27 -0
- package/src/pg-proxy.ts +21 -34
- package/src/replication/change-tracker.test.ts +4 -4
- package/src/replication/change-tracker.ts +49 -3
- package/src/replication/handler.ts +176 -68
- package/src/replication/pgoutput-encoder.ts +9 -1
- package/src/replication/zero-compat.test.ts +7 -3
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { log } from '../log.js'
|
|
10
|
+
import { pgMutex } from '../mutex.js'
|
|
10
11
|
import {
|
|
11
12
|
getChangesSince,
|
|
12
13
|
getCurrentWatermark,
|
|
14
|
+
installTriggersOnShardTables,
|
|
13
15
|
type ChangeRecord,
|
|
14
16
|
} from './change-tracker.js'
|
|
15
17
|
import {
|
|
@@ -198,7 +200,9 @@ export async function handleReplicationQuery(
|
|
|
198
200
|
}
|
|
199
201
|
|
|
200
202
|
if (upper.startsWith('CREATE_REPLICATION_SLOT')) {
|
|
201
|
-
const match = trimmed.match(
|
|
203
|
+
const match = trimmed.match(
|
|
204
|
+
/CREATE_REPLICATION_SLOT\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i
|
|
205
|
+
)
|
|
202
206
|
const slotName = match?.[1] || match?.[2] || match?.[3] || 'zero_slot'
|
|
203
207
|
const lsn = lsnToString(nextLsn())
|
|
204
208
|
const snapshotName = `00000003-00000001-1`
|
|
@@ -238,6 +242,12 @@ export async function handleReplicationQuery(
|
|
|
238
242
|
return buildCommandComplete('ALTER ROLE')
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
// SET TRANSACTION - pglite rejects this if any query ran first (e.g. SET search_path).
|
|
246
|
+
// return synthetic response since pglite is single-connection and doesn't need isolation levels.
|
|
247
|
+
if (upper.startsWith('SET TRANSACTION') || upper.startsWith('SET SESSION')) {
|
|
248
|
+
return buildCommandComplete('SET')
|
|
249
|
+
}
|
|
250
|
+
|
|
241
251
|
return null
|
|
242
252
|
}
|
|
243
253
|
|
|
@@ -262,8 +272,23 @@ export async function handleStartReplication(
|
|
|
262
272
|
|
|
263
273
|
let lastWatermark = 0
|
|
264
274
|
|
|
265
|
-
//
|
|
266
|
-
|
|
275
|
+
// declared outside mutex block so they're accessible in the poll loop
|
|
276
|
+
const tableKeyColumns = new Map<string, Set<string>>()
|
|
277
|
+
const excludedColumns = new Map<string, Set<string>>()
|
|
278
|
+
const booleanColumns = new Map<string, Set<string>>()
|
|
279
|
+
|
|
280
|
+
// acquire mutex for all setup queries to avoid conflicting with proxy connections.
|
|
281
|
+
// the change-streamer's initial copy also queries PGlite via the proxy, and
|
|
282
|
+
// direct db.query()/db.exec() calls here bypass the proxy's mutex, causing
|
|
283
|
+
// "already in transaction" errors when they interleave.
|
|
284
|
+
await pgMutex.acquire()
|
|
285
|
+
try {
|
|
286
|
+
// install change tracking triggers on shard schema tables (e.g. chat_0.clients)
|
|
287
|
+
// these track zero-cache's lastMutationID for .server promise resolution
|
|
288
|
+
await installTriggersOnShardTables(db)
|
|
289
|
+
|
|
290
|
+
// set up LISTEN for real-time change notifications
|
|
291
|
+
await db.exec(`
|
|
267
292
|
CREATE OR REPLACE FUNCTION public._zero_notify_change() RETURNS TRIGGER AS $$
|
|
268
293
|
BEGIN
|
|
269
294
|
PERFORM pg_notify('_zero_changes', TG_TABLE_NAME);
|
|
@@ -272,77 +297,135 @@ export async function handleStartReplication(
|
|
|
272
297
|
$$ LANGUAGE plpgsql;
|
|
273
298
|
`)
|
|
274
299
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
300
|
+
// install notify trigger on tracked tables (use configured publication if available)
|
|
301
|
+
const pubName = process.env.ZERO_APP_PUBLICATIONS
|
|
302
|
+
let tables: { tablename: string }[]
|
|
303
|
+
if (pubName) {
|
|
304
|
+
const result = await db.query<{ tablename: string }>(
|
|
305
|
+
`SELECT tablename FROM pg_publication_tables
|
|
281
306
|
WHERE pubname = $1 AND schemaname = 'public' AND tablename NOT LIKE '_zero_%'`,
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
307
|
+
[pubName]
|
|
308
|
+
)
|
|
309
|
+
tables = result.rows
|
|
310
|
+
} else {
|
|
311
|
+
const result = await db.query<{ tablename: string }>(
|
|
312
|
+
`SELECT tablename FROM pg_tables
|
|
288
313
|
WHERE schemaname = 'public'
|
|
289
314
|
AND tablename NOT IN ('migrations', '_zero_changes')
|
|
290
315
|
AND tablename NOT LIKE '_zero_%'`
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
316
|
+
)
|
|
317
|
+
tables = result.rows
|
|
318
|
+
}
|
|
294
319
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
320
|
+
for (const { tablename } of tables) {
|
|
321
|
+
const quoted = '"' + tablename.replace(/"/g, '""') + '"'
|
|
322
|
+
await db.exec(`
|
|
298
323
|
DROP TRIGGER IF EXISTS _zero_notify_trigger ON public.${quoted};
|
|
299
324
|
CREATE TRIGGER _zero_notify_trigger
|
|
300
325
|
AFTER INSERT OR UPDATE OR DELETE ON public.${quoted}
|
|
301
326
|
FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();
|
|
302
327
|
`)
|
|
303
|
-
|
|
328
|
+
}
|
|
304
329
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
AND
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
330
|
+
// discover shard schemas (e.g. chat_0) and install NOTIFY triggers
|
|
331
|
+
const shardSchemas = await db.query<{ nspname: string }>(
|
|
332
|
+
`SELECT nspname FROM pg_namespace
|
|
333
|
+
WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
|
|
334
|
+
AND nspname NOT LIKE 'pg_%'
|
|
335
|
+
AND nspname NOT LIKE 'zero_%'
|
|
336
|
+
AND nspname NOT LIKE '_zero_%'
|
|
337
|
+
AND nspname NOT LIKE '%/%'`
|
|
338
|
+
)
|
|
339
|
+
const relevantSchemas = ['public', ...shardSchemas.rows.map((r) => r.nspname)]
|
|
340
|
+
|
|
341
|
+
for (const schema of relevantSchemas) {
|
|
342
|
+
if (schema === 'public') continue
|
|
343
|
+
const shardTables = await db.query<{ tablename: string }>(
|
|
344
|
+
`SELECT tablename FROM pg_tables WHERE schemaname = $1`,
|
|
345
|
+
[schema]
|
|
346
|
+
)
|
|
347
|
+
for (const { tablename } of shardTables.rows) {
|
|
348
|
+
const quotedSchema = '"' + schema.replace(/"/g, '""') + '"'
|
|
349
|
+
const quotedTable = '"' + tablename.replace(/"/g, '""') + '"'
|
|
350
|
+
await db.exec(`
|
|
351
|
+
DROP TRIGGER IF EXISTS _zero_notify_trigger ON ${quotedSchema}.${quotedTable};
|
|
352
|
+
CREATE TRIGGER _zero_notify_trigger
|
|
353
|
+
AFTER INSERT OR UPDATE OR DELETE ON ${quotedSchema}.${quotedTable}
|
|
354
|
+
FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();
|
|
355
|
+
`)
|
|
356
|
+
}
|
|
357
|
+
if (shardTables.rows.length > 0) {
|
|
358
|
+
log.debug.proxy(
|
|
359
|
+
`installed notify triggers on ${shardTables.rows.length} tables in schema "${schema}"`
|
|
360
|
+
)
|
|
361
|
+
}
|
|
321
362
|
}
|
|
322
|
-
keys.add(column_name)
|
|
323
|
-
}
|
|
324
|
-
log.debug.proxy(`loaded primary keys for ${tableKeyColumns.size} tables`)
|
|
325
363
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
364
|
+
// build primary key lookup for all relevant schemas
|
|
365
|
+
for (const schema of relevantSchemas) {
|
|
366
|
+
const pkResult = await db.query<{ table_name: string; column_name: string }>(
|
|
367
|
+
`SELECT tc.table_name, kcu.column_name
|
|
368
|
+
FROM information_schema.table_constraints tc
|
|
369
|
+
JOIN information_schema.key_column_usage kcu
|
|
370
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
371
|
+
AND tc.table_schema = kcu.table_schema
|
|
372
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
373
|
+
AND tc.table_schema = $1`,
|
|
374
|
+
[schema]
|
|
375
|
+
)
|
|
376
|
+
for (const { table_name, column_name } of pkResult.rows) {
|
|
377
|
+
const key = `${schema}.${table_name}`
|
|
378
|
+
let keys = tableKeyColumns.get(key)
|
|
379
|
+
if (!keys) {
|
|
380
|
+
keys = new Set()
|
|
381
|
+
tableKeyColumns.set(key, keys)
|
|
382
|
+
}
|
|
383
|
+
keys.add(column_name)
|
|
340
384
|
}
|
|
341
|
-
cols.add(column_name)
|
|
342
385
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
386
|
+
log.debug.proxy(`loaded primary keys for ${tableKeyColumns.size} tables`)
|
|
387
|
+
|
|
388
|
+
// build excluded columns lookup (types zero-cache can't handle)
|
|
389
|
+
// also track boolean columns so we can send correct typeOid (16) in RELATION messages.
|
|
390
|
+
const UNSUPPORTED_TYPES = new Set(['tsvector', 'tsquery', 'USER-DEFINED'])
|
|
391
|
+
for (const schema of relevantSchemas) {
|
|
392
|
+
const colResult = await db.query<{
|
|
393
|
+
table_name: string
|
|
394
|
+
column_name: string
|
|
395
|
+
data_type: string
|
|
396
|
+
}>(
|
|
397
|
+
`SELECT table_name, column_name, data_type
|
|
398
|
+
FROM information_schema.columns
|
|
399
|
+
WHERE table_schema = $1`,
|
|
400
|
+
[schema]
|
|
401
|
+
)
|
|
402
|
+
for (const { table_name, column_name, data_type } of colResult.rows) {
|
|
403
|
+
const key = `${schema}.${table_name}`
|
|
404
|
+
if (UNSUPPORTED_TYPES.has(data_type)) {
|
|
405
|
+
let cols = excludedColumns.get(key)
|
|
406
|
+
if (!cols) {
|
|
407
|
+
cols = new Set()
|
|
408
|
+
excludedColumns.set(key, cols)
|
|
409
|
+
}
|
|
410
|
+
cols.add(column_name)
|
|
411
|
+
}
|
|
412
|
+
if (data_type === 'boolean') {
|
|
413
|
+
let cols = booleanColumns.get(key)
|
|
414
|
+
if (!cols) {
|
|
415
|
+
cols = new Set()
|
|
416
|
+
booleanColumns.set(key, cols)
|
|
417
|
+
}
|
|
418
|
+
cols.add(column_name)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (excludedColumns.size > 0) {
|
|
423
|
+
log.debug.proxy(
|
|
424
|
+
`excluding unsupported columns: ${[...excludedColumns.entries()].map(([t, c]) => `${t}(${[...c].join(',')})`).join(', ')}`
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
} finally {
|
|
428
|
+
pgMutex.release()
|
|
346
429
|
}
|
|
347
430
|
|
|
348
431
|
// track which tables we've sent RELATION messages for
|
|
@@ -356,10 +439,25 @@ export async function handleStartReplication(
|
|
|
356
439
|
const poll = async () => {
|
|
357
440
|
while (running) {
|
|
358
441
|
try {
|
|
359
|
-
|
|
442
|
+
// acquire mutex to avoid conflicting with proxy connections
|
|
443
|
+
await pgMutex.acquire()
|
|
444
|
+
let changes: Awaited<ReturnType<typeof getChangesSince>>
|
|
445
|
+
try {
|
|
446
|
+
changes = await getChangesSince(db, lastWatermark, 100)
|
|
447
|
+
} finally {
|
|
448
|
+
pgMutex.release()
|
|
449
|
+
}
|
|
360
450
|
|
|
361
451
|
if (changes.length > 0) {
|
|
362
|
-
await streamChanges(
|
|
452
|
+
await streamChanges(
|
|
453
|
+
changes,
|
|
454
|
+
writer,
|
|
455
|
+
sentRelations,
|
|
456
|
+
txCounter++,
|
|
457
|
+
tableKeyColumns,
|
|
458
|
+
excludedColumns,
|
|
459
|
+
booleanColumns
|
|
460
|
+
)
|
|
363
461
|
lastWatermark = changes[changes.length - 1].watermark
|
|
364
462
|
}
|
|
365
463
|
|
|
@@ -391,7 +489,8 @@ async function streamChanges(
|
|
|
391
489
|
sentRelations: Set<string>,
|
|
392
490
|
txId: number,
|
|
393
491
|
tableKeyColumns: Map<string, Set<string>>,
|
|
394
|
-
excludedColumns: Map<string, Set<string
|
|
492
|
+
excludedColumns: Map<string, Set<string>>,
|
|
493
|
+
booleanColumns: Map<string, Set<string>>
|
|
395
494
|
): Promise<void> {
|
|
396
495
|
const ts = nowMicros()
|
|
397
496
|
const lsn = nextLsn()
|
|
@@ -401,8 +500,15 @@ async function streamChanges(
|
|
|
401
500
|
writer.write(wrapCopyData(beginMsg))
|
|
402
501
|
|
|
403
502
|
for (const change of changes) {
|
|
404
|
-
|
|
405
|
-
const
|
|
503
|
+
// parse schema-qualified name (schema.table or bare table)
|
|
504
|
+
const dot = change.table_name.indexOf('.')
|
|
505
|
+
const schema = dot !== -1 ? change.table_name.substring(0, dot) : 'public'
|
|
506
|
+
const tableName =
|
|
507
|
+
dot !== -1 ? change.table_name.substring(dot + 1) : change.table_name
|
|
508
|
+
const qualifiedKey = `${schema}.${tableName}`
|
|
509
|
+
|
|
510
|
+
const tableOid = getTableOid(qualifiedKey)
|
|
511
|
+
const excluded = excludedColumns.get(qualifiedKey)
|
|
406
512
|
|
|
407
513
|
// filter out unsupported columns from row data
|
|
408
514
|
let rowData = change.row_data
|
|
@@ -423,17 +529,19 @@ async function streamChanges(
|
|
|
423
529
|
const row = rowData || oldData
|
|
424
530
|
if (!row) continue
|
|
425
531
|
|
|
426
|
-
const keySet = tableKeyColumns.get(
|
|
532
|
+
const keySet = tableKeyColumns.get(qualifiedKey)
|
|
533
|
+
const boolSet = booleanColumns.get(qualifiedKey)
|
|
427
534
|
const columns = inferColumns(row).map((col) => ({
|
|
428
535
|
...col,
|
|
536
|
+
typeOid: boolSet?.has(col.name) ? 16 : col.typeOid,
|
|
429
537
|
isKey: keySet?.has(col.name) ?? false,
|
|
430
538
|
}))
|
|
431
539
|
|
|
432
540
|
// send RELATION if not yet sent
|
|
433
|
-
if (!sentRelations.has(
|
|
434
|
-
const relMsg = encodeRelation(tableOid,
|
|
541
|
+
if (!sentRelations.has(qualifiedKey)) {
|
|
542
|
+
const relMsg = encodeRelation(tableOid, schema, tableName, 0x64, columns)
|
|
435
543
|
writer.write(wrapCopyData(wrapXLogData(lsn, lsn, ts, relMsg)))
|
|
436
|
-
sentRelations.add(
|
|
544
|
+
sentRelations.add(qualifiedKey)
|
|
437
545
|
}
|
|
438
546
|
|
|
439
547
|
// send the change
|
|
@@ -147,7 +147,15 @@ function encodeTupleData(
|
|
|
147
147
|
values.push(null)
|
|
148
148
|
totalSize += 1 // 'n' byte
|
|
149
149
|
} else {
|
|
150
|
-
|
|
150
|
+
// convert to postgresql text format
|
|
151
|
+
let strVal: string
|
|
152
|
+
if (typeof val === 'boolean') {
|
|
153
|
+
strVal = val ? 't' : 'f'
|
|
154
|
+
} else if (typeof val === 'object') {
|
|
155
|
+
strVal = JSON.stringify(val)
|
|
156
|
+
} else {
|
|
157
|
+
strVal = String(val)
|
|
158
|
+
}
|
|
151
159
|
const bytes = encodeString(strVal)
|
|
152
160
|
values.push(bytes)
|
|
153
161
|
totalSize += 1 + 4 + bytes.length // 't' + len + data
|
|
@@ -534,9 +534,13 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
|
|
|
534
534
|
expect(names).toContain('int_val')
|
|
535
535
|
expect(names).toContain('text_val')
|
|
536
536
|
|
|
537
|
-
//
|
|
537
|
+
// typeOids: boolean columns use 16, everything else is 25 (text)
|
|
538
538
|
for (const col of rel.columns) {
|
|
539
|
-
|
|
539
|
+
if (col.name === 'bool_val') {
|
|
540
|
+
expect(col.typeOid).toBe(16)
|
|
541
|
+
} else {
|
|
542
|
+
expect(col.typeOid).toBe(25)
|
|
543
|
+
}
|
|
540
544
|
}
|
|
541
545
|
|
|
542
546
|
s.close()
|
|
@@ -560,7 +564,7 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
|
|
|
560
564
|
expect(ins.new.int_val).toBe('123')
|
|
561
565
|
expect(ins.new.big_val).toBe('9876543210')
|
|
562
566
|
expect(ins.new.flt_val).toBe('3.14')
|
|
563
|
-
expect(ins.new.bool_val).toBe('
|
|
567
|
+
expect(ins.new.bool_val).toBe('t')
|
|
564
568
|
expect(ins.new.text_val).toBe('hello')
|
|
565
569
|
|
|
566
570
|
s.close()
|