orez 0.0.48 → 0.0.49
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/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -112
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +0 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -5
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +91 -280
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +0 -9
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +1 -24
- package/dist/log.js.map +1 -1
- package/dist/mutex.d.ts.map +1 -1
- package/dist/mutex.js +2 -13
- package/dist/mutex.js.map +1 -1
- package/dist/pg-proxy.d.ts +2 -3
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +167 -377
- package/dist/pg-proxy.js.map +1 -1
- package/dist/pglite-manager.d.ts +0 -1
- package/dist/pglite-manager.d.ts.map +1 -1
- package/dist/pglite-manager.js +2 -8
- package/dist/pglite-manager.js.map +1 -1
- package/dist/replication/change-tracker.d.ts +0 -6
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +1 -62
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +7 -66
- package/dist/replication/handler.js.map +1 -1
- package/dist/vite-plugin.d.ts +0 -3
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +0 -24
- package/dist/vite-plugin.js.map +1 -1
- package/package.json +5 -4
- package/src/cli.ts +18 -124
- package/src/config.ts +0 -10
- package/src/index.ts +92 -309
- package/src/integration/integration.test.ts +264 -133
- package/src/log.ts +1 -25
- package/src/mutex.ts +2 -12
- package/src/pg-proxy.ts +187 -451
- package/src/pglite-manager.ts +2 -9
- package/src/replication/change-tracker.ts +1 -83
- package/src/replication/handler.ts +6 -79
- package/src/replication/pgoutput-encoder.test.ts +0 -217
- package/src/replication/zero-compat.test.ts +1 -232
- package/src/shim/hooks.mjs +1 -1
- package/src/vite-plugin.ts +0 -28
- package/src/wasm-sqlite.test.ts +1 -2
package/src/pglite-manager.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface PGliteInstances {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// create a single pglite instance with given dataDir suffix
|
|
19
|
-
|
|
19
|
+
async function createInstance(
|
|
20
20
|
config: ZeroLiteConfig,
|
|
21
21
|
name: string,
|
|
22
22
|
withExtensions: boolean
|
|
@@ -133,15 +133,8 @@ export async function runMigrations(db: PGlite, config: ZeroLiteConfig): Promise
|
|
|
133
133
|
continue
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
const filePath = join(migrationsDir, file)
|
|
137
|
-
if (!existsSync(filePath)) {
|
|
138
|
-
// .ts-only custom migrations are handled by the app's own migration runner
|
|
139
|
-
log.debug.orez(`skipping migration (no .sql file): ${name}`)
|
|
140
|
-
continue
|
|
141
|
-
}
|
|
142
|
-
|
|
143
136
|
log.debug.orez(`applying migration: ${name}`)
|
|
144
|
-
const sql = readFileSync(
|
|
137
|
+
const sql = readFileSync(join(migrationsDir, file), 'utf-8')
|
|
145
138
|
|
|
146
139
|
// split by drizzle's statement-breakpoint marker
|
|
147
140
|
const statements = sql
|
|
@@ -140,58 +140,6 @@ async function installTriggersOnAllTables(db: PGlite): Promise<void> {
|
|
|
140
140
|
log.debug.pglite(`installed change tracking triggers on ${count} tables`)
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/**
|
|
144
|
-
* re-install change tracking triggers on any public tables that don't have them.
|
|
145
|
-
* catches tables created between startup and replication start.
|
|
146
|
-
*/
|
|
147
|
-
export async function ensureChangeTrackingOnAllTables(db: PGlite): Promise<void> {
|
|
148
|
-
const pubName = process.env.ZERO_APP_PUBLICATIONS
|
|
149
|
-
let tables: { tablename: string }[]
|
|
150
|
-
|
|
151
|
-
if (pubName) {
|
|
152
|
-
const result = await db.query<{ tablename: string }>(
|
|
153
|
-
`SELECT tablename FROM pg_publication_tables
|
|
154
|
-
WHERE pubname = $1
|
|
155
|
-
AND schemaname = 'public'
|
|
156
|
-
AND tablename NOT LIKE '_zero_%'`,
|
|
157
|
-
[pubName]
|
|
158
|
-
)
|
|
159
|
-
tables = result.rows
|
|
160
|
-
} else {
|
|
161
|
-
const result = await db.query<{ tablename: string }>(
|
|
162
|
-
`SELECT tablename FROM pg_tables
|
|
163
|
-
WHERE schemaname = 'public'
|
|
164
|
-
AND tablename NOT IN ('migrations', '_zero_changes')
|
|
165
|
-
AND tablename NOT LIKE '_zero_%'`
|
|
166
|
-
)
|
|
167
|
-
tables = result.rows
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// find tables missing the change trigger
|
|
171
|
-
const triggered = await db.query<{ event_object_table: string }>(
|
|
172
|
-
`SELECT DISTINCT event_object_table FROM information_schema.triggers
|
|
173
|
-
WHERE trigger_name = '_zero_change_trigger'
|
|
174
|
-
AND event_object_schema = 'public'`
|
|
175
|
-
)
|
|
176
|
-
const hasTracker = new Set(triggered.rows.map((r) => r.event_object_table))
|
|
177
|
-
|
|
178
|
-
let count = 0
|
|
179
|
-
for (const { tablename } of tables) {
|
|
180
|
-
if (hasTracker.has(tablename)) continue
|
|
181
|
-
const quoted = quoteIdent(tablename)
|
|
182
|
-
await db.exec(`
|
|
183
|
-
CREATE TRIGGER _zero_change_trigger
|
|
184
|
-
AFTER INSERT OR UPDATE OR DELETE ON public.${quoted}
|
|
185
|
-
FOR EACH ROW EXECUTE FUNCTION public._zero_track_change();
|
|
186
|
-
`)
|
|
187
|
-
count++
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (count > 0) {
|
|
191
|
-
log.debug.pglite(`installed change tracking on ${count} new tables`)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
143
|
/**
|
|
196
144
|
* install change tracking triggers on tables in shard schemas.
|
|
197
145
|
* zero-cache creates shard schemas (e.g. chat_0) with clients/mutations
|
|
@@ -210,29 +158,10 @@ export async function installTriggersOnShardTables(db: PGlite): Promise<void> {
|
|
|
210
158
|
|
|
211
159
|
if (result.rows.length === 0) return
|
|
212
160
|
|
|
213
|
-
// only track `clients` — that's the table zero-cache expects in the
|
|
214
|
-
// replication stream (needed for .server promise resolution). other shard
|
|
215
|
-
// tables like `replicas` are zero-cache internal state and streaming them
|
|
216
|
-
// back causes "Unknown table" crashes in zero-cache's change-processor.
|
|
217
161
|
let count = 0
|
|
218
162
|
for (const { nspname } of result.rows) {
|
|
219
|
-
// remove stale triggers from non-clients tables (from previous versions)
|
|
220
|
-
const stale = await db.query<{ event_object_table: string }>(
|
|
221
|
-
`SELECT DISTINCT event_object_table FROM information_schema.triggers
|
|
222
|
-
WHERE trigger_name = '_zero_change_trigger'
|
|
223
|
-
AND event_object_schema = $1
|
|
224
|
-
AND event_object_table != 'clients'`,
|
|
225
|
-
[nspname]
|
|
226
|
-
)
|
|
227
|
-
for (const { event_object_table } of stale.rows) {
|
|
228
|
-
const qs = quoteIdent(nspname)
|
|
229
|
-
const qt = quoteIdent(event_object_table)
|
|
230
|
-
await db.exec(`DROP TRIGGER IF EXISTS _zero_change_trigger ON ${qs}.${qt}`)
|
|
231
|
-
log.debug.pglite(`removed stale shard trigger from ${nspname}.${event_object_table}`)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
163
|
const tables = await db.query<{ tablename: string }>(
|
|
235
|
-
`SELECT tablename FROM pg_tables WHERE schemaname = $1
|
|
164
|
+
`SELECT tablename FROM pg_tables WHERE schemaname = $1`,
|
|
236
165
|
[nspname]
|
|
237
166
|
)
|
|
238
167
|
|
|
@@ -266,17 +195,6 @@ export async function getChangesSince(
|
|
|
266
195
|
return result.rows
|
|
267
196
|
}
|
|
268
197
|
|
|
269
|
-
export async function purgeConsumedChanges(
|
|
270
|
-
db: PGlite,
|
|
271
|
-
watermark: number
|
|
272
|
-
): Promise<number> {
|
|
273
|
-
const result = await db.query<{ count: string }>(
|
|
274
|
-
'WITH deleted AS (DELETE FROM public._zero_changes WHERE watermark <= $1 RETURNING 1) SELECT count(*)::text AS count FROM deleted',
|
|
275
|
-
[watermark]
|
|
276
|
-
)
|
|
277
|
-
return Number(result.rows[0]?.count || 0)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
198
|
export async function getCurrentWatermark(db: PGlite): Promise<number> {
|
|
281
199
|
const result = await db.query<{ last_value: string; is_called: boolean }>(
|
|
282
200
|
'SELECT last_value, is_called FROM public._zero_watermark'
|
|
@@ -10,7 +10,6 @@ import { log } from '../log.js'
|
|
|
10
10
|
import {
|
|
11
11
|
getChangesSince,
|
|
12
12
|
getCurrentWatermark,
|
|
13
|
-
purgeConsumedChanges,
|
|
14
13
|
installTriggersOnShardTables,
|
|
15
14
|
type ChangeRecord,
|
|
16
15
|
} from './change-tracker.js'
|
|
@@ -32,9 +31,6 @@ import {
|
|
|
32
31
|
import type { Mutex } from '../mutex.js'
|
|
33
32
|
import type { PGlite } from '@electric-sql/pglite'
|
|
34
33
|
|
|
35
|
-
// track concurrent replication handlers to detect reconnect-purge race
|
|
36
|
-
let activeHandlerCount = 0
|
|
37
|
-
|
|
38
34
|
export interface ReplicationWriter {
|
|
39
35
|
write(data: Uint8Array): void
|
|
40
36
|
}
|
|
@@ -265,11 +261,6 @@ export async function handleStartReplication(
|
|
|
265
261
|
db: PGlite,
|
|
266
262
|
mutex: Mutex
|
|
267
263
|
): Promise<void> {
|
|
268
|
-
activeHandlerCount++
|
|
269
|
-
const handlerId = activeHandlerCount
|
|
270
|
-
console.info(
|
|
271
|
-
`[orez-repl#${handlerId}] START_REPLICATION (active handlers: ${activeHandlerCount})`
|
|
272
|
-
)
|
|
273
264
|
log.debug.proxy('replication: entering streaming mode')
|
|
274
265
|
|
|
275
266
|
// send CopyBothResponse to enter streaming mode
|
|
@@ -351,7 +342,7 @@ export async function handleStartReplication(
|
|
|
351
342
|
for (const schema of relevantSchemas) {
|
|
352
343
|
if (schema === 'public') continue
|
|
353
344
|
const shardTables = await db.query<{ tablename: string }>(
|
|
354
|
-
`SELECT tablename FROM pg_tables WHERE schemaname = $1
|
|
345
|
+
`SELECT tablename FROM pg_tables WHERE schemaname = $1`,
|
|
355
346
|
[schema]
|
|
356
347
|
)
|
|
357
348
|
for (const { tablename } of shardTables.rows) {
|
|
@@ -461,23 +452,13 @@ export async function handleStartReplication(
|
|
|
461
452
|
mutex.release()
|
|
462
453
|
}
|
|
463
454
|
|
|
464
|
-
console.info(
|
|
465
|
-
`[orez-repl#${handlerId}] setup complete, starting poll (lastWatermark=${lastWatermark})`
|
|
466
|
-
)
|
|
467
|
-
|
|
468
455
|
// track which tables we've sent RELATION messages for
|
|
469
456
|
const sentRelations = new Set<string>()
|
|
470
457
|
let txCounter = 1
|
|
471
458
|
|
|
472
459
|
// polling + notification loop
|
|
473
|
-
|
|
474
|
-
const pollIntervalIdle = 500
|
|
475
|
-
const pollIntervalCatchUp = 20
|
|
476
|
-
const batchSize = 2000
|
|
477
|
-
const purgeEveryN = 10
|
|
460
|
+
const pollInterval = 500
|
|
478
461
|
let running = true
|
|
479
|
-
let pollsSincePurge = 0
|
|
480
|
-
let lastIdleLog = 0
|
|
481
462
|
|
|
482
463
|
const poll = async () => {
|
|
483
464
|
while (running) {
|
|
@@ -486,35 +467,12 @@ export async function handleStartReplication(
|
|
|
486
467
|
await mutex.acquire()
|
|
487
468
|
let changes: Awaited<ReturnType<typeof getChangesSince>>
|
|
488
469
|
try {
|
|
489
|
-
changes = await getChangesSince(db, lastWatermark,
|
|
470
|
+
changes = await getChangesSince(db, lastWatermark, 100)
|
|
490
471
|
} finally {
|
|
491
472
|
mutex.release()
|
|
492
473
|
}
|
|
493
474
|
|
|
494
475
|
if (changes.length > 0) {
|
|
495
|
-
// filter out shard tables that zero-cache doesn't expect.
|
|
496
|
-
// only `clients` is needed (for .server promise resolution).
|
|
497
|
-
// other shard tables (replicas, mutations) crash zero-cache
|
|
498
|
-
// with "Unknown table" in change-processor.
|
|
499
|
-
const batchEnd = changes[changes.length - 1].watermark
|
|
500
|
-
changes = changes.filter((c) => {
|
|
501
|
-
const dot = c.table_name.indexOf('.')
|
|
502
|
-
if (dot === -1) return true
|
|
503
|
-
const schema = c.table_name.substring(0, dot)
|
|
504
|
-
if (schema === 'public') return true
|
|
505
|
-
const table = c.table_name.substring(dot + 1)
|
|
506
|
-
return table === 'clients'
|
|
507
|
-
})
|
|
508
|
-
|
|
509
|
-
if (changes.length === 0) {
|
|
510
|
-
lastWatermark = batchEnd
|
|
511
|
-
continue
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const tables = [...new Set(changes.map((c) => c.table_name))].join(',')
|
|
515
|
-
console.info(
|
|
516
|
-
`[orez-repl#${handlerId}] found ${changes.length} changes [${tables}] (wm ${lastWatermark}→${changes[changes.length - 1].watermark}, type=${typeof changes[0].watermark})`
|
|
517
|
-
)
|
|
518
476
|
await streamChanges(
|
|
519
477
|
changes,
|
|
520
478
|
writer,
|
|
@@ -524,42 +482,14 @@ export async function handleStartReplication(
|
|
|
524
482
|
excludedColumns,
|
|
525
483
|
columnTypeOids
|
|
526
484
|
)
|
|
527
|
-
lastWatermark =
|
|
528
|
-
|
|
529
|
-
// purge consumed changes periodically to free wasm memory
|
|
530
|
-
pollsSincePurge++
|
|
531
|
-
if (pollsSincePurge >= purgeEveryN) {
|
|
532
|
-
pollsSincePurge = 0
|
|
533
|
-
await mutex.acquire()
|
|
534
|
-
try {
|
|
535
|
-
const purged = await purgeConsumedChanges(db, lastWatermark)
|
|
536
|
-
if (purged > 0) {
|
|
537
|
-
console.info(
|
|
538
|
-
`[orez-repl#${handlerId}] purged ${purged} changes (wm<=${lastWatermark})`
|
|
539
|
-
)
|
|
540
|
-
}
|
|
541
|
-
} finally {
|
|
542
|
-
mutex.release()
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
} else {
|
|
546
|
-
// throttled idle logging (every 10s)
|
|
547
|
-
const now = Date.now()
|
|
548
|
-
if (now - lastIdleLog > 10000) {
|
|
549
|
-
lastIdleLog = now
|
|
550
|
-
console.info(
|
|
551
|
-
`[orez-repl#${handlerId}] idle (lastWatermark=${lastWatermark}, type=${typeof lastWatermark})`
|
|
552
|
-
)
|
|
553
|
-
}
|
|
485
|
+
lastWatermark = changes[changes.length - 1].watermark
|
|
554
486
|
}
|
|
555
487
|
|
|
556
488
|
// send keepalive
|
|
557
489
|
const ts = nowMicros()
|
|
558
490
|
writer.write(encodeKeepalive(currentLsn, ts, false))
|
|
559
491
|
|
|
560
|
-
|
|
561
|
-
const delay = changes.length >= batchSize ? pollIntervalCatchUp : pollIntervalIdle
|
|
562
|
-
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
492
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
|
563
493
|
} catch (err: unknown) {
|
|
564
494
|
const msg = err instanceof Error ? err.message : String(err)
|
|
565
495
|
log.debug.proxy(`replication poll error: ${msg}`)
|
|
@@ -574,10 +504,7 @@ export async function handleStartReplication(
|
|
|
574
504
|
|
|
575
505
|
log.debug.proxy('replication: starting poll loop')
|
|
576
506
|
await poll()
|
|
577
|
-
|
|
578
|
-
console.info(
|
|
579
|
-
`[orez-repl#${handlerId}] poll loop exited (remaining handlers: ${activeHandlerCount})`
|
|
580
|
-
)
|
|
507
|
+
log.debug.proxy('replication: poll loop exited')
|
|
581
508
|
}
|
|
582
509
|
|
|
583
510
|
async function streamChanges(
|
|
@@ -364,223 +364,6 @@ 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
|
-
|
|
584
367
|
describe('double-wrap: CopyData(XLogData(message))', () => {
|
|
585
368
|
// this is the exact framing zero-cache expects for every replication message
|
|
586
369
|
it('produces parseable nested structure', () => {
|