orez 0.2.12 → 0.2.14

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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * regression test for the explicit singleDb option in createBrowserProxy.
3
+ *
4
+ * background: orez-web (in soot) wraps a single PGlite worker in three
5
+ * distinct port-proxy façades (one per database role) and hands them to
6
+ * createBrowserProxy. before this fix, mutex coalescing relied on
7
+ * `instances.postgres === instances.cvr` reference equality — which fails
8
+ * for distinct façades, leaving 3 separate mutexes guarding a single
9
+ * underlying PGlite. that allows concurrent extended-protocol sequences on
10
+ * one shared session, racing named-statement slots and replication state.
11
+ *
12
+ * the explicit `config.singleDb` option forces mutex coalescing regardless
13
+ * of object identity. this test pins that contract: with singleDb=true and
14
+ * three distinct façades over one PGlite, concurrent calls must serialize.
15
+ */
16
+
17
+ import { PGlite } from '@electric-sql/pglite'
18
+ import postgres from 'postgres'
19
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest'
20
+
21
+ import { createBrowserProxy } from './pg-proxy-browser.js'
22
+ import { createSocketFactory } from './worker/shims/postgres-socket.js'
23
+
24
+ import type { PGliteInstances } from './pglite-manager.js'
25
+
26
+ /**
27
+ * thin façade that re-exposes a PGlite's surface as a *distinct* object
28
+ * reference. simulates what orez-web does (it wraps the same underlying
29
+ * PGlite worker in three port-proxy objects, one per database role).
30
+ */
31
+ function makeFacade(real: PGlite, label: string) {
32
+ const facade: any = {
33
+ _label: label,
34
+ closed: false,
35
+ ready: true,
36
+ get waitReady() {
37
+ return real.waitReady
38
+ },
39
+ query: (sql: string, params?: any[]) => real.query(sql, params as any),
40
+ exec: (sql: string) => real.exec(sql),
41
+ execProtocolRaw: (data: Uint8Array, options?: any) =>
42
+ real.execProtocolRaw(data, options),
43
+ listen: () => Promise.resolve(async () => {}),
44
+ close: () => Promise.resolve(),
45
+ }
46
+ return facade as PGlite
47
+ }
48
+
49
+ function createSql(
50
+ proxy: ReturnType<typeof createBrowserProxy> extends Promise<infer T> ? T : never
51
+ ) {
52
+ return postgres({
53
+ socket: createSocketFactory((port) => proxy.handleConnection(port)),
54
+ database: 'postgres',
55
+ username: 'u',
56
+ password: '',
57
+ host: '127.0.0.1',
58
+ port: 0,
59
+ ssl: false,
60
+ max: 1,
61
+ no_subscribe: true,
62
+ } as any)
63
+ }
64
+
65
+ function deferred<T>() {
66
+ let resolve!: (value: T | PromiseLike<T>) => void
67
+ let reject!: (reason?: unknown) => void
68
+ const promise = new Promise<T>((res, rej) => {
69
+ resolve = res
70
+ reject = rej
71
+ })
72
+ return { promise, resolve, reject }
73
+ }
74
+
75
+ describe('createBrowserProxy singleDb mutex coalescing', () => {
76
+ let pg: PGlite
77
+
78
+ beforeAll(async () => {
79
+ pg = new PGlite()
80
+ await pg.waitReady
81
+ }, 30_000)
82
+
83
+ afterAll(async () => {
84
+ await pg.close().catch(() => {})
85
+ })
86
+
87
+ test('reference equality on the same PGlite still coalesces (legacy path)', async () => {
88
+ const instances: PGliteInstances = {
89
+ postgres: pg,
90
+ cvr: pg,
91
+ cdb: pg,
92
+ postgresReplicas: [],
93
+ }
94
+ const proxy = await createBrowserProxy(instances, { pgPassword: '', pgUser: 'u' })
95
+ // smoke: it constructs without error. proper concurrency proof requires
96
+ // pg-wire client; covered by integration tests. this guards the legacy path.
97
+ expect(proxy).toBeTruthy()
98
+ proxy.close()
99
+ })
100
+
101
+ test('distinct façades + singleDb=true coalesces; without flag they would split', async () => {
102
+ const facadePg = makeFacade(pg, 'postgres')
103
+ const facadeCvr = makeFacade(pg, 'cvr')
104
+ const facadeCdb = makeFacade(pg, 'cdb')
105
+
106
+ // sanity: the three façades are distinct refs (would defeat reference equality)
107
+ expect(facadePg).not.toBe(facadeCvr)
108
+ expect(facadePg).not.toBe(facadeCdb)
109
+ expect(facadeCvr).not.toBe(facadeCdb)
110
+
111
+ const instances: PGliteInstances = {
112
+ postgres: facadePg,
113
+ cvr: facadeCvr,
114
+ cdb: facadeCdb,
115
+ postgresReplicas: [],
116
+ }
117
+
118
+ // explicit singleDb=true should still build a working proxy.
119
+ const proxy = await createBrowserProxy(instances, {
120
+ pgPassword: '',
121
+ pgUser: 'u',
122
+ singleDb: true,
123
+ })
124
+ expect(proxy).toBeTruthy()
125
+ proxy.close()
126
+ })
127
+
128
+ test('coordinated query/exec runs through the same per-db mutex', async () => {
129
+ // proxy.query / proxy.exec exist so out-of-band JSON callers (soot's
130
+ // project-server / main-thread SAB JSON channels) go through the same
131
+ // mutex + txState the wire-protocol path uses. this test pins that the
132
+ // API works end-to-end against a shared-PGlite façade setup; the actual
133
+ // 'E'-rescue behaviour is exercised by the soot integration suite where
134
+ // a wire-protocol abort populates txState first.
135
+ const facadePg = makeFacade(pg, 'postgres')
136
+ const facadeCvr = makeFacade(pg, 'cvr')
137
+ const facadeCdb = makeFacade(pg, 'cdb')
138
+ const proxy = await createBrowserProxy(
139
+ {
140
+ postgres: facadePg,
141
+ cvr: facadeCvr,
142
+ cdb: facadeCdb,
143
+ postgresReplicas: [],
144
+ },
145
+ { pgPassword: '', pgUser: 'u', singleDb: true }
146
+ )
147
+
148
+ const r1 = await proxy.query('postgres', 'SELECT 1 AS ok')
149
+ expect(r1.rows).toEqual([{ ok: 1 }])
150
+
151
+ const r2 = await proxy.exec(
152
+ 'postgres',
153
+ 'CREATE TABLE IF NOT EXISTS rescue_test (id int)'
154
+ )
155
+ expect(Array.isArray(r2)).toBe(true)
156
+
157
+ proxy.close()
158
+ })
159
+
160
+ test('singleDb waits for the owning transaction before serving another client', async () => {
161
+ await pg.exec(`
162
+ DROP TABLE IF EXISTS singledb_tx_owner;
163
+ CREATE TABLE singledb_tx_owner (id int);
164
+ `)
165
+ const facadePg = makeFacade(pg, 'postgres')
166
+ const facadeCvr = makeFacade(pg, 'cvr')
167
+ const facadeCdb = makeFacade(pg, 'cdb')
168
+ const proxy = await createBrowserProxy(
169
+ {
170
+ postgres: facadePg,
171
+ cvr: facadeCvr,
172
+ cdb: facadeCdb,
173
+ postgresReplicas: [],
174
+ },
175
+ { pgPassword: '', pgUser: 'u', singleDb: true }
176
+ )
177
+ const sql1 = createSql(proxy)
178
+ const sql2 = createSql(proxy)
179
+ const releaseTx = deferred<void>()
180
+ const txStarted = deferred<void>()
181
+
182
+ const tx = sql1.begin(async (sql) => {
183
+ await sql`INSERT INTO singledb_tx_owner VALUES (1)`
184
+ txStarted.resolve()
185
+ await releaseTx.promise
186
+ })
187
+ await txStarted.promise
188
+
189
+ let readCompleted = false
190
+ const read = sql2`SELECT count(*)::int AS count FROM singledb_tx_owner`.then(
191
+ (rows) => {
192
+ readCompleted = true
193
+ return rows[0]?.count
194
+ }
195
+ )
196
+ await new Promise((resolve) => setTimeout(resolve, 25))
197
+ expect(readCompleted).toBe(false)
198
+
199
+ releaseTx.resolve()
200
+ await tx
201
+ await expect(read).resolves.toBe(1)
202
+
203
+ await sql1.end({ timeout: 1 }).catch(() => {})
204
+ await sql2.end({ timeout: 1 }).catch(() => {})
205
+ proxy.close()
206
+ }, 10_000)
207
+
208
+ test('explicit singleDb=false on distinct façades preserves split mutexes', async () => {
209
+ // negative case: when caller doesn't opt in and refs are distinct, the
210
+ // legacy reference-equality heuristic gives separate mutexes (the bug we
211
+ // shipped around). this test pins the contract that singleDb is opt-in,
212
+ // so adding it later cannot quietly break consumers that rely on split
213
+ // mutexes for their three real PGlite instances.
214
+ const facadePg = makeFacade(pg, 'postgres')
215
+ const facadeCvr = makeFacade(pg, 'cvr')
216
+ const facadeCdb = makeFacade(pg, 'cdb')
217
+
218
+ const instances: PGliteInstances = {
219
+ postgres: facadePg,
220
+ cvr: facadeCvr,
221
+ cdb: facadeCdb,
222
+ postgresReplicas: [],
223
+ }
224
+
225
+ const proxy = await createBrowserProxy(instances, {
226
+ pgPassword: '',
227
+ pgUser: 'u',
228
+ // singleDb omitted — defaults to false
229
+ })
230
+ expect(proxy).toBeTruthy()
231
+ proxy.close()
232
+ })
233
+ })
@@ -245,6 +245,41 @@ function extractParseQuery(data: Uint8Array): string | null {
245
245
  return textDecoder.decode(data.subarray(queryStart, offset))
246
246
  }
