orez 0.2.17 → 0.2.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.
@@ -134,6 +134,7 @@ export function resetReplicationState(): void {
134
134
  cachedTableKeyColumns = null
135
135
  cachedExcludedColumns = null
136
136
  cachedColumnTypeOids = null
137
+ cachedColumns.clear()
137
138
  }
138
139
  function nextLsn(): bigint {
139
140
  const floor = lsnFloorFromTime()
@@ -539,8 +540,8 @@ export async function handleStartReplication(
539
540
  if (shardClientSchemas.length > 0) {
540
541
  const shardTables = await db.query<{ schemaname: string; tablename: string }>(
541
542
  `SELECT schemaname, tablename FROM pg_tables
542
- WHERE schemaname = ANY($1) AND tablename = 'clients'`,
543
- [shardClientSchemas]
543
+ WHERE schemaname = ANY($1) AND tablename = ANY($2)`,
544
+ [shardClientSchemas, ['clients', 'mutations']]
544
545
  )
545
546
  for (const { schemaname, tablename } of shardTables.rows) {
546
547
  const qs = '"' + schemaname.replace(/"/g, '""') + '"'
@@ -578,8 +579,9 @@ export async function handleStartReplication(
578
579
  table_name: string
579
580
  column_name: string
580
581
  data_type: string | null
582
+ ordinal_position: number
581
583
  }>(
582
- `SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type
584
+ `SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type, kcu.ordinal_position
583
585
  FROM information_schema.table_constraints tc
584
586
  JOIN information_schema.key_column_usage kcu
585
587
  ON tc.constraint_name = kcu.constraint_name
@@ -587,9 +589,10 @@ export async function handleStartReplication(
587
589
  WHERE tc.constraint_type = 'PRIMARY KEY'
588
590
  AND tc.table_schema = ANY($1)
589
591
  UNION ALL
590
- SELECT 'col' AS kind, table_schema, table_name, column_name, data_type
592
+ SELECT 'col' AS kind, table_schema, table_name, column_name, data_type, ordinal_position
591
593
  FROM information_schema.columns
592
- WHERE table_schema = ANY($1)`,
594
+ WHERE table_schema = ANY($1)
595
+ ORDER BY table_schema, table_name, kind, ordinal_position`,
593
596
  [relevantSchemas]
594
597
  )
595
598
 
@@ -834,9 +837,9 @@ export async function handleStartReplication(
834
837
  `found ${changes.length} changes [${tableSummary}] (wm ${lastWatermark}→${changes[changes.length - 1].watermark}) query=${queryMs.toFixed(1)}ms signal→query=${signalToQueryMs}ms`
835
838
  )
836
839
  // filter out shard tables that zero-cache doesn't expect.
837
- // only `clients` is needed (for .server promise resolution).
838
- // other shard tables (replicas, mutations) crash zero-cache
839
- // with "Unknown table" in change-processor.
840
+ // `clients` advances lmid; `mutations` carries mutation results.
841
+ // other shard tables (e.g. replicas) crash zero-cache with
842
+ // "Unknown table" in the change processor.
840
843
  const batchEnd = changes[changes.length - 1].watermark
841
844
  const preFilterCount = changes.length
842
845
  changes = changes.filter((c) => {
@@ -845,7 +848,7 @@ export async function handleStartReplication(
845
848
  const schema = c.table_name.substring(0, dot)
846
849
  if (schema === 'public') return true
847
850
  const table = c.table_name.substring(dot + 1)
848
- return table === 'clients'
851
+ return table === 'clients' || table === 'mutations'
849
852
  })
850
853
  log.debug.repl(`filter: ${preFilterCount} → ${changes.length} changes`)
851
854
 
@@ -859,6 +862,15 @@ export async function handleStartReplication(
859
862
  continue
860
863
  }
861
864
 
865
+ await ensureMetadataForChangedTables(
866
+ db,
867
+ mutex,
868
+ changes,
869
+ tableKeyColumns,
870
+ excludedColumns,
871
+ columnTypeOids
872
+ )
873
+
862
874
  log.debug.repl(`streaming ${changes.length} changes to writer`)
863
875
  await streamChanges(
864
876
  changes,
@@ -930,6 +942,106 @@ export async function handleStartReplication(
930
942
  log.repl('poll loop exited')
931
943
  }
932
944
 
945
+ async function ensureMetadataForChangedTables(
946
+ db: PGlite,
947
+ mutex: Mutex,
948
+ changes: ChangeRecord[],
949
+ tableKeyColumns: Map<string, Set<string>>,
950
+ excludedColumns: Map<string, Set<string>>,
951
+ columnTypeOids: Map<string, Map<string, number>>
952
+ ): Promise<void> {
953
+ const missing = new Map<string, { schema: string; table: string }>()
954
+
955
+ for (const change of changes) {
956
+ if (
957
+ tableKeyColumns.has(change.table_name) ||
958
+ columnTypeOids.has(change.table_name) ||
959
+ excludedColumns.has(change.table_name)
960
+ ) {
961
+ continue
962
+ }
963
+
964
+ const dot = change.table_name.indexOf('.')
965
+ const schema = dot === -1 ? 'public' : change.table_name.substring(0, dot)
966
+ const table = dot === -1 ? change.table_name : change.table_name.substring(dot + 1)
967
+ missing.set(`${schema}.${table}`, { schema, table })
968
+ }
969
+
970
+ if (missing.size === 0) return
971
+
972
+ const schemas = [...new Set([...missing.values()].map((entry) => entry.schema))]
973
+ const tables = [...new Set([...missing.values()].map((entry) => entry.table))]
974
+
975
+ await mutex.acquire()
976
+ try {
977
+ const schemaResult = await db.query<{
978
+ kind: string
979
+ table_schema: string
980
+ table_name: string
981
+ column_name: string
982
+ data_type: string | null
983
+ ordinal_position: number
984
+ }>(
985
+ `SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type, kcu.ordinal_position
986
+ FROM information_schema.table_constraints tc
987
+ JOIN information_schema.key_column_usage kcu
988
+ ON tc.constraint_name = kcu.constraint_name
989
+ AND tc.table_schema = kcu.table_schema
990
+ WHERE tc.constraint_type = 'PRIMARY KEY'
991
+ AND tc.table_schema = ANY($1)
992
+ AND tc.table_name = ANY($2)
993
+ UNION ALL
994
+ SELECT 'col' AS kind, table_schema, table_name, column_name, data_type, ordinal_position
995
+ FROM information_schema.columns
996
+ WHERE table_schema = ANY($1)
997
+ AND table_name = ANY($2)
998
+ ORDER BY table_schema, table_name, kind, ordinal_position`,
999
+ [schemas, tables]
1000
+ )
1001
+
1002
+ for (const row of schemaResult.rows) {
1003
+ const key = `${row.table_schema}.${row.table_name}`
1004
+ if (!missing.has(key)) continue
1005
+
1006
+ if (row.kind === 'pk') {
1007
+ let keys = tableKeyColumns.get(key)
1008
+ if (!keys) {
1009
+ keys = new Set()
1010
+ tableKeyColumns.set(key, keys)
1011
+ }
1012
+ keys.add(row.column_name)
1013
+ } else {
1014
+ if (row.data_type && UNSUPPORTED_TYPES.has(row.data_type)) {
1015
+ let cols = excludedColumns.get(key)
1016
+ if (!cols) {
1017
+ cols = new Set()
1018
+ excludedColumns.set(key, cols)
1019
+ }
1020
+ cols.add(row.column_name)
1021
+ }
1022
+ if (row.data_type) {
1023
+ const oid = PG_DATA_TYPE_OIDS[row.data_type]
1024
+ if (oid !== undefined) {
1025
+ let cols = columnTypeOids.get(key)
1026
+ if (!cols) {
1027
+ cols = new Map()
1028
+ columnTypeOids.set(key, cols)
1029
+ }
1030
+ cols.set(row.column_name, oid)
1031
+ }
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ log.debug.repl(
1037
+ `refreshed metadata for ${missing.size} late table(s): ${[...missing.keys()].join(',')}`
1038
+ )
1039
+ for (const key of missing.keys()) cachedColumns.delete(key)
1040
+ } finally {
1041
+ mutex.release()
1042
+ }
1043
+ }
1044
+
933
1045
  // cache column info per table to avoid per-change allocation
934
1046
  const cachedColumns = new Map<string, ReturnType<typeof inferColumns>>()
935
1047
 
@@ -17,7 +17,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
17
17
 
18
18
  import { getConfig } from '../config'
19
19
  import { startPgProxy } from '../pg-proxy'
20
- import { installChangeTracking, resetShardSchemaCache } from './change-tracker'
20
+ import {
21
+ installChangeTracking,
22
+ installTriggersOnShardTables,
23
+ resetShardSchemaCache,
24
+ } from './change-tracker'
21
25
  import { signalReplicationChange, resetReplicationState } from './handler'
22
26
 
23
27
  import type { Server, AddressInfo } from 'node:net'
@@ -959,10 +963,10 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
959
963
  s.close()
960
964
  })
961
965
 
962
- it('shard replicas/mutations changes NOT streamed (only clients)', async () => {
966
+ it('streams shard mutation-confirmation tables but filters replicas', async () => {
963
967
  // zero-cache creates shard schemas (chat_0) with clients, replicas, mutations.
964
- // if we stream replicas/mutations changes, zero-cache crashes with
965
- // "Unknown table chat_0.replicas". only clients changes should be streamed.
968
+ // clients advance lmid and mutations carry server results; replicas is
969
+ // internal shard state that zero-cache does not expect in the change stream.
966
970
  await db.exec(`
967
971
  CREATE SCHEMA chat_0;
968
972
  CREATE TABLE chat_0.clients (
@@ -976,9 +980,11 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
976
980
  version TEXT
977
981
  );
978
982
  CREATE TABLE chat_0.mutations (
979
- id TEXT PRIMARY KEY,
980
- "clientID" TEXT,
981
- name TEXT
983
+ "clientGroupID" TEXT NOT NULL,
984
+ "clientID" TEXT NOT NULL,
985
+ "mutationID" BIGINT NOT NULL,
986
+ result JSON,
987
+ PRIMARY KEY ("clientGroupID", "clientID", "mutationID")
982
988
  );
983
989
  `)
984
990
  await installChangeTracking(db)
@@ -995,7 +1001,7 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
995
1001
  )
996
1002
  await db.exec(`INSERT INTO chat_0.replicas (id, version) VALUES ('r1', 'v1')`)
997
1003
  await db.exec(
998
- `INSERT INTO chat_0.mutations (id, "clientID", name) VALUES ('m1', 'c1', 'send')`
1004
+ `INSERT INTO chat_0.mutations ("clientGroupID", "clientID", "mutationID", result) VALUES ('cg1', 'c1', 1, '{}')`
999
1005
  )
1000
1006
  await db.exec(`INSERT INTO public.foo (id) VALUES ('normal')`)
1001
1007
 
@@ -1008,12 +1014,88 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
1008
1014
  if (m.tag === 'insert') inserts.push(m as ZcInsert)
1009
1015
  }
1010
1016
 
1011
- // should see clients + foo inserts, but NOT replicas or mutations
1017
+ // should see clients + mutations + foo inserts, but NOT replicas.
1012
1018
  const streamedTables = inserts.map((i) => `${i.relation.schema}.${i.relation.name}`)
1013
1019
  expect(streamedTables).toContain('public.foo')
1014
1020
  expect(streamedTables).toContain('chat_0.clients')
1021
+ expect(streamedTables).toContain('chat_0.mutations')
1015
1022
  expect(streamedTables).not.toContain('chat_0.replicas')
1016
- expect(streamedTables).not.toContain('chat_0.mutations')
1023
+
1024
+ s.close()
1025
+ })
1026
+
1027
+ it('refreshes metadata for shard tables missing from cached setup', async () => {
1028
+ // zero-cache can create shard schemas after the first replication stream
1029
+ // cached metadata. the next stream must not encode bigint/json shard
1030
+ // columns as text, or zero-cache cannot parse mutation confirmations.
1031
+ const warmup = await stream()
1032
+ warmup.close()
1033
+ await new Promise((r) => setTimeout(r, 50))
1034
+
1035
+ await db.exec(`
1036
+ CREATE SCHEMA chat_0;
1037
+ CREATE TABLE chat_0.clients (
1038
+ "clientGroupID" TEXT NOT NULL,
1039
+ "clientID" TEXT NOT NULL,
1040
+ "lastMutationID" BIGINT,
1041
+ PRIMARY KEY ("clientGroupID", "clientID")
1042
+ );
1043
+ CREATE TABLE chat_0.mutations (
1044
+ "clientGroupID" TEXT NOT NULL,
1045
+ "clientID" TEXT NOT NULL,
1046
+ "mutationID" BIGINT NOT NULL,
1047
+ result JSON,
1048
+ PRIMARY KEY ("clientGroupID", "clientID", "mutationID")
1049
+ );
1050
+ `)
1051
+ await installTriggersOnShardTables(db)
1052
+
1053
+ const s = await stream()
1054
+ const q = s.messages
1055
+
1056
+ await db.exec(
1057
+ `INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 42)`
1058
+ )
1059
+ await db.exec(
1060
+ `INSERT INTO chat_0.mutations ("clientGroupID", "clientID", "mutationID", result) VALUES ('cg1', 'c1', 42, '{"data":"ok"}')`
1061
+ )
1062
+
1063
+ let clientsInsert: ZcInsert | null = null
1064
+ let mutationsInsert: ZcInsert | null = null
1065
+ const deadline = Date.now() + 5000
1066
+ while (Date.now() < deadline) {
1067
+ const m = await q.dequeue(2000).catch(() => null)
1068
+ if (!m) break
1069
+ if (m.tag === 'insert') {
1070
+ const ins = m as ZcInsert
1071
+ if (ins.relation.schema === 'chat_0' && ins.relation.name === 'clients') {
1072
+ clientsInsert = ins
1073
+ }
1074
+ if (ins.relation.schema === 'chat_0' && ins.relation.name === 'mutations') {
1075
+ mutationsInsert = ins
1076
+ }
1077
+ }
1078
+ if (clientsInsert && mutationsInsert) break
1079
+ }
1080
+
1081
+ expect(clientsInsert).not.toBeNull()
1082
+ expect(mutationsInsert).not.toBeNull()
1083
+ expect([...clientsInsert!.relation.keyColumns].sort()).toEqual(
1084
+ ['clientGroupID', 'clientID'].sort()
1085
+ )
1086
+ expect(
1087
+ clientsInsert!.relation.columns.find((col) => col.name === 'lastMutationID')
1088
+ ?.typeOid
1089
+ ).toBe(20)
1090
+ expect([...mutationsInsert!.relation.keyColumns].sort()).toEqual(
1091
+ ['clientGroupID', 'clientID', 'mutationID'].sort()
1092
+ )
1093
+ expect(
1094
+ mutationsInsert!.relation.columns.find((col) => col.name === 'mutationID')?.typeOid
1095
+ ).toBe(20)
1096
+ expect(
1097
+ mutationsInsert!.relation.columns.find((col) => col.name === 'result')?.typeOid
1098
+ ).toBe(114)
1017
1099
 
1018
1100
  s.close()
1019
1101
  })
@@ -66,6 +66,27 @@ describe('messagePortToWs', () => {
66
66
  expect(spy).toHaveBeenCalledWith('hello')
67
67
  })
68
68
 
69
+ it('send() invokes the ws callback after posting', () => {
70
+ const [port1] = createMockPorts()
71
+ const ws = messagePortToWs(port1 as any)
72
+ const callback = vi.fn()
73
+
74
+ ws.send('hello', callback)
75
+
76
+ expect(callback).toHaveBeenCalledWith()
77
+ })
78
+
79
+ it('send() reports an error to the callback after close', () => {
80
+ const [port1] = createMockPorts()
81
+ const ws = messagePortToWs(port1 as any)
82
+ const callback = vi.fn()
83
+
84
+ ws.close()
85
+ ws.send('ignored', callback)
86
+
87
+ expect(callback).toHaveBeenCalledWith(expect.any(Error))
88
+ })
89
+
69
90
  it('forwards port messages as ws message events', () => {
70
91
  const [port1, port2] = createMockPorts()
71
92
  const ws1 = messagePortToWs(port1 as any)
@@ -26,7 +26,10 @@ interface WsCompatible {
26
26
  CLOSING: number
27
27
  CLOSED: number
28
28
  readyState: number
29
- send(data: string | ArrayBuffer | ArrayBufferView): void
29
+ send(
30
+ data: string | ArrayBuffer | ArrayBufferView,
31
+ callback?: (err?: Error) => void
32
+ ): void
30
33
  close(code?: number, reason?: string): void
31
34
  addEventListener(type: string, handler: (event: any) => void): void
32
35
  removeEventListener(type: string, handler: (event: any) => void): void
@@ -143,9 +146,24 @@ export function messagePortToWs(port: MessagePort): WsCompatible {
143
146
  return closed ? 3 : 1 // CLOSED or OPEN
144
147
  },
145
148
 
146
- send(data: string | ArrayBuffer | ArrayBufferView) {
147
- if (closed) return
148
- port.postMessage(data)
149
+ send(data: string | ArrayBuffer | ArrayBufferView, callback?: (err?: Error) => void) {
150
+ if (closed) {
151
+ callback?.(new Error('WebSocket is closed'))
152
+ return
153
+ }
154
+
155
+ try {
156
+ port.postMessage(data)
157
+ } catch (error) {
158
+ const err = error instanceof Error ? error : new Error(String(error))
159
+ if (callback) {
160
+ callback(err)
161
+ return
162
+ }
163
+ throw err
164
+ }
165
+
166
+ callback?.()
149
167
  },
150
168
 
151
169
  close(code?: number, _reason?: string) {
@@ -179,8 +197,19 @@ export function browserWsToWs(ws: WebSocket): WsCompatible {
179
197
  return ws.readyState
180
198
  },
181
199
 
182
- send(data: string | ArrayBuffer | ArrayBufferView) {
183
- ws.send(data)
200
+ send(data: string | ArrayBuffer | ArrayBufferView, callback?: (err?: Error) => void) {
201
+ try {
202
+ ws.send(data)
203
+ } catch (error) {
204
+ const err = error instanceof Error ? error : new Error(String(error))
205
+ if (callback) {
206
+ callback(err)
207
+ return
208
+ }
209
+ throw err
210
+ }
211
+
212
+ callback?.()
184
213
  },
185
214
 
186
215
  close(code?: number, reason?: string) {