orez 0.1.43 → 0.1.44

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 (108) hide show
  1. package/dist/admin/http-proxy.d.ts.map +1 -1
  2. package/dist/admin/http-proxy.js +3 -1
  3. package/dist/admin/http-proxy.js.map +1 -1
  4. package/dist/admin/log-store.d.ts.map +1 -1
  5. package/dist/admin/log-store.js +5 -1
  6. package/dist/admin/log-store.js.map +1 -1
  7. package/dist/admin/server.d.ts.map +1 -1
  8. package/dist/admin/server.js +25 -25
  9. package/dist/admin/server.js.map +1 -1
  10. package/dist/browser.d.ts +54 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +110 -0
  13. package/dist/browser.js.map +1 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/pg-proxy-browser.d.ts +26 -0
  21. package/dist/pg-proxy-browser.d.ts.map +1 -0
  22. package/dist/pg-proxy-browser.js +1460 -0
  23. package/dist/pg-proxy-browser.js.map +1 -0
  24. package/dist/pg-proxy.d.ts.map +1 -1
  25. package/dist/pg-proxy.js +48 -34
  26. package/dist/pg-proxy.js.map +1 -1
  27. package/dist/pglite-ipc.d.ts.map +1 -1
  28. package/dist/pglite-ipc.js +3 -2
  29. package/dist/pglite-ipc.js.map +1 -1
  30. package/dist/pglite-manager.d.ts.map +1 -1
  31. package/dist/pglite-manager.js +33 -85
  32. package/dist/pglite-manager.js.map +1 -1
  33. package/dist/pglite-web-proxy.d.ts +38 -0
  34. package/dist/pglite-web-proxy.d.ts.map +1 -0
  35. package/dist/pglite-web-proxy.js +155 -0
  36. package/dist/pglite-web-proxy.js.map +1 -0
  37. package/dist/pglite-web-worker.d.ts +24 -0
  38. package/dist/pglite-web-worker.d.ts.map +1 -0
  39. package/dist/pglite-web-worker.js +119 -0
  40. package/dist/pglite-web-worker.js.map +1 -0
  41. package/dist/recovery.js +2 -2
  42. package/dist/recovery.js.map +1 -1
  43. package/dist/replication/change-tracker.js +9 -9
  44. package/dist/replication/change-tracker.js.map +1 -1
  45. package/dist/replication/handler.d.ts.map +1 -1
  46. package/dist/replication/handler.js +34 -26
  47. package/dist/replication/handler.js.map +1 -1
  48. package/dist/worker/browser-build-config.d.ts.map +1 -1
  49. package/dist/worker/browser-build-config.js +5 -2
  50. package/dist/worker/browser-build-config.js.map +1 -1
  51. package/dist/worker/browser-embed.d.ts.map +1 -1
  52. package/dist/worker/browser-embed.js +31 -26
  53. package/dist/worker/browser-embed.js.map +1 -1
  54. package/dist/worker/shims/fastify.d.ts +1 -0
  55. package/dist/worker/shims/fastify.d.ts.map +1 -1
  56. package/dist/worker/shims/fastify.js +31 -20
  57. package/dist/worker/shims/fastify.js.map +1 -1
  58. package/dist/worker/shims/postgres-browser.d.ts +12 -0
  59. package/dist/worker/shims/postgres-browser.d.ts.map +1 -0
  60. package/dist/worker/shims/postgres-browser.js +52 -0
  61. package/dist/worker/shims/postgres-browser.js.map +1 -0
  62. package/dist/worker/shims/postgres-socket.d.ts +83 -0
  63. package/dist/worker/shims/postgres-socket.d.ts.map +1 -0
  64. package/dist/worker/shims/postgres-socket.js +278 -0
  65. package/dist/worker/shims/postgres-socket.js.map +1 -0
  66. package/dist/worker/shims/postgres.d.ts.map +1 -1
  67. package/dist/worker/shims/postgres.js +18 -9
  68. package/dist/worker/shims/postgres.js.map +1 -1
  69. package/dist/worker/shims/stream-browser.d.ts +5 -4
  70. package/dist/worker/shims/stream-browser.d.ts.map +1 -1
  71. package/dist/worker/shims/stream-browser.js +7 -6
  72. package/dist/worker/shims/stream-browser.js.map +1 -1
  73. package/dist/worker/shims/ws-browser.d.ts.map +1 -1
  74. package/dist/worker/shims/ws-browser.js +43 -21
  75. package/dist/worker/shims/ws-browser.js.map +1 -1
  76. package/dist/worker/shims/ws.d.ts.map +1 -1
  77. package/dist/worker/shims/ws.js +81 -17
  78. package/dist/worker/shims/ws.js.map +1 -1
  79. package/package.json +11 -58
  80. package/src/admin/http-proxy.ts +4 -1
  81. package/src/admin/log-store.ts +5 -1
  82. package/src/admin/server.ts +26 -25
  83. package/src/browser.ts +195 -0
  84. package/src/cli.ts +1 -1
  85. package/src/index.ts +5 -2
  86. package/src/integration/integration.test.ts +1 -1
  87. package/src/integration/restore-live-stress.test.ts +2 -2
  88. package/src/pg-proxy-browser.ts +1673 -0
  89. package/src/pg-proxy.ts +48 -40
  90. package/src/pglite-ipc.ts +3 -2
  91. package/src/pglite-manager.ts +45 -107
  92. package/src/pglite-web-proxy.ts +180 -0
  93. package/src/pglite-web-worker.ts +132 -0
  94. package/src/recovery.ts +2 -2
  95. package/src/replication/change-tracker.test.ts +1 -1
  96. package/src/replication/change-tracker.ts +9 -9
  97. package/src/replication/handler.ts +37 -26
  98. package/src/worker/browser-build-config.test.ts +1 -1
  99. package/src/worker/browser-build-config.ts +5 -2
  100. package/src/worker/browser-embed.ts +33 -30
  101. package/src/worker/shims/fastify.ts +37 -24
  102. package/src/worker/shims/postgres-browser.ts +59 -0
  103. package/src/worker/shims/postgres-socket.test.ts +576 -0
  104. package/src/worker/shims/postgres-socket.ts +310 -0
  105. package/src/worker/shims/postgres.ts +30 -15
  106. package/src/worker/shims/stream-browser.ts +15 -0
  107. package/src/worker/shims/ws-browser.ts +38 -20
  108. package/src/worker/shims/ws.ts +76 -21
