orez 0.2.26 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/cf-do/worker.d.ts.map +1 -1
  2. package/dist/cf-do/worker.js +9 -1
  3. package/dist/cf-do/worker.js.map +1 -1
  4. package/dist/pg-proxy-do-backend.d.ts +2 -0
  5. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  6. package/dist/pg-proxy-do-backend.js +49 -7
  7. package/dist/pg-proxy-do-backend.js.map +1 -1
  8. package/dist/pg-sqlite-compiler/catalog/seed.d.ts +67 -0
  9. package/dist/pg-sqlite-compiler/catalog/seed.d.ts.map +1 -0
  10. package/dist/pg-sqlite-compiler/catalog/seed.js +436 -0
  11. package/dist/pg-sqlite-compiler/catalog/seed.js.map +1 -0
  12. package/dist/pg-sqlite-compiler/index.d.ts +12 -0
  13. package/dist/pg-sqlite-compiler/index.d.ts.map +1 -0
  14. package/dist/pg-sqlite-compiler/index.js +59 -0
  15. package/dist/pg-sqlite-compiler/index.js.map +1 -0
  16. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts +48 -0
  17. package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts.map +1 -0
  18. package/dist/pg-sqlite-compiler/passes/ast-utils.js +93 -0
  19. package/dist/pg-sqlite-compiler/passes/ast-utils.js.map +1 -0
  20. package/dist/pg-sqlite-compiler/passes/catalog.d.ts +34 -0
  21. package/dist/pg-sqlite-compiler/passes/catalog.d.ts.map +1 -0
  22. package/dist/pg-sqlite-compiler/passes/catalog.js +30 -0
  23. package/dist/pg-sqlite-compiler/passes/catalog.js.map +1 -0
  24. package/dist/pg-sqlite-compiler/passes/datetime.d.ts +21 -0
  25. package/dist/pg-sqlite-compiler/passes/datetime.d.ts.map +1 -0
  26. package/dist/pg-sqlite-compiler/passes/datetime.js +53 -0
  27. package/dist/pg-sqlite-compiler/passes/datetime.js.map +1 -0
  28. package/dist/pg-sqlite-compiler/passes/index.d.ts +21 -0
  29. package/dist/pg-sqlite-compiler/passes/index.d.ts.map +1 -0
  30. package/dist/pg-sqlite-compiler/passes/index.js +39 -0
  31. package/dist/pg-sqlite-compiler/passes/index.js.map +1 -0
  32. package/dist/pg-sqlite-compiler/passes/types.d.ts +41 -0
  33. package/dist/pg-sqlite-compiler/passes/types.d.ts.map +1 -0
  34. package/dist/pg-sqlite-compiler/passes/types.js +103 -0
  35. package/dist/pg-sqlite-compiler/passes/types.js.map +1 -0
  36. package/dist/pg-sqlite-compiler/test/oracle.d.ts +34 -0
  37. package/dist/pg-sqlite-compiler/test/oracle.d.ts.map +1 -0
  38. package/dist/pg-sqlite-compiler/test/oracle.js +204 -0
  39. package/dist/pg-sqlite-compiler/test/oracle.js.map +1 -0
  40. package/dist/pg-sqlite-compiler/types.d.ts +55 -0
  41. package/dist/pg-sqlite-compiler/types.d.ts.map +1 -0
  42. package/dist/pg-sqlite-compiler/types.js +2 -0
  43. package/dist/pg-sqlite-compiler/types.js.map +1 -0
  44. package/package.json +8 -4
  45. package/src/admin/admin-data.test.ts +0 -348
  46. package/src/admin/http-proxy.ts +0 -252
  47. package/src/admin/log-store.ts +0 -192
  48. package/src/admin/server.ts +0 -471
  49. package/src/admin/ui.ts +0 -1322
  50. package/src/bench/proxy-throughput.bench.ts +0 -343
  51. package/src/bench/serial-mutations.bench.ts +0 -270
  52. package/src/browser.ts +0 -203
  53. package/src/cf-do/.wrangler/cache/cf.json +0 -1
  54. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  55. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  56. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  57. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
  58. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
  59. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite +0 -0
  60. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
  61. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
  62. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
  63. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
  64. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
  65. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
  66. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
  67. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
  68. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
  69. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
  70. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
  71. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
  72. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  73. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  74. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  75. package/src/cf-do/ARCHITECTURE.md +0 -83
  76. package/src/cf-do/watermark.test.ts +0 -103
  77. package/src/cf-do/watermark.ts +0 -118
  78. package/src/cf-do/worker.ts +0 -1033
  79. package/src/cf-do/wrangler.toml +0 -11
  80. package/src/cf-pglite/README.md +0 -19
  81. package/src/change-tracking.ts +0 -25
  82. package/src/child-process.test.ts +0 -147
  83. package/src/child-process.ts +0 -90
  84. package/src/cli-entry.ts +0 -72
  85. package/src/cli.test.ts +0 -38
  86. package/src/cli.ts +0 -1214
  87. package/src/config.ts +0 -150
  88. package/src/do-sql-tracking.test.ts +0 -19
  89. package/src/do-sql-tracking.ts +0 -19
  90. package/src/index.ts +0 -1215
  91. package/src/integration/integration.test.ts +0 -517
  92. package/src/integration/native-binary.guard.test.ts +0 -13
  93. package/src/integration/native-startup.test.ts +0 -44
  94. package/src/integration/replication-latency.test.ts +0 -428
  95. package/src/integration/restore-live-stress.test.ts +0 -433
  96. package/src/integration/restore-reset.test.ts +0 -400
  97. package/src/integration/restore.test.ts +0 -274
  98. package/src/integration/test-permissions.ts +0 -147
  99. package/src/load-config.ts +0 -46
  100. package/src/log.ts +0 -96
  101. package/src/mutex.ts +0 -47
  102. package/src/pg-proxy-browser.singledb.test.ts +0 -233
  103. package/src/pg-proxy-browser.ts +0 -2022
  104. package/src/pg-proxy-do-backend.test.ts +0 -3890
  105. package/src/pg-proxy-do-backend.ts +0 -7157
  106. package/src/pg-proxy.ts +0 -1087
  107. package/src/pglite-ipc.test.ts +0 -116
  108. package/src/pglite-ipc.ts +0 -266
  109. package/src/pglite-manager.ts +0 -557
  110. package/src/pglite-web-proxy.test.ts +0 -57
  111. package/src/pglite-web-proxy.ts +0 -221
  112. package/src/pglite-web-worker.ts +0 -152
  113. package/src/pglite-worker-thread.ts +0 -253
  114. package/src/port.ts +0 -25
  115. package/src/process-title.ts +0 -9
  116. package/src/recovery.ts +0 -155
  117. package/src/replication/change-tracker.test.ts +0 -357
  118. package/src/replication/change-tracker.ts +0 -279
  119. package/src/replication/handler.test.ts +0 -511
  120. package/src/replication/handler.ts +0 -1190
  121. package/src/replication/pgoutput-encoder.test.ts +0 -697
  122. package/src/replication/pgoutput-encoder.ts +0 -373
  123. package/src/replication/tcp-replication.test.ts +0 -876
  124. package/src/replication/zero-compat.test.ts +0 -1150
  125. package/src/restore-stress.test.ts +0 -188
  126. package/src/s3-local.ts +0 -203
  127. package/src/shim/hooks.mjs +0 -120
  128. package/src/shim/register.mjs +0 -4
  129. package/src/sqlite-mode/apply-mode.ts +0 -224
  130. package/src/sqlite-mode/index.ts +0 -15
  131. package/src/sqlite-mode/native-binary.ts +0 -89
  132. package/src/sqlite-mode/package-resolve.ts +0 -17
  133. package/src/sqlite-mode/resolve-mode.ts +0 -80
  134. package/src/sqlite-mode/shim-template.ts +0 -159
  135. package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
  136. package/src/sqlite-mode/types.ts +0 -30
  137. package/src/vite-plugin.ts +0 -67
  138. package/src/wasm-sqlite.test.ts +0 -537
  139. package/src/worker/browser-admin.ts +0 -52
  140. package/src/worker/browser-build-config.test.ts +0 -71
  141. package/src/worker/browser-build-config.ts +0 -109
  142. package/src/worker/browser-embed-admin.test.ts +0 -75
  143. package/src/worker/browser-embed.ts +0 -345
  144. package/src/worker/cf-patches.ts +0 -384
  145. package/src/worker/embed-integration.test.ts +0 -321
  146. package/src/worker/index.ts +0 -138
  147. package/src/worker/shims/fastify.test.ts +0 -255
  148. package/src/worker/shims/fastify.ts +0 -306
  149. package/src/worker/shims/http-service.test.ts +0 -355
  150. package/src/worker/shims/http-service.ts +0 -293
  151. package/src/worker/shims/node-stub.ts +0 -290
  152. package/src/worker/shims/oxfmt.ts +0 -3
  153. package/src/worker/shims/postgres-browser.ts +0 -59
  154. package/src/worker/shims/postgres-socket.test.ts +0 -576
  155. package/src/worker/shims/postgres-socket.ts +0 -310
  156. package/src/worker/shims/postgres.test.ts +0 -364
  157. package/src/worker/shims/postgres.ts +0 -1454
  158. package/src/worker/shims/sqlite-browser.test.ts +0 -233
  159. package/src/worker/shims/sqlite-browser.ts +0 -175
  160. package/src/worker/shims/sqlite.test.ts +0 -786
  161. package/src/worker/shims/sqlite.ts +0 -978
  162. package/src/worker/shims/stream-browser.ts +0 -15
  163. package/src/worker/shims/ws-browser.test.ts +0 -205
  164. package/src/worker/shims/ws-browser.ts +0 -248
  165. package/src/worker/shims/ws.test.ts +0 -288
  166. package/src/worker/shims/ws.ts +0 -467
  167. package/src/worker/shims/zero-process-env.ts +0 -11
  168. package/src/worker/types.ts +0 -75
  169. package/src/worker/worker-integration.test.ts +0 -223
  170. package/src/worker/worker.test.ts +0 -136
  171. package/src/worker/zero-cache-embed-cf.ts +0 -463
  172. package/src/worker/zero-cache-embed.ts +0 -277
