orez 0.2.27 → 0.2.30

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.
Files changed (157) hide show
  1. package/dist/cf-do/worker.d.ts +3 -0
  2. package/dist/cf-do/worker.d.ts.map +1 -1
  3. package/dist/cf-do/worker.js +37 -15
  4. package/dist/cf-do/worker.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +8 -0
  7. package/dist/index.js.map +1 -1
  8. package/package.json +3 -4
  9. package/src/admin/admin-data.test.ts +0 -348
  10. package/src/admin/http-proxy.ts +0 -252
  11. package/src/admin/log-store.ts +0 -192
  12. package/src/admin/server.ts +0 -471
  13. package/src/admin/ui.ts +0 -1322
  14. package/src/bench/proxy-throughput.bench.ts +0 -343
  15. package/src/bench/serial-mutations.bench.ts +0 -270
  16. package/src/browser.ts +0 -203
  17. package/src/cf-do/.wrangler/cache/cf.json +0 -1
  18. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  19. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  20. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  21. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
  22. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  23. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  24. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  25. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
  26. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
  27. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
  28. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
  29. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
  30. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
  31. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
  32. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
  33. package/src/cf-do/ARCHITECTURE.md +0 -93
  34. package/src/cf-do/CHAT_E2E.md +0 -213
  35. package/src/cf-do/watermark.test.ts +0 -103
  36. package/src/cf-do/watermark.ts +0 -118
  37. package/src/cf-do/worker.ts +0 -1041
  38. package/src/cf-do/wrangler.toml +0 -11
  39. package/src/cf-pglite/README.md +0 -19
  40. package/src/change-tracking.ts +0 -25
  41. package/src/child-process.test.ts +0 -147
  42. package/src/child-process.ts +0 -90
  43. package/src/cli-entry.ts +0 -72
  44. package/src/cli.test.ts +0 -40
  45. package/src/cli.ts +0 -1214
  46. package/src/config.ts +0 -150
  47. package/src/do-sql-tracking.test.ts +0 -19
  48. package/src/do-sql-tracking.ts +0 -19
  49. package/src/index.ts +0 -1215
  50. package/src/integration/integration.test.ts +0 -517
  51. package/src/integration/native-binary.guard.test.ts +0 -13
  52. package/src/integration/native-startup.test.ts +0 -44
  53. package/src/integration/replication-latency.test.ts +0 -428
  54. package/src/integration/restore-live-stress.test.ts +0 -433
  55. package/src/integration/restore-reset.test.ts +0 -400
  56. package/src/integration/restore.test.ts +0 -274
  57. package/src/integration/test-permissions.ts +0 -147
  58. package/src/load-config.ts +0 -46
  59. package/src/log.ts +0 -96
  60. package/src/mutex.ts +0 -47
  61. package/src/pg-proxy-browser.singledb.test.ts +0 -233
  62. package/src/pg-proxy-browser.ts +0 -2022
  63. package/src/pg-proxy-do-backend.test.ts +0 -3890
  64. package/src/pg-proxy-do-backend.ts +0 -7191
  65. package/src/pg-proxy.ts +0 -1087
  66. package/src/pg-sqlite-compiler/README.md +0 -53
  67. package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
  68. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
  69. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
  70. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
  71. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
  72. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
  73. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
  74. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
  75. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
  76. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
  77. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
  78. package/src/pg-sqlite-compiler/index.ts +0 -73
  79. package/src/pg-sqlite-compiler/integration.test.ts +0 -136
  80. package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
  81. package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
  82. package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
  83. package/src/pg-sqlite-compiler/passes/index.ts +0 -49
  84. package/src/pg-sqlite-compiler/passes/types.ts +0 -156
  85. package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
  86. package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
  87. package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
  88. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
  89. package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
  90. package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
  91. package/src/pg-sqlite-compiler/types.ts +0 -63
  92. package/src/pglite-ipc.test.ts +0 -116
  93. package/src/pglite-ipc.ts +0 -266
  94. package/src/pglite-manager.ts +0 -557
  95. package/src/pglite-web-proxy.test.ts +0 -57
  96. package/src/pglite-web-proxy.ts +0 -221
  97. package/src/pglite-web-worker.ts +0 -152
  98. package/src/pglite-worker-thread.ts +0 -253
  99. package/src/port.ts +0 -25
  100. package/src/process-title.ts +0 -9
  101. package/src/recovery.ts +0 -155
  102. package/src/replication/change-tracker.test.ts +0 -357
  103. package/src/replication/change-tracker.ts +0 -279
  104. package/src/replication/handler.test.ts +0 -511
  105. package/src/replication/handler.ts +0 -1190
  106. package/src/replication/pgoutput-encoder.test.ts +0 -697
  107. package/src/replication/pgoutput-encoder.ts +0 -373
  108. package/src/replication/tcp-replication.test.ts +0 -876
  109. package/src/replication/zero-compat.test.ts +0 -1150
  110. package/src/restore-stress.test.ts +0 -188
  111. package/src/s3-local.ts +0 -203
  112. package/src/shim/hooks.mjs +0 -120
  113. package/src/shim/register.mjs +0 -4
  114. package/src/sqlite-mode/apply-mode.ts +0 -224
  115. package/src/sqlite-mode/index.ts +0 -15
  116. package/src/sqlite-mode/native-binary.ts +0 -89
  117. package/src/sqlite-mode/package-resolve.ts +0 -17
  118. package/src/sqlite-mode/resolve-mode.ts +0 -80
  119. package/src/sqlite-mode/shim-template.ts +0 -159
  120. package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
  121. package/src/sqlite-mode/types.ts +0 -30
  122. package/src/vite-plugin.ts +0 -67
  123. package/src/wasm-sqlite.test.ts +0 -537
  124. package/src/worker/browser-admin.ts +0 -52
  125. package/src/worker/browser-build-config.test.ts +0 -71
  126. package/src/worker/browser-build-config.ts +0 -109
  127. package/src/worker/browser-embed-admin.test.ts +0 -75
  128. package/src/worker/browser-embed.ts +0 -345
  129. package/src/worker/cf-patches.ts +0 -384
  130. package/src/worker/embed-integration.test.ts +0 -321
  131. package/src/worker/index.ts +0 -138
  132. package/src/worker/shims/fastify.test.ts +0 -255
  133. package/src/worker/shims/fastify.ts +0 -306
  134. package/src/worker/shims/http-service.test.ts +0 -355
  135. package/src/worker/shims/http-service.ts +0 -293
  136. package/src/worker/shims/node-stub.ts +0 -290
  137. package/src/worker/shims/oxfmt.ts +0 -3
  138. package/src/worker/shims/postgres-browser.ts +0 -59
  139. package/src/worker/shims/postgres-socket.test.ts +0 -576
  140. package/src/worker/shims/postgres-socket.ts +0 -310
  141. package/src/worker/shims/postgres.test.ts +0 -364
  142. package/src/worker/shims/postgres.ts +0 -1454
  143. package/src/worker/shims/sqlite-browser.test.ts +0 -233
  144. package/src/worker/shims/sqlite-browser.ts +0 -175
  145. package/src/worker/shims/sqlite.test.ts +0 -786
  146. package/src/worker/shims/sqlite.ts +0 -978
  147. package/src/worker/shims/stream-browser.ts +0 -15
  148. package/src/worker/shims/ws-browser.test.ts +0 -205
  149. package/src/worker/shims/ws-browser.ts +0 -248
  150. package/src/worker/shims/ws.test.ts +0 -288
  151. package/src/worker/shims/ws.ts +0 -467
  152. package/src/worker/shims/zero-process-env.ts +0 -11
  153. package/src/worker/types.ts +0 -75
  154. package/src/worker/worker-integration.test.ts +0 -223
  155. package/src/worker/worker.test.ts +0 -136
  156. package/src/worker/zero-cache-embed-cf.ts +0 -463
  157. 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
- })