@@ -0,0 +1,1673 @@
1
+ /**
2
+ * browser proxy that makes pglite speak postgresql wire protocol.
3
+ *
4
+ * browser port of pg-proxy.ts — uses pg-gateway's web DuplexStream
5
+ * instead of TCP sockets. accepts MessagePort connections from zero-cache.
6
+ *
7
+ * regular connections: forwarded to pglite via execProtocolRaw()
8
+ * replication connections: intercepted, replication protocol faked
9
+ *
10
+ * each "database" (postgres, zero_cvr, zero_cdb) maps to its own pglite
11
+ * instance with independent transaction context, preventing cross-database
12
+ * query interleaving that causes CVR concurrent modification errors.
13
+ */
14
+
15
+ import { PostgresConnection, type DuplexStream } from 'pg-gateway'
16
+
17
+ import { log } from './log.js'
18
+ import { Mutex } from './mutex.js'
19
+ import {
20
+ handleReplicationQuery,
21
+ handleStartReplication,
22
+ signalReplicationChange,
23
+ } from './replication/handler.js'
24
+
25
+ import type { PGliteInstances } from './pglite-manager.js'
26
+ import type { PGlite } from '@electric-sql/pglite'
27
+
28
+ // shared encoder/decoder instances
29
+ const textEncoder = new TextEncoder()
30
+ const textDecoder = new TextDecoder()
31
+
32
+ // schema query cache: identical information_schema/catalog queries from multiple
33
+ // zero-cache clients are deduplicated. first query executes, all others get cached result.
34
+ interface CachedQueryResult {
35
+ result: Uint8Array
36
+ expiresAt: number
37
+ }
38
+ const schemaQueryCache = new Map<string, CachedQueryResult>()
39
+ const schemaQueryInFlight = new Map<string, Promise<Uint8Array>>()
40
+ const SCHEMA_CACHE_TTL_MS = 30_000
41
+
42
+ // performance tracking
43
+ const proxyStats = { totalWaitMs: 0, totalExecMs: 0, count: 0, batches: 0 }
44
+
45
+ // query classification helpers — operate on pre-normalized (trimmed+lowercased) query strings
46
+ const SCHEMA_QUERY_MARKERS = [
47
+ 'information_schema.',
48
+ 'pg_catalog.',
49
+ 'pg_tables',
50
+ 'pg_namespace',
51
+ 'pg_class',
52
+ 'pg_attribute',
53
+ 'pg_type',
54
+ 'pg_publication',
55
+ ]
56
+ const WRITE_PREFIXES = ['insert', 'update', 'delete', 'copy', 'truncate']
57
+ const DDL_PREFIXES = ['create', 'alter', 'drop']
58
+ const MUTATING_PREFIXES = [...WRITE_PREFIXES, ...DDL_PREFIXES]
59
+
60
+ function isCacheableNormalized(q: string): boolean {
61
+ // fast-fail: mutating queries are never cacheable
62
+ for (const p of MUTATING_PREFIXES) {
63
+ if (q.startsWith(p)) return false
64
+ }
65
+ // check if it touches schema/catalog tables
66
+ for (const marker of SCHEMA_QUERY_MARKERS) {
67
+ if (q.includes(marker)) return true
68
+ }
69
+ return false
70
+ }
71
+
72
+ function isWriteNormalized(q: string): boolean {
73
+ for (const p of WRITE_PREFIXES) {
74
+ if (q.startsWith(p)) return true
75
+ }
76
+ return false
77
+ }
78
+
79
+ function isDDLNormalized(q: string): boolean {
80
+ for (const p of DDL_PREFIXES) {
81
+ if (q.startsWith(p)) return true
82
+ }
83
+ return false
84
+ }
85
+
86
+ function extractQueryText(data: Uint8Array): string | null {
87
+ if (data[0] === 0x51) {
88
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
89
+ const len = view.getInt32(1)
90
+ return textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
91
+ }
92
+ if (data[0] === 0x50) {
93
+ return extractParseQuery(data)
94
+ }
95
+ return null
96
+ }
97
+
98
+ function invalidateSchemaCache() {
99
+ schemaQueryCache.clear()
100
+ }
101
+
102
+ // abort previous replication handler when a new one starts
103
+ let abortPreviousReplication: (() => void) | null = null
104
+
105
+ // clean version string: strip emscripten compiler info that breaks pg_restore/pg_dump
106
+ const PG_VERSION_STRING =
107
+ "'PostgreSQL 17.4 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 12.2.0, 64-bit'"
108
+
109
+ // query rewrites: make pglite look like real postgres with logical replication
110
+ const QUERY_REWRITES: Array<{ match: RegExp; replace: string }> = [
111
+ // version() — return a standard-looking version string instead of the emscripten one
112
+ {
113
+ match: /\bversion\(\)/gi,
114
+ replace: PG_VERSION_STRING,
115
+ },
116
+ // wal_level check
117
+ {
118
+ match: /current_setting\s*\(\s*'wal_level'\s*\)/gi,
119
+ replace: "'logical'::text",
120
+ },
121
+ // strip READ ONLY from BEGIN (pglite is single-session, no read-only transactions)
122
+ {
123
+ match: /\bREAD\s+ONLY\b/gi,
124
+ replace: '',
125
+ },
126
+ // strip ISOLATION LEVEL from any query (pglite is single-session, isolation is meaningless)
127
+ // catches: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE, BEGIN ISOLATION LEVEL SERIALIZABLE, etc.
128
+ {
129
+ match:
130
+ /\bISOLATION\s+LEVEL\s+(SERIALIZABLE|REPEATABLE\s+READ|READ\s+COMMITTED|READ\s+UNCOMMITTED)\b/gi,
131
+ replace: '',
132
+ },
133
+ // strip bare SET TRANSACTION (after ISOLATION LEVEL is removed, this becomes a no-op statement)
134
+ {
135
+ match: /\bSET\s+TRANSACTION\s*;/gi,
136
+ replace: ';',
137
+ },
138
+ // redirect pg_replication_slots to our fake table in _orez schema
139
+ {
140
+ match: /\bpg_replication_slots\b/g,
141
+ replace: '_orez._zero_replication_slots',
142
+ },
143
+ ]
144
+
145
+ // parameter status messages sent during connection handshake
146
+ // pg_restore and other tools read these to determine server capabilities
147
+ const SERVER_PARAMS: [string, string][] = [
148
+ ['server_encoding', 'UTF8'],
149
+ ['client_encoding', 'UTF8'],
150
+ ['DateStyle', 'ISO, MDY'],
151
+ ['integer_datetimes', 'on'],
152
+ ['standard_conforming_strings', 'on'],
153
+ ['TimeZone', 'UTC'],
154
+ ['IntervalStyle', 'postgres'],
155
+ ]
156
+
157
+ // build a ParameterStatus wire protocol message (type 'S', 0x53)
158
+ function buildParameterStatus(name: string, value: string): Uint8Array {
159
+ const encoder = textEncoder
160
+ const nameBytes = encoder.encode(name)
161
+ const valueBytes = encoder.encode(value)
162
+ const len = 4 + nameBytes.length + 1 + valueBytes.length + 1
163
+ const buf = new Uint8Array(1 + len)
164
+ buf[0] = 0x53 // 'S'
165
+ new DataView(buf.buffer).setInt32(1, len)
166
+ let pos = 5
167
+ buf.set(nameBytes, pos)
168
+ pos += nameBytes.length
169
+ buf[pos++] = 0
170
+ buf.set(valueBytes, pos)
171
+ pos += valueBytes.length
172
+ buf[pos] = 0
173
+ return buf
174
+ }
175
+
176
+ // queries to intercept and return no-op success (synthetic SET response)
177
+ // pglite rejects SET TRANSACTION if any query (e.g. SET search_path) ran first
178
+ const NOOP_QUERY_PATTERNS: RegExp[] = [/^\s*SET\s+TRANSACTION\b/i, /^\s*SET\s+SESSION\b/i]
179
+
180
+ // ping queries (SELECT 1, SELECT 2, etc.) — respond synthetically to avoid
181
+ // mutex contention during zero-cache connection warmup
182
+ const PING_QUERY_RE = /^\s*SELECT\s+(\d+)\s*$/i
183
+
184
+ /**
185
+ * extract query text from a Parse message (0x50).
186
+ */
187
+ function extractParseQuery(data: Uint8Array): string | null {
188
+ if (data[0] !== 0x50) return null
189
+ let offset = 5
190
+ while (offset < data.length && data[offset] !== 0) offset++
191
+ offset++
192
+ const queryStart = offset
193
+ while (offset < data.length && data[offset] !== 0) offset++
194
+ return textDecoder.decode(data.subarray(queryStart, offset))
195
+ }
196
+
197
+ /**
198
+ * rebuild a Parse message with a modified query string.
199
+ */
200
+ function rebuildParseMessage(data: Uint8Array, newQuery: string): Uint8Array {
201
+ let offset = 5
202
+ while (offset < data.length && data[offset] !== 0) offset++
203
+ const nameEnd = offset + 1
204
+ const nameBytes = data.subarray(5, nameEnd)
205
+
206
+ offset = nameEnd
207
+ while (offset < data.length && data[offset] !== 0) offset++
208
+ offset++
209
+
210
+ const suffix = data.subarray(offset)
211
+ const encoder = textEncoder
212
+ const queryBytes = encoder.encode(newQuery)
213
+
214
+ const totalLen = 4 + nameBytes.length + queryBytes.length + 1 + suffix.length
215
+ const result = new Uint8Array(1 + totalLen)
216
+ const dv = new DataView(result.buffer)
217
+ result[0] = 0x50
218
+ dv.setInt32(1, totalLen)
219
+ let pos = 5
220
+ result.set(nameBytes, pos)
221
+ pos += nameBytes.length
222
+ result.set(queryBytes, pos)
223
+ pos += queryBytes.length
224
+ result[pos++] = 0
225
+ result.set(suffix, pos)
226
+ return result
227
+ }
228
+
229
+ /**
230
+ * rebuild a Simple Query message with a modified query string.
231
+ */
232
+ function rebuildSimpleQuery(newQuery: string): Uint8Array {
233
+ const encoder = textEncoder
234
+ const queryBytes = encoder.encode(newQuery + '\0')
235
+ const buf = new Uint8Array(5 + queryBytes.length)
236
+ buf[0] = 0x51
237
+ new DataView(buf.buffer).setInt32(1, 4 + queryBytes.length)
238
+ buf.set(queryBytes, 5)
239
+ return buf
240
+ }
241
+
242
+ // apply all rewrites in one pass, using replace directly (no separate test)
243
+ function applyRewrites(query: string): string {
244
+ let result = query
245
+ for (const rw of QUERY_REWRITES) {
246
+ rw.match.lastIndex = 0
247
+ result = result.replace(rw.match, rw.replace)
248
+ }
249
+ return result
250
+ }
251
+
252
+ /**
253
+ * intercept and rewrite query messages to make pglite look like real postgres.
254
+ */
255
+ function interceptQuery(data: Uint8Array): Uint8Array {
256
+ const msgType = data[0]
257
+
258
+ if (msgType === 0x51) {
259
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
260
+ const len = view.getInt32(1)
261
+ const original = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
262
+ const rewritten = applyRewrites(original)
263
+ if (rewritten !== original) {
264
+ return rebuildSimpleQuery(rewritten)
265
+ }
266
+ } else if (msgType === 0x50) {
267
+ const original = extractParseQuery(data)
268
+ if (original) {
269
+ let rewritten = applyRewrites(original)
270
+ // for extended protocol, noop queries must be rewritten to a harmless query
271
+ // (can't return synthetic responses because they're part of a pipeline batch)
272
+ if (NOOP_QUERY_PATTERNS.some((p) => p.test(rewritten))) {
273
+ rewritten = 'SELECT 1'
274
+ }
275
+ if (rewritten !== original) {
276
+ return rebuildParseMessage(data, rewritten)
277
+ }
278
+ }
279
+ }
280
+
281
+ return data
282
+ }
283
+
284
+ /**
285
+ * check if a query should be intercepted as a no-op.
286
+ */
287
+ function isNoopQuery(data: Uint8Array): boolean {
288
+ let query: string | null = null
289
+ if (data[0] === 0x51) {
290
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
291
+ const len = view.getInt32(1)
292
+ query = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
293
+ } else if (data[0] === 0x50) {
294
+ query = extractParseQuery(data)
295
+ }
296
+ if (!query) return false
297
+ return NOOP_QUERY_PATTERNS.some((p) => p.test(query!))
298
+ }
299
+
300
+ /**
301
+ * build a synthetic "SET" command complete response.
302
+ */
303
+ function buildSetCompleteResponse(): Uint8Array {
304
+ const encoder = textEncoder
305
+ const tag = encoder.encode('SET\0')
306
+ const cc = new Uint8Array(1 + 4 + tag.length)
307
+ cc[0] = 0x43
308
+ new DataView(cc.buffer).setInt32(1, 4 + tag.length)
309
+ cc.set(tag, 5)
310
+
311
+ const rfq = new Uint8Array(6)
312
+ rfq[0] = 0x5a
313
+ new DataView(rfq.buffer).setInt32(1, 5)
314
+ rfq[5] = 0x54 // 'T' = in transaction
315
+
316
+ const result = new Uint8Array(cc.length + rfq.length)
317
+ result.set(cc, 0)
318
+ result.set(rfq, cc.length)
319
+ return result
320
+ }
321
+
322
+ /**
323
+ * build a synthetic response for SELECT <n> (ping queries).
324
+ * returns RowDescription + DataRow + CommandComplete + ReadyForQuery
325
+ * without touching PGlite or the mutex.
326
+ */
327
+ function buildSelectIntResponse(val: string): Uint8Array {
328
+ const enc = textEncoder
329
+ const parts: Uint8Array[] = []
330
+
331
+ // RowDescription: 1 column named "?column?" type int4 (oid 23)
332
+ const colName = enc.encode('?column?\0')
333
+ const rdLen = 4 + 2 + colName.length + 4 + 2 + 4 + 2 + 4 + 2
334
+ const rd = new Uint8Array(1 + rdLen)
335
+ const rdv = new DataView(rd.buffer)
336
+ rd[0] = 0x54
337
+ rdv.setInt32(1, rdLen)
338
+ rdv.setInt16(5, 1)
339
+ rd.set(colName, 7)
340
+ let p = 7 + colName.length
341
+ rdv.setInt32(p, 0)
342
+ p += 4 // tableOid
343
+ rdv.setInt16(p, 0)
344
+ p += 2 // colAttr
345
+ rdv.setInt32(p, 23)
346
+ p += 4 // typeOid (int4)
347
+ rdv.setInt16(p, 4)
348
+ p += 2 // typeLen
349
+ rdv.setInt32(p, -1)
350
+ p += 4 // typeMod
351
+ rdv.setInt16(p, 0) // format (text)
352
+ parts.push(rd)
353
+
354
+ // DataRow: 1 column with the value
355
+ const valBytes = enc.encode(val)
356
+ const drLen = 4 + 2 + 4 + valBytes.length
357
+ const dr = new Uint8Array(1 + drLen)
358
+ const drv = new DataView(dr.buffer)
359
+ dr[0] = 0x44
360
+ drv.setInt32(1, drLen)
361
+ drv.setInt16(5, 1)
362
+ drv.setInt32(7, valBytes.length)
363
+ dr.set(valBytes, 11)
364
+ parts.push(dr)
365
+
366
+ // CommandComplete
367
+ const tag = enc.encode('SELECT 1\0')
368
+ const cc = new Uint8Array(1 + 4 + tag.length)
369
+ cc[0] = 0x43
370
+ new DataView(cc.buffer).setInt32(1, 4 + tag.length)
371
+ cc.set(tag, 5)
372
+ parts.push(cc)
373
+
374
+ // ReadyForQuery
375
+ const rfq = new Uint8Array(6)
376
+ rfq[0] = 0x5a
377
+ new DataView(rfq.buffer).setInt32(1, 5)
378
+ rfq[5] = 0x49 // 'I' idle
379
+ parts.push(rfq)
380
+
381
+ const total = parts.reduce((s, p) => s + p.length, 0)
382
+ const result = new Uint8Array(total)
383
+ let off = 0
384
+ for (const part of parts) {
385
+ result.set(part, off)
386
+ off += part.length
387
+ }
388
+ return result
389
+ }
390
+
391
+ /** read a big-endian int32 from a Uint8Array at the given offset */
392
+ function concatUint8Arrays(bufs: Uint8Array[]): Uint8Array {
393
+ const totalLen = bufs.reduce((s, b) => s + b.length, 0)
394
+ const result = new Uint8Array(totalLen)
395
+ let offset = 0
396
+ for (const b of bufs) {
397
+ result.set(b, offset)
398
+ offset += b.length
399
+ }
400
+ return result
401
+ }
402
+
403
+ function readInt32BE(data: Uint8Array, offset: number): number {
404
+ return (
405
+ ((data[offset] << 24) >>> 0) +
406
+ (data[offset + 1] << 16) +
407
+ (data[offset + 2] << 8) +
408
+ data[offset + 3]
409
+ )
410
+ }
411
+
412
+ /**
413
+ * extract ReadyForQuery status byte from a response.
414
+ * returns the status: 'I' (0x49) idle, 'T' (0x54) in transaction, 'E' (0x45) error.
415
+ * returns null if no ReadyForQuery found.
416
+ */
417
+ function getReadyForQueryStatus(data: Uint8Array): number | null {
418
+ let offset = 0
419
+ let lastStatus: number | null = null
420
+ while (offset < data.length) {
421
+ if (offset + 5 > data.length) break
422
+ const msgLen = readInt32BE(data, offset + 1)
423
+ const totalLen = 1 + msgLen
424
+ if (totalLen <= 0 || offset + totalLen > data.length) break
425
+ if (data[offset] === 0x5a && totalLen >= 6) {
426
+ lastStatus = data[offset + 5]
427
+ }
428
+ offset += totalLen
429
+ }
430
+ return lastStatus
431
+ }
432
+
433
+ /**
434
+ * per-instance transaction state tracking.
435
+ * pglite is single-connection: if one connection leaves an aborted transaction,
436
+ * it pollutes ALL other connections sharing the same pglite instance.
437
+ * track which connection owns the current transaction so we can auto-ROLLBACK when a
438
+ * DIFFERENT connection encounters the stale aborted state, while still letting the
439
+ * ORIGINAL connection handle its own errors (e.g. ROLLBACK TO SAVEPOINT).
440
+ */
441
+ interface PgLiteTxState {
442
+ status: number // 0x49='I' idle, 0x54='T' in-transaction, 0x45='E' aborted
443
+ owner: object | null // opaque connection identity token
444
+ }
445
+
446
+ // pglite warnings to suppress (benign, but noisy)
447
+ // 25001: "there is already a transaction in progress"
448
+ // 25P01: "there is no transaction in progress"
449
+ // 55000: "wal_level is insufficient to publish logical changes"
450
+ // pglite internally tries to create a publication for change streaming, but embedded
451
+ // pglite doesn't support wal_level=logical (server-level postgres config). the
452
+ // change-streamer still works because it falls back to polling.
453
+ const SUPPRESS_NOTICE_CODES = new Set(['25001', '25P01', '55000'])
454
+
455
+ /**
456
+ * extract SQLSTATE code from a NoticeResponse message.
457
+ * returns null if not a NoticeResponse or code not found.
458
+ */
459
+ function extractNoticeCode(
460
+ data: Uint8Array,
461
+ offset: number,
462
+ totalLen: number
463
+ ): string | null {
464
+ if (data[offset] !== 0x4e) return null // not a NoticeResponse
465
+
466
+ let pos = offset + 5 // skip type byte + length
467
+ const end = offset + totalLen
468
+
469
+ while (pos < end) {
470
+ const fieldType = data[pos++]
471
+ if (fieldType === 0) break // terminator
472
+
473
+ // find null-terminated string
474
+ const strStart = pos
475
+ while (pos < end && data[pos] !== 0) pos++
476
+ if (pos >= end) break
477
+
478
+ if (fieldType === 0x43) {
479
+ // 'C' = SQLSTATE code
480
+ return textDecoder.decode(data.subarray(strStart, pos))
481
+ }
482
+ pos++ // skip null terminator
483
+ }
484
+ return null
485
+ }
486
+
487
+ /**
488
+ * single-pass response message filter. strips ReadyForQuery messages (when
489
+ * stripRfq=true) and benign transaction state warnings in one scan.
490
+ */
491
+ function stripResponseMessages(data: Uint8Array, stripRfq: boolean): Uint8Array {
492
+ if (data.length === 0) return data
493
+
494
+ const parts: Uint8Array[] = []
495
+ let offset = 0
496
+ let stripped = false
497
+
498
+ while (offset < data.length) {
499
+ const msgType = data[offset]
500
+ if (offset + 5 > data.length) break
501
+ const msgLen = readInt32BE(data, offset + 1)
502
+ const totalLen = 1 + msgLen
503
+
504
+ if (totalLen <= 0 || offset + totalLen > data.length) break
505
+
506
+ // strip ReadyForQuery (0x5a) when requested
507
+ if (stripRfq && msgType === 0x5a) {
508
+ stripped = true
509
+ }
510
+ // strip benign transaction state notices
511
+ else {
512
+ const code = extractNoticeCode(data, offset, totalLen)
513
+ if (code && SUPPRESS_NOTICE_CODES.has(code)) {
514
+ stripped = true
515
+ } else {
516
+ parts.push(data.subarray(offset, offset + totalLen))
517
+ }
518
+ }
519
+
520
+ offset += totalLen
521
+ }
522
+
523
+ if (!stripped) return data
524
+ if (parts.length === 0) return new Uint8Array(0)
525
+ if (parts.length === 1) return parts[0]
526
+
527
+ const total = parts.reduce((sum, p) => sum + p.length, 0)
528
+ const result = new Uint8Array(total)
529
+ let pos = 0
530
+ for (const p of parts) {
531
+ result.set(p, pos)
532
+ pos += p.length
533
+ }
534
+ return result
535
+ }
536
+
537
+ /**
538
+ * create a DuplexStream<Uint8Array> from a MessagePort.
539
+ * readable receives Uint8Array messages from the port.
540
+ * writable sends Uint8Array messages via the port.
541
+ */
542
+ let _globalWriteCount = 0
543
+ function messagePortToDuplexWithInject(port: MessagePort): {
544
+ duplex: DuplexStream<Uint8Array>
545
+ rawWrite: (data: Uint8Array) => void
546
+ injectMessage: (data: Uint8Array) => void
547
+ } {
548
+ let readController: ReadableStreamDefaultController<Uint8Array>
549
+ let msgCount = 0
550
+ const readable = new ReadableStream<Uint8Array>({
551
+ start(controller) {
552
+ readController = controller
553
+ port.onmessage = (ev: MessageEvent) => {
554
+ msgCount++
555
+ if (ev.data instanceof ArrayBuffer) {
556
+ controller.enqueue(new Uint8Array(ev.data))
557
+ } else if (ev.data instanceof Uint8Array) {
558
+ controller.enqueue(ev.data)
559
+ }
560
+ }
561
+ },
562
+ cancel() {
563
+ port.close()
564
+ },
565
+ })
566
+
567
+ const writable = new WritableStream<Uint8Array>({
568
+ write(chunk) {
569
+ const buf = chunk.buffer.slice(
570
+ chunk.byteOffset,
571
+ chunk.byteOffset + chunk.byteLength
572
+ ) as ArrayBuffer
573
+ port.postMessage(buf, [buf])
574
+ },
575
+ close() {
576
+ port.close()
577
+ },
578
+ })
579
+
580
+ const rawWrite = (data: Uint8Array) => {
581
+ const buf = data.buffer.slice(
582
+ data.byteOffset,
583
+ data.byteOffset + data.byteLength
584
+ ) as ArrayBuffer
585
+ port.postMessage(buf, [buf])
586
+ }
587
+
588
+ const injectMessage = (data: Uint8Array) => {
589
+ if (readController) {
590
+ readController.enqueue(data)
591
+ }
592
+ }
593
+
594
+ return { duplex: { readable, writable }, rawWrite, injectMessage }
595
+ }
596
+
597
+ function messagePortToDuplex(port: MessagePort): {
598
+ duplex: DuplexStream<Uint8Array>
599
+ rawWrite: (data: Uint8Array) => void
600
+ } {
601
+ let msgCount = 0
602
+ const readable = new ReadableStream<Uint8Array>({
603
+ start(controller) {
604
+ port.onmessage = (ev: MessageEvent) => {
605
+ msgCount++
606
+ if (msgCount <= 3) {
607
+ console.debug(
608
+ `[pg-proxy-duplex] msg#${msgCount} type=${typeof ev.data} isAB=${ev.data instanceof ArrayBuffer} isU8=${ev.data instanceof Uint8Array} len=${ev.data?.byteLength ?? ev.data?.length ?? '?'}`
609
+ )
610
+ }
611
+ if (ev.data instanceof ArrayBuffer) {
612
+ controller.enqueue(new Uint8Array(ev.data))
613
+ } else if (ev.data instanceof Uint8Array) {
614
+ controller.enqueue(ev.data)
615
+ } else {
616
+ console.warn(`[pg-proxy-duplex] unexpected data type:`, typeof ev.data, ev.data)
617
+ }
618
+ }
619
+ },
620
+ cancel() {
621
+ port.close()
622
+ },
623
+ })
624
+
625
+ const writable = new WritableStream<Uint8Array>({
626
+ write(chunk) {
627
+ _globalWriteCount++
628
+ if (_globalWriteCount <= 200) {
629
+ console.debug(`[pg-proxy-ws-write] #${_globalWriteCount} len=${chunk.byteLength}`)
630
+ }
631
+ // transfer the ArrayBuffer for zero-copy
632
+ const buf = chunk.buffer.slice(
633
+ chunk.byteOffset,
634
+ chunk.byteOffset + chunk.byteLength
635
+ ) as ArrayBuffer
636
+ port.postMessage(buf, [buf])
637
+ },
638
+ close() {
639
+ port.close()
640
+ },
641
+ })
642
+
643
+ // raw write function for injecting data outside of pg-gateway's stream
644
+ // (e.g. parameter status messages during onAuthenticated)
645
+ const rawWrite = (data: Uint8Array) => {
646
+ const buf = data.buffer.slice(
647
+ data.byteOffset,
648
+ data.byteOffset + data.byteLength
649
+ ) as ArrayBuffer
650
+ port.postMessage(buf, [buf])
651
+ }
652
+
653
+ return { duplex: { readable, writable }, rawWrite }
654
+ }
655
+
656
+ export interface BrowserProxy {
657
+ handleConnection(port: MessagePort): void
658
+ close(): void
659
+ }
660
+
661
+ export async function createBrowserProxy(
662
+ dbInput: PGlite | PGliteInstances,
663
+ config: { pgPassword: string; pgUser: string; pgPort?: number; logLevel?: string }
664
+ ): Promise<BrowserProxy> {
665
+ // normalize input: single PGlite instance = use it for all databases (backwards compat for tests)
666
+ const instances: PGliteInstances =
667
+ 'postgres' in dbInput
668
+ ? (dbInput as PGliteInstances)
669
+ : { postgres: dbInput as PGlite, cvr: dbInput as PGlite, cdb: dbInput as PGlite }
670
+
671
+ // per-instance mutexes for serializing pglite access.
672
+ // when all instances are the same object (single-db mode), share one mutex
673
+ // to prevent concurrent protocol messages on the same pglite instance.
674
+ const sharedInstance =
675
+ instances.postgres === instances.cvr && instances.postgres === instances.cdb
676
+ const pgMutex = new Mutex()
677
+ const mutexes = {
678
+ postgres: pgMutex,
679
+ cvr: sharedInstance ? pgMutex : new Mutex(),
680
+ cdb: sharedInstance ? pgMutex : new Mutex(),
681
+ }
682
+
683
+ // per-instance transaction state: tracks which connection owns the current transaction
684
+ // so we can auto-ROLLBACK stale aborted transactions from other connections
685
+ const txStates: Record<string, PgLiteTxState> = {
686
+ postgres: { status: 0x49, owner: null },
687
+ cvr: { status: 0x49, owner: null },
688
+ cdb: { status: 0x49, owner: null },
689
+ }
690
+
691
+ // helper to get instance + mutex + tx state for a database name
692
+ function getDbContext(dbName: string): {
693
+ db: PGlite
694
+ mutex: Mutex
695
+ txState: PgLiteTxState
696
+ } {
697
+ if (dbName === 'zero_cvr')
698
+ return { db: instances.cvr, mutex: mutexes.cvr, txState: txStates.cvr }
699
+ if (dbName === 'zero_cdb')
700
+ return { db: instances.cdb, mutex: mutexes.cdb, txState: txStates.cdb }
701
+ return { db: instances.postgres, mutex: mutexes.postgres, txState: txStates.postgres }
702
+ }
703
+
704
+ // signal replication handler after extended protocol writes complete.
705
+ // 8ms leading-edge debounce: fires exactly 8ms after the FIRST write,
706
+ // subsequent writes within that window are batched (handler polls all
707
+ // changes at once). gives the PushProcessor time to confirm the mutation
708
+ // before replication streams the same change to zero-cache.
709
+ // signal replication after writes. uses queueMicrotask instead of setTimeout
710
+ // because macrotasks (setTimeout) get starved by continuous microtask chains
711
+ // (async/await) and by Atomics.wait in SAB mode.
712
+ let signalPending = false
713
+ function signalWrite() {
714
+ if (signalPending) return
715
+ signalPending = true
716
+ queueMicrotask(() => {
717
+ signalPending = false
718
+ signalReplicationChange()
719
+ })
720
+ }
721
+
722
+ let closed = false
723
+
724
+ function handleConnection(port: MessagePort) {
725
+ if (closed) {
726
+ port.close()
727
+ return
728
+ }
729
+
730
+ port.start()
731
+
732
+ // peek at the first message to detect replication connections.
733
+ // replication connections bypass pg-gateway entirely and are handled
734
+ // with raw MessagePort communication — matching orez-node where
735
+ // handleReplicationMessage writes directly to the TCP socket.
736
+ // buffer messages until the connection handler is installed
737
+ const buffered: MessageEvent[] = []
738
+ let handlerInstalled = false
739
+
740
+ port.onmessage = (ev: MessageEvent) => {
741
+ if (handlerInstalled) return // shouldn't happen — handler replaced port.onmessage
742
+ buffered.push(ev)
743
+ if (buffered.length > 1) return // only process first message
744
+
745
+ const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data
746
+ if (!(data instanceof Uint8Array) || data.length < 8) {
747
+ handlerInstalled = true
748
+ handleRegularConnection(port, ev)
749
+ // flush buffered messages to the new handler
750
+ for (let i = 1; i < buffered.length; i++) {
751
+ port.onmessage?.(buffered[i])
752
+ }
753
+ return
754
+ }
755
+
756
+ // parse startup message params
757
+ try {
758
+ const params = parseStartupParams(data)
759
+ const dbName = params.database || 'postgres'
760
+ const isRepl = params.replication === 'database'
761
+ console.debug(`[pg-proxy] connection: db=${dbName} repl=${isRepl}`)
762
+ // all connections handled with raw MessagePort (no pg-gateway).
763
+ // pg-gateway uses for-await on ReadableStream which is broken
764
+ // in browser Web Workers (same root cause as patches #9, #18, #20).
765
+ handleRawConnection(port, data, params, getDbContext(dbName), isRepl)
766
+ handlerInstalled = true
767
+ // flush any messages that arrived while we were processing the startup
768
+ for (let i = 1; i < buffered.length; i++) {
769
+ if (port.onmessage) {
770
+ port.onmessage(buffered[i])
771
+ }
772
+ }
773
+ } catch (err: any) {
774
+ console.error(`[pg-proxy] connection error: ${err?.message || err}`)
775
+ }
776
+ }
777
+ }
778
+
779
+ /** parse startup message key-value params */
780
+ function parseStartupParams(data: Uint8Array): Record<string, string> {
781
+ const params: Record<string, string> = {}
782
+ // skip: int32 length + int32 protocol version = 8 bytes
783
+ let pos = 8
784
+ while (pos < data.length - 1) {
785
+ const keyStart = pos
786
+ while (pos < data.length && data[pos] !== 0) pos++
787
+ if (pos >= data.length) break
788
+ const key = textDecoder.decode(data.subarray(keyStart, pos))
789
+ pos++ // skip null
790
+ const valStart = pos
791
+ while (pos < data.length && data[pos] !== 0) pos++
792
+ const val = textDecoder.decode(data.subarray(valStart, pos))
793
+ pos++ // skip null
794
+ if (key) params[key] = val
795
+ }
796
+ return params
797
+ }
798
+
799
+ /** handle ANY connection with raw MessagePort (no pg-gateway) */
800
+ function handleRawConnection(
801
+ port: MessagePort,
802
+ startupData: Uint8Array,
803
+ params: Record<string, string>,
804
+ ctx: { db: PGlite; mutex: Mutex; txState: PgLiteTxState },
805
+ isReplicationConnection: boolean
806
+ ) {
807
+ const { db, mutex, txState } = ctx
808
+ const connId = {}
809
+ const dbName = params.database || 'postgres'
810
+ let connClosed = false
811
+
812
+ const write = (data: Uint8Array) => {
813
+ if (connClosed) return
814
+ // copy instead of transfer — transfer detaches the buffer which can
815
+ // cause issues if the caller still references the original data
816
+ const copy = new Uint8Array(data.length)
817
+ copy.set(data)
818
+ port.postMessage(copy.buffer, [copy.buffer])
819
+ }
820
+
821
+ // step 1: send AuthenticationClearTextPassword (R, type=3) — ask for password
822
+ const authRequest = new Uint8Array([0x52, 0, 0, 0, 8, 0, 0, 0, 3])
823
+ write(authRequest)
824
+
825
+ // step 2: wait for Password message (p), then send AuthOk + params
826
+ port.onmessage = (ev: MessageEvent) => {
827
+ const data2 =
828
+ ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : (ev.data as Uint8Array)
829
+ console.debug(
830
+ `[pg-proxy-raw-auth] ${dbName} repl=${isReplicationConnection} got msg type=0x${data2?.[0]?.toString(16)} len=${data2?.length}`
831
+ )
832
+ if (!data2 || data2[0] !== 0x70) {
833
+ console.warn(
834
+ '[pg-proxy-raw-auth] expected password, got type=0x' + data2?.[0]?.toString(16)
835
+ )
836
+ }
837
+
838
+ // send ALL auth response messages as ONE combined buffer.
839
+ // the postgres package reads from the socket and buffers data.
840
+ // sending as individual postMessage calls creates separate data events,
841
+ // which is fine for TCP but may cause issues with MessagePort timing.
842
+ const parts: Uint8Array[] = []
843
+
844
+ // AuthenticationOk (R, type=0)
845
+ parts.push(new Uint8Array([0x52, 0, 0, 0, 8, 0, 0, 0, 0]))
846
+
847
+ // ParameterStatus messages
848
+ for (const [name, value] of SERVER_PARAMS) {
849
+ parts.push(buildParameterStatus(name, value))
850
+ }
851
+
852
+ // BackendKeyData (K)
853
+ const bkd = new Uint8Array(13)
854
+ bkd[0] = 0x4b
855
+ new DataView(bkd.buffer).setInt32(1, 12)
856
+ new DataView(bkd.buffer).setInt32(5, 1)
857
+ new DataView(bkd.buffer).setInt32(9, 0)
858
+ parts.push(bkd)
859
+
860
+ // ReadyForQuery (Z)
861
+ const rfq = new Uint8Array(6)
862
+ rfq[0] = 0x5a
863
+ new DataView(rfq.buffer).setInt32(1, 5)
864
+ rfq[5] = 0x49
865
+ parts.push(rfq)
866
+
867
+ // combine and send as single message
868
+ const totalLen = parts.reduce((s, p) => s + p.length, 0)
869
+ const combined = new Uint8Array(totalLen)
870
+ let pos = 0
871
+ for (const p of parts) {
872
+ combined.set(p, pos)
873
+ pos += p.length
874
+ }
875
+ write(combined)
876
+
877
+ console.debug('[pg-proxy-repl-raw] auth complete, ready for queries')
878
+
879
+ // step 3: handle subsequent messages (queries, replication commands)
880
+ installQueryHandler()
881
+ }
882
+
883
+ let pipelineMutexHeld = false
884
+ let extWritePending = false
885
+ let pipelineBuffer: Uint8Array[] = []
886
+
887
+ function installQueryHandler() {
888
+ // message buffer: postgres sends multiple protocol messages in one write,
889
+ // we need to split them and process each individually
890
+ let pendingBuffer: Uint8Array | null = null
891
+ // guard against re-entrant onmessage: async handlers can interleave at
892
+ // await points, causing concurrent modifications to pendingBuffer.
893
+ let processing = false
894
+
895
+ port.onmessage = async (ev: MessageEvent) => {
896
+ if (connClosed) return
897
+ const incoming =
898
+ ev.data instanceof ArrayBuffer
899
+ ? new Uint8Array(ev.data)
900
+ : (ev.data as Uint8Array)
901
+ if (!incoming || !(incoming instanceof Uint8Array)) return
902
+
903
+ // append to pending buffer
904
+ if (pendingBuffer && pendingBuffer.length > 0) {
905
+ const combined = new Uint8Array(pendingBuffer.length + incoming.length)
906
+ combined.set(pendingBuffer)
907
+ combined.set(incoming, pendingBuffer.length)
908
+ pendingBuffer = combined
909
+ } else {
910
+ pendingBuffer = incoming
911
+ }
912
+
913
+ // if another invocation is already processing, just buffer
914
+ if (processing) return
915
+ processing = true
916
+ try {
917
+ // process all complete messages in the buffer
918
+ while (pendingBuffer && pendingBuffer.length >= 5) {
919
+ const msgType: number = pendingBuffer[0]
920
+ const msgLen: number = new DataView(
921
+ pendingBuffer.buffer,
922
+ pendingBuffer.byteOffset,
923
+ pendingBuffer.byteLength
924
+ ).getInt32(1)
925
+ const totalLen: number = 1 + msgLen
926
+ if (totalLen > pendingBuffer.length) break // incomplete message, wait for more data
927
+
928
+ // extract single message
929
+ const data = pendingBuffer.slice(0, totalLen)
930
+ pendingBuffer =
931
+ pendingBuffer.length > totalLen ? pendingBuffer.slice(totalLen) : null
932
+
933
+ await processMessage(data)
934
+ }
935
+ } finally {
936
+ processing = false
937
+ }
938
+ }
939
+
940
+ let _pmCount = 0
941
+ async function processMessage(data: Uint8Array) {
942
+ _pmCount++
943
+ const msgType = data[0]
944
+ // log every message with type name for debugging
945
+ const typeNames: Record<number, string> = {
946
+ 0x50: 'Parse',
947
+ 0x42: 'Bind',
948
+ 0x44: 'Describe',
949
+ 0x45: 'Execute',
950
+ 0x43: 'Close',
951
+ 0x48: 'Flush',
952
+ 0x53: 'Sync',
953
+ 0x51: 'Query',
954
+ 0x58: 'Terminate',
955
+ 0x70: 'Password',
956
+ 0x46: 'FunctionCall',
957
+ 0x64: 'CopyData',
958
+ 0x63: 'CopyDone',
959
+ 0x66: 'CopyFail',
960
+ }
961
+ const name = typeNames[msgType] || `unknown(0x${msgType.toString(16)})`
962
+ console.debug(`[pg-proxy-pm] #${_pmCount} ${dbName} ${name} len=${data.length}`)
963
+
964
+ // replication connection: handle replication commands
965
+ if (isReplicationConnection && msgType === 0x51) {
966
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
967
+ const len = view.getInt32(1)
968
+ const query = textDecoder
969
+ .decode(data.subarray(5, 1 + len - 1))
970
+ .replace(/\0$/, '')
971
+ const upper = query.trim().toUpperCase()
972
+
973
+ if (upper.startsWith('START_REPLICATION')) {
974
+ if (abortPreviousReplication) abortPreviousReplication()
975
+ let aborted = false
976
+ const writer = {
977
+ write(chunk: Uint8Array) {
978
+ if (!connClosed && !aborted) {
979
+ try {
980
+ write(chunk)
981
+ } catch {
982
+ aborted = true
983
+ }
984
+ }
985
+ },
986
+ get closed() {
987
+ return connClosed || aborted
988
+ },
989
+ }
990
+ abortPreviousReplication = () => {
991
+ aborted = true
992
+ connClosed = true
993
+ port.close()
994
+ }
995
+ port.onmessage = () => {}
996
+ handleStartReplication(query, writer, db, mutex).catch(() => {})
997
+ return
998
+ }
999
+
1000
+ // replication queries (IDENTIFY_SYSTEM, CREATE/DROP SLOT)
1001
+ await mutex.acquire()
1002
+ try {
1003
+ const response = await handleReplicationQuery(query, db)
1004
+ if (response) {
1005
+ write(response)
1006
+ return
1007
+ }
1008
+ data = interceptQuery(data)
1009
+ let result = await db.execProtocolRaw(data, { syncToFs: false })
1010
+ result = stripResponseMessages(result, false)
1011
+ write(result)
1012
+ } finally {
1013
+ mutex.release()
1014
+ }
1015
+ return
1016
+ }
1017
+
1018
+ // Terminate (0x58) — client wants to close the connection
1019
+ if (msgType === 0x58) {
1020
+ // release mutex if held — connection terminated mid-pipeline
1021
+ if (pipelineMutexHeld) {
1022
+ mutex.release()
1023
+ pipelineMutexHeld = false
1024
+ }
1025
+ connClosed = true
1026
+ port.close()
1027
+ return
1028
+ }
1029
+
1030
+ // regular query handling (SimpleQuery or extended protocol)
1031
+ if (msgType === 0x50) {
1032
+ const q = extractParseQuery(data)
1033
+ if (q) console.debug(`[pg-proxy-raw] ${dbName}: Parse ${q.slice(0, 80)}`)
1034
+ } else if (msgType === 0x51) {
1035
+ console.debug(`[pg-proxy-raw] ${dbName}: SimpleQuery len=${data.length}`)
1036
+ }
1037
+
1038
+ // extended protocol pipeline: Parse(0x50), Bind(0x42), Describe(0x44),
1039
+ // Execute(0x45), Close(0x43), Flush(0x48)
1040
+ const isExtendedMsg =
1041
+ msgType === 0x50 ||
1042
+ msgType === 0x42 ||
1043
+ msgType === 0x44 ||
1044
+ msgType === 0x45 ||
1045
+ msgType === 0x43 ||
1046
+ msgType === 0x48
1047
+ const isSyncInPipeline = msgType === 0x53 && pipelineMutexHeld
1048
+
1049
+ if (isExtendedMsg || isSyncInPipeline) {
1050
+ if (!pipelineMutexHeld) {
1051
+ await mutex.acquire()
1052
+ pipelineMutexHeld = true
1053
+ pipelineBuffer = []
1054
+ // auto-rollback stale transactions
1055
+ if (txState.status === 0x45 && txState.owner !== connId) {
1056
+ try {
1057
+ await db.exec('ROLLBACK')
1058
+ } catch {}
1059
+ txState.status = 0x49
1060
+ txState.owner = null
1061
+ }
1062
+ }
1063
+
1064
+ // detect writes for replication signaling
1065
+ if (dbName === 'postgres' && msgType === 0x50) {
1066
+ const q = extractParseQuery(data)?.trimStart().toLowerCase()
1067
+ if (q && /^(insert|update|delete|copy|truncate)/.test(q)) {
1068
+ extWritePending = true
1069
+ }
1070
+ }
1071
+
1072
+ data = interceptQuery(data)
1073
+
1074
+ // batch: accumulate pipeline messages, send all at once on Sync.
1075
+ // reduces MessagePort round-trips from 5 per query to 1.
1076
+ // (browser MessagePort is ~40ms/hop vs TCP ~0.1ms — batching saves ~5s on init)
1077
+ if (msgType !== 0x53) {
1078
+ pipelineBuffer.push(data)
1079
+ // Flush (0x48): send buffered messages now — describeFirst queries
1080
+ // need the response before the postgres package sends Bind
1081
+ if (msgType === 0x48) {
1082
+ const combined = concatUint8Arrays(pipelineBuffer)
1083
+ pipelineBuffer = []
1084
+ let flushResult: Uint8Array
1085
+ try {
1086
+ flushResult = await db.execProtocolRaw(combined, { syncToFs: false })
1087
+ } catch (err) {
1088
+ console.warn(
1089
+ `[pg-proxy-raw] execProtocolRaw flush error on ${dbName}: ${(err as any)?.message}`
1090
+ )
1091
+ mutex.release()
1092
+ pipelineMutexHeld = false
1093
+ return
1094
+ }
1095
+ flushResult = stripResponseMessages(flushResult, true)
1096
+ write(flushResult)
1097
+ }
1098
+ return // buffered or flushed, don't fall through to Sync handling
1099
+ }
1100
+
1101
+ // Sync: flush all buffered messages + Sync in one call to PGlite
1102
+ pipelineBuffer.push(data)
1103
+ const combined = concatUint8Arrays(pipelineBuffer)
1104
+ pipelineBuffer = []
1105
+
1106
+ let result: Uint8Array
1107
+ const t0 = performance.now()
1108
+ try {
1109
+ result = await db.execProtocolRaw(combined, { syncToFs: false })
1110
+ } catch (err) {
1111
+ console.warn(
1112
+ `[pg-proxy-raw] execProtocolRaw error on ${dbName}: ${(err as any)?.message}`
1113
+ )
1114
+ mutex.release()
1115
+ pipelineMutexHeld = false
1116
+ return
1117
+ }
1118
+ const dt = performance.now() - t0
1119
+ if (dt > 100)
1120
+ console.debug(`[pg-proxy-raw] slow query on ${dbName}: ${dt.toFixed(0)}ms`)
1121
+
1122
+ // update transaction state
1123
+ const rfqStatus = getReadyForQueryStatus(result)
1124
+ if (rfqStatus !== null) {
1125
+ txState.status = rfqStatus
1126
+ txState.owner = rfqStatus === 0x49 ? null : connId
1127
+ }
1128
+
1129
+ // release mutex on Sync
1130
+ if (msgType === 0x53) {
1131
+ mutex.release()
1132
+ pipelineMutexHeld = false
1133
+ if (dbName === 'postgres' && extWritePending) {
1134
+ extWritePending = false
1135
+ signalWrite()
1136
+ }
1137
+ // verify ReadyForQuery and check for errors in the response
1138
+ const rfq = getReadyForQueryStatus(result)
1139
+ if (rfq === null) {
1140
+ console.warn(
1141
+ `[pg-proxy-raw] Sync missing RFQ! db=${dbName} len=${result.length}`
1142
+ )
1143
+ }
1144
+ // check for ErrorResponse (0x45 'E')
1145
+ let pos = 0
1146
+ while (pos < result.length) {
1147
+ if (pos + 5 > result.length) break
1148
+ const t = result[pos]
1149
+ const l = readInt32BE(result, pos + 1)
1150
+ if (t === 0x45) {
1151
+ // ErrorResponse
1152
+ console.warn(
1153
+ `[pg-proxy-raw] ErrorResponse in Sync! db=${dbName} rfq=${rfq === 0x49 ? 'I' : rfq === 0x45 ? 'E' : rfq}`
1154
+ )
1155
+ }
1156
+ pos += 1 + l
1157
+ }
1158
+ } else {
1159
+ // strip ReadyForQuery + notices from non-Sync pipeline messages
1160
+ result = stripResponseMessages(result, true)
1161
+ }
1162
+
1163
+ // strip benign notices (25P01 etc.) from ALL results including Sync
1164
+ result = stripResponseMessages(result, false)
1165
+ write(result)
1166
+ return
1167
+ }
1168
+
1169
+ // SimpleQuery (0x51) or standalone Sync
1170
+ if (msgType === 0x51) {
1171
+ const queryText = extractQueryText(data)
1172
+ // ping fast-path
1173
+ if (queryText) {
1174
+ const pingMatch = queryText.match(PING_QUERY_RE)
1175
+ if (pingMatch) {
1176
+ write(buildSelectIntResponse(pingMatch[1]))
1177
+ return
1178
+ }
1179
+ }
1180
+ if (isNoopQuery(data)) {
1181
+ write(buildSetCompleteResponse())
1182
+ return
1183
+ }
1184
+ }
1185
+
1186
+ data = interceptQuery(data)
1187
+ await mutex.acquire()
1188
+ try {
1189
+ if (txState.status === 0x45 && txState.owner !== connId) {
1190
+ try {
1191
+ await db.exec('ROLLBACK')
1192
+ } catch {}
1193
+ txState.status = 0x49
1194
+ txState.owner = null
1195
+ }
1196
+ let result = await db.execProtocolRaw(data, { syncToFs: false })
1197
+ const rfqStatus = getReadyForQueryStatus(result)
1198
+ if (rfqStatus !== null) {
1199
+ txState.status = rfqStatus
1200
+ txState.owner = rfqStatus === 0x49 ? null : connId
1201
+ }
1202
+ // strip notices (wal_level warnings, transaction state notices)
1203
+ result = stripResponseMessages(result, false)
1204
+ // signal writes
1205
+ if (dbName === 'postgres' && msgType === 0x51) {
1206
+ const qn = extractQueryText(data)?.trimStart().toLowerCase()
1207
+ if (qn && isWriteNormalized(qn)) signalReplicationChange()
1208
+ }
1209
+ write(result)
1210
+ } finally {
1211
+ mutex.release()
1212
+ }
1213
+ } // end processMessage
1214
+ } // end installQueryHandler
1215
+ }
1216
+
1217
+ function handleRegularConnection(port: MessagePort, firstEvent: MessageEvent) {
1218
+ // create duplex AFTER we know it's not a replication connection.
1219
+ // the first message (startup) needs to be re-injected into the readable stream.
1220
+ const { duplex, rawWrite, injectMessage } = messagePortToDuplexWithInject(port)
1221
+ // re-inject the startup message that we consumed for detection
1222
+ if (firstEvent.data instanceof ArrayBuffer) {
1223
+ injectMessage(new Uint8Array(firstEvent.data))
1224
+ } else if (firstEvent.data instanceof Uint8Array) {
1225
+ injectMessage(firstEvent.data)
1226
+ }
1227
+
1228
+ // opaque identity token for this connection (used for tx state ownership)
1229
+ const connId = {}
1230
+
1231
+ let dbName = 'postgres'
1232
+ let isReplicationConnection = false
1233
+ // track extended protocol writes (Parse with INSERT/UPDATE/DELETE/COPY/TRUNCATE)
1234
+ // so we can signal replication on Sync (0x53) after the pipeline completes
1235
+ let extWritePending = false
1236
+ // hold mutex across entire extended protocol pipeline (Parse→Sync).
1237
+ // prevents other connections from interleaving and corrupting PGlite's
1238
+ // unnamed portal/statement state during the pipeline.
1239
+ let pipelineMutexHeld = false
1240
+ // connection closed flag
1241
+ let connClosed = false
1242
+
1243
+ // clean up pglite transaction state when the connection ends
1244
+ const cleanup = async () => {
1245
+ if (connClosed) return
1246
+ connClosed = true
1247
+ // replication connections don't own a transaction — skip ROLLBACK
1248
+ if (isReplicationConnection) return
1249
+ try {
1250
+ const { db, mutex } = getDbContext(dbName)
1251
+ await mutex.acquire()
1252
+ try {
1253
+ await db.exec('ROLLBACK')
1254
+ } catch {
1255
+ // no transaction to rollback, or db is closed
1256
+ } finally {
1257
+ mutex.release()
1258
+ }
1259
+ } catch {
1260
+ // instance may have been replaced during reset, ignore
1261
+ }
1262
+ }
1263
+
1264
+ try {
1265
+ let connection!: PostgresConnection
1266
+ connection = new PostgresConnection(duplex, {
1267
+ serverVersion: '17.4',
1268
+ auth: {
1269
+ method: 'password',
1270
+ getClearTextPassword() {
1271
+ return config.pgPassword
1272
+ },
1273
+ validateCredentials(credentials: {
1274
+ username: string
1275
+ password: string
1276
+ clearTextPassword: string
1277
+ }) {
1278
+ return (
1279
+ credentials.password === credentials.clearTextPassword &&
1280
+ credentials.username === config.pgUser
1281
+ )
1282
+ },
1283
+ },
1284
+
1285
+ // send ParameterStatus messages that standard postgres tools expect
1286
+ // pg-gateway sends server_version via the serverVersion option above,
1287
+ // but tools like pg_restore also need encoding, datestyle, etc.
1288
+ // write directly to the port since pg-gateway owns the writable stream
1289
+ onAuthenticated() {
1290
+ console.debug(`[pg-proxy-conn] authenticated db=${dbName}`)
1291
+ for (const [name, value] of SERVER_PARAMS) {
1292
+ rawWrite(buildParameterStatus(name, value))
1293
+ }
1294
+ },
1295
+
1296
+ async onStartup(state) {
1297
+ const params = state.clientParams
1298
+ if (params?.replication === 'database') {
1299
+ isReplicationConnection = true
1300
+ }
1301
+ dbName = params?.database || 'postgres'
1302
+ console.debug(
1303
+ `[pg-proxy-conn] startup: db=${dbName} user=${params?.user} repl=${params?.replication || 'none'}`
1304
+ )
1305
+ const { db } = getDbContext(dbName)
1306
+ await db.waitReady
1307
+ },
1308
+
1309
+ async onMessage(data, state) {
1310
+ if (!state.isAuthenticated) {
1311
+ console.debug(
1312
+ `[pg-proxy-conn] msg before auth, type=0x${data[0].toString(16)}`
1313
+ )
1314
+ return
1315
+ }
1316
+ console.debug(
1317
+ `[pg-proxy-conn] msg db=${dbName} type=0x${data[0].toString(16)} len=${data.length}`
1318
+ )
1319
+
1320
+ // handle replication connections (always go to postgres instance)
1321
+ if (isReplicationConnection) {
1322
+ if (data[0] === 0x51) {
1323
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
1324
+ const len = view.getInt32(1)
1325
+ const query = textDecoder
1326
+ .decode(data.subarray(5, 1 + len - 1))
1327
+ .replace(/\0$/, '')
1328
+ log.debug.proxy(`repl query: ${query.slice(0, 200)}`)
1329
+ }
1330
+ return handleReplicationMessageBrowser(
1331
+ data,
1332
+ rawWrite,
1333
+ () => connClosed,
1334
+ () => {
1335
+ connClosed = true
1336
+ port.close()
1337
+ },
1338
+ instances.postgres,
1339
+ mutexes.postgres,
1340
+ connection
1341
+ )
1342
+ }
1343
+
1344
+ const msgType = data[0]
1345
+ const { db, mutex, txState } = getDbContext(dbName)
1346
+
1347
+ // extended protocol pipeline: hold mutex across Parse→Sync to prevent
1348
+ // other connections from interleaving and corrupting unnamed portal state.
1349
+ // 0x50=Parse, 0x42=Bind, 0x44=Describe, 0x45=Execute, 0x43=Close, 0x48=Flush
1350
+ const isExtendedMsg =
1351
+ msgType === 0x50 ||
1352
+ msgType === 0x42 ||
1353
+ msgType === 0x44 ||
1354
+ msgType === 0x45 ||
1355
+ msgType === 0x43 ||
1356
+ msgType === 0x48
1357
+ const isSyncInPipeline = msgType === 0x53 && pipelineMutexHeld
1358
+
1359
+ if (isExtendedMsg || isSyncInPipeline) {
1360
+ // acquire mutex on first message of pipeline
1361
+ if (!pipelineMutexHeld) {
1362
+ const t0 = performance.now()
1363
+ await mutex.acquire()
1364
+ proxyStats.totalWaitMs += performance.now() - t0
1365
+ pipelineMutexHeld = true
1366
+ // auto-rollback stale transactions from other connections
1367
+ if (txState.status === 0x45 && txState.owner !== connId) {
1368
+ try {
1369
+ await db.exec('ROLLBACK')
1370
+ } catch {}
1371
+ txState.status = 0x49
1372
+ txState.owner = null
1373
+ }
1374
+ }
1375
+
1376
+ // detect extended protocol writes for replication signaling
1377
+ if (dbName === 'postgres' && msgType === 0x50) {
1378
+ const q = extractParseQuery(data)?.trimStart().toLowerCase()
1379
+ if (q && /^(insert|update|delete|copy|truncate)/.test(q)) {
1380
+ extWritePending = true
1381
+ log.debug.proxy(`ext-write: detected ${q.slice(0, 40)}`)
1382
+ }
1383
+ }
1384
+
1385
+ // apply query rewrites
1386
+ data = interceptQuery(data)
1387
+
1388
+ const t1 = performance.now()
1389
+ let result: Uint8Array
1390
+ try {
1391
+ result = await db.execProtocolRaw(data, { syncToFs: false })
1392
+ } catch (err) {
1393
+ mutex.release()
1394
+ pipelineMutexHeld = false
1395
+ throw err
1396
+ }
1397
+ const t2 = performance.now()
1398
+ proxyStats.totalExecMs += t2 - t1
1399
+ proxyStats.count++
1400
+
1401
+ // update transaction state
1402
+ const rfqStatus = getReadyForQueryStatus(result)
1403
+ if (rfqStatus !== null) {
1404
+ txState.status = rfqStatus
1405
+ txState.owner = rfqStatus === 0x49 ? null : connId
1406
+ }
1407
+
1408
+ // release mutex on Sync (end of pipeline)
1409
+ if (msgType === 0x53) {
1410
+ mutex.release()
1411
+ pipelineMutexHeld = false
1412
+ proxyStats.batches++
1413
+
1414
+ // signal replication handler on postgres writes
1415
+ if (dbName === 'postgres' && extWritePending) {
1416
+ extWritePending = false
1417
+ signalWrite()
1418
+ }
1419
+ } else {
1420
+ // strip ReadyForQuery from non-Sync pipeline messages
1421
+ // result = stripResponseMessages(result, true) // disabled for debugging
1422
+ }
1423
+
1424
+ if (proxyStats.count % 200 === 0) {
1425
+ log.debug.proxy(
1426
+ `perf: ${proxyStats.count} ops (${proxyStats.batches} batches) | mutex ${proxyStats.totalWaitMs.toFixed(0)}ms | pglite ${proxyStats.totalExecMs.toFixed(0)}ms`
1427
+ )
1428
+ }
1429
+
1430
+ return result
1431
+ }
1432
+
1433
+ // Simple Query (0x51) or standalone Sync — per-message mutex
1434
+
1435
+ // fast-path for ping queries (SELECT 1, SELECT 2, etc.)
1436
+ // zero-cache fires these in parallel during warmup — bypass mutex entirely
1437
+ if (msgType === 0x51) {
1438
+ const queryText = extractQueryText(data)
1439
+ if (queryText) {
1440
+ const pingMatch = queryText.match(PING_QUERY_RE)
1441
+ if (pingMatch) {
1442
+ return buildSelectIntResponse(pingMatch[1])
1443
+ }
1444
+ }
1445
+ }
1446
+
1447
+ // check for no-op queries (only SimpleQuery has queries worth intercepting)
1448
+ if (isNoopQuery(data)) {
1449
+ if (msgType === 0x51) {
1450
+ return buildSetCompleteResponse()
1451
+ }
1452
+ }
1453
+
1454
+ // intercept and rewrite queries
1455
+ data = interceptQuery(data)
1456
+
1457
+ // normalize query once for all classification checks
1458
+ const isSimpleQuery = msgType === 0x51
1459
+ const queryText = isSimpleQuery ? extractQueryText(data) : null
1460
+ const queryNorm = queryText ? queryText.trimStart().toLowerCase() : null
1461
+ const cacheable = queryNorm && isCacheableNormalized(queryNorm)
1462
+
1463
+ // cache Simple Query schema queries
1464
+ if (cacheable) {
1465
+ const cached = schemaQueryCache.get(queryText!)
1466
+ if (cached && Date.now() < cached.expiresAt) {
1467
+ return stripResponseMessages(cached.result, false)
1468
+ }
1469
+ const inflight = schemaQueryInFlight.get(queryText!)
1470
+ if (inflight) {
1471
+ return stripResponseMessages(await inflight, false)
1472
+ }
1473
+ }
1474
+
1475
+ const execute = async (): Promise<Uint8Array> => {
1476
+ const t0 = performance.now()
1477
+ await mutex.acquire()
1478
+ if (txState.status === 0x45 && txState.owner !== connId) {
1479
+ try {
1480
+ await db.exec('ROLLBACK')
1481
+ } catch {}
1482
+ txState.status = 0x49
1483
+ txState.owner = null
1484
+ }
1485
+ const t1 = performance.now()
1486
+ let result: Uint8Array
1487
+ try {
1488
+ result = await db.execProtocolRaw(data, { syncToFs: false })
1489
+ } catch (err) {
1490
+ mutex.release()
1491
+ throw err
1492
+ }
1493
+ const rfqStatus = getReadyForQueryStatus(result)
1494
+ if (rfqStatus !== null) {
1495
+ txState.status = rfqStatus
1496
+ txState.owner = rfqStatus === 0x49 ? null : connId
1497
+ }
1498
+ const t2 = performance.now()
1499
+ mutex.release()
1500
+ proxyStats.totalWaitMs += t1 - t0
1501
+ proxyStats.totalExecMs += t2 - t1
1502
+ proxyStats.count++
1503
+ if (proxyStats.count % 200 === 0) {
1504
+ log.debug.proxy(
1505
+ `perf: ${proxyStats.count} ops (${proxyStats.batches} batches) | mutex ${proxyStats.totalWaitMs.toFixed(0)}ms | pglite ${proxyStats.totalExecMs.toFixed(0)}ms`
1506
+ )
1507
+ }
1508
+ return result
1509
+ }
1510
+
1511
+ let result: Uint8Array
1512
+ if (cacheable) {
1513
+ const promise = execute()
1514
+ schemaQueryInFlight.set(queryText!, promise)
1515
+ try {
1516
+ result = await promise
1517
+ schemaQueryCache.set(queryText!, {
1518
+ result,
1519
+ expiresAt: Date.now() + SCHEMA_CACHE_TTL_MS,
1520
+ })
1521
+ } finally {
1522
+ schemaQueryInFlight.delete(queryText!)
1523
+ }
1524
+ } else {
1525
+ result = await execute()
1526
+ if (queryNorm && isDDLNormalized(queryNorm)) {
1527
+ invalidateSchemaCache()
1528
+ }
1529
+ }
1530
+
1531
+ const stripRfq = msgType !== 0x53 && msgType !== 0x51
1532
+ result = stripResponseMessages(result, stripRfq)
1533
+
1534
+ // signal replication handler on postgres writes for instant sync
1535
+ if (dbName === 'postgres' && queryNorm && isWriteNormalized(queryNorm)) {
1536
+ signalReplicationChange()
1537
+ }
1538
+
1539
+ return result
1540
+ },
1541
+ })
1542
+
1543
+ // when the pg-gateway connection's readable stream ends (port closed),
1544
+ // run cleanup. the PostgresConnection constructor starts init() which
1545
+ // reads from duplex.readable — when the port closes, the readable ends
1546
+ // and init() resolves, but there's no explicit "close" callback.
1547
+ // we rely on the readable stream ending to trigger cleanup.
1548
+ // the readable's cancel() calls port.close(), but if the port is closed
1549
+ // externally, the readable controller will error/close and init resolves.
1550
+ void (async () => {
1551
+ // wait for the connection to finish processing
1552
+ // PostgresConnection.init() returns when the readable stream ends
1553
+ try {
1554
+ // small delay to allow init() to start (constructor kicks it off synchronously)
1555
+ await new Promise((r) => setTimeout(r, 0))
1556
+ // poll until the connection is detached or the port signals close
1557
+ // since MessagePort has no 'close' event, we detect when
1558
+ // the connection's internal processing ends
1559
+ } catch {
1560
+ // ignore
1561
+ }
1562
+ cleanup()
1563
+ })()
1564
+ } catch {
1565
+ cleanup()
1566
+ }
1567
+ }
1568
+
1569
+ return {
1570
+ handleConnection,
1571
+ close() {
1572
+ closed = true
1573
+ signalPending = false
1574
+ },
1575
+ }
1576
+ }
1577
+
1578
+ async function handleReplicationMessageBrowser(
1579
+ data: Uint8Array,
1580
+ rawWrite: (data: Uint8Array) => void,
1581
+ isClosed: () => boolean,
1582
+ closeConn: () => void,
1583
+ db: PGlite,
1584
+ mutex: Mutex,
1585
+ connection: PostgresConnection
1586
+ ): Promise<Uint8Array | undefined> {
1587
+ console.debug(`[pg-proxy-repl] ENTRY type=0x${data[0].toString(16)} len=${data.length}`)
1588
+
1589
+ // for non-SimpleQuery messages (extended protocol), execute against PGlite directly.
1590
+ if (data[0] !== 0x51) {
1591
+ console.debug(
1592
+ `[pg-proxy-repl] ext protocol msg type=0x${data[0].toString(16)} len=${data.length}`
1593
+ )
1594
+ await mutex.acquire()
1595
+ try {
1596
+ const result = await db.execProtocolRaw(data, { syncToFs: false })
1597
+ console.debug(`[pg-proxy-repl] ext protocol result len=${result.length}`)
1598
+ return result
1599
+ } finally {
1600
+ mutex.release()
1601
+ }
1602
+ }
1603
+
1604
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
1605
+ const len = view.getInt32(1)
1606
+ const query = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
1607
+ const upper = query.trim().toUpperCase()
1608
+
1609
+ // check if this is a START_REPLICATION command
1610
+ if (upper.startsWith('START_REPLICATION')) {
1611
+ await connection.detach()
1612
+
1613
+ // abort any previous replication handler to prevent zombies
1614
+ if (abortPreviousReplication) {
1615
+ log.proxy('aborting previous replication handler')
1616
+ abortPreviousReplication()
1617
+ }
1618
+
1619
+ let aborted = false
1620
+ const writer = {
1621
+ write(chunk: Uint8Array) {
1622
+ if (!isClosed() && !aborted) {
1623
+ try {
1624
+ rawWrite(chunk)
1625
+ } catch {
1626
+ // port may have closed between our check and write
1627
+ aborted = true
1628
+ }
1629
+ }
1630
+ },
1631
+ get closed() {
1632
+ return isClosed() || aborted
1633
+ },
1634
+ }
1635
+
1636
+ const abort = () => {
1637
+ aborted = true
1638
+ closeConn()
1639
+ }
1640
+ abortPreviousReplication = abort
1641
+
1642
+ handleStartReplication(query, writer, db, mutex).catch((err) => {
1643
+ log.proxy(`replication stream ended: ${err}`)
1644
+ })
1645
+ return undefined
1646
+ }
1647
+
1648
+ // handle replication queries + fallthrough to pglite, all under mutex
1649
+ console.debug(`[pg-proxy-repl] query: ${query.slice(0, 100)}`)
1650
+ console.debug(`[pg-proxy-repl] acquiring mutex...`)
1651
+ await mutex.acquire()
1652
+ console.debug(`[pg-proxy-repl] mutex acquired, testing db access...`)
1653
+ try {
1654
+ const testResult = await db.query('SELECT 1 as test')
1655
+ console.debug(`[pg-proxy-repl] db.query works: ${JSON.stringify(testResult.rows)}`)
1656
+ const response = await handleReplicationQuery(query, db)
1657
+ console.debug(
1658
+ `[pg-proxy-repl] handleReplicationQuery result: ${response ? 'bytes(' + response.length + ')' : 'null'}`
1659
+ )
1660
+ if (response) return response
1661
+
1662
+ // apply query rewrites before forwarding
1663
+ data = interceptQuery(data)
1664
+
1665
+ // fall through to pglite for unrecognized queries
1666
+ const result = await db.execProtocolRaw(data, {
1667
+ throwOnError: false,
1668
+ })
1669
+ return stripResponseMessages(result, false)
1670
+ } finally {
1671
+ mutex.release()
1672
+ }
1673
+ }