package/src/pg-proxy.ts DELETED
@@ -1,1087 +0,0 @@
1
- /**
2
- * tcp proxy that makes pglite speak postgresql wire protocol.
3
- *
4
- * uses pg-gateway to handle protocol lifecycle for regular connections,
5
- * and directly handles the raw socket for replication connections.
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 { createServer, type Server, type Socket } from 'node:net'
16
-
17
- import { fromNodeSocket } from 'pg-gateway/node'
18
-
19
- import { log } from './log.js'
20
- import { Mutex } from './mutex.js'
21
- import {
22
- handleReplicationQuery,
23
- handleStartReplication,
24
- signalReplicationChange,
25
- } from './replication/handler.js'
26
-
27
- import type { ZeroLiteConfig } from './config.js'
28
- import type { PGliteInstances } from './pglite-manager.js'
29
- import type { PGlite } from '@electric-sql/pglite'
30
-
31
- // shared encoder/decoder instances
32
- const textEncoder = new TextEncoder()
33
- const textDecoder = new TextDecoder()
34
-
35
- // schema query cache: identical information_schema/catalog queries from multiple
36
- // zero-cache clients are deduplicated. first query executes, all others get cached result.
37
- interface CachedQueryResult {
38
- result: Uint8Array
39
- expiresAt: number
40
- }
41
- const schemaQueryCache = new Map<string, CachedQueryResult>()
42
- const schemaQueryInFlight = new Map<string, Promise<Uint8Array>>()
43
- const SCHEMA_CACHE_TTL_MS = 30_000
44
-
45
- // performance tracking
46
- const proxyStats = { totalWaitMs: 0, totalExecMs: 0, count: 0, batches: 0 }
47
-
48
- // query classification cache — avoids re-running regex on every repeated query.
49
- // keys are trimmed+lowercased original query texts (before rewrites).
50
- // entries are invalidated on DDL (schema changes may affect classification).
51
- interface QueryClass {
52
- isWrite: boolean
53
- isDDL: boolean
54
- isCacheable: boolean
55
- }
56
- const queryClassCache = new Map<string, QueryClass>()
57
- const QUERY_CLASS_CACHE_MAX = 500
58
-
59
- // query classification helpers — operate on pre-normalized (trimmed+lowercased) query strings
60
- const SCHEMA_QUERY_MARKERS = [
61
- 'information_schema.',
62
- 'pg_catalog.',
63
- 'pg_tables',
64
- 'pg_namespace',
65
- 'pg_class',
66
- 'pg_attribute',
67
- 'pg_type',
68
- 'pg_publication',
69
- ]
70
- const WRITE_PREFIXES = ['insert', 'update', 'delete', 'copy', 'truncate']
71
- const DDL_PREFIXES = ['create', 'alter', 'drop']
72
- const MUTATING_PREFIXES = [...WRITE_PREFIXES, ...DDL_PREFIXES]
73
-
74
- function isCacheableNormalized(q: string): boolean {
75
- // fast-fail: mutating queries are never cacheable
76
- for (const p of MUTATING_PREFIXES) {
77
- if (q.startsWith(p)) return false
78
- }
79
- // check if it touches schema/catalog tables
80
- for (const marker of SCHEMA_QUERY_MARKERS) {
81
- if (q.includes(marker)) return true
82
- }
83
- return false
84
- }
85
-
86
- function isWriteNormalized(q: string): boolean {
87
- for (const p of WRITE_PREFIXES) {
88
- if (q.startsWith(p)) return true
89
- }
90
- return false
91
- }
92
-
93
- function isDDLNormalized(q: string): boolean {
94
- for (const p of DDL_PREFIXES) {
95
- if (q.startsWith(p)) return true
96
- }
97
- return false
98
- }
99
-
100
- /**
101
- * classify a query and cache the result.
102
- * repeated queries (common in app workloads) hit the cache and skip all regex.
103
- * invalidated on DDL (schema changes may reclassify queries).
104
- */
105
- function classifyQuery(q: string): QueryClass {
106
- const cached = queryClassCache.get(q)
107
- if (cached) return cached
108
-
109
- const result: QueryClass = {
110
- isWrite: isWriteNormalized(q),
111
- isDDL: isDDLNormalized(q),
112
- isCacheable: isCacheableNormalized(q),
113
- }
114
-
115
- // LRU-ish eviction: clear oldest half when full
116
- if (queryClassCache.size >= QUERY_CLASS_CACHE_MAX) {
117
- const keys = [...queryClassCache.keys()]
118
- for (let i = 0; i < Math.floor(keys.length / 2); i++) {
119
- queryClassCache.delete(keys[i])
120
- }
121
- }
122
-
123
- queryClassCache.set(q, result)
124
- return result
125
- }
126
-
127
- function extractQueryText(data: Uint8Array): string | null {
128
- if (data[0] === 0x51) {
129
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
130
- const len = view.getInt32(1)
131
- return textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
132
- }
133
- if (data[0] === 0x50) {
134
- return extractParseQuery(data)
135
- }
136
- return null
137
- }
138
-
139
- function invalidateSchemaCache() {
140
- schemaQueryCache.clear()
141
- queryClassCache.clear()
142
- }
143
-
144
- // abort previous replication handler when a new one starts
145
- let abortPreviousReplication: (() => void) | null = null
146
-
147
- // clean version string: strip emscripten compiler info that breaks pg_restore/pg_dump
148
- const PG_VERSION_STRING =
149
- "'PostgreSQL 17.4 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 12.2.0, 64-bit'"
150
-
151
- // query rewrites: make pglite look like real postgres with logical replication
152
- const QUERY_REWRITES: Array<{ match: RegExp; replace: string }> = [
153
- // version() — return a standard-looking version string instead of the emscripten one
154
- {
155
- match: /\bversion\(\)/gi,
156
- replace: PG_VERSION_STRING,
157
- },
158
- // wal_level check
159
- {
160
- match: /current_setting\s*\(\s*'wal_level'\s*\)/gi,
161
- replace: "'logical'::text",
162
- },
163
- // strip READ ONLY from BEGIN (pglite is single-session, no read-only transactions)
164
- {
165
- match: /\bREAD\s+ONLY\b/gi,
166
- replace: '',
167
- },
168
- // strip ISOLATION LEVEL from any query (pglite is single-session, isolation is meaningless)
169
- // catches: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE, BEGIN ISOLATION LEVEL SERIALIZABLE, etc.
170
- {
171
- match:
172
- /\bISOLATION\s+LEVEL\s+(SERIALIZABLE|REPEATABLE\s+READ|READ\s+COMMITTED|READ\s+UNCOMMITTED)\b/gi,
173
- replace: '',
174
- },
175
- // strip bare SET TRANSACTION (after ISOLATION LEVEL is removed, this becomes a no-op statement)
176
- {
177
- match: /\bSET\s+TRANSACTION\s*;/gi,
178
- replace: ';',
179
- },
180
- // redirect pg_replication_slots to our fake table in _orez schema
181
- {
182
- match: /\bpg_replication_slots\b/g,
183
- replace: '_orez._zero_replication_slots',
184
- },
185
- // rewrite pg_drop_replication_slot() calls to DELETE from the fake table.
186
- // PGlite doesn't have real replication slots, so the built-in function errors.
187
- // this runs AFTER the table rewrite above, so the table name is already replaced.
188
- {
189
- match:
190
- /SELECT\s+pg_drop_replication_slot\(slot_name\)\s+FROM\s+_orez\._zero_replication_slots/gi,
191
- replace: 'DELETE FROM _orez._zero_replication_slots',
192
- },
193
- // pg_terminate_backend on replication slots — PGlite is single-process, there are
194
- // no backends to terminate. rewrite to a plain SELECT so zero-cache sees the slots
195
- // but doesn't call the unsupported function.
196
- {
197
- match:
198
- /pg_terminate_backend\(active_pid\)\s+as\s+terminated,\s*active_pid\s+as\s+pid/gi,
199
- replace: 'false as terminated, NULL::int as pid',
200
- },
201
- ]
202
-
203
- // parameter status messages sent during connection handshake
204
- // pg_restore and other tools read these to determine server capabilities
205
- const SERVER_PARAMS: [string, string][] = [
206
- ['server_encoding', 'UTF8'],
207
- ['client_encoding', 'UTF8'],
208
- ['DateStyle', 'ISO, MDY'],
209
- ['integer_datetimes', 'on'],
210
- ['standard_conforming_strings', 'on'],
211
- ['TimeZone', 'UTC'],
212
- ['IntervalStyle', 'postgres'],
213
- ]
214
-
215
- // build a ParameterStatus wire protocol message (type 'S', 0x53)
216
- function buildParameterStatus(name: string, value: string): Uint8Array {
217
- const encoder = textEncoder
218
- const nameBytes = encoder.encode(name)
219
- const valueBytes = encoder.encode(value)
220
- const len = 4 + nameBytes.length + 1 + valueBytes.length + 1
221
- const buf = new Uint8Array(1 + len)
222
- buf[0] = 0x53 // 'S'
223
- new DataView(buf.buffer).setInt32(1, len)
224
- let pos = 5
225
- buf.set(nameBytes, pos)
226
- pos += nameBytes.length
227
- buf[pos++] = 0
228
- buf.set(valueBytes, pos)
229
- pos += valueBytes.length
230
- buf[pos] = 0
231
- return buf
232
- }
233
-
234
- // queries to intercept and return no-op success (synthetic SET response)
235
- // pglite rejects SET TRANSACTION if any query (e.g. SET search_path) ran first
236
- const NOOP_QUERY_PATTERNS: RegExp[] = [/^\s*SET\s+TRANSACTION\b/i, /^\s*SET\s+SESSION\b/i]
237
-
238
- // ping queries (SELECT 1, SELECT 2, etc.) — respond synthetically to avoid
239
- // mutex contention during zero-cache connection warmup
240
- const PING_QUERY_RE = /^\s*SELECT\s+(\d+)\s*$/i
241
-
242
- /**
243
- * extract query text from a Parse message (0x50).
244
- */
245
- function extractParseQuery(data: Uint8Array): string | null {
246
- if (data[0] !== 0x50) return null
247
- let offset = 5
248
- while (offset < data.length && data[offset] !== 0) offset++
249
- offset++
250
- const queryStart = offset
251
- while (offset < data.length && data[offset] !== 0) offset++
252
- return textDecoder.decode(data.subarray(queryStart, offset))
253
- }
254
-
255
- /**
256
- * rebuild a Parse message with a modified query string.
257
- */
258
- function rebuildParseMessage(data: Uint8Array, newQuery: string): Uint8Array {
259
- let offset = 5
260
- while (offset < data.length && data[offset] !== 0) offset++
261
- const nameEnd = offset + 1
262
- const nameBytes = data.subarray(5, nameEnd)
263
-
264
- offset = nameEnd
265
- while (offset < data.length && data[offset] !== 0) offset++
266
- offset++
267
-
268
- const suffix = data.subarray(offset)
269
- const encoder = textEncoder
270
- const queryBytes = encoder.encode(newQuery)
271
-
272
- const totalLen = 4 + nameBytes.length + queryBytes.length + 1 + suffix.length
273
- const result = new Uint8Array(1 + totalLen)
274
- const dv = new DataView(result.buffer)
275
- result[0] = 0x50
276
- dv.setInt32(1, totalLen)
277
- let pos = 5
278
- result.set(nameBytes, pos)
279
- pos += nameBytes.length
280
- result.set(queryBytes, pos)
281
- pos += queryBytes.length
282
- result[pos++] = 0
283
- result.set(suffix, pos)
284
- return result
285
- }
286
-
287
- /**
288
- * rebuild a Simple Query message with a modified query string.
289
- */
290
- function rebuildSimpleQuery(newQuery: string): Uint8Array {
291
- const encoder = textEncoder
292
- const queryBytes = encoder.encode(newQuery + '\0')
293
- const buf = new Uint8Array(5 + queryBytes.length)
294
- buf[0] = 0x51
295
- new DataView(buf.buffer).setInt32(1, 4 + queryBytes.length)
296
- buf.set(queryBytes, 5)
297
- return buf
298
- }
299
-
300
- // apply all rewrites in one pass, using replace directly (no separate test)
301
- function applyRewrites(query: string): string {
302
- let result = query
303
- for (const rw of QUERY_REWRITES) {
304
- rw.match.lastIndex = 0
305
- result = result.replace(rw.match, rw.replace)
306
- }
307
- return result
308
- }
309
-
310
- /**
311
- * intercept and rewrite query messages to make pglite look like real postgres.
312
- */
313
- function interceptQuery(data: Uint8Array): Uint8Array {
314
- const msgType = data[0]
315
-
316
- if (msgType === 0x51) {
317
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
318
- const len = view.getInt32(1)
319
- const original = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
320
- const rewritten = applyRewrites(original)
321
- if (rewritten !== original) {
322
- return rebuildSimpleQuery(rewritten)
323
- }
324
- } else if (msgType === 0x50) {
325
- const original = extractParseQuery(data)
326
- if (original) {
327
- let rewritten = applyRewrites(original)
328
- // for extended protocol, noop queries must be rewritten to a harmless query
329
- // (can't return synthetic responses because they're part of a pipeline batch)
330
- if (NOOP_QUERY_PATTERNS.some((p) => p.test(rewritten))) {
331
- rewritten = 'SELECT 1'
332
- }
333
- if (rewritten !== original) {
334
- return rebuildParseMessage(data, rewritten)
335
- }
336
- }
337
- }
338
-
339
- return data
340
- }
341
-
342
- /**
343
- * build a synthetic "SET" command complete response.
344
- */
345
- function buildSetCompleteResponse(): Uint8Array {
346
- const encoder = textEncoder
347
- const tag = encoder.encode('SET\0')
348
- const cc = new Uint8Array(1 + 4 + tag.length)
349
- cc[0] = 0x43
350
- new DataView(cc.buffer).setInt32(1, 4 + tag.length)
351
- cc.set(tag, 5)
352
-
353
- const rfq = new Uint8Array(6)
354
- rfq[0] = 0x5a
355
- new DataView(rfq.buffer).setInt32(1, 5)
356
- rfq[5] = 0x54 // 'T' = in transaction
357
-
358
- const result = new Uint8Array(cc.length + rfq.length)
359
- result.set(cc, 0)
360
- result.set(rfq, cc.length)
361
- return result
362
- }
363
-
364
- /**
365
- * build a synthetic response for SELECT <n> (ping queries).
366
- * returns RowDescription + DataRow + CommandComplete + ReadyForQuery
367
- * without touching PGlite or the mutex.
368
- */
369
- function buildSelectIntResponse(val: string): Uint8Array {
370
- const enc = textEncoder
371
- const parts: Uint8Array[] = []
372
-
373
- // RowDescription: 1 column named "?column?" type int4 (oid 23)
374
- const colName = enc.encode('?column?\0')
375
- const rdLen = 4 + 2 + colName.length + 4 + 2 + 4 + 2 + 4 + 2
376
- const rd = new Uint8Array(1 + rdLen)
377
- const rdv = new DataView(rd.buffer)
378
- rd[0] = 0x54
379
- rdv.setInt32(1, rdLen)
380
- rdv.setInt16(5, 1)
381
- rd.set(colName, 7)
382
- let p = 7 + colName.length
383
- rdv.setInt32(p, 0)
384
- p += 4 // tableOid
385
- rdv.setInt16(p, 0)
386
- p += 2 // colAttr
387
- rdv.setInt32(p, 23)
388
- p += 4 // typeOid (int4)
389
- rdv.setInt16(p, 4)
390
- p += 2 // typeLen
391
- rdv.setInt32(p, -1)
392
- p += 4 // typeMod
393
- rdv.setInt16(p, 0) // format (text)
394
- parts.push(rd)
395
-
396
- // DataRow: 1 column with the value
397
- const valBytes = enc.encode(val)
398
- const drLen = 4 + 2 + 4 + valBytes.length
399
- const dr = new Uint8Array(1 + drLen)
400
- const drv = new DataView(dr.buffer)
401
- dr[0] = 0x44
402
- drv.setInt32(1, drLen)
403
- drv.setInt16(5, 1)
404
- drv.setInt32(7, valBytes.length)
405
- dr.set(valBytes, 11)
406
- parts.push(dr)
407
-
408
- // CommandComplete
409
- const tag = enc.encode('SELECT 1\0')
410
- const cc = new Uint8Array(1 + 4 + tag.length)
411
- cc[0] = 0x43
412
- new DataView(cc.buffer).setInt32(1, 4 + tag.length)
413
- cc.set(tag, 5)
414
- parts.push(cc)
415
-
416
- // ReadyForQuery
417
- const rfq = new Uint8Array(6)
418
- rfq[0] = 0x5a
419
- new DataView(rfq.buffer).setInt32(1, 5)
420
- rfq[5] = 0x49 // 'I' idle
421
- parts.push(rfq)
422
-
423
- const total = parts.reduce((s, p) => s + p.length, 0)
424
- const result = new Uint8Array(total)
425
- let off = 0
426
- for (const part of parts) {
427
- result.set(part, off)
428
- off += part.length
429
- }
430
- return result
431
- }
432
-
433
- /** read a big-endian int32 from a Uint8Array at the given offset */
434
- function readInt32BE(data: Uint8Array, offset: number): number {
435
- return (
436
- ((data[offset] << 24) >>> 0) +
437
- (data[offset + 1] << 16) +
438
- (data[offset + 2] << 8) +
439
- data[offset + 3]
440
- )
441
- }
442
-
443
- /**
444
- * extract ReadyForQuery status byte from a response.
445
- * returns the status: 'I' (0x49) idle, 'T' (0x54) in transaction, 'E' (0x45) error.
446
- * returns null if no ReadyForQuery found.
447
- */
448
- function getReadyForQueryStatus(data: Uint8Array): number | null {
449
- let offset = 0
450
- let lastStatus: number | null = null
451
- while (offset < data.length) {
452
- if (offset + 5 > data.length) break
453
- const msgLen = readInt32BE(data, offset + 1)
454
- const totalLen = 1 + msgLen
455
- if (totalLen <= 0 || offset + totalLen > data.length) break
456
- if (data[offset] === 0x5a && totalLen >= 6) {
457
- lastStatus = data[offset + 5]
458
- }
459
- offset += totalLen
460
- }
461
- return lastStatus
462
- }
463
-
464
- /**
465
- * per-instance transaction state tracking.
466
- * pglite is single-connection: if one TCP "connection" leaves an aborted transaction,
467
- * it pollutes ALL other connections sharing the same pglite instance.
468
- * track which socket owns the current transaction so we can auto-ROLLBACK when a
469
- * DIFFERENT connection encounters the stale aborted state, while still letting the
470
- * ORIGINAL connection handle its own errors (e.g. ROLLBACK TO SAVEPOINT).
471
- */
472
- interface PgLiteTxState {
473
- status: number // 0x49='I' idle, 0x54='T' in-transaction, 0x45='E' aborted
474
- owner: Socket | null // the socket that started the current transaction
475
- }
476
-
477
- // pglite warnings to suppress (benign, but noisy)
478
- // 25001: "there is already a transaction in progress"
479
- // 25P01: "there is no transaction in progress"
480
- // 55000: "wal_level is insufficient to publish logical changes"
481
- // pglite internally tries to create a publication for change streaming, but embedded
482
- // pglite doesn't support wal_level=logical (server-level postgres config). the
483
- // change-streamer still works because it falls back to polling.
484
- const SUPPRESS_NOTICE_CODES = new Set(['25001', '25P01', '55000'])
485
-
486
- /**
487
- * extract SQLSTATE code from a NoticeResponse message.
488
- * returns null if not a NoticeResponse or code not found.
489
- */
490
- function extractNoticeCode(
491
- data: Uint8Array,
492
- offset: number,
493
- totalLen: number
494
- ): string | null {
495
- if (data[offset] !== 0x4e) return null // not a NoticeResponse
496
-
497
- let pos = offset + 5 // skip type byte + length
498
- const end = offset + totalLen
499
-
500
- while (pos < end) {
501
- const fieldType = data[pos++]
502
- if (fieldType === 0) break // terminator
503
-
504
- // find null-terminated string
505
- const strStart = pos
506
- while (pos < end && data[pos] !== 0) pos++
507
- if (pos >= end) break
508
-
509
- if (fieldType === 0x43) {
510
- // 'C' = SQLSTATE code
511
- return textDecoder.decode(data.subarray(strStart, pos))
512
- }
513
- pos++ // skip null terminator
514
- }
515
- return null
516
- }
517
-
518
- /**
519
- * single-pass response message filter. strips ReadyForQuery messages (when
520
- * stripRfq=true) and benign transaction state warnings in one scan.
521
- * returns the original buffer unchanged when nothing was stripped.
522
- */
523
- function stripResponseMessages(data: Uint8Array, stripRfq: boolean): Uint8Array {
524
- if (data.length === 0) return data
525
-
526
- const parts: Uint8Array[] = []
527
- let offset = 0
528
- let stripped = false
529
-
530
- while (offset < data.length) {
531
- const msgType = data[offset]
532
- if (offset + 5 > data.length) break
533
- const msgLen = readInt32BE(data, offset + 1)
534
- const totalLen = 1 + msgLen
535
-
536
- if (totalLen <= 0 || offset + totalLen > data.length) break
537
-
538
- // strip ReadyForQuery (0x5a) when requested
539
- if (stripRfq && msgType === 0x5a) {
540
- stripped = true
541
- }
542
- // strip benign transaction state notices
543
- else {
544
- const code = extractNoticeCode(data, offset, totalLen)
545
- if (code && SUPPRESS_NOTICE_CODES.has(code)) {
546
- stripped = true
547
- } else {
548
- parts.push(data.subarray(offset, offset + totalLen))
549
- }
550
- }
551
-
552
- offset += totalLen
553
- }
554
-
555
- if (!stripped) return data
556
- if (parts.length === 0) return new Uint8Array(0)
557
- if (parts.length === 1) return parts[0]
558
-
559
- const total = parts.reduce((sum, p) => sum + p.length, 0)
560
- const result = new Uint8Array(total)
561
- let pos = 0
562
- for (const p of parts) {
563
- result.set(p, pos)
564
- pos += p.length
565
- }
566
- return result
567
- }
568
-
569
- export async function startPgProxy(
570
- dbInput: PGlite | PGliteInstances,
571
- config: ZeroLiteConfig
572
- ): Promise<Server> {
573
- // normalize input: single PGlite instance = use it for all databases (backwards compat for tests)
574
- const instances: PGliteInstances =
575
- 'postgres' in dbInput
576
- ? (dbInput as PGliteInstances)
577
- : {
578
- postgres: dbInput as PGlite,
579
- cvr: dbInput as PGlite,
580
- cdb: dbInput as PGlite,
581
- postgresReplicas: [],
582
- }
583
-
584
- // per-instance mutexes for serializing pglite access.
585
- // when all instances are the same object (single-db mode), share one mutex
586
- // to prevent concurrent protocol messages on the same pglite instance.
587
- // explicit config.singleDb wins over reference equality — callers that wrap
588
- // one PGlite in three distinct façades still need coalesced mutexes.
589
- const sharedInstance =
590
- config.singleDb === true ||
591
- (instances.postgres === instances.cvr && instances.postgres === instances.cdb)
592
- const pgMutex = new Mutex()
593
- const mutexes = {
594
- postgres: pgMutex,
595
- cvr: sharedInstance ? pgMutex : new Mutex(),
596
- cdb: sharedInstance ? pgMutex : new Mutex(),
597
- }
598
-
599
- // per-instance transaction state: tracks which socket owns the current transaction
600
- // so we can auto-ROLLBACK stale aborted transactions from other connections.
601
- // shared-instance (singleDb) coalesces txState — pglite is single-session,
602
- // so an 'E' state from role A's aborted txn poisons every subsequent query
603
- // unless role B can see the aborted state and ROLLBACK before its own work.
604
- const pgTxState: PgLiteTxState = { status: 0x49, owner: null }
605
- const txStates: Record<string, PgLiteTxState> = {
606
- postgres: pgTxState,
607
- cvr: sharedInstance ? pgTxState : { status: 0x49, owner: null },
608
- cdb: sharedInstance ? pgTxState : { status: 0x49, owner: null },
609
- }
610
-
611
- // helper to get instance + mutex + tx state for a database name
612
- function getDbContext(dbName: string): {
613
- db: PGlite
614
- mutex: Mutex
615
- txState: PgLiteTxState
616
- } {
617
- if (dbName === 'zero_cvr')
618
- return { db: instances.cvr, mutex: mutexes.cvr, txState: txStates.cvr }
619
- if (dbName === 'zero_cdb')
620
- return { db: instances.cdb, mutex: mutexes.cdb, txState: txStates.cdb }
621
- return { db: instances.postgres, mutex: mutexes.postgres, txState: txStates.postgres }
622
- }
623
-
624
- // signal replication handler after extended protocol writes complete.
625
- // 8ms leading-edge debounce: fires exactly 8ms after the FIRST write,
626
- // subsequent writes within that window are batched (handler polls all
627
- // changes at once). gives the PushProcessor time to confirm the mutation
628
- // before replication streams the same change to zero-cache.
629
- let signalTimer: ReturnType<typeof setTimeout> | null = null
630
- function signalWrite() {
631
- if (signalTimer) return
632
- signalTimer = setTimeout(() => {
633
- signalTimer = null
634
- signalReplicationChange()
635
- }, 8)
636
- }
637
-
638
- // pg-gateway uses Node WebStream adapters internally. when zero-cache
639
- // closes connections during startup, the WebStream write() throws EPIPE
640
- // as an unhandled promise rejection that escapes socket error handlers.
641
- // catch these globally while the proxy is running.
642
- const suppressSocketErrors = (err: unknown) => {
643
- const code = (err as NodeJS.ErrnoException)?.code
644
- if (code === 'EPIPE' || code === 'ECONNRESET') return
645
- const msg = err instanceof Error ? err.message : String(err)
646
- if (msg.includes('ended by the other party')) return
647
- // re-throw non-socket errors
648
- throw err
649
- }
650
- process.on('uncaughtException', suppressSocketErrors)
651
- process.on('unhandledRejection', suppressSocketErrors)
652
-
653
- const server = createServer(async (socket: Socket) => {
654
- // when the remote end sends FIN, destroy our socket immediately so
655
- // pg-gateway's WebStream adapter won't attempt further writes (EPIPE).
656
- socket.on('end', () => socket.destroy())
657
- // catch socket-level errors (EPIPE/ECONNRESET are expected during teardown)
658
- socket.on('error', (err: NodeJS.ErrnoException) => {
659
- if (err.code === 'EPIPE' || err.code === 'ECONNRESET') return
660
- log.proxy(`socket error: ${err.message}`)
661
- })
662
- // prevent idle timeouts from killing connections
663
- socket.setKeepAlive(true, 30000)
664
- socket.setTimeout(0)
665
- // disable Nagle's algorithm — send every response immediately.
666
- // critical for wire protocol where each message is a complete unit.
667
- socket.setNoDelay(true)
668
-
669
- let dbName = 'postgres'
670
- let isReplicationConnection = false
671
- // track extended protocol writes (Parse with INSERT/UPDATE/DELETE/COPY/TRUNCATE)
672
- // so we can signal replication on Sync (0x53) after the pipeline completes
673
- let extWritePending = false
674
- // hold mutex across entire extended protocol pipeline (Parse→Sync).
675
- // prevents other connections from interleaving and corrupting PGlite's
676
- // unnamed portal/statement state during the pipeline.
677
- let pipelineMutexHeld = false
678
- // clean up pglite transaction state when a client disconnects.
679
- // CRITICAL: only ROLLBACK if this socket owns the current pglite
680
- // transaction. pglite is single-session, so an unconditional ROLLBACK
681
- // here clobbers any OTHER socket's active transaction. that was the
682
- // fresh-boot race: migrate.ts's idle pool sockets closed after exit,
683
- // ran ROLLBACK while zero-cache had just sent BEGIN, and zero-cache's
684
- // next SAVEPOINT failed with "25P01: not in a transaction block".
685
- socket.on('close', async () => {
686
- // replication sockets don't own a transaction — skip ROLLBACK
687
- if (isReplicationConnection) return
688
- try {
689
- const { db, mutex, txState } = getDbContext(dbName)
690
- await mutex.acquire()
691
- try {
692
- // only rollback OUR transaction. if idle (owner=null) there's
693
- // nothing to do; if another socket owns it, leave theirs alone.
694
- if (txState.owner === socket && txState.status !== 0x49) {
695
- await db.exec('ROLLBACK')
696
- txState.status = 0x49
697
- txState.owner = null
698
- }
699
- } catch {
700
- // db is closed or rollback failed — ignore
701
- } finally {
702
- mutex.release()
703
- }
704
- } catch {
705
- // instance may have been replaced during reset, ignore
706
- }
707
- })
708
-
709
- try {
710
- const connection = await fromNodeSocket(socket, {
711
- serverVersion: '17.4',
712
- auth: {
713
- method: 'password',
714
- getClearTextPassword() {
715
- return config.pgPassword
716
- },
717
- validateCredentials(credentials: {
718
- username: string
719
- password: string
720
- clearTextPassword: string
721
- }) {
722
- return (
723
- credentials.password === credentials.clearTextPassword &&
724
- credentials.username === config.pgUser
725
- )
726
- },
727
- },
728
-
729
- // send ParameterStatus messages that standard postgres tools expect
730
- // pg-gateway sends server_version via the serverVersion option above,
731
- // but tools like pg_restore also need encoding, datestyle, etc.
732
- onAuthenticated() {
733
- for (const [name, value] of SERVER_PARAMS) {
734
- socket.write(buildParameterStatus(name, value))
735
- }
736
- },
737
-
738
- async onStartup(state) {
739
- const params = state.clientParams
740
- if (params?.replication === 'database') {
741
- isReplicationConnection = true
742
- }
743
- dbName = params?.database || 'postgres'
744
- log.debug.proxy(
745
- `connection: db=${dbName} user=${params?.user} replication=${params?.replication || 'none'}`
746
- )
747
- const { db } = getDbContext(dbName)
748
- await db.waitReady
749
- },
750
-
751
- async onMessage(data, state) {
752
- if (!state.isAuthenticated) return
753
-
754
- // handle replication connections (always go to postgres instance)
755
- if (isReplicationConnection) {
756
- if (data[0] === 0x51) {
757
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
758
- const len = view.getInt32(1)
759
- const query = textDecoder
760
- .decode(data.subarray(5, 1 + len - 1))
761
- .replace(/\0$/, '')
762
- log.debug.proxy(`repl query: ${query.slice(0, 200)}`)
763
- }
764
- return handleReplicationMessage(
765
- data,
766
- socket,
767
- instances.postgres,
768
- mutexes.postgres,
769
- connection
770
- )
771
- }
772
-
773
- const msgType = data[0]
774
- const { db, mutex, txState } = getDbContext(dbName)
775
-
776
- // extended protocol pipeline: hold mutex across Parse→Sync to prevent
777
- // other connections from interleaving and corrupting unnamed portal state.
778
- // 0x50=Parse, 0x42=Bind, 0x44=Describe, 0x45=Execute, 0x43=Close, 0x48=Flush
779
- const isExtendedMsg =
780
- msgType === 0x50 ||
781
- msgType === 0x42 ||
782
- msgType === 0x44 ||
783
- msgType === 0x45 ||
784
- msgType === 0x43 ||
785
- msgType === 0x48
786
- const isSyncInPipeline = msgType === 0x53 && pipelineMutexHeld
787
-
788
- if (isExtendedMsg || isSyncInPipeline) {
789
- // acquire mutex on first message of pipeline
790
- if (!pipelineMutexHeld) {
791
- const t0 = performance.now()
792
- await mutex.acquire()
793
- proxyStats.totalWaitMs += performance.now() - t0
794
- pipelineMutexHeld = true
795
- // auto-rollback stale transactions from other connections
796
- if (txState.status === 0x45 && txState.owner !== socket) {
797
- try {
798
- await db.exec('ROLLBACK')
799
- } catch {}
800
- txState.status = 0x49
801
- txState.owner = null
802
- }
803
- }
804
-
805
- // detect extended protocol writes for replication signaling
806
- if (dbName === 'postgres' && msgType === 0x50) {
807
- const q = extractParseQuery(data)?.trimStart().toLowerCase()
808
- if (q && /^(insert|update|delete|copy|truncate)/.test(q)) {
809
- extWritePending = true
810
- log.debug.proxy(`ext-write: detected ${q.slice(0, 40)}`)
811
- }
812
- }
813
-
814
- // apply query rewrites
815
- data = interceptQuery(data)
816
-
817
- const t1 = performance.now()
818
- let result: Uint8Array
819
- try {
820
- result = await db.execProtocolRaw(data, { syncToFs: false })
821
- } catch (err) {
822
- mutex.release()
823
- pipelineMutexHeld = false
824
- throw err
825
- }
826
- const t2 = performance.now()
827
- proxyStats.totalExecMs += t2 - t1
828
- proxyStats.count++
829
-
830
- // update transaction state
831
- const rfqStatus = getReadyForQueryStatus(result)
832
- if (rfqStatus !== null) {
833
- txState.status = rfqStatus
834
- txState.owner = rfqStatus === 0x49 ? null : socket
835
- }
836
-
837
- // release mutex on Sync (end of pipeline)
838
- if (msgType === 0x53) {
839
- mutex.release()
840
- pipelineMutexHeld = false
841
- proxyStats.batches++
842
-
843
- // signal replication handler on postgres writes
844
- if (dbName === 'postgres' && extWritePending) {
845
- extWritePending = false
846
- signalWrite()
847
- }
848
- } else {
849
- // strip ReadyForQuery from non-Sync pipeline messages
850
- result = stripResponseMessages(result, true)
851
- }
852
-
853
- if (proxyStats.count % 200 === 0) {
854
- log.debug.proxy(
855
- `perf: ${proxyStats.count} ops (${proxyStats.batches} batches) | mutex ${proxyStats.totalWaitMs.toFixed(0)}ms | pglite ${proxyStats.totalExecMs.toFixed(0)}ms`
856
- )
857
- }
858
-
859
- return result
860
- }
861
-
862
- // Simple Query (0x51) or standalone Sync — per-message mutex
863
-
864
- // extract query text ONCE for all checks (ping, noop, classification, caching)
865
- let queryText: string | null = null
866
- let queryClass: QueryClass | null = null
867
- // cache/dedup key — the rewritten query when a rewrite applies, else
868
- // the original. read and write paths MUST use the same key.
869
- let schemaCacheKey: string | null = null
870
- if (msgType === 0x51) {
871
- queryText = extractQueryText(data)
872
- if (queryText) {
873
- // fast-path: ping queries — bypass mutex entirely
874
- const pingMatch = queryText.match(PING_QUERY_RE)
875
- if (pingMatch) {
876
- return buildSelectIntResponse(pingMatch[1])
877
- }
878
-
879
- // fast-path: no-op queries — synthetic response, no mutex
880
- if (NOOP_QUERY_PATTERNS.some((p) => p.test(queryText!))) {
881
- return buildSetCompleteResponse()
882
- }
883
-
884
- // normalize once, classify once (cached for repeated queries)
885
- queryClass = classifyQuery(queryText.trimStart().toLowerCase())
886
-
887
- // schema query cache: identical information_schema queries deduplicated
888
- if (queryClass.isCacheable) {
889
- // apply rewrites before caching (version() etc. change the query)
890
- const rewritten = applyRewrites(queryText)
891
- schemaCacheKey = rewritten !== queryText ? rewritten : queryText
892
- const cached = schemaQueryCache.get(schemaCacheKey)
893
- if (cached && Date.now() < cached.expiresAt) {
894
- return stripResponseMessages(cached.result, false)
895
- }
896
- const inflight = schemaQueryInFlight.get(schemaCacheKey)
897
- if (inflight) {
898
- return stripResponseMessages(await inflight, false)
899
- }
900
- // rewrite data for execution
901
- if (rewritten !== queryText) {
902
- data = rebuildSimpleQuery(rewritten)
903
- }
904
- } else {
905
- // apply query rewrites for non-cacheable queries
906
- data = interceptQuery(data)
907
- }
908
- }
909
- }
910
-
911
- const execute = async (): Promise<Uint8Array> => {
912
- const t0 = performance.now()
913
- await mutex.acquire()
914
- if (txState.status === 0x45 && txState.owner !== socket) {
915
- try {
916
- await db.exec('ROLLBACK')
917
- } catch {}
918
- txState.status = 0x49
919
- txState.owner = null
920
- }
921
- const t1 = performance.now()
922
- let result: Uint8Array
923
- try {
924
- result = await db.execProtocolRaw(data, { syncToFs: false })
925
- } catch (err) {
926
- mutex.release()
927
- throw err
928
- }
929
- const rfqStatus = getReadyForQueryStatus(result)
930
- if (rfqStatus !== null) {
931
- txState.status = rfqStatus
932
- txState.owner = rfqStatus === 0x49 ? null : socket
933
- }
934
- const t2 = performance.now()
935
- mutex.release()
936
- proxyStats.totalWaitMs += t1 - t0
937
- proxyStats.totalExecMs += t2 - t1
938
- proxyStats.count++
939
- if (proxyStats.count % 200 === 0) {
940
- log.debug.proxy(
941
- `perf: ${proxyStats.count} ops (${proxyStats.batches} batches) | mutex ${proxyStats.totalWaitMs.toFixed(0)}ms | pglite ${proxyStats.totalExecMs.toFixed(0)}ms`
942
- )
943
- }
944
- return result
945
- }
946
-
947
- let result: Uint8Array
948
- const cacheable = queryClass?.isCacheable ?? false
949
- if (cacheable && schemaCacheKey) {
950
- const promise = execute()
951
- schemaQueryInFlight.set(schemaCacheKey, promise)
952
- try {
953
- result = await promise
954
- schemaQueryCache.set(schemaCacheKey, {
955
- result,
956
- expiresAt: Date.now() + SCHEMA_CACHE_TTL_MS,
957
- })
958
- } finally {
959
- schemaQueryInFlight.delete(schemaCacheKey)
960
- }
961
- } else {
962
- result = await execute()
963
- if (queryClass?.isDDL) {
964
- invalidateSchemaCache()
965
- }
966
- }
967
-
968
- const stripRfq = msgType !== 0x53 && msgType !== 0x51
969
- result = stripResponseMessages(result, stripRfq)
970
-
971
- // signal replication handler on postgres writes for instant sync
972
- if (dbName === 'postgres' && queryClass?.isWrite) {
973
- signalReplicationChange()
974
- }
975
-
976
- return result
977
- },
978
- })
979
- } catch (err) {
980
- if (!socket.destroyed) {
981
- socket.destroy()
982
- }
983
- }
984
- })
985
-
986
- server.on('close', () => {
987
- process.removeListener('uncaughtException', suppressSocketErrors)
988
- process.removeListener('unhandledRejection', suppressSocketErrors)
989
- })
990
-
991
- return new Promise((resolve, reject) => {
992
- server.listen(config.pgPort, '127.0.0.1', () => {
993
- log.debug.proxy(`listening on port ${config.pgPort}`)
994
- resolve(server)
995
- })
996
- server.on('error', reject)
997
- })
998
- }
999
-
1000
- async function handleReplicationMessage(
1001
- data: Uint8Array,
1002
- socket: Socket,
1003
- db: PGlite,
1004
- mutex: Mutex,
1005
- connection: Awaited<ReturnType<typeof fromNodeSocket>>
1006
- ): Promise<Uint8Array | undefined> {
1007
- if (data[0] !== 0x51) return undefined
1008
-
1009
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
1010
- const len = view.getInt32(1)
1011
- const query = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
1012
- const upper = query.trim().toUpperCase()
1013
-
1014
- // check if this is a START_REPLICATION command
1015
- if (upper.startsWith('START_REPLICATION')) {
1016
- await connection.detach()
1017
-
1018
- // abort any previous replication handler to prevent zombies
1019
- if (abortPreviousReplication) {
1020
- log.proxy('aborting previous replication handler')
1021
- abortPreviousReplication()
1022
- }
1023
-
1024
- let aborted = false
1025
- const writer = {
1026
- write(chunk: Uint8Array) {
1027
- if (!socket.destroyed && !socket.writableEnded && !aborted) {
1028
- try {
1029
- socket.write(chunk)
1030
- } catch {
1031
- // socket may have closed between our check and write (EPIPE)
1032
- aborted = true
1033
- }
1034
- }
1035
- },
1036
- get closed() {
1037
- return socket.destroyed || socket.writableEnded || aborted
1038
- },
1039
- }
1040
-
1041
- const abort = () => {
1042
- aborted = true
1043
- // use end() instead of destroy() to flush any pending writes.
1044
- // the first handler may have just written 1MB+ of WAL data that
1045
- // hasn't been fully flushed to the network. destroy() would discard
1046
- // buffered data, causing zero-cache to receive truncated/corrupt
1047
- // WAL messages which breaks its internal state.
1048
- if (!socket.destroyed) {
1049
- socket.end()
1050
- }
1051
- }
1052
- abortPreviousReplication = abort
1053
-
1054
- // drain incoming standby status updates
1055
- socket.on('data', (_chunk: Buffer) => {})
1056
-
1057
- // suppress socket errors (EPIPE/ECONNRESET) during shutdown
1058
- socket.on('error', () => {
1059
- aborted = true
1060
- })
1061
-
1062
- socket.on('close', abort)
1063
-
1064
- handleStartReplication(query, writer, db, mutex).catch((err) => {
1065
- log.proxy(`replication stream ended: ${err}`)
1066
- })
1067
- return undefined
1068
- }
1069
-
1070
- // handle replication queries + fallthrough to pglite, all under mutex
1071
- await mutex.acquire()
1072
- try {
1073
- const response = await handleReplicationQuery(query, db)
1074
- if (response) return response
1075
-
1076
- // apply query rewrites before forwarding
1077
- data = interceptQuery(data)
1078
-
1079
- // fall through to pglite for unrecognized queries
1080
- const result = await db.execProtocolRaw(data, {
1081
- throwOnError: false,
1082
- })
1083
- return stripResponseMessages(result, false)
1084
- } finally {
1085
- mutex.release()
1086
- }
1087
- }