orez 0.2.27 → 0.2.29

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.
Files changed (150) hide show
  1. package/package.json +3 -4
  2. package/src/admin/admin-data.test.ts +0 -348
  3. package/src/admin/http-proxy.ts +0 -252
  4. package/src/admin/log-store.ts +0 -192
  5. package/src/admin/server.ts +0 -471
  6. package/src/admin/ui.ts +0 -1322
  7. package/src/bench/proxy-throughput.bench.ts +0 -343
  8. package/src/bench/serial-mutations.bench.ts +0 -270
  9. package/src/browser.ts +0 -203
  10. package/src/cf-do/.wrangler/cache/cf.json +0 -1
  11. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  12. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  13. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  14. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
  15. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  16. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  17. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  18. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
  19. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
  20. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
  21. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
  22. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
  23. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
  24. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
  25. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
  26. package/src/cf-do/ARCHITECTURE.md +0 -93
  27. package/src/cf-do/CHAT_E2E.md +0 -213
  28. package/src/cf-do/watermark.test.ts +0 -103
  29. package/src/cf-do/watermark.ts +0 -118
  30. package/src/cf-do/worker.ts +0 -1041
  31. package/src/cf-do/wrangler.toml +0 -11
  32. package/src/cf-pglite/README.md +0 -19
  33. package/src/change-tracking.ts +0 -25
  34. package/src/child-process.test.ts +0 -147
  35. package/src/child-process.ts +0 -90
  36. package/src/cli-entry.ts +0 -72
  37. package/src/cli.test.ts +0 -40
  38. package/src/cli.ts +0 -1214
  39. package/src/config.ts +0 -150
  40. package/src/do-sql-tracking.test.ts +0 -19
  41. package/src/do-sql-tracking.ts +0 -19
  42. package/src/index.ts +0 -1215
  43. package/src/integration/integration.test.ts +0 -517
  44. package/src/integration/native-binary.guard.test.ts +0 -13
  45. package/src/integration/native-startup.test.ts +0 -44
  46. package/src/integration/replication-latency.test.ts +0 -428
  47. package/src/integration/restore-live-stress.test.ts +0 -433
  48. package/src/integration/restore-reset.test.ts +0 -400
  49. package/src/integration/restore.test.ts +0 -274
  50. package/src/integration/test-permissions.ts +0 -147
  51. package/src/load-config.ts +0 -46
  52. package/src/log.ts +0 -96
  53. package/src/mutex.ts +0 -47
  54. package/src/pg-proxy-browser.singledb.test.ts +0 -233
  55. package/src/pg-proxy-browser.ts +0 -2022
  56. package/src/pg-proxy-do-backend.test.ts +0 -3890
  57. package/src/pg-proxy-do-backend.ts +0 -7191
  58. package/src/pg-proxy.ts +0 -1087
  59. package/src/pg-sqlite-compiler/README.md +0 -53
  60. package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
  61. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
  62. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
  63. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
  64. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
  65. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
  66. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
  67. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
  68. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
  69. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
  70. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
  71. package/src/pg-sqlite-compiler/index.ts +0 -73
  72. package/src/pg-sqlite-compiler/integration.test.ts +0 -136
  73. package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
  74. package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
  75. package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
  76. package/src/pg-sqlite-compiler/passes/index.ts +0 -49
  77. package/src/pg-sqlite-compiler/passes/types.ts +0 -156
  78. package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
  79. package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
  80. package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
  81. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
  82. package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
  83. package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
  84. package/src/pg-sqlite-compiler/types.ts +0 -63
  85. package/src/pglite-ipc.test.ts +0 -116
  86. package/src/pglite-ipc.ts +0 -266
  87. package/src/pglite-manager.ts +0 -557
  88. package/src/pglite-web-proxy.test.ts +0 -57
  89. package/src/pglite-web-proxy.ts +0 -221
  90. package/src/pglite-web-worker.ts +0 -152
  91. package/src/pglite-worker-thread.ts +0 -253
  92. package/src/port.ts +0 -25
  93. package/src/process-title.ts +0 -9
  94. package/src/recovery.ts +0 -155
  95. package/src/replication/change-tracker.test.ts +0 -357
  96. package/src/replication/change-tracker.ts +0 -279
  97. package/src/replication/handler.test.ts +0 -511
  98. package/src/replication/handler.ts +0 -1190
  99. package/src/replication/pgoutput-encoder.test.ts +0 -697
  100. package/src/replication/pgoutput-encoder.ts +0 -373
  101. package/src/replication/tcp-replication.test.ts +0 -876
  102. package/src/replication/zero-compat.test.ts +0 -1150
  103. package/src/restore-stress.test.ts +0 -188
  104. package/src/s3-local.ts +0 -203
  105. package/src/shim/hooks.mjs +0 -120
  106. package/src/shim/register.mjs +0 -4
  107. package/src/sqlite-mode/apply-mode.ts +0 -224
  108. package/src/sqlite-mode/index.ts +0 -15
  109. package/src/sqlite-mode/native-binary.ts +0 -89
  110. package/src/sqlite-mode/package-resolve.ts +0 -17
  111. package/src/sqlite-mode/resolve-mode.ts +0 -80
  112. package/src/sqlite-mode/shim-template.ts +0 -159
  113. package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
  114. package/src/sqlite-mode/types.ts +0 -30
  115. package/src/vite-plugin.ts +0 -67
  116. package/src/wasm-sqlite.test.ts +0 -537
  117. package/src/worker/browser-admin.ts +0 -52
  118. package/src/worker/browser-build-config.test.ts +0 -71
  119. package/src/worker/browser-build-config.ts +0 -109
  120. package/src/worker/browser-embed-admin.test.ts +0 -75
  121. package/src/worker/browser-embed.ts +0 -345
  122. package/src/worker/cf-patches.ts +0 -384
  123. package/src/worker/embed-integration.test.ts +0 -321
  124. package/src/worker/index.ts +0 -138
  125. package/src/worker/shims/fastify.test.ts +0 -255
  126. package/src/worker/shims/fastify.ts +0 -306
  127. package/src/worker/shims/http-service.test.ts +0 -355
  128. package/src/worker/shims/http-service.ts +0 -293
  129. package/src/worker/shims/node-stub.ts +0 -290
  130. package/src/worker/shims/oxfmt.ts +0 -3
  131. package/src/worker/shims/postgres-browser.ts +0 -59
  132. package/src/worker/shims/postgres-socket.test.ts +0 -576
  133. package/src/worker/shims/postgres-socket.ts +0 -310
  134. package/src/worker/shims/postgres.test.ts +0 -364
  135. package/src/worker/shims/postgres.ts +0 -1454
  136. package/src/worker/shims/sqlite-browser.test.ts +0 -233
  137. package/src/worker/shims/sqlite-browser.ts +0 -175
  138. package/src/worker/shims/sqlite.test.ts +0 -786
  139. package/src/worker/shims/sqlite.ts +0 -978
  140. package/src/worker/shims/stream-browser.ts +0 -15
  141. package/src/worker/shims/ws-browser.test.ts +0 -205
  142. package/src/worker/shims/ws-browser.ts +0 -248
  143. package/src/worker/shims/ws.test.ts +0 -288
  144. package/src/worker/shims/ws.ts +0 -467
  145. package/src/worker/shims/zero-process-env.ts +0 -11
  146. package/src/worker/types.ts +0 -75
  147. package/src/worker/worker-integration.test.ts +0 -223
  148. package/src/worker/worker.test.ts +0 -136
  149. package/src/worker/zero-cache-embed-cf.ts +0 -463
  150. package/src/worker/zero-cache-embed.ts +0 -277
