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,876 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tcp-level integration test for the full replication stack.
|
|
3
|
-
*
|
|
4
|
-
* connects to pg-proxy over tcp, speaks the pg wire protocol,
|
|
5
|
-
* runs the replication handshake, and verifies streamed changes
|
|
6
|
-
* match what a real pg consumer expects.
|
|
7
|
-
*
|
|
8
|
-
* this catches integration bugs (socket handling, framing, auth,
|
|
9
|
-
* query routing) that unit tests on individual components miss.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { createConnection, type Socket } from 'node:net'
|
|
13
|
-
|
|
14
|
-
import { PGlite } from '@electric-sql/pglite'
|
|
15
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
16
|
-
|
|
17
|
-
import { getConfig } from '../config'
|
|
18
|
-
import { startPgProxy } from '../pg-proxy'
|
|
19
|
-
import { installChangeTracking } from './change-tracker'
|
|
20
|
-
import { signalReplicationChange, resetReplicationState } from './handler'
|
|
21
|
-
|
|
22
|
-
import type { Server, AddressInfo } from 'node:net'
|
|
23
|
-
|
|
24
|
-
// --- pgoutput decoder (validates against pg protocol spec) ---
|
|
25
|
-
|
|
26
|
-
interface DecodedMessage {
|
|
27
|
-
type: string
|
|
28
|
-
raw: Uint8Array
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface BeginMessage extends DecodedMessage {
|
|
32
|
-
type: 'Begin'
|
|
33
|
-
lsn: bigint
|
|
34
|
-
timestamp: bigint
|
|
35
|
-
xid: number
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface CommitMessage extends DecodedMessage {
|
|
39
|
-
type: 'Commit'
|
|
40
|
-
flags: number
|
|
41
|
-
lsn: bigint
|
|
42
|
-
endLsn: bigint
|
|
43
|
-
timestamp: bigint
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface RelationColumn {
|
|
47
|
-
flags: number
|
|
48
|
-
name: string
|
|
49
|
-
typeOid: number
|
|
50
|
-
typeMod: number
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface RelationMessage extends DecodedMessage {
|
|
54
|
-
type: 'Relation'
|
|
55
|
-
tableOid: number
|
|
56
|
-
schema: string
|
|
57
|
-
tableName: string
|
|
58
|
-
replicaIdentity: number
|
|
59
|
-
columns: RelationColumn[]
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface InsertMessage extends DecodedMessage {
|
|
63
|
-
type: 'Insert'
|
|
64
|
-
tableOid: number
|
|
65
|
-
tupleData: TupleData
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface UpdateMessage extends DecodedMessage {
|
|
69
|
-
type: 'Update'
|
|
70
|
-
tableOid: number
|
|
71
|
-
oldTupleData?: TupleData
|
|
72
|
-
newTupleData: TupleData
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
interface DeleteMessage extends DecodedMessage {
|
|
76
|
-
type: 'Delete'
|
|
77
|
-
tableOid: number
|
|
78
|
-
keyTupleData: TupleData
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface TupleData {
|
|
82
|
-
columns: Array<{ type: 'null' | 'text'; value: string | null }>
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
interface KeepaliveMessage extends DecodedMessage {
|
|
86
|
-
type: 'Keepalive'
|
|
87
|
-
walEnd: bigint
|
|
88
|
-
timestamp: bigint
|
|
89
|
-
replyRequested: boolean
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
type PgOutputMessage =
|
|
93
|
-
| BeginMessage
|
|
94
|
-
| CommitMessage
|
|
95
|
-
| RelationMessage
|
|
96
|
-
| InsertMessage
|
|
97
|
-
| UpdateMessage
|
|
98
|
-
| DeleteMessage
|
|
99
|
-
| KeepaliveMessage
|
|
100
|
-
|
|
101
|
-
function r16(buf: Uint8Array, off: number) {
|
|
102
|
-
return new DataView(buf.buffer, buf.byteOffset).getInt16(off)
|
|
103
|
-
}
|
|
104
|
-
function r32(buf: Uint8Array, off: number) {
|
|
105
|
-
return new DataView(buf.buffer, buf.byteOffset).getInt32(off)
|
|
106
|
-
}
|
|
107
|
-
function r64(buf: Uint8Array, off: number) {
|
|
108
|
-
return new DataView(buf.buffer, buf.byteOffset).getBigInt64(off)
|
|
109
|
-
}
|
|
110
|
-
function rCStr(buf: Uint8Array, off: number): [string, number] {
|
|
111
|
-
let end = off
|
|
112
|
-
while (end < buf.length && buf[end] !== 0) end++
|
|
113
|
-
return [new TextDecoder().decode(buf.subarray(off, end)), end + 1]
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function decodeTupleData(buf: Uint8Array, off: number): [TupleData, number] {
|
|
117
|
-
const numCols = r16(buf, off)
|
|
118
|
-
off += 2
|
|
119
|
-
const columns: TupleData['columns'] = []
|
|
120
|
-
for (let i = 0; i < numCols; i++) {
|
|
121
|
-
const colType = buf[off++]
|
|
122
|
-
if (colType === 0x6e) {
|
|
123
|
-
// 'n' null
|
|
124
|
-
columns.push({ type: 'null', value: null })
|
|
125
|
-
} else if (colType === 0x74) {
|
|
126
|
-
// 't' text
|
|
127
|
-
const len = r32(buf, off)
|
|
128
|
-
off += 4
|
|
129
|
-
const value = new TextDecoder().decode(buf.subarray(off, off + len))
|
|
130
|
-
off += len
|
|
131
|
-
columns.push({ type: 'text', value })
|
|
132
|
-
} else {
|
|
133
|
-
throw new Error(`unknown tuple column type: 0x${colType.toString(16)}`)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return [{ columns }, off]
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function decodePgOutput(data: Uint8Array): PgOutputMessage {
|
|
140
|
-
const msgType = data[0]
|
|
141
|
-
|
|
142
|
-
switch (msgType) {
|
|
143
|
-
case 0x42: {
|
|
144
|
-
// Begin
|
|
145
|
-
return {
|
|
146
|
-
type: 'Begin',
|
|
147
|
-
raw: data,
|
|
148
|
-
lsn: r64(data, 1),
|
|
149
|
-
timestamp: r64(data, 9),
|
|
150
|
-
xid: r32(data, 17),
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
case 0x43: {
|
|
154
|
-
// Commit
|
|
155
|
-
return {
|
|
156
|
-
type: 'Commit',
|
|
157
|
-
raw: data,
|
|
158
|
-
flags: data[1],
|
|
159
|
-
lsn: r64(data, 2),
|
|
160
|
-
endLsn: r64(data, 10),
|
|
161
|
-
timestamp: r64(data, 18),
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
case 0x52: {
|
|
165
|
-
// Relation
|
|
166
|
-
const tableOid = r32(data, 1)
|
|
167
|
-
let pos = 5
|
|
168
|
-
const [schema, p1] = rCStr(data, pos)
|
|
169
|
-
pos = p1
|
|
170
|
-
const [tableName, p2] = rCStr(data, pos)
|
|
171
|
-
pos = p2
|
|
172
|
-
const replicaIdentity = data[pos++]
|
|
173
|
-
const numCols = r16(data, pos)
|
|
174
|
-
pos += 2
|
|
175
|
-
const columns: RelationColumn[] = []
|
|
176
|
-
for (let i = 0; i < numCols; i++) {
|
|
177
|
-
const flags = data[pos++]
|
|
178
|
-
const [name, np] = rCStr(data, pos)
|
|
179
|
-
pos = np
|
|
180
|
-
const typeOid = r32(data, pos)
|
|
181
|
-
pos += 4
|
|
182
|
-
const typeMod = r32(data, pos)
|
|
183
|
-
pos += 4
|
|
184
|
-
columns.push({ flags, name, typeOid, typeMod })
|
|
185
|
-
}
|
|
186
|
-
return {
|
|
187
|
-
type: 'Relation',
|
|
188
|
-
raw: data,
|
|
189
|
-
tableOid,
|
|
190
|
-
schema,
|
|
191
|
-
tableName,
|
|
192
|
-
replicaIdentity,
|
|
193
|
-
columns,
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
case 0x49: {
|
|
197
|
-
// Insert
|
|
198
|
-
const tableOid = r32(data, 1)
|
|
199
|
-
const marker = data[5] // should be 'N'
|
|
200
|
-
if (marker !== 0x4e)
|
|
201
|
-
throw new Error(`insert: expected 'N' marker, got 0x${marker.toString(16)}`)
|
|
202
|
-
const [tupleData] = decodeTupleData(data, 6)
|
|
203
|
-
return { type: 'Insert', raw: data, tableOid, tupleData }
|
|
204
|
-
}
|
|
205
|
-
case 0x55: {
|
|
206
|
-
// Update
|
|
207
|
-
const tableOid = r32(data, 1)
|
|
208
|
-
let pos = 5
|
|
209
|
-
let oldTupleData: TupleData | undefined
|
|
210
|
-
if (data[pos] === 0x4f) {
|
|
211
|
-
// 'O' old tuple
|
|
212
|
-
pos++
|
|
213
|
-
const [old, np] = decodeTupleData(data, pos)
|
|
214
|
-
oldTupleData = old
|
|
215
|
-
pos = np
|
|
216
|
-
}
|
|
217
|
-
if (data[pos] !== 0x4e) throw new Error(`update: expected 'N' marker at ${pos}`)
|
|
218
|
-
pos++
|
|
219
|
-
const [newTupleData] = decodeTupleData(data, pos)
|
|
220
|
-
return { type: 'Update', raw: data, tableOid, oldTupleData, newTupleData }
|
|
221
|
-
}
|
|
222
|
-
case 0x44: {
|
|
223
|
-
// Delete
|
|
224
|
-
const tableOid = r32(data, 1)
|
|
225
|
-
const marker = data[5]
|
|
226
|
-
if (marker !== 0x4b && marker !== 0x4f)
|
|
227
|
-
throw new Error(
|
|
228
|
-
`delete: expected 'K' or 'O' marker, got 0x${marker.toString(16)}`
|
|
229
|
-
)
|
|
230
|
-
const [keyTupleData] = decodeTupleData(data, 6)
|
|
231
|
-
return { type: 'Delete', raw: data, tableOid, keyTupleData }
|
|
232
|
-
}
|
|
233
|
-
default:
|
|
234
|
-
throw new Error(`unknown pgoutput message type: 0x${msgType.toString(16)}`)
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// decode a CopyData frame, returning either an XLogData payload or a Keepalive
|
|
239
|
-
function decodeCopyData(frame: Uint8Array): PgOutputMessage | KeepaliveMessage | null {
|
|
240
|
-
if (frame[0] !== 0x64) return null // not CopyData
|
|
241
|
-
const innerType = frame[5]
|
|
242
|
-
if (innerType === 0x77) {
|
|
243
|
-
// XLogData: walStart(8) + walEnd(8) + timestamp(8) + data
|
|
244
|
-
const payload = frame.subarray(30)
|
|
245
|
-
return decodePgOutput(payload)
|
|
246
|
-
}
|
|
247
|
-
if (innerType === 0x6b) {
|
|
248
|
-
// Keepalive
|
|
249
|
-
return {
|
|
250
|
-
type: 'Keepalive',
|
|
251
|
-
raw: frame,
|
|
252
|
-
walEnd: r64(frame, 6),
|
|
253
|
-
timestamp: r64(frame, 14),
|
|
254
|
-
replyRequested: frame[22] === 1,
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return null
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// --- minimal pg wire protocol client ---
|
|
261
|
-
|
|
262
|
-
function buildStartupMessage(params: Record<string, string>): Buffer {
|
|
263
|
-
const pairs: Buffer[] = []
|
|
264
|
-
for (const [k, v] of Object.entries(params)) {
|
|
265
|
-
pairs.push(Buffer.from(`${k}\0${v}\0`, 'utf8'))
|
|
266
|
-
}
|
|
267
|
-
pairs.push(Buffer.from('\0', 'utf8'))
|
|
268
|
-
|
|
269
|
-
const bodyLen = pairs.reduce((s, b) => s + b.length, 0)
|
|
270
|
-
const buf = Buffer.alloc(4 + 4 + bodyLen)
|
|
271
|
-
buf.writeInt32BE(4 + 4 + bodyLen, 0) // length
|
|
272
|
-
buf.writeInt32BE(196608, 4) // protocol version 3.0
|
|
273
|
-
let pos = 8
|
|
274
|
-
for (const p of pairs) {
|
|
275
|
-
p.copy(buf, pos)
|
|
276
|
-
pos += p.length
|
|
277
|
-
}
|
|
278
|
-
return buf
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function buildPasswordMessage(password: string): Buffer {
|
|
282
|
-
const pwBuf = Buffer.from(password + '\0', 'utf8')
|
|
283
|
-
const buf = Buffer.alloc(1 + 4 + pwBuf.length)
|
|
284
|
-
buf[0] = 0x70 // 'p'
|
|
285
|
-
buf.writeInt32BE(4 + pwBuf.length, 1)
|
|
286
|
-
pwBuf.copy(buf, 5)
|
|
287
|
-
return buf
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function buildQuery(sql: string): Buffer {
|
|
291
|
-
const sqlBuf = Buffer.from(sql + '\0', 'utf8')
|
|
292
|
-
const buf = Buffer.alloc(1 + 4 + sqlBuf.length)
|
|
293
|
-
buf[0] = 0x51 // 'Q'
|
|
294
|
-
buf.writeInt32BE(4 + sqlBuf.length, 1)
|
|
295
|
-
sqlBuf.copy(buf, 5)
|
|
296
|
-
return buf
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
interface PgMessage {
|
|
300
|
-
type: number
|
|
301
|
-
data: Buffer
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// reads exactly one PG message from a buffer, returns [message, remainingBuffer]
|
|
305
|
-
function parseMessage(buf: Buffer): [PgMessage | null, Buffer] {
|
|
306
|
-
if (buf.length < 5) return [null, buf]
|
|
307
|
-
const type = buf[0]
|
|
308
|
-
const len = buf.readInt32BE(1)
|
|
309
|
-
const totalLen = 1 + len
|
|
310
|
-
if (buf.length < totalLen) return [null, buf]
|
|
311
|
-
return [{ type, data: buf.subarray(0, totalLen) }, buf.subarray(totalLen)]
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// higher-level client that connects, authenticates, and can send queries
|
|
315
|
-
class TestPgClient {
|
|
316
|
-
private socket!: Socket
|
|
317
|
-
private buffer = Buffer.alloc(0)
|
|
318
|
-
private waiters: Array<(msg: PgMessage) => void> = []
|
|
319
|
-
private messages: PgMessage[] = []
|
|
320
|
-
port: number
|
|
321
|
-
|
|
322
|
-
constructor(port: number) {
|
|
323
|
-
this.port = port
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async connect(opts: {
|
|
327
|
-
user: string
|
|
328
|
-
password: string
|
|
329
|
-
database: string
|
|
330
|
-
replication?: boolean
|
|
331
|
-
}): Promise<void> {
|
|
332
|
-
this.socket = createConnection({ port: this.port, host: '127.0.0.1' })
|
|
333
|
-
|
|
334
|
-
await new Promise<void>((resolve, reject) => {
|
|
335
|
-
this.socket.once('connect', resolve)
|
|
336
|
-
this.socket.once('error', reject)
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
this.socket.on('data', (chunk: Buffer) => {
|
|
340
|
-
this.buffer = Buffer.concat([this.buffer, chunk])
|
|
341
|
-
this.drain()
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
const params: Record<string, string> = {
|
|
345
|
-
user: opts.user,
|
|
346
|
-
database: opts.database,
|
|
347
|
-
}
|
|
348
|
-
if (opts.replication) {
|
|
349
|
-
params.replication = 'database'
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
this.socket.write(buildStartupMessage(params))
|
|
353
|
-
|
|
354
|
-
// wait for auth request
|
|
355
|
-
const authReq = await this.nextMessage()
|
|
356
|
-
expect(authReq.type).toBe(0x52) // 'R' Authentication
|
|
357
|
-
|
|
358
|
-
const authType = authReq.data.readInt32BE(5)
|
|
359
|
-
if (authType === 3) {
|
|
360
|
-
// cleartext password
|
|
361
|
-
this.socket.write(buildPasswordMessage(opts.password))
|
|
362
|
-
const authOk = await this.nextMessage()
|
|
363
|
-
expect(authOk.type).toBe(0x52)
|
|
364
|
-
expect(authOk.data.readInt32BE(5)).toBe(0) // AuthenticationOk
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// consume parameter status + backend key data + ready for query
|
|
368
|
-
while (true) {
|
|
369
|
-
const msg = await this.nextMessage()
|
|
370
|
-
if (msg.type === 0x5a) break // ReadyForQuery
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// send simple query and collect all response messages until ReadyForQuery
|
|
375
|
-
async query(sql: string): Promise<PgMessage[]> {
|
|
376
|
-
this.socket.write(buildQuery(sql))
|
|
377
|
-
const responses: PgMessage[] = []
|
|
378
|
-
while (true) {
|
|
379
|
-
const msg = await this.nextMessage()
|
|
380
|
-
responses.push(msg)
|
|
381
|
-
if (msg.type === 0x5a) break // ReadyForQuery
|
|
382
|
-
}
|
|
383
|
-
return responses
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// send START_REPLICATION and return CopyBothResponse, then collect stream messages
|
|
387
|
-
async startReplication(query: string): Promise<void> {
|
|
388
|
-
this.socket.write(buildQuery(query))
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// collect streaming messages for a duration
|
|
392
|
-
async collectStream(durationMs: number): Promise<PgMessage[]> {
|
|
393
|
-
const collected: PgMessage[] = []
|
|
394
|
-
const deadline = Date.now() + durationMs
|
|
395
|
-
while (Date.now() < deadline) {
|
|
396
|
-
try {
|
|
397
|
-
const msg = await this.nextMessage(Math.max(50, deadline - Date.now()))
|
|
398
|
-
collected.push(msg)
|
|
399
|
-
} catch {
|
|
400
|
-
// timeout, keep going
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return collected
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// send raw data to inject into connection (e.g. for data connection)
|
|
407
|
-
sendRaw(data: Buffer) {
|
|
408
|
-
this.socket.write(data)
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
close() {
|
|
412
|
-
this.socket?.destroy()
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
private drain() {
|
|
416
|
-
while (true) {
|
|
417
|
-
const [msg, remaining] = parseMessage(this.buffer)
|
|
418
|
-
if (!msg) break
|
|
419
|
-
this.buffer = remaining
|
|
420
|
-
const waiter = this.waiters.shift()
|
|
421
|
-
if (waiter) {
|
|
422
|
-
waiter(msg)
|
|
423
|
-
} else {
|
|
424
|
-
this.messages.push(msg)
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
private nextMessage(timeoutMs = 5000): Promise<PgMessage> {
|
|
430
|
-
const queued = this.messages.shift()
|
|
431
|
-
if (queued) return Promise.resolve(queued)
|
|
432
|
-
|
|
433
|
-
return new Promise<PgMessage>((resolve, reject) => {
|
|
434
|
-
const timer = setTimeout(() => {
|
|
435
|
-
const idx = this.waiters.indexOf(resolve)
|
|
436
|
-
if (idx >= 0) this.waiters.splice(idx, 1)
|
|
437
|
-
reject(new Error(`timeout waiting for message (${timeoutMs}ms)`))
|
|
438
|
-
}, timeoutMs)
|
|
439
|
-
|
|
440
|
-
this.waiters.push((msg) => {
|
|
441
|
-
clearTimeout(timer)
|
|
442
|
-
resolve(msg)
|
|
443
|
-
})
|
|
444
|
-
})
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// --- tests ---
|
|
449
|
-
|
|
450
|
-
describe('tcp replication', () => {
|
|
451
|
-
let db: PGlite
|
|
452
|
-
let server: Server
|
|
453
|
-
let port: number
|
|
454
|
-
|
|
455
|
-
beforeEach(async () => {
|
|
456
|
-
resetReplicationState()
|
|
457
|
-
db = new PGlite()
|
|
458
|
-
await db.waitReady
|
|
459
|
-
|
|
460
|
-
await db.exec(`
|
|
461
|
-
CREATE TABLE public.items (
|
|
462
|
-
id SERIAL PRIMARY KEY,
|
|
463
|
-
name TEXT NOT NULL,
|
|
464
|
-
value INTEGER
|
|
465
|
-
)
|
|
466
|
-
`)
|
|
467
|
-
|
|
468
|
-
// publication for zero-cache
|
|
469
|
-
await db.exec(`CREATE PUBLICATION zero_takeout FOR ALL TABLES`)
|
|
470
|
-
|
|
471
|
-
await installChangeTracking(db)
|
|
472
|
-
|
|
473
|
-
const config = {
|
|
474
|
-
...getConfig(),
|
|
475
|
-
pgPort: 0, // random port
|
|
476
|
-
}
|
|
477
|
-
server = await startPgProxy(db, config)
|
|
478
|
-
port = (server.address() as AddressInfo).port
|
|
479
|
-
})
|
|
480
|
-
|
|
481
|
-
afterEach(async () => {
|
|
482
|
-
server?.close()
|
|
483
|
-
await db?.close()
|
|
484
|
-
})
|
|
485
|
-
|
|
486
|
-
it('accepts connection and authenticates', async () => {
|
|
487
|
-
const client = new TestPgClient(port)
|
|
488
|
-
await client.connect({
|
|
489
|
-
user: 'user',
|
|
490
|
-
password: 'password',
|
|
491
|
-
database: 'postgres',
|
|
492
|
-
})
|
|
493
|
-
client.close()
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
it('rejects wrong password', async () => {
|
|
497
|
-
const client = new TestPgClient(port)
|
|
498
|
-
await expect(
|
|
499
|
-
client.connect({
|
|
500
|
-
user: 'user',
|
|
501
|
-
password: 'wrong',
|
|
502
|
-
database: 'postgres',
|
|
503
|
-
})
|
|
504
|
-
).rejects.toThrow()
|
|
505
|
-
client.close()
|
|
506
|
-
})
|
|
507
|
-
|
|
508
|
-
it('handles IDENTIFY_SYSTEM over tcp', async () => {
|
|
509
|
-
const client = new TestPgClient(port)
|
|
510
|
-
await client.connect({
|
|
511
|
-
user: 'user',
|
|
512
|
-
password: 'password',
|
|
513
|
-
database: 'postgres',
|
|
514
|
-
replication: true,
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
const response = await client.query('IDENTIFY_SYSTEM')
|
|
518
|
-
// should have RowDescription + DataRow + CommandComplete + ReadyForQuery
|
|
519
|
-
const types = response.map((m) => m.type)
|
|
520
|
-
expect(types).toContain(0x54) // RowDescription
|
|
521
|
-
expect(types).toContain(0x44) // DataRow
|
|
522
|
-
expect(types).toContain(0x43) // CommandComplete
|
|
523
|
-
expect(types).toContain(0x5a) // ReadyForQuery
|
|
524
|
-
|
|
525
|
-
client.close()
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
it('handles CREATE_REPLICATION_SLOT over tcp', async () => {
|
|
529
|
-
const client = new TestPgClient(port)
|
|
530
|
-
await client.connect({
|
|
531
|
-
user: 'user',
|
|
532
|
-
password: 'password',
|
|
533
|
-
database: 'postgres',
|
|
534
|
-
replication: true,
|
|
535
|
-
})
|
|
536
|
-
|
|
537
|
-
const response = await client.query(
|
|
538
|
-
'CREATE_REPLICATION_SLOT "tcp_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT'
|
|
539
|
-
)
|
|
540
|
-
const types = response.map((m) => m.type)
|
|
541
|
-
expect(types).toContain(0x54) // RowDescription
|
|
542
|
-
expect(types).toContain(0x44) // DataRow
|
|
543
|
-
|
|
544
|
-
client.close()
|
|
545
|
-
})
|
|
546
|
-
|
|
547
|
-
it('streams replication changes over tcp', async () => {
|
|
548
|
-
const replClient = new TestPgClient(port)
|
|
549
|
-
await replClient.connect({
|
|
550
|
-
user: 'user',
|
|
551
|
-
password: 'password',
|
|
552
|
-
database: 'postgres',
|
|
553
|
-
replication: true,
|
|
554
|
-
})
|
|
555
|
-
|
|
556
|
-
await replClient.query(
|
|
557
|
-
'CREATE_REPLICATION_SLOT "stream_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT'
|
|
558
|
-
)
|
|
559
|
-
|
|
560
|
-
await replClient.startReplication(
|
|
561
|
-
"START_REPLICATION SLOT \"stream_test\" LOGICAL 0/0 (proto_version '1', publication_names 'zero_takeout')"
|
|
562
|
-
)
|
|
563
|
-
|
|
564
|
-
// insert data right away - signal so the handler picks it up immediately
|
|
565
|
-
await db.exec(`INSERT INTO public.items (name, value) VALUES ('tcp_streamed', 42)`)
|
|
566
|
-
signalReplicationChange()
|
|
567
|
-
|
|
568
|
-
// collect everything for long enough to catch the change
|
|
569
|
-
const allRaw = await replClient.collectStream(3000)
|
|
570
|
-
|
|
571
|
-
// decode all CopyData frames
|
|
572
|
-
const decoded: PgOutputMessage[] = []
|
|
573
|
-
for (const msg of allRaw) {
|
|
574
|
-
if (msg.type === 0x64) {
|
|
575
|
-
const result = decodeCopyData(new Uint8Array(msg.data))
|
|
576
|
-
if (result) decoded.push(result)
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// should have keepalives
|
|
581
|
-
const keepalives = decoded.filter((m) => m.type === 'Keepalive')
|
|
582
|
-
expect(keepalives.length).toBeGreaterThan(0)
|
|
583
|
-
|
|
584
|
-
// should have BEGIN, RELATION, INSERT, COMMIT
|
|
585
|
-
const msgTypes = decoded.map((m) => m.type)
|
|
586
|
-
expect(msgTypes).toContain('Begin')
|
|
587
|
-
expect(msgTypes).toContain('Relation')
|
|
588
|
-
expect(msgTypes).toContain('Insert')
|
|
589
|
-
expect(msgTypes).toContain('Commit')
|
|
590
|
-
|
|
591
|
-
// validate the RELATION message
|
|
592
|
-
const relation = decoded.find((m) => m.type === 'Relation') as RelationMessage
|
|
593
|
-
expect(relation.schema).toBe('public')
|
|
594
|
-
expect(relation.tableName).toBe('items')
|
|
595
|
-
expect(relation.columns.length).toBeGreaterThanOrEqual(3) // id, name, value
|
|
596
|
-
|
|
597
|
-
// validate the INSERT message
|
|
598
|
-
const insert = decoded.find((m) => m.type === 'Insert') as InsertMessage
|
|
599
|
-
expect(insert.tableOid).toBe(relation.tableOid) // same table
|
|
600
|
-
const values = insert.tupleData.columns.map((c) => c.value)
|
|
601
|
-
expect(values).toContain('tcp_streamed')
|
|
602
|
-
expect(values).toContain('42')
|
|
603
|
-
|
|
604
|
-
// validate transaction structure: BEGIN before INSERT before COMMIT
|
|
605
|
-
const beginIdx = decoded.findIndex((m) => m.type === 'Begin')
|
|
606
|
-
const insertIdx = decoded.findIndex((m) => m.type === 'Insert')
|
|
607
|
-
const commitIdx = decoded.findIndex((m) => m.type === 'Commit')
|
|
608
|
-
expect(beginIdx).toBeLessThan(insertIdx)
|
|
609
|
-
expect(insertIdx).toBeLessThan(commitIdx)
|
|
610
|
-
|
|
611
|
-
replClient.close()
|
|
612
|
-
}, 15_000)
|
|
613
|
-
|
|
614
|
-
it('streams updates with old + new tuple data', async () => {
|
|
615
|
-
const replClient = new TestPgClient(port)
|
|
616
|
-
await replClient.connect({
|
|
617
|
-
user: 'user',
|
|
618
|
-
password: 'password',
|
|
619
|
-
database: 'postgres',
|
|
620
|
-
replication: true,
|
|
621
|
-
})
|
|
622
|
-
await replClient.query(
|
|
623
|
-
'CREATE_REPLICATION_SLOT "upd_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT'
|
|
624
|
-
)
|
|
625
|
-
await replClient.startReplication(
|
|
626
|
-
"START_REPLICATION SLOT \"upd_test\" LOGICAL 0/0 (proto_version '1', publication_names 'zero_takeout')"
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
await replClient.collectStream(200) // skip CopyBothResponse
|
|
630
|
-
|
|
631
|
-
await db.exec(`INSERT INTO public.items (name, value) VALUES ('upd_target', 10)`)
|
|
632
|
-
signalReplicationChange()
|
|
633
|
-
await replClient.collectStream(1500)
|
|
634
|
-
|
|
635
|
-
await db.exec(`UPDATE public.items SET value = 20 WHERE name = 'upd_target'`)
|
|
636
|
-
signalReplicationChange()
|
|
637
|
-
const stream = await replClient.collectStream(1500)
|
|
638
|
-
|
|
639
|
-
const decoded: PgOutputMessage[] = []
|
|
640
|
-
for (const msg of stream) {
|
|
641
|
-
if (msg.type === 0x64) {
|
|
642
|
-
const result = decodeCopyData(new Uint8Array(msg.data))
|
|
643
|
-
if (result) decoded.push(result)
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const update = decoded.find((m) => m.type === 'Update') as UpdateMessage
|
|
648
|
-
expect(update).toBeDefined()
|
|
649
|
-
// new data should have value=20
|
|
650
|
-
const newValues = update.newTupleData.columns.map((c) => c.value)
|
|
651
|
-
expect(newValues).toContain('20')
|
|
652
|
-
// old data should have value=10
|
|
653
|
-
expect(update.oldTupleData).toBeDefined()
|
|
654
|
-
const oldValues = update.oldTupleData!.columns.map((c) => c.value)
|
|
655
|
-
expect(oldValues).toContain('10')
|
|
656
|
-
|
|
657
|
-
replClient.close()
|
|
658
|
-
}, 15_000)
|
|
659
|
-
|
|
660
|
-
it('streams deletes with key data', async () => {
|
|
661
|
-
const replClient = new TestPgClient(port)
|
|
662
|
-
await replClient.connect({
|
|
663
|
-
user: 'user',
|
|
664
|
-
password: 'password',
|
|
665
|
-
database: 'postgres',
|
|
666
|
-
replication: true,
|
|
667
|
-
})
|
|
668
|
-
await replClient.query(
|
|
669
|
-
'CREATE_REPLICATION_SLOT "del_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT'
|
|
670
|
-
)
|
|
671
|
-
await replClient.startReplication(
|
|
672
|
-
"START_REPLICATION SLOT \"del_test\" LOGICAL 0/0 (proto_version '1', publication_names 'zero_takeout')"
|
|
673
|
-
)
|
|
674
|
-
|
|
675
|
-
await replClient.collectStream(200)
|
|
676
|
-
|
|
677
|
-
await db.exec(`INSERT INTO public.items (name, value) VALUES ('del_target', 99)`)
|
|
678
|
-
signalReplicationChange()
|
|
679
|
-
await replClient.collectStream(2000)
|
|
680
|
-
|
|
681
|
-
await db.exec(`DELETE FROM public.items WHERE name = 'del_target'`)
|
|
682
|
-
signalReplicationChange()
|
|
683
|
-
|
|
684
|
-
const allDecoded: PgOutputMessage[] = []
|
|
685
|
-
const deadline = Date.now() + 5000
|
|
686
|
-
while (Date.now() < deadline) {
|
|
687
|
-
const stream = await replClient.collectStream(500)
|
|
688
|
-
for (const msg of stream) {
|
|
689
|
-
if (msg.type === 0x64) {
|
|
690
|
-
const result = decodeCopyData(new Uint8Array(msg.data))
|
|
691
|
-
if (result) allDecoded.push(result)
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
if (allDecoded.some((m) => m.type === 'Delete')) break
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const del = allDecoded.find((m) => m.type === 'Delete') as DeleteMessage
|
|
698
|
-
expect(del).toBeDefined()
|
|
699
|
-
const keyValues = del.keyTupleData.columns.map((c) => c.value)
|
|
700
|
-
expect(keyValues).toContain('del_target')
|
|
701
|
-
|
|
702
|
-
replClient.close()
|
|
703
|
-
}, 15_000)
|
|
704
|
-
|
|
705
|
-
it('handles multiple tables in same stream', async () => {
|
|
706
|
-
// create a second table and re-install tracking for both
|
|
707
|
-
await db.exec(`CREATE TABLE public.products (id SERIAL PRIMARY KEY, label TEXT)`)
|
|
708
|
-
await installChangeTracking(db)
|
|
709
|
-
|
|
710
|
-
const replClient = new TestPgClient(port)
|
|
711
|
-
await replClient.connect({
|
|
712
|
-
user: 'user',
|
|
713
|
-
password: 'password',
|
|
714
|
-
database: 'postgres',
|
|
715
|
-
replication: true,
|
|
716
|
-
})
|
|
717
|
-
await replClient.query(
|
|
718
|
-
'CREATE_REPLICATION_SLOT "multi_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT'
|
|
719
|
-
)
|
|
720
|
-
await replClient.startReplication(
|
|
721
|
-
"START_REPLICATION SLOT \"multi_test\" LOGICAL 0/0 (proto_version '1', publication_names 'zero_takeout')"
|
|
722
|
-
)
|
|
723
|
-
|
|
724
|
-
await replClient.collectStream(200)
|
|
725
|
-
|
|
726
|
-
// insert into both tables
|
|
727
|
-
await db.exec(`INSERT INTO public.items (name, value) VALUES ('multi1', 1)`)
|
|
728
|
-
await db.exec(`INSERT INTO public.products (label) VALUES ('multi2')`)
|
|
729
|
-
signalReplicationChange()
|
|
730
|
-
|
|
731
|
-
// collect until we see both relations (with timeout)
|
|
732
|
-
const allDecoded: PgOutputMessage[] = []
|
|
733
|
-
const deadline = Date.now() + 10000
|
|
734
|
-
while (Date.now() < deadline) {
|
|
735
|
-
const stream = await replClient.collectStream(500)
|
|
736
|
-
for (const msg of stream) {
|
|
737
|
-
if (msg.type === 0x64) {
|
|
738
|
-
const result = decodeCopyData(new Uint8Array(msg.data))
|
|
739
|
-
if (result) allDecoded.push(result)
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
const relations = allDecoded.filter(
|
|
743
|
-
(m) => m.type === 'Relation'
|
|
744
|
-
) as RelationMessage[]
|
|
745
|
-
const tableNames = relations.map((r) => r.tableName)
|
|
746
|
-
if (tableNames.includes('items') && tableNames.includes('products')) break
|
|
747
|
-
signalReplicationChange()
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const relations = allDecoded.filter((m) => m.type === 'Relation') as RelationMessage[]
|
|
751
|
-
const tableNames = relations.map((r) => r.tableName)
|
|
752
|
-
expect(tableNames).toContain('items')
|
|
753
|
-
expect(tableNames).toContain('products')
|
|
754
|
-
|
|
755
|
-
replClient.close()
|
|
756
|
-
}, 15_000)
|
|
757
|
-
|
|
758
|
-
it('handles rapid inserts over tcp', async () => {
|
|
759
|
-
const replClient = new TestPgClient(port)
|
|
760
|
-
await replClient.connect({
|
|
761
|
-
user: 'user',
|
|
762
|
-
password: 'password',
|
|
763
|
-
database: 'postgres',
|
|
764
|
-
replication: true,
|
|
765
|
-
})
|
|
766
|
-
await replClient.query(
|
|
767
|
-
'CREATE_REPLICATION_SLOT "rapid_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT'
|
|
768
|
-
)
|
|
769
|
-
await replClient.startReplication(
|
|
770
|
-
"START_REPLICATION SLOT \"rapid_test\" LOGICAL 0/0 (proto_version '1', publication_names 'zero_takeout')"
|
|
771
|
-
)
|
|
772
|
-
|
|
773
|
-
await replClient.collectStream(200)
|
|
774
|
-
|
|
775
|
-
const count = 15
|
|
776
|
-
for (let i = 0; i < count; i++) {
|
|
777
|
-
await db.exec(`INSERT INTO public.items (name, value) VALUES ('rapid${i}', ${i})`)
|
|
778
|
-
}
|
|
779
|
-
signalReplicationChange()
|
|
780
|
-
|
|
781
|
-
// poll until all inserts arrive
|
|
782
|
-
const allDecoded: PgOutputMessage[] = []
|
|
783
|
-
const deadline = Date.now() + 5000
|
|
784
|
-
while (Date.now() < deadline) {
|
|
785
|
-
const stream = await replClient.collectStream(500)
|
|
786
|
-
for (const msg of stream) {
|
|
787
|
-
if (msg.type === 0x64) {
|
|
788
|
-
const result = decodeCopyData(new Uint8Array(msg.data))
|
|
789
|
-
if (result) allDecoded.push(result)
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
const inserts = allDecoded.filter((m) => m.type === 'Insert')
|
|
793
|
-
if (inserts.length >= count) break
|
|
794
|
-
signalReplicationChange()
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
const inserts = allDecoded.filter((m) => m.type === 'Insert')
|
|
798
|
-
expect(inserts.length).toBe(count)
|
|
799
|
-
|
|
800
|
-
replClient.close()
|
|
801
|
-
}, 15_000)
|
|
802
|
-
|
|
803
|
-
it('regular (non-replication) queries work over tcp', async () => {
|
|
804
|
-
const client = new TestPgClient(port)
|
|
805
|
-
await client.connect({
|
|
806
|
-
user: 'user',
|
|
807
|
-
password: 'password',
|
|
808
|
-
database: 'postgres',
|
|
809
|
-
})
|
|
810
|
-
|
|
811
|
-
// insert via tcp
|
|
812
|
-
await client.query(`INSERT INTO public.items (name, value) VALUES ('tcp_direct', 77)`)
|
|
813
|
-
|
|
814
|
-
// select back
|
|
815
|
-
const response = await client.query(
|
|
816
|
-
`SELECT name, value FROM public.items WHERE name = 'tcp_direct'`
|
|
817
|
-
)
|
|
818
|
-
const dataRow = response.find((m) => m.type === 0x44) // DataRow
|
|
819
|
-
expect(dataRow).toBeDefined()
|
|
820
|
-
|
|
821
|
-
client.close()
|
|
822
|
-
})
|
|
823
|
-
|
|
824
|
-
it('concurrent replication + regular connections', async () => {
|
|
825
|
-
// start replication client
|
|
826
|
-
const replClient = new TestPgClient(port)
|
|
827
|
-
await replClient.connect({
|
|
828
|
-
user: 'user',
|
|
829
|
-
password: 'password',
|
|
830
|
-
database: 'postgres',
|
|
831
|
-
replication: true,
|
|
832
|
-
})
|
|
833
|
-
await replClient.query(
|
|
834
|
-
'CREATE_REPLICATION_SLOT "concurrent_test" TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT'
|
|
835
|
-
)
|
|
836
|
-
await replClient.startReplication(
|
|
837
|
-
"START_REPLICATION SLOT \"concurrent_test\" LOGICAL 0/0 (proto_version '1', publication_names 'zero_takeout')"
|
|
838
|
-
)
|
|
839
|
-
await replClient.collectStream(200)
|
|
840
|
-
|
|
841
|
-
// regular client inserts data
|
|
842
|
-
const dataClient = new TestPgClient(port)
|
|
843
|
-
await dataClient.connect({
|
|
844
|
-
user: 'user',
|
|
845
|
-
password: 'password',
|
|
846
|
-
database: 'postgres',
|
|
847
|
-
})
|
|
848
|
-
await dataClient.query(
|
|
849
|
-
`INSERT INTO public.items (name, value) VALUES ('concurrent', 123)`
|
|
850
|
-
)
|
|
851
|
-
signalReplicationChange()
|
|
852
|
-
|
|
853
|
-
// poll until the replication stream picks up the change
|
|
854
|
-
const allDecoded: PgOutputMessage[] = []
|
|
855
|
-
const deadline = Date.now() + 5000
|
|
856
|
-
while (Date.now() < deadline) {
|
|
857
|
-
const stream = await replClient.collectStream(500)
|
|
858
|
-
for (const msg of stream) {
|
|
859
|
-
if (msg.type === 0x64) {
|
|
860
|
-
const result = decodeCopyData(new Uint8Array(msg.data))
|
|
861
|
-
if (result) allDecoded.push(result)
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
const inserts = allDecoded.filter((m) => m.type === 'Insert')
|
|
865
|
-
if (inserts.length >= 1) break
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const inserts = allDecoded.filter((m) => m.type === 'Insert') as InsertMessage[]
|
|
869
|
-
expect(inserts.length).toBe(1)
|
|
870
|
-
const values = inserts[0].tupleData.columns.map((c) => c.value)
|
|
871
|
-
expect(values).toContain('concurrent')
|
|
872
|
-
|
|
873
|
-
dataClient.close()
|
|
874
|
-
replClient.close()
|
|
875
|
-
}, 15_000)
|
|
876
|
-
})
|