orez 0.0.47 → 0.0.48
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/admin/http-proxy.d.ts.map +1 -1
- package/dist/admin/http-proxy.js.map +1 -1
- package/dist/admin/log-store.d.ts.map +1 -1
- package/dist/admin/log-store.js.map +1 -1
- package/dist/admin/server.d.ts +2 -2
- package/dist/admin/server.d.ts.map +1 -1
- package/dist/admin/server.js.map +1 -1
- package/dist/admin/ui.d.ts.map +1 -1
- package/dist/admin/ui.js +2 -2
- package/dist/admin/ui.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -37
- package/dist/index.js.map +1 -1
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js.map +1 -1
- package/dist/pglite-manager.d.ts.map +1 -1
- package/dist/pglite-manager.js +7 -1
- package/dist/pglite-manager.js.map +1 -1
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +16 -29
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +23 -6
- package/dist/replication/handler.js.map +1 -1
- package/dist/vite-plugin.js +1 -1
- package/dist/vite-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/admin/http-proxy.ts +5 -1
- package/src/admin/log-store.ts +4 -1
- package/src/admin/server.ts +7 -3
- package/src/admin/ui.ts +682 -680
- package/src/cli.ts +19 -18
- package/src/index.ts +87 -40
- package/src/pg-proxy.ts +3 -1
- package/src/pglite-manager.ts +8 -1
- package/src/replication/change-tracker.ts +20 -30
- package/src/replication/handler.ts +40 -13
- package/src/replication/pgoutput-encoder.test.ts +217 -0
- package/src/replication/zero-compat.test.ts +232 -1
- package/src/shim/hooks.mjs +33 -0
- package/src/vite-plugin.ts +1 -1
|
@@ -364,6 +364,223 @@ describe('pgoutput-encoder', () => {
|
|
|
364
364
|
})
|
|
365
365
|
})
|
|
366
366
|
|
|
367
|
+
// roundtrip tests: encode with orez → parse with zero-cache's parser
|
|
368
|
+
// this validates the fundamental contract between orez and zero-cache
|
|
369
|
+
describe('roundtrip: orez encoder → zero-cache parser', () => {
|
|
370
|
+
// absolute path bypasses package.json exports restriction
|
|
371
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
372
|
+
const { PgoutputParser } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/logical-replication/pgoutput-parser.js')
|
|
373
|
+
|
|
374
|
+
// mock type parsers: unknown OIDs default to String (identity for text)
|
|
375
|
+
const typeParsers = { getTypeParser: () => String }
|
|
376
|
+
|
|
377
|
+
function makeParser() {
|
|
378
|
+
return new PgoutputParser(typeParsers)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
it('BEGIN roundtrip', () => {
|
|
382
|
+
const lsn = 0x1000200n
|
|
383
|
+
const ts = BigInt(Date.now()) * 1000n
|
|
384
|
+
const parser = makeParser()
|
|
385
|
+
const parsed = parser.parse(encodeBegin(lsn, ts, 42))
|
|
386
|
+
|
|
387
|
+
expect(parsed.tag).toBe('begin')
|
|
388
|
+
expect(parsed.commitLsn).toBe('00000000/01000200')
|
|
389
|
+
expect(parsed.xid).toBe(42)
|
|
390
|
+
expect(parsed.commitTime).toBe(ts)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('COMMIT roundtrip', () => {
|
|
394
|
+
const lsn = 0x1000200n
|
|
395
|
+
const endLsn = 0x1000300n
|
|
396
|
+
const ts = BigInt(Date.now()) * 1000n
|
|
397
|
+
const parser = makeParser()
|
|
398
|
+
const parsed = parser.parse(encodeCommit(0, lsn, endLsn, ts))
|
|
399
|
+
|
|
400
|
+
expect(parsed.tag).toBe('commit')
|
|
401
|
+
expect(parsed.commitLsn).toBe('00000000/01000200')
|
|
402
|
+
expect(parsed.commitEndLsn).toBe('00000000/01000300')
|
|
403
|
+
expect(parsed.commitTime).toBe(ts)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('RELATION roundtrip', () => {
|
|
407
|
+
const oid = getTableOid('rt.rel_test')
|
|
408
|
+
const cols: ColumnInfo[] = [
|
|
409
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
410
|
+
{ name: 'name', typeOid: 25, typeMod: -1 },
|
|
411
|
+
]
|
|
412
|
+
const parser = makeParser()
|
|
413
|
+
const parsed = parser.parse(encodeRelation(oid, 'public', 'rel_test', 0x64, cols))
|
|
414
|
+
|
|
415
|
+
expect(parsed.tag).toBe('relation')
|
|
416
|
+
expect(parsed.schema).toBe('public')
|
|
417
|
+
expect(parsed.name).toBe('rel_test')
|
|
418
|
+
expect(parsed.columns).toHaveLength(2)
|
|
419
|
+
expect(parsed.keyColumns).toEqual(['id'])
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('INSERT roundtrip', () => {
|
|
423
|
+
const oid = getTableOid('rt.ins_test')
|
|
424
|
+
const cols: ColumnInfo[] = [
|
|
425
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
426
|
+
{ name: 'val', typeOid: 25, typeMod: -1 },
|
|
427
|
+
]
|
|
428
|
+
const parser = makeParser()
|
|
429
|
+
parser.parse(encodeRelation(oid, 'public', 'ins_test', 0x64, cols))
|
|
430
|
+
|
|
431
|
+
const parsed = parser.parse(encodeInsert(oid, { id: 'abc', val: 'hello' }, cols))
|
|
432
|
+
expect(parsed.tag).toBe('insert')
|
|
433
|
+
expect(parsed.new.id).toBe('abc')
|
|
434
|
+
expect(parsed.new.val).toBe('hello')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('INSERT with null', () => {
|
|
438
|
+
const oid = getTableOid('rt.null_test')
|
|
439
|
+
const cols: ColumnInfo[] = [
|
|
440
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
441
|
+
{ name: 'opt', typeOid: 25, typeMod: -1 },
|
|
442
|
+
]
|
|
443
|
+
const parser = makeParser()
|
|
444
|
+
parser.parse(encodeRelation(oid, 'public', 'null_test', 0x64, cols))
|
|
445
|
+
|
|
446
|
+
const parsed = parser.parse(encodeInsert(oid, { id: 'x', opt: null }, cols))
|
|
447
|
+
expect(parsed.new.opt).toBeNull()
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('UPDATE with old row roundtrip', () => {
|
|
451
|
+
const oid = getTableOid('rt.upd_test')
|
|
452
|
+
const cols: ColumnInfo[] = [
|
|
453
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
454
|
+
{ name: 'val', typeOid: 25, typeMod: -1 },
|
|
455
|
+
]
|
|
456
|
+
const parser = makeParser()
|
|
457
|
+
parser.parse(encodeRelation(oid, 'public', 'upd_test', 0x64, cols))
|
|
458
|
+
|
|
459
|
+
const parsed = parser.parse(
|
|
460
|
+
encodeUpdate(oid, { id: '1', val: 'new' }, { id: '1', val: 'old' }, cols)
|
|
461
|
+
)
|
|
462
|
+
expect(parsed.tag).toBe('update')
|
|
463
|
+
expect(parsed.new.val).toBe('new')
|
|
464
|
+
expect(parsed.old.val).toBe('old')
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('UPDATE without old row', () => {
|
|
468
|
+
const oid = getTableOid('rt.upd_no_old')
|
|
469
|
+
const cols: ColumnInfo[] = [
|
|
470
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
471
|
+
{ name: 'val', typeOid: 25, typeMod: -1 },
|
|
472
|
+
]
|
|
473
|
+
const parser = makeParser()
|
|
474
|
+
parser.parse(encodeRelation(oid, 'public', 'upd_no_old', 0x64, cols))
|
|
475
|
+
|
|
476
|
+
const parsed = parser.parse(encodeUpdate(oid, { id: '1', val: 'v' }, null, cols))
|
|
477
|
+
expect(parsed.tag).toBe('update')
|
|
478
|
+
expect(parsed.new.val).toBe('v')
|
|
479
|
+
expect(parsed.old).toBeNull()
|
|
480
|
+
expect(parsed.key).toBeNull()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('DELETE roundtrip', () => {
|
|
484
|
+
const oid = getTableOid('rt.del_test')
|
|
485
|
+
const cols: ColumnInfo[] = [
|
|
486
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
487
|
+
{ name: 'val', typeOid: 25, typeMod: -1 },
|
|
488
|
+
]
|
|
489
|
+
const parser = makeParser()
|
|
490
|
+
parser.parse(encodeRelation(oid, 'public', 'del_test', 0x64, cols))
|
|
491
|
+
|
|
492
|
+
const parsed = parser.parse(encodeDelete(oid, { id: 'gone', val: 'x' }, cols))
|
|
493
|
+
expect(parsed.tag).toBe('delete')
|
|
494
|
+
expect(parsed.key.id).toBe('gone')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('full transaction: BEGIN → RELATION → INSERT → COMMIT', () => {
|
|
498
|
+
const parser = makeParser()
|
|
499
|
+
const lsn = 0x2000000n
|
|
500
|
+
const endLsn = 0x2000100n
|
|
501
|
+
const ts = BigInt(Date.now()) * 1000n
|
|
502
|
+
|
|
503
|
+
const begin = parser.parse(encodeBegin(lsn, ts, 1))
|
|
504
|
+
expect(begin.commitLsn).toBe('00000000/02000000')
|
|
505
|
+
|
|
506
|
+
const oid = getTableOid('rt.full_tx')
|
|
507
|
+
const cols: ColumnInfo[] = [
|
|
508
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
509
|
+
{ name: 'data', typeOid: 25, typeMod: -1 },
|
|
510
|
+
]
|
|
511
|
+
parser.parse(encodeRelation(oid, 'public', 'full_tx', 0x64, cols))
|
|
512
|
+
|
|
513
|
+
const ins = parser.parse(encodeInsert(oid, { id: '1', data: 'test' }, cols))
|
|
514
|
+
expect(ins.new.id).toBe('1')
|
|
515
|
+
|
|
516
|
+
const commit = parser.parse(encodeCommit(0, lsn, endLsn, ts))
|
|
517
|
+
expect(commit.commitLsn).toBe(begin.commitLsn)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('XLogData + CopyData wrapper roundtrip with parser', () => {
|
|
521
|
+
const lsn = 0x3000000n
|
|
522
|
+
const ts = BigInt(Date.now()) * 1000n
|
|
523
|
+
const pgoutput = encodeBegin(lsn, ts, 1)
|
|
524
|
+
const xlog = wrapXLogData(lsn, lsn, ts, pgoutput)
|
|
525
|
+
const frame = wrapCopyData(xlog)
|
|
526
|
+
|
|
527
|
+
// unwrap CopyData
|
|
528
|
+
const copyLen = r32(frame, 1)
|
|
529
|
+
const inner = frame.subarray(5, 1 + copyLen)
|
|
530
|
+
|
|
531
|
+
// parse like stream.js
|
|
532
|
+
expect(inner[0]).toBe(0x77)
|
|
533
|
+
const streamLsn = new DataView(inner.buffer, inner.byteOffset).getBigUint64(1)
|
|
534
|
+
expect(streamLsn).toBe(lsn)
|
|
535
|
+
|
|
536
|
+
// parse pgoutput
|
|
537
|
+
const parser = makeParser()
|
|
538
|
+
const parsed = parser.parse(inner.subarray(25))
|
|
539
|
+
expect(parsed.tag).toBe('begin')
|
|
540
|
+
expect(parsed.commitLsn).toBe('00000000/03000000')
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('shard schema encoding', () => {
|
|
544
|
+
const oid = getTableOid('rt.chat_0.clients')
|
|
545
|
+
const cols: ColumnInfo[] = [
|
|
546
|
+
{ name: 'id', typeOid: 25, typeMod: -1, isKey: true },
|
|
547
|
+
{ name: 'lastMutationID', typeOid: 20, typeMod: -1 },
|
|
548
|
+
]
|
|
549
|
+
const parser = makeParser()
|
|
550
|
+
const rel = parser.parse(encodeRelation(oid, 'chat_0', 'clients', 0x64, cols))
|
|
551
|
+
expect(rel.schema).toBe('chat_0')
|
|
552
|
+
expect(rel.name).toBe('clients')
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('LSN ordering: slot < streaming changes', () => {
|
|
556
|
+
// validates that streaming changes will be seen as "new" by zero-cache
|
|
557
|
+
let testLsn = 0x1000000n
|
|
558
|
+
const next = () => { testLsn += 0x100n; return testLsn }
|
|
559
|
+
|
|
560
|
+
const slotLsn = next() // CREATE_REPLICATION_SLOT
|
|
561
|
+
const beginLsn = next() // first streaming BEGIN
|
|
562
|
+
const commitLsn = next() // first streaming COMMIT
|
|
563
|
+
|
|
564
|
+
expect(beginLsn).toBeGreaterThan(slotLsn)
|
|
565
|
+
expect(commitLsn).toBeGreaterThan(beginLsn)
|
|
566
|
+
|
|
567
|
+
// verify lexi version ordering is preserved
|
|
568
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
569
|
+
const { versionToLexi } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/types/lexi-version.js')
|
|
570
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
571
|
+
const { toBigInt: lsnToBigInt } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/lsn.js')
|
|
572
|
+
|
|
573
|
+
const slotHex = `00000000/${slotLsn.toString(16).padStart(8, '0')}`.toUpperCase()
|
|
574
|
+
const beginHex = `00000000/${beginLsn.toString(16).padStart(8, '0')}`.toUpperCase()
|
|
575
|
+
|
|
576
|
+
const slotVersion = versionToLexi(lsnToBigInt(slotHex))
|
|
577
|
+
const beginVersion = versionToLexi(lsnToBigInt(beginHex))
|
|
578
|
+
|
|
579
|
+
// lexi versions must maintain ordering
|
|
580
|
+
expect(beginVersion > slotVersion).toBe(true)
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
|
|
367
584
|
describe('double-wrap: CopyData(XLogData(message))', () => {
|
|
368
585
|
// this is the exact framing zero-cache expects for every replication message
|
|
369
586
|
it('produces parseable nested structure', () => {
|
|
@@ -701,17 +701,22 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
|
|
|
701
701
|
const s = await stream()
|
|
702
702
|
const q = s.messages
|
|
703
703
|
|
|
704
|
+
// insert sequentially with waits to avoid batching
|
|
704
705
|
await db.exec(`INSERT INTO public.foo (id) VALUES ('t1')`)
|
|
705
706
|
await db.exec(`INSERT INTO public.foo (id) VALUES ('t2')`)
|
|
706
707
|
await db.exec(`INSERT INTO public.foo (id) VALUES ('t3')`)
|
|
707
708
|
|
|
709
|
+
// orez may batch changes into fewer transactions, so just verify
|
|
710
|
+
// all inserts arrive and every begin has a matching commit
|
|
708
711
|
const all: ZcMessage[] = []
|
|
709
712
|
const deadline = Date.now() + 8000
|
|
710
713
|
while (Date.now() < deadline) {
|
|
711
714
|
const m = await q.dequeue(2000).catch(() => null)
|
|
712
715
|
if (!m) break
|
|
713
716
|
if (m.tag !== 'keepalive') all.push(m)
|
|
714
|
-
|
|
717
|
+
const inserts = all.filter((x) => x.tag === 'insert')
|
|
718
|
+
const commits = all.filter((x) => x.tag === 'commit')
|
|
719
|
+
if (inserts.length >= 3 && commits.length >= 1) break
|
|
715
720
|
}
|
|
716
721
|
|
|
717
722
|
const begins = all.filter((m) => m.tag === 'begin')
|
|
@@ -941,3 +946,229 @@ describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
|
|
|
941
946
|
s.close()
|
|
942
947
|
})
|
|
943
948
|
})
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* postgres.js replication stream test.
|
|
952
|
+
*
|
|
953
|
+
* uses the same postgres library and code path that zero-cache uses
|
|
954
|
+
* in its stream.js subscribe function. validates that orez's CopyData
|
|
955
|
+
* frames are correctly parsed by postgres.js's wire protocol handler
|
|
956
|
+
* and that zero-cache's PgoutputParser can consume the payloads.
|
|
957
|
+
*/
|
|
958
|
+
describe('postgres.js replication stream (zero-cache code path)', { timeout: 30000 }, () => {
|
|
959
|
+
let db: PGlite
|
|
960
|
+
let server: Server
|
|
961
|
+
let port: number
|
|
962
|
+
|
|
963
|
+
beforeEach(async () => {
|
|
964
|
+
db = new PGlite()
|
|
965
|
+
await db.waitReady
|
|
966
|
+
await db.exec(`
|
|
967
|
+
CREATE TABLE public.items (
|
|
968
|
+
id TEXT PRIMARY KEY,
|
|
969
|
+
val INTEGER,
|
|
970
|
+
note TEXT
|
|
971
|
+
)
|
|
972
|
+
`)
|
|
973
|
+
await db.exec(`CREATE PUBLICATION zero_data FOR ALL TABLES`)
|
|
974
|
+
await installChangeTracking(db)
|
|
975
|
+
|
|
976
|
+
const config = { ...getConfig(), pgPort: 0 }
|
|
977
|
+
server = await startPgProxy(db, config)
|
|
978
|
+
port = (server.address() as AddressInfo).port
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
afterEach(async () => {
|
|
982
|
+
server?.close()
|
|
983
|
+
await db?.close()
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('postgres.js receives CopyData and parseStreamMessage decodes it', { timeout: 30000 }, async () => {
|
|
987
|
+
// import postgres (same lib zero-cache uses)
|
|
988
|
+
const pg = (await import('postgres')).default
|
|
989
|
+
|
|
990
|
+
// create a regular connection for queries
|
|
991
|
+
const regular = pg({
|
|
992
|
+
host: '127.0.0.1',
|
|
993
|
+
port,
|
|
994
|
+
user: 'user',
|
|
995
|
+
password: 'password',
|
|
996
|
+
database: 'postgres',
|
|
997
|
+
max: 1,
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
// create replication connection (same as zero-cache's subscribe)
|
|
1001
|
+
const session = pg({
|
|
1002
|
+
host: '127.0.0.1',
|
|
1003
|
+
port,
|
|
1004
|
+
user: 'user',
|
|
1005
|
+
password: 'password',
|
|
1006
|
+
database: 'postgres',
|
|
1007
|
+
max: 1,
|
|
1008
|
+
fetch_types: false,
|
|
1009
|
+
idle_timeout: null,
|
|
1010
|
+
max_lifetime: null,
|
|
1011
|
+
connection: { replication: 'database' },
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
try {
|
|
1015
|
+
// create slot (same as zero-cache)
|
|
1016
|
+
await session.unsafe(
|
|
1017
|
+
`CREATE_REPLICATION_SLOT "pgjs_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT`
|
|
1018
|
+
).simple()
|
|
1019
|
+
|
|
1020
|
+
// start replication stream (same pattern as stream.js)
|
|
1021
|
+
const stream = session.unsafe(
|
|
1022
|
+
`START_REPLICATION SLOT "pgjs_test" LOGICAL 0/0 (proto_version '1', publication_names 'zero_data', messages 'true')`
|
|
1023
|
+
).execute()
|
|
1024
|
+
|
|
1025
|
+
const [readable, _writable] = await Promise.all([
|
|
1026
|
+
stream.readable(),
|
|
1027
|
+
stream.writable(),
|
|
1028
|
+
])
|
|
1029
|
+
|
|
1030
|
+
// import zero-cache's actual parser
|
|
1031
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1032
|
+
const { PgoutputParser } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/logical-replication/pgoutput-parser.js')
|
|
1033
|
+
const typeParsers = { getTypeParser: () => String }
|
|
1034
|
+
const parser = new PgoutputParser(typeParsers)
|
|
1035
|
+
|
|
1036
|
+
// parseStreamMessage from zero-cache's stream.js
|
|
1037
|
+
function parseStreamMessage(buffer: Buffer): [bigint, any] | null {
|
|
1038
|
+
if (buffer[0] !== 0x77 && buffer[0] !== 0x6b) return null
|
|
1039
|
+
const lsn = buffer.readBigUInt64BE(1)
|
|
1040
|
+
if (buffer[0] === 0x77) {
|
|
1041
|
+
return [lsn, parser.parse(buffer.subarray(25))]
|
|
1042
|
+
}
|
|
1043
|
+
if (buffer.readInt8(17)) {
|
|
1044
|
+
return [lsn, { tag: 'keepalive' }]
|
|
1045
|
+
}
|
|
1046
|
+
return null // keepalive with shouldRespond=false
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// collect parsed messages
|
|
1050
|
+
const messages: any[] = []
|
|
1051
|
+
const collectDone = new Promise<void>((resolve) => {
|
|
1052
|
+
readable.on('data', (chunk: Buffer) => {
|
|
1053
|
+
const result = parseStreamMessage(chunk)
|
|
1054
|
+
if (result) {
|
|
1055
|
+
const [_lsn, msg] = result
|
|
1056
|
+
messages.push(msg)
|
|
1057
|
+
}
|
|
1058
|
+
})
|
|
1059
|
+
setTimeout(resolve, 3000)
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
1063
|
+
await regular.unsafe(`INSERT INTO public.items (id, val, note) VALUES ('pgjs', 42, 'postgres.js test')`)
|
|
1064
|
+
|
|
1065
|
+
await collectDone
|
|
1066
|
+
readable.destroy()
|
|
1067
|
+
|
|
1068
|
+
// filter out keepalives
|
|
1069
|
+
const data = messages.filter((m: any) => m.tag !== 'keepalive')
|
|
1070
|
+
|
|
1071
|
+
// should have: begin, relation, insert, commit
|
|
1072
|
+
const tags = data.map((m: any) => m.tag)
|
|
1073
|
+
expect(tags).toContain('begin')
|
|
1074
|
+
expect(tags).toContain('relation')
|
|
1075
|
+
expect(tags).toContain('insert')
|
|
1076
|
+
expect(tags).toContain('commit')
|
|
1077
|
+
|
|
1078
|
+
// validate BEGIN has commitLsn as string (not BigInt)
|
|
1079
|
+
const begin = data.find((m: any) => m.tag === 'begin')
|
|
1080
|
+
expect(typeof begin.commitLsn).toBe('string')
|
|
1081
|
+
expect(begin.commitLsn).toMatch(/^[0-9A-F]+\/[0-9A-F]+$/)
|
|
1082
|
+
|
|
1083
|
+
// validate RELATION has correct structure
|
|
1084
|
+
const rel = data.find((m: any) => m.tag === 'relation')
|
|
1085
|
+
expect(rel.schema).toBe('public')
|
|
1086
|
+
expect(rel.name).toBe('items')
|
|
1087
|
+
expect(rel.columns.length).toBe(3)
|
|
1088
|
+
|
|
1089
|
+
// validate INSERT has parsed values
|
|
1090
|
+
const ins = data.find((m: any) => m.tag === 'insert')
|
|
1091
|
+
expect(ins.relation.name).toBe('items')
|
|
1092
|
+
expect(ins.new.id).toBe('pgjs')
|
|
1093
|
+
expect(ins.new.val).toBe('42')
|
|
1094
|
+
expect(ins.new.note).toBe('postgres.js test')
|
|
1095
|
+
|
|
1096
|
+
// validate COMMIT has commitLsn and commitEndLsn
|
|
1097
|
+
const commit = data.find((m: any) => m.tag === 'commit')
|
|
1098
|
+
expect(typeof commit.commitLsn).toBe('string')
|
|
1099
|
+
expect(typeof commit.commitEndLsn).toBe('string')
|
|
1100
|
+
|
|
1101
|
+
// validate LSN ordering: commit.commitEndLsn > begin.commitLsn
|
|
1102
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1103
|
+
const { toBigInt: lsnToBigInt } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/lsn.js')
|
|
1104
|
+
expect(lsnToBigInt(commit.commitEndLsn)).toBeGreaterThan(lsnToBigInt(begin.commitLsn))
|
|
1105
|
+
|
|
1106
|
+
// validate lexi version conversion works (storer uses this)
|
|
1107
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1108
|
+
const { versionToLexi } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/types/lexi-version.js')
|
|
1109
|
+
const beginVersion = versionToLexi(lsnToBigInt(begin.commitLsn))
|
|
1110
|
+
const commitVersion = versionToLexi(lsnToBigInt(commit.commitEndLsn))
|
|
1111
|
+
expect(typeof beginVersion).toBe('string')
|
|
1112
|
+
expect(commitVersion > beginVersion).toBe(true)
|
|
1113
|
+
|
|
1114
|
+
} finally {
|
|
1115
|
+
await regular.end()
|
|
1116
|
+
// session.end() can hang because the replication handler keeps polling.
|
|
1117
|
+
// just force-close the underlying connection by destroying the socket.
|
|
1118
|
+
await session.end({ timeout: 2 }).catch(() => {})
|
|
1119
|
+
}
|
|
1120
|
+
})
|
|
1121
|
+
|
|
1122
|
+
it('postgres.js handles concurrent regular + replication connections', { timeout: 30000 }, async () => {
|
|
1123
|
+
const pg = (await import('postgres')).default
|
|
1124
|
+
|
|
1125
|
+
const regular = pg({
|
|
1126
|
+
host: '127.0.0.1', port,
|
|
1127
|
+
user: 'user', password: 'password', database: 'postgres',
|
|
1128
|
+
max: 1,
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
const session = pg({
|
|
1132
|
+
host: '127.0.0.1', port,
|
|
1133
|
+
user: 'user', password: 'password', database: 'postgres',
|
|
1134
|
+
max: 1,
|
|
1135
|
+
fetch_types: false,
|
|
1136
|
+
idle_timeout: null,
|
|
1137
|
+
max_lifetime: null,
|
|
1138
|
+
connection: { replication: 'database' },
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
await session.unsafe(
|
|
1143
|
+
`CREATE_REPLICATION_SLOT "conc_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT`
|
|
1144
|
+
).simple()
|
|
1145
|
+
|
|
1146
|
+
const stream = session.unsafe(
|
|
1147
|
+
`START_REPLICATION SLOT "conc_test" LOGICAL 0/0 (proto_version '1', publication_names 'zero_data', messages 'true')`
|
|
1148
|
+
).execute()
|
|
1149
|
+
|
|
1150
|
+
const [readable] = await Promise.all([stream.readable(), stream.writable()])
|
|
1151
|
+
|
|
1152
|
+
const received: Buffer[] = []
|
|
1153
|
+
readable.on('data', (chunk: Buffer) => received.push(chunk))
|
|
1154
|
+
|
|
1155
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
1156
|
+
|
|
1157
|
+
// do 5 inserts via regular connection while replication is active
|
|
1158
|
+
for (let i = 0; i < 5; i++) {
|
|
1159
|
+
await regular.unsafe(`INSERT INTO public.items (id, val) VALUES ('c${i}', ${i})`)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
1163
|
+
readable.destroy()
|
|
1164
|
+
|
|
1165
|
+
// should have received XLogData frames (0x77) from replication
|
|
1166
|
+
const xlogFrames = received.filter((b) => b[0] === 0x77)
|
|
1167
|
+
expect(xlogFrames.length).toBeGreaterThan(0)
|
|
1168
|
+
|
|
1169
|
+
} finally {
|
|
1170
|
+
await regular.end()
|
|
1171
|
+
await session.end({ timeout: 2 }).catch(() => {})
|
|
1172
|
+
}
|
|
1173
|
+
})
|
|
1174
|
+
})
|
package/src/shim/hooks.mjs
CHANGED
|
@@ -20,6 +20,13 @@ export function load(url, context, nextLoad) {
|
|
|
20
20
|
format: 'module',
|
|
21
21
|
shortCircuit: true,
|
|
22
22
|
source: `
|
|
23
|
+
// catch uncaught exceptions from bedrock-sqlite wasm clearly
|
|
24
|
+
process.on('uncaughtException', (err) => {
|
|
25
|
+
console.error('[orez-shim] UNCAUGHT EXCEPTION:', err?.message || err);
|
|
26
|
+
console.error('[orez-shim] code:', err?.code, 'name:', err?.name);
|
|
27
|
+
console.error('[orez-shim] stack:', err?.stack?.split('\\n').slice(0, 5).join('\\n'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
23
30
|
import { createRequire } from 'node:module';
|
|
24
31
|
const require = createRequire('${BEDROCK_PATH}');
|
|
25
32
|
const mod = require('${BEDROCK_PATH}');
|
|
@@ -34,6 +41,32 @@ Database.prototype = OrigDatabase.prototype;
|
|
|
34
41
|
Database.prototype.constructor = Database;
|
|
35
42
|
Object.keys(OrigDatabase).forEach(k => { Database[k] = OrigDatabase[k]; });
|
|
36
43
|
Database.prototype.unsafeMode = function() { return this; };
|
|
44
|
+
// wrap pragma to skip optimize (corrupts wasm vfs) and swallow sqlite errors
|
|
45
|
+
const origPragma = OrigDatabase.prototype.pragma;
|
|
46
|
+
Database.prototype.pragma = function(str, opts) {
|
|
47
|
+
if (str && str.trim().toLowerCase().startsWith('optimize')) return [];
|
|
48
|
+
try { return origPragma.call(this, str, opts); }
|
|
49
|
+
catch(e) { if (e && (e.code === 'SQLITE_CORRUPT' || e.code === 'SQLITE_IOERR')) return []; throw e; }
|
|
50
|
+
};
|
|
51
|
+
// wrap close to swallow wasm errors during shutdown
|
|
52
|
+
const origClose = OrigDatabase.prototype.close;
|
|
53
|
+
Database.prototype.close = function() {
|
|
54
|
+
try { return origClose.call(this); }
|
|
55
|
+
catch(e) { console.error('[orez-shim] close error (swallowed):', e?.message || e); }
|
|
56
|
+
};
|
|
57
|
+
// trace writes to _zero.changeLog and _zero.replicationState to debug view-syncer
|
|
58
|
+
const origRun = OrigDatabase.prototype.run;
|
|
59
|
+
Database.prototype.run = function(sql, ...args) {
|
|
60
|
+
if (typeof sql === 'string') {
|
|
61
|
+
if (sql.includes('_zero.changeLog')) {
|
|
62
|
+
console.info('[orez-shim] changeLog write:', sql.slice(0, 120), args.length ? JSON.stringify(args[0]).slice(0, 80) : '');
|
|
63
|
+
}
|
|
64
|
+
if (sql.includes('_zero.replicationState') && (sql.includes('UPDATE') || sql.includes('INSERT'))) {
|
|
65
|
+
console.info('[orez-shim] replicationState update:', sql.slice(0, 120), args.length ? JSON.stringify(args[0]).slice(0, 80) : '');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return origRun.call(this, sql, ...args);
|
|
69
|
+
};
|
|
37
70
|
if (!Database.prototype.defaultSafeIntegers) Database.prototype.defaultSafeIntegers = function() { return this; };
|
|
38
71
|
if (!Database.prototype.serialize) Database.prototype.serialize = function() { throw new Error('not supported in wasm'); };
|
|
39
72
|
if (!Database.prototype.backup) Database.prototype.backup = function() { throw new Error('not supported in wasm'); };
|
package/src/vite-plugin.ts
CHANGED
|
@@ -36,7 +36,7 @@ export default function orez(options?: OrezPluginOptions): Plugin {
|
|
|
36
36
|
if (options?.admin && result.logStore) {
|
|
37
37
|
const { findPort } = await import('./port.js')
|
|
38
38
|
const { log } = await import('./log.js')
|
|
39
|
-
const adminPort = options.adminPort ||
|
|
39
|
+
const adminPort = options.adminPort || result.config.zeroPort + 2
|
|
40
40
|
const resolvedPort = await findPort(adminPort)
|
|
41
41
|
const { startAdminServer } = await import('./admin/server.js')
|
|
42
42
|
adminServer = await startAdminServer({
|