orez 0.2.25 → 0.2.26
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/watermark.d.ts +21 -0
- package/dist/cf-do/watermark.d.ts.map +1 -0
- package/dist/cf-do/watermark.js +93 -0
- package/dist/cf-do/watermark.js.map +1 -0
- package/dist/cf-do/worker.d.ts +48 -22
- package/dist/cf-do/worker.d.ts.map +1 -1
- package/dist/cf-do/worker.js +642 -269
- package/dist/cf-do/worker.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/do-sql-tracking.d.ts +6 -0
- package/dist/do-sql-tracking.d.ts.map +1 -0
- package/dist/do-sql-tracking.js +14 -0
- package/dist/do-sql-tracking.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -14
- package/dist/index.js.map +1 -1
- package/dist/pg-proxy-browser.js +6 -6
- package/dist/pg-proxy-browser.js.map +1 -1
- package/dist/pg-proxy-do-backend.d.ts +96 -17
- package/dist/pg-proxy-do-backend.d.ts.map +1 -1
- package/dist/pg-proxy-do-backend.js +6033 -454
- package/dist/pg-proxy-do-backend.js.map +1 -1
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +18 -1
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +7 -2
- package/dist/replication/handler.js.map +1 -1
- package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
- package/dist/replication/pgoutput-encoder.js +72 -30
- package/dist/replication/pgoutput-encoder.js.map +1 -1
- package/dist/worker/browser-build-config.d.ts.map +1 -1
- package/dist/worker/browser-build-config.js +2 -1
- package/dist/worker/browser-build-config.js.map +1 -1
- package/dist/worker/cf-patches.d.ts +5 -2
- package/dist/worker/cf-patches.d.ts.map +1 -1
- package/dist/worker/cf-patches.js +238 -4
- package/dist/worker/cf-patches.js.map +1 -1
- package/dist/worker/shims/node-stub.d.ts +35 -0
- package/dist/worker/shims/node-stub.d.ts.map +1 -1
- package/dist/worker/shims/node-stub.js +53 -1
- package/dist/worker/shims/node-stub.js.map +1 -1
- package/dist/worker/shims/oxfmt.d.ts +4 -0
- package/dist/worker/shims/oxfmt.d.ts.map +1 -0
- package/dist/worker/shims/oxfmt.js +4 -0
- package/dist/worker/shims/oxfmt.js.map +1 -0
- package/dist/worker/shims/postgres-socket.js +1 -1
- package/dist/worker/shims/postgres-socket.js.map +1 -1
- package/dist/worker/shims/sqlite.d.ts +1 -0
- package/dist/worker/shims/sqlite.d.ts.map +1 -1
- package/dist/worker/shims/sqlite.js +229 -9
- package/dist/worker/shims/sqlite.js.map +1 -1
- package/dist/worker/shims/ws.d.ts.map +1 -1
- package/dist/worker/shims/ws.js +45 -0
- package/dist/worker/shims/ws.js.map +1 -1
- package/dist/worker/shims/zero-process-env.d.ts +2 -0
- package/dist/worker/shims/zero-process-env.d.ts.map +1 -0
- package/dist/worker/shims/zero-process-env.js +9 -0
- package/dist/worker/shims/zero-process-env.js.map +1 -0
- package/dist/worker/zero-cache-embed-cf.d.ts +29 -12
- package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
- package/dist/worker/zero-cache-embed-cf.js +83 -14
- package/dist/worker/zero-cache-embed-cf.js.map +1 -1
- package/package.json +6 -2
- package/src/cf-do/.wrangler/cache/cf.json +1 -0
- 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 +83 -0
- package/src/cf-do/watermark.test.ts +103 -0
- package/src/cf-do/watermark.ts +118 -0
- package/src/cf-do/worker.ts +1033 -0
- package/src/cf-do/wrangler.toml +11 -0
- package/src/config.ts +1 -1
- package/src/do-sql-tracking.test.ts +19 -0
- package/src/do-sql-tracking.ts +19 -0
- package/src/index.ts +29 -14
- package/src/pg-proxy-browser.ts +6 -6
- package/src/pg-proxy-do-backend.test.ts +3890 -0
- package/src/pg-proxy-do-backend.ts +6799 -482
- package/src/replication/change-tracker.ts +16 -1
- package/src/replication/handler.test.ts +35 -0
- package/src/replication/handler.ts +7 -2
- package/src/replication/pgoutput-encoder.test.ts +71 -2
- package/src/replication/pgoutput-encoder.ts +65 -30
- package/src/worker/browser-build-config.test.ts +12 -0
- package/src/worker/browser-build-config.ts +2 -1
- package/src/worker/cf-patches.ts +274 -4
- package/src/worker/shims/node-stub.ts +53 -1
- package/src/worker/shims/oxfmt.ts +3 -0
- package/src/worker/shims/postgres-socket.ts +1 -1
- package/src/worker/shims/sqlite.test.ts +145 -0
- package/src/worker/shims/sqlite.ts +256 -9
- package/src/worker/shims/ws.ts +45 -0
- package/src/worker/shims/zero-process-env.ts +11 -0
- package/src/worker/zero-cache-embed-cf.ts +114 -18
- package/src/query-rewrites.test.ts +0 -30
- package/src/query-rewrites.ts +0 -152
|
@@ -0,0 +1,3890 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http'
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { DoBackend } from './pg-proxy-do-backend.js'
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder()
|
|
8
|
+
let servers: Server[] = []
|
|
9
|
+
|
|
10
|
+
function cstr(s: string): Uint8Array {
|
|
11
|
+
const encoded = encoder.encode(s)
|
|
12
|
+
const out = new Uint8Array(encoded.length + 1)
|
|
13
|
+
out.set(encoded)
|
|
14
|
+
return out
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function i16(v: number): Uint8Array {
|
|
18
|
+
const out = new Uint8Array(2)
|
|
19
|
+
new DataView(out.buffer).setInt16(0, v)
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function i32(v: number): Uint8Array {
|
|
24
|
+
const out = new Uint8Array(4)
|
|
25
|
+
new DataView(out.buffer).setInt32(0, v)
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
30
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
|
|
31
|
+
let offset = 0
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
out.set(part, offset)
|
|
34
|
+
offset += part.length
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function msg(type: number, payload: Uint8Array): Uint8Array {
|
|
40
|
+
return concat(new Uint8Array([type]), i32(payload.length + 4), payload)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseMessage(sql: string, name = '', paramOIDs: number[] = []): Uint8Array {
|
|
44
|
+
return msg(
|
|
45
|
+
0x50,
|
|
46
|
+
concat(
|
|
47
|
+
cstr(name),
|
|
48
|
+
cstr(sql),
|
|
49
|
+
i16(paramOIDs.length),
|
|
50
|
+
...paramOIDs.map((oid) => {
|
|
51
|
+
const out = new Uint8Array(4)
|
|
52
|
+
new DataView(out.buffer).setUint32(0, oid)
|
|
53
|
+
return out
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function describeStatement(name = ''): Uint8Array {
|
|
60
|
+
return msg(0x44, concat(encoder.encode('S'), cstr(name)))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function describePortal(name = ''): Uint8Array {
|
|
64
|
+
return msg(0x44, concat(encoder.encode('P'), cstr(name)))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function bindStatement(statement = '', portal = ''): Uint8Array {
|
|
68
|
+
return msg(0x42, concat(cstr(portal), cstr(statement), i16(0), i16(0), i16(0)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bindStatementParams(params: unknown[], statement = '', portal = ''): Uint8Array {
|
|
72
|
+
return msg(
|
|
73
|
+
0x42,
|
|
74
|
+
concat(
|
|
75
|
+
cstr(portal),
|
|
76
|
+
cstr(statement),
|
|
77
|
+
i16(0),
|
|
78
|
+
i16(params.length),
|
|
79
|
+
...params.map((param) => {
|
|
80
|
+
if (param === null || param === undefined) return i32(-1)
|
|
81
|
+
const encoded = encoder.encode(String(param))
|
|
82
|
+
return concat(i32(encoded.length), encoded)
|
|
83
|
+
}),
|
|
84
|
+
i16(0)
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function executePortal(portal = ''): Uint8Array {
|
|
90
|
+
return msg(0x45, concat(cstr(portal), i32(0)))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function closePortal(name = ''): Uint8Array {
|
|
94
|
+
return msg(0x43, concat(encoder.encode('P'), cstr(name)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function closeStatement(name = ''): Uint8Array {
|
|
98
|
+
return msg(0x43, concat(encoder.encode('S'), cstr(name)))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function messageTypes(data: Uint8Array): string[] {
|
|
102
|
+
const types: string[] = []
|
|
103
|
+
for (let offset = 0; offset < data.length; ) {
|
|
104
|
+
types.push(String.fromCharCode(data[offset]))
|
|
105
|
+
const len = new DataView(data.buffer, data.byteOffset + offset + 1, 4).getInt32(0)
|
|
106
|
+
offset += 1 + len
|
|
107
|
+
}
|
|
108
|
+
return types
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readyForQueryStatuses(data: Uint8Array): string[] {
|
|
112
|
+
const statuses: string[] = []
|
|
113
|
+
for (let offset = 0; offset < data.length; ) {
|
|
114
|
+
const type = data[offset]
|
|
115
|
+
const len = new DataView(data.buffer, data.byteOffset + offset + 1, 4).getInt32(0)
|
|
116
|
+
if (type === 0x5a) statuses.push(String.fromCharCode(data[offset + 5]))
|
|
117
|
+
offset += 1 + len
|
|
118
|
+
}
|
|
119
|
+
return statuses
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rowDescriptionOids(data: Uint8Array): Record<string, number> {
|
|
123
|
+
const oids: Record<string, number> = {}
|
|
124
|
+
for (let offset = 0; offset < data.length; ) {
|
|
125
|
+
const type = data[offset]
|
|
126
|
+
const len = new DataView(data.buffer, data.byteOffset + offset + 1, 4).getInt32(0)
|
|
127
|
+
if (type === 0x54) {
|
|
128
|
+
const view = new DataView(data.buffer, data.byteOffset + offset, 1 + len)
|
|
129
|
+
const count = view.getInt16(5)
|
|
130
|
+
let pos = 7
|
|
131
|
+
for (let i = 0; i < count; i++) {
|
|
132
|
+
const nameStart = pos
|
|
133
|
+
while (pos < 1 + len && data[offset + pos] !== 0) pos++
|
|
134
|
+
const name = new TextDecoder().decode(
|
|
135
|
+
data.subarray(offset + nameStart, offset + pos)
|
|
136
|
+
)
|
|
137
|
+
pos++
|
|
138
|
+
pos += 6
|
|
139
|
+
oids[name] = view.getUint32(pos)
|
|
140
|
+
pos += 12
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
offset += 1 + len
|
|
144
|
+
}
|
|
145
|
+
return oids
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function rowDescriptionNames(data: Uint8Array): string[] {
|
|
149
|
+
for (let offset = 0; offset < data.length; ) {
|
|
150
|
+
const type = data[offset]
|
|
151
|
+
const len = new DataView(data.buffer, data.byteOffset + offset + 1, 4).getInt32(0)
|
|
152
|
+
if (type === 0x54) {
|
|
153
|
+
const view = new DataView(data.buffer, data.byteOffset + offset, 1 + len)
|
|
154
|
+
const count = view.getInt16(5)
|
|
155
|
+
let pos = 7
|
|
156
|
+
const names: string[] = []
|
|
157
|
+
for (let i = 0; i < count; i++) {
|
|
158
|
+
const nameStart = pos
|
|
159
|
+
while (pos < 1 + len && data[offset + pos] !== 0) pos++
|
|
160
|
+
names.push(
|
|
161
|
+
new TextDecoder().decode(data.subarray(offset + nameStart, offset + pos))
|
|
162
|
+
)
|
|
163
|
+
pos += 19
|
|
164
|
+
}
|
|
165
|
+
return names
|
|
166
|
+
}
|
|
167
|
+
offset += 1 + len
|
|
168
|
+
}
|
|
169
|
+
return []
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parameterDescriptionOids(data: Uint8Array): number[] {
|
|
173
|
+
for (let offset = 0; offset < data.length; ) {
|
|
174
|
+
const type = data[offset]
|
|
175
|
+
const len = new DataView(data.buffer, data.byteOffset + offset + 1, 4).getInt32(0)
|
|
176
|
+
if (type === 0x74) {
|
|
177
|
+
const view = new DataView(data.buffer, data.byteOffset + offset, 1 + len)
|
|
178
|
+
const count = view.getInt16(5)
|
|
179
|
+
return Array.from({ length: count }, (_, index) => view.getUint32(7 + index * 4))
|
|
180
|
+
}
|
|
181
|
+
offset += 1 + len
|
|
182
|
+
}
|
|
183
|
+
return []
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function dataRowValues(data: Uint8Array): (string | null)[][] {
|
|
187
|
+
const rows: (string | null)[][] = []
|
|
188
|
+
for (let offset = 0; offset < data.length; ) {
|
|
189
|
+
const type = data[offset]
|
|
190
|
+
const len = new DataView(data.buffer, data.byteOffset + offset + 1, 4).getInt32(0)
|
|
191
|
+
if (type === 0x44) {
|
|
192
|
+
const view = new DataView(data.buffer, data.byteOffset + offset, 1 + len)
|
|
193
|
+
const count = view.getInt16(5)
|
|
194
|
+
let pos = 7
|
|
195
|
+
const row: (string | null)[] = []
|
|
196
|
+
for (let i = 0; i < count; i++) {
|
|
197
|
+
const valueLength = view.getInt32(pos)
|
|
198
|
+
pos += 4
|
|
199
|
+
if (valueLength === -1) {
|
|
200
|
+
row.push(null)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
row.push(
|
|
204
|
+
new TextDecoder().decode(
|
|
205
|
+
data.subarray(offset + pos, offset + pos + valueLength)
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
pos += valueLength
|
|
209
|
+
}
|
|
210
|
+
rows.push(row)
|
|
211
|
+
}
|
|
212
|
+
offset += 1 + len
|
|
213
|
+
}
|
|
214
|
+
return rows
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function compactSQL(sql: string): string {
|
|
218
|
+
return sql.replace(/\s+/g, ' ').trim()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sqlContaining(sqls: string[], needle: string): string {
|
|
222
|
+
const found = [...sqls].reverse().find((sql) => compactSQL(sql).includes(needle))
|
|
223
|
+
expect(found).toBeDefined()
|
|
224
|
+
return found ?? ''
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function startDoHttp(
|
|
228
|
+
handler: (sql: string, url: URL) => { rows?: unknown[]; columns?: string[] } | Response
|
|
229
|
+
): Promise<{
|
|
230
|
+
url: string
|
|
231
|
+
requests: URL[]
|
|
232
|
+
sqls: string[]
|
|
233
|
+
params: unknown[][]
|
|
234
|
+
bodies: any[]
|
|
235
|
+
}> {
|
|
236
|
+
const requests: URL[] = []
|
|
237
|
+
const sqls: string[] = []
|
|
238
|
+
const params: unknown[][] = []
|
|
239
|
+
const bodies: any[] = []
|
|
240
|
+
const server = createServer(async (req, res) => {
|
|
241
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
242
|
+
requests.push(url)
|
|
243
|
+
let body = ''
|
|
244
|
+
req.on('data', (chunk) => {
|
|
245
|
+
body += chunk
|
|
246
|
+
})
|
|
247
|
+
req.on('end', () => {
|
|
248
|
+
const parsed = body ? JSON.parse(body) : {}
|
|
249
|
+
bodies.push(parsed)
|
|
250
|
+
if (url.pathname === '/batch') {
|
|
251
|
+
const statements = Array.isArray(parsed.statements) ? parsed.statements : []
|
|
252
|
+
sqls.push(
|
|
253
|
+
...statements.map((statement: any) =>
|
|
254
|
+
typeof statement === 'string' ? statement : statement.sql
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
res.setHeader('content-type', 'application/json')
|
|
258
|
+
res.end(
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
results: statements.map(() => ({ rows: [], columns: [] })),
|
|
261
|
+
})
|
|
262
|
+
)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
const sql = parsed.sql || ''
|
|
266
|
+
sqls.push(sql)
|
|
267
|
+
params.push(Array.isArray(parsed.params) ? parsed.params : [])
|
|
268
|
+
const result = handler(sql, url)
|
|
269
|
+
if (result instanceof Response) {
|
|
270
|
+
res.statusCode = result.status
|
|
271
|
+
result.text().then((text) => res.end(text))
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
res.setHeader('content-type', 'application/json')
|
|
275
|
+
res.end(JSON.stringify({ rows: result.rows ?? [], columns: result.columns ?? [] }))
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
servers.push(server)
|
|
279
|
+
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
server.once('error', reject)
|
|
282
|
+
server.listen(0, '127.0.0.1', () => {
|
|
283
|
+
const addr = server.address()
|
|
284
|
+
if (!addr || typeof addr === 'string') {
|
|
285
|
+
reject(new Error('server did not bind to a tcp port'))
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
resolve({
|
|
289
|
+
url: `http://127.0.0.1:${addr.port}`,
|
|
290
|
+
requests,
|
|
291
|
+
sqls,
|
|
292
|
+
params,
|
|
293
|
+
bodies,
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
afterEach(async () => {
|
|
300
|
+
await Promise.all(
|
|
301
|
+
servers.map(
|
|
302
|
+
(server) =>
|
|
303
|
+
new Promise<void>((resolve) => {
|
|
304
|
+
server.close(() => resolve())
|
|
305
|
+
})
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
servers = []
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('DoBackend', () => {
|
|
312
|
+
test('sends the configured Durable Object namespace on every SQL request', async () => {
|
|
313
|
+
const http = await startDoHttp(() => ({ rows: [{ ok: 1 }], columns: ['ok'] }))
|
|
314
|
+
const backend = new DoBackend(http.url, 'postgres', 'chat-test-namespace')
|
|
315
|
+
await backend.waitReady
|
|
316
|
+
|
|
317
|
+
await backend.query('SELECT 1 AS ok')
|
|
318
|
+
|
|
319
|
+
expect(http.requests.length).toBeGreaterThanOrEqual(2)
|
|
320
|
+
expect(http.requests.every((url) => url.searchParams.get('db') === 'postgres')).toBe(
|
|
321
|
+
true
|
|
322
|
+
)
|
|
323
|
+
expect(
|
|
324
|
+
http.requests.every((url) => url.searchParams.get('ns') === 'chat-test-namespace')
|
|
325
|
+
).toBe(true)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('describes prepared statements with parameter and row metadata', async () => {
|
|
329
|
+
const http = await startDoHttp((sql) => {
|
|
330
|
+
if (sql.includes('_orez_describe')) return { rows: [], columns: ['value'] }
|
|
331
|
+
return { rows: [{ value: 'ok' }], columns: ['value'] }
|
|
332
|
+
})
|
|
333
|
+
const backend = new DoBackend(http.url, 'postgres', 'describe-test')
|
|
334
|
+
await backend.waitReady
|
|
335
|
+
|
|
336
|
+
expect(
|
|
337
|
+
messageTypes(
|
|
338
|
+
await backend.execProtocolRaw(parseMessage('SELECT $1 AS value', '', [25]))
|
|
339
|
+
)
|
|
340
|
+
).toEqual(['1'])
|
|
341
|
+
expect(messageTypes(await backend.execProtocolRaw(describeStatement()))).toEqual([
|
|
342
|
+
't',
|
|
343
|
+
'T',
|
|
344
|
+
])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('returns row metadata for zero-row selects', async () => {
|
|
348
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['id'] }))
|
|
349
|
+
const backend = new DoBackend(http.url, 'postgres', 'zero-row-test')
|
|
350
|
+
await backend.waitReady
|
|
351
|
+
|
|
352
|
+
const result = await backend.execProtocolRaw(
|
|
353
|
+
msg(0x51, cstr('SELECT id FROM message WHERE 1 = 0'))
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
expect(messageTypes(result)).toEqual(['T', 'C', 'Z'])
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('rewrites pg_column_size totals as ordinary SQL instead of catalog probes', async () => {
|
|
360
|
+
const http = await startDoHttp((sql) => {
|
|
361
|
+
if (compactSQL(sql).includes('length')) {
|
|
362
|
+
return { rows: [{ totalBytes: 42 }], columns: ['totalBytes'] }
|
|
363
|
+
}
|
|
364
|
+
return { rows: [], columns: [] }
|
|
365
|
+
})
|
|
366
|
+
const backend = new DoBackend(http.url, 'postgres', 'pg-column-size-test')
|
|
367
|
+
await backend.waitReady
|
|
368
|
+
|
|
369
|
+
await backend.execProtocolRaw(
|
|
370
|
+
parseMessage(`
|
|
371
|
+
SELECT (
|
|
372
|
+
SUM(COALESCE(pg_column_size("id"), 0)) +
|
|
373
|
+
SUM(COALESCE(pg_column_size("payload"), 0))
|
|
374
|
+
) AS "totalBytes"
|
|
375
|
+
FROM public.message
|
|
376
|
+
`)
|
|
377
|
+
)
|
|
378
|
+
await backend.execProtocolRaw(bindStatement())
|
|
379
|
+
const result = await backend.execProtocolRaw(executePortal())
|
|
380
|
+
|
|
381
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
382
|
+
expect(messageTypes(result)).toEqual(['T', 'D', 'C'])
|
|
383
|
+
expect(dataRowValues(result)).toEqual([['42']])
|
|
384
|
+
expect(sent).toContain('length')
|
|
385
|
+
expect(sent).toContain('FROM message')
|
|
386
|
+
expect(sent).not.toContain('pg_column_size')
|
|
387
|
+
expect(sent).not.toContain('public.message')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('streams COPY TO STDOUT from a parsed select query', async () => {
|
|
391
|
+
const http = await startDoHttp(() => ({
|
|
392
|
+
rows: [{ id: 'm1', deleted: 0 }],
|
|
393
|
+
columns: ['id', 'deleted'],
|
|
394
|
+
}))
|
|
395
|
+
const backend = new DoBackend(http.url, 'postgres', 'copy-test')
|
|
396
|
+
await backend.waitReady
|
|
397
|
+
await backend.exec(`
|
|
398
|
+
CREATE TABLE public.message (
|
|
399
|
+
id text PRIMARY KEY,
|
|
400
|
+
deleted boolean NOT NULL DEFAULT false
|
|
401
|
+
)
|
|
402
|
+
`)
|
|
403
|
+
|
|
404
|
+
const result = await backend.execProtocolRaw(
|
|
405
|
+
msg(0x51, cstr('COPY (SELECT id, deleted FROM public.message) TO STDOUT'))
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
expect(messageTypes(result)).toEqual(['H', 'd', 'c', 'C', 'Z'])
|
|
409
|
+
expect(new TextDecoder().decode(result)).toContain('m1\tf\n')
|
|
410
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('FROM message'))).toBe(true)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('formats timestamp typed rows as postgres text for DataRow and COPY', async () => {
|
|
414
|
+
const http = await startDoHttp((sql) => {
|
|
415
|
+
if (compactSQL(sql).startsWith('SELECT')) {
|
|
416
|
+
return {
|
|
417
|
+
rows: [
|
|
418
|
+
{ id: 'm1', createdAt: '2026-05-25T20:17:28.377Z' },
|
|
419
|
+
{ id: 'm2', createdAt: '1779746873949.0' },
|
|
420
|
+
],
|
|
421
|
+
columns: ['id', 'createdAt'],
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { rows: [], columns: [] }
|
|
425
|
+
})
|
|
426
|
+
const backend = new DoBackend(http.url, 'postgres', 'timestamp-format-test')
|
|
427
|
+
await backend.waitReady
|
|
428
|
+
|
|
429
|
+
await backend.exec(`
|
|
430
|
+
CREATE TABLE public.message (
|
|
431
|
+
id text PRIMARY KEY,
|
|
432
|
+
"createdAt" timestamptz NOT NULL
|
|
433
|
+
)
|
|
434
|
+
`)
|
|
435
|
+
|
|
436
|
+
const select = await backend.execProtocolRaw(
|
|
437
|
+
msg(0x51, cstr('SELECT id, "createdAt" FROM public.message'))
|
|
438
|
+
)
|
|
439
|
+
const copy = await backend.execProtocolRaw(
|
|
440
|
+
msg(0x51, cstr('COPY (SELECT id, "createdAt" FROM public.message) TO STDOUT'))
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
expect(rowDescriptionOids(select)).toMatchObject({ createdAt: 1184 })
|
|
444
|
+
expect(dataRowValues(select)).toEqual([
|
|
445
|
+
['m1', '2026-05-25 20:17:28.377+00'],
|
|
446
|
+
['m2', '2026-05-25 22:07:53.949+00'],
|
|
447
|
+
])
|
|
448
|
+
expect(new TextDecoder().decode(copy)).toContain('m1\t2026-05-25 20:17:28.377+00\n')
|
|
449
|
+
expect(new TextDecoder().decode(copy)).toContain('m2\t2026-05-25 22:07:53.949+00\n')
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('normalizes timestamp typed parameters before sending them to SQLite', async () => {
|
|
453
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
454
|
+
const backend = new DoBackend(http.url, 'postgres', 'timestamp-param-test')
|
|
455
|
+
await backend.waitReady
|
|
456
|
+
|
|
457
|
+
await backend.exec(`
|
|
458
|
+
CREATE TABLE public.event (
|
|
459
|
+
id text PRIMARY KEY,
|
|
460
|
+
"createdAt" timestamptz NOT NULL
|
|
461
|
+
)
|
|
462
|
+
`)
|
|
463
|
+
await backend.exec(`
|
|
464
|
+
CREATE TABLE public.notification (
|
|
465
|
+
id text PRIMARY KEY,
|
|
466
|
+
"seenAt" timestamptz
|
|
467
|
+
)
|
|
468
|
+
`)
|
|
469
|
+
|
|
470
|
+
await backend.execProtocolRaw(
|
|
471
|
+
parseMessage(
|
|
472
|
+
'INSERT INTO public.event (id, "createdAt") VALUES ($1, $2)',
|
|
473
|
+
'insert_timestamp'
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
const insertDescribe = await backend.execProtocolRaw(
|
|
477
|
+
describeStatement('insert_timestamp')
|
|
478
|
+
)
|
|
479
|
+
expect(parameterDescriptionOids(insertDescribe)).toEqual([25, 1184])
|
|
480
|
+
await backend.execProtocolRaw(
|
|
481
|
+
bindStatementParams(['e1', 1779746873949], 'insert_timestamp')
|
|
482
|
+
)
|
|
483
|
+
await backend.execProtocolRaw(executePortal())
|
|
484
|
+
|
|
485
|
+
await backend.execProtocolRaw(
|
|
486
|
+
parseMessage(
|
|
487
|
+
'UPDATE public.event SET "createdAt" = $1 WHERE id = $2',
|
|
488
|
+
'update_timestamp'
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
const updateDescribe = await backend.execProtocolRaw(
|
|
492
|
+
describeStatement('update_timestamp')
|
|
493
|
+
)
|
|
494
|
+
expect(parameterDescriptionOids(updateDescribe)).toEqual([1184, 25])
|
|
495
|
+
await backend.execProtocolRaw(
|
|
496
|
+
bindStatementParams([1779746874950, 'e1'], 'update_timestamp')
|
|
497
|
+
)
|
|
498
|
+
await backend.execProtocolRaw(executePortal())
|
|
499
|
+
|
|
500
|
+
await backend.execProtocolRaw(
|
|
501
|
+
parseMessage(
|
|
502
|
+
'INSERT INTO public.notification (id, "seenAt") VALUES ($1, $2)',
|
|
503
|
+
'insert_null_timestamp'
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
const nullDescribe = await backend.execProtocolRaw(
|
|
507
|
+
describeStatement('insert_null_timestamp')
|
|
508
|
+
)
|
|
509
|
+
expect(parameterDescriptionOids(nullDescribe)).toEqual([25, 1184])
|
|
510
|
+
await backend.execProtocolRaw(
|
|
511
|
+
bindStatementParams(['n1', null], 'insert_null_timestamp')
|
|
512
|
+
)
|
|
513
|
+
await backend.execProtocolRaw(executePortal())
|
|
514
|
+
|
|
515
|
+
expect(http.params.at(-3)).toEqual(['e1', '2026-05-25 22:07:53.949+00'])
|
|
516
|
+
expect(http.params.at(-2)).toEqual(['2026-05-25 22:07:54.950+00', 'e1'])
|
|
517
|
+
expect(http.params.at(-1)).toEqual(['n1', null])
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
test('normalizes boolean parameters before sending them to SQLite', async () => {
|
|
521
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
522
|
+
const backend = new DoBackend(http.url, 'postgres', 'boolean-param-test')
|
|
523
|
+
await backend.waitReady
|
|
524
|
+
|
|
525
|
+
await backend.exec(`
|
|
526
|
+
CREATE TABLE public.flag_probe (
|
|
527
|
+
id text PRIMARY KEY,
|
|
528
|
+
enabled boolean NOT NULL
|
|
529
|
+
)
|
|
530
|
+
`)
|
|
531
|
+
await backend.execProtocolRaw(
|
|
532
|
+
parseMessage('INSERT INTO public.flag_probe (id, enabled) VALUES ($1, $2)')
|
|
533
|
+
)
|
|
534
|
+
await backend.execProtocolRaw(bindStatementParams(['f1', false]))
|
|
535
|
+
await backend.execProtocolRaw(executePortal())
|
|
536
|
+
await backend.execProtocolRaw(bindStatementParams(['t1', true]))
|
|
537
|
+
await backend.execProtocolRaw(executePortal())
|
|
538
|
+
|
|
539
|
+
expect(http.params.at(-2)).toEqual(['f1', 0])
|
|
540
|
+
expect(http.params.at(-1)).toEqual(['t1', 1])
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test('normalizes boolean parameters through text cast chains', async () => {
|
|
544
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
545
|
+
const backend = new DoBackend(http.url, 'postgres', 'boolean-text-cast-param-test')
|
|
546
|
+
await backend.waitReady
|
|
547
|
+
|
|
548
|
+
await backend.query(
|
|
549
|
+
'SELECT id FROM public.flag_probe WHERE enabled = $1::text::boolean OR enabled = $1::text::boolean',
|
|
550
|
+
['false']
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
expect(http.params.at(-1)).toEqual([0, 0])
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
test('infers JSON parameters in inserts and ON CONFLICT updates', async () => {
|
|
557
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
558
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-param-test')
|
|
559
|
+
await backend.waitReady
|
|
560
|
+
|
|
561
|
+
await backend.exec(`
|
|
562
|
+
CREATE TABLE public.client_state (
|
|
563
|
+
id text PRIMARY KEY,
|
|
564
|
+
"clientSchema" jsonb
|
|
565
|
+
)
|
|
566
|
+
`)
|
|
567
|
+
|
|
568
|
+
await backend.execProtocolRaw(
|
|
569
|
+
parseMessage(
|
|
570
|
+
`INSERT INTO public.client_state (id, "clientSchema")
|
|
571
|
+
VALUES ($1, $2)
|
|
572
|
+
ON CONFLICT (id) DO UPDATE SET "clientSchema" = $3`,
|
|
573
|
+
'json_upsert'
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
const describe = await backend.execProtocolRaw(describeStatement('json_upsert'))
|
|
577
|
+
expect(parameterDescriptionOids(describe)).toEqual([25, 3802, 3802])
|
|
578
|
+
|
|
579
|
+
await backend.query(
|
|
580
|
+
`INSERT INTO public.client_state (id, "clientSchema")
|
|
581
|
+
VALUES ($1, $2)
|
|
582
|
+
ON CONFLICT (id) DO UPDATE SET "clientSchema" = $3`,
|
|
583
|
+
['cg', { tables: { insert: true } }, { tables: { update: true } }]
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
expect(http.params.at(-1)).toEqual([
|
|
587
|
+
'cg',
|
|
588
|
+
'{"tables":{"insert":true}}',
|
|
589
|
+
'{"tables":{"update":true}}',
|
|
590
|
+
])
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test('returns aliased current_setting catalog values needed by zero-cache', async () => {
|
|
594
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
595
|
+
const backend = new DoBackend(http.url, 'postgres', 'current-setting-test')
|
|
596
|
+
await backend.waitReady
|
|
597
|
+
|
|
598
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
599
|
+
SELECT current_setting('wal_level') as "walLevel",
|
|
600
|
+
current_setting('server_version_num') as "version";
|
|
601
|
+
`)
|
|
602
|
+
|
|
603
|
+
expect(result.fields.map((field: any) => field.name)).toEqual(['walLevel', 'version'])
|
|
604
|
+
expect(result.rows).toEqual([{ walLevel: 'logical', version: '160000' }])
|
|
605
|
+
|
|
606
|
+
const rewrittenResult = await (backend as any).handleCatalogQuery(`
|
|
607
|
+
SELECT 'logical'::text as "walLevel",
|
|
608
|
+
current_setting('server_version_num') as "version";
|
|
609
|
+
`)
|
|
610
|
+
|
|
611
|
+
expect(rewrittenResult.fields.map((field: any) => field.name)).toEqual([
|
|
612
|
+
'walLevel',
|
|
613
|
+
'version',
|
|
614
|
+
])
|
|
615
|
+
expect(rewrittenResult.rows).toEqual([{ walLevel: 'logical', version: '160000' }])
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
test('projects pg_settings expressions from synthesized settings rows', async () => {
|
|
619
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
620
|
+
const backend = new DoBackend(http.url, 'postgres', 'pg-settings-test')
|
|
621
|
+
await backend.waitReady
|
|
622
|
+
|
|
623
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
624
|
+
SELECT EXTRACT(EPOCH FROM (setting || unit)::interval) * 1000
|
|
625
|
+
AS "walSenderTimeoutMs"
|
|
626
|
+
FROM pg_settings
|
|
627
|
+
WHERE name = 'wal_sender_timeout'
|
|
628
|
+
`)
|
|
629
|
+
|
|
630
|
+
expect(result.fields).toEqual([{ name: 'walSenderTimeoutMs', oid: 701 }])
|
|
631
|
+
expect(result.rows).toEqual([{ walSenderTimeoutMs: 60000 }])
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
test('returns requested pg_publication rows for zero-cache validation', async () => {
|
|
635
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
636
|
+
const backend = new DoBackend(http.url, 'postgres', 'publication-test')
|
|
637
|
+
await backend.waitReady
|
|
638
|
+
await backend.exec('CREATE PUBLICATION zero_chat FOR TABLE message')
|
|
639
|
+
|
|
640
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
641
|
+
SELECT pubname FROM pg_publication WHERE pubname IN ('zero_chat')
|
|
642
|
+
`)
|
|
643
|
+
|
|
644
|
+
expect(result.fields.map((field: any) => field.name)).toEqual(['pubname'])
|
|
645
|
+
expect(result.rows).toEqual([{ pubname: 'zero_chat' }])
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('executes the portal named by extended-protocol Execute', async () => {
|
|
649
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['value'] }))
|
|
650
|
+
const backend = new DoBackend(http.url, 'postgres', 'portal-execute-test')
|
|
651
|
+
await backend.waitReady
|
|
652
|
+
await backend.exec('CREATE PUBLICATION zero_chat FOR TABLE message')
|
|
653
|
+
|
|
654
|
+
await backend.execProtocolRaw(
|
|
655
|
+
parseMessage('SELECT value FROM ordinary WHERE id = $1', 'ordinary')
|
|
656
|
+
)
|
|
657
|
+
await backend.execProtocolRaw(
|
|
658
|
+
bindStatementParams(['ignore'], 'ordinary', 'ordinary_portal')
|
|
659
|
+
)
|
|
660
|
+
await backend.execProtocolRaw(
|
|
661
|
+
parseMessage(
|
|
662
|
+
'SELECT pubname FROM pg_publication WHERE pubname IN ($1)',
|
|
663
|
+
'publication'
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
await backend.execProtocolRaw(
|
|
667
|
+
bindStatementParams(['zero_chat'], 'publication', 'publication_portal')
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
const result = await backend.execProtocolRaw(executePortal('publication_portal'))
|
|
671
|
+
|
|
672
|
+
expect(messageTypes(result)).toEqual(['T', 'D', 'C'])
|
|
673
|
+
expect(new TextDecoder().decode(result)).toContain('zero_chat')
|
|
674
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('FROM ordinary'))).toBe(false)
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
test('describes and closes bound portals by portal name', async () => {
|
|
678
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['value'] }))
|
|
679
|
+
const backend = new DoBackend(http.url, 'postgres', 'portal-describe-test')
|
|
680
|
+
await backend.waitReady
|
|
681
|
+
|
|
682
|
+
await backend.execProtocolRaw(parseMessage('SELECT $1 AS value', 'statement', [25]))
|
|
683
|
+
await backend.execProtocolRaw(bindStatementParams(['ok'], 'statement', 'portal'))
|
|
684
|
+
|
|
685
|
+
expect(messageTypes(await backend.execProtocolRaw(describePortal('portal')))).toEqual(
|
|
686
|
+
['T']
|
|
687
|
+
)
|
|
688
|
+
expect(messageTypes(await backend.execProtocolRaw(closePortal('portal')))).toEqual([
|
|
689
|
+
'3',
|
|
690
|
+
])
|
|
691
|
+
expect(messageTypes(await backend.execProtocolRaw(describePortal('portal')))).toEqual(
|
|
692
|
+
['n']
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
await backend.execProtocolRaw(bindStatementParams(['ok'], 'statement', 'portal'))
|
|
696
|
+
expect(
|
|
697
|
+
messageTypes(await backend.execProtocolRaw(closeStatement('statement')))
|
|
698
|
+
).toEqual(['3'])
|
|
699
|
+
expect(messageTypes(await backend.execProtocolRaw(describePortal('portal')))).toEqual(
|
|
700
|
+
['n']
|
|
701
|
+
)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
test('returns publication flags with boolean oids for zero-cache schema checks', async () => {
|
|
705
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
706
|
+
const backend = new DoBackend(http.url, 'postgres', 'publication-flags-test')
|
|
707
|
+
await backend.waitReady
|
|
708
|
+
await backend.exec(`
|
|
709
|
+
CREATE PUBLICATION zero_chat FOR TABLE message;
|
|
710
|
+
CREATE PUBLICATION _zero_metadata FOR TABLE "_zero"."clients";
|
|
711
|
+
`)
|
|
712
|
+
|
|
713
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
714
|
+
SELECT pubname,pubinsert,pubupdate,pubdelete,pubtruncate FROM pg_publication pb
|
|
715
|
+
WHERE pb.pubname IN ('zero_chat','_zero_metadata')
|
|
716
|
+
ORDER BY pubname
|
|
717
|
+
`)
|
|
718
|
+
|
|
719
|
+
expect(result.fields).toEqual([
|
|
720
|
+
{ name: 'pubname', oid: undefined },
|
|
721
|
+
{ name: 'pubinsert', oid: 16 },
|
|
722
|
+
{ name: 'pubupdate', oid: 16 },
|
|
723
|
+
{ name: 'pubdelete', oid: 16 },
|
|
724
|
+
{ name: 'pubtruncate', oid: 16 },
|
|
725
|
+
])
|
|
726
|
+
expect(result.rows).toEqual([
|
|
727
|
+
{
|
|
728
|
+
pubname: '_zero_metadata',
|
|
729
|
+
pubinsert: 't',
|
|
730
|
+
pubupdate: 't',
|
|
731
|
+
pubdelete: 't',
|
|
732
|
+
pubtruncate: 't',
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
pubname: 'zero_chat',
|
|
736
|
+
pubinsert: 't',
|
|
737
|
+
pubupdate: 't',
|
|
738
|
+
pubdelete: 't',
|
|
739
|
+
pubtruncate: 't',
|
|
740
|
+
},
|
|
741
|
+
])
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
test('preserves selected field metadata for empty catalog probes', async () => {
|
|
745
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
746
|
+
const backend = new DoBackend(http.url, 'postgres', 'pg-class-probe-test')
|
|
747
|
+
await backend.waitReady
|
|
748
|
+
|
|
749
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
750
|
+
SELECT nspname, relname FROM pg_class
|
|
751
|
+
JOIN pg_namespace ON relnamespace = pg_namespace.oid
|
|
752
|
+
WHERE nspname = 'chat_0' AND relname = 'versionHistory'
|
|
753
|
+
`)
|
|
754
|
+
|
|
755
|
+
expect(result.rows).toEqual([])
|
|
756
|
+
expect(result.fields.map((field: any) => field.name)).toEqual(['nspname', 'relname'])
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
test('synthesizes advisory lock catalog calls with one null row', async () => {
|
|
760
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
761
|
+
const backend = new DoBackend(http.url, 'postgres', 'advisory-lock-test')
|
|
762
|
+
await backend.waitReady
|
|
763
|
+
|
|
764
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
765
|
+
SELECT pg_advisory_xact_lock(hashtext('migrate-schema:chat_0'))
|
|
766
|
+
`)
|
|
767
|
+
|
|
768
|
+
expect(result.fields.map((field: any) => field.name)).toEqual([
|
|
769
|
+
'pg_advisory_xact_lock',
|
|
770
|
+
])
|
|
771
|
+
expect(result.rows).toEqual([{ pg_advisory_xact_lock: null }])
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
test('synthesizes logical message lag-report probes', async () => {
|
|
775
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
776
|
+
const backend = new DoBackend(http.url, 'postgres', 'logical-message-test')
|
|
777
|
+
await backend.waitReady
|
|
778
|
+
|
|
779
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
780
|
+
WITH CTE AS (SELECT extract(epoch from now()) * 1000 AS "commitTimeMs")
|
|
781
|
+
SELECT "commitTimeMs", pg_logical_emit_message(
|
|
782
|
+
false,
|
|
783
|
+
'zero/0',
|
|
784
|
+
json_build_object(
|
|
785
|
+
'id', 'lag-1'::text,
|
|
786
|
+
'sendTimeMs', 1::int8,
|
|
787
|
+
'commitTimeMs', "commitTimeMs"
|
|
788
|
+
)::text
|
|
789
|
+
) as lsn FROM CTE;
|
|
790
|
+
`)
|
|
791
|
+
|
|
792
|
+
expect(result.fields.map((field: any) => field.name)).toEqual(['commitTimeMs', 'lsn'])
|
|
793
|
+
expect(result.rows[0]).toEqual({
|
|
794
|
+
commitTimeMs: expect.any(Number),
|
|
795
|
+
lsn: '0/1',
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
const wire = await backend.execProtocolRaw(
|
|
799
|
+
msg(
|
|
800
|
+
0x51,
|
|
801
|
+
cstr(`
|
|
802
|
+
WITH CTE AS (SELECT extract(epoch from now()) * 1000 AS "commitTimeMs")
|
|
803
|
+
SELECT "commitTimeMs", pg_logical_emit_message(
|
|
804
|
+
false,
|
|
805
|
+
'zero/0',
|
|
806
|
+
json_build_object(
|
|
807
|
+
'id', 'lag-1'::text,
|
|
808
|
+
'sendTimeMs', 1::int8,
|
|
809
|
+
'commitTimeMs', "commitTimeMs"
|
|
810
|
+
)::text
|
|
811
|
+
) as lsn FROM CTE;
|
|
812
|
+
`)
|
|
813
|
+
)
|
|
814
|
+
)
|
|
815
|
+
expect(rowDescriptionOids(wire)).toMatchObject({
|
|
816
|
+
commitTimeMs: 701,
|
|
817
|
+
lsn: 25,
|
|
818
|
+
})
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('synthesizes pg_tables from sqlite schema for publication setup', async () => {
|
|
822
|
+
const http = await startDoHttp((sql) => {
|
|
823
|
+
if (sql.includes('sqlite_master')) {
|
|
824
|
+
return {
|
|
825
|
+
rows: [
|
|
826
|
+
{ name: 'message', sql: 'CREATE TABLE message (id varchar PRIMARY KEY)' },
|
|
827
|
+
{ name: 'user', sql: 'CREATE TABLE user (id varchar PRIMARY KEY)' },
|
|
828
|
+
{
|
|
829
|
+
name: 'public_migrations',
|
|
830
|
+
sql: 'CREATE TABLE public_migrations (id integer PRIMARY KEY)',
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
columns: ['name', 'sql'],
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (sql.includes('PRAGMA table_info("message")')) {
|
|
837
|
+
return {
|
|
838
|
+
rows: [
|
|
839
|
+
{ cid: 0, name: 'id', type: 'varchar', notnull: 1, dflt_value: null, pk: 1 },
|
|
840
|
+
],
|
|
841
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return { rows: [], columns: [] }
|
|
845
|
+
})
|
|
846
|
+
const backend = new DoBackend(http.url, 'postgres', 'pg-tables-test')
|
|
847
|
+
await backend.waitReady
|
|
848
|
+
|
|
849
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
850
|
+
SELECT tablename FROM pg_tables
|
|
851
|
+
WHERE schemaname = 'public'
|
|
852
|
+
AND tablename != ALL('{"user","migrations"}')
|
|
853
|
+
`)
|
|
854
|
+
|
|
855
|
+
expect(result.rows).toEqual([{ tablename: 'message' }])
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
test('synthesizes information_schema.columns from parsed DDL metadata', async () => {
|
|
859
|
+
const http = await startDoHttp((sql) => {
|
|
860
|
+
if (sql.includes('sqlite_master')) {
|
|
861
|
+
return {
|
|
862
|
+
rows: [
|
|
863
|
+
{
|
|
864
|
+
name: 'message',
|
|
865
|
+
sql: 'CREATE TABLE message (id varchar, payload text, enabled integer, tags text)',
|
|
866
|
+
},
|
|
867
|
+
],
|
|
868
|
+
columns: ['name', 'sql'],
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (sql.includes('PRAGMA table_info("message")')) {
|
|
872
|
+
return {
|
|
873
|
+
rows: [
|
|
874
|
+
{ cid: 0, name: 'id', type: 'varchar', notnull: 1, dflt_value: null, pk: 1 },
|
|
875
|
+
{
|
|
876
|
+
cid: 1,
|
|
877
|
+
name: 'payload',
|
|
878
|
+
type: 'text',
|
|
879
|
+
notnull: 0,
|
|
880
|
+
dflt_value: null,
|
|
881
|
+
pk: 0,
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
cid: 2,
|
|
885
|
+
name: 'enabled',
|
|
886
|
+
type: 'integer',
|
|
887
|
+
notnull: 1,
|
|
888
|
+
dflt_value: '0',
|
|
889
|
+
pk: 0,
|
|
890
|
+
},
|
|
891
|
+
{ cid: 3, name: 'tags', type: 'text', notnull: 0, dflt_value: null, pk: 0 },
|
|
892
|
+
],
|
|
893
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return { rows: [], columns: [] }
|
|
897
|
+
})
|
|
898
|
+
const backend = new DoBackend(http.url, 'postgres', 'information-schema-test')
|
|
899
|
+
await backend.waitReady
|
|
900
|
+
await backend.exec(`
|
|
901
|
+
CREATE TABLE public.message (
|
|
902
|
+
id varchar(64) PRIMARY KEY,
|
|
903
|
+
payload jsonb,
|
|
904
|
+
enabled boolean NOT NULL DEFAULT false,
|
|
905
|
+
tags text[]
|
|
906
|
+
)
|
|
907
|
+
`)
|
|
908
|
+
|
|
909
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
910
|
+
SELECT
|
|
911
|
+
c.table_schema::text AS schema,
|
|
912
|
+
c.table_name::text AS table,
|
|
913
|
+
c.column_name::text AS column,
|
|
914
|
+
c.data_type::text AS "dataType",
|
|
915
|
+
c.character_maximum_length AS length,
|
|
916
|
+
c.numeric_precision AS precision,
|
|
917
|
+
c.numeric_scale AS scale,
|
|
918
|
+
t.typtype::text AS typtype,
|
|
919
|
+
t.typname::text AS typename,
|
|
920
|
+
CASE WHEN t.typelem <> 0 THEN et.typtype::text ELSE NULL END AS "elemTyptype",
|
|
921
|
+
CASE WHEN t.typelem <> 0 THEN et.typname::text ELSE NULL END AS "elemTypname"
|
|
922
|
+
FROM information_schema.columns c
|
|
923
|
+
JOIN pg_catalog.pg_type t ON c.udt_name = t.typname
|
|
924
|
+
LEFT JOIN pg_catalog.pg_type et ON t.typelem = et.oid
|
|
925
|
+
JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid
|
|
926
|
+
WHERE (c.table_schema, c.table_name) IN (('public'::text, 'message'::text))
|
|
927
|
+
`)
|
|
928
|
+
|
|
929
|
+
expect(result.rows).toEqual([
|
|
930
|
+
expect.objectContaining({
|
|
931
|
+
column: 'id',
|
|
932
|
+
dataType: 'character varying',
|
|
933
|
+
length: 64,
|
|
934
|
+
typtype: 'b',
|
|
935
|
+
typename: 'varchar',
|
|
936
|
+
elemTyptype: null,
|
|
937
|
+
}),
|
|
938
|
+
expect.objectContaining({
|
|
939
|
+
column: 'payload',
|
|
940
|
+
dataType: 'jsonb',
|
|
941
|
+
typename: 'jsonb',
|
|
942
|
+
}),
|
|
943
|
+
expect.objectContaining({
|
|
944
|
+
column: 'enabled',
|
|
945
|
+
dataType: 'boolean',
|
|
946
|
+
typename: 'bool',
|
|
947
|
+
}),
|
|
948
|
+
expect.objectContaining({
|
|
949
|
+
column: 'tags',
|
|
950
|
+
dataType: 'ARRAY',
|
|
951
|
+
typename: '_text',
|
|
952
|
+
elemTyptype: 'b',
|
|
953
|
+
elemTypname: 'text',
|
|
954
|
+
}),
|
|
955
|
+
])
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
test('tracks parser-backed publication membership without private table lists', async () => {
|
|
959
|
+
const http = await startDoHttp((sql) => {
|
|
960
|
+
if (sql.includes('sqlite_master')) {
|
|
961
|
+
return {
|
|
962
|
+
rows: [
|
|
963
|
+
{ name: 'message', sql: 'CREATE TABLE message (id text)' },
|
|
964
|
+
{ name: 'account', sql: 'CREATE TABLE account (id text)' },
|
|
965
|
+
],
|
|
966
|
+
columns: ['name', 'sql'],
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (sql.includes('PRAGMA table_info("message")')) {
|
|
970
|
+
return {
|
|
971
|
+
rows: [
|
|
972
|
+
{ cid: 0, name: 'id', type: 'text', notnull: 1, dflt_value: null, pk: 1 },
|
|
973
|
+
],
|
|
974
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (sql.includes('PRAGMA table_info("account")')) {
|
|
978
|
+
return {
|
|
979
|
+
rows: [
|
|
980
|
+
{ cid: 0, name: 'id', type: 'text', notnull: 1, dflt_value: null, pk: 1 },
|
|
981
|
+
],
|
|
982
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return { rows: [], columns: [] }
|
|
986
|
+
})
|
|
987
|
+
const backend = new DoBackend(http.url, 'postgres', 'publication-membership-test')
|
|
988
|
+
await backend.waitReady
|
|
989
|
+
await backend.exec('CREATE PUBLICATION zero_chat FOR TABLE message')
|
|
990
|
+
|
|
991
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
992
|
+
SELECT schemaname, tablename, pubname
|
|
993
|
+
FROM pg_publication_tables
|
|
994
|
+
WHERE pubname IN ('zero_chat')
|
|
995
|
+
`)
|
|
996
|
+
|
|
997
|
+
expect(result.rows).toEqual([
|
|
998
|
+
{ schemaname: 'public', tablename: 'message', pubname: 'zero_chat' },
|
|
999
|
+
])
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
test('synthesizes zero-cache publication metadata result sets', async () => {
|
|
1003
|
+
const http = await startDoHttp((sql) => {
|
|
1004
|
+
if (sql.includes('sqlite_master')) {
|
|
1005
|
+
return {
|
|
1006
|
+
rows: [
|
|
1007
|
+
{ name: 'message', sql: 'CREATE TABLE message (id varchar PRIMARY KEY)' },
|
|
1008
|
+
],
|
|
1009
|
+
columns: ['name', 'sql'],
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (sql.includes('PRAGMA table_info("message")')) {
|
|
1013
|
+
return {
|
|
1014
|
+
rows: [
|
|
1015
|
+
{ cid: 0, name: 'id', type: 'varchar', notnull: 1, dflt_value: null, pk: 1 },
|
|
1016
|
+
{
|
|
1017
|
+
cid: 1,
|
|
1018
|
+
name: 'deleted',
|
|
1019
|
+
type: 'INTEGER',
|
|
1020
|
+
notnull: 1,
|
|
1021
|
+
dflt_value: '0',
|
|
1022
|
+
pk: 0,
|
|
1023
|
+
},
|
|
1024
|
+
],
|
|
1025
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return { rows: [], columns: [] }
|
|
1029
|
+
})
|
|
1030
|
+
const backend = new DoBackend(http.url, 'postgres', 'publication-metadata-test')
|
|
1031
|
+
await backend.waitReady
|
|
1032
|
+
await backend.exec(`
|
|
1033
|
+
CREATE TABLE message (
|
|
1034
|
+
id varchar PRIMARY KEY,
|
|
1035
|
+
deleted boolean NOT NULL DEFAULT false
|
|
1036
|
+
);
|
|
1037
|
+
CREATE PUBLICATION zero_chat FOR TABLE message;
|
|
1038
|
+
`)
|
|
1039
|
+
|
|
1040
|
+
const results = await (backend as any).handleCatalogQueries(`
|
|
1041
|
+
SELECT schemaname AS "schema", tablename AS "table",
|
|
1042
|
+
json_object_agg(pubname, attnames) AS "publications"
|
|
1043
|
+
FROM pg_publication_tables pb
|
|
1044
|
+
WHERE pb.pubname IN ('zero_chat')
|
|
1045
|
+
GROUP BY schemaname, tablename;
|
|
1046
|
+
|
|
1047
|
+
WITH published_columns AS (
|
|
1048
|
+
SELECT attname FROM pg_attribute
|
|
1049
|
+
JOIN pg_publication_tables pb ON attname = ANY(pb.attnames)
|
|
1050
|
+
WHERE pb.pubname IN ('zero_chat')
|
|
1051
|
+
)
|
|
1052
|
+
SELECT COALESCE(json_agg("table"), '[]'::json) as "tables" FROM published_columns;
|
|
1053
|
+
|
|
1054
|
+
WITH indexed_columns AS (
|
|
1055
|
+
SELECT pg_indexes.indexname FROM pg_indexes
|
|
1056
|
+
JOIN pg_index ON true
|
|
1057
|
+
)
|
|
1058
|
+
SELECT COALESCE(json_agg("index"), '[]'::json) as "indexes" FROM indexed_columns;
|
|
1059
|
+
`)
|
|
1060
|
+
|
|
1061
|
+
expect(results[0].rows).toEqual([
|
|
1062
|
+
{
|
|
1063
|
+
schema: 'public',
|
|
1064
|
+
table: 'message',
|
|
1065
|
+
publications: { zero_chat: ['id', 'deleted'] },
|
|
1066
|
+
},
|
|
1067
|
+
])
|
|
1068
|
+
expect(results[1].rows[0].tables).toEqual([
|
|
1069
|
+
expect.objectContaining({
|
|
1070
|
+
name: 'message',
|
|
1071
|
+
primaryKey: ['id'],
|
|
1072
|
+
columns: expect.objectContaining({
|
|
1073
|
+
id: expect.objectContaining({ dataType: 'character varying', typeOID: 1043 }),
|
|
1074
|
+
deleted: expect.objectContaining({ dataType: 'boolean', typeOID: 16 }),
|
|
1075
|
+
}),
|
|
1076
|
+
}),
|
|
1077
|
+
])
|
|
1078
|
+
expect(results[2]).toEqual({
|
|
1079
|
+
rows: [
|
|
1080
|
+
{
|
|
1081
|
+
indexes: [
|
|
1082
|
+
expect.objectContaining({
|
|
1083
|
+
schema: 'public',
|
|
1084
|
+
tableName: 'message',
|
|
1085
|
+
name: 'message_id_pkey',
|
|
1086
|
+
unique: true,
|
|
1087
|
+
isPrimaryKey: true,
|
|
1088
|
+
isImmediate: true,
|
|
1089
|
+
columns: { id: 'ASC' },
|
|
1090
|
+
}),
|
|
1091
|
+
],
|
|
1092
|
+
},
|
|
1093
|
+
],
|
|
1094
|
+
fields: [{ name: 'indexes', oid: 114 }],
|
|
1095
|
+
})
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
test('tracks published table writes with parser-derived returning SQL', async () => {
|
|
1099
|
+
const http = await startDoHttp((sql) => {
|
|
1100
|
+
if (compactSQL(sql).includes('RETURNING *')) {
|
|
1101
|
+
return { rows: [{ id: 't1', body: 'hello' }], columns: ['id', 'body'] }
|
|
1102
|
+
}
|
|
1103
|
+
return { rows: [], columns: [] }
|
|
1104
|
+
})
|
|
1105
|
+
const backend = new DoBackend(http.url, 'postgres', 'write-tracking-test')
|
|
1106
|
+
await backend.waitReady
|
|
1107
|
+
await backend.exec('CREATE PUBLICATION zero_chat FOR TABLE task_item')
|
|
1108
|
+
|
|
1109
|
+
const result = await backend.execProtocolRaw(
|
|
1110
|
+
msg(0x51, cstr("INSERT INTO task_item (id, body) VALUES ('t1', 'hello')"))
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
expect(messageTypes(result)).toEqual(['C', 'Z'])
|
|
1114
|
+
const tracked = http.bodies.find((body) => body.track)
|
|
1115
|
+
expect(tracked.track).toEqual({
|
|
1116
|
+
tableName: 'public.task_item',
|
|
1117
|
+
operation: 'INSERT',
|
|
1118
|
+
returnRows: false,
|
|
1119
|
+
})
|
|
1120
|
+
expect(compactSQL(tracked.sql)).toContain('RETURNING *')
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
test('signals replication immediately after tracked writes', async () => {
|
|
1124
|
+
const http = await startDoHttp((sql) => {
|
|
1125
|
+
if (compactSQL(sql).includes('RETURNING *')) {
|
|
1126
|
+
return { rows: [{ id: 't1', body: 'hello' }], columns: ['id', 'body'] }
|
|
1127
|
+
}
|
|
1128
|
+
return { rows: [], columns: [] }
|
|
1129
|
+
})
|
|
1130
|
+
const backend = new DoBackend(http.url, 'postgres', 'write-signal-test')
|
|
1131
|
+
await backend.waitReady
|
|
1132
|
+
await backend.exec('CREATE PUBLICATION zero_chat FOR TABLE task_item')
|
|
1133
|
+
|
|
1134
|
+
const globalObject = globalThis as any
|
|
1135
|
+
const previousWakeup = globalObject.__orez_signal_replication
|
|
1136
|
+
let wakeups = 0
|
|
1137
|
+
globalObject.__orez_signal_replication = () => {
|
|
1138
|
+
wakeups++
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
await backend.query("INSERT INTO task_item (id, body) VALUES ('t1', 'hello')")
|
|
1142
|
+
} finally {
|
|
1143
|
+
if (previousWakeup === undefined) {
|
|
1144
|
+
delete globalObject.__orez_signal_replication
|
|
1145
|
+
} else {
|
|
1146
|
+
globalObject.__orez_signal_replication = previousWakeup
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
expect(wakeups).toBe(1)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
test('tracks full published rows while preserving client RETURNING projection', async () => {
|
|
1154
|
+
const http = await startDoHttp((sql) => {
|
|
1155
|
+
const compact = compactSQL(sql)
|
|
1156
|
+
if (compact.includes('RETURNING *')) {
|
|
1157
|
+
return {
|
|
1158
|
+
rows: [{ id: 't1', body: 'hello', __orez_returning_1: 'HELLO' }],
|
|
1159
|
+
columns: ['id', 'body', '__orez_returning_1'],
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
return { rows: [], columns: [] }
|
|
1163
|
+
})
|
|
1164
|
+
const backend = new DoBackend(http.url, 'postgres', 'write-returning-test')
|
|
1165
|
+
await backend.waitReady
|
|
1166
|
+
await backend.exec('CREATE TABLE task_item (id TEXT PRIMARY KEY, body TEXT)')
|
|
1167
|
+
await backend.exec('CREATE PUBLICATION zero_chat FOR TABLE task_item')
|
|
1168
|
+
|
|
1169
|
+
const result = await backend.execProtocolRaw(
|
|
1170
|
+
msg(
|
|
1171
|
+
0x51,
|
|
1172
|
+
cstr(
|
|
1173
|
+
`INSERT INTO task_item (id, body)
|
|
1174
|
+
VALUES ('t1', 'hello')
|
|
1175
|
+
RETURNING "id", upper("body") AS "bodyUpper"`
|
|
1176
|
+
)
|
|
1177
|
+
)
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
expect(messageTypes(result)).toEqual(['T', 'D', 'C', 'Z'])
|
|
1181
|
+
expect(rowDescriptionNames(result)).toEqual(['id', 'bodyUpper'])
|
|
1182
|
+
expect(dataRowValues(result)).toEqual([['t1', 'HELLO']])
|
|
1183
|
+
|
|
1184
|
+
const tracked = http.bodies.find((body) => body.track)
|
|
1185
|
+
expect(tracked.track).toEqual({
|
|
1186
|
+
tableName: 'public.task_item',
|
|
1187
|
+
operation: 'INSERT',
|
|
1188
|
+
returnRows: true,
|
|
1189
|
+
rowColumns: ['id', 'body'],
|
|
1190
|
+
})
|
|
1191
|
+
expect(compactSQL(tracked.sql)).toContain('RETURNING *')
|
|
1192
|
+
expect(compactSQL(tracked.sql)).toContain('__orez_returning_1')
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
test('synthesizes primary-key rows for zero-cache relation metadata queries', async () => {
|
|
1196
|
+
const http = await startDoHttp((sql) => {
|
|
1197
|
+
if (sql.includes('sqlite_master')) {
|
|
1198
|
+
return {
|
|
1199
|
+
rows: [{ name: 'server', sql: 'CREATE TABLE server (id varchar PRIMARY KEY)' }],
|
|
1200
|
+
columns: ['name', 'sql'],
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (sql.includes('PRAGMA table_info("server")')) {
|
|
1204
|
+
return {
|
|
1205
|
+
rows: [
|
|
1206
|
+
{ cid: 0, name: 'id', type: 'varchar', notnull: 1, dflt_value: null, pk: 1 },
|
|
1207
|
+
{
|
|
1208
|
+
cid: 1,
|
|
1209
|
+
name: 'name',
|
|
1210
|
+
type: 'varchar',
|
|
1211
|
+
notnull: 1,
|
|
1212
|
+
dflt_value: null,
|
|
1213
|
+
pk: 0,
|
|
1214
|
+
},
|
|
1215
|
+
],
|
|
1216
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return { rows: [], columns: [] }
|
|
1220
|
+
})
|
|
1221
|
+
const backend = new DoBackend(http.url, 'postgres', 'relation-metadata-test')
|
|
1222
|
+
await backend.waitReady
|
|
1223
|
+
|
|
1224
|
+
const result = await backend.query<{
|
|
1225
|
+
kind: string
|
|
1226
|
+
table_schema: string
|
|
1227
|
+
table_name: string
|
|
1228
|
+
column_name: string
|
|
1229
|
+
data_type: string | null
|
|
1230
|
+
ordinal_position: number
|
|
1231
|
+
}>(
|
|
1232
|
+
`SELECT 'pk' AS kind, tc.table_schema, tc.table_name, kcu.column_name, NULL AS data_type, kcu.ordinal_position
|
|
1233
|
+
FROM information_schema.table_constraints tc
|
|
1234
|
+
JOIN information_schema.key_column_usage kcu
|
|
1235
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
1236
|
+
AND tc.table_schema = kcu.table_schema
|
|
1237
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
1238
|
+
AND tc.table_schema = ANY($1)
|
|
1239
|
+
UNION ALL
|
|
1240
|
+
SELECT 'col' AS kind, table_schema, table_name, column_name, data_type, ordinal_position
|
|
1241
|
+
FROM information_schema.columns
|
|
1242
|
+
WHERE table_schema = ANY($1)
|
|
1243
|
+
ORDER BY table_schema, table_name, kind, ordinal_position`,
|
|
1244
|
+
[['public']]
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
expect(result.rows).toContainEqual({
|
|
1248
|
+
kind: 'pk',
|
|
1249
|
+
table_schema: 'public',
|
|
1250
|
+
table_name: 'server',
|
|
1251
|
+
column_name: 'id',
|
|
1252
|
+
data_type: null,
|
|
1253
|
+
ordinal_position: 1,
|
|
1254
|
+
})
|
|
1255
|
+
expect(result.rows).toContainEqual({
|
|
1256
|
+
kind: 'col',
|
|
1257
|
+
table_schema: 'public',
|
|
1258
|
+
table_name: 'server',
|
|
1259
|
+
column_name: 'name',
|
|
1260
|
+
data_type: 'character varying',
|
|
1261
|
+
ordinal_position: 2,
|
|
1262
|
+
})
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
test('does not track unpublished public table writes', async () => {
|
|
1266
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1267
|
+
const backend = new DoBackend(http.url, 'postgres', 'unpublished-write-test')
|
|
1268
|
+
await backend.waitReady
|
|
1269
|
+
|
|
1270
|
+
await backend.execProtocolRaw(
|
|
1271
|
+
msg(0x51, cstr("INSERT INTO private_note (id) VALUES ('n1')"))
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
expect(http.bodies.some((body) => body.track)).toBe(false)
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
test('synthesizes primary and unique index metadata for published tables', async () => {
|
|
1278
|
+
const http = await startDoHttp((sql) => {
|
|
1279
|
+
if (sql.includes('sqlite_master')) {
|
|
1280
|
+
return {
|
|
1281
|
+
rows: [
|
|
1282
|
+
{
|
|
1283
|
+
name: 'account',
|
|
1284
|
+
sql: 'CREATE TABLE account (id text PRIMARY KEY, email text NOT NULL UNIQUE)',
|
|
1285
|
+
},
|
|
1286
|
+
],
|
|
1287
|
+
columns: ['name', 'sql'],
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (sql.includes('PRAGMA table_info("account")')) {
|
|
1291
|
+
return {
|
|
1292
|
+
rows: [
|
|
1293
|
+
{ cid: 0, name: 'id', type: 'text', notnull: 1, dflt_value: null, pk: 1 },
|
|
1294
|
+
{
|
|
1295
|
+
cid: 1,
|
|
1296
|
+
name: 'email',
|
|
1297
|
+
type: 'text',
|
|
1298
|
+
notnull: 1,
|
|
1299
|
+
dflt_value: null,
|
|
1300
|
+
pk: 0,
|
|
1301
|
+
},
|
|
1302
|
+
],
|
|
1303
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (sql.includes('PRAGMA index_list("account")')) {
|
|
1307
|
+
return {
|
|
1308
|
+
rows: [
|
|
1309
|
+
{
|
|
1310
|
+
seq: 0,
|
|
1311
|
+
name: 'sqlite_autoindex_account_2',
|
|
1312
|
+
unique: 1,
|
|
1313
|
+
origin: 'u',
|
|
1314
|
+
partial: 0,
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
seq: 1,
|
|
1318
|
+
name: 'sqlite_autoindex_account_1',
|
|
1319
|
+
unique: 1,
|
|
1320
|
+
origin: 'pk',
|
|
1321
|
+
partial: 0,
|
|
1322
|
+
},
|
|
1323
|
+
],
|
|
1324
|
+
columns: ['seq', 'name', 'unique', 'origin', 'partial'],
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
if (sql.includes('PRAGMA index_xinfo("sqlite_autoindex_account_2")')) {
|
|
1328
|
+
return {
|
|
1329
|
+
rows: [
|
|
1330
|
+
{ seqno: 0, cid: 1, name: 'email', desc: 0, key: 1 },
|
|
1331
|
+
{ seqno: 1, cid: -1, name: null, desc: 0, key: 0 },
|
|
1332
|
+
],
|
|
1333
|
+
columns: ['seqno', 'cid', 'name', 'desc', 'key'],
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (sql.includes('PRAGMA index_xinfo("sqlite_autoindex_account_1")')) {
|
|
1337
|
+
return {
|
|
1338
|
+
rows: [
|
|
1339
|
+
{ seqno: 0, cid: 0, name: 'id', desc: 0, key: 1 },
|
|
1340
|
+
{ seqno: 1, cid: -1, name: null, desc: 0, key: 0 },
|
|
1341
|
+
],
|
|
1342
|
+
columns: ['seqno', 'cid', 'name', 'desc', 'key'],
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return { rows: [], columns: [] }
|
|
1346
|
+
})
|
|
1347
|
+
const backend = new DoBackend(http.url, 'postgres', 'published-index-test')
|
|
1348
|
+
await backend.waitReady
|
|
1349
|
+
await backend.exec('CREATE PUBLICATION zero_chat FOR TABLE account')
|
|
1350
|
+
|
|
1351
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
1352
|
+
WITH indexed_columns AS (
|
|
1353
|
+
SELECT pg_indexes.indexname FROM pg_indexes
|
|
1354
|
+
JOIN pg_index ON true
|
|
1355
|
+
JOIN pg_publication_tables pb ON true
|
|
1356
|
+
WHERE pb.pubname IN ('zero_chat')
|
|
1357
|
+
)
|
|
1358
|
+
SELECT COALESCE(json_agg("index"), '[]'::json) as "indexes"
|
|
1359
|
+
FROM indexed_columns;
|
|
1360
|
+
`)
|
|
1361
|
+
|
|
1362
|
+
expect(result).toEqual({
|
|
1363
|
+
rows: [
|
|
1364
|
+
{
|
|
1365
|
+
indexes: [
|
|
1366
|
+
expect.objectContaining({
|
|
1367
|
+
schema: 'public',
|
|
1368
|
+
tableName: 'account',
|
|
1369
|
+
name: 'account_id_pkey',
|
|
1370
|
+
unique: true,
|
|
1371
|
+
isPrimaryKey: true,
|
|
1372
|
+
isImmediate: true,
|
|
1373
|
+
columns: { id: 'ASC' },
|
|
1374
|
+
}),
|
|
1375
|
+
expect.objectContaining({
|
|
1376
|
+
schema: 'public',
|
|
1377
|
+
tableName: 'account',
|
|
1378
|
+
name: 'account_email_key',
|
|
1379
|
+
unique: true,
|
|
1380
|
+
isPrimaryKey: false,
|
|
1381
|
+
isImmediate: true,
|
|
1382
|
+
columns: { email: 'ASC' },
|
|
1383
|
+
}),
|
|
1384
|
+
],
|
|
1385
|
+
},
|
|
1386
|
+
],
|
|
1387
|
+
fields: [{ name: 'indexes', oid: 114 }],
|
|
1388
|
+
})
|
|
1389
|
+
})
|
|
1390
|
+
|
|
1391
|
+
test('uses parsed ADD COLUMN primary-key metadata when SQLite cannot alter the physical key', async () => {
|
|
1392
|
+
const http = await startDoHttp((sql) => {
|
|
1393
|
+
if (sql.includes('sqlite_master')) {
|
|
1394
|
+
return {
|
|
1395
|
+
rows: [
|
|
1396
|
+
{
|
|
1397
|
+
name: 'appInstall',
|
|
1398
|
+
sql: 'CREATE TABLE "appInstall" ("serverId" text, "creatorId" text, "appId" text, "id" text, PRIMARY KEY ("serverId", "creatorId", "appId"))',
|
|
1399
|
+
},
|
|
1400
|
+
],
|
|
1401
|
+
columns: ['name', 'sql'],
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (sql.includes('PRAGMA table_info("appInstall")')) {
|
|
1405
|
+
return {
|
|
1406
|
+
rows: [
|
|
1407
|
+
{
|
|
1408
|
+
cid: 0,
|
|
1409
|
+
name: 'serverId',
|
|
1410
|
+
type: 'text',
|
|
1411
|
+
notnull: 0,
|
|
1412
|
+
dflt_value: null,
|
|
1413
|
+
pk: 1,
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
cid: 1,
|
|
1417
|
+
name: 'creatorId',
|
|
1418
|
+
type: 'text',
|
|
1419
|
+
notnull: 0,
|
|
1420
|
+
dflt_value: null,
|
|
1421
|
+
pk: 2,
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
cid: 2,
|
|
1425
|
+
name: 'appId',
|
|
1426
|
+
type: 'text',
|
|
1427
|
+
notnull: 0,
|
|
1428
|
+
dflt_value: null,
|
|
1429
|
+
pk: 3,
|
|
1430
|
+
},
|
|
1431
|
+
{ cid: 3, name: 'id', type: 'text', notnull: 0, dflt_value: null, pk: 0 },
|
|
1432
|
+
],
|
|
1433
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
if (sql.includes('PRAGMA index_list("appInstall")')) {
|
|
1437
|
+
return {
|
|
1438
|
+
rows: [
|
|
1439
|
+
{
|
|
1440
|
+
seq: 0,
|
|
1441
|
+
name: 'sqlite_autoindex_appInstall_1',
|
|
1442
|
+
unique: 1,
|
|
1443
|
+
origin: 'pk',
|
|
1444
|
+
partial: 0,
|
|
1445
|
+
},
|
|
1446
|
+
],
|
|
1447
|
+
columns: ['seq', 'name', 'unique', 'origin', 'partial'],
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return { rows: [], columns: [] }
|
|
1451
|
+
})
|
|
1452
|
+
const backend = new DoBackend(http.url, 'postgres', 'alter-primary-key-test')
|
|
1453
|
+
await backend.waitReady
|
|
1454
|
+
await backend.exec(`
|
|
1455
|
+
ALTER TABLE "appInstall"
|
|
1456
|
+
ADD COLUMN "id" varchar PRIMARY KEY NOT NULL;
|
|
1457
|
+
CREATE PUBLICATION zero_chat FOR TABLE "appInstall";
|
|
1458
|
+
`)
|
|
1459
|
+
|
|
1460
|
+
const results = await (backend as any).handleCatalogQueries(`
|
|
1461
|
+
WITH published_columns AS (
|
|
1462
|
+
SELECT attname FROM pg_attribute
|
|
1463
|
+
JOIN pg_publication_tables pb ON attname = ANY(pb.attnames)
|
|
1464
|
+
WHERE pb.pubname IN ('zero_chat')
|
|
1465
|
+
)
|
|
1466
|
+
SELECT COALESCE(json_agg("table"), '[]'::json) as "tables" FROM published_columns;
|
|
1467
|
+
|
|
1468
|
+
WITH indexed_columns AS (
|
|
1469
|
+
SELECT pg_indexes.indexname FROM pg_indexes
|
|
1470
|
+
JOIN pg_index ON true
|
|
1471
|
+
)
|
|
1472
|
+
SELECT COALESCE(json_agg("index"), '[]'::json) as "indexes" FROM indexed_columns;
|
|
1473
|
+
`)
|
|
1474
|
+
|
|
1475
|
+
expect(results[0].rows[0].tables).toEqual([
|
|
1476
|
+
expect.objectContaining({
|
|
1477
|
+
name: 'appInstall',
|
|
1478
|
+
primaryKey: ['id'],
|
|
1479
|
+
columns: expect.objectContaining({
|
|
1480
|
+
id: expect.objectContaining({ notNull: true }),
|
|
1481
|
+
}),
|
|
1482
|
+
}),
|
|
1483
|
+
])
|
|
1484
|
+
expect(results[1].rows[0].indexes).toEqual([
|
|
1485
|
+
expect.objectContaining({
|
|
1486
|
+
tableName: 'appInstall',
|
|
1487
|
+
unique: true,
|
|
1488
|
+
isPrimaryKey: true,
|
|
1489
|
+
columns: { id: 'ASC' },
|
|
1490
|
+
}),
|
|
1491
|
+
])
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
test('does not convert backend SQL errors into empty result sets', async () => {
|
|
1495
|
+
const http = await startDoHttp(() => new Response('boom', { status: 500 }))
|
|
1496
|
+
const backend = new DoBackend(http.url, 'postgres', 'error-test')
|
|
1497
|
+
await backend.waitReady
|
|
1498
|
+
|
|
1499
|
+
await expect(backend.query('SELECT broken')).rejects.toThrow('HTTP 500: boom')
|
|
1500
|
+
})
|
|
1501
|
+
|
|
1502
|
+
test('flattens public schema table references before sending SQL to DO', async () => {
|
|
1503
|
+
const http = await startDoHttp(() => ({ rows: [{ count: 0 }], columns: ['count'] }))
|
|
1504
|
+
const backend = new DoBackend(http.url, 'postgres', 'schema-flatten-test')
|
|
1505
|
+
await backend.waitReady
|
|
1506
|
+
|
|
1507
|
+
await backend.query('SELECT count(*) FROM public.migrations')
|
|
1508
|
+
|
|
1509
|
+
expect(
|
|
1510
|
+
http.sqls.some((sql) => compactSQL(sql).includes('FROM public_migrations'))
|
|
1511
|
+
).toBe(true)
|
|
1512
|
+
expect(
|
|
1513
|
+
http.sqls.some((sql) => compactSQL(sql).includes('FROM public.migrations'))
|
|
1514
|
+
).toBe(false)
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
test('flushes transactional writes before reads so migrations can see DDL', async () => {
|
|
1518
|
+
const http = await startDoHttp((sql) => {
|
|
1519
|
+
if (/select\s+name\s+from\s+public_migrations/i.test(sql)) {
|
|
1520
|
+
return { rows: [], columns: ['name'] }
|
|
1521
|
+
}
|
|
1522
|
+
return { rows: [], columns: [] }
|
|
1523
|
+
})
|
|
1524
|
+
const backend = new DoBackend(http.url, 'postgres', 'transaction-read-test')
|
|
1525
|
+
await backend.waitReady
|
|
1526
|
+
|
|
1527
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
1528
|
+
await backend.execProtocolRaw(
|
|
1529
|
+
msg(
|
|
1530
|
+
0x51,
|
|
1531
|
+
cstr(`
|
|
1532
|
+
CREATE TABLE IF NOT EXISTS public.migrations (
|
|
1533
|
+
id SERIAL PRIMARY KEY,
|
|
1534
|
+
name VARCHAR(255) NOT NULL
|
|
1535
|
+
)
|
|
1536
|
+
`)
|
|
1537
|
+
)
|
|
1538
|
+
)
|
|
1539
|
+
await backend.execProtocolRaw(msg(0x51, cstr('SELECT name FROM public.migrations')))
|
|
1540
|
+
|
|
1541
|
+
const createIndex = http.sqls.findIndex((sql) =>
|
|
1542
|
+
/create\s+table\s+if\s+not\s+exists\s+public_migrations/i.test(sql)
|
|
1543
|
+
)
|
|
1544
|
+
const selectIndex = http.sqls.findIndex((sql) =>
|
|
1545
|
+
/select\s+name\s+from\s+public_migrations/i.test(sql)
|
|
1546
|
+
)
|
|
1547
|
+
expect(createIndex).toBeGreaterThanOrEqual(0)
|
|
1548
|
+
expect(selectIndex).toBeGreaterThan(createIndex)
|
|
1549
|
+
await backend.execProtocolRaw(msg(0x51, cstr('ROLLBACK')))
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
test('intercepts parser-recognized transaction variants before DO execution', async () => {
|
|
1553
|
+
// The Durable Object refuses raw BEGIN/SAVEPOINT (it requires the JS-side
|
|
1554
|
+
// transaction API). All PG transaction control statements must be handled
|
|
1555
|
+
// locally and never reach the DO as SQL.
|
|
1556
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1557
|
+
const backend = new DoBackend(http.url, 'postgres', 'transaction-variant-test')
|
|
1558
|
+
await backend.waitReady
|
|
1559
|
+
|
|
1560
|
+
const begin = await backend.execProtocolRaw(
|
|
1561
|
+
msg(0x51, cstr('BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ'))
|
|
1562
|
+
)
|
|
1563
|
+
await backend.execProtocolRaw(msg(0x51, cstr('SAVEPOINT zero_migrate')))
|
|
1564
|
+
await backend.execProtocolRaw(msg(0x51, cstr('RELEASE SAVEPOINT zero_migrate')))
|
|
1565
|
+
await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
1566
|
+
|
|
1567
|
+
expect(messageTypes(begin)).toEqual(['C', 'Z'])
|
|
1568
|
+
const sent = http.sqls.map((sql) => sql.trim().toUpperCase())
|
|
1569
|
+
expect(sent.some((sql) => sql.startsWith('BEGIN'))).toBe(false)
|
|
1570
|
+
expect(sent.some((sql) => sql.startsWith('COMMIT'))).toBe(false)
|
|
1571
|
+
expect(sent.some((sql) => sql.startsWith('SAVEPOINT'))).toBe(false)
|
|
1572
|
+
expect(sent.some((sql) => sql.startsWith('RELEASE'))).toBe(false)
|
|
1573
|
+
})
|
|
1574
|
+
|
|
1575
|
+
test('reports ReadyForQuery transaction status while a transaction is open', async () => {
|
|
1576
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['ok'] }))
|
|
1577
|
+
const backend = new DoBackend(http.url, 'postgres', 'transaction-status-test')
|
|
1578
|
+
await backend.waitReady
|
|
1579
|
+
|
|
1580
|
+
const begin = await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
1581
|
+
const select = await backend.execProtocolRaw(msg(0x51, cstr('SELECT 1 AS ok')))
|
|
1582
|
+
const commit = await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
1583
|
+
|
|
1584
|
+
expect(readyForQueryStatuses(begin)).toEqual(['T'])
|
|
1585
|
+
expect(readyForQueryStatuses(select)).toEqual(['T'])
|
|
1586
|
+
expect(readyForQueryStatuses(commit)).toEqual(['I'])
|
|
1587
|
+
})
|
|
1588
|
+
|
|
1589
|
+
test('keeps extended-protocol Sync in transaction state after BEGIN', async () => {
|
|
1590
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1591
|
+
const backend = new DoBackend(
|
|
1592
|
+
http.url,
|
|
1593
|
+
'postgres',
|
|
1594
|
+
'extended-transaction-status-test'
|
|
1595
|
+
)
|
|
1596
|
+
await backend.waitReady
|
|
1597
|
+
|
|
1598
|
+
await backend.execProtocolRaw(parseMessage('BEGIN'))
|
|
1599
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1600
|
+
await backend.execProtocolRaw(executePortal())
|
|
1601
|
+
const sync = await backend.execProtocolRaw(msg(0x53, new Uint8Array(0)))
|
|
1602
|
+
|
|
1603
|
+
expect(readyForQueryStatuses(sync)).toEqual(['T'])
|
|
1604
|
+
await backend.execProtocolRaw(parseMessage('ROLLBACK'))
|
|
1605
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1606
|
+
await backend.execProtocolRaw(executePortal())
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
test('returns command completion for extended transaction starts', async () => {
|
|
1610
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1611
|
+
const backend = new DoBackend(http.url, 'postgres', 'extended-begin-test')
|
|
1612
|
+
await backend.waitReady
|
|
1613
|
+
|
|
1614
|
+
await backend.execProtocolRaw(parseMessage('BEGIN'))
|
|
1615
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1616
|
+
const result = await backend.execProtocolRaw(executePortal())
|
|
1617
|
+
|
|
1618
|
+
expect(messageTypes(result)).toEqual(['C'])
|
|
1619
|
+
expect(http.sqls.some((sql) => compactSQL(sql).startsWith('BEGIN'))).toBe(false)
|
|
1620
|
+
await backend.execProtocolRaw(parseMessage('ROLLBACK'))
|
|
1621
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1622
|
+
await backend.execProtocolRaw(executePortal())
|
|
1623
|
+
})
|
|
1624
|
+
|
|
1625
|
+
test('clears the rewrite cache on transaction rollback', async () => {
|
|
1626
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1627
|
+
const backend = new DoBackend(http.url, 'postgres', 'rewrite-cache-rollback-test')
|
|
1628
|
+
await backend.waitReady
|
|
1629
|
+
|
|
1630
|
+
await backend.exec('SELECT 1')
|
|
1631
|
+
const beforeCount = http.sqls.filter((sql) => compactSQL(sql) === 'SELECT 1').length
|
|
1632
|
+
|
|
1633
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
1634
|
+
await backend.execProtocolRaw(msg(0x51, cstr('ROLLBACK')))
|
|
1635
|
+
|
|
1636
|
+
await backend.exec('SELECT 1')
|
|
1637
|
+
// BEGIN / ROLLBACK don't reach the DO; the cache invalidation should
|
|
1638
|
+
// re-issue the SELECT.
|
|
1639
|
+
expect(http.sqls.filter((sql) => compactSQL(sql) === 'SELECT 1').length).toBe(
|
|
1640
|
+
beforeCount + 1
|
|
1641
|
+
)
|
|
1642
|
+
})
|
|
1643
|
+
|
|
1644
|
+
test('snapshots extended transaction writes for rollback', async () => {
|
|
1645
|
+
const http = await startDoHttp((sql) => {
|
|
1646
|
+
if (sql.includes('sqlite_master')) {
|
|
1647
|
+
return { rows: [{ ok: 1 }], columns: ['ok'] }
|
|
1648
|
+
}
|
|
1649
|
+
if (compactSQL(sql).includes('RETURNING *')) {
|
|
1650
|
+
return {
|
|
1651
|
+
rows: [{ clientGroupID: 'cg', clientID: 'client-a', lastMutationID: 1 }],
|
|
1652
|
+
columns: ['clientGroupID', 'clientID', 'lastMutationID'],
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
return { rows: [], columns: [] }
|
|
1656
|
+
})
|
|
1657
|
+
const backend = new DoBackend(http.url, 'postgres', 'extended-tx-session-test')
|
|
1658
|
+
await backend.waitReady
|
|
1659
|
+
|
|
1660
|
+
await backend.execProtocolRaw(parseMessage('BEGIN'))
|
|
1661
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1662
|
+
await backend.execProtocolRaw(executePortal())
|
|
1663
|
+
|
|
1664
|
+
await backend.execProtocolRaw(
|
|
1665
|
+
parseMessage(`
|
|
1666
|
+
INSERT INTO "chat_0"."clients" AS current
|
|
1667
|
+
("clientGroupID", "clientID", "lastMutationID")
|
|
1668
|
+
VALUES ('cg', 'client-a', 1)
|
|
1669
|
+
ON CONFLICT ("clientGroupID", "clientID")
|
|
1670
|
+
DO UPDATE SET "lastMutationID" = current."lastMutationID" + 1
|
|
1671
|
+
RETURNING "lastMutationID"
|
|
1672
|
+
`)
|
|
1673
|
+
)
|
|
1674
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1675
|
+
const result = await backend.execProtocolRaw(executePortal())
|
|
1676
|
+
expect(dataRowValues(result)).toEqual([['1']])
|
|
1677
|
+
|
|
1678
|
+
await backend.execProtocolRaw(parseMessage('ROLLBACK'))
|
|
1679
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1680
|
+
await backend.execProtocolRaw(executePortal())
|
|
1681
|
+
|
|
1682
|
+
expect(
|
|
1683
|
+
http.sqls.some((sql) =>
|
|
1684
|
+
/CREATE TABLE "_orez_tx_.*_chat_0_clients" AS SELECT \* FROM "chat_0_clients"/.test(
|
|
1685
|
+
sql
|
|
1686
|
+
)
|
|
1687
|
+
)
|
|
1688
|
+
).toBe(true)
|
|
1689
|
+
expect(http.requests.some((url) => url.pathname === '/batch')).toBe(true)
|
|
1690
|
+
expect(
|
|
1691
|
+
http.bodies.some(
|
|
1692
|
+
(body) =>
|
|
1693
|
+
Array.isArray(body.statements) &&
|
|
1694
|
+
body.statements.some((sql: string) =>
|
|
1695
|
+
/INSERT OR REPLACE INTO "chat_0_clients" SELECT \* FROM "_orez_tx_.*_chat_0_clients"/.test(
|
|
1696
|
+
sql
|
|
1697
|
+
)
|
|
1698
|
+
)
|
|
1699
|
+
)
|
|
1700
|
+
).toBe(true)
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1703
|
+
test('restores rollback snapshots without firing table triggers', async () => {
|
|
1704
|
+
const triggerSQL =
|
|
1705
|
+
'CREATE TRIGGER "clients_hash" AFTER INSERT ON "chat_0_clients" BEGIN SELECT md5(new."clientID"); END'
|
|
1706
|
+
const http = await startDoHttp((sql) => {
|
|
1707
|
+
const compact = compactSQL(sql)
|
|
1708
|
+
if (compact.includes("WHERE type = 'trigger'")) {
|
|
1709
|
+
return {
|
|
1710
|
+
rows: [{ name: 'clients_hash', sql: triggerSQL }],
|
|
1711
|
+
columns: ['name', 'sql'],
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (sql.includes('sqlite_master')) {
|
|
1715
|
+
return { rows: [{ ok: 1 }], columns: ['ok'] }
|
|
1716
|
+
}
|
|
1717
|
+
if (compact.includes('RETURNING *')) {
|
|
1718
|
+
return {
|
|
1719
|
+
rows: [{ clientGroupID: 'cg', clientID: 'client-a', lastMutationID: 1 }],
|
|
1720
|
+
columns: ['clientGroupID', 'clientID', 'lastMutationID'],
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return { rows: [], columns: [] }
|
|
1724
|
+
})
|
|
1725
|
+
const backend = new DoBackend(
|
|
1726
|
+
http.url,
|
|
1727
|
+
'postgres',
|
|
1728
|
+
'extended-tx-trigger-restore-test'
|
|
1729
|
+
)
|
|
1730
|
+
await backend.waitReady
|
|
1731
|
+
|
|
1732
|
+
await backend.execProtocolRaw(parseMessage('BEGIN'))
|
|
1733
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1734
|
+
await backend.execProtocolRaw(executePortal())
|
|
1735
|
+
|
|
1736
|
+
await backend.execProtocolRaw(
|
|
1737
|
+
parseMessage(`
|
|
1738
|
+
INSERT INTO "chat_0"."clients" AS current
|
|
1739
|
+
("clientGroupID", "clientID", "lastMutationID")
|
|
1740
|
+
VALUES ('cg', 'client-a', 1)
|
|
1741
|
+
ON CONFLICT ("clientGroupID", "clientID")
|
|
1742
|
+
DO UPDATE SET "lastMutationID" = current."lastMutationID" + 1
|
|
1743
|
+
RETURNING "lastMutationID"
|
|
1744
|
+
`)
|
|
1745
|
+
)
|
|
1746
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1747
|
+
await backend.execProtocolRaw(executePortal())
|
|
1748
|
+
|
|
1749
|
+
await backend.execProtocolRaw(parseMessage('ROLLBACK'))
|
|
1750
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1751
|
+
await backend.execProtocolRaw(executePortal())
|
|
1752
|
+
|
|
1753
|
+
const batch = http.bodies.find(
|
|
1754
|
+
(body) =>
|
|
1755
|
+
Array.isArray(body.statements) &&
|
|
1756
|
+
body.statements.some((sql: string) =>
|
|
1757
|
+
sql.includes('INSERT OR REPLACE INTO "chat_0_clients"')
|
|
1758
|
+
)
|
|
1759
|
+
)
|
|
1760
|
+
expect(batch).toBeTruthy()
|
|
1761
|
+
const statements = batch.statements.map(compactSQL)
|
|
1762
|
+
const dropIndex = statements.indexOf('DROP TRIGGER IF EXISTS "clients_hash"')
|
|
1763
|
+
const insertIndex = statements.findIndex((sql: string) =>
|
|
1764
|
+
sql.includes('INSERT OR REPLACE INTO "chat_0_clients"')
|
|
1765
|
+
)
|
|
1766
|
+
const recreateIndex = statements.indexOf(triggerSQL)
|
|
1767
|
+
expect(dropIndex).toBeGreaterThanOrEqual(0)
|
|
1768
|
+
expect(insertIndex).toBeGreaterThan(dropIndex)
|
|
1769
|
+
expect(recreateIndex).toBeGreaterThan(insertIndex)
|
|
1770
|
+
})
|
|
1771
|
+
|
|
1772
|
+
test('returns command completion for parser-skipped extended statements', async () => {
|
|
1773
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1774
|
+
const backend = new DoBackend(http.url, 'postgres', 'extended-noop-test')
|
|
1775
|
+
await backend.waitReady
|
|
1776
|
+
|
|
1777
|
+
await backend.execProtocolRaw(
|
|
1778
|
+
parseMessage(`
|
|
1779
|
+
CREATE OR REPLACE FUNCTION chat.set_permissions_hash()
|
|
1780
|
+
RETURNS TRIGGER AS $$
|
|
1781
|
+
BEGIN
|
|
1782
|
+
RETURN NEW;
|
|
1783
|
+
END;
|
|
1784
|
+
$$ LANGUAGE plpgsql
|
|
1785
|
+
`)
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
expect(messageTypes(await backend.execProtocolRaw(describeStatement()))).toEqual([
|
|
1789
|
+
't',
|
|
1790
|
+
'n',
|
|
1791
|
+
])
|
|
1792
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1793
|
+
const result = await backend.execProtocolRaw(executePortal())
|
|
1794
|
+
|
|
1795
|
+
expect(messageTypes(result)).toEqual(['C'])
|
|
1796
|
+
expect(http.sqls.some((sql) => sql.includes('CREATE OR REPLACE FUNCTION'))).toBe(
|
|
1797
|
+
false
|
|
1798
|
+
)
|
|
1799
|
+
})
|
|
1800
|
+
|
|
1801
|
+
test('rewrites DEFAULT values in inserts by omitting those columns', async () => {
|
|
1802
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1803
|
+
const backend = new DoBackend(http.url, 'postgres', 'insert-default-test')
|
|
1804
|
+
await backend.waitReady
|
|
1805
|
+
|
|
1806
|
+
await backend.exec(`
|
|
1807
|
+
INSERT INTO reaction(id, value, keyword, "createdAt", "updatedAt")
|
|
1808
|
+
VALUES ('1', 'wave', 'wave', DEFAULT, DEFAULT)
|
|
1809
|
+
ON CONFLICT DO NOTHING;
|
|
1810
|
+
`)
|
|
1811
|
+
|
|
1812
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
1813
|
+
expect(sent).toContain('INSERT INTO reaction')
|
|
1814
|
+
expect(sent).toContain('id')
|
|
1815
|
+
expect(sent).toContain('value')
|
|
1816
|
+
expect(sent).toContain('keyword')
|
|
1817
|
+
expect(sent).toContain('ON CONFLICT DO NOTHING')
|
|
1818
|
+
expect(sent).not.toContain('DEFAULT')
|
|
1819
|
+
expect(sent).not.toContain('createdAt')
|
|
1820
|
+
expect(sent).not.toContain('updatedAt')
|
|
1821
|
+
})
|
|
1822
|
+
|
|
1823
|
+
test('rewrites json_to_recordset range functions to SQLite json_each', async () => {
|
|
1824
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1825
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-recordset-test')
|
|
1826
|
+
await backend.waitReady
|
|
1827
|
+
|
|
1828
|
+
await backend.query(
|
|
1829
|
+
`
|
|
1830
|
+
INSERT INTO "chat_0/cvr_queries" (
|
|
1831
|
+
"clientGroupID",
|
|
1832
|
+
"queryHash",
|
|
1833
|
+
"clientAST",
|
|
1834
|
+
"queryName",
|
|
1835
|
+
"queryArgs",
|
|
1836
|
+
"patchVersion",
|
|
1837
|
+
"transformationHash",
|
|
1838
|
+
"transformationVersion",
|
|
1839
|
+
"internal",
|
|
1840
|
+
"deleted"
|
|
1841
|
+
)
|
|
1842
|
+
SELECT
|
|
1843
|
+
"clientGroupID",
|
|
1844
|
+
"queryHash",
|
|
1845
|
+
"clientAST",
|
|
1846
|
+
"queryName",
|
|
1847
|
+
CASE
|
|
1848
|
+
WHEN "queryArgs" IS NULL THEN NULL
|
|
1849
|
+
ELSE "queryArgs"::json
|
|
1850
|
+
END,
|
|
1851
|
+
"patchVersion",
|
|
1852
|
+
"transformationHash",
|
|
1853
|
+
"transformationVersion",
|
|
1854
|
+
"internal",
|
|
1855
|
+
"deleted"
|
|
1856
|
+
FROM json_to_recordset($1) AS x(
|
|
1857
|
+
"clientGroupID" TEXT,
|
|
1858
|
+
"queryHash" TEXT,
|
|
1859
|
+
"clientAST" JSONB,
|
|
1860
|
+
"queryName" TEXT,
|
|
1861
|
+
"queryArgs" TEXT,
|
|
1862
|
+
"patchVersion" TEXT,
|
|
1863
|
+
"transformationHash" TEXT,
|
|
1864
|
+
"transformationVersion" TEXT,
|
|
1865
|
+
"internal" BOOLEAN,
|
|
1866
|
+
"deleted" BOOLEAN
|
|
1867
|
+
)
|
|
1868
|
+
ON CONFLICT ("clientGroupID", "queryHash") DO UPDATE SET
|
|
1869
|
+
"clientAST" = excluded."clientAST",
|
|
1870
|
+
"queryName" = excluded."queryName"
|
|
1871
|
+
`,
|
|
1872
|
+
[
|
|
1873
|
+
JSON.stringify([
|
|
1874
|
+
{
|
|
1875
|
+
clientGroupID: 'cg1',
|
|
1876
|
+
queryHash: 'hash1',
|
|
1877
|
+
clientAST: { table: 'message' },
|
|
1878
|
+
queryName: 'messages',
|
|
1879
|
+
queryArgs: null,
|
|
1880
|
+
patchVersion: '01',
|
|
1881
|
+
transformationHash: 'th',
|
|
1882
|
+
transformationVersion: 'tv',
|
|
1883
|
+
internal: false,
|
|
1884
|
+
deleted: false,
|
|
1885
|
+
},
|
|
1886
|
+
]),
|
|
1887
|
+
]
|
|
1888
|
+
)
|
|
1889
|
+
|
|
1890
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
1891
|
+
expect(sent).toContain('FROM json_each(?)')
|
|
1892
|
+
expect(sent).toContain(`json_extract(value, '$.clientGroupID') AS "clientGroupID"`)
|
|
1893
|
+
expect(sent).toContain('WHERE 1 ON CONFLICT')
|
|
1894
|
+
expect(sent).not.toContain('json_to_recordset')
|
|
1895
|
+
})
|
|
1896
|
+
|
|
1897
|
+
test('infers JSON parameter oid for json_to_recordset inputs', async () => {
|
|
1898
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1899
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-recordset-oid-test')
|
|
1900
|
+
await backend.waitReady
|
|
1901
|
+
|
|
1902
|
+
await backend.execProtocolRaw(
|
|
1903
|
+
parseMessage(
|
|
1904
|
+
`
|
|
1905
|
+
SELECT *
|
|
1906
|
+
FROM json_to_recordset($1) AS x(
|
|
1907
|
+
"clientAST" JSONB,
|
|
1908
|
+
"deleted" BOOLEAN
|
|
1909
|
+
)
|
|
1910
|
+
`,
|
|
1911
|
+
'',
|
|
1912
|
+
[0]
|
|
1913
|
+
)
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
const describe = await backend.execProtocolRaw(describeStatement())
|
|
1917
|
+
|
|
1918
|
+
expect(parameterDescriptionOids(describe)).toEqual([114])
|
|
1919
|
+
})
|
|
1920
|
+
|
|
1921
|
+
test('normalizes PostgreSQL array literal params used as JSON documents', async () => {
|
|
1922
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
1923
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-param-normalize-test')
|
|
1924
|
+
await backend.waitReady
|
|
1925
|
+
|
|
1926
|
+
await backend.query(
|
|
1927
|
+
`
|
|
1928
|
+
SELECT "clientGroupID"
|
|
1929
|
+
FROM json_to_recordset($1) AS x("clientGroupID" TEXT)
|
|
1930
|
+
`,
|
|
1931
|
+
['{"{\\"clientGroupID\\":\\"cg1\\"}"}']
|
|
1932
|
+
)
|
|
1933
|
+
|
|
1934
|
+
expect(compactSQL(http.sqls.at(-1) || '')).toContain('FROM json_each(?)')
|
|
1935
|
+
expect(http.params.at(-1)).toEqual(['[{"clientGroupID":"cg1"}]'])
|
|
1936
|
+
})
|
|
1937
|
+
|
|
1938
|
+
test('rewrites Zero timestamp and row JSON helpers for SQLite', async () => {
|
|
1939
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['zql_result'] }))
|
|
1940
|
+
const backend = new DoBackend(http.url, 'postgres', 'zql-helper-rewrite-test')
|
|
1941
|
+
await backend.waitReady
|
|
1942
|
+
|
|
1943
|
+
await backend.query(`
|
|
1944
|
+
SELECT row_to_json(zql_root) AS zql_result
|
|
1945
|
+
FROM (
|
|
1946
|
+
SELECT
|
|
1947
|
+
"userPublic_0".id AS id,
|
|
1948
|
+
EXTRACT(EPOCH FROM "userPublic_0"."joinedAt") * 1000 AS "joinedAt"
|
|
1949
|
+
FROM "userPublic" AS "userPublic_0"
|
|
1950
|
+
) zql_root
|
|
1951
|
+
`)
|
|
1952
|
+
|
|
1953
|
+
const select = compactSQL(http.sqls.at(-1) || '')
|
|
1954
|
+
expect(select).toContain(`"json_object"('id'`)
|
|
1955
|
+
expect(select).toContain(`zql_root.id`)
|
|
1956
|
+
expect(select).toContain(`'joinedAt'`)
|
|
1957
|
+
expect(select).toContain(`zql_root."joinedAt"`)
|
|
1958
|
+
expect(select).toContain(`strftime('%s', "userPublic_0"."joinedAt") * 1000`)
|
|
1959
|
+
expect(select).not.toContain('row_to_json')
|
|
1960
|
+
expect(select).not.toContain('EXTRACT')
|
|
1961
|
+
|
|
1962
|
+
await backend.query(
|
|
1963
|
+
`
|
|
1964
|
+
INSERT INTO "userPublic" ("joinedAt")
|
|
1965
|
+
VALUES (to_timestamp($1::text::numeric / 1000.0) AT TIME ZONE 'UTC')
|
|
1966
|
+
`,
|
|
1967
|
+
[123000]
|
|
1968
|
+
)
|
|
1969
|
+
|
|
1970
|
+
const insert = compactSQL(http.sqls.at(-1) || '')
|
|
1971
|
+
expect(insert).toContain(`datetime(? / 1000.0, 'unixepoch')`)
|
|
1972
|
+
expect(insert).not.toContain('to_timestamp')
|
|
1973
|
+
expect(insert).not.toContain('AT TIME ZONE')
|
|
1974
|
+
})
|
|
1975
|
+
|
|
1976
|
+
test('respects explicit text casts on JSON result expressions', async () => {
|
|
1977
|
+
const http = await startDoHttp(() => ({
|
|
1978
|
+
rows: [{ zql_result: [{ id: 'u1' }] }],
|
|
1979
|
+
columns: ['zql_result'],
|
|
1980
|
+
}))
|
|
1981
|
+
const backend = new DoBackend(http.url, 'postgres', 'zql-text-result-test')
|
|
1982
|
+
await backend.waitReady
|
|
1983
|
+
|
|
1984
|
+
await backend.execProtocolRaw(
|
|
1985
|
+
parseMessage(`
|
|
1986
|
+
SELECT COALESCE(json_agg(row_to_json(zql_root)), '[]'::json)::text AS zql_result
|
|
1987
|
+
FROM (
|
|
1988
|
+
SELECT id
|
|
1989
|
+
FROM "userPublic"
|
|
1990
|
+
) zql_root
|
|
1991
|
+
`)
|
|
1992
|
+
)
|
|
1993
|
+
await backend.execProtocolRaw(bindStatement())
|
|
1994
|
+
const result = await backend.execProtocolRaw(executePortal())
|
|
1995
|
+
|
|
1996
|
+
expect(rowDescriptionOids(result)).toMatchObject({ zql_result: 25 })
|
|
1997
|
+
expect(dataRowValues(result)).toEqual([[`[{"id":"u1"}]`]])
|
|
1998
|
+
})
|
|
1999
|
+
|
|
2000
|
+
test('flushes simple-protocol transaction writes before extended statements', async () => {
|
|
2001
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2002
|
+
const backend = new DoBackend(http.url, 'postgres', 'mixed-protocol-test')
|
|
2003
|
+
await backend.waitReady
|
|
2004
|
+
|
|
2005
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
2006
|
+
await backend.execProtocolRaw(
|
|
2007
|
+
msg(0x51, cstr('CREATE TABLE reaction (id TEXT PRIMARY KEY)'))
|
|
2008
|
+
)
|
|
2009
|
+
await backend.execProtocolRaw(parseMessage("INSERT INTO reaction(id) VALUES ('1')"))
|
|
2010
|
+
await backend.execProtocolRaw(bindStatement())
|
|
2011
|
+
await backend.execProtocolRaw(executePortal())
|
|
2012
|
+
|
|
2013
|
+
const createIndex = http.sqls.findIndex((sql) =>
|
|
2014
|
+
/create\s+table\s+(if\s+not\s+exists\s+)?reaction/i.test(sql)
|
|
2015
|
+
)
|
|
2016
|
+
const insertIndex = http.sqls.findIndex((sql) =>
|
|
2017
|
+
/insert\s+into\s+reaction/i.test(sql)
|
|
2018
|
+
)
|
|
2019
|
+
expect(createIndex).toBeGreaterThanOrEqual(0)
|
|
2020
|
+
expect(insertIndex).toBeGreaterThan(createIndex)
|
|
2021
|
+
await backend.execProtocolRaw(msg(0x51, cstr('ROLLBACK')))
|
|
2022
|
+
})
|
|
2023
|
+
|
|
2024
|
+
test('sends extended-protocol params as bound DO parameters', async () => {
|
|
2025
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2026
|
+
const backend = new DoBackend(http.url, 'postgres', 'param-inline-test')
|
|
2027
|
+
await backend.waitReady
|
|
2028
|
+
|
|
2029
|
+
await backend.execProtocolRaw(
|
|
2030
|
+
parseMessage('INSERT INTO docs(id, content) VALUES ($1, $2)')
|
|
2031
|
+
)
|
|
2032
|
+
await backend.execProtocolRaw(
|
|
2033
|
+
bindStatementParams(['doc_start-doc/intro', "body keeps $1 and 'quote'"])
|
|
2034
|
+
)
|
|
2035
|
+
await backend.execProtocolRaw(executePortal())
|
|
2036
|
+
|
|
2037
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
2038
|
+
expect(sent).toContain('VALUES ( ?, ? )')
|
|
2039
|
+
expect(http.params.at(-1)).toEqual([
|
|
2040
|
+
'doc_start-doc/intro',
|
|
2041
|
+
"body keeps $1 and 'quote'",
|
|
2042
|
+
])
|
|
2043
|
+
expect(sent).not.toContain("body keeps 'doc_start-doc/intro'")
|
|
2044
|
+
})
|
|
2045
|
+
|
|
2046
|
+
test('rewrites PG ALL array comparisons to SQLite json_each subqueries', async () => {
|
|
2047
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2048
|
+
const backend = new DoBackend(http.url, 'postgres', 'all-array-test')
|
|
2049
|
+
await backend.waitReady
|
|
2050
|
+
|
|
2051
|
+
await backend.execProtocolRaw(
|
|
2052
|
+
parseMessage(
|
|
2053
|
+
`DELETE FROM search_documents
|
|
2054
|
+
WHERE id LIKE 'doc_start-doc/%'
|
|
2055
|
+
AND type = 'doc'
|
|
2056
|
+
AND id != ALL($1)`
|
|
2057
|
+
)
|
|
2058
|
+
)
|
|
2059
|
+
await backend.execProtocolRaw(
|
|
2060
|
+
bindStatementParams(['{"doc_start-doc/intro","doc_start-doc/api"}'])
|
|
2061
|
+
)
|
|
2062
|
+
await backend.execProtocolRaw(executePortal())
|
|
2063
|
+
|
|
2064
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
2065
|
+
expect(sent).toContain('NOT (id IN (SELECT value FROM json_each')
|
|
2066
|
+
expect(sent).toContain('json_each(?)')
|
|
2067
|
+
expect(http.params.at(-1)).toEqual(['["doc_start-doc/intro","doc_start-doc/api"]'])
|
|
2068
|
+
expect(sent).not.toContain('ALL')
|
|
2069
|
+
})
|
|
2070
|
+
|
|
2071
|
+
test('rewrites JSONB array element filters to SQLite json_each', async () => {
|
|
2072
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['value'] }))
|
|
2073
|
+
const backend = new DoBackend(http.url, 'postgres', 'jsonb-array-elements-test')
|
|
2074
|
+
await backend.waitReady
|
|
2075
|
+
|
|
2076
|
+
await backend.execProtocolRaw(
|
|
2077
|
+
parseMessage(
|
|
2078
|
+
`SELECT value
|
|
2079
|
+
FROM jsonb_array_elements_text($1::text::jsonb)`
|
|
2080
|
+
)
|
|
2081
|
+
)
|
|
2082
|
+
await backend.execProtocolRaw(bindStatementParams(['{"data","chat"}']))
|
|
2083
|
+
await backend.execProtocolRaw(executePortal())
|
|
2084
|
+
|
|
2085
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
2086
|
+
expect(sent).toContain('FROM json_each(?)')
|
|
2087
|
+
expect(http.params.at(-1)).toEqual(['["data","chat"]'])
|
|
2088
|
+
expect(sent).not.toContain('jsonb_array_elements_text')
|
|
2089
|
+
})
|
|
2090
|
+
|
|
2091
|
+
test('collapses json_each over ARRAY subqueries from plural filters', async () => {
|
|
2092
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['id'] }))
|
|
2093
|
+
const backend = new DoBackend(http.url, 'postgres', 'jsonb-array-filter-test')
|
|
2094
|
+
await backend.waitReady
|
|
2095
|
+
|
|
2096
|
+
await backend.execProtocolRaw(
|
|
2097
|
+
parseMessage(
|
|
2098
|
+
`SELECT id
|
|
2099
|
+
FROM app
|
|
2100
|
+
WHERE id IN (
|
|
2101
|
+
SELECT value
|
|
2102
|
+
FROM jsonb_array_elements_text(
|
|
2103
|
+
ARRAY(
|
|
2104
|
+
SELECT value::text
|
|
2105
|
+
FROM jsonb_array_elements_text($1::text::jsonb)
|
|
2106
|
+
)
|
|
2107
|
+
)
|
|
2108
|
+
)`
|
|
2109
|
+
)
|
|
2110
|
+
)
|
|
2111
|
+
await backend.execProtocolRaw(bindStatementParams(['{"data","chat"}']))
|
|
2112
|
+
await backend.execProtocolRaw(executePortal())
|
|
2113
|
+
|
|
2114
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
2115
|
+
expect(sent).toContain('IN (SELECT value FROM json_each(?))')
|
|
2116
|
+
expect(sent).not.toContain('ARRAY')
|
|
2117
|
+
expect(http.params.at(-1)).toEqual(['["data","chat"]'])
|
|
2118
|
+
})
|
|
2119
|
+
|
|
2120
|
+
test('rewrites PG array column declarations to SQLite text columns', async () => {
|
|
2121
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2122
|
+
const backend = new DoBackend(http.url, 'postgres', 'array-column-test')
|
|
2123
|
+
await backend.waitReady
|
|
2124
|
+
|
|
2125
|
+
await backend.exec(`
|
|
2126
|
+
CREATE TABLE "chat_0"."shardConfig" (
|
|
2127
|
+
"publications" TEXT[] NOT NULL,
|
|
2128
|
+
"ddlDetection" BOOL NOT NULL
|
|
2129
|
+
);
|
|
2130
|
+
`)
|
|
2131
|
+
|
|
2132
|
+
const sent = compactSQL(
|
|
2133
|
+
sqlContaining(http.sqls, 'CREATE TABLE IF NOT EXISTS "chat_0_shardConfig"')
|
|
2134
|
+
)
|
|
2135
|
+
expect(sent).toContain('CREATE TABLE IF NOT EXISTS "chat_0_shardConfig"')
|
|
2136
|
+
expect(sent).toContain('publications text NOT NULL')
|
|
2137
|
+
expect(sent).not.toContain('text[]')
|
|
2138
|
+
})
|
|
2139
|
+
|
|
2140
|
+
test('rewrites PG array constructors to JSON text literals', async () => {
|
|
2141
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2142
|
+
const backend = new DoBackend(http.url, 'postgres', 'array-constructor-test')
|
|
2143
|
+
await backend.waitReady
|
|
2144
|
+
|
|
2145
|
+
await backend.exec(`
|
|
2146
|
+
INSERT INTO "chat_0"."shardConfig" ("publications", "ddlDetection")
|
|
2147
|
+
VALUES (ARRAY['zero_chat', '_zero_metadata'], false);
|
|
2148
|
+
`)
|
|
2149
|
+
|
|
2150
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
2151
|
+
expect(sent).toContain(`'["zero_chat","_zero_metadata"]'`)
|
|
2152
|
+
expect(sent).not.toContain('ARRAY')
|
|
2153
|
+
})
|
|
2154
|
+
|
|
2155
|
+
test('rewrites PG sequences to readable SQLite sequence tables', async () => {
|
|
2156
|
+
const http = await startDoHttp((sql) => {
|
|
2157
|
+
if (compactSQL(sql).startsWith('SELECT')) {
|
|
2158
|
+
return {
|
|
2159
|
+
rows: [{ last_value: 1, is_called: 0 }],
|
|
2160
|
+
columns: ['last_value', 'is_called'],
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
return { rows: [], columns: [] }
|
|
2164
|
+
})
|
|
2165
|
+
const backend = new DoBackend(http.url, 'postgres', 'sequence-table-test')
|
|
2166
|
+
await backend.waitReady
|
|
2167
|
+
|
|
2168
|
+
await backend.exec('CREATE SEQUENCE IF NOT EXISTS _orez._zero_watermark')
|
|
2169
|
+
const result = await backend.execProtocolRaw(
|
|
2170
|
+
msg(0x51, cstr('SELECT last_value, is_called FROM _orez._zero_watermark'))
|
|
2171
|
+
)
|
|
2172
|
+
|
|
2173
|
+
const sent = compactSQL(http.sqls.join('; '))
|
|
2174
|
+
expect(sent).toContain('CREATE TABLE IF NOT EXISTS "_orez___zero_watermark"')
|
|
2175
|
+
expect(sent).toContain('INSERT OR IGNORE INTO "_orez___zero_watermark"')
|
|
2176
|
+
expect(rowDescriptionOids(result)).toMatchObject({
|
|
2177
|
+
last_value: 20,
|
|
2178
|
+
is_called: 16,
|
|
2179
|
+
})
|
|
2180
|
+
expect(dataRowValues(result)).toEqual([['1', 'f']])
|
|
2181
|
+
})
|
|
2182
|
+
|
|
2183
|
+
test('sends high-level query params as bound DO parameters', async () => {
|
|
2184
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2185
|
+
const backend = new DoBackend(http.url, 'postgres', 'query-param-test')
|
|
2186
|
+
await backend.waitReady
|
|
2187
|
+
|
|
2188
|
+
await backend.query(
|
|
2189
|
+
`INSERT INTO _orez._zero_replication_slots (
|
|
2190
|
+
slot_name,
|
|
2191
|
+
restart_lsn,
|
|
2192
|
+
confirmed_flush_lsn
|
|
2193
|
+
) VALUES ($1, $2, $3)`,
|
|
2194
|
+
['slot_1', '0/16B6C50', '0/16B6C50']
|
|
2195
|
+
)
|
|
2196
|
+
|
|
2197
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
2198
|
+
expect(sent).toContain('VALUES ( ?, ?, ? )')
|
|
2199
|
+
expect(sent).not.toContain('$1')
|
|
2200
|
+
expect(http.params.at(-1)).toEqual(['slot_1', '0/16B6C50', '0/16B6C50'])
|
|
2201
|
+
})
|
|
2202
|
+
|
|
2203
|
+
test('returns JSON and boolean type metadata for rewritten SQLite rows', async () => {
|
|
2204
|
+
const http = await startDoHttp((sql) => {
|
|
2205
|
+
if (compactSQL(sql).startsWith('SELECT')) {
|
|
2206
|
+
return {
|
|
2207
|
+
rows: [
|
|
2208
|
+
{
|
|
2209
|
+
publications: '["_chat_metadata_0","zero_chat"]',
|
|
2210
|
+
ddlDetection: 0,
|
|
2211
|
+
},
|
|
2212
|
+
],
|
|
2213
|
+
columns: ['publications', 'ddlDetection'],
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
return { rows: [], columns: [] }
|
|
2217
|
+
})
|
|
2218
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-field-oid-test')
|
|
2219
|
+
await backend.waitReady
|
|
2220
|
+
|
|
2221
|
+
await backend.exec(`
|
|
2222
|
+
CREATE TABLE "_zero"."shardConfig" (
|
|
2223
|
+
"publications" TEXT[] NOT NULL,
|
|
2224
|
+
"ddlDetection" BOOL NOT NULL
|
|
2225
|
+
);
|
|
2226
|
+
`)
|
|
2227
|
+
|
|
2228
|
+
const result = await backend.execProtocolRaw(
|
|
2229
|
+
msg(0x51, cstr('SELECT "publications", "ddlDetection" FROM "_zero_shardConfig"'))
|
|
2230
|
+
)
|
|
2231
|
+
|
|
2232
|
+
expect(messageTypes(result)).toEqual(['T', 'D', 'C', 'Z'])
|
|
2233
|
+
expect(rowDescriptionOids(result)).toMatchObject({
|
|
2234
|
+
publications: 114,
|
|
2235
|
+
ddlDetection: 16,
|
|
2236
|
+
})
|
|
2237
|
+
expect(dataRowValues(result)).toEqual([['["_chat_metadata_0","zero_chat"]', 'f']])
|
|
2238
|
+
})
|
|
2239
|
+
|
|
2240
|
+
test('falls back to zero-cache metadata column types when durable metadata is absent', async () => {
|
|
2241
|
+
const http = await startDoHttp((sql) => {
|
|
2242
|
+
const compact = compactSQL(sql)
|
|
2243
|
+
if (
|
|
2244
|
+
compact.includes('_orez_pg_metadata') ||
|
|
2245
|
+
compact.includes('sqlite_master') ||
|
|
2246
|
+
compact.startsWith('PRAGMA')
|
|
2247
|
+
) {
|
|
2248
|
+
return { rows: [], columns: [] }
|
|
2249
|
+
}
|
|
2250
|
+
if (compact.startsWith('SELECT')) {
|
|
2251
|
+
return {
|
|
2252
|
+
rows: [
|
|
2253
|
+
{
|
|
2254
|
+
slot: 'slot_1',
|
|
2255
|
+
version: '01',
|
|
2256
|
+
publications: '["_chat_metadata_0","zero_chat"]',
|
|
2257
|
+
ddlDetection: 1,
|
|
2258
|
+
},
|
|
2259
|
+
],
|
|
2260
|
+
columns: ['slot', 'version', 'publications', 'ddlDetection'],
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
return { rows: [], columns: [] }
|
|
2264
|
+
})
|
|
2265
|
+
const backend = new DoBackend(http.url, 'postgres', 'missing-metadata-oid-test')
|
|
2266
|
+
await backend.waitReady
|
|
2267
|
+
|
|
2268
|
+
const result = await backend.execProtocolRaw(
|
|
2269
|
+
msg(
|
|
2270
|
+
0x51,
|
|
2271
|
+
cstr(`
|
|
2272
|
+
SELECT * FROM "chat_0".replicas
|
|
2273
|
+
JOIN "chat_0"."shardConfig" ON true
|
|
2274
|
+
WHERE version = '01'
|
|
2275
|
+
`)
|
|
2276
|
+
)
|
|
2277
|
+
)
|
|
2278
|
+
|
|
2279
|
+
expect(rowDescriptionOids(result)).toMatchObject({
|
|
2280
|
+
publications: 114,
|
|
2281
|
+
ddlDetection: 16,
|
|
2282
|
+
})
|
|
2283
|
+
expect(dataRowValues(result)).toEqual([
|
|
2284
|
+
['slot_1', '01', '["_chat_metadata_0","zero_chat"]', 't'],
|
|
2285
|
+
])
|
|
2286
|
+
})
|
|
2287
|
+
|
|
2288
|
+
test('resolves JSON metadata for columns from joined tables', async () => {
|
|
2289
|
+
const http = await startDoHttp((sql) => {
|
|
2290
|
+
if (compactSQL(sql).startsWith('SELECT')) {
|
|
2291
|
+
return {
|
|
2292
|
+
rows: [
|
|
2293
|
+
{
|
|
2294
|
+
slot: 'slot_1',
|
|
2295
|
+
version: '01',
|
|
2296
|
+
publications: '["_chat_metadata_0","zero_chat"]',
|
|
2297
|
+
ddlDetection: 1,
|
|
2298
|
+
},
|
|
2299
|
+
],
|
|
2300
|
+
columns: ['slot', 'version', 'publications', 'ddlDetection'],
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return { rows: [], columns: [] }
|
|
2304
|
+
})
|
|
2305
|
+
const backend = new DoBackend(http.url, 'postgres', 'join-field-oid-test')
|
|
2306
|
+
await backend.waitReady
|
|
2307
|
+
|
|
2308
|
+
await backend.exec(`
|
|
2309
|
+
CREATE TABLE "chat_0".replicas (
|
|
2310
|
+
"slot" text PRIMARY KEY,
|
|
2311
|
+
"version" text NOT NULL
|
|
2312
|
+
);
|
|
2313
|
+
CREATE TABLE "chat_0"."shardConfig" (
|
|
2314
|
+
"publications" TEXT[] NOT NULL,
|
|
2315
|
+
"ddlDetection" BOOL NOT NULL
|
|
2316
|
+
);
|
|
2317
|
+
CREATE TABLE "chat_0/cdc"."replicationConfig" (
|
|
2318
|
+
"publications" text NOT NULL
|
|
2319
|
+
);
|
|
2320
|
+
`)
|
|
2321
|
+
|
|
2322
|
+
const result = await backend.execProtocolRaw(
|
|
2323
|
+
msg(
|
|
2324
|
+
0x51,
|
|
2325
|
+
cstr(`
|
|
2326
|
+
SELECT * FROM "chat_0".replicas
|
|
2327
|
+
JOIN "chat_0"."shardConfig" ON true
|
|
2328
|
+
WHERE version = '01'
|
|
2329
|
+
`)
|
|
2330
|
+
)
|
|
2331
|
+
)
|
|
2332
|
+
|
|
2333
|
+
expect(rowDescriptionOids(result)).toMatchObject({
|
|
2334
|
+
publications: 114,
|
|
2335
|
+
ddlDetection: 16,
|
|
2336
|
+
})
|
|
2337
|
+
expect(dataRowValues(result)).toEqual([
|
|
2338
|
+
['slot_1', '01', '["_chat_metadata_0","zero_chat"]', 't'],
|
|
2339
|
+
])
|
|
2340
|
+
|
|
2341
|
+
await backend.execProtocolRaw(
|
|
2342
|
+
parseMessage(
|
|
2343
|
+
`
|
|
2344
|
+
SELECT * FROM "chat_0".replicas
|
|
2345
|
+
JOIN "chat_0"."shardConfig" ON true
|
|
2346
|
+
WHERE version = $1
|
|
2347
|
+
`,
|
|
2348
|
+
'replica-at-version',
|
|
2349
|
+
[25]
|
|
2350
|
+
)
|
|
2351
|
+
)
|
|
2352
|
+
const described = await backend.execProtocolRaw(
|
|
2353
|
+
describeStatement('replica-at-version')
|
|
2354
|
+
)
|
|
2355
|
+
expect(rowDescriptionOids(described)).toMatchObject({
|
|
2356
|
+
publications: 114,
|
|
2357
|
+
ddlDetection: 16,
|
|
2358
|
+
})
|
|
2359
|
+
})
|
|
2360
|
+
|
|
2361
|
+
test('hydrates persisted PG column metadata for existing DO tables', async () => {
|
|
2362
|
+
const metadataRows: Record<string, unknown>[] = []
|
|
2363
|
+
const http = await startDoHttp((sql) => {
|
|
2364
|
+
const compact = compactSQL(sql)
|
|
2365
|
+
if (compact.startsWith('CREATE TABLE IF NOT EXISTS "_orez_pg_metadata"')) {
|
|
2366
|
+
return { rows: [], columns: [] }
|
|
2367
|
+
}
|
|
2368
|
+
if (compact.startsWith('SELECT kind, key, subkey, value FROM')) {
|
|
2369
|
+
return {
|
|
2370
|
+
rows: metadataRows,
|
|
2371
|
+
columns: ['kind', 'key', 'subkey', 'value'],
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
if (compact.startsWith('DELETE FROM "_orez_pg_metadata"')) {
|
|
2375
|
+
metadataRows.length = 0
|
|
2376
|
+
return { rows: [], columns: [] }
|
|
2377
|
+
}
|
|
2378
|
+
if (compact.startsWith('INSERT OR REPLACE INTO "_orez_pg_metadata"')) {
|
|
2379
|
+
return { rows: [], columns: [] }
|
|
2380
|
+
}
|
|
2381
|
+
return { rows: [], columns: [] }
|
|
2382
|
+
})
|
|
2383
|
+
|
|
2384
|
+
const first = new DoBackend(http.url, 'postgres', 'durable-metadata-test')
|
|
2385
|
+
await first.waitReady
|
|
2386
|
+
await first.exec(`
|
|
2387
|
+
CREATE TABLE "chat_0".replicas (
|
|
2388
|
+
"slot" text PRIMARY KEY,
|
|
2389
|
+
"version" text NOT NULL
|
|
2390
|
+
);
|
|
2391
|
+
CREATE TABLE "chat_0"."shardConfig" (
|
|
2392
|
+
"publications" TEXT[] NOT NULL,
|
|
2393
|
+
"ddlDetection" BOOL NOT NULL
|
|
2394
|
+
);
|
|
2395
|
+
`)
|
|
2396
|
+
|
|
2397
|
+
for (const body of http.bodies) {
|
|
2398
|
+
if (
|
|
2399
|
+
typeof body.sql === 'string' &&
|
|
2400
|
+
compactSQL(body.sql).startsWith('INSERT OR REPLACE INTO "_orez_pg_metadata"')
|
|
2401
|
+
) {
|
|
2402
|
+
const [kind, key, subkey, value] = body.params
|
|
2403
|
+
metadataRows.push({ kind, key, subkey, value })
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
const second = new DoBackend(http.url, 'postgres', 'durable-metadata-test')
|
|
2408
|
+
await second.waitReady
|
|
2409
|
+
await second.execProtocolRaw(
|
|
2410
|
+
parseMessage(
|
|
2411
|
+
`
|
|
2412
|
+
SELECT * FROM "chat_0".replicas
|
|
2413
|
+
JOIN "chat_0"."shardConfig" ON true
|
|
2414
|
+
WHERE version = $1
|
|
2415
|
+
`,
|
|
2416
|
+
'replica-at-version',
|
|
2417
|
+
[25]
|
|
2418
|
+
)
|
|
2419
|
+
)
|
|
2420
|
+
const described = await second.execProtocolRaw(
|
|
2421
|
+
describeStatement('replica-at-version')
|
|
2422
|
+
)
|
|
2423
|
+
|
|
2424
|
+
expect(rowDescriptionOids(described)).toMatchObject({
|
|
2425
|
+
publications: 114,
|
|
2426
|
+
ddlDetection: 16,
|
|
2427
|
+
})
|
|
2428
|
+
})
|
|
2429
|
+
|
|
2430
|
+
test('repairs internal metadata publication from shardConfig rows', async () => {
|
|
2431
|
+
const http = await startDoHttp((sql) => {
|
|
2432
|
+
const compact = compactSQL(sql)
|
|
2433
|
+
if (compact.startsWith('CREATE TABLE IF NOT EXISTS "_orez_pg_metadata"')) {
|
|
2434
|
+
return { rows: [], columns: [] }
|
|
2435
|
+
}
|
|
2436
|
+
if (compact.startsWith('SELECT kind, key, subkey, value FROM')) {
|
|
2437
|
+
return { rows: [], columns: ['kind', 'key', 'subkey', 'value'] }
|
|
2438
|
+
}
|
|
2439
|
+
if (compact.startsWith('DELETE FROM "_orez_pg_metadata"')) {
|
|
2440
|
+
return { rows: [], columns: [] }
|
|
2441
|
+
}
|
|
2442
|
+
if (compact.startsWith('INSERT OR REPLACE INTO "_orez_pg_metadata"')) {
|
|
2443
|
+
return { rows: [], columns: [] }
|
|
2444
|
+
}
|
|
2445
|
+
if (compact.includes('sqlite_master')) {
|
|
2446
|
+
return {
|
|
2447
|
+
rows: [
|
|
2448
|
+
{
|
|
2449
|
+
name: 'todo_permissions',
|
|
2450
|
+
sql: 'CREATE TABLE todo_permissions (permissions text, hash text)',
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
name: 'todo_0_clients',
|
|
2454
|
+
sql: 'CREATE TABLE todo_0_clients (clientGroupID text, clientID text)',
|
|
2455
|
+
},
|
|
2456
|
+
{
|
|
2457
|
+
name: 'todo_0_mutations',
|
|
2458
|
+
sql: 'CREATE TABLE todo_0_mutations (clientGroupID text, mutation text)',
|
|
2459
|
+
},
|
|
2460
|
+
{
|
|
2461
|
+
name: 'todo_0_shardConfig',
|
|
2462
|
+
sql: 'CREATE TABLE todo_0_shardConfig (publications text)',
|
|
2463
|
+
},
|
|
2464
|
+
],
|
|
2465
|
+
columns: ['name', 'sql'],
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
if (compact === 'SELECT publications FROM "todo_0_shardConfig" LIMIT 1') {
|
|
2469
|
+
return {
|
|
2470
|
+
rows: [{ publications: '["_todo_metadata_0","zero_todo"]' }],
|
|
2471
|
+
columns: ['publications'],
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
if (compact.includes('PRAGMA table_info("todo_permissions")')) {
|
|
2475
|
+
return {
|
|
2476
|
+
rows: [
|
|
2477
|
+
{ cid: 0, name: 'permissions', type: 'text', notnull: 0, pk: 0 },
|
|
2478
|
+
{ cid: 1, name: 'hash', type: 'text', notnull: 0, pk: 0 },
|
|
2479
|
+
],
|
|
2480
|
+
columns: ['cid', 'name', 'type', 'notnull', 'pk'],
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
if (compact.includes('PRAGMA table_info("todo_0_clients")')) {
|
|
2484
|
+
return {
|
|
2485
|
+
rows: [
|
|
2486
|
+
{ cid: 0, name: 'clientGroupID', type: 'text', notnull: 1, pk: 1 },
|
|
2487
|
+
{ cid: 1, name: 'clientID', type: 'text', notnull: 1, pk: 2 },
|
|
2488
|
+
],
|
|
2489
|
+
columns: ['cid', 'name', 'type', 'notnull', 'pk'],
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
if (compact.includes('PRAGMA table_info("todo_0_mutations")')) {
|
|
2493
|
+
return {
|
|
2494
|
+
rows: [
|
|
2495
|
+
{ cid: 0, name: 'clientGroupID', type: 'text', notnull: 1, pk: 1 },
|
|
2496
|
+
{ cid: 1, name: 'mutation', type: 'text', notnull: 1, pk: 0 },
|
|
2497
|
+
],
|
|
2498
|
+
columns: ['cid', 'name', 'type', 'notnull', 'pk'],
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
return { rows: [], columns: [] }
|
|
2502
|
+
})
|
|
2503
|
+
const backend = new DoBackend(http.url, 'postgres', 'metadata-publication-test')
|
|
2504
|
+
await backend.waitReady
|
|
2505
|
+
|
|
2506
|
+
expect(
|
|
2507
|
+
http.bodies.some(
|
|
2508
|
+
(body) =>
|
|
2509
|
+
body.params?.[0] === 'publication' && body.params?.[1] === '_todo_metadata_0'
|
|
2510
|
+
)
|
|
2511
|
+
).toBe(true)
|
|
2512
|
+
|
|
2513
|
+
const publications = await (backend as any).handleCatalogQuery(`
|
|
2514
|
+
SELECT pubname FROM pg_publication
|
|
2515
|
+
WHERE pubname IN ('_todo_metadata_0', 'zero_todo')
|
|
2516
|
+
`)
|
|
2517
|
+
expect(publications.rows).toEqual([{ pubname: '_todo_metadata_0' }])
|
|
2518
|
+
|
|
2519
|
+
const publicationTables = await (backend as any).handleCatalogQuery(`
|
|
2520
|
+
SELECT pubname, schemaname, tablename
|
|
2521
|
+
FROM pg_publication_tables
|
|
2522
|
+
WHERE pubname IN ('_todo_metadata_0')
|
|
2523
|
+
`)
|
|
2524
|
+
expect(publicationTables.rows).toEqual(
|
|
2525
|
+
expect.arrayContaining([
|
|
2526
|
+
{
|
|
2527
|
+
pubname: '_todo_metadata_0',
|
|
2528
|
+
schemaname: 'todo',
|
|
2529
|
+
tablename: 'permissions',
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
pubname: '_todo_metadata_0',
|
|
2533
|
+
schemaname: 'todo_0',
|
|
2534
|
+
tablename: 'clients',
|
|
2535
|
+
},
|
|
2536
|
+
{
|
|
2537
|
+
pubname: '_todo_metadata_0',
|
|
2538
|
+
schemaname: 'todo_0',
|
|
2539
|
+
tablename: 'mutations',
|
|
2540
|
+
},
|
|
2541
|
+
])
|
|
2542
|
+
)
|
|
2543
|
+
expect(publicationTables.rows).toHaveLength(3)
|
|
2544
|
+
})
|
|
2545
|
+
|
|
2546
|
+
test('repairing internal metadata publication preserves app publications', async () => {
|
|
2547
|
+
const metadataRows: Record<string, unknown>[] = [
|
|
2548
|
+
{
|
|
2549
|
+
kind: 'publication',
|
|
2550
|
+
key: 'zero_todo',
|
|
2551
|
+
subkey: '',
|
|
2552
|
+
value: JSON.stringify({
|
|
2553
|
+
name: 'zero_todo',
|
|
2554
|
+
allTables: false,
|
|
2555
|
+
schemas: [],
|
|
2556
|
+
tables: [['todo', { table: 'todo', schema: 'public', tableName: 'todo' }]],
|
|
2557
|
+
}),
|
|
2558
|
+
},
|
|
2559
|
+
]
|
|
2560
|
+
let http: Awaited<ReturnType<typeof startDoHttp>>
|
|
2561
|
+
http = await startDoHttp((sql) => {
|
|
2562
|
+
const compact = compactSQL(sql)
|
|
2563
|
+
if (compact.startsWith('CREATE TABLE IF NOT EXISTS "_orez_pg_metadata"')) {
|
|
2564
|
+
return { rows: [], columns: [] }
|
|
2565
|
+
}
|
|
2566
|
+
if (compact.startsWith('SELECT kind, key, subkey, value FROM')) {
|
|
2567
|
+
return {
|
|
2568
|
+
rows: metadataRows,
|
|
2569
|
+
columns: ['kind', 'key', 'subkey', 'value'],
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
if (compact.startsWith('INSERT OR REPLACE INTO "_orez_pg_metadata"')) {
|
|
2573
|
+
const params = http.bodies.at(-1)?.params ?? []
|
|
2574
|
+
const [kind, key, subkey, value] =
|
|
2575
|
+
params.length === 3 ? [params[0], params[1], '', params[2]] : params
|
|
2576
|
+
const existing = metadataRows.findIndex(
|
|
2577
|
+
(row) => row.kind === kind && row.key === key && row.subkey === subkey
|
|
2578
|
+
)
|
|
2579
|
+
const row = { kind, key, subkey, value }
|
|
2580
|
+
if (existing >= 0) metadataRows[existing] = row
|
|
2581
|
+
else metadataRows.push(row)
|
|
2582
|
+
return { rows: [], columns: [] }
|
|
2583
|
+
}
|
|
2584
|
+
if (compact.includes('sqlite_master')) {
|
|
2585
|
+
return {
|
|
2586
|
+
rows: [
|
|
2587
|
+
{
|
|
2588
|
+
name: 'todo_0_shardConfig',
|
|
2589
|
+
sql: 'CREATE TABLE todo_0_shardConfig (publications text)',
|
|
2590
|
+
},
|
|
2591
|
+
],
|
|
2592
|
+
columns: ['name', 'sql'],
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
if (compact === 'SELECT publications FROM "todo_0_shardConfig" LIMIT 1') {
|
|
2596
|
+
return {
|
|
2597
|
+
rows: [{ publications: '["_todo_metadata_0","zero_todo"]' }],
|
|
2598
|
+
columns: ['publications'],
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
return { rows: [], columns: [] }
|
|
2602
|
+
})
|
|
2603
|
+
const backend = new DoBackend(http.url, 'postgres', 'metadata-merge-test')
|
|
2604
|
+
await backend.waitReady
|
|
2605
|
+
|
|
2606
|
+
const publications = await (backend as any).handleCatalogQuery(`
|
|
2607
|
+
SELECT pubname FROM pg_publication
|
|
2608
|
+
WHERE pubname IN ('_todo_metadata_0', 'zero_todo')
|
|
2609
|
+
ORDER BY pubname
|
|
2610
|
+
`)
|
|
2611
|
+
expect(publications.rows).toEqual([
|
|
2612
|
+
{ pubname: '_todo_metadata_0' },
|
|
2613
|
+
{ pubname: 'zero_todo' },
|
|
2614
|
+
])
|
|
2615
|
+
expect(metadataRows.map((row) => row.key).sort()).toEqual([
|
|
2616
|
+
'_todo_metadata_0',
|
|
2617
|
+
'zero_todo',
|
|
2618
|
+
])
|
|
2619
|
+
})
|
|
2620
|
+
|
|
2621
|
+
test('infers JSON parameter oids from parsed insert target columns', async () => {
|
|
2622
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2623
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-param-oid-test')
|
|
2624
|
+
await backend.waitReady
|
|
2625
|
+
|
|
2626
|
+
await backend.exec(`
|
|
2627
|
+
CREATE TABLE "chat_0".replicas (
|
|
2628
|
+
"slot" text PRIMARY KEY,
|
|
2629
|
+
"version" text NOT NULL,
|
|
2630
|
+
"initialSchema" JSON NOT NULL,
|
|
2631
|
+
"initialSyncContext" JSON
|
|
2632
|
+
)
|
|
2633
|
+
`)
|
|
2634
|
+
|
|
2635
|
+
await backend.execProtocolRaw(
|
|
2636
|
+
parseMessage(
|
|
2637
|
+
`INSERT INTO "chat_0".replicas
|
|
2638
|
+
("slot", "version", "initialSchema", "initialSyncContext")
|
|
2639
|
+
VALUES ($1, $2, $3, $4)`,
|
|
2640
|
+
'',
|
|
2641
|
+
[0, 0, 0, 0]
|
|
2642
|
+
)
|
|
2643
|
+
)
|
|
2644
|
+
const describe = await backend.execProtocolRaw(describeStatement())
|
|
2645
|
+
|
|
2646
|
+
expect(parameterDescriptionOids(describe)).toEqual([25, 25, 114, 114])
|
|
2647
|
+
|
|
2648
|
+
await backend.execProtocolRaw(
|
|
2649
|
+
bindStatementParams(
|
|
2650
|
+
[
|
|
2651
|
+
'slot_1',
|
|
2652
|
+
'0/16B6C50',
|
|
2653
|
+
'{"tables":[{"name":"message"}],"indexes":[]}',
|
|
2654
|
+
'{"requestID":"req_1"}',
|
|
2655
|
+
],
|
|
2656
|
+
'',
|
|
2657
|
+
'replica_insert'
|
|
2658
|
+
)
|
|
2659
|
+
)
|
|
2660
|
+
await backend.execProtocolRaw(executePortal('replica_insert'))
|
|
2661
|
+
|
|
2662
|
+
expect(compactSQL(http.sqls.at(-1) || '')).toContain('VALUES ( ?, ?, ?, ? )')
|
|
2663
|
+
expect(http.params.at(-1)).toEqual([
|
|
2664
|
+
'slot_1',
|
|
2665
|
+
'0/16B6C50',
|
|
2666
|
+
'{"tables":[{"name":"message"}],"indexes":[]}',
|
|
2667
|
+
'{"requestID":"req_1"}',
|
|
2668
|
+
])
|
|
2669
|
+
})
|
|
2670
|
+
|
|
2671
|
+
test('infers zero-cache JSON parameter oids without durable metadata', async () => {
|
|
2672
|
+
const http = await startDoHttp((sql) => {
|
|
2673
|
+
const compact = compactSQL(sql)
|
|
2674
|
+
if (
|
|
2675
|
+
compact.includes('_orez_pg_metadata') ||
|
|
2676
|
+
compact.includes('sqlite_master') ||
|
|
2677
|
+
compact.startsWith('PRAGMA')
|
|
2678
|
+
) {
|
|
2679
|
+
return { rows: [], columns: [] }
|
|
2680
|
+
}
|
|
2681
|
+
return { rows: [], columns: [] }
|
|
2682
|
+
})
|
|
2683
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-param-fallback-test')
|
|
2684
|
+
await backend.waitReady
|
|
2685
|
+
|
|
2686
|
+
await backend.execProtocolRaw(
|
|
2687
|
+
parseMessage(
|
|
2688
|
+
`INSERT INTO "chat_0".replicas
|
|
2689
|
+
("slot", "version", "initialSchema", "initialSyncContext")
|
|
2690
|
+
VALUES ($1, $2, $3, $4)`,
|
|
2691
|
+
'replica-insert-no-metadata',
|
|
2692
|
+
[0, 0, 0, 0]
|
|
2693
|
+
)
|
|
2694
|
+
)
|
|
2695
|
+
let describe = await backend.execProtocolRaw(
|
|
2696
|
+
describeStatement('replica-insert-no-metadata')
|
|
2697
|
+
)
|
|
2698
|
+
expect(parameterDescriptionOids(describe)).toEqual([0, 0, 114, 114])
|
|
2699
|
+
|
|
2700
|
+
await backend.execProtocolRaw(
|
|
2701
|
+
parseMessage(
|
|
2702
|
+
`UPDATE "chat_0".replicas
|
|
2703
|
+
SET "subscriberContext" = $1
|
|
2704
|
+
WHERE slot = $2`,
|
|
2705
|
+
'replica-update-no-metadata',
|
|
2706
|
+
[0, 0]
|
|
2707
|
+
)
|
|
2708
|
+
)
|
|
2709
|
+
describe = await backend.execProtocolRaw(
|
|
2710
|
+
describeStatement('replica-update-no-metadata')
|
|
2711
|
+
)
|
|
2712
|
+
expect(parameterDescriptionOids(describe)).toEqual([114, 0])
|
|
2713
|
+
})
|
|
2714
|
+
|
|
2715
|
+
test('infers fallback insert params for ON CONFLICT without durable metadata', async () => {
|
|
2716
|
+
const http = await startDoHttp((sql) => {
|
|
2717
|
+
const compact = compactSQL(sql)
|
|
2718
|
+
if (
|
|
2719
|
+
compact.includes('_orez_pg_metadata') ||
|
|
2720
|
+
compact.includes('sqlite_master') ||
|
|
2721
|
+
compact.startsWith('PRAGMA')
|
|
2722
|
+
) {
|
|
2723
|
+
return { rows: [], columns: [] }
|
|
2724
|
+
}
|
|
2725
|
+
return { rows: [], columns: [] }
|
|
2726
|
+
})
|
|
2727
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-upsert-no-metadata')
|
|
2728
|
+
await backend.waitReady
|
|
2729
|
+
|
|
2730
|
+
await backend.execProtocolRaw(
|
|
2731
|
+
parseMessage(
|
|
2732
|
+
`INSERT INTO "chat_0".replicas
|
|
2733
|
+
("slot", "version", "initialSchema", "initialSyncContext")
|
|
2734
|
+
VALUES ($1, $2, $3, $4)
|
|
2735
|
+
ON CONFLICT ("slot") DO UPDATE SET "initialSchema" = $5`,
|
|
2736
|
+
'replica-upsert-no-metadata',
|
|
2737
|
+
[0, 0, 0, 0, 0]
|
|
2738
|
+
)
|
|
2739
|
+
)
|
|
2740
|
+
const describe = await backend.execProtocolRaw(
|
|
2741
|
+
describeStatement('replica-upsert-no-metadata')
|
|
2742
|
+
)
|
|
2743
|
+
|
|
2744
|
+
expect(parameterDescriptionOids(describe)).toEqual([0, 0, 114, 114, 114])
|
|
2745
|
+
})
|
|
2746
|
+
|
|
2747
|
+
test('splits Drizzle statement-breakpoint batches and drops PG constraint alters', async () => {
|
|
2748
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2749
|
+
const backend = new DoBackend(http.url, 'postgres', 'statement-batch-test')
|
|
2750
|
+
await backend.waitReady
|
|
2751
|
+
|
|
2752
|
+
await backend.exec(`
|
|
2753
|
+
CREATE TABLE "parent" ("id" text PRIMARY KEY);
|
|
2754
|
+
--> statement-breakpoint
|
|
2755
|
+
ALTER TABLE "child" ADD CONSTRAINT "child_parent_fk" FOREIGN KEY ("parentId") REFERENCES "public"."parent"("id");
|
|
2756
|
+
--> statement-breakpoint
|
|
2757
|
+
ALTER TABLE "child" ADD PRIMARY KEY("id");
|
|
2758
|
+
--> statement-breakpoint
|
|
2759
|
+
CREATE TABLE "child" ("id" text PRIMARY KEY, "parentId" text);
|
|
2760
|
+
`)
|
|
2761
|
+
|
|
2762
|
+
const sent = http.sqls.join('; ')
|
|
2763
|
+
expect(sent).toContain('CREATE TABLE IF NOT EXISTS parent')
|
|
2764
|
+
expect(sent).toContain('CREATE TABLE IF NOT EXISTS child')
|
|
2765
|
+
expect(sent).not.toContain('ADD CONSTRAINT')
|
|
2766
|
+
expect(sent).not.toContain('statement-breakpoint')
|
|
2767
|
+
})
|
|
2768
|
+
|
|
2769
|
+
test('splits semicolon batches inside Drizzle breakpoint chunks', async () => {
|
|
2770
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2771
|
+
const backend = new DoBackend(http.url, 'postgres', 'semicolon-batch-test')
|
|
2772
|
+
await backend.waitReady
|
|
2773
|
+
|
|
2774
|
+
await backend.exec(`
|
|
2775
|
+
ALTER TABLE "serverApp" DROP COLUMN "id";
|
|
2776
|
+
ALTER TABLE "serverApp" ADD CONSTRAINT "serverApp_pk" PRIMARY KEY("serverId","creatorId");
|
|
2777
|
+
--> statement-breakpoint
|
|
2778
|
+
INSERT INTO "log" ("message") VALUES ('keeps ; inside strings');
|
|
2779
|
+
`)
|
|
2780
|
+
|
|
2781
|
+
const sent = http.sqls.at(-1) || ''
|
|
2782
|
+
const compact = compactSQL(sent)
|
|
2783
|
+
expect(compact).toContain('ALTER TABLE "serverApp" DROP COLUMN id')
|
|
2784
|
+
expect(compact).toContain(
|
|
2785
|
+
`INSERT INTO log ( message ) VALUES ( 'keeps ; inside strings' )`
|
|
2786
|
+
)
|
|
2787
|
+
expect(sent).not.toContain('ADD CONSTRAINT')
|
|
2788
|
+
expect(sent).not.toContain('PRIMARY KEY("serverId"')
|
|
2789
|
+
})
|
|
2790
|
+
|
|
2791
|
+
test('rewrites btree indexes and drops unsupported PG index methods', async () => {
|
|
2792
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2793
|
+
const backend = new DoBackend(http.url, 'postgres', 'index-rewrite-test')
|
|
2794
|
+
await backend.waitReady
|
|
2795
|
+
|
|
2796
|
+
await backend.exec(`
|
|
2797
|
+
CREATE INDEX "idx_message_channel" ON "message" USING btree ("channelId","order");
|
|
2798
|
+
--> statement-breakpoint
|
|
2799
|
+
CREATE INDEX "idx_message_search" ON "message" USING gin ("content" gin_trgm_ops);
|
|
2800
|
+
`)
|
|
2801
|
+
|
|
2802
|
+
const sent = http.sqls.at(-1) || ''
|
|
2803
|
+
expect(compactSQL(sent)).toContain(
|
|
2804
|
+
'CREATE INDEX IF NOT EXISTS idx_message_channel ON message ("channelId", "order")'
|
|
2805
|
+
)
|
|
2806
|
+
expect(sent).not.toContain('USING')
|
|
2807
|
+
expect(sent).not.toContain('idx_message_search')
|
|
2808
|
+
})
|
|
2809
|
+
|
|
2810
|
+
test('drops PG null ordering from index elements', async () => {
|
|
2811
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2812
|
+
const backend = new DoBackend(http.url, 'postgres', 'index-nulls-order-test')
|
|
2813
|
+
await backend.waitReady
|
|
2814
|
+
|
|
2815
|
+
await backend.exec(`
|
|
2816
|
+
CREATE INDEX queries_patch_version
|
|
2817
|
+
ON "chat_0/cvr".queries ("patchVersion" NULLS FIRST);
|
|
2818
|
+
`)
|
|
2819
|
+
|
|
2820
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
2821
|
+
expect(sent).toContain(
|
|
2822
|
+
'CREATE INDEX IF NOT EXISTS queries_patch_version ON "chat_0/cvr_queries" ("patchVersion")'
|
|
2823
|
+
)
|
|
2824
|
+
expect(sent).not.toContain('NULLS FIRST')
|
|
2825
|
+
expect(sent).not.toContain('"chat_0/cvr".')
|
|
2826
|
+
})
|
|
2827
|
+
|
|
2828
|
+
test('normalizes unsupported ALTER TABLE ADD COLUMN constraints', async () => {
|
|
2829
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2830
|
+
const backend = new DoBackend(http.url, 'postgres', 'alter-add-column-test')
|
|
2831
|
+
await backend.waitReady
|
|
2832
|
+
|
|
2833
|
+
await backend.exec(
|
|
2834
|
+
'ALTER TABLE "appInstall" ADD COLUMN IF NOT EXISTS "id" varchar PRIMARY KEY NOT NULL DEFAULT md5(random()::text);'
|
|
2835
|
+
)
|
|
2836
|
+
|
|
2837
|
+
const sent = compactSQL(
|
|
2838
|
+
sqlContaining(http.sqls, 'ALTER TABLE "appInstall" ADD COLUMN id varchar')
|
|
2839
|
+
)
|
|
2840
|
+
expect(sent).toContain('ALTER TABLE "appInstall" ADD COLUMN id varchar')
|
|
2841
|
+
expect(sent).not.toContain('IF NOT EXISTS')
|
|
2842
|
+
expect(sent).not.toContain('PRIMARY KEY')
|
|
2843
|
+
expect(sent).not.toContain('NOT NULL')
|
|
2844
|
+
expect(sent).not.toContain('md5')
|
|
2845
|
+
})
|
|
2846
|
+
|
|
2847
|
+
test('tracks parser metadata through table and column renames', async () => {
|
|
2848
|
+
const http = await startDoHttp((sql) => {
|
|
2849
|
+
const compact = compactSQL(sql)
|
|
2850
|
+
if (compact.includes("sqlite_master WHERE type = 'table'")) {
|
|
2851
|
+
return {
|
|
2852
|
+
rows: [{ name: 'app', sql: 'CREATE TABLE "app" ("madeAt" text, meta text)' }],
|
|
2853
|
+
columns: ['name', 'sql'],
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
if (compact.includes('PRAGMA table_info("app")')) {
|
|
2857
|
+
return {
|
|
2858
|
+
rows: [
|
|
2859
|
+
{
|
|
2860
|
+
cid: 0,
|
|
2861
|
+
name: 'madeAt',
|
|
2862
|
+
type: 'text',
|
|
2863
|
+
notnull: 0,
|
|
2864
|
+
dflt_value: null,
|
|
2865
|
+
pk: 0,
|
|
2866
|
+
},
|
|
2867
|
+
{
|
|
2868
|
+
cid: 1,
|
|
2869
|
+
name: 'meta',
|
|
2870
|
+
type: 'text',
|
|
2871
|
+
notnull: 0,
|
|
2872
|
+
dflt_value: null,
|
|
2873
|
+
pk: 0,
|
|
2874
|
+
},
|
|
2875
|
+
],
|
|
2876
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
return { rows: [], columns: [] }
|
|
2880
|
+
})
|
|
2881
|
+
const backend = new DoBackend(http.url, 'postgres', 'rename-metadata-test')
|
|
2882
|
+
await backend.waitReady
|
|
2883
|
+
|
|
2884
|
+
await backend.exec(`
|
|
2885
|
+
CREATE TABLE "plugin" (
|
|
2886
|
+
"createdAt" timestamp,
|
|
2887
|
+
"meta" jsonb
|
|
2888
|
+
);
|
|
2889
|
+
`)
|
|
2890
|
+
await backend.exec('ALTER TABLE "plugin" RENAME TO "app";')
|
|
2891
|
+
await backend.exec('ALTER TABLE "app" RENAME COLUMN "createdAt" TO "madeAt";')
|
|
2892
|
+
|
|
2893
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
2894
|
+
SELECT c.column_name::text AS column,
|
|
2895
|
+
c.data_type::text AS "dataType",
|
|
2896
|
+
t.typname::text AS typename
|
|
2897
|
+
FROM information_schema.columns c
|
|
2898
|
+
JOIN pg_catalog.pg_type t ON c.udt_name = t.typname
|
|
2899
|
+
LEFT JOIN pg_catalog.pg_type et ON t.typelem = et.oid
|
|
2900
|
+
JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid
|
|
2901
|
+
WHERE (c.table_schema, c.table_name) IN (('public'::text, 'app'::text))
|
|
2902
|
+
`)
|
|
2903
|
+
|
|
2904
|
+
expect(result.rows).toEqual([
|
|
2905
|
+
{
|
|
2906
|
+
column: 'madeAt',
|
|
2907
|
+
dataType: 'timestamp without time zone',
|
|
2908
|
+
typename: 'timestamp',
|
|
2909
|
+
},
|
|
2910
|
+
{ column: 'meta', dataType: 'jsonb', typename: 'jsonb' },
|
|
2911
|
+
])
|
|
2912
|
+
})
|
|
2913
|
+
|
|
2914
|
+
test('tracks ALTER COLUMN TYPE as catalog metadata without SQLite DDL', async () => {
|
|
2915
|
+
const http = await startDoHttp((sql) => {
|
|
2916
|
+
const compact = compactSQL(sql)
|
|
2917
|
+
if (compact.includes("sqlite_master WHERE type = 'table'")) {
|
|
2918
|
+
return {
|
|
2919
|
+
rows: [{ name: 'data', sql: 'CREATE TABLE data (value text)' }],
|
|
2920
|
+
columns: ['name', 'sql'],
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
if (compact.includes('PRAGMA table_info("data")')) {
|
|
2924
|
+
return {
|
|
2925
|
+
rows: [
|
|
2926
|
+
{
|
|
2927
|
+
cid: 0,
|
|
2928
|
+
name: 'value',
|
|
2929
|
+
type: 'text',
|
|
2930
|
+
notnull: 0,
|
|
2931
|
+
dflt_value: null,
|
|
2932
|
+
pk: 0,
|
|
2933
|
+
},
|
|
2934
|
+
],
|
|
2935
|
+
columns: ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'],
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
return { rows: [], columns: [] }
|
|
2939
|
+
})
|
|
2940
|
+
const backend = new DoBackend(http.url, 'postgres', 'alter-type-metadata-test')
|
|
2941
|
+
await backend.waitReady
|
|
2942
|
+
|
|
2943
|
+
await backend.exec('CREATE TABLE data (value text);')
|
|
2944
|
+
await backend.exec(
|
|
2945
|
+
'ALTER TABLE data ALTER COLUMN value SET DATA TYPE jsonb USING value::jsonb;'
|
|
2946
|
+
)
|
|
2947
|
+
|
|
2948
|
+
const result = await (backend as any).handleCatalogQuery(`
|
|
2949
|
+
SELECT c.column_name::text AS column,
|
|
2950
|
+
c.data_type::text AS "dataType",
|
|
2951
|
+
t.typname::text AS typename
|
|
2952
|
+
FROM information_schema.columns c
|
|
2953
|
+
JOIN pg_catalog.pg_type t ON c.udt_name = t.typname
|
|
2954
|
+
LEFT JOIN pg_catalog.pg_type et ON t.typelem = et.oid
|
|
2955
|
+
JOIN pg_catalog.pg_namespace n ON t.typnamespace = n.oid
|
|
2956
|
+
WHERE (c.table_schema, c.table_name) IN (('public'::text, 'data'::text))
|
|
2957
|
+
`)
|
|
2958
|
+
|
|
2959
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('ALTER COLUMN'))).toBe(false)
|
|
2960
|
+
expect(result.rows).toEqual([
|
|
2961
|
+
{ column: 'value', dataType: 'jsonb', typename: 'jsonb' },
|
|
2962
|
+
])
|
|
2963
|
+
})
|
|
2964
|
+
|
|
2965
|
+
test('normalizes pgvector and generated tsvector columns in create table', async () => {
|
|
2966
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2967
|
+
const backend = new DoBackend(http.url, 'postgres', 'search-table-test')
|
|
2968
|
+
await backend.waitReady
|
|
2969
|
+
|
|
2970
|
+
await backend.exec(`
|
|
2971
|
+
CREATE TABLE IF NOT EXISTS search_documents (
|
|
2972
|
+
id text PRIMARY KEY,
|
|
2973
|
+
title text,
|
|
2974
|
+
content text,
|
|
2975
|
+
search_vector tsvector GENERATED ALWAYS AS (
|
|
2976
|
+
setweight(to_tsvector('english', coalesce(title, '')), 'A')
|
|
2977
|
+
) STORED,
|
|
2978
|
+
embedding vector(384)
|
|
2979
|
+
);
|
|
2980
|
+
`)
|
|
2981
|
+
|
|
2982
|
+
const sent = compactSQL(
|
|
2983
|
+
sqlContaining(http.sqls, 'CREATE TABLE IF NOT EXISTS search_documents')
|
|
2984
|
+
)
|
|
2985
|
+
expect(sent).toContain('search_vector text')
|
|
2986
|
+
expect(sent).toContain('embedding text')
|
|
2987
|
+
expect(sent).not.toContain('GENERATED')
|
|
2988
|
+
expect(sent).not.toContain('to_tsvector')
|
|
2989
|
+
expect(sent).not.toContain('vector(384)')
|
|
2990
|
+
})
|
|
2991
|
+
|
|
2992
|
+
test('makes create-table statements idempotent for repeated migrations', async () => {
|
|
2993
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
2994
|
+
const backend = new DoBackend(http.url, 'postgres', 'create-table-idempotent-test')
|
|
2995
|
+
await backend.waitReady
|
|
2996
|
+
|
|
2997
|
+
await backend.exec(`
|
|
2998
|
+
CREATE TABLE "privateChatsStats" (
|
|
2999
|
+
"id" text PRIMARY KEY,
|
|
3000
|
+
"createdAt" timestamptz DEFAULT now()
|
|
3001
|
+
);
|
|
3002
|
+
`)
|
|
3003
|
+
|
|
3004
|
+
const sent = compactSQL(
|
|
3005
|
+
sqlContaining(http.sqls, 'CREATE TABLE IF NOT EXISTS "privateChatsStats"')
|
|
3006
|
+
)
|
|
3007
|
+
expect(sent).toContain('CREATE TABLE IF NOT EXISTS "privateChatsStats"')
|
|
3008
|
+
expect(sent).toContain('"createdAt" text DEFAULT CURRENT_TIMESTAMP')
|
|
3009
|
+
})
|
|
3010
|
+
|
|
3011
|
+
test('drops foreign-key constraints while flattening schema-qualified create table DDL', async () => {
|
|
3012
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3013
|
+
const backend = new DoBackend(http.url, 'postgres', 'cvr-foreign-key-test')
|
|
3014
|
+
await backend.waitReady
|
|
3015
|
+
|
|
3016
|
+
await backend.exec(`
|
|
3017
|
+
CREATE TABLE "chat_0/cvr".rows (
|
|
3018
|
+
"clientGroupID" TEXT,
|
|
3019
|
+
"rowKey" JSONB,
|
|
3020
|
+
PRIMARY KEY ("clientGroupID", "rowKey"),
|
|
3021
|
+
CONSTRAINT fk_rows_client_group
|
|
3022
|
+
FOREIGN KEY("clientGroupID")
|
|
3023
|
+
REFERENCES "chat_0/cvr"."rowsVersion" ("clientGroupID")
|
|
3024
|
+
ON DELETE CASCADE
|
|
3025
|
+
);
|
|
3026
|
+
`)
|
|
3027
|
+
|
|
3028
|
+
const sent = compactSQL(
|
|
3029
|
+
sqlContaining(http.sqls, 'CREATE TABLE IF NOT EXISTS "chat_0/cvr_rows"')
|
|
3030
|
+
)
|
|
3031
|
+
expect(sent).toContain('CREATE TABLE IF NOT EXISTS "chat_0/cvr_rows"')
|
|
3032
|
+
expect(sent).toContain('"rowKey" text')
|
|
3033
|
+
expect(sent).not.toContain('FOREIGN KEY')
|
|
3034
|
+
expect(sent).not.toContain('REFERENCES')
|
|
3035
|
+
expect(sent).not.toContain('"chat_0/cvr".')
|
|
3036
|
+
})
|
|
3037
|
+
|
|
3038
|
+
test('rewrites temporary create-table-as statements to persistent SQLite tables', async () => {
|
|
3039
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3040
|
+
const backend = new DoBackend(http.url, 'postgres', 'temp-table-as-test')
|
|
3041
|
+
await backend.waitReady
|
|
3042
|
+
|
|
3043
|
+
await backend.exec(`
|
|
3044
|
+
CREATE TEMP TABLE app_id_mapping AS
|
|
3045
|
+
SELECT id AS old_id, uid AS new_id
|
|
3046
|
+
FROM public.app;
|
|
3047
|
+
`)
|
|
3048
|
+
|
|
3049
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3050
|
+
expect(sent).toContain('CREATE TABLE app_id_mapping AS SELECT')
|
|
3051
|
+
expect(sent).toContain('FROM app')
|
|
3052
|
+
expect(sent).not.toContain('TEMP')
|
|
3053
|
+
})
|
|
3054
|
+
|
|
3055
|
+
test('normalizes multiline ALTER TABLE ADD COLUMN modifiers', async () => {
|
|
3056
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3057
|
+
const backend = new DoBackend(http.url, 'postgres', 'multiline-add-column-test')
|
|
3058
|
+
await backend.waitReady
|
|
3059
|
+
|
|
3060
|
+
await backend.exec(`
|
|
3061
|
+
ALTER TABLE "userPublic"
|
|
3062
|
+
ADD COLUMN IF NOT EXISTS "hasOnboarded" BOOLEAN NOT NULL DEFAULT false;
|
|
3063
|
+
`)
|
|
3064
|
+
|
|
3065
|
+
const sent = compactSQL(
|
|
3066
|
+
sqlContaining(
|
|
3067
|
+
http.sqls,
|
|
3068
|
+
'ALTER TABLE "userPublic" ADD COLUMN "hasOnboarded" integer DEFAULT 0'
|
|
3069
|
+
)
|
|
3070
|
+
)
|
|
3071
|
+
expect(sent).toContain(
|
|
3072
|
+
'ALTER TABLE "userPublic" ADD COLUMN "hasOnboarded" integer DEFAULT 0'
|
|
3073
|
+
)
|
|
3074
|
+
expect(sent).not.toContain('IF NOT EXISTS')
|
|
3075
|
+
expect(sent).not.toContain('NOT NULL')
|
|
3076
|
+
})
|
|
3077
|
+
|
|
3078
|
+
test('splits multi-command ALTER TABLE statements for SQLite', async () => {
|
|
3079
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3080
|
+
const backend = new DoBackend(http.url, 'postgres', 'multi-add-column-test')
|
|
3081
|
+
await backend.waitReady
|
|
3082
|
+
|
|
3083
|
+
await backend.exec(`
|
|
3084
|
+
ALTER TABLE search_documents
|
|
3085
|
+
ADD COLUMN server_id text,
|
|
3086
|
+
ADD COLUMN channel_id text;
|
|
3087
|
+
`)
|
|
3088
|
+
|
|
3089
|
+
const sent = http.sqls.join('; ')
|
|
3090
|
+
expect(compactSQL(sent)).toContain(
|
|
3091
|
+
'ALTER TABLE search_documents ADD COLUMN server_id text'
|
|
3092
|
+
)
|
|
3093
|
+
expect(compactSQL(sent)).toContain(
|
|
3094
|
+
'ALTER TABLE search_documents ADD COLUMN channel_id text'
|
|
3095
|
+
)
|
|
3096
|
+
expect(compactSQL(sent)).not.toContain('server_id text, ADD COLUMN')
|
|
3097
|
+
})
|
|
3098
|
+
|
|
3099
|
+
test('skips ADD COLUMN IF NOT EXISTS when parser metadata finds the column', async () => {
|
|
3100
|
+
const http = await startDoHttp((sql) => {
|
|
3101
|
+
if (sql.toLowerCase().includes('pragma table_info')) {
|
|
3102
|
+
return { rows: [{ name: 'hasOnboarded' }], columns: ['name'] }
|
|
3103
|
+
}
|
|
3104
|
+
return { rows: [], columns: [] }
|
|
3105
|
+
})
|
|
3106
|
+
const backend = new DoBackend(http.url, 'postgres', 'conditional-add-column-test')
|
|
3107
|
+
await backend.waitReady
|
|
3108
|
+
|
|
3109
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
3110
|
+
await backend.execProtocolRaw(
|
|
3111
|
+
msg(
|
|
3112
|
+
0x51,
|
|
3113
|
+
cstr(`
|
|
3114
|
+
ALTER TABLE "userPublic"
|
|
3115
|
+
ADD COLUMN IF NOT EXISTS "hasOnboarded" BOOLEAN NOT NULL DEFAULT false;
|
|
3116
|
+
`)
|
|
3117
|
+
)
|
|
3118
|
+
)
|
|
3119
|
+
await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
3120
|
+
|
|
3121
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('PRAGMA table_info'))).toBe(
|
|
3122
|
+
true
|
|
3123
|
+
)
|
|
3124
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('ADD COLUMN'))).toBe(false)
|
|
3125
|
+
})
|
|
3126
|
+
|
|
3127
|
+
test('skips plain ADD COLUMN when parser metadata finds the column', async () => {
|
|
3128
|
+
const http = await startDoHttp((sql) => {
|
|
3129
|
+
if (sql.toLowerCase().includes('pragma table_info')) {
|
|
3130
|
+
return { rows: [{ name: 'latestMessageOrder' }], columns: ['name'] }
|
|
3131
|
+
}
|
|
3132
|
+
return { rows: [], columns: [] }
|
|
3133
|
+
})
|
|
3134
|
+
const backend = new DoBackend(http.url, 'postgres', 'plain-add-column-test')
|
|
3135
|
+
await backend.waitReady
|
|
3136
|
+
|
|
3137
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
3138
|
+
await backend.execProtocolRaw(
|
|
3139
|
+
msg(
|
|
3140
|
+
0x51,
|
|
3141
|
+
cstr(`
|
|
3142
|
+
ALTER TABLE channel
|
|
3143
|
+
ADD COLUMN "latestMessageOrder" varchar;
|
|
3144
|
+
`)
|
|
3145
|
+
)
|
|
3146
|
+
)
|
|
3147
|
+
await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
3148
|
+
|
|
3149
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('PRAGMA table_info'))).toBe(
|
|
3150
|
+
true
|
|
3151
|
+
)
|
|
3152
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('ADD COLUMN'))).toBe(false)
|
|
3153
|
+
})
|
|
3154
|
+
|
|
3155
|
+
test('skips DROP COLUMN IF EXISTS when parser metadata cannot find the column', async () => {
|
|
3156
|
+
const http = await startDoHttp((sql) => {
|
|
3157
|
+
if (sql.toLowerCase().includes('pragma table_info')) {
|
|
3158
|
+
return { rows: [{ name: 'id' }], columns: ['name'] }
|
|
3159
|
+
}
|
|
3160
|
+
return { rows: [], columns: [] }
|
|
3161
|
+
})
|
|
3162
|
+
const backend = new DoBackend(http.url, 'postgres', 'conditional-drop-column-test')
|
|
3163
|
+
await backend.waitReady
|
|
3164
|
+
|
|
3165
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
3166
|
+
await backend.execProtocolRaw(
|
|
3167
|
+
msg(
|
|
3168
|
+
0x51,
|
|
3169
|
+
cstr(`
|
|
3170
|
+
ALTER TABLE search_documents
|
|
3171
|
+
DROP COLUMN IF EXISTS message_order;
|
|
3172
|
+
`)
|
|
3173
|
+
)
|
|
3174
|
+
)
|
|
3175
|
+
await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
3176
|
+
|
|
3177
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('PRAGMA table_info'))).toBe(
|
|
3178
|
+
true
|
|
3179
|
+
)
|
|
3180
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('DROP COLUMN'))).toBe(false)
|
|
3181
|
+
})
|
|
3182
|
+
|
|
3183
|
+
test('keeps table-qualified column refs while flattening schemas', async () => {
|
|
3184
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3185
|
+
const backend = new DoBackend(http.url, 'postgres', 'update-from-test')
|
|
3186
|
+
await backend.waitReady
|
|
3187
|
+
|
|
3188
|
+
await backend.exec(`
|
|
3189
|
+
UPDATE thread
|
|
3190
|
+
SET "serverId" = channel."serverId"
|
|
3191
|
+
FROM channel
|
|
3192
|
+
WHERE thread."channelId" = channel.id
|
|
3193
|
+
AND public.thread."deleted" = false;
|
|
3194
|
+
`)
|
|
3195
|
+
|
|
3196
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3197
|
+
expect(sent).toContain('channel."serverId"')
|
|
3198
|
+
expect(sent).toContain('thread."channelId" = channel.id')
|
|
3199
|
+
expect(sent).toContain('thread.deleted = 0')
|
|
3200
|
+
expect(sent).not.toContain('channel_id')
|
|
3201
|
+
expect(sent).not.toContain('thread_channelId')
|
|
3202
|
+
})
|
|
3203
|
+
|
|
3204
|
+
test('rewrites PG least and greatest scalar functions to SQLite min and max', async () => {
|
|
3205
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3206
|
+
const backend = new DoBackend(http.url, 'postgres', 'least-greatest-test')
|
|
3207
|
+
await backend.waitReady
|
|
3208
|
+
|
|
3209
|
+
await backend.exec(`
|
|
3210
|
+
UPDATE "thread"
|
|
3211
|
+
SET "replyCount" = LEAST((SELECT COUNT(*)::INTEGER FROM "message"), 11),
|
|
3212
|
+
"order" = GREATEST("order", 0);
|
|
3213
|
+
`)
|
|
3214
|
+
|
|
3215
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3216
|
+
expect(sent).toContain('"replyCount" = min')
|
|
3217
|
+
expect(sent).toContain('"order" = max')
|
|
3218
|
+
expect(sent).not.toContain('LEAST')
|
|
3219
|
+
expect(sent).not.toContain('GREATEST')
|
|
3220
|
+
})
|
|
3221
|
+
|
|
3222
|
+
test('rewrites PG starts_with scalar function to SQLite instr predicate', async () => {
|
|
3223
|
+
const http = await startDoHttp((sql) => {
|
|
3224
|
+
if (sql.includes('instr(')) return { rows: [{ users: 3 }], columns: ['users'] }
|
|
3225
|
+
return { rows: [], columns: [] }
|
|
3226
|
+
})
|
|
3227
|
+
const backend = new DoBackend(http.url, 'postgres', 'starts-with-test')
|
|
3228
|
+
await backend.waitReady
|
|
3229
|
+
|
|
3230
|
+
const result = await backend.query(
|
|
3231
|
+
`SELECT count(*) FILTER (WHERE starts_with("profileID", 'p')) AS users
|
|
3232
|
+
FROM "chat_0/cvr_instances"`
|
|
3233
|
+
)
|
|
3234
|
+
|
|
3235
|
+
expect(result.rows).toEqual([{ users: 3 }])
|
|
3236
|
+
const sent = http.sqls.at(-1) ?? ''
|
|
3237
|
+
expect(sent).not.toContain('starts_with')
|
|
3238
|
+
expect(sent).toContain('instr("profileID", \'p\') = 1')
|
|
3239
|
+
})
|
|
3240
|
+
|
|
3241
|
+
test('rewrites DISTINCT ON selects with a window function for SQLite', async () => {
|
|
3242
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3243
|
+
const backend = new DoBackend(http.url, 'postgres', 'distinct-on-test')
|
|
3244
|
+
await backend.waitReady
|
|
3245
|
+
|
|
3246
|
+
await backend.exec(`
|
|
3247
|
+
UPDATE "agentConfig" ac
|
|
3248
|
+
SET "systemPrompt" = sub."chatPrompt"
|
|
3249
|
+
FROM (
|
|
3250
|
+
SELECT DISTINCT ON (c."serverId")
|
|
3251
|
+
c."serverId",
|
|
3252
|
+
c."chatPrompt"
|
|
3253
|
+
FROM channel c
|
|
3254
|
+
WHERE c."chatPrompt" IS NOT NULL
|
|
3255
|
+
AND c."chatPrompt" != ''
|
|
3256
|
+
ORDER BY c."serverId", c."updatedAt" DESC
|
|
3257
|
+
) sub
|
|
3258
|
+
WHERE ac."serverId" = sub."serverId"
|
|
3259
|
+
AND ac.type = 'builtin';
|
|
3260
|
+
`)
|
|
3261
|
+
|
|
3262
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3263
|
+
expect(sent).toContain('row_number() OVER')
|
|
3264
|
+
expect(sent).toContain('PARTITION BY c."serverId"')
|
|
3265
|
+
expect(sent).toContain('ORDER BY c."serverId", c."updatedAt" DESC')
|
|
3266
|
+
expect(sent).toContain('_orez_rn = 1')
|
|
3267
|
+
expect(sent).not.toContain('DISTINCT ON')
|
|
3268
|
+
})
|
|
3269
|
+
|
|
3270
|
+
test('strips PostgreSQL row-locking clauses from SELECTs for SQLite', async () => {
|
|
3271
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['clientGroupID'] }))
|
|
3272
|
+
const backend = new DoBackend(http.url, 'postgres', 'select-locking-clause-test')
|
|
3273
|
+
await backend.waitReady
|
|
3274
|
+
|
|
3275
|
+
await backend.exec(`
|
|
3276
|
+
SELECT "clientGroupID"
|
|
3277
|
+
FROM "chat_0/cvr_instances"
|
|
3278
|
+
WHERE NOT "deleted"
|
|
3279
|
+
ORDER BY "lastActive" ASC
|
|
3280
|
+
LIMIT 10
|
|
3281
|
+
FOR UPDATE SKIP LOCKED
|
|
3282
|
+
`)
|
|
3283
|
+
|
|
3284
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3285
|
+
expect(sent).toContain('ORDER BY "lastActive" ASC')
|
|
3286
|
+
expect(sent).toContain('LIMIT 10')
|
|
3287
|
+
expect(sent).not.toContain('FOR UPDATE')
|
|
3288
|
+
expect(sent).not.toContain('SKIP LOCKED')
|
|
3289
|
+
})
|
|
3290
|
+
|
|
3291
|
+
test('rewrites LIKE with PostgreSQL escape semantics to SQLite like()', async () => {
|
|
3292
|
+
const http = await startDoHttp(() => ({ rows: [], columns: ['slot'] }))
|
|
3293
|
+
const backend = new DoBackend(http.url, 'postgres', 'like-escape-test')
|
|
3294
|
+
await backend.waitReady
|
|
3295
|
+
|
|
3296
|
+
await backend.query(
|
|
3297
|
+
`
|
|
3298
|
+
SELECT slot_name AS slot
|
|
3299
|
+
FROM _orez._zero_replication_slots
|
|
3300
|
+
WHERE slot_name LIKE $1
|
|
3301
|
+
`,
|
|
3302
|
+
['chat\\_0\\_%']
|
|
3303
|
+
)
|
|
3304
|
+
|
|
3305
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3306
|
+
expect(sent).toContain('"like"(?, slot_name, "char"(92))')
|
|
3307
|
+
expect(sent).not.toContain('slot_name LIKE')
|
|
3308
|
+
expect(http.params.at(-1)).toEqual(['chat\\_0\\_%'])
|
|
3309
|
+
})
|
|
3310
|
+
|
|
3311
|
+
test('rewrites PG JSONB helper functions to SQLite JSON1 equivalents', async () => {
|
|
3312
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3313
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-function-test')
|
|
3314
|
+
await backend.waitReady
|
|
3315
|
+
|
|
3316
|
+
await backend.exec(`
|
|
3317
|
+
UPDATE task
|
|
3318
|
+
SET "numSteps" = CASE
|
|
3319
|
+
WHEN steps IS NULL THEN 0
|
|
3320
|
+
ELSE jsonb_array_length(steps)
|
|
3321
|
+
END;
|
|
3322
|
+
`)
|
|
3323
|
+
|
|
3324
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3325
|
+
expect(sent).toContain('json_array_length(steps)')
|
|
3326
|
+
expect(sent).not.toContain('jsonb_array_length')
|
|
3327
|
+
})
|
|
3328
|
+
|
|
3329
|
+
test('rewrites PG JSONB any-key operator to SQLite JSON1 joins', async () => {
|
|
3330
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3331
|
+
const backend = new DoBackend(http.url, 'postgres', 'jsonb-any-key-test')
|
|
3332
|
+
await backend.waitReady
|
|
3333
|
+
|
|
3334
|
+
await backend.query(
|
|
3335
|
+
`
|
|
3336
|
+
SELECT *
|
|
3337
|
+
FROM "chat_0/cvr_rows"
|
|
3338
|
+
WHERE
|
|
3339
|
+
"clientGroupID" = $1
|
|
3340
|
+
AND "patchVersion" > $2
|
|
3341
|
+
AND "patchVersion" <= $3
|
|
3342
|
+
AND ("refCounts" IS NULL OR NOT ("refCounts" ?| $4))
|
|
3343
|
+
`,
|
|
3344
|
+
['cg1', '00:01', '00:02', ['q1', 'q2']]
|
|
3345
|
+
)
|
|
3346
|
+
|
|
3347
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3348
|
+
expect(sent).toContain('json_each("refCounts")')
|
|
3349
|
+
expect(sent).toContain('json_each(?)')
|
|
3350
|
+
expect(sent).toContain('obj.key = keys.value')
|
|
3351
|
+
expect(sent).not.toContain('?|')
|
|
3352
|
+
expect(http.params.at(-1)).toEqual(['cg1', '00:01', '00:02', '["q1","q2"]'])
|
|
3353
|
+
})
|
|
3354
|
+
|
|
3355
|
+
test('rewrites JSON object builders and marks their result columns as JSON', async () => {
|
|
3356
|
+
const http = await startDoHttp((sql) => {
|
|
3357
|
+
if (compactSQL(sql).startsWith('SELECT')) {
|
|
3358
|
+
return {
|
|
3359
|
+
rows: [
|
|
3360
|
+
{
|
|
3361
|
+
table: '{"schema":"public","name":"message","metadata":{"rowKey":["id"]}}',
|
|
3362
|
+
columns: '{"payload":{"source":"backfill"}}',
|
|
3363
|
+
},
|
|
3364
|
+
],
|
|
3365
|
+
columns: ['table', 'columns'],
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
return { rows: [], columns: [] }
|
|
3369
|
+
})
|
|
3370
|
+
const backend = new DoBackend(http.url, 'postgres', 'json-object-builder-test')
|
|
3371
|
+
await backend.waitReady
|
|
3372
|
+
|
|
3373
|
+
const result = await backend.execProtocolRaw(
|
|
3374
|
+
msg(
|
|
3375
|
+
0x51,
|
|
3376
|
+
cstr(`
|
|
3377
|
+
SELECT
|
|
3378
|
+
json_build_object(
|
|
3379
|
+
'schema', b."schema",
|
|
3380
|
+
'name', b."table",
|
|
3381
|
+
'metadata', t."metadata"
|
|
3382
|
+
) AS "table",
|
|
3383
|
+
json_object_agg(b."column", b."backfill") AS "columns"
|
|
3384
|
+
FROM "chat_0/change-streamer_0"."backfilling" AS b
|
|
3385
|
+
LEFT JOIN "chat_0/change-streamer_0"."tableMetadata" AS t
|
|
3386
|
+
ON (b."schema" = t."schema" AND b."table" = t."table")
|
|
3387
|
+
GROUP BY b."schema", b."table", t."metadata"
|
|
3388
|
+
`)
|
|
3389
|
+
)
|
|
3390
|
+
)
|
|
3391
|
+
|
|
3392
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3393
|
+
expect(sent).toContain('json_group_object')
|
|
3394
|
+
expect(sent).toContain('json_valid')
|
|
3395
|
+
expect(sent).not.toContain('json_build_object')
|
|
3396
|
+
expect(sent).not.toContain('json_object_agg')
|
|
3397
|
+
expect(rowDescriptionOids(result)).toMatchObject({
|
|
3398
|
+
table: 114,
|
|
3399
|
+
columns: 114,
|
|
3400
|
+
})
|
|
3401
|
+
expect(dataRowValues(result)).toEqual([
|
|
3402
|
+
[
|
|
3403
|
+
'{"schema":"public","name":"message","metadata":{"rowKey":["id"]}}',
|
|
3404
|
+
'{"payload":{"source":"backfill"}}',
|
|
3405
|
+
],
|
|
3406
|
+
])
|
|
3407
|
+
})
|
|
3408
|
+
|
|
3409
|
+
test('skips unsupported regexp_replace updates when the target table is empty', async () => {
|
|
3410
|
+
const http = await startDoHttp((sql) => {
|
|
3411
|
+
if (compactSQL(sql).includes('SELECT 1 AS ok FROM "message" LIMIT 1')) {
|
|
3412
|
+
return { rows: [], columns: ['ok'] }
|
|
3413
|
+
}
|
|
3414
|
+
return { rows: [], columns: [] }
|
|
3415
|
+
})
|
|
3416
|
+
const backend = new DoBackend(http.url, 'postgres', 'regexp-empty-update-test')
|
|
3417
|
+
await backend.waitReady
|
|
3418
|
+
|
|
3419
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
3420
|
+
await backend.execProtocolRaw(
|
|
3421
|
+
msg(
|
|
3422
|
+
0x51,
|
|
3423
|
+
cstr(`
|
|
3424
|
+
UPDATE message
|
|
3425
|
+
SET content = regexp_replace(content, '<@{([^:}]+):([^:}]+):([^}]+)}>', E'<@{\\\\1%\\\\2%\\\\3}>', 'g')
|
|
3426
|
+
WHERE content LIKE '%<@{%:%:%}%';
|
|
3427
|
+
`)
|
|
3428
|
+
)
|
|
3429
|
+
)
|
|
3430
|
+
await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
3431
|
+
|
|
3432
|
+
expect(
|
|
3433
|
+
http.sqls.some((sql) =>
|
|
3434
|
+
compactSQL(sql).includes('SELECT 1 AS ok FROM "message" LIMIT 1')
|
|
3435
|
+
)
|
|
3436
|
+
).toBe(true)
|
|
3437
|
+
expect(http.sqls.some((sql) => sql.includes('regexp_replace'))).toBe(false)
|
|
3438
|
+
})
|
|
3439
|
+
|
|
3440
|
+
test('skips CTE backfill inserts when the source table is empty', async () => {
|
|
3441
|
+
const http = await startDoHttp((sql) => {
|
|
3442
|
+
if (compactSQL(sql).includes('SELECT 1 AS ok FROM "messageReaction" LIMIT 1')) {
|
|
3443
|
+
return { rows: [], columns: ['ok'] }
|
|
3444
|
+
}
|
|
3445
|
+
return { rows: [], columns: [] }
|
|
3446
|
+
})
|
|
3447
|
+
const backend = new DoBackend(http.url, 'postgres', 'empty-cte-insert-test')
|
|
3448
|
+
await backend.waitReady
|
|
3449
|
+
|
|
3450
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
3451
|
+
await backend.execProtocolRaw(
|
|
3452
|
+
msg(
|
|
3453
|
+
0x51,
|
|
3454
|
+
cstr(`
|
|
3455
|
+
INSERT INTO "messageReactionStats"
|
|
3456
|
+
WITH ranked_reactions AS (
|
|
3457
|
+
SELECT mr."messageId", mr."reactionId"
|
|
3458
|
+
FROM "messageReaction" mr
|
|
3459
|
+
)
|
|
3460
|
+
SELECT "messageId", "reactionId" FROM ranked_reactions;
|
|
3461
|
+
`)
|
|
3462
|
+
)
|
|
3463
|
+
)
|
|
3464
|
+
await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
3465
|
+
|
|
3466
|
+
expect(
|
|
3467
|
+
http.sqls.some((sql) =>
|
|
3468
|
+
compactSQL(sql).includes('SELECT 1 AS ok FROM "messageReaction" LIMIT 1')
|
|
3469
|
+
)
|
|
3470
|
+
).toBe(true)
|
|
3471
|
+
expect(http.sqls.some((sql) => compactSQL(sql).startsWith('INSERT INTO'))).toBe(false)
|
|
3472
|
+
})
|
|
3473
|
+
|
|
3474
|
+
test('executes zero-cache DELETE RETURNING count CTEs as SQLite deletes', async () => {
|
|
3475
|
+
const http = await startDoHttp((sql) => {
|
|
3476
|
+
if (compactSQL(sql).startsWith('DELETE FROM "todo_0/cdc_changeLog"')) {
|
|
3477
|
+
return {
|
|
3478
|
+
rows: [{ __orez_deleted: 1 }, { __orez_deleted: 1 }],
|
|
3479
|
+
columns: ['__orez_deleted'],
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
return { rows: [], columns: [] }
|
|
3483
|
+
})
|
|
3484
|
+
const backend = new DoBackend(http.url, 'zero_cdb', 'delete-count-cte-test')
|
|
3485
|
+
await backend.waitReady
|
|
3486
|
+
|
|
3487
|
+
await backend.execProtocolRaw(
|
|
3488
|
+
parseMessage(`
|
|
3489
|
+
WITH purged AS (
|
|
3490
|
+
DELETE FROM "todo_0/cdc"."changeLog"
|
|
3491
|
+
WHERE watermark < $1
|
|
3492
|
+
RETURNING watermark, pos
|
|
3493
|
+
)
|
|
3494
|
+
SELECT COUNT(*) AS deleted
|
|
3495
|
+
FROM purged;
|
|
3496
|
+
`)
|
|
3497
|
+
)
|
|
3498
|
+
await backend.execProtocolRaw(bindStatementParams(['a1zs3dw2usxs']))
|
|
3499
|
+
const result = await backend.execProtocolRaw(executePortal())
|
|
3500
|
+
|
|
3501
|
+
expect(dataRowValues(result)).toEqual([['2']])
|
|
3502
|
+
expect(compactSQL(http.sqls.at(-1) || '')).toBe(
|
|
3503
|
+
'DELETE FROM "todo_0/cdc_changeLog" WHERE watermark < ? RETURNING 1 AS "__orez_deleted"'
|
|
3504
|
+
)
|
|
3505
|
+
expect(http.params.at(-1)).toEqual(['a1zs3dw2usxs'])
|
|
3506
|
+
})
|
|
3507
|
+
|
|
3508
|
+
test('skips DELETE USING cleanup statements when the target table is empty', async () => {
|
|
3509
|
+
const http = await startDoHttp((sql) => {
|
|
3510
|
+
if (
|
|
3511
|
+
compactSQL(sql).includes(
|
|
3512
|
+
'SELECT 1 AS ok FROM "channelNotificationSetting" LIMIT 1'
|
|
3513
|
+
)
|
|
3514
|
+
) {
|
|
3515
|
+
return { rows: [], columns: ['ok'] }
|
|
3516
|
+
}
|
|
3517
|
+
return { rows: [], columns: [] }
|
|
3518
|
+
})
|
|
3519
|
+
const backend = new DoBackend(http.url, 'postgres', 'empty-delete-using-test')
|
|
3520
|
+
await backend.waitReady
|
|
3521
|
+
|
|
3522
|
+
await backend.execProtocolRaw(msg(0x51, cstr('BEGIN')))
|
|
3523
|
+
await backend.execProtocolRaw(
|
|
3524
|
+
msg(
|
|
3525
|
+
0x51,
|
|
3526
|
+
cstr(`
|
|
3527
|
+
WITH ranked AS (
|
|
3528
|
+
SELECT ctid, row_number() OVER (
|
|
3529
|
+
PARTITION BY "channelId", "userId"
|
|
3530
|
+
ORDER BY "updatedAt" DESC NULLS LAST, "id" DESC
|
|
3531
|
+
) AS rn
|
|
3532
|
+
FROM "channelNotificationSetting"
|
|
3533
|
+
)
|
|
3534
|
+
DELETE FROM "channelNotificationSetting" t
|
|
3535
|
+
USING ranked
|
|
3536
|
+
WHERE t.ctid = ranked.ctid
|
|
3537
|
+
AND ranked.rn > 1;
|
|
3538
|
+
`)
|
|
3539
|
+
)
|
|
3540
|
+
)
|
|
3541
|
+
await backend.execProtocolRaw(msg(0x51, cstr('COMMIT')))
|
|
3542
|
+
|
|
3543
|
+
expect(
|
|
3544
|
+
http.sqls.some((sql) =>
|
|
3545
|
+
compactSQL(sql).includes(
|
|
3546
|
+
'SELECT 1 AS ok FROM "channelNotificationSetting" LIMIT 1'
|
|
3547
|
+
)
|
|
3548
|
+
)
|
|
3549
|
+
).toBe(true)
|
|
3550
|
+
expect(http.sqls.some((sql) => compactSQL(sql).startsWith('WITH ranked'))).toBe(false)
|
|
3551
|
+
})
|
|
3552
|
+
|
|
3553
|
+
test('rewrites TRUNCATE statements to SQLite deletes', async () => {
|
|
3554
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3555
|
+
const backend = new DoBackend(http.url, 'postgres', 'truncate-rewrite-test')
|
|
3556
|
+
await backend.waitReady
|
|
3557
|
+
|
|
3558
|
+
await backend.exec('TRUNCATE public.data, "apiKey";')
|
|
3559
|
+
|
|
3560
|
+
const sent = compactSQL(http.sqls.at(-1) || '')
|
|
3561
|
+
expect(sent).toContain('DELETE FROM data')
|
|
3562
|
+
expect(sent).toContain('DELETE FROM "apiKey"')
|
|
3563
|
+
expect(sent).not.toContain('TRUNCATE')
|
|
3564
|
+
})
|
|
3565
|
+
|
|
3566
|
+
test('translates simple plpgsql row triggers to SQLite triggers', async () => {
|
|
3567
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3568
|
+
const backend = new DoBackend(http.url, 'postgres', 'sqlite-trigger-test')
|
|
3569
|
+
await backend.waitReady
|
|
3570
|
+
|
|
3571
|
+
await backend.exec(`
|
|
3572
|
+
CREATE OR REPLACE FUNCTION update_channel_latest_message_order()
|
|
3573
|
+
RETURNS TRIGGER AS $$
|
|
3574
|
+
BEGIN
|
|
3575
|
+
IF NEW.type IS DISTINCT FROM 'draft'
|
|
3576
|
+
AND NEW.deleted = false
|
|
3577
|
+
AND NEW."isThreadReply" = false
|
|
3578
|
+
AND NEW."order" IS NOT NULL THEN
|
|
3579
|
+
UPDATE "channel"
|
|
3580
|
+
SET "latestMessageOrder" = NEW."order"
|
|
3581
|
+
WHERE id = NEW."channelId"
|
|
3582
|
+
AND ("latestMessageOrder" IS NULL OR NEW."order" > "latestMessageOrder");
|
|
3583
|
+
END IF;
|
|
3584
|
+
RETURN NEW;
|
|
3585
|
+
END;
|
|
3586
|
+
$$ LANGUAGE plpgsql;
|
|
3587
|
+
|
|
3588
|
+
DROP TRIGGER IF EXISTS trg_update_channel_latest_message_order ON "message";
|
|
3589
|
+
CREATE TRIGGER trg_update_channel_latest_message_order
|
|
3590
|
+
AFTER INSERT OR UPDATE ON "message"
|
|
3591
|
+
FOR EACH ROW
|
|
3592
|
+
EXECUTE FUNCTION update_channel_latest_message_order();
|
|
3593
|
+
`)
|
|
3594
|
+
|
|
3595
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3596
|
+
expect(sent).toContain(
|
|
3597
|
+
'DROP TRIGGER IF EXISTS "trg_update_channel_latest_message_order_insert"'
|
|
3598
|
+
)
|
|
3599
|
+
expect(sent).toContain(
|
|
3600
|
+
'CREATE TRIGGER IF NOT EXISTS "trg_update_channel_latest_message_order_insert" AFTER INSERT ON "message"'
|
|
3601
|
+
)
|
|
3602
|
+
expect(sent).toContain(
|
|
3603
|
+
'CREATE TRIGGER IF NOT EXISTS "trg_update_channel_latest_message_order_update" AFTER UPDATE ON "message"'
|
|
3604
|
+
)
|
|
3605
|
+
expect(sent).toContain('UPDATE channel SET "latestMessageOrder" = new."order"')
|
|
3606
|
+
expect(sent).toContain('new.deleted = 0')
|
|
3607
|
+
expect(sent).not.toContain('CREATE OR REPLACE FUNCTION')
|
|
3608
|
+
expect(sent).not.toContain('EXECUTE FUNCTION')
|
|
3609
|
+
})
|
|
3610
|
+
|
|
3611
|
+
test('translates SELECT INTO NEW in plpgsql row triggers', async () => {
|
|
3612
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3613
|
+
const backend = new DoBackend(http.url, 'postgres', 'sqlite-select-into-trigger-test')
|
|
3614
|
+
await backend.waitReady
|
|
3615
|
+
|
|
3616
|
+
await backend.exec(`
|
|
3617
|
+
CREATE OR REPLACE FUNCTION update_seen_last_order()
|
|
3618
|
+
RETURNS TRIGGER AS $$
|
|
3619
|
+
BEGIN
|
|
3620
|
+
IF NEW."messageId" IS NOT NULL THEN
|
|
3621
|
+
SELECT "order" INTO NEW."lastSeenOrder"
|
|
3622
|
+
FROM "message"
|
|
3623
|
+
WHERE id = NEW."messageId";
|
|
3624
|
+
END IF;
|
|
3625
|
+
RETURN NEW;
|
|
3626
|
+
END;
|
|
3627
|
+
$$ LANGUAGE plpgsql;
|
|
3628
|
+
|
|
3629
|
+
CREATE TRIGGER trg_update_seen_last_order
|
|
3630
|
+
BEFORE INSERT OR UPDATE ON "seen"
|
|
3631
|
+
FOR EACH ROW
|
|
3632
|
+
EXECUTE FUNCTION update_seen_last_order();
|
|
3633
|
+
`)
|
|
3634
|
+
|
|
3635
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3636
|
+
expect(sent).toContain(
|
|
3637
|
+
'CREATE TRIGGER IF NOT EXISTS "trg_update_seen_last_order_insert" AFTER INSERT ON "seen"'
|
|
3638
|
+
)
|
|
3639
|
+
expect(sent).toContain(
|
|
3640
|
+
'UPDATE "seen" SET "lastSeenOrder" = (SELECT "order" FROM message WHERE id = new."messageId") WHERE rowid = NEW.rowid'
|
|
3641
|
+
)
|
|
3642
|
+
})
|
|
3643
|
+
|
|
3644
|
+
test('translates NEW column assignments in plpgsql row triggers', async () => {
|
|
3645
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3646
|
+
const backend = new DoBackend(
|
|
3647
|
+
http.url,
|
|
3648
|
+
'postgres',
|
|
3649
|
+
'sqlite-new-assignment-trigger-test'
|
|
3650
|
+
)
|
|
3651
|
+
await backend.waitReady
|
|
3652
|
+
|
|
3653
|
+
await backend.exec(`
|
|
3654
|
+
CREATE OR REPLACE FUNCTION update_task_numsteps()
|
|
3655
|
+
RETURNS TRIGGER AS $$
|
|
3656
|
+
BEGIN
|
|
3657
|
+
IF NEW."steps" IS NULL THEN
|
|
3658
|
+
NEW."numSteps" = 0;
|
|
3659
|
+
ELSE
|
|
3660
|
+
NEW."numSteps" = jsonb_array_length(NEW."steps");
|
|
3661
|
+
END IF;
|
|
3662
|
+
RETURN NEW;
|
|
3663
|
+
END;
|
|
3664
|
+
$$ LANGUAGE plpgsql;
|
|
3665
|
+
|
|
3666
|
+
CREATE TRIGGER task_numsteps_trigger
|
|
3667
|
+
BEFORE INSERT OR UPDATE OF "steps" ON "task"
|
|
3668
|
+
FOR EACH ROW
|
|
3669
|
+
EXECUTE FUNCTION update_task_numsteps();
|
|
3670
|
+
`)
|
|
3671
|
+
|
|
3672
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3673
|
+
expect(sent).toContain(
|
|
3674
|
+
'CREATE TRIGGER IF NOT EXISTS "task_numsteps_trigger_insert" AFTER INSERT ON "task"'
|
|
3675
|
+
)
|
|
3676
|
+
expect(sent).toContain(
|
|
3677
|
+
'CREATE TRIGGER IF NOT EXISTS "task_numsteps_trigger_update" AFTER UPDATE ON "task"'
|
|
3678
|
+
)
|
|
3679
|
+
expect(sent).toContain(
|
|
3680
|
+
'UPDATE "task" SET "numSteps" = 0 WHERE rowid = NEW.rowid AND (new.steps IS NULL)'
|
|
3681
|
+
)
|
|
3682
|
+
expect(sent).toContain(
|
|
3683
|
+
'UPDATE "task" SET "numSteps" = json_array_length(new.steps) WHERE rowid = NEW.rowid AND (NOT (new.steps IS NULL))'
|
|
3684
|
+
)
|
|
3685
|
+
})
|
|
3686
|
+
|
|
3687
|
+
test('removes update target aliases from compiled SQLite triggers', async () => {
|
|
3688
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3689
|
+
const backend = new DoBackend(
|
|
3690
|
+
http.url,
|
|
3691
|
+
'postgres',
|
|
3692
|
+
'sqlite-update-alias-trigger-test'
|
|
3693
|
+
)
|
|
3694
|
+
await backend.waitReady
|
|
3695
|
+
|
|
3696
|
+
await backend.exec(`
|
|
3697
|
+
CREATE OR REPLACE FUNCTION sync_user_role_permissions_on_insert()
|
|
3698
|
+
RETURNS TRIGGER AS $$
|
|
3699
|
+
BEGIN
|
|
3700
|
+
UPDATE "userRole" ur SET
|
|
3701
|
+
"canAdmin" = r."canAdmin"
|
|
3702
|
+
FROM "role" r
|
|
3703
|
+
WHERE ur."roleId" = r."id"
|
|
3704
|
+
AND ur."serverId" = NEW."serverId";
|
|
3705
|
+
RETURN NEW;
|
|
3706
|
+
END;
|
|
3707
|
+
$$ LANGUAGE plpgsql;
|
|
3708
|
+
|
|
3709
|
+
CREATE TRIGGER trg_user_role_permission_copy
|
|
3710
|
+
AFTER INSERT ON "userRole"
|
|
3711
|
+
FOR EACH ROW
|
|
3712
|
+
EXECUTE FUNCTION sync_user_role_permissions_on_insert();
|
|
3713
|
+
`)
|
|
3714
|
+
|
|
3715
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3716
|
+
expect(sent).toContain(
|
|
3717
|
+
'UPDATE "userRole" SET "canAdmin" = r."canAdmin" FROM role AS r WHERE "userRole"."roleId" = r.id'
|
|
3718
|
+
)
|
|
3719
|
+
expect(sent).toContain('"userRole"."serverId" = new."serverId"')
|
|
3720
|
+
expect(sent).not.toContain('UPDATE "userRole" AS ur')
|
|
3721
|
+
})
|
|
3722
|
+
|
|
3723
|
+
test('rewrites md5 trigger expressions to deterministic SQLite-compatible values', async () => {
|
|
3724
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3725
|
+
const backend = new DoBackend(http.url, 'postgres', 'sqlite-md5-trigger-test')
|
|
3726
|
+
await backend.waitReady
|
|
3727
|
+
|
|
3728
|
+
await backend.exec(`
|
|
3729
|
+
CREATE OR REPLACE FUNCTION set_permissions_hash()
|
|
3730
|
+
RETURNS TRIGGER AS $$
|
|
3731
|
+
BEGIN
|
|
3732
|
+
NEW.hash = md5(NEW.permissions::text);
|
|
3733
|
+
RETURN NEW;
|
|
3734
|
+
END;
|
|
3735
|
+
$$ LANGUAGE plpgsql;
|
|
3736
|
+
|
|
3737
|
+
CREATE TRIGGER on_set_permissions
|
|
3738
|
+
BEFORE INSERT OR UPDATE ON "chat_permissions"
|
|
3739
|
+
FOR EACH ROW
|
|
3740
|
+
EXECUTE FUNCTION set_permissions_hash();
|
|
3741
|
+
`)
|
|
3742
|
+
|
|
3743
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3744
|
+
expect(sent).toContain(
|
|
3745
|
+
'UPDATE "chat_permissions" SET "hash" = new.permissions WHERE rowid = NEW.rowid'
|
|
3746
|
+
)
|
|
3747
|
+
expect(sent).not.toContain('md5(')
|
|
3748
|
+
})
|
|
3749
|
+
|
|
3750
|
+
test('skips unsupported plpgsql trigger statements instead of throwing', async () => {
|
|
3751
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3752
|
+
const backend = new DoBackend(http.url, 'postgres', 'sqlite-unsupported-trigger-test')
|
|
3753
|
+
await backend.waitReady
|
|
3754
|
+
|
|
3755
|
+
await backend.exec(`
|
|
3756
|
+
CREATE OR REPLACE FUNCTION unsupported_trigger()
|
|
3757
|
+
RETURNS TRIGGER AS $$
|
|
3758
|
+
BEGIN
|
|
3759
|
+
invalid plpgsql syntax here;
|
|
3760
|
+
RETURN NEW;
|
|
3761
|
+
END;
|
|
3762
|
+
$$ LANGUAGE plpgsql;
|
|
3763
|
+
|
|
3764
|
+
CREATE TRIGGER unsupported_trigger
|
|
3765
|
+
BEFORE INSERT ON "task"
|
|
3766
|
+
FOR EACH ROW
|
|
3767
|
+
EXECUTE FUNCTION unsupported_trigger();
|
|
3768
|
+
`)
|
|
3769
|
+
|
|
3770
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3771
|
+
expect(sent).not.toContain('CREATE TRIGGER IF NOT EXISTS "unsupported_trigger"')
|
|
3772
|
+
})
|
|
3773
|
+
|
|
3774
|
+
test('skips plpgsql triggers that require PostgreSQL trigger variables', async () => {
|
|
3775
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3776
|
+
const backend = new DoBackend(http.url, 'postgres', 'sqlite-trigger-variable-test')
|
|
3777
|
+
await backend.waitReady
|
|
3778
|
+
|
|
3779
|
+
await backend.exec(`
|
|
3780
|
+
CREATE OR REPLACE FUNCTION refresh_stats()
|
|
3781
|
+
RETURNS TRIGGER AS $$
|
|
3782
|
+
BEGIN
|
|
3783
|
+
IF TG_OP = 'INSERT' THEN
|
|
3784
|
+
UPDATE stats SET count = count + 1;
|
|
3785
|
+
END IF;
|
|
3786
|
+
RETURN NEW;
|
|
3787
|
+
END;
|
|
3788
|
+
$$ LANGUAGE plpgsql;
|
|
3789
|
+
|
|
3790
|
+
CREATE TRIGGER refresh_stats_insert
|
|
3791
|
+
AFTER INSERT ON "messageReaction"
|
|
3792
|
+
FOR EACH ROW
|
|
3793
|
+
EXECUTE FUNCTION refresh_stats();
|
|
3794
|
+
`)
|
|
3795
|
+
|
|
3796
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3797
|
+
expect(sent).not.toContain('CREATE TRIGGER IF NOT EXISTS "refresh_stats_insert"')
|
|
3798
|
+
expect(sent).not.toContain('TG_OP')
|
|
3799
|
+
})
|
|
3800
|
+
|
|
3801
|
+
test('rewrites trigger drops for SQLite trigger variants', async () => {
|
|
3802
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3803
|
+
const backend = new DoBackend(http.url, 'postgres', 'drop-trigger-test')
|
|
3804
|
+
await backend.waitReady
|
|
3805
|
+
|
|
3806
|
+
await backend.exec(
|
|
3807
|
+
'DROP TRIGGER IF EXISTS "messageReactionInsertTrigger" ON "messageReaction";'
|
|
3808
|
+
)
|
|
3809
|
+
|
|
3810
|
+
const sent = http.sqls.map(compactSQL).join('\n')
|
|
3811
|
+
expect(sent).toContain('DROP TRIGGER IF EXISTS "messageReactionInsertTrigger"')
|
|
3812
|
+
expect(sent).toContain('DROP TRIGGER IF EXISTS "messageReactionInsertTrigger_insert"')
|
|
3813
|
+
})
|
|
3814
|
+
|
|
3815
|
+
test('drops event trigger statements because event trigger creation is skipped', async () => {
|
|
3816
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3817
|
+
const backend = new DoBackend(http.url, 'postgres', 'drop-event-trigger-test')
|
|
3818
|
+
await backend.waitReady
|
|
3819
|
+
|
|
3820
|
+
await backend.exec(`
|
|
3821
|
+
DROP EVENT TRIGGER IF EXISTS chat_ddl_start_0;
|
|
3822
|
+
CREATE EVENT TRIGGER chat_ddl_start_0
|
|
3823
|
+
ON ddl_command_start EXECUTE FUNCTION public._zero_notify_change();
|
|
3824
|
+
`)
|
|
3825
|
+
|
|
3826
|
+
expect(http.sqls.some((sql) => compactSQL(sql).includes('EVENT TRIGGER'))).toBe(false)
|
|
3827
|
+
})
|
|
3828
|
+
|
|
3829
|
+
test('drops function statements because function creation is skipped', async () => {
|
|
3830
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3831
|
+
const backend = new DoBackend(http.url, 'postgres', 'drop-function-test')
|
|
3832
|
+
await backend.waitReady
|
|
3833
|
+
|
|
3834
|
+
await backend.exec('DROP FUNCTION IF EXISTS "notifyReactionChange"();')
|
|
3835
|
+
|
|
3836
|
+
expect(http.sqls.some((sql) => compactSQL(sql).startsWith('DROP FUNCTION'))).toBe(
|
|
3837
|
+
false
|
|
3838
|
+
)
|
|
3839
|
+
})
|
|
3840
|
+
|
|
3841
|
+
test('drops anonymous DO blocks because they only wrap skipped PG DDL', async () => {
|
|
3842
|
+
const http = await startDoHttp(() => ({ rows: [], columns: [] }))
|
|
3843
|
+
const backend = new DoBackend(http.url, 'postgres', 'do-block-test')
|
|
3844
|
+
await backend.waitReady
|
|
3845
|
+
|
|
3846
|
+
await backend.exec(`
|
|
3847
|
+
DO $$ BEGIN
|
|
3848
|
+
IF NOT EXISTS (
|
|
3849
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'example_constraint'
|
|
3850
|
+
) THEN
|
|
3851
|
+
ALTER TABLE "example" ADD CONSTRAINT "example_constraint" UNIQUE ("id");
|
|
3852
|
+
END IF;
|
|
3853
|
+
END $$;
|
|
3854
|
+
`)
|
|
3855
|
+
|
|
3856
|
+
expect(http.sqls.some((sql) => compactSQL(sql).startsWith('DO'))).toBe(false)
|
|
3857
|
+
})
|
|
3858
|
+
|
|
3859
|
+
test('no-ops direct calls to functions skipped by parser-backed DDL rewrite', async () => {
|
|
3860
|
+
const http = await startDoHttp((sql) => {
|
|
3861
|
+
if (compactSQL(sql).startsWith('SELECT NULL AS')) {
|
|
3862
|
+
return {
|
|
3863
|
+
rows: [{ refreshMessageReactionStats: null }],
|
|
3864
|
+
columns: ['refreshMessageReactionStats'],
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
return { rows: [], columns: [] }
|
|
3868
|
+
})
|
|
3869
|
+
const backend = new DoBackend(http.url, 'postgres', 'skipped-function-call-test')
|
|
3870
|
+
await backend.waitReady
|
|
3871
|
+
|
|
3872
|
+
await backend.exec(`
|
|
3873
|
+
CREATE OR REPLACE FUNCTION "refreshMessageReactionStats"()
|
|
3874
|
+
RETURNS void AS $$
|
|
3875
|
+
BEGIN
|
|
3876
|
+
END;
|
|
3877
|
+
$$ LANGUAGE plpgsql;
|
|
3878
|
+
`)
|
|
3879
|
+
await backend.exec('SELECT "refreshMessageReactionStats"();')
|
|
3880
|
+
|
|
3881
|
+
expect(http.sqls.some((sql) => sql.includes('"refreshMessageReactionStats"()'))).toBe(
|
|
3882
|
+
false
|
|
3883
|
+
)
|
|
3884
|
+
expect(
|
|
3885
|
+
http.sqls.some((sql) =>
|
|
3886
|
+
compactSQL(sql).includes('SELECT NULL AS "refreshMessageReactionStats"')
|
|
3887
|
+
)
|
|
3888
|
+
).toBe(true)
|
|
3889
|
+
})
|
|
3890
|
+
})
|