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
|
@@ -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
|