orez 0.0.47 → 0.0.49

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 (65) hide show
  1. package/dist/admin/http-proxy.d.ts.map +1 -1
  2. package/dist/admin/http-proxy.js.map +1 -1
  3. package/dist/admin/log-store.d.ts.map +1 -1
  4. package/dist/admin/log-store.js.map +1 -1
  5. package/dist/admin/server.d.ts +2 -2
  6. package/dist/admin/server.d.ts.map +1 -1
  7. package/dist/admin/server.js.map +1 -1
  8. package/dist/admin/ui.d.ts.map +1 -1
  9. package/dist/admin/ui.js +2 -2
  10. package/dist/admin/ui.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +6 -112
  13. package/dist/cli.js.map +1 -1
  14. package/dist/config.d.ts +0 -5
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +0 -5
  17. package/dist/config.js.map +1 -1
  18. package/dist/index.d.ts +0 -9
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +91 -249
  21. package/dist/index.js.map +1 -1
  22. package/dist/log.d.ts +0 -9
  23. package/dist/log.d.ts.map +1 -1
  24. package/dist/log.js +1 -24
  25. package/dist/log.js.map +1 -1
  26. package/dist/mutex.d.ts.map +1 -1
  27. package/dist/mutex.js +2 -13
  28. package/dist/mutex.js.map +1 -1
  29. package/dist/pg-proxy.d.ts +2 -3
  30. package/dist/pg-proxy.d.ts.map +1 -1
  31. package/dist/pg-proxy.js +167 -377
  32. package/dist/pg-proxy.js.map +1 -1
  33. package/dist/pglite-manager.d.ts +0 -1
  34. package/dist/pglite-manager.d.ts.map +1 -1
  35. package/dist/pglite-manager.js +1 -1
  36. package/dist/pglite-manager.js.map +1 -1
  37. package/dist/replication/change-tracker.d.ts +0 -6
  38. package/dist/replication/change-tracker.d.ts.map +1 -1
  39. package/dist/replication/change-tracker.js +0 -74
  40. package/dist/replication/change-tracker.js.map +1 -1
  41. package/dist/replication/handler.d.ts.map +1 -1
  42. package/dist/replication/handler.js +5 -47
  43. package/dist/replication/handler.js.map +1 -1
  44. package/dist/vite-plugin.d.ts +0 -3
  45. package/dist/vite-plugin.d.ts.map +1 -1
  46. package/dist/vite-plugin.js +0 -24
  47. package/dist/vite-plugin.js.map +1 -1
  48. package/package.json +5 -4
  49. package/src/admin/http-proxy.ts +5 -1
  50. package/src/admin/log-store.ts +4 -1
  51. package/src/admin/server.ts +7 -3
  52. package/src/admin/ui.ts +682 -680
  53. package/src/cli.ts +6 -111
  54. package/src/config.ts +0 -10
  55. package/src/index.ts +92 -262
  56. package/src/integration/integration.test.ts +264 -133
  57. package/src/log.ts +1 -25
  58. package/src/mutex.ts +2 -12
  59. package/src/pg-proxy.ts +187 -449
  60. package/src/pglite-manager.ts +1 -1
  61. package/src/replication/change-tracker.ts +0 -92
  62. package/src/replication/handler.ts +4 -50
  63. package/src/shim/hooks.mjs +34 -1
  64. package/src/vite-plugin.ts +0 -28
  65. package/src/wasm-sqlite.test.ts +1 -2
package/src/pg-proxy.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * tcp proxy that makes pglite speak postgresql wire protocol.
3
3
  *
4
- * handles the postgresql wire protocol directly using raw tcp sockets,
5
- * avoiding pg-gateway's Duplex.toWeb() which deadlocks under concurrent
6
- * connections with large responses.
4
+ * uses pg-gateway to handle protocol lifecycle for regular connections,
5
+ * and directly handles the raw socket for replication connections.
7
6
  *
8
7
  * regular connections: forwarded to pglite via execProtocolRaw()
9
8
  * replication connections: intercepted, replication protocol faked
@@ -15,6 +14,8 @@
15
14
 
16
15
  import { createServer, type Server, type Socket } from 'node:net'
17
16
 
17
+ import { fromNodeSocket } from 'pg-gateway/node'
18
+
18
19
  import { log } from './log.js'
19
20
  import { Mutex } from './mutex.js'