247
247
 
248
+ // parse postgres ErrorResponse fields. expects `data` to be the full
249
+ // ErrorResponse message (including type byte and length).
250
+ // returns { code, message } extracted from 'C' and 'M' fields.
251
+ function parseErrorFields(
252
+ data: Uint8Array,
253
+ start: number,
254
+ length: number
255
+ ): {
256
+ code: string
257
+ message: string
258
+ severity: string
259
+ } {
260
+ let code = ''
261
+ let message = ''
262
+ let severity = ''
263
+ // body starts after the 1-byte type + 4-byte length
264
+ let pos = start + 5
265
+ const end = start + 1 + length
266
+ while (pos < end) {
267
+ const fieldType = data[pos]
268
+ if (fieldType === 0) break
269
+ pos++
270
+ const strStart = pos
271
+ while (pos < end && data[pos] !== 0) pos++
272
+ const value = textDecoder.decode(data.subarray(strStart, pos))
273
+ pos++
274
+ if (fieldType === 0x43)
275
+ code = value // 'C' SQLSTATE
276
+ else if (fieldType === 0x4d)
277
+ message = value // 'M' message
278
+ else if (fieldType === 0x53) severity = value // 'S' severity
279
+ }
280
+ return { code, message, severity }
281
+ }
282
+
248
283
  /**
249
284
  * rebuild a Parse message with a modified query string.
250
285
  */
