orez 0.1.6 → 0.1.8
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/README.md +186 -225
- package/dist/admin/log-store.d.ts.map +1 -1
- package/dist/admin/log-store.js +17 -6
- package/dist/admin/log-store.js.map +1 -1
- package/dist/admin/server.d.ts +1 -0
- package/dist/admin/server.d.ts.map +1 -1
- package/dist/admin/server.js +10 -0
- package/dist/admin/server.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +96 -46
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +158 -23
- package/dist/index.js.map +1 -1
- package/dist/integration/test-permissions.d.ts +7 -0
- package/dist/integration/test-permissions.d.ts.map +1 -0
- package/dist/integration/test-permissions.js +117 -0
- package/dist/integration/test-permissions.js.map +1 -0
- package/dist/pg-proxy.js +2 -2
- package/dist/pg-proxy.js.map +1 -1
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +15 -13
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +27 -2
- package/dist/replication/handler.js.map +1 -1
- package/dist/sqlite-mode/index.d.ts +1 -0
- package/dist/sqlite-mode/index.d.ts.map +1 -1
- package/dist/sqlite-mode/index.js +1 -0
- package/dist/sqlite-mode/index.js.map +1 -1
- package/dist/sqlite-mode/native-binary.d.ts +11 -0
- package/dist/sqlite-mode/native-binary.d.ts.map +1 -0
- package/dist/sqlite-mode/native-binary.js +67 -0
- package/dist/sqlite-mode/native-binary.js.map +1 -0
- package/dist/sqlite-mode/package-resolve.d.ts +6 -0
- package/dist/sqlite-mode/package-resolve.d.ts.map +1 -0
- package/dist/sqlite-mode/package-resolve.js +20 -0
- package/dist/sqlite-mode/package-resolve.js.map +1 -0
- package/dist/sqlite-mode/resolve-mode.d.ts +12 -7
- package/dist/sqlite-mode/resolve-mode.d.ts.map +1 -1
- package/dist/sqlite-mode/resolve-mode.js +27 -23
- package/dist/sqlite-mode/resolve-mode.js.map +1 -1
- package/package.json +8 -2
- package/src/admin/log-store.ts +19 -9
- package/src/admin/server.ts +12 -0
- package/src/cli.ts +99 -44
- package/src/config.ts +2 -0
- package/src/index.ts +186 -24
- package/src/integration/integration.test.ts +93 -15
- package/src/integration/native-binary.guard.test.ts +13 -0
- package/src/integration/native-startup.test.ts +44 -0
- package/src/integration/restore-live-stress.test.ts +433 -0
- package/src/integration/restore-reset.test.ts +136 -20
- package/src/integration/test-permissions.ts +147 -0
- package/src/pg-proxy.ts +2 -2
- package/src/replication/change-tracker.test.ts +1 -1
- package/src/replication/change-tracker.ts +16 -13
- package/src/replication/handler.test.ts +2 -2
- package/src/replication/handler.ts +30 -2
- package/src/sqlite-mode/index.ts +1 -0
- package/src/sqlite-mode/native-binary.ts +89 -0
- package/src/sqlite-mode/package-resolve.ts +17 -0
- package/src/sqlite-mode/resolve-mode.ts +31 -21
- package/src/sqlite-mode/sqlite-mode.test.ts +11 -5
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live restore stress test.
|
|
3
|
+
*
|
|
4
|
+
* keeps a frontend-like websocket connection active while a large restore runs,
|
|
5
|
+
* then triggers the same full reset path used by pg_restore (SIGUSR1) and
|
|
6
|
+
* verifies sync still works after restart.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { loadModule } from 'pgsql-parser'
|
|
14
|
+
import postgres from 'postgres'
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
|
16
|
+
import WebSocket from 'ws'
|
|
17
|
+
|
|
18
|
+
import { execDumpFile } from '../cli.js'
|
|
19
|
+
import { startZeroLite } from '../index.js'
|
|
20
|
+
import { installChangeTracking } from '../replication/change-tracker.js'
|
|
21
|
+
import {
|
|
22
|
+
ensureTablesInPublications,
|
|
23
|
+
hasNonNullPermissions,
|
|
24
|
+
installAllowAllPermissions,
|
|
25
|
+
} from './test-permissions.js'
|
|
26
|
+
|
|
27
|
+
import type { PGlite } from '@electric-sql/pglite'
|
|
28
|
+
|
|
29
|
+
const SYNC_PROTOCOL_VERSION = 45
|
|
30
|
+
const LIVE_CLIENT_SCHEMA = {
|
|
31
|
+
tables: {
|
|
32
|
+
restore_live_probe: {
|
|
33
|
+
columns: {
|
|
34
|
+
id: { type: 'string' },
|
|
35
|
+
value: { type: 'string' },
|
|
36
|
+
},
|
|
37
|
+
primaryKey: ['id'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function encodeSecProtocols(
|
|
43
|
+
initConnectionMessage: unknown,
|
|
44
|
+
authToken: string | undefined
|
|
45
|
+
): string {
|
|
46
|
+
const payload = JSON.stringify({ initConnectionMessage, authToken })
|
|
47
|
+
return encodeURIComponent(Buffer.from(payload, 'utf-8').toString('base64'))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class Queue<T> {
|
|
51
|
+
private items: T[] = []
|
|
52
|
+
private waiters: Array<{
|
|
53
|
+
resolve: (v: T) => void
|
|
54
|
+
timer?: ReturnType<typeof setTimeout>
|
|
55
|
+
}> = []
|
|
56
|
+
|
|
57
|
+
enqueue(item: T) {
|
|
58
|
+
const waiter = this.waiters.shift()
|
|
59
|
+
if (waiter) {
|
|
60
|
+
if (waiter.timer) clearTimeout(waiter.timer)
|
|
61
|
+
waiter.resolve(item)
|
|
62
|
+
} else {
|
|
63
|
+
this.items.push(item)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
dequeue(fallback?: T, timeoutMs = 10_000): Promise<T> {
|
|
68
|
+
if (this.items.length > 0) {
|
|
69
|
+
return Promise.resolve(this.items.shift()!)
|
|
70
|
+
}
|
|
71
|
+
return new Promise<T>((resolve) => {
|
|
72
|
+
const waiter: { resolve: (v: T) => void; timer?: ReturnType<typeof setTimeout> } = {
|
|
73
|
+
resolve,
|
|
74
|
+
}
|
|
75
|
+
if (fallback !== undefined) {
|
|
76
|
+
waiter.timer = setTimeout(() => {
|
|
77
|
+
const idx = this.waiters.indexOf(waiter)
|
|
78
|
+
if (idx >= 0) this.waiters.splice(idx, 1)
|
|
79
|
+
resolve(fallback)
|
|
80
|
+
}, timeoutMs)
|
|
81
|
+
}
|
|
82
|
+
this.waiters.push(waiter)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function envInt(name: string, fallback: number): number {
|
|
88
|
+
const raw = process.env[name]
|
|
89
|
+
if (!raw) return fallback
|
|
90
|
+
const n = Number(raw)
|
|
91
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeCopy(val: string): string {
|
|
95
|
+
return val
|
|
96
|
+
.replace(/\\/g, '\\\\')
|
|
97
|
+
.replace(/\t/g, '\\t')
|
|
98
|
+
.replace(/\n/g, '\\n')
|
|
99
|
+
.replace(/\r/g, '\\r')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function generateStressDump(opts: {
|
|
103
|
+
tables: number
|
|
104
|
+
rowsPerTable: number
|
|
105
|
+
columnsPerTable: number
|
|
106
|
+
payloadBytes: number
|
|
107
|
+
}): string {
|
|
108
|
+
const lines: string[] = []
|
|
109
|
+
lines.push('SET statement_timeout = 0;')
|
|
110
|
+
lines.push("SET client_encoding = 'UTF8';")
|
|
111
|
+
lines.push('SET standard_conforming_strings = on;')
|
|
112
|
+
lines.push('')
|
|
113
|
+
|
|
114
|
+
for (let t = 0; t < opts.tables; t++) {
|
|
115
|
+
const table = `stress_restore_${t}`
|
|
116
|
+
const cols = Array.from({ length: opts.columnsPerTable }, (_, i) => `c_${i} TEXT`)
|
|
117
|
+
lines.push(
|
|
118
|
+
`CREATE TABLE IF NOT EXISTS ${table} (id BIGINT PRIMARY KEY, ${cols.join(', ')});`
|
|
119
|
+
)
|
|
120
|
+
lines.push(
|
|
121
|
+
`COPY ${table} (id, ${Array.from({ length: opts.columnsPerTable }, (_, i) => `c_${i}`).join(', ')}) FROM stdin;`
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for (let r = 0; r < opts.rowsPerTable; r++) {
|
|
125
|
+
const id = t * 1_000_000 + r + 1
|
|
126
|
+
const row = Array.from({ length: opts.columnsPerTable }, (_, c) => {
|
|
127
|
+
if (r % 97 === 0 && c === 0) return '\\N'
|
|
128
|
+
const base = `t${t}_r${r}_c${c}_`
|
|
129
|
+
return escapeCopy(base + 'x'.repeat(Math.max(1, opts.payloadBytes - base.length)))
|
|
130
|
+
})
|
|
131
|
+
lines.push(`${id}\t${row.join('\t')}`)
|
|
132
|
+
}
|
|
133
|
+
lines.push('\\.')
|
|
134
|
+
lines.push('')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return lines.join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function connectAndSubscribe(
|
|
141
|
+
port: number,
|
|
142
|
+
downstream: Queue<unknown>,
|
|
143
|
+
query: Record<string, unknown>
|
|
144
|
+
): Promise<WebSocket> {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const initConnectionMessage: [string, Record<string, unknown>] = [
|
|
147
|
+
'initConnection',
|
|
148
|
+
{
|
|
149
|
+
desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
|
|
150
|
+
clientSchema: LIVE_CLIENT_SCHEMA,
|
|
151
|
+
},
|
|
152
|
+
]
|
|
153
|
+
const secProtocol = encodeSecProtocols(initConnectionMessage, undefined)
|
|
154
|
+
const ws = new WebSocket(
|
|
155
|
+
`ws://127.0.0.1:${port}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
|
|
156
|
+
`?clientGroupID=restore-live-cg-${Date.now()}` +
|
|
157
|
+
`&clientID=restore-live-client` +
|
|
158
|
+
`&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
|
|
159
|
+
secProtocol
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
let settled = false
|
|
163
|
+
const failTimer = setTimeout(() => {
|
|
164
|
+
if (settled) return
|
|
165
|
+
settled = true
|
|
166
|
+
try {
|
|
167
|
+
ws.close()
|
|
168
|
+
} catch {}
|
|
169
|
+
reject(new Error('websocket connected but no downstream messages'))
|
|
170
|
+
}, 7000)
|
|
171
|
+
|
|
172
|
+
ws.on('message', (data) => {
|
|
173
|
+
const msg = JSON.parse(data.toString())
|
|
174
|
+
downstream.enqueue(msg)
|
|
175
|
+
if (!settled) {
|
|
176
|
+
settled = true
|
|
177
|
+
clearTimeout(failTimer)
|
|
178
|
+
resolve(ws)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
ws.once('error', (err) => {
|
|
182
|
+
if (settled) return
|
|
183
|
+
settled = true
|
|
184
|
+
clearTimeout(failTimer)
|
|
185
|
+
reject(err)
|
|
186
|
+
})
|
|
187
|
+
ws.once('close', () => {
|
|
188
|
+
if (settled) return
|
|
189
|
+
settled = true
|
|
190
|
+
clearTimeout(failTimer)
|
|
191
|
+
reject(new Error('websocket closed before initial downstream message'))
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function connectAndSubscribeWithRetry(
|
|
197
|
+
port: number,
|
|
198
|
+
downstream: Queue<unknown>,
|
|
199
|
+
query: Record<string, unknown>,
|
|
200
|
+
timeoutMs = 30_000
|
|
201
|
+
): Promise<WebSocket> {
|
|
202
|
+
const deadline = Date.now() + timeoutMs
|
|
203
|
+
let lastErr: unknown
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
try {
|
|
206
|
+
return await connectAndSubscribe(port, downstream, query)
|
|
207
|
+
} catch (err) {
|
|
208
|
+
lastErr = err
|
|
209
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw new Error(
|
|
213
|
+
`timed out connecting websocket after reset: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function drainInitialPokes(downstream: Queue<unknown>) {
|
|
218
|
+
const deadline = Date.now() + 30_000
|
|
219
|
+
while (Date.now() < deadline) {
|
|
220
|
+
const msg = (await downstream.dequeue('timeout' as any, 3000)) as any
|
|
221
|
+
if (msg === 'timeout') return
|
|
222
|
+
if (Array.isArray(msg) && msg[0] === 'pokeEnd') return
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function waitForPokeWithValue(
|
|
227
|
+
downstream: Queue<unknown>,
|
|
228
|
+
expectedValue: string,
|
|
229
|
+
timeoutMs = 20_000
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const deadline = Date.now() + timeoutMs
|
|
232
|
+
const seen: unknown[] = []
|
|
233
|
+
while (Date.now() < deadline) {
|
|
234
|
+
const remaining = Math.max(1000, deadline - Date.now())
|
|
235
|
+
const msg = (await downstream.dequeue('timeout' as any, remaining)) as any
|
|
236
|
+
if (msg === 'timeout') {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`timed out waiting for pokePart; recent messages: ${JSON.stringify(seen.slice(-8))}`
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
seen.push(msg)
|
|
242
|
+
if (!Array.isArray(msg) || msg[0] !== 'pokePart' || !msg[1]?.rowsPatch) continue
|
|
243
|
+
const rowsPatch = msg[1].rowsPatch as Array<Record<string, any>>
|
|
244
|
+
if (
|
|
245
|
+
rowsPatch.some(
|
|
246
|
+
(patch) =>
|
|
247
|
+
patch.op === 'put' &&
|
|
248
|
+
patch.tableName === 'restore_live_probe' &&
|
|
249
|
+
patch.value?.value === expectedValue
|
|
250
|
+
)
|
|
251
|
+
) {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
throw new Error(
|
|
256
|
+
`timed out waiting for restore_live_probe value "${expectedValue}"; recent messages: ${JSON.stringify(seen.slice(-8))}`
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function waitForZero(port: number, timeoutMs = 60_000) {
|
|
261
|
+
const { Socket } = await import('node:net')
|
|
262
|
+
const deadline = Date.now() + timeoutMs
|
|
263
|
+
while (Date.now() < deadline) {
|
|
264
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
265
|
+
const sock = new Socket()
|
|
266
|
+
const done = (value: boolean) => {
|
|
267
|
+
sock.removeAllListeners()
|
|
268
|
+
try {
|
|
269
|
+
sock.destroy()
|
|
270
|
+
} catch {}
|
|
271
|
+
resolve(value)
|
|
272
|
+
}
|
|
273
|
+
sock.setTimeout(1000)
|
|
274
|
+
sock.once('connect', () => done(true))
|
|
275
|
+
sock.once('timeout', () => done(false))
|
|
276
|
+
sock.once('error', () => done(false))
|
|
277
|
+
sock.connect(port, '127.0.0.1')
|
|
278
|
+
})
|
|
279
|
+
if (ok) return
|
|
280
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
281
|
+
}
|
|
282
|
+
throw new Error(`zero-cache not ready on port ${port} after ${timeoutMs}ms`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
describe('live restore stress with connected frontend', { timeout: 360_000 }, () => {
|
|
286
|
+
let db: PGlite
|
|
287
|
+
let pgPort: number
|
|
288
|
+
let zeroPort: number
|
|
289
|
+
let shutdown: () => Promise<void>
|
|
290
|
+
let restartZero: (() => Promise<void>) | undefined
|
|
291
|
+
let resetZeroFull: (() => Promise<void>) | undefined
|
|
292
|
+
let dataDir: string
|
|
293
|
+
let dumpFile: string
|
|
294
|
+
|
|
295
|
+
beforeAll(async () => {
|
|
296
|
+
await loadModule()
|
|
297
|
+
|
|
298
|
+
const tables = envInt('OREZ_STRESS_TABLES', 6)
|
|
299
|
+
const rowsPerTable = envInt('OREZ_STRESS_ROWS', 1800)
|
|
300
|
+
const columnsPerTable = envInt('OREZ_STRESS_COLS', 8)
|
|
301
|
+
const payloadBytes = envInt('OREZ_STRESS_PAYLOAD', 96)
|
|
302
|
+
|
|
303
|
+
dumpFile = join(tmpdir(), `orez-live-stress-${Date.now()}.sql`)
|
|
304
|
+
writeFileSync(
|
|
305
|
+
dumpFile,
|
|
306
|
+
generateStressDump({ tables, rowsPerTable, columnsPerTable, payloadBytes })
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
dataDir = `.orez-live-stress-test-${Date.now()}`
|
|
310
|
+
const started = await startZeroLite({
|
|
311
|
+
pgPort: 29000 + Math.floor(Math.random() * 1000),
|
|
312
|
+
zeroPort: 30000 + Math.floor(Math.random() * 1000),
|
|
313
|
+
dataDir,
|
|
314
|
+
logLevel: 'warn',
|
|
315
|
+
skipZeroCache: false,
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
db = started.db
|
|
319
|
+
pgPort = started.pgPort
|
|
320
|
+
zeroPort = started.zeroPort
|
|
321
|
+
shutdown = started.stop
|
|
322
|
+
restartZero = started.restartZero
|
|
323
|
+
resetZeroFull = started.resetZeroFull
|
|
324
|
+
await waitForZero(zeroPort, 90_000)
|
|
325
|
+
}, 180_000)
|
|
326
|
+
|
|
327
|
+
afterAll(async () => {
|
|
328
|
+
if (shutdown) await shutdown()
|
|
329
|
+
try {
|
|
330
|
+
unlinkSync(dumpFile)
|
|
331
|
+
} catch {}
|
|
332
|
+
if (dataDir) {
|
|
333
|
+
try {
|
|
334
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
335
|
+
} catch {}
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('frontend stays connected through restore lifecycle and syncs after reset', async () => {
|
|
340
|
+
await db.exec(`
|
|
341
|
+
CREATE TABLE IF NOT EXISTS restore_live_probe (
|
|
342
|
+
id TEXT PRIMARY KEY,
|
|
343
|
+
value TEXT NOT NULL
|
|
344
|
+
)
|
|
345
|
+
`)
|
|
346
|
+
await ensureTablesInPublications(db, ['restore_live_probe'])
|
|
347
|
+
await installAllowAllPermissions(db, ['restore_live_probe'])
|
|
348
|
+
expect(await hasNonNullPermissions(db)).toBe(true)
|
|
349
|
+
if (resetZeroFull) {
|
|
350
|
+
await resetZeroFull()
|
|
351
|
+
await waitForZero(zeroPort, 90_000)
|
|
352
|
+
} else if (restartZero) {
|
|
353
|
+
await restartZero()
|
|
354
|
+
await waitForZero(zeroPort, 60_000)
|
|
355
|
+
}
|
|
356
|
+
const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
|
|
357
|
+
if (pubName) {
|
|
358
|
+
const quotedPub = '"' + pubName.replace(/"/g, '""') + '"'
|
|
359
|
+
await db
|
|
360
|
+
.exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public"."restore_live_probe"`)
|
|
361
|
+
.catch(() => {})
|
|
362
|
+
await installChangeTracking(db)
|
|
363
|
+
}
|
|
364
|
+
await db.query(`INSERT INTO restore_live_probe (id, value) VALUES ($1, $2)`, [
|
|
365
|
+
'before-restore',
|
|
366
|
+
'before',
|
|
367
|
+
])
|
|
368
|
+
|
|
369
|
+
const downstream = new Queue<unknown>()
|
|
370
|
+
let ws = await connectAndSubscribeWithRetry(zeroPort, downstream, {
|
|
371
|
+
table: 'restore_live_probe',
|
|
372
|
+
orderBy: [['id', 'asc']],
|
|
373
|
+
})
|
|
374
|
+
await drainInitialPokes(downstream)
|
|
375
|
+
|
|
376
|
+
// restore while websocket is connected (frontend simulation)
|
|
377
|
+
const sql = postgres({
|
|
378
|
+
host: '127.0.0.1',
|
|
379
|
+
port: pgPort,
|
|
380
|
+
user: 'user',
|
|
381
|
+
password: 'password',
|
|
382
|
+
database: 'postgres',
|
|
383
|
+
max: 1,
|
|
384
|
+
onnotice: () => {},
|
|
385
|
+
})
|
|
386
|
+
try {
|
|
387
|
+
const wireDb = { exec: (query: string) => sql.unsafe(query) as Promise<unknown> }
|
|
388
|
+
await execDumpFile(wireDb, dumpFile)
|
|
389
|
+
} finally {
|
|
390
|
+
await sql.end({ timeout: 1 }).catch(() => {})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const pid = Number(readFileSync(join(dataDir, 'orez.pid'), 'utf-8').trim())
|
|
394
|
+
expect(pid).toBeGreaterThan(0)
|
|
395
|
+
process.kill(pid, 'SIGUSR1')
|
|
396
|
+
await waitForZero(zeroPort, 90_000)
|
|
397
|
+
if (pubName) {
|
|
398
|
+
const quotedPub = '"' + pubName.replace(/"/g, '""') + '"'
|
|
399
|
+
await db
|
|
400
|
+
.exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public"."restore_live_probe"`)
|
|
401
|
+
.catch(() => {})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
ws.close()
|
|
406
|
+
} catch {}
|
|
407
|
+
const downstreamAfterReset = new Queue<unknown>()
|
|
408
|
+
ws = await connectAndSubscribeWithRetry(zeroPort, downstreamAfterReset, {
|
|
409
|
+
table: 'restore_live_probe',
|
|
410
|
+
orderBy: [['id', 'asc']],
|
|
411
|
+
})
|
|
412
|
+
await drainInitialPokes(downstreamAfterReset)
|
|
413
|
+
|
|
414
|
+
// verify write is captured in change tracking after reset
|
|
415
|
+
const marker = `after-${Date.now()}`
|
|
416
|
+
await db.query(`INSERT INTO restore_live_probe (id, value) VALUES ($1, $2)`, [
|
|
417
|
+
`post-restore-${Date.now()}`,
|
|
418
|
+
marker,
|
|
419
|
+
])
|
|
420
|
+
const tracked = await db.query<{ count: string }>(
|
|
421
|
+
`SELECT count(*)::text as count
|
|
422
|
+
FROM _orez._zero_changes
|
|
423
|
+
WHERE table_name = 'public.restore_live_probe'`
|
|
424
|
+
)
|
|
425
|
+
if (Number(tracked.rows[0]?.count || '0') === 0) {
|
|
426
|
+
throw new Error('post-reset write was not captured in _orez._zero_changes')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await waitForPokeWithValue(downstreamAfterReset, marker, 30_000)
|
|
430
|
+
|
|
431
|
+
ws.close()
|
|
432
|
+
})
|
|
433
|
+
})
|
|
@@ -18,6 +18,35 @@ import WebSocket from 'ws'
|
|
|
18
18
|
|
|
19
19
|
import { execDumpFile } from '../cli.js'
|
|
20
20
|
import { startZeroLite } from '../index.js'
|
|
21
|
+
import {
|
|
22
|
+
ensureTablesInPublications,
|
|
23
|
+
hasNonNullPermissions,
|
|
24
|
+
installAllowAllPermissions,
|
|
25
|
+
} from './test-permissions.js'
|
|
26
|
+
|
|
27
|
+
// zero-cache protocol version (from @rocicorp/zero/out/zero-protocol/src/protocol-version.js)
|
|
28
|
+
const PROTOCOL_VERSION = 45
|
|
29
|
+
const RESET_CLIENT_SCHEMA = {
|
|
30
|
+
tables: {
|
|
31
|
+
reset_probe: {
|
|
32
|
+
columns: {
|
|
33
|
+
id: { type: 'string' },
|
|
34
|
+
value: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
primaryKey: ['id'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// encode initConnection message for sec-websocket-protocol header
|
|
42
|
+
// matches zero-protocol's encodeSecProtocols implementation
|
|
43
|
+
function encodeSecProtocols(
|
|
44
|
+
initConnectionMessage: unknown,
|
|
45
|
+
authToken: string | undefined
|
|
46
|
+
): string {
|
|
47
|
+
const payload = JSON.stringify({ initConnectionMessage, authToken })
|
|
48
|
+
return encodeURIComponent(Buffer.from(payload, 'utf-8').toString('base64'))
|
|
49
|
+
}
|
|
21
50
|
|
|
22
51
|
import type { PGlite } from '@electric-sql/pglite'
|
|
23
52
|
|
|
@@ -102,6 +131,8 @@ describe('restore/reset integration regression', { timeout: 150_000 }, () => {
|
|
|
102
131
|
let pgPort: number
|
|
103
132
|
let zeroPort: number
|
|
104
133
|
let shutdown: () => Promise<void>
|
|
134
|
+
let restartZero: (() => Promise<void>) | undefined
|
|
135
|
+
let resetZeroFull: (() => Promise<void>) | undefined
|
|
105
136
|
let dataDir: string
|
|
106
137
|
let dumpFile: string
|
|
107
138
|
let dumpFileIsTemp = false
|
|
@@ -127,6 +158,8 @@ describe('restore/reset integration regression', { timeout: 150_000 }, () => {
|
|
|
127
158
|
pgPort = started.pgPort
|
|
128
159
|
zeroPort = started.zeroPort
|
|
129
160
|
shutdown = started.stop
|
|
161
|
+
restartZero = started.restartZero
|
|
162
|
+
resetZeroFull = started.resetZeroFull
|
|
130
163
|
|
|
131
164
|
await waitForZero(zeroPort, 90_000)
|
|
132
165
|
}, 120_000)
|
|
@@ -175,11 +208,40 @@ describe('restore/reset integration regression', { timeout: 150_000 }, () => {
|
|
|
175
208
|
CREATE TABLE IF NOT EXISTS reset_probe (
|
|
176
209
|
id text PRIMARY KEY,
|
|
177
210
|
value text NOT NULL
|
|
178
|
-
)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
-- install change tracking trigger on the new table
|
|
214
|
+
DROP TRIGGER IF EXISTS _zero_change_trigger ON public.reset_probe;
|
|
215
|
+
CREATE TRIGGER _zero_change_trigger
|
|
216
|
+
AFTER INSERT OR UPDATE OR DELETE ON public.reset_probe
|
|
217
|
+
FOR EACH ROW EXECUTE FUNCTION public._zero_track_change();
|
|
218
|
+
|
|
219
|
+
-- install notify trigger for real-time notifications
|
|
220
|
+
DROP TRIGGER IF EXISTS _zero_notify_trigger ON public.reset_probe;
|
|
221
|
+
CREATE TRIGGER _zero_notify_trigger
|
|
222
|
+
AFTER INSERT OR UPDATE OR DELETE ON public.reset_probe
|
|
223
|
+
FOR EACH STATEMENT EXECUTE FUNCTION public._zero_notify_change();
|
|
179
224
|
`)
|
|
225
|
+
await ensureTablesInPublications(db, ['reset_probe'])
|
|
226
|
+
const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
|
|
227
|
+
if (pubName) {
|
|
228
|
+
const quotedPub = '"' + pubName.replace(/"/g, '""') + '"'
|
|
229
|
+
await db
|
|
230
|
+
.exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public"."reset_probe"`)
|
|
231
|
+
.catch(() => {})
|
|
232
|
+
}
|
|
233
|
+
await installAllowAllPermissions(db, ['reset_probe'])
|
|
234
|
+
expect(await hasNonNullPermissions(db)).toBe(true)
|
|
235
|
+
if (resetZeroFull) {
|
|
236
|
+
await resetZeroFull()
|
|
237
|
+
await waitForZero(zeroPort, 90_000)
|
|
238
|
+
} else if (restartZero) {
|
|
239
|
+
await restartZero()
|
|
240
|
+
await waitForZero(zeroPort, 60_000)
|
|
241
|
+
}
|
|
180
242
|
|
|
181
243
|
const downstream = new Queue<unknown>()
|
|
182
|
-
const ws =
|
|
244
|
+
const ws = await connectAndSubscribeWithRetry(zeroPort, downstream, {
|
|
183
245
|
table: 'reset_probe',
|
|
184
246
|
orderBy: [['id', 'asc']],
|
|
185
247
|
})
|
|
@@ -214,28 +276,82 @@ function connectAndSubscribe(
|
|
|
214
276
|
port: number,
|
|
215
277
|
downstream: Queue<unknown>,
|
|
216
278
|
query: Record<string, unknown>
|
|
217
|
-
): WebSocket {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
279
|
+
): Promise<WebSocket> {
|
|
280
|
+
return new Promise((resolve, reject) => {
|
|
281
|
+
const ts = Date.now()
|
|
282
|
+
const clientGroupID = `restore-reset-cg-${ts}`
|
|
283
|
+
const clientID = 'restore-reset-client'
|
|
284
|
+
const initConnectionMessage: [string, Record<string, unknown>] = [
|
|
285
|
+
'initConnection',
|
|
286
|
+
{
|
|
287
|
+
desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
|
|
288
|
+
clientSchema: RESET_CLIENT_SCHEMA,
|
|
289
|
+
},
|
|
290
|
+
]
|
|
291
|
+
const secProtocol = encodeSecProtocols(initConnectionMessage, undefined)
|
|
292
|
+
const ws = new WebSocket(
|
|
293
|
+
`ws://127.0.0.1:${port}/sync/v${PROTOCOL_VERSION}/connect` +
|
|
294
|
+
`?clientGroupID=${clientGroupID}&clientID=${clientID}&wsid=ws1&schemaVersion=1&baseCookie=&ts=${ts}&lmid=0`,
|
|
295
|
+
secProtocol
|
|
296
|
+
)
|
|
222
297
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
298
|
+
let settled = false
|
|
299
|
+
let sawMessage = false
|
|
300
|
+
const failTimer = setTimeout(() => {
|
|
301
|
+
if (settled) return
|
|
302
|
+
settled = true
|
|
303
|
+
try {
|
|
304
|
+
ws.close()
|
|
305
|
+
} catch {}
|
|
306
|
+
reject(new Error('websocket connected but no downstream messages'))
|
|
307
|
+
}, 7000)
|
|
308
|
+
|
|
309
|
+
ws.on('message', (data) => {
|
|
310
|
+
const msg = JSON.parse(data.toString())
|
|
311
|
+
downstream.enqueue(msg)
|
|
312
|
+
if (!sawMessage && !settled) {
|
|
313
|
+
sawMessage = true
|
|
314
|
+
settled = true
|
|
315
|
+
clearTimeout(failTimer)
|
|
316
|
+
resolve(ws)
|
|
317
|
+
}
|
|
318
|
+
})
|
|
226
319
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
320
|
+
ws.once('error', (err) => {
|
|
321
|
+
if (settled) return
|
|
322
|
+
settled = true
|
|
323
|
+
clearTimeout(failTimer)
|
|
324
|
+
reject(err)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
ws.once('close', () => {
|
|
328
|
+
if (settled) return
|
|
329
|
+
settled = true
|
|
330
|
+
clearTimeout(failTimer)
|
|
331
|
+
reject(new Error('websocket closed before initial downstream message'))
|
|
332
|
+
})
|
|
236
333
|
})
|
|
334
|
+
}
|
|
237
335
|
|
|
238
|
-
|
|
336
|
+
async function connectAndSubscribeWithRetry(
|
|
337
|
+
port: number,
|
|
338
|
+
downstream: Queue<unknown>,
|
|
339
|
+
query: Record<string, unknown>,
|
|
340
|
+
timeoutMs = 30_000
|
|
341
|
+
): Promise<WebSocket> {
|
|
342
|
+
const deadline = Date.now() + timeoutMs
|
|
343
|
+
let lastErr: unknown
|
|
344
|
+
while (Date.now() < deadline) {
|
|
345
|
+
try {
|
|
346
|
+
return await connectAndSubscribe(port, downstream, query)
|
|
347
|
+
} catch (err) {
|
|
348
|
+
lastErr = err
|
|
349
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
throw new Error(
|
|
353
|
+
`timed out connecting websocket after reset: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
354
|
+
)
|
|
239
355
|
}
|
|
240
356
|
|
|
241
357
|
async function drainInitialPokes(downstream: Queue<unknown>) {
|