orez 0.2.27 → 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -4
- package/src/admin/admin-data.test.ts +0 -348
- package/src/admin/http-proxy.ts +0 -252
- package/src/admin/log-store.ts +0 -192
- package/src/admin/server.ts +0 -471
- package/src/admin/ui.ts +0 -1322
- package/src/bench/proxy-throughput.bench.ts +0 -343
- package/src/bench/serial-mutations.bench.ts +0 -270
- package/src/browser.ts +0 -203
- package/src/cf-do/.wrangler/cache/cf.json +0 -1
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
- package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
- package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
- package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
- package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
- package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
- package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
- package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
- package/src/cf-do/ARCHITECTURE.md +0 -93
- package/src/cf-do/CHAT_E2E.md +0 -213
- package/src/cf-do/watermark.test.ts +0 -103
- package/src/cf-do/watermark.ts +0 -118
- package/src/cf-do/worker.ts +0 -1041
- package/src/cf-do/wrangler.toml +0 -11
- package/src/cf-pglite/README.md +0 -19
- package/src/change-tracking.ts +0 -25
- package/src/child-process.test.ts +0 -147
- package/src/child-process.ts +0 -90
- package/src/cli-entry.ts +0 -72
- package/src/cli.test.ts +0 -40
- package/src/cli.ts +0 -1214
- package/src/config.ts +0 -150
- package/src/do-sql-tracking.test.ts +0 -19
- package/src/do-sql-tracking.ts +0 -19
- package/src/index.ts +0 -1215
- package/src/integration/integration.test.ts +0 -517
- package/src/integration/native-binary.guard.test.ts +0 -13
- package/src/integration/native-startup.test.ts +0 -44
- package/src/integration/replication-latency.test.ts +0 -428
- package/src/integration/restore-live-stress.test.ts +0 -433
- package/src/integration/restore-reset.test.ts +0 -400
- package/src/integration/restore.test.ts +0 -274
- package/src/integration/test-permissions.ts +0 -147
- package/src/load-config.ts +0 -46
- package/src/log.ts +0 -96
- package/src/mutex.ts +0 -47
- package/src/pg-proxy-browser.singledb.test.ts +0 -233
- package/src/pg-proxy-browser.ts +0 -2022
- package/src/pg-proxy-do-backend.test.ts +0 -3890
- package/src/pg-proxy-do-backend.ts +0 -7191
- package/src/pg-proxy.ts +0 -1087
- package/src/pg-sqlite-compiler/README.md +0 -53
- package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
- package/src/pg-sqlite-compiler/index.ts +0 -73
- package/src/pg-sqlite-compiler/integration.test.ts +0 -136
- package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
- package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
- package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
- package/src/pg-sqlite-compiler/passes/index.ts +0 -49
- package/src/pg-sqlite-compiler/passes/types.ts +0 -156
- package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
- package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
- package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
- package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
- package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
- package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
- package/src/pg-sqlite-compiler/types.ts +0 -63
- package/src/pglite-ipc.test.ts +0 -116
- package/src/pglite-ipc.ts +0 -266
- package/src/pglite-manager.ts +0 -557
- package/src/pglite-web-proxy.test.ts +0 -57
- package/src/pglite-web-proxy.ts +0 -221
- package/src/pglite-web-worker.ts +0 -152
- package/src/pglite-worker-thread.ts +0 -253
- package/src/port.ts +0 -25
- package/src/process-title.ts +0 -9
- package/src/recovery.ts +0 -155
- package/src/replication/change-tracker.test.ts +0 -357
- package/src/replication/change-tracker.ts +0 -279
- package/src/replication/handler.test.ts +0 -511
- package/src/replication/handler.ts +0 -1190
- package/src/replication/pgoutput-encoder.test.ts +0 -697
- package/src/replication/pgoutput-encoder.ts +0 -373
- package/src/replication/tcp-replication.test.ts +0 -876
- package/src/replication/zero-compat.test.ts +0 -1150
- package/src/restore-stress.test.ts +0 -188
- package/src/s3-local.ts +0 -203
- package/src/shim/hooks.mjs +0 -120
- package/src/shim/register.mjs +0 -4
- package/src/sqlite-mode/apply-mode.ts +0 -224
- package/src/sqlite-mode/index.ts +0 -15
- package/src/sqlite-mode/native-binary.ts +0 -89
- package/src/sqlite-mode/package-resolve.ts +0 -17
- package/src/sqlite-mode/resolve-mode.ts +0 -80
- package/src/sqlite-mode/shim-template.ts +0 -159
- package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
- package/src/sqlite-mode/types.ts +0 -30
- package/src/vite-plugin.ts +0 -67
- package/src/wasm-sqlite.test.ts +0 -537
- package/src/worker/browser-admin.ts +0 -52
- package/src/worker/browser-build-config.test.ts +0 -71
- package/src/worker/browser-build-config.ts +0 -109
- package/src/worker/browser-embed-admin.test.ts +0 -75
- package/src/worker/browser-embed.ts +0 -345
- package/src/worker/cf-patches.ts +0 -384
- package/src/worker/embed-integration.test.ts +0 -321
- package/src/worker/index.ts +0 -138
- package/src/worker/shims/fastify.test.ts +0 -255
- package/src/worker/shims/fastify.ts +0 -306
- package/src/worker/shims/http-service.test.ts +0 -355
- package/src/worker/shims/http-service.ts +0 -293
- package/src/worker/shims/node-stub.ts +0 -290
- package/src/worker/shims/oxfmt.ts +0 -3
- package/src/worker/shims/postgres-browser.ts +0 -59
- package/src/worker/shims/postgres-socket.test.ts +0 -576
- package/src/worker/shims/postgres-socket.ts +0 -310
- package/src/worker/shims/postgres.test.ts +0 -364
- package/src/worker/shims/postgres.ts +0 -1454
- package/src/worker/shims/sqlite-browser.test.ts +0 -233
- package/src/worker/shims/sqlite-browser.ts +0 -175
- package/src/worker/shims/sqlite.test.ts +0 -786
- package/src/worker/shims/sqlite.ts +0 -978
- package/src/worker/shims/stream-browser.ts +0 -15
- package/src/worker/shims/ws-browser.test.ts +0 -205
- package/src/worker/shims/ws-browser.ts +0 -248
- package/src/worker/shims/ws.test.ts +0 -288
- package/src/worker/shims/ws.ts +0 -467
- package/src/worker/shims/zero-process-env.ts +0 -11
- package/src/worker/types.ts +0 -75
- package/src/worker/worker-integration.test.ts +0 -223
- package/src/worker/worker.test.ts +0 -136
- package/src/worker/zero-cache-embed-cf.ts +0 -463
- 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
|
-
}
|