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