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