orez 0.2.27 → 0.2.30

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