orez 0.1.43 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/admin/http-proxy.d.ts.map +1 -1
  2. package/dist/admin/http-proxy.js +3 -1
  3. package/dist/admin/http-proxy.js.map +1 -1
  4. package/dist/admin/log-store.d.ts.map +1 -1
  5. package/dist/admin/log-store.js +5 -1
  6. package/dist/admin/log-store.js.map +1 -1
  7. package/dist/admin/server.d.ts.map +1 -1
  8. package/dist/admin/server.js +25 -25
  9. package/dist/admin/server.js.map +1 -1
  10. package/dist/browser.d.ts +54 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +110 -0
  13. package/dist/browser.js.map +1 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/pg-proxy-browser.d.ts +26 -0
  21. package/dist/pg-proxy-browser.d.ts.map +1 -0
  22. package/dist/pg-proxy-browser.js +1460 -0
  23. package/dist/pg-proxy-browser.js.map +1 -0
  24. package/dist/pg-proxy.d.ts.map +1 -1
  25. package/dist/pg-proxy.js +48 -34
  26. package/dist/pg-proxy.js.map +1 -1
  27. package/dist/pglite-ipc.d.ts.map +1 -1
  28. package/dist/pglite-ipc.js +3 -2
  29. package/dist/pglite-ipc.js.map +1 -1
  30. package/dist/pglite-manager.d.ts.map +1 -1
  31. package/dist/pglite-manager.js +33 -85
  32. package/dist/pglite-manager.js.map +1 -1
  33. package/dist/pglite-web-proxy.d.ts +38 -0
  34. package/dist/pglite-web-proxy.d.ts.map +1 -0
  35. package/dist/pglite-web-proxy.js +155 -0
  36. package/dist/pglite-web-proxy.js.map +1 -0
  37. package/dist/pglite-web-worker.d.ts +24 -0
  38. package/dist/pglite-web-worker.d.ts.map +1 -0
  39. package/dist/pglite-web-worker.js +119 -0
  40. package/dist/pglite-web-worker.js.map +1 -0
  41. package/dist/recovery.js +2 -2
  42. package/dist/recovery.js.map +1 -1
  43. package/dist/replication/change-tracker.js +9 -9
  44. package/dist/replication/change-tracker.js.map +1 -1
  45. package/dist/replication/handler.d.ts.map +1 -1
  46. package/dist/replication/handler.js +34 -26
  47. package/dist/replication/handler.js.map +1 -1
  48. package/dist/worker/browser-build-config.d.ts.map +1 -1
  49. package/dist/worker/browser-build-config.js +5 -2
  50. package/dist/worker/browser-build-config.js.map +1 -1
  51. package/dist/worker/browser-embed.d.ts.map +1 -1
  52. package/dist/worker/browser-embed.js +31 -26
  53. package/dist/worker/browser-embed.js.map +1 -1
  54. package/dist/worker/shims/fastify.d.ts +1 -0
  55. package/dist/worker/shims/fastify.d.ts.map +1 -1
  56. package/dist/worker/shims/fastify.js +31 -20
  57. package/dist/worker/shims/fastify.js.map +1 -1
  58. package/dist/worker/shims/postgres-browser.d.ts +12 -0
  59. package/dist/worker/shims/postgres-browser.d.ts.map +1 -0
  60. package/dist/worker/shims/postgres-browser.js +52 -0
  61. package/dist/worker/shims/postgres-browser.js.map +1 -0
  62. package/dist/worker/shims/postgres-socket.d.ts +83 -0
  63. package/dist/worker/shims/postgres-socket.d.ts.map +1 -0
  64. package/dist/worker/shims/postgres-socket.js +278 -0
  65. package/dist/worker/shims/postgres-socket.js.map +1 -0
  66. package/dist/worker/shims/postgres.d.ts.map +1 -1
  67. package/dist/worker/shims/postgres.js +18 -9
  68. package/dist/worker/shims/postgres.js.map +1 -1
  69. package/dist/worker/shims/stream-browser.d.ts +5 -4
  70. package/dist/worker/shims/stream-browser.d.ts.map +1 -1
  71. package/dist/worker/shims/stream-browser.js +7 -6
  72. package/dist/worker/shims/stream-browser.js.map +1 -1
  73. package/dist/worker/shims/ws-browser.d.ts.map +1 -1
  74. package/dist/worker/shims/ws-browser.js +43 -21
  75. package/dist/worker/shims/ws-browser.js.map +1 -1
  76. package/dist/worker/shims/ws.d.ts.map +1 -1
  77. package/dist/worker/shims/ws.js +81 -17
  78. package/dist/worker/shims/ws.js.map +1 -1
  79. package/package.json +11 -58
  80. package/src/admin/http-proxy.ts +4 -1
  81. package/src/admin/log-store.ts +5 -1
  82. package/src/admin/server.ts +26 -25
  83. package/src/browser.ts +195 -0
  84. package/src/cli.ts +1 -1
  85. package/src/index.ts +5 -2
  86. package/src/integration/integration.test.ts +1 -1
  87. package/src/integration/restore-live-stress.test.ts +2 -2
  88. package/src/pg-proxy-browser.ts +1673 -0
  89. package/src/pg-proxy.ts +48 -40
  90. package/src/pglite-ipc.ts +3 -2
  91. package/src/pglite-manager.ts +45 -107
  92. package/src/pglite-web-proxy.ts +180 -0
  93. package/src/pglite-web-worker.ts +132 -0
  94. package/src/recovery.ts +2 -2
  95. package/src/replication/change-tracker.test.ts +1 -1
  96. package/src/replication/change-tracker.ts +9 -9
  97. package/src/replication/handler.ts +37 -26
  98. package/src/worker/browser-build-config.test.ts +1 -1
  99. package/src/worker/browser-build-config.ts +5 -2
  100. package/src/worker/browser-embed.ts +33 -30
  101. package/src/worker/shims/fastify.ts +37 -24
  102. package/src/worker/shims/postgres-browser.ts +59 -0
  103. package/src/worker/shims/postgres-socket.test.ts +576 -0
  104. package/src/worker/shims/postgres-socket.ts +310 -0
  105. package/src/worker/shims/postgres.ts +30 -15
  106. package/src/worker/shims/stream-browser.ts +15 -0
  107. package/src/worker/shims/ws-browser.ts +38 -20
  108. package/src/worker/shims/ws.ts +76 -21
@@ -0,0 +1,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