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.
@@ -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(/CREATE_REPLICATION_SLOT\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i)
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
- // set up LISTEN for real-time change notifications
266
- await db.exec(`
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
- // install notify trigger on tracked tables (use configured publication if available)
276
- const pubName = process.env.ZERO_APP_PUBLICATIONS
277
- let tables: { tablename: string }[]
278
- if (pubName) {
279
- const result = await db.query<{ tablename: string }>(
280
- `SELECT tablename FROM pg_publication_tables
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
- [pubName]
283
- )
284
- tables = result.rows
285
- } else {
286
- const result = await db.query<{ tablename: string }>(
287
- `SELECT tablename FROM pg_tables
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
- tables = result.rows
293
- }
316
+ )
317
+ tables = result.rows
318
+ }
294
319
 
295
- for (const { tablename } of tables) {
296
- const quoted = '"' + tablename.replace(/"/g, '""') + '"'
297
- await db.exec(`
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
- // build primary key lookup for all public tables
306
- const tableKeyColumns = new Map<string, Set<string>>()
307
- const pkResult = await db.query<{ table_name: string; column_name: string }>(
308
- `SELECT tc.table_name, kcu.column_name
309
- FROM information_schema.table_constraints tc
310
- JOIN information_schema.key_column_usage kcu
311
- ON tc.constraint_name = kcu.constraint_name
312
- AND tc.table_schema = kcu.table_schema
313
- WHERE tc.constraint_type = 'PRIMARY KEY'
314
- AND tc.table_schema = 'public'`
315
- )
316
- for (const { table_name, column_name } of pkResult.rows) {
317
- let keys = tableKeyColumns.get(table_name)
318
- if (!keys) {
319
- keys = new Set()
320
- tableKeyColumns.set(table_name, keys)
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
- // build excluded columns lookup (types zero-cache can't handle)
327
- const excludedColumns = new Map<string, Set<string>>()
328
- const UNSUPPORTED_TYPES = new Set(['tsvector', 'tsquery', 'USER-DEFINED'])
329
- const colResult = await db.query<{ table_name: string; column_name: string; data_type: string }>(
330
- `SELECT table_name, column_name, data_type
331
- FROM information_schema.columns
332
- WHERE table_schema = 'public'`
333
- )
334
- for (const { table_name, column_name, data_type } of colResult.rows) {
335
- if (UNSUPPORTED_TYPES.has(data_type)) {
336
- let cols = excludedColumns.get(table_name)
337
- if (!cols) {
338
- cols = new Set()
339
- excludedColumns.set(table_name, cols)
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
- if (excludedColumns.size > 0) {
345
- log.debug.proxy(`excluding unsupported columns: ${[...excludedColumns.entries()].map(([t, c]) => `${t}(${[...c].join(',')})`).join(', ')}`)
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
- const changes = await getChangesSince(db, lastWatermark, 100)
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(changes, writer, sentRelations, txCounter++, tableKeyColumns, excludedColumns)
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
- const tableOid = getTableOid(change.table_name)
405
- const excluded = excludedColumns.get(change.table_name)
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(change.table_name)
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(change.table_name)) {
434
- const relMsg = encodeRelation(tableOid, 'public', change.table_name, 0x64, columns)
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(change.table_name)
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
- const strVal = typeof val === 'object' ? JSON.stringify(val) : String(val)
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
- // all typeOids are 25 (text) - known difference from real postgres
537
+ // typeOids: boolean columns use 16, everything else is 25 (text)
538
538
  for (const col of rel.columns) {
539
- expect(col.typeOid).toBe(25)
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('true')
567
+ expect(ins.new.bool_val).toBe('t')
564
568
  expect(ins.new.text_val).toBe('hello')
565
569
 
566
570
  s.close()