20
21
  import { handleReplicationQuery, handleStartReplication } from './replication/handler.js'
@@ -66,7 +67,6 @@ const QUERY_REWRITES: Array<{ match: RegExp; replace: string }> = [
66
67
  // parameter status messages sent during connection handshake
67
68
  // pg_restore and other tools read these to determine server capabilities
68
69
  const SERVER_PARAMS: [string, string][] = [
69
- ['server_version', '16.4'],
70
70
  ['server_encoding', 'UTF8'],
71
71
  ['client_encoding', 'UTF8'],
72
72
  ['DateStyle', 'ISO, MDY'],
@@ -76,12 +76,7 @@ const SERVER_PARAMS: [string, string][] = [
76
76
  ['IntervalStyle', 'postgres'],
77
77
  ]
78
78
 
79
- // queries to intercept and return no-op success (synthetic SET response)
80
- // pglite rejects SET TRANSACTION if any query (e.g. SET search_path) ran first
81
- const NOOP_QUERY_PATTERNS: RegExp[] = [/^\s*SET\s+TRANSACTION\b/i, /^\s*SET\s+SESSION\b/i]
82
-
83
- // ── wire protocol helpers ──
84
-
79
+ // build a ParameterStatus wire protocol message (type 'S', 0x53)
85
80
  function buildParameterStatus(name: string, value: string): Uint8Array {
86
81
  const encoder = new TextEncoder()
87
82
  const nameBytes = encoder.encode(name)
@@ -100,64 +95,13 @@ function buildParameterStatus(name: string, value: string): Uint8Array {
100
95
  return buf
101
96
  }
102
97
 
103
- function buildAuthOk(): Uint8Array {
104
- const buf = new Uint8Array(9)
105
- buf[0] = 0x52 // 'R' AuthenticationOk
106
- new DataView(buf.buffer).setInt32(1, 8)
107
- new DataView(buf.buffer).setInt32(5, 0) // auth ok
108
- return buf
109
- }
110
-
111
- function buildAuthCleartextPassword(): Uint8Array {
112
- const buf = new Uint8Array(9)
113
- buf[0] = 0x52 // 'R'
114
- new DataView(buf.buffer).setInt32(1, 8)
115
- new DataView(buf.buffer).setInt32(5, 3) // cleartext password
116
- return buf
117
- }
118
-
119
- function buildBackendKeyData(): Uint8Array {
120
- const buf = new Uint8Array(13)
121
- buf[0] = 0x4b // 'K'
122
- new DataView(buf.buffer).setInt32(1, 12)
123
- new DataView(buf.buffer).setInt32(5, process.pid)
124
- new DataView(buf.buffer).setInt32(9, 0)
125
- return buf
126
- }
127
-
128
- function buildReadyForQuery(status: number = 0x49): Uint8Array {
129
- const buf = new Uint8Array(6)
130
- buf[0] = 0x5a // 'Z'
131
- new DataView(buf.buffer).setInt32(1, 5)
132
- buf[5] = status // 'I' = idle
133
- return buf
134
- }
135
-
136
- function buildErrorResponse(message: string): Uint8Array {
137
- const encoder = new TextEncoder()
138
- const msgBytes = encoder.encode(message)
139
- // S(ERROR) + C(code) + M(message) + terminator
140
- const sField = new Uint8Array([0x53, ...encoder.encode('ERROR'), 0])
141
- const cField = new Uint8Array([0x43, ...encoder.encode('08006'), 0])
142
- const mField = new Uint8Array([0x4d, ...msgBytes, 0])
143
- const terminator = new Uint8Array([0])
144
- const bodyLen = 4 + sField.length + cField.length + mField.length + terminator.length
145
- const buf = new Uint8Array(1 + bodyLen)
146
- buf[0] = 0x45 // 'E'
147
- new DataView(buf.buffer).setInt32(1, bodyLen)
148
- let pos = 5
149
- buf.set(sField, pos)
150
- pos += sField.length
151
- buf.set(cField, pos)
152
- pos += cField.length
153
- buf.set(mField, pos)
154
- pos += mField.length
155
- buf.set(terminator, pos)
156
- return buf
157
- }
158
-
159
- // ── query helpers ──
98
+ // queries to intercept and return no-op success (synthetic SET response)
99
+ // pglite rejects SET TRANSACTION if any query (e.g. SET search_path) ran first
100
+ const NOOP_QUERY_PATTERNS: RegExp[] = [/^\s*SET\s+TRANSACTION\b/i, /^\s*SET\s+SESSION\b/i]
160
101
 
102
+ /**
103
+ * extract query text from a Parse message (0x50).
104
+ */
161
105
  function extractParseQuery(data: Uint8Array): string | null {
162
106
  if (data[0] !== 0x50) return null
163
107
  let offset = 5
@@ -168,6 +112,9 @@ function extractParseQuery(data: Uint8Array): string | null {
168
112
  return new TextDecoder().decode(data.subarray(queryStart, offset))
169
113
  }
170
114
 
115
+ /**
116
+ * rebuild a Parse message with a modified query string.
117
+ */
171
118
  function rebuildParseMessage(data: Uint8Array, newQuery: string): Uint8Array {
172
119
  let offset = 5
173
120
  while (offset < data.length && data[offset] !== 0) offset++
@@ -197,6 +144,9 @@ function rebuildParseMessage(data: Uint8Array, newQuery: string): Uint8Array {
197
144
  return result
198
145
  }
199
146
 
147
+ /**
148
+ * rebuild a Simple Query message with a modified query string.
149
+ */
200
150
  function rebuildSimpleQuery(newQuery: string): Uint8Array {
201
151
  const encoder = new TextEncoder()
202
152
  const queryBytes = encoder.encode(newQuery + '\0')
@@ -207,6 +157,9 @@ function rebuildSimpleQuery(newQuery: string): Uint8Array {
207
157
  return buf
208
158
  }
209
159
 
160
+ /**
161
+ * intercept and rewrite query messages to make pglite look like real postgres.
162
+ */
210
163
  function interceptQuery(data: Uint8Array): Uint8Array {
211
164
  const msgType = data[0]
212
165
 
@@ -250,6 +203,9 @@ function interceptQuery(data: Uint8Array): Uint8Array {
250
203
  return data
251
204
  }
252
205
 
206
+ /**
207
+ * check if a query should be intercepted as a no-op.
208
+ */
253
209
  function isNoopQuery(data: Uint8Array): boolean {
254
210
  let query: string | null = null
255
211
  if (data[0] === 0x51) {
@@ -263,6 +219,9 @@ function isNoopQuery(data: Uint8Array): boolean {
263
219
  return NOOP_QUERY_PATTERNS.some((p) => p.test(query!))
264
220
  }
265
221
 
222
+ /**
223
+ * build a synthetic "SET" command complete response.
224
+ */
266
225
  function buildSetCompleteResponse(): Uint8Array {
267
226
  const encoder = new TextEncoder()
268
227
  const tag = encoder.encode('SET\0')
@@ -282,6 +241,9 @@ function buildSetCompleteResponse(): Uint8Array {
282
241
  return result
283
242
  }
284
243
 
244
+ /**
245
+ * build a synthetic ParseComplete response for extended protocol no-ops.
246
+ */
285
247
  function buildParseCompleteResponse(): Uint8Array {
286
248
  const pc = new Uint8Array(5)
287
249
  pc[0] = 0x31 // ParseComplete
@@ -289,6 +251,9 @@ function buildParseCompleteResponse(): Uint8Array {
289
251
  return pc
290
252
  }
291
253
 
254
+ /**
255
+ * strip ReadyForQuery messages from a response buffer.
256
+ */
292
257
  function stripReadyForQuery(data: Uint8Array): Uint8Array {
293
258
  if (data.length === 0) return data
294
259
 
@@ -320,299 +285,184 @@ function stripReadyForQuery(data: Uint8Array): Uint8Array {
320
285
  return result
321
286
  }
322
287
 
323
- // ── socket write with backpressure ──
324
-
325
- function socketWrite(socket: Socket, data: Uint8Array): Promise<void> {
326
- if (data.length === 0 || socket.destroyed) return Promise.resolve()
327
- return new Promise<void>((resolve, reject) => {
328
- const ok = socket.write(data as any, (err) => (err ? reject(err) : resolve()))
329
- // if buffer is full, the callback still fires when flushed
330
- if (!ok) void 0
331
- })
332
- }
333
-
334
- // ── startup handshake ──
335
-
336
- // parse startup message from raw bytes.
337
- // handles SSLRequest (8 bytes, code 80877103) and StartupMessage.
338
- function parseStartupMessage(buf: Buffer): {
339
- isSSL: boolean
340
- params: Record<string, string>
341
- } {
342
- const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
343
- const len = dv.getInt32(0)
344
- const code = dv.getInt32(4)
345
-
346
- // SSL request: length=8, code=80877103
347
- if (len === 8 && code === 80877103) {
348
- return { isSSL: true, params: {} }
349
- }
350
-
351
- // startup message: length, protocol(196608=3.0), then key=value pairs
352
- const params: Record<string, string> = {}
353
- let offset = 8
354
- while (offset < len) {
355
- const keyStart = offset
356
- while (offset < buf.length && buf[offset] !== 0) offset++
357
- const key = buf.subarray(keyStart, offset).toString()
358
- offset++
359
- if (!key) break // double-null = end of params
360
- const valStart = offset
361
- while (offset < buf.length && buf[offset] !== 0) offset++
362
- params[key] = buf.subarray(valStart, offset).toString()
363
- offset++
364
- }
365
-
366
- return { isSSL: false, params }
367
- }
368
-
369
- // read exactly `n` bytes from socket
370
- function readBytes(socket: Socket, n: number): Promise<Buffer> {
371
- return new Promise((resolve, reject) => {
372
- let collected = Buffer.alloc(0)
373
-
374
- const onData = (chunk: Buffer) => {
375
- collected = Buffer.concat([collected, chunk])
376
- if (collected.length >= n) {
377
- socket.removeListener('data', onData)
378
- socket.removeListener('error', onError)
379
- socket.removeListener('close', onClose)
380
- socket.pause()
381
- resolve(collected)
382
- }
383
- }
384
- const onError = (err: Error) => {
385
- socket.removeListener('data', onData)
386
- socket.removeListener('close', onClose)
387
- reject(err)
388
- }
389
- const onClose = () => {
390
- socket.removeListener('data', onData)
391
- socket.removeListener('error', onError)
392
- reject(new Error('socket closed'))
393
- }
394
-
395
- socket.on('data', onData)
396
- socket.on('error', onError)
397
- socket.on('close', onClose)
398
- socket.resume()
399
- })
400
- }
401
-
402
- // perform the startup handshake (SSL negotiation, auth, parameter status)
403
- async function performHandshake(
404
- socket: Socket,
288
+ export async function startPgProxy(
289
+ dbInput: PGlite | PGliteInstances,
405
290
  config: ZeroLiteConfig
406
- ): Promise<{ params: Record<string, string> }> {
407
- // read initial message length (first 4 bytes)
408
- let buf = await readBytes(socket, 8)
409
-
410
- // check for SSL request
411
- const startup = parseStartupMessage(buf)
412
- if (startup.isSSL) {
413
- // reject SSL, client will reconnect without it
414
- socket.write(Buffer.from('N'))
415
- buf = await readBytes(socket, 8)
416
- }
417
-
418
- // now we have startup message header - read the rest if needed
419
- const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
420
- const msgLen = dv.getInt32(0)
421
- if (buf.length < msgLen) {
422
- const rest = await readBytes(socket, msgLen - buf.length)
423
- buf = Buffer.concat([buf, rest])
424
- }
425
-
426
- const { params } = parseStartupMessage(buf)
427
-
428
- // request cleartext password
429
- socket.write(buildAuthCleartextPassword())
291
+ ): Promise<Server> {
292
+ // normalize input: single PGlite instance = use it for all databases (backwards compat for tests)
293
+ const instances: PGliteInstances =
294
+ 'postgres' in dbInput
295
+ ? (dbInput as PGliteInstances)
296
+ : { postgres: dbInput as PGlite, cvr: dbInput as PGlite, cdb: dbInput as PGlite }
430
297
 
431
- // read password message: type(1) + len(4) + password + null
432
- const pwBuf = await readBytes(socket, 5)
433
- const pwDv = new DataView(pwBuf.buffer, pwBuf.byteOffset, pwBuf.byteLength)
434
- const pwLen = pwDv.getInt32(1)
435
- let fullPwBuf = pwBuf
436
- if (fullPwBuf.length < 1 + pwLen) {
437
- const rest = await readBytes(socket, 1 + pwLen - fullPwBuf.length)
438
- fullPwBuf = Buffer.concat([fullPwBuf, rest])
439
- }
440
- const password = fullPwBuf.subarray(5, 1 + pwLen - 1).toString()
441
-
442
- // validate credentials
443
- if (params.user !== config.pgUser || password !== config.pgPassword) {
444
- socket.write(buildErrorResponse('authentication failed'))
445
- socket.write(buildReadyForQuery())
446
- socket.destroy()
447
- throw new Error('auth failed')
298
+ // per-instance mutexes for serializing pglite access
299
+ const mutexes = {
300
+ postgres: new Mutex(),
301
+ cvr: new Mutex(),
302
+ cdb: new Mutex(),
448
303
  }
449
304
 
450
- // auth ok
451
- socket.write(buildAuthOk())
452
-
453
- // send parameter status messages
454
- for (const [name, value] of SERVER_PARAMS) {
455
- socket.write(buildParameterStatus(name, value))
305
+ // helper to get instance + mutex for a database name
306
+ function getDbContext(dbName: string): { db: PGlite; mutex: Mutex } {
307
+ if (dbName === 'zero_cvr') return { db: instances.cvr, mutex: mutexes.cvr }
308
+ if (dbName === 'zero_cdb') return { db: instances.cdb, mutex: mutexes.cdb }
309
+ return { db: instances.postgres, mutex: mutexes.postgres }
456
310
  }
457
311
 
458
- // backend key data
459
- socket.write(buildBackendKeyData())
460
-
461
- // ready for query
462
- socket.write(buildReadyForQuery())
463
-
464
- return { params }
465
- }
312
+ const server = createServer(async (socket: Socket) => {
313
+ // prevent idle timeouts from killing connections
314
+ socket.setKeepAlive(true, 30000)
315
+ socket.setTimeout(0)
466
316
 
467
- // ── connection tracking ──
317
+ let dbName = 'postgres'
318
+ let isReplicationConnection = false
468
319
 
469
- // per-database active connection count. pglite is single-session so all
470
- // connections share one transaction context. we skip ROLLBACK on close when
471
- // other connections are still active to avoid killing their transactions.
472
- const activeConns: Record<string, number> = {}
473
- let connCounter = 0
320
+ // clean up pglite transaction state when a client disconnects
321
+ socket.on('close', async () => {
322
+ const { db, mutex } = getDbContext(dbName)
323
+ await mutex.acquire()
324
+ try {
325
+ await db.exec('ROLLBACK')
326
+ } catch {
327
+ // no transaction to rollback
328
+ } finally {
329
+ mutex.release()
330
+ }
331
+ })
474
332
 
475
- // ── message loop ──
333
+ try {
334
+ const connection = await fromNodeSocket(socket, {
335
+ serverVersion: '16.4',
336
+ auth: {
337
+ method: 'password',
338
+ getClearTextPassword() {
339
+ return config.pgPassword
340
+ },
341
+ validateCredentials(credentials: {
342
+ username: string
343
+ password: string
344
+ clearTextPassword: string
345
+ }) {
346
+ return (
347
+ credentials.password === credentials.clearTextPassword &&
348
+ credentials.username === config.pgUser
349
+ )
350
+ },
351
+ },
352
+
353
+ // send ParameterStatus messages that standard postgres tools expect
354
+ // pg-gateway sends server_version via the serverVersion option above,
355
+ // but tools like pg_restore also need encoding, datestyle, etc.
356
+ onAuthenticated() {
357
+ for (const [name, value] of SERVER_PARAMS) {
358
+ socket.write(buildParameterStatus(name, value))
359
+ }
360
+ },
476
361
 
477
- // process messages from a connected, authenticated client.
478
- // uses callback-based 'data' events instead of async iterators
479
- // for reliable behavior across runtimes (node.js, bun).
480
- function messageLoop(
481
- socket: Socket,
482
- db: PGlite,
483
- mutex: Mutex,
484
- isReplicationConnection: boolean,
485
- replicationDb: PGlite,
486
- replicationMutex: Mutex
487
- ): Promise<void> {
488
- return new Promise<void>((resolve, reject) => {
489
- let buffer: Buffer = Buffer.alloc(0)
490
- let processing = false
491
-
492
- async function processBuffer() {
493
- if (processing) return
494
- processing = true
495
- socket.pause()
362
+ async onStartup(state) {
363
+ const params = state.clientParams
364
+ if (params?.replication === 'database') {
365
+ isReplicationConnection = true
366
+ }
367
+ dbName = params?.database || 'postgres'
368
+ log.debug.proxy(
369
+ `connection: db=${dbName} user=${params?.user} replication=${params?.replication || 'none'}`
370
+ )
371
+ const { db } = getDbContext(dbName)
372
+ await db.waitReady
373
+ },
496
374
 
497
- try {
498
- while (buffer.length >= 5) {
499
- const msgType = buffer[0]
500
- const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
501
- const msgLen = dv.getInt32(1)
502
- const totalLen = 1 + msgLen
375
+ async onMessage(data, state) {
376
+ if (!state.isAuthenticated) return
503
377
 
504
- if (buffer.length < totalLen) break // need more data
378
+ // handle replication connections (always go to postgres instance)
379
+ if (isReplicationConnection) {
380
+ if (data[0] === 0x51) {
381
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
382
+ const len = view.getInt32(1)
383
+ const query = new TextDecoder()
384
+ .decode(data.subarray(5, 1 + len - 1))
385
+ .replace(/\0$/, '')
386
+ log.debug.proxy(`repl query: ${query.slice(0, 200)}`)
387
+ }
388
+ return handleReplicationMessage(
389
+ data,
390
+ socket,
391
+ instances.postgres,
392
+ mutexes.postgres,
393
+ connection
394
+ )
395
+ }
505
396
 
506
- // copy message out before modifying buffer
507
- const message = new Uint8Array(
508
- buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + totalLen)
509
- )
510
- buffer = buffer.subarray(totalLen)
397
+ // check for no-op queries
398
+ if (isNoopQuery(data)) {
399
+ if (data[0] === 0x51) {
400
+ return buildSetCompleteResponse()
401
+ } else if (data[0] === 0x50) {
402
+ return buildParseCompleteResponse()
403
+ }
404
+ }
511
405
 
512
- // handle Terminate message
513
- if (msgType === 0x58) {
514
- resolve()
515
- return
406
+ // intercept and rewrite queries
407
+ data = interceptQuery(data)
408
+
409
+ // message-level locking on the connection's pglite instance
410
+ const { db, mutex } = getDbContext(dbName)
411
+ await mutex.acquire()
412
+
413
+ let result: Uint8Array
414
+ try {
415
+ result = await db.execProtocolRaw(data, {
416
+ throwOnError: false,
417
+ })
418
+ } catch (err) {
419
+ mutex.release()
420
+ throw err
516
421
  }
517
422
 
518
- // handle replication connections
519
- if (isReplicationConnection) {
520
- await handleReplicationMsg(message, socket, replicationDb, replicationMutex)
521
- continue
423
+ // strip ReadyForQuery from non-Sync/non-SimpleQuery responses
424
+ if (data[0] !== 0x53 && data[0] !== 0x51) {
425
+ result = stripReadyForQuery(result)
522
426
  }
523
427
 
524
- // handle regular messages
525
- await handleRegularMessage(message, socket, db, mutex)
526
- }
527
- } catch (err) {
528
- reject(err)
529
- return
428
+ mutex.release()
429
+ return result
430
+ },
431
+ })
432
+ } catch (err) {
433
+ if (!socket.destroyed) {
434
+ socket.destroy()
530
435
  }
531
-
532
- processing = false
533
- socket.resume()
534
436
  }
437
+ })
535
438
 
536
- socket.on('data', (chunk: Buffer) => {
537
- buffer = buffer.length > 0 ? Buffer.concat([buffer, chunk]) : chunk
538
- processBuffer()
439
+ return new Promise((resolve, reject) => {
440
+ server.listen(config.pgPort, '127.0.0.1', () => {
441
+ log.debug.proxy(`listening on port ${config.pgPort}`)
442
+ resolve(server)
539
443
  })
540
-
541
- socket.on('end', () => resolve())
542
- socket.on('error', (err) => reject(err))
543
- socket.on('close', () => resolve())
544
-
545
- socket.resume()
444
+ server.on('error', reject)
546
445
  })
547
446
  }
548
447
 
549
- async function handleRegularMessage(
550
- data: Uint8Array,
551
- socket: Socket,
552
- db: PGlite,
553
- mutex: Mutex
554
- ): Promise<void> {
555
- // check for no-op queries
556
- if (isNoopQuery(data)) {
557
- if (data[0] === 0x51) {
558
- await socketWrite(socket, buildSetCompleteResponse())
559
- return
560
- } else if (data[0] === 0x50) {
561
- await socketWrite(socket, buildParseCompleteResponse())
562
- return
563
- }
564
- }
565
-
566
- // intercept and rewrite queries
567
- data = interceptQuery(data)
568
-
569
- // serialize pglite access
570
- await mutex.acquire()
571
- let result: Uint8Array
572
- try {
573
- result = await db.execProtocolRaw(data, { throwOnError: false })
574
- } catch (err: any) {
575
- mutex.release()
576
- // send error response instead of killing the connection — PGlite internal
577
- // errors shouldn't terminate the client's tcp session
578
- log.debug.proxy(`execProtocolRaw error: ${err?.message || err}`)
579
- const errMsg = err?.message || 'internal error'
580
- const errResp = buildErrorResponse(errMsg)
581
- const rfq = buildReadyForQuery(0x45) // 'E' = failed transaction
582
- const combined = new Uint8Array(errResp.length + rfq.length)
583
- combined.set(errResp, 0)
584
- combined.set(rfq, errResp.length)
585
- await socketWrite(socket, combined)
586
- return
587
- }
588
-
589
- // strip ReadyForQuery from non-Sync/non-SimpleQuery responses
590
- if (data[0] !== 0x53 && data[0] !== 0x51) {
591
- result = stripReadyForQuery(result)
592
- }
593
-
594
- mutex.release()
595
-
596
- // write response directly to socket
597
- await socketWrite(socket, result)
598
- }
599
-
600
- async function handleReplicationMsg(
448
+ async function handleReplicationMessage(
601
449
  data: Uint8Array,
602
450
  socket: Socket,
603
451
  db: PGlite,
604
- mutex: Mutex
605
- ): Promise<void> {
606
- if (data[0] !== 0x51) return
452
+ mutex: Mutex,
453
+ connection: Awaited<ReturnType<typeof fromNodeSocket>>
454
+ ): Promise<Uint8Array | undefined> {
455
+ if (data[0] !== 0x51) return undefined
607
456
 
608
457
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
609
458
  const len = view.getInt32(1)
610
459
  const query = new TextDecoder().decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
611
460
  const upper = query.trim().toUpperCase()
612
461
 
613
- log.debug.proxy(`repl query: ${query.slice(0, 200)}`)
614
-
462
+ // check if this is a START_REPLICATION command
615
463
  if (upper.startsWith('START_REPLICATION')) {
464
+ await connection.detach()
465
+
616
466
  const writer = {
617
467
  write(chunk: Uint8Array) {
618
468
  if (!socket.destroyed) {
@@ -623,143 +473,31 @@ async function handleReplicationMsg(
623
473
 
624
474
  // drain incoming standby status updates
625
475
  socket.on('data', (_chunk: Buffer) => {})
626
- socket.on('close', () => socket.destroy())
627
476
 
628
- // this runs indefinitely until the socket closes
629
- await handleStartReplication(query, writer, db, mutex).catch((err) => {
477
+ socket.on('close', () => {
478
+ socket.destroy()
479
+ })
480
+
481
+ handleStartReplication(query, writer, db, mutex).catch((err) => {
630
482
  log.debug.proxy(`replication stream ended: ${err}`)
631
483
  })
632
- return
484
+ return undefined
633
485
  }
634
486
 
635
- // handle replication queries + fallthrough to pglite
487
+ // handle replication queries + fallthrough to pglite, all under mutex
636
488
  await mutex.acquire()
637
489
  try {
638
490
  const response = await handleReplicationQuery(query, db)
639
- if (response) {
640
- await socketWrite(socket, response)
641
- return
642
- }
491
+ if (response) return response
643
492
 
644
493
  // apply query rewrites before forwarding
645
494
  data = interceptQuery(data)
646
495
 
647
- const result = await db.execProtocolRaw(data, { throwOnError: false })
648
- await socketWrite(socket, result)
496
+ // fall through to pglite for unrecognized queries
497
+ return await db.execProtocolRaw(data, {
498
+ throwOnError: false,
499
+ })
649
500
  } finally {
650
501
  mutex.release()
651
502
  }
652
503
  }
653
-
654
- // ── main entry point ──
655
-
656
- export async function startPgProxy(
657
- dbInput: PGlite | PGliteInstances,
658
- config: ZeroLiteConfig
659
- ): Promise<Server> {
660
- // normalize input: single PGlite instance = use it for all databases (backwards compat for tests)
661
- const instances: PGliteInstances =
662
- 'postgres' in dbInput
663
- ? (dbInput as PGliteInstances)
664
- : { postgres: dbInput as PGlite, cvr: dbInput as PGlite, cdb: dbInput as PGlite }
665
-
666
- // per-instance mutexes for serializing pglite access
667
- const mutexes = {
668
- postgres: new Mutex(),
669
- cvr: new Mutex(),
670
- cdb: new Mutex(),
671
- }
672
- function getDbContext(dbName: string): { db: PGlite; mutex: Mutex } {
673
- if (dbName === 'zero_cvr') return { db: instances.cvr, mutex: mutexes.cvr }
674
- if (dbName === 'zero_cdb') return { db: instances.cdb, mutex: mutexes.cdb }
675
- return { db: instances.postgres, mutex: mutexes.postgres }
676
- }
677
-
678
- const server = createServer(async (socket: Socket) => {
679
- socket.setKeepAlive(true, 30000)
680
- socket.setTimeout(0)
681
- socket.setNoDelay(true)
682
-
683
- let dbName = 'postgres'
684
- let isReplicationConnection = false
685
- const connId = ++connCounter
686
-
687
- try {
688
- // perform startup handshake
689
- const { params } = await performHandshake(socket, config)
690
-
691
- dbName = params.database || 'postgres'
692
- isReplicationConnection = params.replication === 'database'
693
-
694
- // track active connections per database
695
- activeConns[dbName] = (activeConns[dbName] || 0) + 1
696
-
697
- console.info(`[orez-proxy#${connId}] connect db=${dbName} repl=${params.replication || 'none'}`)
698
-
699
- const { db } = getDbContext(dbName)
700
- await db.waitReady
701
-
702
- // clean up pglite session state when client disconnects.
703
- // pglite is single-session — all connections share one session.
704
- // only ROLLBACK + reset when this is the LAST connection for this db,
705
- // to avoid killing another connection's active transaction.
706
- socket.on('close', async () => {
707
- activeConns[dbName] = Math.max(0, (activeConns[dbName] || 1) - 1)
708
- const remaining = activeConns[dbName]
709
- const shouldRollback = remaining === 0
710
-
711
- console.info(
712
- `[orez-proxy#${connId}] close [${dbName}] (remaining=${remaining}, shouldRollback=${shouldRollback})`
713
- )
714
-
715
- if (!shouldRollback) return
716
-
717
- const { db: closeDb, mutex: closeMutex } = getDbContext(dbName)
718
- await closeMutex.acquire()
719
- try {
720
- await closeDb.exec('ROLLBACK')
721
- } catch {
722
- // no transaction to rollback
723
- }
724
- try {
725
- await closeDb.exec(`SET search_path TO public`)
726
- await closeDb.exec(`RESET statement_timeout`)
727
- await closeDb.exec(`RESET lock_timeout`)
728
- await closeDb.exec(`RESET idle_in_transaction_session_timeout`)
729
- } catch {
730
- // best-effort reset
731
- } finally {
732
- closeMutex.release()
733
- }
734
- })
735
-
736
- // enter message processing loop
737
- const { db: msgDb, mutex: msgMutex } = getDbContext(dbName)
738
- await messageLoop(
739
- socket,
740
- msgDb,
741
- msgMutex,
742
- isReplicationConnection,
743
- instances.postgres,
744
- mutexes.postgres
745
- )
746
- } catch (err: any) {
747
- const msg = err?.message || err
748
- // suppress expected errors (client disconnected, auth failures)
749
- if (msg !== 'auth failed' && msg !== 'socket closed') {
750
- log.debug.proxy(`connection error: ${msg}`)
751
- }
752
- if (!socket.destroyed) {
753
- socket.destroy()
754
- }
755
- }
756
- })
757
-
758
- return new Promise((resolve, reject) => {
759
- server.listen(config.pgPort, '127.0.0.1', () => {
760
- log.debug.proxy(`listening on port ${config.pgPort}`)
761
- resolve(server)
762
- })
763
- server.on('error', reject)
764
- })
765
- }