orez 0.2.26 → 0.2.29
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/cf-do/worker.d.ts.map +1 -1
- package/dist/cf-do/worker.js +9 -1
- package/dist/cf-do/worker.js.map +1 -1
- package/dist/pg-proxy-do-backend.d.ts +2 -0
- package/dist/pg-proxy-do-backend.d.ts.map +1 -1
- package/dist/pg-proxy-do-backend.js +49 -7
- package/dist/pg-proxy-do-backend.js.map +1 -1
- package/dist/pg-sqlite-compiler/catalog/seed.d.ts +67 -0
- package/dist/pg-sqlite-compiler/catalog/seed.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/catalog/seed.js +436 -0
- package/dist/pg-sqlite-compiler/catalog/seed.js.map +1 -0
- package/dist/pg-sqlite-compiler/index.d.ts +12 -0
- package/dist/pg-sqlite-compiler/index.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/index.js +59 -0
- package/dist/pg-sqlite-compiler/index.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts +48 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.js +93 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/catalog.d.ts +34 -0
- package/dist/pg-sqlite-compiler/passes/catalog.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/catalog.js +30 -0
- package/dist/pg-sqlite-compiler/passes/catalog.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/datetime.d.ts +21 -0
- package/dist/pg-sqlite-compiler/passes/datetime.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/datetime.js +53 -0
- package/dist/pg-sqlite-compiler/passes/datetime.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/index.d.ts +21 -0
- package/dist/pg-sqlite-compiler/passes/index.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/index.js +39 -0
- package/dist/pg-sqlite-compiler/passes/index.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/types.d.ts +41 -0
- package/dist/pg-sqlite-compiler/passes/types.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/types.js +103 -0
- package/dist/pg-sqlite-compiler/passes/types.js.map +1 -0
- package/dist/pg-sqlite-compiler/test/oracle.d.ts +34 -0
- package/dist/pg-sqlite-compiler/test/oracle.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/test/oracle.js +204 -0
- package/dist/pg-sqlite-compiler/test/oracle.js.map +1 -0
- package/dist/pg-sqlite-compiler/types.d.ts +55 -0
- package/dist/pg-sqlite-compiler/types.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/types.js +2 -0
- package/dist/pg-sqlite-compiler/types.js.map +1 -0
- package/package.json +8 -4
- package/src/admin/admin-data.test.ts +0 -348
- package/src/admin/http-proxy.ts +0 -252
- package/src/admin/log-store.ts +0 -192
- package/src/admin/server.ts +0 -471
- package/src/admin/ui.ts +0 -1322
- package/src/bench/proxy-throughput.bench.ts +0 -343
- package/src/bench/serial-mutations.bench.ts +0 -270
- package/src/browser.ts +0 -203
- package/src/cf-do/.wrangler/cache/cf.json +0 -1
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
- package/src/cf-do/ARCHITECTURE.md +0 -83
- package/src/cf-do/watermark.test.ts +0 -103
- package/src/cf-do/watermark.ts +0 -118
- package/src/cf-do/worker.ts +0 -1033
- package/src/cf-do/wrangler.toml +0 -11
- package/src/cf-pglite/README.md +0 -19
- package/src/change-tracking.ts +0 -25
- package/src/child-process.test.ts +0 -147
- package/src/child-process.ts +0 -90
- package/src/cli-entry.ts +0 -72
- package/src/cli.test.ts +0 -38
- package/src/cli.ts +0 -1214
- package/src/config.ts +0 -150
- package/src/do-sql-tracking.test.ts +0 -19
- package/src/do-sql-tracking.ts +0 -19
- package/src/index.ts +0 -1215
- package/src/integration/integration.test.ts +0 -517
- package/src/integration/native-binary.guard.test.ts +0 -13
- package/src/integration/native-startup.test.ts +0 -44
- package/src/integration/replication-latency.test.ts +0 -428
- package/src/integration/restore-live-stress.test.ts +0 -433
- package/src/integration/restore-reset.test.ts +0 -400
- package/src/integration/restore.test.ts +0 -274
- package/src/integration/test-permissions.ts +0 -147
- package/src/load-config.ts +0 -46
- package/src/log.ts +0 -96
- package/src/mutex.ts +0 -47
- package/src/pg-proxy-browser.singledb.test.ts +0 -233
- package/src/pg-proxy-browser.ts +0 -2022
- package/src/pg-proxy-do-backend.test.ts +0 -3890
- package/src/pg-proxy-do-backend.ts +0 -7157
- package/src/pg-proxy.ts +0 -1087
- package/src/pglite-ipc.test.ts +0 -116
- package/src/pglite-ipc.ts +0 -266
- package/src/pglite-manager.ts +0 -557
- package/src/pglite-web-proxy.test.ts +0 -57
- package/src/pglite-web-proxy.ts +0 -221
- package/src/pglite-web-worker.ts +0 -152
- package/src/pglite-worker-thread.ts +0 -253
- package/src/port.ts +0 -25
- package/src/process-title.ts +0 -9
- package/src/recovery.ts +0 -155
- package/src/replication/change-tracker.test.ts +0 -357
- package/src/replication/change-tracker.ts +0 -279
- package/src/replication/handler.test.ts +0 -511
- package/src/replication/handler.ts +0 -1190
- package/src/replication/pgoutput-encoder.test.ts +0 -697
- package/src/replication/pgoutput-encoder.ts +0 -373
- package/src/replication/tcp-replication.test.ts +0 -876
- package/src/replication/zero-compat.test.ts +0 -1150
- package/src/restore-stress.test.ts +0 -188
- package/src/s3-local.ts +0 -203
- package/src/shim/hooks.mjs +0 -120
- package/src/shim/register.mjs +0 -4
- package/src/sqlite-mode/apply-mode.ts +0 -224
- package/src/sqlite-mode/index.ts +0 -15
- package/src/sqlite-mode/native-binary.ts +0 -89
- package/src/sqlite-mode/package-resolve.ts +0 -17
- package/src/sqlite-mode/resolve-mode.ts +0 -80
- package/src/sqlite-mode/shim-template.ts +0 -159
- package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
- package/src/sqlite-mode/types.ts +0 -30
- package/src/vite-plugin.ts +0 -67
- package/src/wasm-sqlite.test.ts +0 -537
- package/src/worker/browser-admin.ts +0 -52
- package/src/worker/browser-build-config.test.ts +0 -71
- package/src/worker/browser-build-config.ts +0 -109
- package/src/worker/browser-embed-admin.test.ts +0 -75
- package/src/worker/browser-embed.ts +0 -345
- package/src/worker/cf-patches.ts +0 -384
- package/src/worker/embed-integration.test.ts +0 -321
- package/src/worker/index.ts +0 -138
- package/src/worker/shims/fastify.test.ts +0 -255
- package/src/worker/shims/fastify.ts +0 -306
- package/src/worker/shims/http-service.test.ts +0 -355
- package/src/worker/shims/http-service.ts +0 -293
- package/src/worker/shims/node-stub.ts +0 -290
- package/src/worker/shims/oxfmt.ts +0 -3
- package/src/worker/shims/postgres-browser.ts +0 -59
- package/src/worker/shims/postgres-socket.test.ts +0 -576
- package/src/worker/shims/postgres-socket.ts +0 -310
- package/src/worker/shims/postgres.test.ts +0 -364
- package/src/worker/shims/postgres.ts +0 -1454
- package/src/worker/shims/sqlite-browser.test.ts +0 -233
- package/src/worker/shims/sqlite-browser.ts +0 -175
- package/src/worker/shims/sqlite.test.ts +0 -786
- package/src/worker/shims/sqlite.ts +0 -978
- package/src/worker/shims/stream-browser.ts +0 -15
- package/src/worker/shims/ws-browser.test.ts +0 -205
- package/src/worker/shims/ws-browser.ts +0 -248
- package/src/worker/shims/ws.test.ts +0 -288
- package/src/worker/shims/ws.ts +0 -467
- package/src/worker/shims/zero-process-env.ts +0 -11
- package/src/worker/types.ts +0 -75
- package/src/worker/worker-integration.test.ts +0 -223
- package/src/worker/worker.test.ts +0 -136
- package/src/worker/zero-cache-embed-cf.ts +0 -463
- package/src/worker/zero-cache-embed.ts +0 -277
|
@@ -1,1150 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* zero-cache pgoutput compatibility tests.
|
|
3
|
-
*
|
|
4
|
-
* adapted from zero-cache's stream.pg.test.ts. validates that our
|
|
5
|
-
* pglite proxy produces pgoutput messages decodable by zero-cache's
|
|
6
|
-
* PgoutputParser.
|
|
7
|
-
*
|
|
8
|
-
* our proxy stores change data as jsonb and encodes values as text in
|
|
9
|
-
* pgoutput tuples. RELATION messages carry correct postgres type OIDs
|
|
10
|
-
* so zero-cache can apply proper value conversions (e.g. timestamp → number).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { createConnection, type Socket } from 'node:net'
|
|
14
|
-
|
|
15
|
-
import { PGlite } from '@electric-sql/pglite'
|
|
16
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
17
|
-
|
|
18
|
-
import { getConfig } from '../config'
|
|
19
|
-
import { startPgProxy } from '../pg-proxy'
|
|
20
|
-
import {
|
|
21
|
-
installChangeTracking,
|
|
22
|
-
installTriggersOnShardTables,
|
|
23
|
-
resetShardSchemaCache,
|
|
24
|
-
} from './change-tracker'
|
|
25
|
-
import { signalReplicationChange, resetReplicationState } from './handler'
|
|
26
|
-
|
|
27
|
-
import type { Server, AddressInfo } from 'node:net'
|
|
28
|
-
|
|
29
|
-
// --- async queue (matches zero-cache's Queue pattern) ---
|
|
30
|
-
|
|
31
|
-
class Queue<T> {
|
|
32
|
-
private items: T[] = []
|
|
33
|
-
private waiters: Array<{
|
|
34
|
-
resolve: (item: T) => void
|
|
35
|
-
timer: ReturnType<typeof setTimeout>
|
|
36
|
-
}> = []
|
|
37
|
-
|
|
38
|
-
enqueue(item: T) {
|
|
39
|
-
const waiter = this.waiters.shift()
|
|
40
|
-
if (waiter) {
|
|
41
|
-
clearTimeout(waiter.timer)
|
|
42
|
-
waiter.resolve(item)
|
|
43
|
-
} else {
|
|
44
|
-
this.items.push(item)
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
dequeue(timeoutMs = 8000): Promise<T> {
|
|
49
|
-
const item = this.items.shift()
|
|
50
|
-
if (item !== undefined) return Promise.resolve(item)
|
|
51
|
-
|
|
52
|
-
return new Promise<T>((resolve, reject) => {
|
|
53
|
-
const entry = {
|
|
54
|
-
resolve,
|
|
55
|
-
timer: setTimeout(() => {
|
|
56
|
-
const idx = this.waiters.indexOf(entry)
|
|
57
|
-
if (idx >= 0) this.waiters.splice(idx, 1)
|
|
58
|
-
reject(new Error(`queue dequeue timeout (${timeoutMs}ms)`))
|
|
59
|
-
}, timeoutMs),
|
|
60
|
-
}
|
|
61
|
-
this.waiters.push(entry)
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// --- zero-cache compatible message types (mirrors pgoutput.types.ts) ---
|
|
67
|
-
|
|
68
|
-
interface ZcRelation {
|
|
69
|
-
tag: 'relation'
|
|
70
|
-
relationOid: number
|
|
71
|
-
schema: string
|
|
72
|
-
name: string
|
|
73
|
-
replicaIdentity: 'default' | 'nothing' | 'full' | 'index'
|
|
74
|
-
columns: Array<{ name: string; flags: number; typeOid: number; typeMod: number }>
|
|
75
|
-
keyColumns: string[]
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface ZcBegin {
|
|
79
|
-
tag: 'begin'
|
|
80
|
-
commitLsn: string
|
|
81
|
-
xid: number
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
interface ZcCommit {
|
|
85
|
-
tag: 'commit'
|
|
86
|
-
flags: number
|
|
87
|
-
commitLsn: string
|
|
88
|
-
commitEndLsn: string
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
interface ZcInsert {
|
|
92
|
-
tag: 'insert'
|
|
93
|
-
relation: ZcRelation
|
|
94
|
-
new: Record<string, string | null>
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface ZcUpdate {
|
|
98
|
-
tag: 'update'
|
|
99
|
-
relation: ZcRelation
|
|
100
|
-
key: Record<string, string | null> | null
|
|
101
|
-
old: Record<string, string | null> | null
|
|
102
|
-
new: Record<string, string | null>
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
interface ZcDelete {
|
|
106
|
-
tag: 'delete'
|
|
107
|
-
relation: ZcRelation
|
|
108
|
-
key: Record<string, string | null> | null
|
|
109
|
-
old: Record<string, string | null> | null
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface ZcKeepalive {
|
|
113
|
-
tag: 'keepalive'
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
type ZcMessage =
|
|
117
|
-
| ZcBegin
|
|
118
|
-
| ZcCommit
|
|
119
|
-
| ZcRelation
|
|
120
|
-
| ZcInsert
|
|
121
|
-
| ZcUpdate
|
|
122
|
-
| ZcDelete
|
|
123
|
-
| ZcKeepalive
|
|
124
|
-
|
|
125
|
-
// --- pgoutput decoder (zero-cache compatible output) ---
|
|
126
|
-
|
|
127
|
-
const REPLICA_IDENTITY: Record<number, ZcRelation['replicaIdentity']> = {
|
|
128
|
-
0x64: 'default',
|
|
129
|
-
0x6e: 'nothing',
|
|
130
|
-
0x66: 'full',
|
|
131
|
-
0x69: 'index',
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function lsnStr(val: bigint): string {
|
|
135
|
-
const hi = Number((val >> 32n) & 0xffffffffn)
|
|
136
|
-
const lo = Number(val & 0xffffffffn)
|
|
137
|
-
return `${hi.toString(16).toUpperCase()}/${lo.toString(16).toUpperCase()}`
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
class ZcDecoder {
|
|
141
|
-
private relations = new Map<number, ZcRelation>()
|
|
142
|
-
|
|
143
|
-
decodeCopyData(frame: Uint8Array): ZcMessage | null {
|
|
144
|
-
if (frame[0] !== 0x64) return null
|
|
145
|
-
if (frame[5] === 0x77) return this.decode(frame.subarray(30)) // XLogData
|
|
146
|
-
if (frame[5] === 0x6b) return { tag: 'keepalive' } as ZcKeepalive
|
|
147
|
-
return null
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private decode(buf: Uint8Array): ZcMessage {
|
|
151
|
-
const dv = new DataView(buf.buffer, buf.byteOffset)
|
|
152
|
-
switch (buf[0]) {
|
|
153
|
-
case 0x42: // Begin
|
|
154
|
-
return {
|
|
155
|
-
tag: 'begin',
|
|
156
|
-
commitLsn: lsnStr(dv.getBigInt64(1)),
|
|
157
|
-
xid: dv.getInt32(17),
|
|
158
|
-
}
|
|
159
|
-
case 0x43: // Commit
|
|
160
|
-
return {
|
|
161
|
-
tag: 'commit',
|
|
162
|
-
flags: buf[1],
|
|
163
|
-
commitLsn: lsnStr(dv.getBigInt64(2)),
|
|
164
|
-
commitEndLsn: lsnStr(dv.getBigInt64(10)),
|
|
165
|
-
}
|
|
166
|
-
case 0x52: // Relation
|
|
167
|
-
return this.decodeRelation(buf, dv)
|
|
168
|
-
case 0x49: // Insert
|
|
169
|
-
return this.decodeInsert(buf, dv)
|
|
170
|
-
case 0x55: // Update
|
|
171
|
-
return this.decodeUpdate(buf, dv)
|
|
172
|
-
case 0x44: // Delete
|
|
173
|
-
return this.decodeDelete(buf, dv)
|
|
174
|
-
default:
|
|
175
|
-
throw new Error(`unknown pgoutput tag: 0x${buf[0].toString(16)}`)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private decodeRelation(buf: Uint8Array, dv: DataView): ZcRelation {
|
|
180
|
-
const oid = dv.getInt32(1)
|
|
181
|
-
let pos = 5
|
|
182
|
-
const [schema, p1] = this.cstr(buf, pos)
|
|
183
|
-
pos = p1
|
|
184
|
-
const [name, p2] = this.cstr(buf, pos)
|
|
185
|
-
pos = p2
|
|
186
|
-
const replicaIdentity = REPLICA_IDENTITY[buf[pos++]] || 'default'
|
|
187
|
-
const numCols = dv.getInt16(pos)
|
|
188
|
-
pos += 2
|
|
189
|
-
const columns: ZcRelation['columns'] = []
|
|
190
|
-
for (let i = 0; i < numCols; i++) {
|
|
191
|
-
const flags = buf[pos++]
|
|
192
|
-
const [colName, np] = this.cstr(buf, pos)
|
|
193
|
-
pos = np
|
|
194
|
-
const typeOid = new DataView(buf.buffer, buf.byteOffset).getInt32(pos)
|
|
195
|
-
pos += 4
|
|
196
|
-
const typeMod = new DataView(buf.buffer, buf.byteOffset).getInt32(pos)
|
|
197
|
-
pos += 4
|
|
198
|
-
columns.push({ name: colName, flags, typeOid, typeMod })
|
|
199
|
-
}
|
|
200
|
-
const keyColumns = columns.filter((c) => c.flags & 1).map((c) => c.name)
|
|
201
|
-
const rel: ZcRelation = {
|
|
202
|
-
tag: 'relation',
|
|
203
|
-
relationOid: oid,
|
|
204
|
-
schema,
|
|
205
|
-
name,
|
|
206
|
-
replicaIdentity,
|
|
207
|
-
columns,
|
|
208
|
-
keyColumns,
|
|
209
|
-
}
|
|
210
|
-
this.relations.set(oid, rel)
|
|
211
|
-
return rel
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
private decodeInsert(buf: Uint8Array, dv: DataView): ZcInsert {
|
|
215
|
-
const oid = dv.getInt32(1)
|
|
216
|
-
const rel = this.relations.get(oid)!
|
|
217
|
-
// skip marker byte at offset 5 ('N')
|
|
218
|
-
const [tuple] = this.readTuple(buf, 6, rel)
|
|
219
|
-
return { tag: 'insert', relation: rel, new: tuple }
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
private decodeUpdate(buf: Uint8Array, dv: DataView): ZcUpdate {
|
|
223
|
-
const oid = dv.getInt32(1)
|
|
224
|
-
const rel = this.relations.get(oid)!
|
|
225
|
-
let pos = 5
|
|
226
|
-
let old: Record<string, string | null> | null = null
|
|
227
|
-
let key: Record<string, string | null> | null = null
|
|
228
|
-
|
|
229
|
-
if (buf[pos] === 0x4b) {
|
|
230
|
-
// 'K' key tuple
|
|
231
|
-
pos++
|
|
232
|
-
const [k, np] = this.readTuple(buf, pos, rel)
|
|
233
|
-
key = k
|
|
234
|
-
pos = np
|
|
235
|
-
} else if (buf[pos] === 0x4f) {
|
|
236
|
-
// 'O' old tuple
|
|
237
|
-
pos++
|
|
238
|
-
const [o, np] = this.readTuple(buf, pos, rel)
|
|
239
|
-
old = o
|
|
240
|
-
pos = np
|
|
241
|
-
}
|
|
242
|
-
// consume 'N' marker
|
|
243
|
-
if (buf[pos] === 0x4e) pos++
|
|
244
|
-
const [newTuple] = this.readTuple(buf, pos, rel)
|
|
245
|
-
return { tag: 'update', relation: rel, key, old, new: newTuple }
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private decodeDelete(buf: Uint8Array, dv: DataView): ZcDelete {
|
|
249
|
-
const oid = dv.getInt32(1)
|
|
250
|
-
const rel = this.relations.get(oid)!
|
|
251
|
-
let key: Record<string, string | null> | null = null
|
|
252
|
-
let old: Record<string, string | null> | null = null
|
|
253
|
-
if (buf[5] === 0x4b) {
|
|
254
|
-
const [k] = this.readTuple(buf, 6, rel)
|
|
255
|
-
key = k
|
|
256
|
-
} else if (buf[5] === 0x4f) {
|
|
257
|
-
const [o] = this.readTuple(buf, 6, rel)
|
|
258
|
-
old = o
|
|
259
|
-
}
|
|
260
|
-
return { tag: 'delete', relation: rel, key, old }
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
private readTuple(
|
|
264
|
-
buf: Uint8Array,
|
|
265
|
-
off: number,
|
|
266
|
-
rel: ZcRelation
|
|
267
|
-
): [Record<string, string | null>, number] {
|
|
268
|
-
const n = new DataView(buf.buffer, buf.byteOffset).getInt16(off)
|
|
269
|
-
off += 2
|
|
270
|
-
const row: Record<string, string | null> = {}
|
|
271
|
-
for (let i = 0; i < n; i++) {
|
|
272
|
-
const name = rel.columns[i]?.name || `col${i}`
|
|
273
|
-
const kind = buf[off++]
|
|
274
|
-
if (kind === 0x6e) {
|
|
275
|
-
row[name] = null
|
|
276
|
-
} else if (kind === 0x74) {
|
|
277
|
-
const len = new DataView(buf.buffer, buf.byteOffset).getInt32(off)
|
|
278
|
-
off += 4
|
|
279
|
-
row[name] = new TextDecoder().decode(buf.subarray(off, off + len))
|
|
280
|
-
off += len
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return [row, off]
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
private cstr(buf: Uint8Array, off: number): [string, number] {
|
|
287
|
-
let end = off
|
|
288
|
-
while (end < buf.length && buf[end] !== 0) end++
|
|
289
|
-
return [new TextDecoder().decode(buf.subarray(off, end)), end + 1]
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// --- wire protocol helpers ---
|
|
294
|
-
|
|
295
|
-
function startup(params: Record<string, string>): Buffer {
|
|
296
|
-
const pairs: Buffer[] = []
|
|
297
|
-
for (const [k, v] of Object.entries(params)) pairs.push(Buffer.from(`${k}\0${v}\0`))
|
|
298
|
-
pairs.push(Buffer.from('\0'))
|
|
299
|
-
const bodyLen = pairs.reduce((s, b) => s + b.length, 0)
|
|
300
|
-
const buf = Buffer.alloc(8 + bodyLen)
|
|
301
|
-
buf.writeInt32BE(8 + bodyLen, 0)
|
|
302
|
-
buf.writeInt32BE(196608, 4)
|
|
303
|
-
let pos = 8
|
|
304
|
-
for (const p of pairs) {
|
|
305
|
-
p.copy(buf, pos)
|
|
306
|
-
pos += p.length
|
|
307
|
-
}
|
|
308
|
-
return buf
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function password(pw: string): Buffer {
|
|
312
|
-
const b = Buffer.from(pw + '\0')
|
|
313
|
-
const buf = Buffer.alloc(5 + b.length)
|
|
314
|
-
buf[0] = 0x70
|
|
315
|
-
buf.writeInt32BE(4 + b.length, 1)
|
|
316
|
-
b.copy(buf, 5)
|
|
317
|
-
return buf
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function query(sql: string): Buffer {
|
|
321
|
-
const b = Buffer.from(sql + '\0')
|
|
322
|
-
const buf = Buffer.alloc(5 + b.length)
|
|
323
|
-
buf[0] = 0x51
|
|
324
|
-
buf.writeInt32BE(4 + b.length, 1)
|
|
325
|
-
b.copy(buf, 5)
|
|
326
|
-
return buf
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function parsePgMsg(buf: Buffer): [{ type: number; data: Buffer } | null, Buffer] {
|
|
330
|
-
if (buf.length < 5) return [null, buf]
|
|
331
|
-
const len = buf.readInt32BE(1)
|
|
332
|
-
if (buf.length < 1 + len) return [null, buf]
|
|
333
|
-
return [{ type: buf[0], data: buf.subarray(0, 1 + len) }, buf.subarray(1 + len)]
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// --- replication stream (high-level wrapper) ---
|
|
337
|
-
|
|
338
|
-
class ReplicationStream {
|
|
339
|
-
private socket!: Socket
|
|
340
|
-
private buf = Buffer.alloc(0)
|
|
341
|
-
private pgWaiters: Array<(msg: { type: number; data: Buffer }) => void> = []
|
|
342
|
-
private pgQueue: Array<{ type: number; data: Buffer }> = []
|
|
343
|
-
private decoder = new ZcDecoder()
|
|
344
|
-
private _msgs = new Queue<ZcMessage>()
|
|
345
|
-
private streaming = false
|
|
346
|
-
|
|
347
|
-
constructor(private port: number) {}
|
|
348
|
-
|
|
349
|
-
get messages(): Queue<ZcMessage> {
|
|
350
|
-
return this._msgs
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async connect(): Promise<void> {
|
|
354
|
-
this.socket = createConnection({ port: this.port, host: '127.0.0.1' })
|
|
355
|
-
await new Promise<void>((res, rej) => {
|
|
356
|
-
this.socket.once('connect', res)
|
|
357
|
-
this.socket.once('error', rej)
|
|
358
|
-
})
|
|
359
|
-
this.socket.on('data', (chunk: Buffer) => {
|
|
360
|
-
this.buf = Buffer.concat([this.buf, chunk])
|
|
361
|
-
this.drain()
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
this.socket.write(
|
|
365
|
-
startup({ user: 'user', database: 'postgres', replication: 'database' })
|
|
366
|
-
)
|
|
367
|
-
const auth = await this.nextPg()
|
|
368
|
-
if (auth.data.readInt32BE(5) === 3) {
|
|
369
|
-
this.socket.write(password('password'))
|
|
370
|
-
await this.nextPg()
|
|
371
|
-
}
|
|
372
|
-
while ((await this.nextPg()).type !== 0x5a) {
|
|
373
|
-
/* consume until ReadyForQuery */
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
async createSlot(name: string): Promise<void> {
|
|
378
|
-
this.socket.write(
|
|
379
|
-
query(
|
|
380
|
-
`CREATE_REPLICATION_SLOT "${name}" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT`
|
|
381
|
-
)
|
|
382
|
-
)
|
|
383
|
-
while ((await this.nextPg()).type !== 0x5a) {
|
|
384
|
-
/* consume until ReadyForQuery */
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async startReplication(slot: string, pubs: string[]): Promise<void> {
|
|
389
|
-
this.streaming = true
|
|
390
|
-
this.socket.write(
|
|
391
|
-
query(
|
|
392
|
-
`START_REPLICATION SLOT "${slot}" LOGICAL 0/0 (proto_version '1', publication_names '${pubs.join(',')}')`
|
|
393
|
-
)
|
|
394
|
-
)
|
|
395
|
-
await new Promise((r) => setTimeout(r, 150))
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
close(): void {
|
|
399
|
-
this.socket?.destroy()
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
private drain() {
|
|
403
|
-
while (true) {
|
|
404
|
-
const [msg, rest] = parsePgMsg(this.buf)
|
|
405
|
-
if (!msg) break
|
|
406
|
-
this.buf = rest
|
|
407
|
-
if (this.streaming) {
|
|
408
|
-
if (msg.type === 0x64) {
|
|
409
|
-
const decoded = this.decoder.decodeCopyData(new Uint8Array(msg.data))
|
|
410
|
-
if (decoded) this._msgs.enqueue(decoded)
|
|
411
|
-
}
|
|
412
|
-
// skip CopyBothResponse (0x57) and other non-CopyData in streaming
|
|
413
|
-
} else {
|
|
414
|
-
const w = this.pgWaiters.shift()
|
|
415
|
-
if (w) w(msg)
|
|
416
|
-
else this.pgQueue.push(msg)
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
private nextPg(ms = 5000): Promise<{ type: number; data: Buffer }> {
|
|
422
|
-
const q = this.pgQueue.shift()
|
|
423
|
-
if (q) return Promise.resolve(q)
|
|
424
|
-
return new Promise((resolve, reject) => {
|
|
425
|
-
const t = setTimeout(() => {
|
|
426
|
-
const i = this.pgWaiters.indexOf(resolve)
|
|
427
|
-
if (i >= 0) this.pgWaiters.splice(i, 1)
|
|
428
|
-
reject(new Error('pg message timeout'))
|
|
429
|
-
}, ms)
|
|
430
|
-
this.pgWaiters.push((msg) => {
|
|
431
|
-
clearTimeout(t)
|
|
432
|
-
resolve(msg)
|
|
433
|
-
})
|
|
434
|
-
})
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// --- helper: skip keepalives ---
|
|
439
|
-
|
|
440
|
-
async function nextData(q: Queue<ZcMessage>): Promise<ZcMessage> {
|
|
441
|
-
while (true) {
|
|
442
|
-
const m = await q.dequeue()
|
|
443
|
-
if (m.tag !== 'keepalive') return m
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// --- tests ---
|
|
448
|
-
|
|
449
|
-
describe('zero-cache pgoutput compatibility', { timeout: 30000 }, () => {
|
|
450
|
-
let db: PGlite
|
|
451
|
-
let server: Server
|
|
452
|
-
let port: number
|
|
453
|
-
|
|
454
|
-
beforeEach(async () => {
|
|
455
|
-
resetReplicationState()
|
|
456
|
-
resetShardSchemaCache()
|
|
457
|
-
db = new PGlite()
|
|
458
|
-
await db.waitReady
|
|
459
|
-
await db.exec(`
|
|
460
|
-
CREATE TABLE public.foo (
|
|
461
|
-
id TEXT PRIMARY KEY,
|
|
462
|
-
int_val INTEGER,
|
|
463
|
-
big_val BIGINT,
|
|
464
|
-
flt_val FLOAT8,
|
|
465
|
-
bool_val BOOLEAN,
|
|
466
|
-
text_val TEXT
|
|
467
|
-
)
|
|
468
|
-
`)
|
|
469
|
-
await db.exec(`
|
|
470
|
-
CREATE TABLE public.bar (
|
|
471
|
-
a TEXT PRIMARY KEY, b TEXT, c TEXT
|
|
472
|
-
)
|
|
473
|
-
`)
|
|
474
|
-
await db.exec(`CREATE PUBLICATION zero_pub FOR ALL TABLES`)
|
|
475
|
-
await installChangeTracking(db)
|
|
476
|
-
|
|
477
|
-
// auto-signal the replication handler after every db.exec() call.
|
|
478
|
-
// in production, writes go through the TCP proxy which signals automatically.
|
|
479
|
-
// in tests, db.exec() bypasses the proxy, so we signal explicitly.
|
|
480
|
-
const origExec = db.exec.bind(db)
|
|
481
|
-
;(db as any).exec = async (sql: string) => {
|
|
482
|
-
const result = await origExec(sql)
|
|
483
|
-
signalReplicationChange()
|
|
484
|
-
return result
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const config = { ...getConfig(), pgPort: 0 }
|
|
488
|
-
server = await startPgProxy(db, config)
|
|
489
|
-
port = (server.address() as AddressInfo).port
|
|
490
|
-
})
|
|
491
|
-
|
|
492
|
-
afterEach(async () => {
|
|
493
|
-
signalReplicationChange()
|
|
494
|
-
server?.close()
|
|
495
|
-
// yield to let socket close events propagate so the replication
|
|
496
|
-
// poll loop exits before we close pglite (0.4.x close() is stricter)
|
|
497
|
-
await new Promise((r) => setTimeout(r, 50))
|
|
498
|
-
signalReplicationChange()
|
|
499
|
-
await db?.close()
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
async function stream(): Promise<ReplicationStream> {
|
|
503
|
-
const s = new ReplicationStream(port)
|
|
504
|
-
await s.connect()
|
|
505
|
-
await s.createSlot('compat_slot')
|
|
506
|
-
await s.startReplication('compat_slot', ['zero_pub'])
|
|
507
|
-
return s
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
it('insert: begin → relation → insert → commit', async () => {
|
|
511
|
-
const s = await stream()
|
|
512
|
-
await db.exec(`INSERT INTO public.foo (id, text_val) VALUES ('hello', 'world')`)
|
|
513
|
-
|
|
514
|
-
const q = s.messages
|
|
515
|
-
const begin = await nextData(q)
|
|
516
|
-
expect(begin).toMatchObject({ tag: 'begin' })
|
|
517
|
-
|
|
518
|
-
const rel = await nextData(q)
|
|
519
|
-
expect(rel).toMatchObject({
|
|
520
|
-
tag: 'relation',
|
|
521
|
-
schema: 'public',
|
|
522
|
-
name: 'foo',
|
|
523
|
-
replicaIdentity: 'default',
|
|
524
|
-
})
|
|
525
|
-
|
|
526
|
-
const ins = await nextData(q)
|
|
527
|
-
expect(ins.tag).toBe('insert')
|
|
528
|
-
expect((ins as ZcInsert).relation.name).toBe('foo')
|
|
529
|
-
expect((ins as ZcInsert).new.id).toBe('hello')
|
|
530
|
-
expect((ins as ZcInsert).new.text_val).toBe('world')
|
|
531
|
-
|
|
532
|
-
const commit = await nextData(q)
|
|
533
|
-
expect(commit).toMatchObject({ tag: 'commit' })
|
|
534
|
-
|
|
535
|
-
s.close()
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
it('relation has correct schema, columns, and replica identity', async () => {
|
|
539
|
-
const s = await stream()
|
|
540
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('rel_test')`)
|
|
541
|
-
|
|
542
|
-
const q = s.messages
|
|
543
|
-
let rel: ZcRelation | null = null
|
|
544
|
-
while (!rel) {
|
|
545
|
-
const m = await nextData(q)
|
|
546
|
-
if (m.tag === 'relation') rel = m as ZcRelation
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
expect(rel.schema).toBe('public')
|
|
550
|
-
expect(rel.name).toBe('foo')
|
|
551
|
-
expect(rel.replicaIdentity).toBe('default')
|
|
552
|
-
expect(rel.relationOid).toBeGreaterThanOrEqual(16384)
|
|
553
|
-
|
|
554
|
-
const names = rel.columns.map((c) => c.name)
|
|
555
|
-
expect(names).toContain('id')
|
|
556
|
-
expect(names).toContain('int_val')
|
|
557
|
-
expect(names).toContain('text_val')
|
|
558
|
-
|
|
559
|
-
// typeOids match actual postgres column types
|
|
560
|
-
const expectedOids: Record<string, number> = {
|
|
561
|
-
id: 25, // text
|
|
562
|
-
int_val: 23, // integer
|
|
563
|
-
big_val: 20, // bigint
|
|
564
|
-
flt_val: 701, // double precision (float8)
|
|
565
|
-
bool_val: 16, // boolean
|
|
566
|
-
text_val: 25, // text
|
|
567
|
-
}
|
|
568
|
-
for (const col of rel.columns) {
|
|
569
|
-
expect(col.typeOid, `typeOid for ${col.name}`).toBe(expectedOids[col.name])
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
s.close()
|
|
573
|
-
})
|
|
574
|
-
|
|
575
|
-
it('values encoded as text format from jsonb', async () => {
|
|
576
|
-
const s = await stream()
|
|
577
|
-
await db.exec(`
|
|
578
|
-
INSERT INTO public.foo (id, int_val, big_val, flt_val, bool_val, text_val)
|
|
579
|
-
VALUES ('types', 123, 9876543210, 3.14, true, 'hello')
|
|
580
|
-
`)
|
|
581
|
-
|
|
582
|
-
const q = s.messages
|
|
583
|
-
let ins: ZcInsert | null = null
|
|
584
|
-
while (!ins) {
|
|
585
|
-
const m = await nextData(q)
|
|
586
|
-
if (m.tag === 'insert') ins = m as ZcInsert
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
expect(ins.new.id).toBe('types')
|
|
590
|
-
expect(ins.new.int_val).toBe('123')
|
|
591
|
-
expect(ins.new.big_val).toBe('9876543210')
|
|
592
|
-
expect(ins.new.flt_val).toBe('3.14')
|
|
593
|
-
expect(ins.new.bool_val).toBe('t')
|
|
594
|
-
expect(ins.new.text_val).toBe('hello')
|
|
595
|
-
|
|
596
|
-
s.close()
|
|
597
|
-
})
|
|
598
|
-
|
|
599
|
-
it('null values encoded correctly', async () => {
|
|
600
|
-
const s = await stream()
|
|
601
|
-
await db.exec(
|
|
602
|
-
`INSERT INTO public.foo (id, int_val, text_val) VALUES ('nul', NULL, NULL)`
|
|
603
|
-
)
|
|
604
|
-
|
|
605
|
-
const q = s.messages
|
|
606
|
-
let ins: ZcInsert | null = null
|
|
607
|
-
while (!ins) {
|
|
608
|
-
const m = await nextData(q)
|
|
609
|
-
if (m.tag === 'insert') ins = m as ZcInsert
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
expect(ins.new.id).toBe('nul')
|
|
613
|
-
expect(ins.new.int_val).toBeNull()
|
|
614
|
-
expect(ins.new.text_val).toBeNull()
|
|
615
|
-
|
|
616
|
-
s.close()
|
|
617
|
-
})
|
|
618
|
-
|
|
619
|
-
it('update includes old + new tuple (like zero-cache expects)', async () => {
|
|
620
|
-
const s = await stream()
|
|
621
|
-
const q = s.messages
|
|
622
|
-
|
|
623
|
-
await db.exec(`INSERT INTO public.foo (id, int_val) VALUES ('upd', 10)`)
|
|
624
|
-
// consume insert transaction
|
|
625
|
-
while ((await nextData(q)).tag !== 'commit') {}
|
|
626
|
-
|
|
627
|
-
await db.exec(`UPDATE public.foo SET int_val = 20 WHERE id = 'upd'`)
|
|
628
|
-
|
|
629
|
-
let upd: ZcUpdate | null = null
|
|
630
|
-
while (!upd) {
|
|
631
|
-
const m = await nextData(q)
|
|
632
|
-
if (m.tag === 'update') upd = m as ZcUpdate
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
expect(upd.relation.name).toBe('foo')
|
|
636
|
-
expect(upd.new.id).toBe('upd')
|
|
637
|
-
expect(upd.new.int_val).toBe('20')
|
|
638
|
-
expect(upd.old).not.toBeNull()
|
|
639
|
-
expect(upd.old!.id).toBe('upd')
|
|
640
|
-
expect(upd.old!.int_val).toBe('10')
|
|
641
|
-
|
|
642
|
-
s.close()
|
|
643
|
-
})
|
|
644
|
-
|
|
645
|
-
it('delete includes key data', async () => {
|
|
646
|
-
const s = await stream()
|
|
647
|
-
const q = s.messages
|
|
648
|
-
|
|
649
|
-
await db.exec(`INSERT INTO public.foo (id, text_val) VALUES ('del', 'bye')`)
|
|
650
|
-
while ((await nextData(q)).tag !== 'commit') {}
|
|
651
|
-
|
|
652
|
-
await db.exec(`DELETE FROM public.foo WHERE id = 'del'`)
|
|
653
|
-
|
|
654
|
-
let del: ZcDelete | null = null
|
|
655
|
-
while (!del) {
|
|
656
|
-
const m = await nextData(q)
|
|
657
|
-
if (m.tag === 'delete') del = m as ZcDelete
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
expect(del.relation.name).toBe('foo')
|
|
661
|
-
// our proxy sends 'K' key tuple with all column data
|
|
662
|
-
expect(del.key).not.toBeNull()
|
|
663
|
-
expect(del.key!.id).toBe('del')
|
|
664
|
-
|
|
665
|
-
s.close()
|
|
666
|
-
})
|
|
667
|
-
|
|
668
|
-
it('multiple tables produce separate relations (like zero-cache multi-publication)', async () => {
|
|
669
|
-
const s = await stream()
|
|
670
|
-
const q = s.messages
|
|
671
|
-
|
|
672
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('from_foo')`)
|
|
673
|
-
await db.exec(`INSERT INTO public.bar (a, b) VALUES ('from_bar', 'val')`)
|
|
674
|
-
|
|
675
|
-
const rels: ZcRelation[] = []
|
|
676
|
-
const inserts: ZcInsert[] = []
|
|
677
|
-
|
|
678
|
-
const deadline = Date.now() + 6000
|
|
679
|
-
while (inserts.length < 2 && Date.now() < deadline) {
|
|
680
|
-
const m = await nextData(q)
|
|
681
|
-
if (m.tag === 'relation') rels.push(m as ZcRelation)
|
|
682
|
-
if (m.tag === 'insert') inserts.push(m as ZcInsert)
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
expect(inserts).toHaveLength(2)
|
|
686
|
-
|
|
687
|
-
const tables = new Set(rels.map((r) => r.name))
|
|
688
|
-
expect(tables).toContain('foo')
|
|
689
|
-
expect(tables).toContain('bar')
|
|
690
|
-
|
|
691
|
-
// inserts reference correct relations
|
|
692
|
-
const fooIns = inserts.find((i) => i.relation.name === 'foo')!
|
|
693
|
-
const barIns = inserts.find((i) => i.relation.name === 'bar')!
|
|
694
|
-
expect(fooIns.new.id).toBe('from_foo')
|
|
695
|
-
expect(barIns.new.a).toBe('from_bar')
|
|
696
|
-
|
|
697
|
-
s.close()
|
|
698
|
-
})
|
|
699
|
-
|
|
700
|
-
it('relation sent only once per table across transactions', async () => {
|
|
701
|
-
const s = await stream()
|
|
702
|
-
const q = s.messages
|
|
703
|
-
|
|
704
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('first')`)
|
|
705
|
-
while ((await nextData(q)).tag !== 'commit') {}
|
|
706
|
-
|
|
707
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('second')`)
|
|
708
|
-
|
|
709
|
-
// second transaction should NOT repeat the relation
|
|
710
|
-
const tx: ZcMessage[] = []
|
|
711
|
-
while (true) {
|
|
712
|
-
const m = await nextData(q)
|
|
713
|
-
tx.push(m)
|
|
714
|
-
if (m.tag === 'commit') break
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
expect(tx.filter((m) => m.tag === 'relation')).toHaveLength(0)
|
|
718
|
-
|
|
719
|
-
s.close()
|
|
720
|
-
})
|
|
721
|
-
|
|
722
|
-
it('each transaction has matching begin/commit', async () => {
|
|
723
|
-
const s = await stream()
|
|
724
|
-
const q = s.messages
|
|
725
|
-
|
|
726
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('t1')`)
|
|
727
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('t2')`)
|
|
728
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('t3')`)
|
|
729
|
-
|
|
730
|
-
const all: ZcMessage[] = []
|
|
731
|
-
const deadline = Date.now() + 8000
|
|
732
|
-
while (Date.now() < deadline) {
|
|
733
|
-
const m = await q.dequeue(2000).catch(() => null)
|
|
734
|
-
if (!m) break
|
|
735
|
-
if (m.tag !== 'keepalive') all.push(m)
|
|
736
|
-
if (all.filter((x) => x.tag === 'commit').length >= 3) break
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
const begins = all.filter((m) => m.tag === 'begin')
|
|
740
|
-
const commits = all.filter((m) => m.tag === 'commit')
|
|
741
|
-
const inserts = all.filter((m) => m.tag === 'insert') as ZcInsert[]
|
|
742
|
-
|
|
743
|
-
expect(begins.length).toBeGreaterThanOrEqual(1)
|
|
744
|
-
expect(begins.length).toBe(commits.length)
|
|
745
|
-
expect(inserts).toHaveLength(3)
|
|
746
|
-
|
|
747
|
-
const ids = inserts.map((i) => i.new.id)
|
|
748
|
-
expect(ids).toContain('t1')
|
|
749
|
-
expect(ids).toContain('t2')
|
|
750
|
-
expect(ids).toContain('t3')
|
|
751
|
-
|
|
752
|
-
s.close()
|
|
753
|
-
})
|
|
754
|
-
|
|
755
|
-
it('commit LSNs increase monotonically', async () => {
|
|
756
|
-
const s = await stream()
|
|
757
|
-
const q = s.messages
|
|
758
|
-
|
|
759
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('lsn1')`)
|
|
760
|
-
// wait for first commit before second insert to avoid poll batching
|
|
761
|
-
let commit1: ZcCommit | null = null
|
|
762
|
-
while (true) {
|
|
763
|
-
const m = await nextData(q)
|
|
764
|
-
if (m.tag === 'commit') {
|
|
765
|
-
commit1 = m as ZcCommit
|
|
766
|
-
break
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('lsn2')`)
|
|
771
|
-
let commit2: ZcCommit | null = null
|
|
772
|
-
while (true) {
|
|
773
|
-
const m = await nextData(q)
|
|
774
|
-
if (m.tag === 'commit') {
|
|
775
|
-
commit2 = m as ZcCommit
|
|
776
|
-
break
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
function parseLsn(s: string): bigint {
|
|
781
|
-
const [hi, lo] = s.split('/')
|
|
782
|
-
return (BigInt(parseInt(hi, 16)) << 32n) | BigInt(parseInt(lo, 16))
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
expect(parseLsn(commit2!.commitEndLsn)).toBeGreaterThan(
|
|
786
|
-
parseLsn(commit1!.commitEndLsn)
|
|
787
|
-
)
|
|
788
|
-
|
|
789
|
-
s.close()
|
|
790
|
-
})
|
|
791
|
-
|
|
792
|
-
it('mixed insert/update/delete in sequence', async () => {
|
|
793
|
-
const s = await stream()
|
|
794
|
-
const q = s.messages
|
|
795
|
-
|
|
796
|
-
await db.exec(`INSERT INTO public.foo (id, int_val) VALUES ('mix', 1)`)
|
|
797
|
-
while ((await nextData(q)).tag !== 'commit') {}
|
|
798
|
-
|
|
799
|
-
await db.exec(`UPDATE public.foo SET int_val = 2 WHERE id = 'mix'`)
|
|
800
|
-
while ((await nextData(q)).tag !== 'commit') {}
|
|
801
|
-
|
|
802
|
-
await db.exec(`DELETE FROM public.foo WHERE id = 'mix'`)
|
|
803
|
-
|
|
804
|
-
const tx: ZcMessage[] = []
|
|
805
|
-
while (true) {
|
|
806
|
-
const m = await nextData(q)
|
|
807
|
-
tx.push(m)
|
|
808
|
-
if (m.tag === 'commit') break
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
expect(tx[0].tag).toBe('begin')
|
|
812
|
-
expect(tx[1].tag).toBe('delete')
|
|
813
|
-
expect(tx[2].tag).toBe('commit')
|
|
814
|
-
|
|
815
|
-
s.close()
|
|
816
|
-
})
|
|
817
|
-
|
|
818
|
-
it('multi-row insert produces individual insert messages', async () => {
|
|
819
|
-
const s = await stream()
|
|
820
|
-
const q = s.messages
|
|
821
|
-
|
|
822
|
-
await db.exec(
|
|
823
|
-
`INSERT INTO public.foo (id, int_val) VALUES ('m1', 1), ('m2', 2), ('m3', 3)`
|
|
824
|
-
)
|
|
825
|
-
|
|
826
|
-
const inserts: ZcInsert[] = []
|
|
827
|
-
const deadline = Date.now() + 5000
|
|
828
|
-
while (inserts.length < 3 && Date.now() < deadline) {
|
|
829
|
-
const m = await nextData(q)
|
|
830
|
-
if (m.tag === 'insert') inserts.push(m as ZcInsert)
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
expect(inserts).toHaveLength(3)
|
|
834
|
-
const vals = inserts.map((i) => i.new.int_val).sort()
|
|
835
|
-
expect(vals).toEqual(['1', '2', '3'])
|
|
836
|
-
|
|
837
|
-
s.close()
|
|
838
|
-
})
|
|
839
|
-
|
|
840
|
-
it('multi-row update produces individual update messages', async () => {
|
|
841
|
-
const s = await stream()
|
|
842
|
-
const q = s.messages
|
|
843
|
-
|
|
844
|
-
await db.exec(
|
|
845
|
-
`INSERT INTO public.foo (id, int_val) VALUES ('u1', 1), ('u2', 2), ('u3', 3)`
|
|
846
|
-
)
|
|
847
|
-
// consume insert tx
|
|
848
|
-
while ((await nextData(q)).tag !== 'commit') {}
|
|
849
|
-
|
|
850
|
-
await db.exec(`UPDATE public.foo SET int_val = int_val * 10`)
|
|
851
|
-
|
|
852
|
-
const updates: ZcUpdate[] = []
|
|
853
|
-
const deadline = Date.now() + 5000
|
|
854
|
-
while (updates.length < 3 && Date.now() < deadline) {
|
|
855
|
-
const m = await nextData(q)
|
|
856
|
-
if (m.tag === 'update') updates.push(m as ZcUpdate)
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
expect(updates).toHaveLength(3)
|
|
860
|
-
const newVals = updates.map((u) => u.new.int_val).sort()
|
|
861
|
-
expect(newVals).toEqual(['10', '20', '30'])
|
|
862
|
-
|
|
863
|
-
s.close()
|
|
864
|
-
})
|
|
865
|
-
|
|
866
|
-
it('json/object values serialized as json strings', async () => {
|
|
867
|
-
await db.exec(`CREATE TABLE public.jtest (id TEXT PRIMARY KEY, meta JSONB)`)
|
|
868
|
-
await installChangeTracking(db)
|
|
869
|
-
|
|
870
|
-
const s = await stream()
|
|
871
|
-
const q = s.messages
|
|
872
|
-
await db.exec(
|
|
873
|
-
`INSERT INTO public.jtest (id, meta) VALUES ('j1', '{"foo":"bar","n":42}')`
|
|
874
|
-
)
|
|
875
|
-
|
|
876
|
-
let ins: ZcInsert | null = null
|
|
877
|
-
while (!ins) {
|
|
878
|
-
const m = await nextData(q)
|
|
879
|
-
if (m.tag === 'insert' && (m as ZcInsert).relation.name === 'jtest')
|
|
880
|
-
ins = m as ZcInsert
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
const meta = ins.new.meta!
|
|
884
|
-
expect(JSON.parse(meta)).toEqual({ foo: 'bar', n: 42 })
|
|
885
|
-
|
|
886
|
-
s.close()
|
|
887
|
-
})
|
|
888
|
-
|
|
889
|
-
it('insert references cached relation by oid', async () => {
|
|
890
|
-
const s = await stream()
|
|
891
|
-
const q = s.messages
|
|
892
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('ref_test')`)
|
|
893
|
-
|
|
894
|
-
let rel: ZcRelation | null = null
|
|
895
|
-
let ins: ZcInsert | null = null
|
|
896
|
-
while (!ins) {
|
|
897
|
-
const m = await nextData(q)
|
|
898
|
-
if (m.tag === 'relation' && (m as ZcRelation).name === 'foo') rel = m as ZcRelation
|
|
899
|
-
if (m.tag === 'insert') ins = m as ZcInsert
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
expect(rel).not.toBeNull()
|
|
903
|
-
// insert.relation should be the same object reference (from cache)
|
|
904
|
-
expect(ins!.relation).toBe(rel)
|
|
905
|
-
expect(ins!.relation.relationOid).toBe(rel!.relationOid)
|
|
906
|
-
|
|
907
|
-
s.close()
|
|
908
|
-
})
|
|
909
|
-
|
|
910
|
-
it('empty string distinct from null', async () => {
|
|
911
|
-
const s = await stream()
|
|
912
|
-
const q = s.messages
|
|
913
|
-
await db.exec(`INSERT INTO public.foo (id, text_val) VALUES ('empty', '')`)
|
|
914
|
-
|
|
915
|
-
let ins: ZcInsert | null = null
|
|
916
|
-
while (!ins) {
|
|
917
|
-
const m = await nextData(q)
|
|
918
|
-
if (m.tag === 'insert') ins = m as ZcInsert
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
expect(ins.new.text_val).toBe('')
|
|
922
|
-
expect(ins.new.int_val).toBeNull()
|
|
923
|
-
|
|
924
|
-
s.close()
|
|
925
|
-
})
|
|
926
|
-
|
|
927
|
-
it('keepalives sent during idle periods', async () => {
|
|
928
|
-
const s = await stream()
|
|
929
|
-
const q = s.messages
|
|
930
|
-
|
|
931
|
-
// collect messages for ~600ms without doing anything
|
|
932
|
-
const msgs: ZcMessage[] = []
|
|
933
|
-
const deadline = Date.now() + 600
|
|
934
|
-
while (Date.now() < deadline) {
|
|
935
|
-
const m = await q.dequeue(400).catch(() => null)
|
|
936
|
-
if (m) msgs.push(m)
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const keepalives = msgs.filter((m) => m.tag === 'keepalive')
|
|
940
|
-
expect(keepalives.length).toBeGreaterThan(0)
|
|
941
|
-
|
|
942
|
-
s.close()
|
|
943
|
-
})
|
|
944
|
-
|
|
945
|
-
it('rapid sequential inserts all captured', async () => {
|
|
946
|
-
const s = await stream()
|
|
947
|
-
const q = s.messages
|
|
948
|
-
|
|
949
|
-
const count = 20
|
|
950
|
-
for (let i = 0; i < count; i++) {
|
|
951
|
-
await db.exec(`INSERT INTO public.foo (id, int_val) VALUES ('r${i}', ${i})`)
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
const inserts: ZcInsert[] = []
|
|
955
|
-
const deadline = Date.now() + 8000
|
|
956
|
-
while (inserts.length < count && Date.now() < deadline) {
|
|
957
|
-
const m = await nextData(q)
|
|
958
|
-
if (m.tag === 'insert') inserts.push(m as ZcInsert)
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
expect(inserts).toHaveLength(count)
|
|
962
|
-
|
|
963
|
-
s.close()
|
|
964
|
-
})
|
|
965
|
-
|
|
966
|
-
it('streams shard mutation-confirmation tables but filters replicas', async () => {
|
|
967
|
-
// zero-cache creates shard schemas (chat_0) with clients, replicas, mutations.
|
|
968
|
-
// clients advance lmid and mutations carry server results; replicas is
|
|
969
|
-
// internal shard state that zero-cache does not expect in the change stream.
|
|
970
|
-
await db.exec(`
|
|
971
|
-
CREATE SCHEMA chat_0;
|
|
972
|
-
CREATE TABLE chat_0.clients (
|
|
973
|
-
"clientGroupID" TEXT NOT NULL,
|
|
974
|
-
"clientID" TEXT NOT NULL,
|
|
975
|
-
"lastMutationID" BIGINT,
|
|
976
|
-
PRIMARY KEY ("clientGroupID", "clientID")
|
|
977
|
-
);
|
|
978
|
-
CREATE TABLE chat_0.replicas (
|
|
979
|
-
id TEXT PRIMARY KEY,
|
|
980
|
-
version TEXT
|
|
981
|
-
);
|
|
982
|
-
CREATE TABLE chat_0.mutations (
|
|
983
|
-
"clientGroupID" TEXT NOT NULL,
|
|
984
|
-
"clientID" TEXT NOT NULL,
|
|
985
|
-
"mutationID" BIGINT NOT NULL,
|
|
986
|
-
result JSON,
|
|
987
|
-
PRIMARY KEY ("clientGroupID", "clientID", "mutationID")
|
|
988
|
-
);
|
|
989
|
-
`)
|
|
990
|
-
await installChangeTracking(db)
|
|
991
|
-
|
|
992
|
-
const s = await stream()
|
|
993
|
-
const q = s.messages
|
|
994
|
-
|
|
995
|
-
// give handler time to finish setup (trigger installation)
|
|
996
|
-
await new Promise((r) => setTimeout(r, 300))
|
|
997
|
-
|
|
998
|
-
// insert into all three shard tables + a public table
|
|
999
|
-
await db.exec(
|
|
1000
|
-
`INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 1)`
|
|
1001
|
-
)
|
|
1002
|
-
await db.exec(`INSERT INTO chat_0.replicas (id, version) VALUES ('r1', 'v1')`)
|
|
1003
|
-
await db.exec(
|
|
1004
|
-
`INSERT INTO chat_0.mutations ("clientGroupID", "clientID", "mutationID", result) VALUES ('cg1', 'c1', 1, '{}')`
|
|
1005
|
-
)
|
|
1006
|
-
await db.exec(`INSERT INTO public.foo (id) VALUES ('normal')`)
|
|
1007
|
-
|
|
1008
|
-
// collect all inserts for a few seconds
|
|
1009
|
-
const inserts: ZcInsert[] = []
|
|
1010
|
-
const deadline = Date.now() + 4000
|
|
1011
|
-
while (Date.now() < deadline) {
|
|
1012
|
-
const m = await q.dequeue(1500).catch(() => null)
|
|
1013
|
-
if (!m) break
|
|
1014
|
-
if (m.tag === 'insert') inserts.push(m as ZcInsert)
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
// should see clients + mutations + foo inserts, but NOT replicas.
|
|
1018
|
-
const streamedTables = inserts.map((i) => `${i.relation.schema}.${i.relation.name}`)
|
|
1019
|
-
expect(streamedTables).toContain('public.foo')
|
|
1020
|
-
expect(streamedTables).toContain('chat_0.clients')
|
|
1021
|
-
expect(streamedTables).toContain('chat_0.mutations')
|
|
1022
|
-
expect(streamedTables).not.toContain('chat_0.replicas')
|
|
1023
|
-
|
|
1024
|
-
s.close()
|
|
1025
|
-
})
|
|
1026
|
-
|
|
1027
|
-
it('refreshes metadata for shard tables missing from cached setup', async () => {
|
|
1028
|
-
// zero-cache can create shard schemas after the first replication stream
|
|
1029
|
-
// cached metadata. the next stream must not encode bigint/json shard
|
|
1030
|
-
// columns as text, or zero-cache cannot parse mutation confirmations.
|
|
1031
|
-
const warmup = await stream()
|
|
1032
|
-
warmup.close()
|
|
1033
|
-
await new Promise((r) => setTimeout(r, 50))
|
|
1034
|
-
|
|
1035
|
-
await db.exec(`
|
|
1036
|
-
CREATE SCHEMA chat_0;
|
|
1037
|
-
CREATE TABLE chat_0.clients (
|
|
1038
|
-
"clientGroupID" TEXT NOT NULL,
|
|
1039
|
-
"clientID" TEXT NOT NULL,
|
|
1040
|
-
"lastMutationID" BIGINT,
|
|
1041
|
-
PRIMARY KEY ("clientGroupID", "clientID")
|
|
1042
|
-
);
|
|
1043
|
-
CREATE TABLE chat_0.mutations (
|
|
1044
|
-
"clientGroupID" TEXT NOT NULL,
|
|
1045
|
-
"clientID" TEXT NOT NULL,
|
|
1046
|
-
"mutationID" BIGINT NOT NULL,
|
|
1047
|
-
result JSON,
|
|
1048
|
-
PRIMARY KEY ("clientGroupID", "clientID", "mutationID")
|
|
1049
|
-
);
|
|
1050
|
-
`)
|
|
1051
|
-
await installTriggersOnShardTables(db)
|
|
1052
|
-
|
|
1053
|
-
const s = await stream()
|
|
1054
|
-
const q = s.messages
|
|
1055
|
-
|
|
1056
|
-
await db.exec(
|
|
1057
|
-
`INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 42)`
|
|
1058
|
-
)
|
|
1059
|
-
await db.exec(
|
|
1060
|
-
`INSERT INTO chat_0.mutations ("clientGroupID", "clientID", "mutationID", result) VALUES ('cg1', 'c1', 42, '{"data":"ok"}')`
|
|
1061
|
-
)
|
|
1062
|
-
|
|
1063
|
-
let clientsInsert: ZcInsert | null = null
|
|
1064
|
-
let mutationsInsert: ZcInsert | null = null
|
|
1065
|
-
const deadline = Date.now() + 5000
|
|
1066
|
-
while (Date.now() < deadline) {
|
|
1067
|
-
const m = await q.dequeue(2000).catch(() => null)
|
|
1068
|
-
if (!m) break
|
|
1069
|
-
if (m.tag === 'insert') {
|
|
1070
|
-
const ins = m as ZcInsert
|
|
1071
|
-
if (ins.relation.schema === 'chat_0' && ins.relation.name === 'clients') {
|
|
1072
|
-
clientsInsert = ins
|
|
1073
|
-
}
|
|
1074
|
-
if (ins.relation.schema === 'chat_0' && ins.relation.name === 'mutations') {
|
|
1075
|
-
mutationsInsert = ins
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
if (clientsInsert && mutationsInsert) break
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
expect(clientsInsert).not.toBeNull()
|
|
1082
|
-
expect(mutationsInsert).not.toBeNull()
|
|
1083
|
-
expect([...clientsInsert!.relation.keyColumns].sort()).toEqual(
|
|
1084
|
-
['clientGroupID', 'clientID'].sort()
|
|
1085
|
-
)
|
|
1086
|
-
expect(
|
|
1087
|
-
clientsInsert!.relation.columns.find((col) => col.name === 'lastMutationID')
|
|
1088
|
-
?.typeOid
|
|
1089
|
-
).toBe(20)
|
|
1090
|
-
expect([...mutationsInsert!.relation.keyColumns].sort()).toEqual(
|
|
1091
|
-
['clientGroupID', 'clientID', 'mutationID'].sort()
|
|
1092
|
-
)
|
|
1093
|
-
expect(
|
|
1094
|
-
mutationsInsert!.relation.columns.find((col) => col.name === 'mutationID')?.typeOid
|
|
1095
|
-
).toBe(20)
|
|
1096
|
-
expect(
|
|
1097
|
-
mutationsInsert!.relation.columns.find((col) => col.name === 'result')?.typeOid
|
|
1098
|
-
).toBe(114)
|
|
1099
|
-
|
|
1100
|
-
s.close()
|
|
1101
|
-
})
|
|
1102
|
-
|
|
1103
|
-
it('shard clients table created AFTER replication starts still gets tracked', async () => {
|
|
1104
|
-
// zero-cache creates shard schemas after the replication connection is live.
|
|
1105
|
-
// the poll loop must detect new shard tables and install triggers dynamically.
|
|
1106
|
-
const s = await stream()
|
|
1107
|
-
const q = s.messages
|
|
1108
|
-
|
|
1109
|
-
// give replication time to start polling
|
|
1110
|
-
await new Promise((r) => setTimeout(r, 300))
|
|
1111
|
-
|
|
1112
|
-
// now create shard schema (simulating zero-cache's DDL during initial sync)
|
|
1113
|
-
await db.exec(`
|
|
1114
|
-
CREATE SCHEMA chat_0;
|
|
1115
|
-
CREATE TABLE chat_0.clients (
|
|
1116
|
-
"clientGroupID" TEXT NOT NULL,
|
|
1117
|
-
"clientID" TEXT NOT NULL,
|
|
1118
|
-
"lastMutationID" BIGINT,
|
|
1119
|
-
PRIMARY KEY ("clientGroupID", "clientID")
|
|
1120
|
-
);
|
|
1121
|
-
`)
|
|
1122
|
-
|
|
1123
|
-
// wait for poll loop to detect new table (rescan every ~10s)
|
|
1124
|
-
await new Promise((r) => setTimeout(r, 12000))
|
|
1125
|
-
|
|
1126
|
-
// insert data that should be captured
|
|
1127
|
-
await db.exec(
|
|
1128
|
-
`INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 42)`
|
|
1129
|
-
)
|
|
1130
|
-
|
|
1131
|
-
// the insert should appear in the replication stream
|
|
1132
|
-
let found = false
|
|
1133
|
-
const deadline = Date.now() + 5000
|
|
1134
|
-
while (Date.now() < deadline) {
|
|
1135
|
-
const m = await q.dequeue(2000).catch(() => null)
|
|
1136
|
-
if (!m) break
|
|
1137
|
-
if (m.tag === 'insert') {
|
|
1138
|
-
const ins = m as ZcInsert
|
|
1139
|
-
if (ins.relation.schema === 'chat_0' && ins.relation.name === 'clients') {
|
|
1140
|
-
found = true
|
|
1141
|
-
break
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
expect(found).toBe(true)
|
|
1147
|
-
|
|
1148
|
-
s.close()
|
|
1149
|
-
})
|
|
1150
|
-
})
|