@@ -1,1190 +0,0 @@
1
- /**
2
- * replication protocol handler.
3
- *
4
- * intercepts replication-mode queries (IDENTIFY_SYSTEM, CREATE_REPLICATION_SLOT,
5
- * START_REPLICATION) and returns fake responses that make zero-cache believe
6
- * it's talking to a real postgres with logical replication.
7
- */
8
-
9
- import { log } from '../log.js'
10
-
11
- const textEncoder = new TextEncoder()
12
- import {
13
- getChangesSince,
14
- getCurrentWatermark,
15
- purgeConsumedChanges,
16
- installTriggersOnShardTables,
17
- type ChangeRecord,
18
- } from './change-tracker.js'
19
- import {
20
- encodeBegin,
21
- encodeCommit,
22
- encodeRelation,
23
- encodeInsert,
24
- encodeUpdate,
25
- encodeDelete,
26
- encodeKeepalive,
27
- encodeWrappedChange,
28
- getTableOid,
29
- inferColumns,
30
- } from './pgoutput-encoder.js'
31
-
32
- import type { Mutex } from '../mutex.js'
33
- import type { PGlite } from '@electric-sql/pglite'
34
-
35
- // types pglite can't replicate — excluded from change tracking columns
36
- const UNSUPPORTED_TYPES = new Set(['tsvector', 'tsquery', 'USER-DEFINED'])
37
-
38
- // pg data_type string → wire protocol oid mapping
39
- const PG_DATA_TYPE_OIDS: Record<string, number> = {
40
- boolean: 16,
41
- bytea: 17,
42
- bigint: 20,
43
- smallint: 21,
44
- integer: 23,
45
- text: 25,
46
- json: 114,
47
- real: 700,
48
- 'double precision': 701,
49
- character: 1042,
50
- 'character varying': 1043,
51
- date: 1082,
52
- 'time without time zone': 1083,
53
- 'timestamp without time zone': 1114,
54
- 'timestamp with time zone': 1184,
55
- 'time with time zone': 1266,
56
- numeric: 1700,
57
- uuid: 2950,
58
- jsonb: 3802,
59
- }
60
-
61
- export interface ReplicationWriter {
62
- write(data: Uint8Array): void
63
- readonly closed?: boolean
64
- }
65
-
66
- /**
67
- * in-process replication writer. routes pgoutput data via callback
68
- * instead of a TCP socket. used in CF Workers / embedded mode where
69
- * there's no network between orez and zero-cache.
70
- */
71
- export class InProcessWriter implements ReplicationWriter {
72
- #onData: (data: Uint8Array) => void
73
- #closed = false
74
-
75
- constructor(onData: (data: Uint8Array) => void) {
76
- this.#onData = onData
77
- }
78
-
79
- write(data: Uint8Array): void {
80
- if (!this.#closed) {
81
- this.#onData(data)
82
- }
83
- }
84
-
85
- get closed(): boolean {
86
- return this.#closed
87
- }
88
-
89
- close(): void {
90
- this.#closed = true
91
- }
92
- }
93
-
94
- const MIN_LSN = 0x1000000n
95
- const LSN_INCREMENT = 0x1_0000_0000n
96
- const LSN_TIME_SHIFT = 12n
97
-
98
- function lsnFloorFromTime(): bigint {
99
- const timeLsn = BigInt(Date.now()) << LSN_TIME_SHIFT
100
- return timeLsn > MIN_LSN ? timeLsn : MIN_LSN
101
- }
102
-
103
- // current lsn counter. seed from wall time so a restarted browser proxy never
104
- // emits stream lsns behind zero-cache's persisted initial-sync watermark.
105
- // use a large stride because zero-cache's initial backfill can reserve
106
- // watermarks after slot creation while app writes are already queued; the next
107
- // streamed transaction still has to land beyond that warmup range.
108
- let currentLsn = lsnFloorFromTime()
109
- // persistent watermark across handler restarts so new handlers
110
- // don't replay already-streamed changes
111
- let lastStreamedWatermark = 0
112
-
113
- // direct wakeup from proxy — bypasses pg_notify for instant replication
114
- let _replicationWakeup: (() => void) | null = null
115
-
116
- /** signal the replication handler that changes may be available.
117
- * called by the proxy after executing writes on the postgres instance. */
118
- export function signalReplicationChange() {
119
- _replicationWakeup?.()
120
- const globalWakeup = (globalThis as any).__orez_signal_replication
121
- if (typeof globalWakeup === 'function' && globalWakeup !== _replicationWakeup) {
122
- globalWakeup()
123
- }
124
- }
125
-
126
- // cached setup results so reconnects skip the expensive mutex-holding setup phase.
127
- // zero-cache reconnects the replication stream after initial sync, and if setup
128
- // takes too long (holding the mutex, blocking proxy queries), zero-cache's
129
- // queries timeout and it kills the connection.
130
- let cachedTableKeyColumns: Map<string, Set<string>> | null = null
131
- let cachedExcludedColumns: Map<string, Set<string>> | null = null
132
- let cachedColumnTypeOids: Map<string, Map<string, number>> | null = null
133
-
134
- /** reset module state (for tests) */
135
- export function resetReplicationState(): void {
136
- currentLsn = lsnFloorFromTime()
137
- lastStreamedWatermark = 0
138
- cachedTableKeyColumns = null
139
- cachedExcludedColumns = null
140
- cachedColumnTypeOids = null
141
- cachedColumns.clear()
142
- }
143
- function nextLsn(): bigint {
144
- const floor = lsnFloorFromTime()
145
- if (currentLsn < floor) currentLsn = floor
146
- currentLsn += LSN_INCREMENT
147
- return currentLsn
148
- }
149
-
150
- function lsnToString(lsn: bigint): string {
151
- const high = Number(lsn >> 32n)
152
- const low = Number(lsn & 0xffffffffn)
153
- return `${high.toString(16).toUpperCase()}/${low.toString(16).toUpperCase()}`
154
- }
155
-
156
- /**
157
- * parse an LSN string like "0/01000300" into a bigint.
158
- * returns null if the string doesn't match (e.g. "0/0" still parses to 0n).
159
- */
160
- function lsnFromString(s: string): bigint | null {
161
- const m = s.trim().match(/^([0-9a-f]+)\/([0-9a-f]+)$/i)
162
- if (!m) return null
163
- return (BigInt('0x' + m[1]) << 32n) | BigInt('0x' + m[2])
164
- }
165
-
166
- /**
167
- * extract the client-supplied LSN from a START_REPLICATION query.
168
- * format: START_REPLICATION SLOT name LOGICAL <high>/<low> [proto_version 'N', publication_names 'X']
169
- * accepts optional surrounding quotes (some clients send `LOGICAL '0/0'`).
170
- * returns null if no parseable LSN is found.
171
- */
172
- export function extractStartLsn(query: string): bigint | null {
173
- const m = query.match(/\bLOGICAL\s+'?([0-9a-f]+\/[0-9a-f]+)'?/i)
174
- if (!m) return null
175
- return lsnFromString(m[1])
176
- }
177
-
178
- export { lsnFromString }
179
-
180
- function nowMicros(): bigint {
181
- return BigInt(Date.now()) * 1000n
182
- }
183
-
184
- // build a wire protocol row description + data row response
185
- function buildSimpleResponse(columns: string[], values: string[]): Uint8Array {
186
- const parts: Uint8Array[] = []
187
- const encoder = textEncoder
188
-
189
- // RowDescription (0x54)
190
- let rdSize = 6 // int32 len + int16 numFields
191
- const colBytes: Uint8Array[] = []
192
- for (const col of columns) {
193
- const b = encoder.encode(col)
194
- colBytes.push(b)
195
- rdSize += b.length + 1 + 4 + 2 + 4 + 2 + 4 + 2 // name+null + tableOid + colAttr + typeOid + typeLen + typeMod + formatCode
196
- }
197
- const rd = new Uint8Array(1 + rdSize)
198
- const rdv = new DataView(rd.buffer)
199
- rd[0] = 0x54
200
- rdv.setInt32(1, rdSize)
201
- rdv.setInt16(5, columns.length)
202
- let pos = 7
203
- for (let i = 0; i < columns.length; i++) {
204
- rd.set(colBytes[i], pos)
205
- pos += colBytes[i].length
206
- rd[pos++] = 0
207
- rdv.setInt32(pos, 0) // tableOid
208
- pos += 4
209
- rdv.setInt16(pos, 0) // colAttr
210
- pos += 2
211
- rdv.setInt32(pos, 25) // typeOid (text)
212
- pos += 4
213
- rdv.setInt16(pos, -1) // typeLen
214
- pos += 2
215
- rdv.setInt32(pos, -1) // typeMod
216
- pos += 4
217
- rdv.setInt16(pos, 0) // formatCode (text)
218
- pos += 2
219
- }
220
- parts.push(rd)
221
-
222
- // DataRow (0x44)
223
- let drSize = 6 // int32 len + int16 numCols
224
- const valBytes: Uint8Array[] = []
225
- for (const val of values) {
226
- const b = encoder.encode(val)
227
- valBytes.push(b)
228
- drSize += 4 + b.length
229
- }
230
- const dr = new Uint8Array(1 + drSize)
231
- const drv = new DataView(dr.buffer)
232
- dr[0] = 0x44
233
- drv.setInt32(1, drSize)
234
- drv.setInt16(5, values.length)
235
- pos = 7
236
- for (const vb of valBytes) {
237
- drv.setInt32(pos, vb.length)
238
- pos += 4
239
- dr.set(vb, pos)
240
- pos += vb.length
241
- }
242
- parts.push(dr)
243
-
244
- // CommandComplete (0x43)
245
- const tag = encoder.encode('SELECT 1\0')
246
- const cc = new Uint8Array(1 + 4 + tag.length)
247
- cc[0] = 0x43
248
- new DataView(cc.buffer).setInt32(1, 4 + tag.length)
249
- cc.set(tag, 5)
250
- parts.push(cc)
251
-
252
- // ReadyForQuery (0x5a)
253
- const rfq = new Uint8Array(6)
254
- rfq[0] = 0x5a
255
- new DataView(rfq.buffer).setInt32(1, 5)
256
- rfq[5] = 0x49 // 'I' idle
257
- parts.push(rfq)
258
-
259
- // concatenate
260
- const totalLen = parts.reduce((sum, p) => sum + p.length, 0)
261
- const result = new Uint8Array(totalLen)
262
- let offset = 0
263
- for (const p of parts) {
264
- result.set(p, offset)
265
- offset += p.length
266
- }
267
- return result
268
- }
269
-
270
- function buildCommandComplete(tag: string): Uint8Array {
271
- const encoder = textEncoder
272
- const tagBytes = encoder.encode(tag + '\0')
273
- const cc = new Uint8Array(1 + 4 + tagBytes.length)
274
- cc[0] = 0x43
275
- new DataView(cc.buffer).setInt32(1, 4 + tagBytes.length)
276
- cc.set(tagBytes, 5)
277
-
278
- const rfq = new Uint8Array(6)
279
- rfq[0] = 0x5a
280
- new DataView(rfq.buffer).setInt32(1, 5)
281
- rfq[5] = 0x49
282
-
283
- const result = new Uint8Array(cc.length + rfq.length)
284
- result.set(cc, 0)
285
- result.set(rfq, cc.length)
286
- return result
287
- }
288
-
289
- function buildErrorResponse(message: string): Uint8Array {
290
- const encoder = textEncoder
291
- const msgBytes = encoder.encode(message)
292
- // S(severity) + M(message) + null terminator
293
- const fields = new Uint8Array(2 + 6 + 2 + msgBytes.length + 1 + 1) // S + ERROR\0 + M + msg\0 + terminator
294
- let pos = 0
295
- fields[pos++] = 0x53 // 'S'
296
- const sev = encoder.encode('ERROR\0')
297
- fields.set(sev, pos)
298
- pos += sev.length
299
- fields[pos++] = 0x4d // 'M'
300
- fields.set(msgBytes, pos)
301
- pos += msgBytes.length
302
- fields[pos++] = 0 // null terminate message
303
- fields[pos++] = 0 // final terminator
304
-
305
- const buf = new Uint8Array(1 + 4 + pos)
306
- buf[0] = 0x45 // 'E'
307
- new DataView(buf.buffer).setInt32(1, 4 + pos)
308
- buf.set(fields.subarray(0, pos), 5)
309
- return buf
310
- }
311
-
312
- /**
313
- * handle a replication query. returns response bytes or null if not handled.
314
- * async because slot operations need to write to pglite.
315
- */
316
- export async function handleReplicationQuery(
317
- query: string,
318
- db: PGlite
319
- ): Promise<Uint8Array | null> {
320
- const trimmed = query.trim().replace(/;$/, '').trim()
321
- const upper = trimmed.toUpperCase()
322
-
323
- if (upper === 'IDENTIFY_SYSTEM') {
324
- const lsn = lsnToString(currentLsn)
325
- return buildSimpleResponse(
326
- ['systemid', 'timeline', 'xlogpos', 'dbname'],
327
- ['1234567890', '1', lsn, 'postgres']
328
- )
329
- }
330
-
331
- if (upper.startsWith('CREATE_REPLICATION_SLOT')) {
332
- const match = trimmed.match(
333
- /CREATE_REPLICATION_SLOT\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i
334
- )
335
- const slotName = match?.[1] || match?.[2] || match?.[3] || 'zero_slot'
336
- const lsn = lsnToString(nextLsn())
337
- const snapshotName = `00000003-00000001-1`
338
-
339
- // set watermark to current DB state so replication only delivers changes
340
- // that happen AFTER this point. this mirrors real postgres behavior where
341
- // CREATE_REPLICATION_SLOT creates a consistent snapshot — the initial copy
342
- // captures everything up to this point, and replication picks up from here.
343
- // on reconnect this is effectively a no-op since the watermark is already
344
- // at or past the current DB state.
345
- const currentWm = await getCurrentWatermark(db)
346
- if (currentWm > lastStreamedWatermark) {
347
- lastStreamedWatermark = currentWm
348
- }
349
-
350
- // persist slot so pg_replication_slots queries find it
351
- await db.query(
352
- `INSERT INTO _orez._zero_replication_slots (slot_name, restart_lsn, confirmed_flush_lsn)
353
- VALUES ($1, $2, $2)
354
- ON CONFLICT (slot_name) DO UPDATE SET restart_lsn = $2, confirmed_flush_lsn = $2`,
355
- [slotName, lsn]
356
- )
357
-
358
- return buildSimpleResponse(
359
- ['slot_name', 'consistent_point', 'snapshot_name', 'output_plugin'],
360
- [slotName, lsn, snapshotName, 'pgoutput']
361
- )
362
- }
363
-
364
- if (upper.startsWith('DROP_REPLICATION_SLOT')) {
365
- const match = trimmed.match(/DROP_REPLICATION_SLOT\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i)
366
- const slotName = match?.[1] || match?.[2] || match?.[3]
367
- if (slotName) {
368
- await db.query(`DELETE FROM _orez._zero_replication_slots WHERE slot_name = $1`, [
369
- slotName,
370
- ])
371
- }
372
- return buildCommandComplete('DROP_REPLICATION_SLOT')
373
- }
374
-
375
- // wal_level check via simple query
376
- if (upper.includes('WAL_LEVEL') && upper.includes('CURRENT_SETTING')) {
377
- return buildSimpleResponse(['walLevel', 'version'], ['logical', '170004'])
378
- }
379
-
380
- // ALTER ROLE for replication permission
381
- if (upper.startsWith('ALTER ROLE') && upper.includes('REPLICATION')) {
382
- return buildCommandComplete('ALTER ROLE')
383
- }
384
-
385
- // SET TRANSACTION - pglite rejects this if any query ran first (e.g. SET search_path).
386
- // return synthetic response since pglite is single-connection and doesn't need isolation levels.
387
- if (upper.startsWith('SET TRANSACTION') || upper.startsWith('SET SESSION')) {
388
- return buildCommandComplete('SET')
389
- }
390
-
391
- return null
392
- }
393
-
394
- /**
395
- * start streaming replication changes to the client.
396
- * this runs indefinitely until the connection is closed.
397
- */
398
- export async function handleStartReplication(
399
- query: string,
400
- writer: ReplicationWriter,
401
- db: PGlite,
402
- mutex: Mutex
403
- ): Promise<void> {
404
- log.debug.repl('entering streaming mode')
405
-
406
- // honor zero-cache's resume LSN. without this, after a page reload the
407
- // in-memory currentLsn / lastStreamedWatermark are reset to defaults but
408
- // changeLog persists with prior LSNs — re-streaming from BIGINT 0 makes
409
- // the change-streamer try to INSERT (watermark, pos) tuples that already
410
- // exist, hitting `changeLog_pkey` violations and tearing down the loop.
411
- // by advancing currentLsn past the client's last-seen LSN we guarantee
412
- // newly-emitted batches use strictly higher LSNs, and by jumping
413
- // lastStreamedWatermark to the current sequence value we skip already-
414
- // streamed _zero_changes rows.
415
- const clientStartLsn = extractStartLsn(query)
416
- if (clientStartLsn !== null && clientStartLsn > currentLsn) {
417
- log.debug.repl(
418
- `advancing currentLsn ${lsnToString(currentLsn)} → ${lsnToString(clientStartLsn)} from client START_REPLICATION`
419
- )
420
- currentLsn = clientStartLsn
421
- }
422
-
423
- // send CopyBothResponse to enter streaming mode
424
- const copyBoth = new Uint8Array(1 + 4 + 1 + 2)
425
- copyBoth[0] = 0x57 // 'W' CopyBothResponse
426
- new DataView(copyBoth.buffer).setInt32(1, 4 + 1 + 2)
427
- copyBoth[5] = 0 // overall format (0 = text)
428
- new DataView(copyBoth.buffer).setInt16(6, 0) // 0 columns
429
- writer.write(copyBoth)
430
-
431
- // resume from where the previous handler left off to avoid
432
- // replaying already-streamed changes after reconnect.
433
- // when client supplied a NON-ZERO LSN (i.e. this is a reconnect to an
434
- // existing slot with prior progress), also bump lastStreamedWatermark to
435
- // the current sequence value — anything before that has already been
436
- // written to changeLog, so re-streaming would just produce duplicate-key
437
- // errors. `0/0` indicates "fresh slot" and must NOT trigger this jump,
438
- // otherwise we'd skip rows that legitimately need to be streamed for the
439
- // initial sync.
440
- if (clientStartLsn !== null && clientStartLsn > 0n) {
441
- try {
442
- const currentWm = await getCurrentWatermark(db)
443
- if (currentWm > lastStreamedWatermark) {
444
- log.debug.repl(
445
- `advancing lastStreamedWatermark ${lastStreamedWatermark} → ${currentWm} on reconnect`
446
- )
447
- lastStreamedWatermark = currentWm
448
- }
449
- } catch (err) {
450
- log.repl(
451
- `getCurrentWatermark failed on reconnect: ${(err as Error)?.message || err}`
452
- )
453
- }
454
- }
455
- let lastWatermark = lastStreamedWatermark
456
-
457
- // use cached setup results on reconnect to avoid holding the mutex
458
- // for seconds doing trigger installation + schema queries. zero-cache
459
- // disconnects if its proxy queries are blocked too long by the mutex.
460
- let tableKeyColumns: Map<string, Set<string>>
461
- let excludedColumns: Map<string, Set<string>>
462
- let columnTypeOids: Map<string, Map<string, number>>
463
-
464
- if (cachedTableKeyColumns && cachedExcludedColumns && cachedColumnTypeOids) {
465
- log.debug.repl('reconnect: using cached setup (skipping mutex)')
466
- tableKeyColumns = cachedTableKeyColumns
467
- excludedColumns = cachedExcludedColumns
468
- columnTypeOids = cachedColumnTypeOids
469
- } else {
470
- tableKeyColumns = new Map()
471
- excludedColumns = new Map()
472
- columnTypeOids = new Map()
473
-
474
- // acquire mutex for all setup queries to avoid conflicting with proxy connections.
475
- // the change-streamer's initial copy also queries PGlite via the proxy, and
476
- // direct db.query()/db.exec() calls here bypass the proxy's mutex, causing
477
- // "already in transaction" errors when they interleave.
478
- // phase 1: DDL operations (trigger installation) under mutex
479
- // split into two phases so proxy queries can run between them
480
- await mutex.acquire()
481
- let relevantSchemas: string[]
482
- try {
483
- // install change tracking triggers on shard schema tables (e.g. chat_0.clients)
484
- await installTriggersOnShardTables(db)
485
-
486
- // set up LISTEN + install notify triggers in one batch
487
- const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
488
- let tables: { tablename: string }[]
489
- if (pubName) {
490
- const result = await db.query<{ tablename: string }>(
491
- `SELECT tablename FROM pg_publication_tables
492
- WHERE pubname = $1 AND schemaname = 'public' AND tablename NOT LIKE '_zero_%'`,
493
- [pubName]
494
- )
495
- tables = result.rows
496
- if (tables.length === 0) {
497
- log.proxy(
498
- `publication "${pubName}" is empty; installing no public notify triggers`
499
- )
500
- }
501
- } else {
502
- const all = await db.query<{ tablename: string }>(
503
- `SELECT tablename FROM pg_tables
504
- WHERE schemaname = 'public'
505
- AND tablename NOT IN ('migrations', '_zero_changes')
506
- AND tablename NOT LIKE '_zero_%'`
507
- )
508
- tables = all.rows
509
- }
510
-
511
- // combine notify function creation + trigger installations into single exec
512
- const ddlParts: string[] = [
513
- `CREATE OR REPLACE FUNCTION public._zero_notify_change() RETURNS TRIGGER AS $$
514
- BEGIN
515
- PERFORM pg_notify('_zero_changes', TG_TABLE_NAME);
516
- RETURN NULL;
517
- END;
518
- $$ LANGUAGE plpgsql;`,
519
- ]
520
- for (const { tablename } of tables) {
521
- const quoted = '"' + tablename.replace(/"/g, '""') + '"'
522
- ddlParts.push(
523
- `DROP TRIGGER IF EXISTS _zero_notify_trigger ON public.${quoted};
524
- CREATE TRIGGER _zero_notify_trigger
525
- AFTER INSERT OR UPDATE OR DELETE ON public.${quoted}
526
- FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();`
527
- )
528
- }
529
-
530
- // discover shard schemas and install their triggers in same batch
531
- const shardSchemas = await db.query<{ nspname: string }>(
532
- `SELECT nspname FROM pg_namespace
533
- WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
534
- AND nspname NOT LIKE 'pg_%'
535
- AND nspname NOT LIKE 'zero_%'
536
- AND nspname NOT LIKE '_zero_%'
537
- AND nspname NOT LIKE '%/%'`
538
- )
539
- relevantSchemas = ['public', ...shardSchemas.rows.map((r) => r.nspname)]
540
-
541
- const shardClientSchemas = shardSchemas.rows
542
- .map((r) => r.nspname)
543
- .filter((s) => s !== 'public')
544
- if (shardClientSchemas.length > 0) {
545
- const shardTables = await db.query<{ schemaname: string; tablename: string }>(
546
- `SELECT schemaname, tablename FROM pg_tables
547
- WHERE schemaname = ANY($1) AND tablename = ANY($2)`,
548
- [shardClientSchemas, ['clients', 'mutations']]
549
- )
550
- for (const { schemaname, tablename } of shardTables.rows) {
551
- const qs = '"' + schemaname.replace(/"/g, '""') + '"'
552
- const qt = '"' + tablename.replace(/"/g, '""') + '"'
553
- ddlParts.push(
554
- `DROP TRIGGER IF EXISTS _zero_notify_trigger ON ${qs}.${qt};
555
- CREATE TRIGGER _zero_notify_trigger
556
- AFTER INSERT OR UPDATE OR DELETE ON ${qs}.${qt}
557
- FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();`
558
- )
559
- }
560
- if (shardTables.rows.length > 0) {
561
- log.debug.proxy(
562
- `installed notify triggers on ${shardTables.rows.length} shard tables`
563
- )
564
- }
565
- }
566
-
567
- await db.exec(ddlParts.join('\n'))
568
- if (tables.length > 0) {
569
- log.proxy(`installed notify triggers on ${tables.length} public table(s)`)
570
- }
571
- } finally {
572
- mutex.release()
573
- }
574
-
575
- // phase 2: schema introspection (read-only, separate mutex acquisition)
576
- // releasing between phases lets proxy queries run during the gap
577
- await mutex.acquire()
578
- try {
579
- // combined PK + column introspection in a single query using UNION ALL
580
- const schemaResult = await db.query<{
581
- kind: string
582
- table_schema: string
583
- table_name: string
584
- column_name: string
585
- data_type: string | null
586
- ordinal_position: number
587
- }>(
588
- `SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type, kcu.ordinal_position
589
- FROM information_schema.table_constraints tc
590
- JOIN information_schema.key_column_usage kcu
591
- ON tc.constraint_name = kcu.constraint_name
592
- AND tc.table_schema = kcu.table_schema
593
- WHERE tc.constraint_type = 'PRIMARY KEY'
594
- AND tc.table_schema = ANY($1)
595
- UNION ALL
596
- SELECT 'col' AS kind, table_schema, table_name, column_name, data_type, ordinal_position
597
- FROM information_schema.columns
598
- WHERE table_schema = ANY($1)
599
- ORDER BY table_schema, table_name, kind, ordinal_position`,
600
- [relevantSchemas]
601
- )
602
-
603
- for (const row of schemaResult.rows) {
604
- const key = `${row.table_schema}.${row.table_name}`
605
- if (row.kind === 'pk') {
606
- let keys = tableKeyColumns.get(key)
607
- if (!keys) {
608
- keys = new Set()
609
- tableKeyColumns.set(key, keys)
610
- }
611
- keys.add(row.column_name)
612
- } else {
613
- if (row.data_type && UNSUPPORTED_TYPES.has(row.data_type)) {
614
- let cols = excludedColumns.get(key)
615
- if (!cols) {
616
- cols = new Set()
617
- excludedColumns.set(key, cols)
618
- }
619
- cols.add(row.column_name)
620
- }
621
- if (row.data_type) {
622
- const oid = PG_DATA_TYPE_OIDS[row.data_type]
623
- if (oid !== undefined) {
624
- let cols = columnTypeOids.get(key)
625
- if (!cols) {
626
- cols = new Map()
627
- columnTypeOids.set(key, cols)
628
- }
629
- cols.set(row.column_name, oid)
630
- }
631
- }
632
- }
633
- }
634
- log.debug.proxy(`loaded primary keys for ${tableKeyColumns.size} tables`)
635
- if (excludedColumns.size > 0) {
636
- log.debug.proxy(
637
- `excluding unsupported columns: ${[...excludedColumns.entries()].map(([t, c]) => `${t}(${[...c].join(',')})`).join(', ')}`
638
- )
639
- }
640
-
641
- // cache for subsequent reconnects
642
- cachedTableKeyColumns = tableKeyColumns
643
- cachedExcludedColumns = excludedColumns
644
- cachedColumnTypeOids = columnTypeOids
645
- } finally {
646
- mutex.release()
647
- }
648
- }
649
-
650
- // track which tables we've sent RELATION messages for
651
- const sentRelations = new Set<string>()
652
- let txCounter = 1
653
-
654
- // event-driven replication: proxy signals changes directly via signalReplicationChange(),
655
- // pg_notify as secondary signal, polling as final fallback.
656
- const pollIntervalIdle = 5000
657
- const batchSize = 50000
658
- const purgeEveryN = 1
659
- const shardRescanIntervalMs = 10_000
660
- let running = true
661
- let pollsSincePurge = 0
662
- let tryAcquireFailures = 0
663
- let lastShardRescan = -shardRescanIntervalMs
664
- let hasStreamedOnce = false
665
-
666
- // promise-based wakeup mechanism.
667
- // signalPending captures signals that arrive while the handler is
668
- // processing (not in waitForWakeup), preventing signal loss.
669
- let wakeupResolve: (() => void) | null = null
670
- let signalPending = false
671
- let lastWakeupTime = 0
672
- const wakeup = () => {
673
- signalPending = true
674
- if (wakeupResolve) {
675
- lastWakeupTime = performance.now()
676
- log.debug.repl('signal received, waking up')
677
- wakeupResolve()
678
- wakeupResolve = null
679
- }
680
- }
681
- const waitForWakeup = (timeoutMs: number): Promise<boolean> => {
682
- return new Promise((resolve) => {
683
- const timer = setTimeout(() => {
684
- wakeupResolve = null
685
- resolve(false)
686
- }, timeoutMs)
687
- wakeupResolve = () => {
688
- clearTimeout(timer)
689
- resolve(true)
690
- }
691
- })
692
- }
693
-
694
- // register direct wakeup so the proxy can signal us immediately
695
- _replicationWakeup = wakeup
696
-
697
- // expose on globalThis so external code (e.g. pglite-pool) can signal
698
- // without importing from this module (works across separate bundles)
699
- ;(globalThis as any).__orez_signal_replication = wakeup
700
-
701
- // also set up LISTEN as secondary signal
702
- let unsubscribe: (() => Promise<void>) | null = null
703
- try {
704
- unsubscribe = await db.listen('_zero_changes', wakeup)
705
- log.debug.proxy('replication: listening for _zero_changes notifications')
706
- } catch {
707
- log.debug.proxy('replication: LISTEN not available')
708
- }
709
-
710
- const poll = async () => {
711
- let queryPending = true // query immediately on first iteration
712
- let idleTimeoutCount = 0
713
-
714
- while (running) {
715
- // check if the connection or database was closed
716
- if (writer.closed || db.closed) {
717
- log.debug.proxy('replication: writer/db closed, exiting poll loop')
718
- running = false
719
- break
720
- }
721
-
722
- try {
723
- // when no query is pending, wait for a signal or timeout.
724
- // signals fire instantly when the proxy processes a write,
725
- // so we only hit the timeout when truly idle.
726
- if (!queryPending) {
727
- // check if a signal arrived while we were processing
728
- if (!signalPending) {
729
- log.debug.repl(
730
- `waiting for signal (lastWm=${lastWatermark}, streamed=${hasStreamedOnce})`
731
- )
732
- const wasSignaled = await waitForWakeup(pollIntervalIdle)
733
- if (writer.closed || db.closed) {
734
- running = false
735
- break
736
- }
737
- if (!wasSignaled) {
738
- idleTimeoutCount++
739
- // send keepalive on every timeout
740
- writer.write(encodeKeepalive(currentLsn, nowMicros(), false))
741
- log.debug.repl(`idle keepalive (lastWatermark=${lastWatermark})`)
742
- // re-scan for new shard schemas during idle
743
- if (performance.now() - lastShardRescan > shardRescanIntervalMs) {
744
- if (mutex.tryAcquire()) {
745
- lastShardRescan = performance.now()
746
- try {
747
- await installTriggersOnShardTables(db)
748
- } finally {
749
- mutex.release()
750
- }
751
- }
752
- }
753
- // safety poll every ~30s to catch edge cases (6 * 5000ms)
754
- if (idleTimeoutCount < 6) continue
755
- idleTimeoutCount = 0
756
- log.debug.repl('safety poll')
757
- // fall through to query
758
- } else {
759
- idleTimeoutCount = 0
760
- }
761
- } else {
762
- idleTimeoutCount = 0
763
- }
764
- signalPending = false
765
- }
766
- queryPending = false
767
-
768
- // periodically re-scan for new shard schemas (e.g. chat_0 created by zero-cache)
769
- if (performance.now() - lastShardRescan > shardRescanIntervalMs) {
770
- if (mutex.tryAcquire()) {
771
- lastShardRescan = performance.now()
772
- try {
773
- await installTriggersOnShardTables(db)
774
- } finally {
775
- mutex.release()
776
- }
777
- } else {
778
- log.debug.repl('shard rescan skipped: mutex busy')
779
- }
780
- }
781
-
782
- // try to acquire mutex without blocking proxy connections.
783
- // post-sync: short backoff since writes signal us directly.
784
- // pre-sync: yield more generously so zero-cache initial copy can finish.
785
- log.debug.repl(
786
- `pre-query: tryAcquire mutex (streamed=${hasStreamedOnce}, fails=${tryAcquireFailures})`
787
- )
788
- if (!mutex.tryAcquire()) {
789
- if (hasStreamedOnce) {
790
- // post-sync: block immediately. change query is fast (~0.5ms),
791
- // so holding the mutex briefly doesn't starve proxy connections.
792
- // avoids 25ms+ backoff delays that cause test flakiness.
793
- await mutex.acquire()
794
- } else {
795
- tryAcquireFailures++
796
- if (tryAcquireFailures < 10) {
797
- // pre-sync: yield so zero-cache initial copy can finish
798
- await waitForWakeup(Math.min(10 * tryAcquireFailures, 100))
799
- queryPending = true
800
- continue
801
- }
802
- await mutex.acquire()
803
- tryAcquireFailures = 0
804
- }
805
- } else {
806
- tryAcquireFailures = 0
807
- }
808
- let changes: Awaited<ReturnType<typeof getChangesSince>>
809
- const queryStart = performance.now()
810
- try {
811
- try {
812
- changes = await getChangesSince(db, lastWatermark, batchSize)
813
- } catch (queryErr: unknown) {
814
- // pglite is single-connection — if we acquire the mutex between
815
- // extended protocol messages and the previous query left an aborted
816
- // transaction, we'll get 25P02. rollback and retry once.
817
- const code =
818
- queryErr && typeof queryErr === 'object' && 'code' in queryErr
819
- ? (queryErr as { code: string }).code
820
- : ''
821
- if (code === '25P02') {
822
- try {
823
- await db.exec('ROLLBACK')
824
- } catch {}
825
- changes = await getChangesSince(db, lastWatermark, batchSize)
826
- } else {
827
- throw queryErr
828
- }
829
- }
830
- } finally {
831
- mutex.release()
832
- }
833
-
834
- if (changes.length > 0) {
835
- const queryMs = performance.now() - queryStart
836
- const signalToQueryMs =
837
- lastWakeupTime > 0 ? (performance.now() - lastWakeupTime).toFixed(1) : '?'
838
- // summarize which tables changed
839
- const tableSummary = [...new Set(changes.map((c) => c.table_name))].join(',')
840
- log.debug.repl(
841
- `found ${changes.length} changes [${tableSummary}] (wm ${lastWatermark}→${changes[changes.length - 1].watermark}) query=${queryMs.toFixed(1)}ms signal→query=${signalToQueryMs}ms`
842
- )
843
- // filter out shard tables that zero-cache doesn't expect.
844
- // `clients` advances lmid; `mutations` carries mutation results.
845
- // other shard tables (e.g. replicas) crash zero-cache with
846
- // "Unknown table" in the change processor.
847
- const batchEnd = changes[changes.length - 1].watermark
848
- const preFilterCount = changes.length
849
- changes = changes.filter((c) => {
850
- const dot = c.table_name.indexOf('.')
851
- if (dot === -1) return true
852
- const schema = c.table_name.substring(0, dot)
853
- if (schema === 'public') return true
854
- const table = c.table_name.substring(dot + 1)
855
- return table === 'clients' || table === 'mutations'
856
- })
857
- log.debug.repl(`filter: ${preFilterCount} → ${changes.length} changes`)
858
-
859
- if (changes.length === 0) {
860
- lastWatermark = batchEnd
861
- lastStreamedWatermark = batchEnd
862
- // all changes were filtered out (e.g. shard internal tables).
863
- // brief wait to avoid tight loop, then recheck.
864
- await waitForWakeup(200)
865
- queryPending = true
866
- continue
867
- }
868
-
869
- await ensureMetadataForChangedTables(
870
- db,
871
- mutex,
872
- changes,
873
- tableKeyColumns,
874
- excludedColumns,
875
- columnTypeOids
876
- )
877
-
878
- log.debug.repl(`streaming ${changes.length} changes to writer`)
879
- await streamChanges(
880
- changes,
881
- writer,
882
- sentRelations,
883
- txCounter++,
884
- tableKeyColumns,
885
- excludedColumns,
886
- columnTypeOids
887
- )
888
- lastWatermark = batchEnd
889
- lastStreamedWatermark = batchEnd
890
- log.debug.repl(`streamed ok, watermark=${batchEnd}`)
891
- hasStreamedOnce = true
892
-
893
- // purge consumed changes periodically to free wasm memory
894
- pollsSincePurge++
895
- if (pollsSincePurge >= purgeEveryN && mutex.tryAcquire()) {
896
- pollsSincePurge = 0
897
- try {
898
- const purged = await purgeConsumedChanges(db, lastWatermark)
899
- if (purged > 0) {
900
- log.debug.proxy(`purged ${purged} consumed changes`)
901
- }
902
- } finally {
903
- mutex.release()
904
- }
905
- }
906
-
907
- // got changes - continue immediately to check for more
908
- queryPending = true
909
- continue
910
- }
911
-
912
- // no changes: send keepalive
913
- const ts = nowMicros()
914
- writer.write(encodeKeepalive(currentLsn, ts, false))
915
- log.debug.repl(`idle (lastWatermark=${lastWatermark})`)
916
- // next iteration will wait for signal at the top
917
- } catch (err: unknown) {
918
- const msg = err instanceof Error ? err.message : String(err)
919
- log.repl(`replication poll error: ${msg}`)
920
- if (
921
- msg.includes('closed') ||
922
- msg.includes('destroyed') ||
923
- msg.includes('ECONNRESET') ||
924
- msg.includes('EPIPE')
925
- ) {
926
- running = false
927
- break
928
- }
929
- await new Promise((resolve) => setTimeout(resolve, 1000))
930
- }
931
- }
932
- }
933
-
934
- log.debug.repl(`starting poll (lastWatermark=${lastWatermark})`)
935
- try {
936
- await poll()
937
- } finally {
938
- // only clear if still pointing to our wakeup (a new handler may have replaced it)
939
- if (_replicationWakeup === wakeup) {
940
- _replicationWakeup = null
941
- }
942
- if (unsubscribe) {
943
- await unsubscribe().catch(() => {})
944
- }
945
- }
946
- log.repl('poll loop exited')
947
- }
948
-
949
- async function ensureMetadataForChangedTables(
950
- db: PGlite,
951
- mutex: Mutex,
952
- changes: ChangeRecord[],
953
- tableKeyColumns: Map<string, Set<string>>,
954
- excludedColumns: Map<string, Set<string>>,
955
- columnTypeOids: Map<string, Map<string, number>>
956
- ): Promise<void> {
957
- const missing = new Map<string, { schema: string; table: string }>()
958
-
959
- for (const change of changes) {
960
- if (
961
- tableKeyColumns.has(change.table_name) ||
962
- columnTypeOids.has(change.table_name) ||
963
- excludedColumns.has(change.table_name)
964
- ) {
965
- continue
966
- }
967
-
968
- const dot = change.table_name.indexOf('.')
969
- const schema = dot === -1 ? 'public' : change.table_name.substring(0, dot)
970
- const table = dot === -1 ? change.table_name : change.table_name.substring(dot + 1)
971
- missing.set(`${schema}.${table}`, { schema, table })
972
- }
973
-
974
- if (missing.size === 0) return
975
-
976
- const schemas = [...new Set([...missing.values()].map((entry) => entry.schema))]
977
- const tables = [...new Set([...missing.values()].map((entry) => entry.table))]
978
-
979
- await mutex.acquire()
980
- try {
981
- const schemaResult = await db.query<{
982
- kind: string
983
- table_schema: string
984
- table_name: string
985
- column_name: string
986
- data_type: string | null
987
- ordinal_position: number
988
- }>(
989
- `SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type, kcu.ordinal_position
990
- FROM information_schema.table_constraints tc
991
- JOIN information_schema.key_column_usage kcu
992
- ON tc.constraint_name = kcu.constraint_name
993
- AND tc.table_schema = kcu.table_schema
994
- WHERE tc.constraint_type = 'PRIMARY KEY'
995
- AND tc.table_schema = ANY($1)
996
- AND tc.table_name = ANY($2)
997
- UNION ALL
998
- SELECT 'col' AS kind, table_schema, table_name, column_name, data_type, ordinal_position
999
- FROM information_schema.columns
1000
- WHERE table_schema = ANY($1)
1001
- AND table_name = ANY($2)
1002
- ORDER BY table_schema, table_name, kind, ordinal_position`,
1003
- [schemas, tables]
1004
- )
1005
-
1006
- for (const row of schemaResult.rows) {
1007
- const key = `${row.table_schema}.${row.table_name}`
1008
- if (!missing.has(key)) continue
1009
-
1010
- if (row.kind === 'pk') {
1011
- let keys = tableKeyColumns.get(key)
1012
- if (!keys) {
1013
- keys = new Set()
1014
- tableKeyColumns.set(key, keys)
1015
- }
1016
- keys.add(row.column_name)
1017
- } else {
1018
- if (row.data_type && UNSUPPORTED_TYPES.has(row.data_type)) {
1019
- let cols = excludedColumns.get(key)
1020
- if (!cols) {
1021
- cols = new Set()
1022
- excludedColumns.set(key, cols)
1023
- }
1024
- cols.add(row.column_name)
1025
- }
1026
- if (row.data_type) {
1027
- const oid = PG_DATA_TYPE_OIDS[row.data_type]
1028
- if (oid !== undefined) {
1029
- let cols = columnTypeOids.get(key)
1030
- if (!cols) {
1031
- cols = new Map()
1032
- columnTypeOids.set(key, cols)
1033
- }
1034
- cols.set(row.column_name, oid)
1035
- }
1036
- }
1037
- }
1038
- }
1039
-
1040
- log.debug.repl(
1041
- `refreshed metadata for ${missing.size} late table(s): ${[...missing.keys()].join(',')}`
1042
- )
1043
- for (const key of missing.keys()) cachedColumns.delete(key)
1044
- } finally {
1045
- mutex.release()
1046
- }
1047
- }
1048
-
1049
- // cache column info per table to avoid per-change allocation
1050
- const cachedColumns = new Map<string, ReturnType<typeof inferColumns>>()
1051
-
1052
- async function streamChanges(
1053
- changes: ChangeRecord[],
1054
- writer: ReplicationWriter,
1055
- sentRelations: Set<string>,
1056
- txId: number,
1057
- tableKeyColumns: Map<string, Set<string>>,
1058
- excludedColumns: Map<string, Set<string>>,
1059
- columnTypeOids: Map<string, Map<string, number>>
1060
- ): Promise<void> {
1061
- const ts = nowMicros()
1062
- const lsn = nextLsn()
1063
-
1064
- // collect all encoded messages into a list, then batch-write
1065
- // to minimize syscalls (each writer.write → socket.write is a syscall)
1066
- const messages: Uint8Array[] = []
1067
-
1068
- // BEGIN
1069
- messages.push(encodeWrappedChange(lsn, lsn, ts, encodeBegin(lsn, ts, txId)))
1070
-
1071
- for (const change of changes) {
1072
- // parse schema-qualified name (schema.table or bare table)
1073
- const dot = change.table_name.indexOf('.')
1074
- const schema = dot !== -1 ? change.table_name.substring(0, dot) : 'public'
1075
- const tableName =
1076
- dot !== -1 ? change.table_name.substring(dot + 1) : change.table_name
1077
- const qualifiedKey = `${schema}.${tableName}`
1078
-
1079
- const tableOid = getTableOid(qualifiedKey)
1080
- const excluded = excludedColumns.get(qualifiedKey)
1081
-
1082
- // filter out unsupported columns from row data
1083
- let rowData = change.row_data
1084
- let oldData = change.old_data
1085
- if (excluded && excluded.size > 0) {
1086
- if (rowData) {
1087
- rowData = Object.fromEntries(
1088
- Object.entries(rowData).filter(([k]) => !excluded.has(k))
1089
- )
1090
- }
1091
- if (oldData) {
1092
- oldData = Object.fromEntries(
1093
- Object.entries(oldData).filter(([k]) => !excluded.has(k))
1094
- )
1095
- }
1096
- }
1097
-
1098
- // zero-cache expects specific camel-cased keys in shard clients rows
1099
- if (schema !== 'public' && tableName === 'clients') {
1100
- rowData = normalizeShardClientsRow(rowData)
1101
- oldData = normalizeShardClientsRow(oldData)
1102
- }
1103
-
1104
- const row = rowData || oldData
1105
- if (!row) continue
1106
-
1107
- // use cached columns or build and cache them
1108
- let columns = cachedColumns.get(qualifiedKey)
1109
- if (!columns) {
1110
- const keySet = tableKeyColumns.get(qualifiedKey)
1111
- const typeOids = columnTypeOids.get(qualifiedKey)
1112
- columns = inferColumns(row).map((col) => ({
1113
- ...col,
1114
- typeOid: typeOids?.get(col.name) ?? col.typeOid,
1115
- isKey: keySet?.has(col.name) ?? false,
1116
- }))
1117
- cachedColumns.set(qualifiedKey, columns)
1118
- }
1119
-
1120
- // send RELATION if not yet sent
1121
- if (!sentRelations.has(qualifiedKey)) {
1122
- const relMsg = encodeRelation(tableOid, schema, tableName, 0x64, columns)
1123
- messages.push(encodeWrappedChange(lsn, lsn, ts, relMsg))
1124
- sentRelations.add(qualifiedKey)
1125
- }
1126
-
1127
- // encode the change
1128
- let changeMsg: Uint8Array | null = null
1129
- switch (change.op) {
1130
- case 'INSERT':
1131
- if (!rowData) continue
1132
- changeMsg = encodeInsert(tableOid, rowData, columns)
1133
- break
1134
- case 'UPDATE':
1135
- if (!rowData) continue
1136
- changeMsg = encodeUpdate(tableOid, rowData, oldData, columns)
1137
- break
1138
- case 'DELETE':
1139
- if (!oldData) continue
1140
- changeMsg = encodeDelete(tableOid, oldData, columns)
1141
- break
1142
- default:
1143
- continue
1144
- }
1145
-
1146
- messages.push(encodeWrappedChange(lsn, lsn, ts, changeMsg))
1147
- }
1148
-
1149
- // COMMIT
1150
- const endLsn = nextLsn()
1151
- messages.push(encodeWrappedChange(endLsn, endLsn, ts, encodeCommit(0, lsn, endLsn, ts)))
1152
-
1153
- // The MessagePort-backed socket delivers each write as one readable chunk.
1154
- // zero-cache parses one replication payload per chunk, so each CopyData frame
1155
- // must be written separately.
1156
- let totalSize = 0
1157
- for (const msg of messages) totalSize += msg.length
1158
- log.debug.repl(
1159
- `streaming ${messages.length} wal messages (${totalSize} bytes, txId=${txId})`
1160
- )
1161
- for (const msg of messages) {
1162
- writer.write(msg)
1163
- }
1164
-
1165
- // hook for arch instrumentation (soot-arch sq-write events)
1166
- const hook = (globalThis as any).__orez_on_repl_commit
1167
- if (hook) {
1168
- const tables = new Set(changes.map((c) => c.table_name))
1169
- hook({ changes: changes.length, tables: [...tables], txId })
1170
- }
1171
- }
1172
-
1173
- function normalizeShardClientsRow(
1174
- row: Record<string, unknown> | null
1175
- ): Record<string, unknown> | null {
1176
- if (!row) return row
1177
- const out: Record<string, unknown> = { ...row }
1178
- if (out.clientGroupID === undefined && out.clientgroupid !== undefined) {
1179
- out.clientGroupID = out.clientgroupid
1180
- }
1181
- if (out.clientID === undefined && out.clientid !== undefined) {
1182
- out.clientID = out.clientid
1183
- }
1184
- if (out.lastMutationID === undefined && out.lastmutationid !== undefined) {
1185
- out.lastMutationID = out.lastmutationid
1186
- }
1187
- return out
1188
- }
1189
-
1190
- export { buildErrorResponse }