orez 0.2.12 → 0.2.13

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.
@@ -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
@@ -943,6 +1024,9 @@ export async function createBrowserProxy(
943
1024
  let pipelineMutexHeld = false
944
1025
  let extWritePending = false
945
1026
  let pipelineBuffer: Uint8Array[] = []
1027
+ // remember the most recent Parse / SimpleQuery for diagnostic logging when
1028
+ // an ErrorResponse fires. trimmed to 200 chars so we don't blow up logs.
1029
+ let lastQueryForDiag = ''
946
1030
 
947
1031
  function installQueryHandler() {
948
1032
  // message buffer: postgres sends multiple protocol messages in one write,
@@ -1108,11 +1192,15 @@ export async function createBrowserProxy(
1108
1192
  }
1109
1193
  }
1110
1194
 
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
1195
+ // detect writes for replication signaling and remember last query
1196
+ if (msgType === 0x50) {
1197
+ const parsed = extractParseQuery(data)
1198
+ if (parsed) lastQueryForDiag = parsed.slice(0, 200)
1199
+ if (dbName === 'postgres') {
1200
+ const q = parsed?.trimStart().toLowerCase()
1201
+ if (q && /^(insert|update|delete|copy|truncate)/.test(q)) {
1202
+ extWritePending = true
1203
+ }
1116
1204
  }
1117
1205
  }
1118
1206
 
@@ -1195,9 +1283,12 @@ export async function createBrowserProxy(
1195
1283
  const t = result[pos]
1196
1284
  const l = readInt32BE(result, pos + 1)
1197
1285
  if (t === 0x45) {
1198
- // ErrorResponse
1286
+ const errFields = parseErrorFields(result, pos, l)
1199
1287
  console.warn(
1200
- `[pg-proxy-raw] ErrorResponse in Sync! db=${dbName} rfq=${rfq === 0x49 ? 'I' : rfq === 0x45 ? 'E' : rfq}`
1288
+ `[pg-proxy-raw] ErrorResponse in Sync! db=${dbName} rfq=${rfq === 0x49 ? 'I' : rfq === 0x45 ? 'E' : rfq} ` +
1289
+ `code=${errFields.code} severity=${errFields.severity} ` +
1290
+ `msg=${JSON.stringify(errFields.message)} ` +
1291
+ `query=${JSON.stringify(lastQueryForDiag)}`
1201
1292
  )
1202
1293
  }
1203
1294
  pos += 1 + l
@@ -1287,19 +1378,30 @@ export async function createBrowserProxy(
1287
1378
  // connection closed flag
1288
1379
  let connClosed = false
1289
1380
 
1290
- // clean up pglite transaction state when the connection ends
1381
+ // clean up pglite transaction state when the connection ends.
1382
+ // CRITICAL: only ROLLBACK if THIS connection owns the current pglite
1383
+ // transaction. pglite is single-session and the singleDb path coalesces
1384
+ // txState across roles, so an unconditional ROLLBACK here clobbers any
1385
+ // OTHER connection's active transaction — exactly the cross-role
1386
+ // pollution that 2eb1873 fixed on the node side. browser cleanup was
1387
+ // missed and showed up as "current transaction is aborted, commands
1388
+ // ignored" when a foreign connection closed mid-someone-else's-txn.
1291
1389
  const cleanup = async () => {
1292
1390
  if (connClosed) return
1293
1391
  connClosed = true
1294
1392
  // replication connections don't own a transaction — skip ROLLBACK
1295
1393
  if (isReplicationConnection) return
1296
1394
  try {
1297
- const { db, mutex } = getDbContext(dbName)
1395
+ const { db, mutex, txState } = getDbContext(dbName)
1298
1396
  await mutex.acquire()
1299
1397
  try {
1300
- await db.exec('ROLLBACK')
1398
+ if (txState.owner === connId && txState.status !== 0x49) {
1399
+ await db.exec('ROLLBACK')
1400
+ txState.status = 0x49
1401
+ txState.owner = null
1402
+ }
1301
1403
  } catch {
1302
- // no transaction to rollback, or db is closed
1404
+ // db is closed or rollback failed ignore
1303
1405
  } finally {
1304
1406
  mutex.release()
1305
1407
  }
@@ -1619,12 +1721,81 @@ export async function createBrowserProxy(
1619
1721
  }
1620
1722
  }
1621
1723
 
1724
+ // rescue any stale aborted txn left on the shared session before running an
1725
+ // out-of-band JSON query. external callers don't own a transaction (each
1726
+ // call is a single statement), so we always rescue when status is 'E' —
1727
+ // there's no "is this mine" check like the wire path has.
1728
+ async function rescueStaleAborted(db: PGlite, txState: PgLiteTxState) {
1729
+ if (txState.status === 0x45) {
1730
+ try {
1731
+ await db.exec('ROLLBACK')
1732
+ } catch (err) {
1733
+ // surfaced for diagnosis only — a failing ROLLBACK still leaves us in
1734
+ // a state where the next statement will get 25P02; that's the signal
1735
+ // the caller needs anyway.
1736
+ log.proxy(
1737
+ `[pg-proxy] rescueStaleAborted: ROLLBACK failed: ${(err as Error)?.message || err}`
1738
+ )
1739
+ }
1740
+ txState.status = 0x49
1741
+ txState.owner = null
1742
+ }
1743
+ }
1744
+
1745
+ // detect writes that need replication signaling. wire path does this on
1746
+ // postgres-bound INSERT/UPDATE/DELETE/COPY/TRUNCATE — same rule applies to
1747
+ // out-of-band callers. without it, a project-server write via SAB JSON
1748
+ // wouldn't wake up the replication stream until the next wire-protocol
1749
+ // write came along.
1750
+ function maybeSignalWriteForExternal(dbName: string, sql: string) {
1751
+ if (dbName !== 'postgres' && dbName !== '') return
1752
+ const head = sql.trimStart().toLowerCase()
1753
+ if (/^(insert|update|delete|copy|truncate)/.test(head)) {
1754
+ signalWrite()
1755
+ }
1756
+ }
1757
+
1758
+ async function externalQuery(dbName: string, sql: string, params?: unknown[]) {
1759
+ if (closed) throw new Error('pg-proxy is closed')
1760
+ const { db, mutex, txState } = getDbContext(dbName)
1761
+ await mutex.acquire()
1762
+ try {
1763
+ await rescueStaleAborted(db, txState)
1764
+ const result = await db.query(sql, params as Array<unknown> | undefined)
1765
+ maybeSignalWriteForExternal(dbName, sql)
1766
+ return {
1767
+ rows: result.rows as unknown[],
1768
+ affectedRows: result.affectedRows,
1769
+ }
1770
+ } finally {
1771
+ mutex.release()
1772
+ }
1773
+ }
1774
+
1775
+ async function externalExec(dbName: string, sql: string) {
1776
+ if (closed) throw new Error('pg-proxy is closed')
1777
+ const { db, mutex, txState } = getDbContext(dbName)
1778
+ await mutex.acquire()
1779
+ try {
1780
+ await rescueStaleAborted(db, txState)
1781
+ const result = await db.exec(sql)
1782
+ maybeSignalWriteForExternal(dbName, sql)
1783
+ return result.map((r: { affectedRows?: number }) => ({
1784
+ affectedRows: r.affectedRows ?? 0,
1785
+ }))
1786
+ } finally {
1787
+ mutex.release()
1788
+ }
1789
+ }
1790
+
1622
1791
  return {
1623
1792
  handleConnection,
1624
1793
  close() {
1625
1794
  closed = true
1626
1795
  signalPending = false
1627
1796
  },
1797
+ query: externalQuery,
1798
+ exec: externalExec,
1628
1799
  }
1629
1800
  }
1630
1801
 
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
@@ -4,8 +4,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
4
4
  import { Mutex } from '../mutex'
5
5
  import { installChangeTracking } from './change-tracker'
6
6
  import {
7
+ extractStartLsn,
7
8
  handleReplicationQuery,
8
9
  handleStartReplication,
10
+ lsnFromString,
9
11
  resetReplicationState,
10
12
  signalReplicationChange,
11
13
  type ReplicationWriter,
@@ -404,3 +406,71 @@ describe('InProcessWriter', () => {
404
406
  expect(rw.closed).toBe(false)
405
407
  })
406
408
  })
409
+
410
+ describe('lsnFromString', () => {
411
+ it('parses 0/0 to 0n', () => {
412
+ expect(lsnFromString('0/0')).toBe(0n)
413
+ })
414
+
415
+ it('parses simple LSN', () => {
416
+ expect(lsnFromString('0/1000000')).toBe(0x1000000n)
417
+ })
418
+
419
+ it('combines high and low halves', () => {
420
+ expect(lsnFromString('1/0')).toBe(0x100000000n)
421
+ expect(lsnFromString('1/1')).toBe(0x100000001n)
422
+ expect(lsnFromString('A/B')).toBe(0xa0000000bn)
423
+ })
424
+
425
+ it('is case-insensitive', () => {
426
+ expect(lsnFromString('0/ff')).toBe(0xffn)
427
+ expect(lsnFromString('0/FF')).toBe(0xffn)
428
+ })
429
+
430
+ it('tolerates surrounding whitespace', () => {
431
+ expect(lsnFromString(' 0/100 ')).toBe(0x100n)
432
+ })
433
+
434
+ it('returns null for malformed input', () => {
435
+ expect(lsnFromString('0')).toBeNull()
436
+ expect(lsnFromString('0/')).toBeNull()
437
+ expect(lsnFromString('/0')).toBeNull()
438
+ expect(lsnFromString('xyz')).toBeNull()
439
+ expect(lsnFromString('')).toBeNull()
440
+ })
441
+ })
442
+
443
+ describe('extractStartLsn', () => {
444
+ it('extracts from a basic START_REPLICATION query', () => {
445
+ expect(extractStartLsn('START_REPLICATION SLOT "zero" LOGICAL 0/01000300')).toBe(
446
+ 0x1000300n
447
+ )
448
+ })
449
+
450
+ it('handles trailing options', () => {
451
+ expect(
452
+ extractStartLsn(
453
+ `START_REPLICATION SLOT "zero" LOGICAL 0/01000300 (proto_version '4', publication_names 'orez_zero_public')`
454
+ )
455
+ ).toBe(0x1000300n)
456
+ })
457
+
458
+ it('handles 0/0 (fresh slot)', () => {
459
+ expect(extractStartLsn('START_REPLICATION SLOT "zero" LOGICAL 0/0')).toBe(0n)
460
+ })
461
+
462
+ it('handles quoted LSN', () => {
463
+ expect(extractStartLsn(`START_REPLICATION SLOT "zero" LOGICAL '0/01000300'`)).toBe(
464
+ 0x1000300n
465
+ )
466
+ })
467
+
468
+ it('is case-insensitive on the keyword', () => {
469
+ expect(extractStartLsn('start_replication slot "z" logical 0/abc')).toBe(0xabcn)
470
+ })
471
+
472
+ it('returns null when no LSN is present', () => {
473
+ expect(extractStartLsn('START_REPLICATION SLOT "z"')).toBeNull()
474
+ expect(extractStartLsn('IDENTIFY_SYSTEM')).toBeNull()
475
+ })
476
+ })
@@ -133,6 +133,30 @@ function lsnToString(lsn: bigint): string {
133
133
  return `${high.toString(16).toUpperCase()}/${low.toString(16).toUpperCase()}`
134
134
  }
135
135
 
136
+ /**
137
+ * parse an LSN string like "0/01000300" into a bigint.
138
+ * returns null if the string doesn't match (e.g. "0/0" still parses to 0n).
139
+ */
140
+ function lsnFromString(s: string): bigint | null {
141
+ const m = s.trim().match(/^([0-9a-f]+)\/([0-9a-f]+)$/i)
142
+ if (!m) return null
143
+ return (BigInt('0x' + m[1]) << 32n) | BigInt('0x' + m[2])
144
+ }
145
+
146
+ /**
147
+ * extract the client-supplied LSN from a START_REPLICATION query.
148
+ * format: START_REPLICATION SLOT name LOGICAL <high>/<low> [proto_version 'N', publication_names 'X']
149
+ * accepts optional surrounding quotes (some clients send `LOGICAL '0/0'`).
150
+ * returns null if no parseable LSN is found.
151
+ */
152
+ export function extractStartLsn(query: string): bigint | null {
153
+ const m = query.match(/\bLOGICAL\s+'?([0-9a-f]+\/[0-9a-f]+)'?/i)
154
+ if (!m) return null
155
+ return lsnFromString(m[1])
156
+ }
157
+
158
+ export { lsnFromString }
159
+
136
160
  function nowMicros(): bigint {
137
161
  return BigInt(Date.now()) * 1000n
138
162
  }
@@ -359,6 +383,23 @@ export async function handleStartReplication(
359
383
  ): Promise<void> {
360
384
  log.debug.repl('entering streaming mode')
361
385
 
386
+ // honor zero-cache's resume LSN. without this, after a page reload the
387
+ // in-memory currentLsn / lastStreamedWatermark are reset to defaults but
388
+ // changeLog persists with prior LSNs — re-streaming from BIGINT 0 makes
389
+ // the change-streamer try to INSERT (watermark, pos) tuples that already
390
+ // exist, hitting `changeLog_pkey` violations and tearing down the loop.
391
+ // by advancing currentLsn past the client's last-seen LSN we guarantee
392
+ // newly-emitted batches use strictly higher LSNs, and by jumping
393
+ // lastStreamedWatermark to the current sequence value we skip already-
394
+ // streamed _zero_changes rows.
395
+ const clientStartLsn = extractStartLsn(query)
396
+ if (clientStartLsn !== null && clientStartLsn > currentLsn) {
397
+ log.debug.repl(
398
+ `advancing currentLsn ${lsnToString(currentLsn)} → ${lsnToString(clientStartLsn)} from client START_REPLICATION`
399
+ )
400
+ currentLsn = clientStartLsn
401
+ }
402
+
362
403
  // send CopyBothResponse to enter streaming mode
363
404
  const copyBoth = new Uint8Array(1 + 4 + 1 + 2)
364
405
  copyBoth[0] = 0x57 // 'W' CopyBothResponse
@@ -368,7 +409,29 @@ export async function handleStartReplication(
368
409
  writer.write(copyBoth)
369
410
 
370
411
  // resume from where the previous handler left off to avoid
371
- // replaying already-streamed changes after reconnect
412
+ // replaying already-streamed changes after reconnect.
413
+ // when client supplied a NON-ZERO LSN (i.e. this is a reconnect to an
414
+ // existing slot with prior progress), also bump lastStreamedWatermark to
415
+ // the current sequence value — anything before that has already been
416
+ // written to changeLog, so re-streaming would just produce duplicate-key
417
+ // errors. `0/0` indicates "fresh slot" and must NOT trigger this jump,
418
+ // otherwise we'd skip rows that legitimately need to be streamed for the
419
+ // initial sync.
420
+ if (clientStartLsn !== null && clientStartLsn > 0n) {
421
+ try {
422
+ const currentWm = await getCurrentWatermark(db)
423
+ if (currentWm > lastStreamedWatermark) {
424
+ log.debug.repl(
425
+ `advancing lastStreamedWatermark ${lastStreamedWatermark} → ${currentWm} on reconnect`
426
+ )
427
+ lastStreamedWatermark = currentWm
428
+ }
429
+ } catch (err) {
430
+ log.repl(
431
+ `getCurrentWatermark failed on reconnect: ${(err as Error)?.message || err}`
432
+ )
433
+ }
434
+ }
372
435
  let lastWatermark = lastStreamedWatermark
373
436
 
374
437
  // use cached setup results on reconnect to avoid holding the mutex