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.
- package/dist/pg-proxy-browser.d.ts +31 -0
- package/dist/pg-proxy-browser.d.ts.map +1 -1
- package/dist/pg-proxy-browser.js +172 -56
- package/dist/pg-proxy-browser.js.map +1 -1
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +12 -5
- package/dist/pg-proxy.js.map +1 -1
- package/dist/replication/handler.d.ts +13 -0
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +57 -1
- package/dist/replication/handler.js.map +1 -1
- package/package.json +2 -2
- package/src/pg-proxy-browser.singledb.test.ts +233 -0
- package/src/pg-proxy-browser.ts +233 -53
- package/src/pg-proxy.ts +12 -5
- package/src/replication/handler.test.ts +70 -0
- package/src/replication/handler.ts +64 -1
|
@@ -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
|
+
})
|
package/src/pg-proxy-browser.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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 (
|
|
1113
|
-
const
|
|
1114
|
-
if (
|
|
1115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|