@@ -704,14 +739,50 @@ function messagePortToDuplex(port: MessagePort): {
704
739
  return { duplex: { readable, writable }, rawWrite }
705
740
  }
706
741
 
742
+ /**
743
+ * wire-protocol database name. anything not matching `zero_cvr` / `zero_cdb`
744
+ * routes to postgres (see `getDbContext`).
745
+ */
746
+ export type BrowserProxyDbName = 'postgres' | 'zero_cvr' | 'zero_cdb'
747
+
707
748
  export interface BrowserProxy {
708
749
  handleConnection(port: MessagePort): void
709
750
  close(): void
751
+ /**
752
+ * mutex-coordinated query for out-of-band JSON callers (e.g. soot's
753
+ * project-server / main-thread SAB JSON channels). takes the same per-db
754
+ * mutex the wire-protocol path uses and rescues a stale aborted txn before
755
+ * running. critical in singleDb mode where wire-protocol roles share a
756
+ * single PGlite session with these external callers — without going through
757
+ * here, an external query can land on an `E`-state session left by a
758
+ * different role and fail with "current transaction is aborted".
759
+ */
760
+ query(
761
+ dbName: BrowserProxyDbName,
762
+ sql: string,
763
+ params?: unknown[]
764
+ ): Promise<{ rows: unknown[]; affectedRows?: number }>
765
+ exec(dbName: BrowserProxyDbName, sql: string): Promise<Array<{ affectedRows: number }>>
710
766
  }
