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