711
767
 
712
768
  export async function createBrowserProxy(
713
769
  dbInput: PGlite | PGliteInstances,
714
- config: { pgPassword: string; pgUser: string; pgPort?: number; logLevel?: string }
770
+ config: {
771
+ pgPassword: string
772
+ pgUser: string
773
+ pgPort?: number
774
+ logLevel?: string
775
+ /**
776
+ * declares that postgres/cvr/cdb are all backed by the same underlying
777
+ * PGlite, even if the references are distinct proxy wrappers. forces
778
+ * mutex coalescing so concurrent protocol messages cannot interleave on
779
+ * the shared instance (named-statement collisions, replication-slot races).
780
+ * defaults to autodetect via reference equality — set explicitly when the
781
+ * caller wraps one PGlite in three thin façades (e.g. orez-web's port
782
+ * proxies) that fool the reference check.
783
+ */
784
+ singleDb?: boolean
785
+ }
715
786
  ): Promise<BrowserProxy> {
716
787
  // normalize input: single PGlite instance = use it for all databases (backwards compat for tests)
717
788
  const instances: PGliteInstances =
@@ -727,8 +798,11 @@ export async function createBrowserProxy(
727
798
  // per-instance mutexes for serializing pglite access.
728
799
  // when all instances are the same object (single-db mode), share one mutex
729
800
  // to prevent concurrent protocol messages on the same pglite instance.
801
+ // explicit singleDb wins over reference equality — callers like orez-web
802
+ // hand us three distinct wrapper objects that all point at one PGlite.
730
803
  const sharedInstance =
731
- instances.postgres === instances.cvr && instances.postgres === instances.cdb
804
+ config.singleDb === true ||
805
+ (instances.postgres === instances.cvr && instances.postgres === instances.cdb)
732
806
  const pgMutex = new Mutex()
733
807
  const mutexes = {
734
808
  postgres: pgMutex,
@@ -737,11 +811,18 @@ export async function createBrowserProxy(
737
811
  }
738
812
 
739
813
  // per-instance transaction state: tracks which connection owns the current transaction
740
- // so we can auto-ROLLBACK stale aborted transactions from other connections
814
+ // so we can auto-ROLLBACK stale aborted transactions from other connections.
815
+ // shared-instance (singleDb) coalesces txState too — pglite is single-session,
816
+ // so an 'E' state from role A's aborted txn poisons every subsequent query
817
+ // unless role B can see "yes there's an aborted txn here" and ROLLBACK before
818
+ // its own work. without coalesce, each role's per-role txState reads idle
819
+ // even when pglite's actual session is in E, and the rescue at every
820
+ // mutex.acquire() site never fires for the foreign role.
821
+ const pgTxState: PgLiteTxState = { status: 0x49, owner: null }
741
822
  const txStates: Record<string, PgLiteTxState> = {
742
- postgres: { status: 0x49, owner: null },
743
- cvr: { status: 0x49, owner: null },
744
- cdb: { status: 0x49, owner: null },
823
+ postgres: pgTxState,
824
+ cvr: sharedInstance ? pgTxState : { status: 0x49, owner: null },
825
+ cdb: sharedInstance ? pgTxState : { status: 0x49, owner: null },
745
826
  }
746
827
 
747
828
  // helper to get instance + mutex + tx state for a database name
@@ -757,6 +838,63 @@ export async function createBrowserProxy(
757
838
  return { db: instances.postgres, mutex: mutexes.postgres, txState: txStates.postgres }
758
839
  }
759
840
 
841
+ const waitOneTurn = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
842
+
843
+ async function acquireForOwner(
844
+ db: PGlite,
845
+ mutex: Mutex,
846
+ txState: PgLiteTxState,
847
+ owner: object | null
848
+ ) {
849
+ while (true) {
850
+ await mutex.acquire()
851
+ const ownsCurrentTransaction = owner !== null && txState.owner === owner
852
+ if (txState.status === 0x54 && !ownsCurrentTransaction) {
853
+ mutex.release()
854
+ await waitOneTurn()
855
+ continue
856
+ }
857
+ if (txState.status === 0x45 && !ownsCurrentTransaction) {
858
+ try {
859
+ await db.exec('ROLLBACK')
860
+ } catch {}
861
+ txState.status = 0x49
862
+ txState.owner = null
863
+ }
864
+ return
865
+ }
866
+ }
867
+
868
+ function tryAcquireForOwner(
869
+ mutex: Mutex,
870
+ txState: PgLiteTxState,
871
+ owner: object | null
872
+ ) {
873
+ if (!mutex.tryAcquire()) return false
874
+ const ownsCurrentTransaction = owner !== null && txState.owner === owner
875
+ if ((txState.status === 0x54 || txState.status === 0x45) && !ownsCurrentTransaction) {
876
+ mutex.release()
877
+ return false
878
+ }
879
+ return true
880
+ }
881
+
882
+ function transactionAwareMutex(
883
+ db: PGlite,
884
+ mutex: Mutex,
885
+ txState: PgLiteTxState,
886
+ owner: object | null
887
+ ): Mutex {
888
+ return {
889
+ get isLocked() {
890
+ return mutex.isLocked
891
+ },
892
+ acquire: () => acquireForOwner(db, mutex, txState, owner),
893
+ tryAcquire: () => tryAcquireForOwner(mutex, txState, owner),
894
+ release: () => mutex.release(),
895
+ } as Mutex
896
+ }
897
+
760
898
  // signal replication handler after extended protocol writes complete.
761
899
  // 8ms leading-edge debounce: fires exactly 8ms after the FIRST write,
762
900
  // subsequent writes within that window are batched (handler polls all
@@ -943,6 +1081,9 @@ export async function createBrowserProxy(
943
1081
  let pipelineMutexHeld = false
944
1082
  let extWritePending = false
945
1083
  let pipelineBuffer: Uint8Array[] = []
1084
+ // remember the most recent Parse / SimpleQuery for diagnostic logging when
1085
+ // an ErrorResponse fires. trimmed to 200 chars so we don't blow up logs.
1086
+ let lastQueryForDiag = ''
946
1087
 
947
1088
  function installQueryHandler() {
948
1089
  // message buffer: postgres sends multiple protocol messages in one write,
@@ -1038,12 +1179,17 @@ export async function createBrowserProxy(
1038
1179
  port.close()
1039
1180
  }
1040
1181
  port.onmessage = () => {}
1041
- handleStartReplication(query, writer, db, mutex).catch(() => {})
1182
+ handleStartReplication(
1183
+ query,
1184
+ writer,
1185
+ db,
1186
+ transactionAwareMutex(db, mutex, txState, null)
1187
+ ).catch(() => {})
1042
1188
  return
1043
1189
  }
1044
1190
 
1045
1191
  // replication queries (IDENTIFY_SYSTEM, CREATE/DROP SLOT)
1046
- await mutex.acquire()
1192
+ await acquireForOwner(db, mutex, txState, null)
1047
1193
  try {
1048
1194
  const response = await handleReplicationQuery(query, db)
1049
1195
  if (response) {
@@ -1095,24 +1241,20 @@ export async function createBrowserProxy(
1095
1241
 
1096
1242
  if (isExtendedMsg || isSyncInPipeline) {
1097
1243
  if (!pipelineMutexHeld) {
1098
- await mutex.acquire()
1244
+ await acquireForOwner(db, mutex, txState, connId)
1099
1245
  pipelineMutexHeld = true
1100
1246
  pipelineBuffer = []
1101
- // auto-rollback stale transactions
1102
- if (txState.status === 0x45 && txState.owner !== connId) {
1103
- try {
1104
- await db.exec('ROLLBACK')
1105
- } catch {}
1106
- txState.status = 0x49
1107
- txState.owner = null
1108
- }
1109
1247
  }
1110
1248
 
1111
- // detect writes for replication signaling
1112
- if (dbName === 'postgres' && msgType === 0x50) {
1113
- const q = extractParseQuery(data)?.trimStart().toLowerCase()
1114
- if (q && /^(insert|update|delete|copy|truncate)/.test(q)) {
1115
- extWritePending = true
1249
+ // detect writes for replication signaling and remember last query
1250
+ if (msgType === 0x50) {
1251
+ const parsed = extractParseQuery(data)
1252
+ if (parsed) lastQueryForDiag = parsed.slice(0, 200)
1253
+ if (dbName === 'postgres') {
1254
+ const q = parsed?.trimStart().toLowerCase()
1255
+ if (q && /^(insert|update|delete|copy|truncate)/.test(q)) {
1256
+ extWritePending = true
1257
+ }
1116
1258
  }
1117
1259
  }
1118
1260
 
@@ -1195,9 +1337,12 @@ export async function createBrowserProxy(
1195
1337
  const t = result[pos]
1196
1338
  const l = readInt32BE(result, pos + 1)
1197
1339
  if (t === 0x45) {
1198
- // ErrorResponse
1340
+ const errFields = parseErrorFields(result, pos, l)
1199
1341
  console.warn(
1200
- `[pg-proxy-raw] ErrorResponse in Sync! db=${dbName} rfq=${rfq === 0x49 ? 'I' : rfq === 0x45 ? 'E' : rfq}`
1342
+ `[pg-proxy-raw] ErrorResponse in Sync! db=${dbName} rfq=${rfq === 0x49 ? 'I' : rfq === 0x45 ? 'E' : rfq} ` +
1343
+ `code=${errFields.code} severity=${errFields.severity} ` +
1344
+ `msg=${JSON.stringify(errFields.message)} ` +
1345
+ `query=${JSON.stringify(lastQueryForDiag)}`
1201
1346
  )
1202
1347
  }
1203
1348
  pos += 1 + l
@@ -1231,15 +1376,8 @@ export async function createBrowserProxy(
1231
1376
  }
1232
1377
 
1233
1378
  data = interceptQuery(data)
1234
- await mutex.acquire()
1379
+ await acquireForOwner(db, mutex, txState, connId)
1235
1380
  try {
1236
- if (txState.status === 0x45 && txState.owner !== connId) {
1237
- try {
1238
- await db.exec('ROLLBACK')
1239
- } catch {}
1240
- txState.status = 0x49
1241
- txState.owner = null
1242
- }
1243
1381
  let result = await db.execProtocolRaw(data, { syncToFs: false })
1244
1382
  const rfqStatus = getReadyForQueryStatus(result)
1245
1383
  if (rfqStatus !== null) {
@@ -1287,19 +1425,30 @@ export async function createBrowserProxy(
1287
1425
  // connection closed flag
1288
1426
  let connClosed = false
1289
1427
 
1290
- // clean up pglite transaction state when the connection ends
1428
+ // clean up pglite transaction state when the connection ends.
1429
+ // CRITICAL: only ROLLBACK if THIS connection owns the current pglite
1430
+ // transaction. pglite is single-session and the singleDb path coalesces
1431
+ // txState across roles, so an unconditional ROLLBACK here clobbers any
1432
+ // OTHER connection's active transaction — exactly the cross-role
1433
+ // pollution that 2eb1873 fixed on the node side. browser cleanup was
1434
+ // missed and showed up as "current transaction is aborted, commands
1435
+ // ignored" when a foreign connection closed mid-someone-else's-txn.
1291
1436
  const cleanup = async () => {
1292
1437
  if (connClosed) return
1293
1438
  connClosed = true
1294
1439
  // replication connections don't own a transaction — skip ROLLBACK
1295
1440
  if (isReplicationConnection) return
1296
1441
  try {
1297
- const { db, mutex } = getDbContext(dbName)
1442
+ const { db, mutex, txState } = getDbContext(dbName)
1298
1443
  await mutex.acquire()
1299
1444
  try {
1300
- await db.exec('ROLLBACK')
1445
+ if (txState.owner === connId && txState.status !== 0x49) {
1446
+ await db.exec('ROLLBACK')
1447
+ txState.status = 0x49
1448
+ txState.owner = null
1449
+ }
1301
1450
  } catch {
1302
- // no transaction to rollback, or db is closed
1451
+ // db is closed or rollback failed ignore
1303
1452
  } finally {
1304
1453
  mutex.release()
1305
1454
  }
@@ -1413,17 +1562,9 @@ export async function createBrowserProxy(
1413
1562
  // acquire mutex on first message of pipeline
1414
1563
  if (!pipelineMutexHeld) {
1415
1564
  const t0 = performance.now()
1416
- await mutex.acquire()
1565
+ await acquireForOwner(db, mutex, txState, connId)
1417
1566
  proxyStats.totalWaitMs += performance.now() - t0
1418
1567
  pipelineMutexHeld = true
1419
- // auto-rollback stale transactions from other connections
1420
- if (txState.status === 0x45 && txState.owner !== connId) {
1421
- try {
1422
- await db.exec('ROLLBACK')
1423
- } catch {}
1424
- txState.status = 0x49
1425
- txState.owner = null
1426
- }
1427
1568
  }
1428
1569
 
1429
1570
  // detect extended protocol writes for replication signaling
@@ -1527,14 +1668,7 @@ export async function createBrowserProxy(
1527
1668
 
1528
1669
  const execute = async (): Promise<Uint8Array> => {
1529
1670
  const t0 = performance.now()
1530
- await mutex.acquire()
1531
- if (txState.status === 0x45 && txState.owner !== connId) {
1532
- try {
1533
- await db.exec('ROLLBACK')
1534
- } catch {}
1535
- txState.status = 0x49
1536
- txState.owner = null
1537
- }
1671
+ await acquireForOwner(db, mutex, txState, connId)
1538
1672
  const t1 = performance.now()
1539
1673
  let result: Uint8Array
1540
1674
  try {
@@ -1619,12 +1753,58 @@ export async function createBrowserProxy(
1619
1753
  }
1620
1754
  }
1621
1755
 
1756
+ // detect writes that need replication signaling. wire path does this on
1757
+ // postgres-bound INSERT/UPDATE/DELETE/COPY/TRUNCATE — same rule applies to
1758
+ // out-of-band callers. without it, a project-server write via SAB JSON
1759
+ // wouldn't wake up the replication stream until the next wire-protocol
1760
+ // write came along.
1761
+ function maybeSignalWriteForExternal(dbName: string, sql: string) {
1762
+ if (dbName !== 'postgres' && dbName !== '') return
1763
+ const head = sql.trimStart().toLowerCase()
1764
+ if (/^(insert|update|delete|copy|truncate)/.test(head)) {
1765
+ signalWrite()
1766
+ }
1767
+ }
1768
+
1769
+ async function externalQuery(dbName: string, sql: string, params?: unknown[]) {
1770
+ if (closed) throw new Error('pg-proxy is closed')
1771
+ const { db, mutex, txState } = getDbContext(dbName)
1772
+ await acquireForOwner(db, mutex, txState, null)
1773
+ try {
1774
+ const result = await db.query(sql, params as Array<unknown> | undefined)
1775
+ maybeSignalWriteForExternal(dbName, sql)
1776
+ return {
1777
+ rows: result.rows as unknown[],
1778
+ affectedRows: result.affectedRows,
1779
+ }
1780
+ } finally {
1781
+ mutex.release()
1782
+ }
1783
+ }
1784
+
1785
+ async function externalExec(dbName: string, sql: string) {
1786
+ if (closed) throw new Error('pg-proxy is closed')
1787
+ const { db, mutex, txState } = getDbContext(dbName)
1788
+ await acquireForOwner(db, mutex, txState, null)
1789
+ try {
1790
+ const result = await db.exec(sql)
1791
+ maybeSignalWriteForExternal(dbName, sql)
1792
+ return result.map((r: { affectedRows?: number }) => ({
1793
+ affectedRows: r.affectedRows ?? 0,
1794
+ }))
1795
+ } finally {
1796
+ mutex.release()
1797
+ }
1798
+ }
1799
+
1622
1800
  return {
1623
1801
  handleConnection,
1624
1802
  close() {
1625
1803
  closed = true
1626
1804
  signalPending = false
1627
1805
  },
1806
+ query: externalQuery,
1807
+ exec: externalExec,
1628
1808
  }
1629
1809
  }
1630
1810
 
package/src/pg-proxy.ts CHANGED
@@ -560,8 +560,11 @@ export async function startPgProxy(
560
560
  // per-instance mutexes for serializing pglite access.
561
561
  // when all instances are the same object (single-db mode), share one mutex
562
562
  // to prevent concurrent protocol messages on the same pglite instance.
563
+ // explicit config.singleDb wins over reference equality — callers that wrap
564
+ // one PGlite in three distinct façades still need coalesced mutexes.
563
565
  const sharedInstance =
564
- instances.postgres === instances.cvr && instances.postgres === instances.cdb
566
+ config.singleDb === true ||
567
+ (instances.postgres === instances.cvr && instances.postgres === instances.cdb)
565
568
  const pgMutex = new Mutex()
566
569
  const mutexes = {
567
570
  postgres: pgMutex,
@@ -570,11 +573,15 @@ export async function startPgProxy(
570
573
  }
571
574
 
572
575
  // per-instance transaction state: tracks which socket owns the current transaction
573
- // so we can auto-ROLLBACK stale aborted transactions from other connections
576
+ // so we can auto-ROLLBACK stale aborted transactions from other connections.
577
+ // shared-instance (singleDb) coalesces txState — pglite is single-session,
578
+ // so an 'E' state from role A's aborted txn poisons every subsequent query
579
+ // unless role B can see the aborted state and ROLLBACK before its own work.
580
+ const pgTxState: PgLiteTxState = { status: 0x49, owner: null }
574
581
  const txStates: Record<string, PgLiteTxState> = {
575
- postgres: { status: 0x49, owner: null },
576
- cvr: { status: 0x49, owner: null },
577
- cdb: { status: 0x49, owner: null },
582
+ postgres: pgTxState,
583
+ cvr: sharedInstance ? pgTxState : { status: 0x49, owner: null },
584
+ cdb: sharedInstance ? pgTxState : { status: 0x49, owner: null },
578
585
  }
579
586
 
580
587
  // helper to get instance + mutex + tx state for a database name