orez 0.1.13 → 0.1.15
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/bench/serial-mutations.bench.d.ts +10 -0
- package/dist/bench/serial-mutations.bench.d.ts.map +1 -0
- package/dist/bench/serial-mutations.bench.js +228 -0
- package/dist/bench/serial-mutations.bench.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -1
- package/dist/index.js.map +1 -1
- package/dist/mutex.d.ts +1 -0
- package/dist/mutex.d.ts.map +1 -1
- package/dist/mutex.js +8 -0
- package/dist/mutex.js.map +1 -1
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +38 -36
- package/dist/pg-proxy.js.map +1 -1
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +6 -1
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts +1 -0
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +94 -38
- package/dist/replication/handler.js.map +1 -1
- package/dist/replication/pgoutput-encoder.d.ts +5 -0
- package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
- package/dist/replication/pgoutput-encoder.js +85 -59
- package/dist/replication/pgoutput-encoder.js.map +1 -1
- package/package.json +2 -2
- package/src/bench/serial-mutations.bench.ts +270 -0
- package/src/index.ts +44 -1
- package/src/integration/integration.test.ts +27 -14
- package/src/mutex.ts +9 -0
- package/src/pg-proxy.ts +40 -38
- package/src/replication/change-tracker.ts +5 -1
- package/src/replication/handler.ts +97 -39
- package/src/replication/pgoutput-encoder.ts +101 -60
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* benchmark: serial mutations with connected client
|
|
3
|
+
*
|
|
4
|
+
* measures the time for N mutations to be fully replicated
|
|
5
|
+
* to a connected websocket client.
|
|
6
|
+
*
|
|
7
|
+
* run: bun src/bench/serial-mutations.bench.ts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import WebSocket from 'ws'
|
|
11
|
+
|
|
12
|
+
import { startZeroLite } from '../index.js'
|
|
13
|
+
import {
|
|
14
|
+
ensureTablesInPublications,
|
|
15
|
+
installAllowAllPermissions,
|
|
16
|
+
} from '../integration/test-permissions.js'
|
|
17
|
+
import { installChangeTracking } from '../replication/change-tracker.js'
|
|
18
|
+
|
|
19
|
+
import type { PGlite } from '@electric-sql/pglite'
|
|
20
|
+
|
|
21
|
+
const SYNC_PROTOCOL_VERSION = 45
|
|
22
|
+
const NUM_MUTATIONS = 100
|
|
23
|
+
|
|
24
|
+
// test schema
|
|
25
|
+
const CLIENT_SCHEMA = {
|
|
26
|
+
tables: {
|
|
27
|
+
bench_items: {
|
|
28
|
+
columns: {
|
|
29
|
+
id: { type: 'string' },
|
|
30
|
+
value: { type: 'string' },
|
|
31
|
+
num: { type: 'number' },
|
|
32
|
+
},
|
|
33
|
+
primaryKey: ['id'],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encodeSecProtocols(
|
|
39
|
+
initConnectionMessage: unknown,
|
|
40
|
+
authToken: string | undefined
|
|
41
|
+
): string {
|
|
42
|
+
const payload = JSON.stringify({ initConnectionMessage, authToken })
|
|
43
|
+
return encodeURIComponent(Buffer.from(payload, 'utf-8').toString('base64'))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class Queue<T> {
|
|
47
|
+
private items: T[] = []
|
|
48
|
+
private waiters: Array<{
|
|
49
|
+
resolve: (v: T) => void
|
|
50
|
+
timer?: ReturnType<typeof setTimeout>
|
|
51
|
+
}> = []
|
|
52
|
+
|
|
53
|
+
enqueue(item: T) {
|
|
54
|
+
const waiter = this.waiters.shift()
|
|
55
|
+
if (waiter) {
|
|
56
|
+
if (waiter.timer) clearTimeout(waiter.timer)
|
|
57
|
+
waiter.resolve(item)
|
|
58
|
+
} else {
|
|
59
|
+
this.items.push(item)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
dequeue(fallback?: T, timeoutMs = 10000): Promise<T> {
|
|
64
|
+
if (this.items.length > 0) {
|
|
65
|
+
return Promise.resolve(this.items.shift()!)
|
|
66
|
+
}
|
|
67
|
+
return new Promise<T>((resolve) => {
|
|
68
|
+
const waiter: { resolve: (v: T) => void; timer?: ReturnType<typeof setTimeout> } = {
|
|
69
|
+
resolve,
|
|
70
|
+
}
|
|
71
|
+
if (fallback !== undefined) {
|
|
72
|
+
waiter.timer = setTimeout(() => {
|
|
73
|
+
const idx = this.waiters.indexOf(waiter)
|
|
74
|
+
if (idx >= 0) this.waiters.splice(idx, 1)
|
|
75
|
+
resolve(fallback)
|
|
76
|
+
}, timeoutMs)
|
|
77
|
+
}
|
|
78
|
+
this.waiters.push(waiter)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitForZero(port: number, timeoutMs = 30000) {
|
|
84
|
+
const deadline = Date.now() + timeoutMs
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(`http://localhost:${port}/`)
|
|
88
|
+
if (res.ok || res.status === 404) return
|
|
89
|
+
} catch {}
|
|
90
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`zero-cache not ready on port ${port} after ${timeoutMs}ms`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function runBenchmark() {
|
|
96
|
+
console.log(`\n=== Serial Mutations Benchmark (${NUM_MUTATIONS} mutations) ===\n`)
|
|
97
|
+
|
|
98
|
+
const testPgPort = 24000 + Math.floor(Math.random() * 1000)
|
|
99
|
+
const testZeroPort = testPgPort + 100
|
|
100
|
+
const dataDir = `.orez-bench-${Date.now()}`
|
|
101
|
+
|
|
102
|
+
console.log(`starting orez on pg:${testPgPort} zero:${testZeroPort}`)
|
|
103
|
+
const result = await startZeroLite({
|
|
104
|
+
pgPort: testPgPort,
|
|
105
|
+
zeroPort: testZeroPort,
|
|
106
|
+
dataDir,
|
|
107
|
+
logLevel: 'info',
|
|
108
|
+
skipZeroCache: false,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const db = result.db
|
|
112
|
+
const zeroPort = result.zeroPort
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// create test table
|
|
116
|
+
await db.exec(`
|
|
117
|
+
CREATE TABLE IF NOT EXISTS bench_items (
|
|
118
|
+
id TEXT PRIMARY KEY,
|
|
119
|
+
value TEXT,
|
|
120
|
+
num INTEGER
|
|
121
|
+
);
|
|
122
|
+
`)
|
|
123
|
+
|
|
124
|
+
// add table to publication and install permissions
|
|
125
|
+
await ensureTablesInPublications(db, ['bench_items'])
|
|
126
|
+
await installChangeTracking(db)
|
|
127
|
+
await installAllowAllPermissions(db, ['bench_items'])
|
|
128
|
+
|
|
129
|
+
if (result.resetZeroFull) {
|
|
130
|
+
await result.resetZeroFull()
|
|
131
|
+
} else if (result.restartZero) {
|
|
132
|
+
await result.restartZero()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log('waiting for zero-cache...')
|
|
136
|
+
await waitForZero(zeroPort, 90000)
|
|
137
|
+
console.log('zero-cache ready')
|
|
138
|
+
|
|
139
|
+
// connect websocket client
|
|
140
|
+
const downstream = new Queue<unknown>()
|
|
141
|
+
const cg = `bench-cg-${Date.now()}`
|
|
142
|
+
const cid = `bench-client-${Date.now()}`
|
|
143
|
+
const secProtocol = encodeSecProtocols(
|
|
144
|
+
[
|
|
145
|
+
'initConnection',
|
|
146
|
+
{
|
|
147
|
+
desiredQueriesPatch: [
|
|
148
|
+
{
|
|
149
|
+
op: 'put',
|
|
150
|
+
hash: 'q1',
|
|
151
|
+
ast: { table: 'bench_items', orderBy: [['id', 'asc']] },
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
clientSchema: CLIENT_SCHEMA,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
undefined
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const ws = new WebSocket(
|
|
161
|
+
`ws://localhost:${zeroPort}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
|
|
162
|
+
`?clientGroupID=${cg}&clientID=${cid}&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
|
|
163
|
+
secProtocol
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
ws.on('message', (data) => {
|
|
167
|
+
downstream.enqueue(JSON.parse(data.toString()))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// wait for connection
|
|
171
|
+
await new Promise<void>((resolve, reject) => {
|
|
172
|
+
ws.on('open', resolve)
|
|
173
|
+
ws.on('error', reject)
|
|
174
|
+
setTimeout(() => reject(new Error('ws connect timeout')), 5000)
|
|
175
|
+
})
|
|
176
|
+
console.log('websocket connected')
|
|
177
|
+
|
|
178
|
+
// drain initial pokes
|
|
179
|
+
let settled = false
|
|
180
|
+
const timeout = Date.now() + 30000
|
|
181
|
+
while (!settled && Date.now() < timeout) {
|
|
182
|
+
const msg = (await downstream.dequeue('timeout' as any, 3000)) as any
|
|
183
|
+
if (msg === 'timeout') {
|
|
184
|
+
settled = true
|
|
185
|
+
} else if (Array.isArray(msg) && msg[0] === 'pokeEnd') {
|
|
186
|
+
const next = (await downstream.dequeue('timeout' as any, 2000)) as any
|
|
187
|
+
if (next === 'timeout') {
|
|
188
|
+
settled = true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
console.log('initial sync complete, starting benchmark...\n')
|
|
193
|
+
|
|
194
|
+
// ========== BENCHMARK: Serial Mutations ==========
|
|
195
|
+
const receivedIds = new Set<string>()
|
|
196
|
+
const startTime = performance.now()
|
|
197
|
+
|
|
198
|
+
// insert mutations serially
|
|
199
|
+
for (let i = 0; i < NUM_MUTATIONS; i++) {
|
|
200
|
+
const id = `bench-${i}`
|
|
201
|
+
await db.query(`INSERT INTO bench_items (id, value, num) VALUES ($1, $2, $3)`, [
|
|
202
|
+
id,
|
|
203
|
+
`value-${i}`,
|
|
204
|
+
i,
|
|
205
|
+
])
|
|
206
|
+
}
|
|
207
|
+
const insertEndTime = performance.now()
|
|
208
|
+
console.log(`inserts completed in ${(insertEndTime - startTime).toFixed(1)}ms`)
|
|
209
|
+
|
|
210
|
+
// wait for all mutations to be replicated
|
|
211
|
+
const replicationTimeout = Date.now() + 60000
|
|
212
|
+
while (receivedIds.size < NUM_MUTATIONS && Date.now() < replicationTimeout) {
|
|
213
|
+
const msg = (await downstream.dequeue('timeout' as any, 1000)) as any
|
|
214
|
+
if (
|
|
215
|
+
msg !== 'timeout' &&
|
|
216
|
+
Array.isArray(msg) &&
|
|
217
|
+
msg[0] === 'pokePart' &&
|
|
218
|
+
msg[1]?.rowsPatch
|
|
219
|
+
) {
|
|
220
|
+
for (const row of msg[1].rowsPatch) {
|
|
221
|
+
if (row.op === 'put' && row.tableName === 'bench_items' && row.value?.id) {
|
|
222
|
+
receivedIds.add(row.value.id)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const endTime = performance.now()
|
|
228
|
+
|
|
229
|
+
ws.close()
|
|
230
|
+
|
|
231
|
+
// results
|
|
232
|
+
const totalMs = endTime - startTime
|
|
233
|
+
const insertMs = insertEndTime - startTime
|
|
234
|
+
const replicationMs = endTime - insertEndTime
|
|
235
|
+
const perMutation = totalMs / NUM_MUTATIONS
|
|
236
|
+
|
|
237
|
+
console.log(`\n=== Results ===`)
|
|
238
|
+
console.log(`total time: ${totalMs.toFixed(1)}ms`)
|
|
239
|
+
console.log(
|
|
240
|
+
`insert time: ${insertMs.toFixed(1)}ms (${(insertMs / NUM_MUTATIONS).toFixed(1)}ms/op)`
|
|
241
|
+
)
|
|
242
|
+
console.log(`replication time: ${replicationMs.toFixed(1)}ms`)
|
|
243
|
+
console.log(`per mutation (end-to-end): ${perMutation.toFixed(1)}ms`)
|
|
244
|
+
console.log(`mutations received: ${receivedIds.size}/${NUM_MUTATIONS}`)
|
|
245
|
+
console.log(`throughput: ${(1000 / perMutation).toFixed(1)} mutations/sec`)
|
|
246
|
+
|
|
247
|
+
if (receivedIds.size < NUM_MUTATIONS) {
|
|
248
|
+
console.log(`\nWARNING: not all mutations were replicated!`)
|
|
249
|
+
const missing = []
|
|
250
|
+
for (let i = 0; i < NUM_MUTATIONS; i++) {
|
|
251
|
+
if (!receivedIds.has(`bench-${i}`)) missing.push(i)
|
|
252
|
+
}
|
|
253
|
+
console.log(
|
|
254
|
+
`missing: ${missing.slice(0, 10).join(', ')}${missing.length > 10 ? '...' : ''}`
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
await result.stop()
|
|
259
|
+
// cleanup
|
|
260
|
+
const { rmSync } = await import('node:fs')
|
|
261
|
+
try {
|
|
262
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
runBenchmark().catch((err) => {
|
|
268
|
+
console.error('benchmark failed:', err)
|
|
269
|
+
process.exit(1)
|
|
270
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -134,6 +134,44 @@ async function syncManagedPublications(
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* ensure publications have table membership after on-db-ready.
|
|
139
|
+
* handles the case where orez pre-created an empty publication and the app's
|
|
140
|
+
* migration skipped adding tables because the publication already existed.
|
|
141
|
+
*/
|
|
142
|
+
async function ensurePublicationHasTables(db: PGlite, names: string[]): Promise<void> {
|
|
143
|
+
for (const pub of names) {
|
|
144
|
+
const inPub = await db.query<{ count: string }>(
|
|
145
|
+
`SELECT count(*)::text as count FROM pg_publication_tables
|
|
146
|
+
WHERE pubname = $1 AND schemaname = 'public'`,
|
|
147
|
+
[pub]
|
|
148
|
+
)
|
|
149
|
+
if (Number(inPub.rows[0]?.count) > 0) continue
|
|
150
|
+
|
|
151
|
+
// publication exists but has no tables — add all public tables
|
|
152
|
+
const pubExists = await db.query<{ count: string }>(
|
|
153
|
+
`SELECT count(*)::text as count FROM pg_publication WHERE pubname = $1`,
|
|
154
|
+
[pub]
|
|
155
|
+
)
|
|
156
|
+
if (Number(pubExists.rows[0]?.count) === 0) continue
|
|
157
|
+
|
|
158
|
+
const tables = await db.query<{ tablename: string }>(
|
|
159
|
+
`SELECT tablename FROM pg_tables
|
|
160
|
+
WHERE schemaname = 'public'
|
|
161
|
+
AND tablename NOT LIKE '_zero_%'
|
|
162
|
+
AND tablename NOT LIKE '\\_%'`
|
|
163
|
+
)
|
|
164
|
+
if (tables.rows.length === 0) continue
|
|
165
|
+
|
|
166
|
+
const tableList = tables.rows
|
|
167
|
+
.map((t) => `"public"."${t.tablename.replace(/"/g, '""')}"`)
|
|
168
|
+
.join(', ')
|
|
169
|
+
const quotedPub = '"' + pub.replace(/"/g, '""') + '"'
|
|
170
|
+
await db.exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE ${tableList}`)
|
|
171
|
+
log.orez(`publication "${pub}" was empty, added ${tables.rows.length} table(s)`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
137
175
|
// resolvePackage moved to sqlite-mode/resolve-mode.ts
|
|
138
176
|
import { resolvePackage } from './sqlite-mode/resolve-mode.js'
|
|
139
177
|
|
|
@@ -238,8 +276,13 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
|
|
|
238
276
|
OREZ_PG_PORT: String(config.pgPort),
|
|
239
277
|
})
|
|
240
278
|
|
|
241
|
-
// re-
|
|
279
|
+
// re-sync publication membership after on-db-ready.
|
|
280
|
+
// for orez-managed publications, add any new public tables.
|
|
281
|
+
// for user-managed publications, ensure the publication isn't empty
|
|
282
|
+
// (app migrations may have created the pub with tables, but if orez
|
|
283
|
+
// pre-created an empty pub, the migration may have skipped adding tables).
|
|
242
284
|
await syncManagedPublications(db, managedPub.names, managedPub.managedByOrez)
|
|
285
|
+
await ensurePublicationHasTables(db, managedPub.names)
|
|
243
286
|
log.debug.orez('re-installing change tracking after on-db-ready')
|
|
244
287
|
await installChangeTracking(db)
|
|
245
288
|
}
|
|
@@ -182,8 +182,29 @@ describe('orez integration', { timeout: 120000 }, () => {
|
|
|
182
182
|
beforeEach(async () => {
|
|
183
183
|
// clean tables between tests
|
|
184
184
|
await db.exec(`DELETE FROM foo; DELETE FROM bar;`)
|
|
185
|
+
// wait for replication to consume cleanup changes so zero-cache's replica is clean
|
|
186
|
+
await waitForReplicationCatchup(db)
|
|
187
|
+
// settle time for zero-cache to finish processing previous client views
|
|
188
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
185
189
|
})
|
|
186
190
|
|
|
191
|
+
// wait until the replication handler has consumed all pending changes.
|
|
192
|
+
// zero-cache queries its own sqlite replica, so data written to pglite
|
|
193
|
+
// isn't visible to clients until replication streams it through.
|
|
194
|
+
async function waitForReplicationCatchup(
|
|
195
|
+
pglite: PGlite,
|
|
196
|
+
timeoutMs = 15000
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const deadline = Date.now() + timeoutMs
|
|
199
|
+
while (Date.now() < deadline) {
|
|
200
|
+
const result = await pglite.query<{ count: string }>(
|
|
201
|
+
`SELECT count(*)::text as count FROM _orez._zero_changes`
|
|
202
|
+
)
|
|
203
|
+
if (Number(result.rows[0]?.count) === 0) return
|
|
204
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
187
208
|
test('zero-cache starts and accepts websocket connections', async () => {
|
|
188
209
|
const cg = `test-cg-${Date.now()}`
|
|
189
210
|
const cid = `test-client-${Date.now()}`
|
|
@@ -224,6 +245,11 @@ describe('orez integration', { timeout: 120000 }, () => {
|
|
|
224
245
|
42,
|
|
225
246
|
])
|
|
226
247
|
|
|
248
|
+
// wait for replication to deliver the row to zero-cache's replica
|
|
249
|
+
await waitForReplicationCatchup(db)
|
|
250
|
+
// extra settle time for zero-cache to process the replication stream
|
|
251
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
252
|
+
|
|
227
253
|
const downstream = new Queue<unknown>()
|
|
228
254
|
const ws = connectAndSubscribe(zeroPort, downstream, {
|
|
229
255
|
table: 'foo',
|
|
@@ -426,7 +452,6 @@ describe('orez integration', { timeout: 120000 }, () => {
|
|
|
426
452
|
|
|
427
453
|
async function drainInitialPokes(downstream: Queue<unknown>) {
|
|
428
454
|
// drain messages until we've seen the initial data sync complete
|
|
429
|
-
// pattern: connected → pokeStart/End → pokeStart/pokePart(queries)/pokeEnd → pokeStart/pokePart(data)/pokeEnd
|
|
430
455
|
let settled = false
|
|
431
456
|
const timeout = Date.now() + 30000
|
|
432
457
|
|
|
@@ -435,7 +460,6 @@ describe('orez integration', { timeout: 120000 }, () => {
|
|
|
435
460
|
if (msg === 'timeout') {
|
|
436
461
|
settled = true
|
|
437
462
|
} else if (Array.isArray(msg) && msg[0] === 'pokeEnd') {
|
|
438
|
-
// after a pokeEnd, check if another poke comes quickly
|
|
439
463
|
const next = (await downstream.dequeue('timeout' as any, 2000)) as any
|
|
440
464
|
if (next === 'timeout') {
|
|
441
465
|
settled = true
|
|
@@ -466,24 +490,13 @@ describe('orez integration', { timeout: 120000 }, () => {
|
|
|
466
490
|
): Promise<any[]> {
|
|
467
491
|
const rows: any[] = []
|
|
468
492
|
const deadline = Date.now() + windowMs
|
|
469
|
-
//
|
|
493
|
+
// collect all poke parts until timeout
|
|
470
494
|
while (Date.now() < deadline) {
|
|
471
495
|
const remaining = Math.max(1000, deadline - Date.now())
|
|
472
496
|
const msg = (await downstream.dequeue('timeout' as any, remaining)) as any
|
|
473
497
|
if (msg === 'timeout') break
|
|
474
498
|
if (Array.isArray(msg) && msg[0] === 'pokePart' && msg[1]?.rowsPatch) {
|
|
475
499
|
rows.push(...msg[1].rowsPatch)
|
|
476
|
-
// check if more poke parts come quickly
|
|
477
|
-
const more = (await downstream.dequeue('timeout' as any, 2000)) as any
|
|
478
|
-
if (
|
|
479
|
-
more !== 'timeout' &&
|
|
480
|
-
Array.isArray(more) &&
|
|
481
|
-
more[0] === 'pokePart' &&
|
|
482
|
-
more[1]?.rowsPatch
|
|
483
|
-
) {
|
|
484
|
-
rows.push(...more[1].rowsPatch)
|
|
485
|
-
}
|
|
486
|
-
break
|
|
487
500
|
}
|
|
488
501
|
}
|
|
489
502
|
return rows
|
package/src/mutex.ts
CHANGED
|
@@ -13,6 +13,15 @@ export class Mutex {
|
|
|
13
13
|
})
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// non-blocking acquire: returns true if lock was obtained, false otherwise
|
|
17
|
+
tryAcquire(): boolean {
|
|
18
|
+
if (!this.locked) {
|
|
19
|
+
this.locked = true
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
release(): void {
|
|
17
26
|
const next = this.queue.shift()
|
|
18
27
|
if (next) {
|
package/src/pg-proxy.ts
CHANGED
|
@@ -24,6 +24,10 @@ import type { ZeroLiteConfig } from './config.js'
|
|
|
24
24
|
import type { PGliteInstances } from './pglite-manager.js'
|
|
25
25
|
import type { PGlite } from '@electric-sql/pglite'
|
|
26
26
|
|
|
27
|
+
// shared encoder/decoder instances
|
|
28
|
+
const textEncoder = new TextEncoder()
|
|
29
|
+
const textDecoder = new TextDecoder()
|
|
30
|
+
|
|
27
31
|
// clean version string: strip emscripten compiler info that breaks pg_restore/pg_dump
|
|
28
32
|
const PG_VERSION_STRING =
|
|
29
33
|
"'PostgreSQL 17.4 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 12.2.0, 64-bit'"
|
|
@@ -78,7 +82,7 @@ const SERVER_PARAMS: [string, string][] = [
|
|
|
78
82
|
|
|
79
83
|
// build a ParameterStatus wire protocol message (type 'S', 0x53)
|
|
80
84
|
function buildParameterStatus(name: string, value: string): Uint8Array {
|
|
81
|
-
const encoder =
|
|
85
|
+
const encoder = textEncoder
|
|
82
86
|
const nameBytes = encoder.encode(name)
|
|
83
87
|
const valueBytes = encoder.encode(value)
|
|
84
88
|
const len = 4 + nameBytes.length + 1 + valueBytes.length + 1
|
|
@@ -109,7 +113,7 @@ function extractParseQuery(data: Uint8Array): string | null {
|
|
|
109
113
|
offset++
|
|
110
114
|
const queryStart = offset
|
|
111
115
|
while (offset < data.length && data[offset] !== 0) offset++
|
|
112
|
-
return
|
|
116
|
+
return textDecoder.decode(data.subarray(queryStart, offset))
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
/**
|
|
@@ -126,7 +130,7 @@ function rebuildParseMessage(data: Uint8Array, newQuery: string): Uint8Array {
|
|
|
126
130
|
offset++
|
|
127
131
|
|
|
128
132
|
const suffix = data.subarray(offset)
|
|
129
|
-
const encoder =
|
|
133
|
+
const encoder = textEncoder
|
|
130
134
|
const queryBytes = encoder.encode(newQuery)
|
|
131
135
|
|
|
132
136
|
const totalLen = 4 + nameBytes.length + queryBytes.length + 1 + suffix.length
|
|
@@ -148,7 +152,7 @@ function rebuildParseMessage(data: Uint8Array, newQuery: string): Uint8Array {
|
|
|
148
152
|
* rebuild a Simple Query message with a modified query string.
|
|
149
153
|
*/
|
|
150
154
|
function rebuildSimpleQuery(newQuery: string): Uint8Array {
|
|
151
|
-
const encoder =
|
|
155
|
+
const encoder = textEncoder
|
|
152
156
|
const queryBytes = encoder.encode(newQuery + '\0')
|
|
153
157
|
const buf = new Uint8Array(5 + queryBytes.length)
|
|
154
158
|
buf[0] = 0x51
|
|
@@ -157,6 +161,16 @@ function rebuildSimpleQuery(newQuery: string): Uint8Array {
|
|
|
157
161
|
return buf
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
// apply all rewrites in one pass, using replace directly (no separate test)
|
|
165
|
+
function applyRewrites(query: string): string {
|
|
166
|
+
let result = query
|
|
167
|
+
for (const rw of QUERY_REWRITES) {
|
|
168
|
+
rw.match.lastIndex = 0
|
|
169
|
+
result = result.replace(rw.match, rw.replace)
|
|
170
|
+
}
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
/**
|
|
161
175
|
* intercept and rewrite query messages to make pglite look like real postgres.
|
|
162
176
|
*/
|
|
@@ -166,36 +180,17 @@ function interceptQuery(data: Uint8Array): Uint8Array {
|
|
|
166
180
|
if (msgType === 0x51) {
|
|
167
181
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
168
182
|
const len = view.getInt32(1)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (rw.match.test(query)) {
|
|
174
|
-
query = query.replace(rw.match, rw.replace)
|
|
175
|
-
modified = true
|
|
176
|
-
rw.match.lastIndex = 0
|
|
177
|
-
}
|
|
178
|
-
rw.match.lastIndex = 0
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (modified) {
|
|
182
|
-
return rebuildSimpleQuery(query)
|
|
183
|
+
const original = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
|
|
184
|
+
const rewritten = applyRewrites(original)
|
|
185
|
+
if (rewritten !== original) {
|
|
186
|
+
return rebuildSimpleQuery(rewritten)
|
|
183
187
|
}
|
|
184
188
|
} else if (msgType === 0x50) {
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (rw.match.test(newQuery)) {
|
|
191
|
-
newQuery = newQuery.replace(rw.match, rw.replace)
|
|
192
|
-
modified = true
|
|
193
|
-
rw.match.lastIndex = 0
|
|
194
|
-
}
|
|
195
|
-
rw.match.lastIndex = 0
|
|
196
|
-
}
|
|
197
|
-
if (modified) {
|
|
198
|
-
return rebuildParseMessage(data, newQuery)
|
|
189
|
+
const original = extractParseQuery(data)
|
|
190
|
+
if (original) {
|
|
191
|
+
const rewritten = applyRewrites(original)
|
|
192
|
+
if (rewritten !== original) {
|
|
193
|
+
return rebuildParseMessage(data, rewritten)
|
|
199
194
|
}
|
|
200
195
|
}
|
|
201
196
|
}
|
|
@@ -211,7 +206,7 @@ function isNoopQuery(data: Uint8Array): boolean {
|
|
|
211
206
|
if (data[0] === 0x51) {
|
|
212
207
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
213
208
|
const len = view.getInt32(1)
|
|
214
|
-
query =
|
|
209
|
+
query = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
|
|
215
210
|
} else if (data[0] === 0x50) {
|
|
216
211
|
query = extractParseQuery(data)
|
|
217
212
|
}
|
|
@@ -223,7 +218,7 @@ function isNoopQuery(data: Uint8Array): boolean {
|
|
|
223
218
|
* build a synthetic "SET" command complete response.
|
|
224
219
|
*/
|
|
225
220
|
function buildSetCompleteResponse(): Uint8Array {
|
|
226
|
-
const encoder =
|
|
221
|
+
const encoder = textEncoder
|
|
227
222
|
const tag = encoder.encode('SET\0')
|
|
228
223
|
const cc = new Uint8Array(1 + 4 + tag.length)
|
|
229
224
|
cc[0] = 0x43
|
|
@@ -295,7 +290,7 @@ function extractNoticeCode(
|
|
|
295
290
|
|
|
296
291
|
if (fieldType === 0x43) {
|
|
297
292
|
// 'C' = SQLSTATE code
|
|
298
|
-
return
|
|
293
|
+
return textDecoder.decode(data.subarray(strStart, pos))
|
|
299
294
|
}
|
|
300
295
|
pos++ // skip null terminator
|
|
301
296
|
}
|
|
@@ -479,7 +474,7 @@ export async function startPgProxy(
|
|
|
479
474
|
if (data[0] === 0x51) {
|
|
480
475
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
481
476
|
const len = view.getInt32(1)
|
|
482
|
-
const query =
|
|
477
|
+
const query = textDecoder
|
|
483
478
|
.decode(data.subarray(5, 1 + len - 1))
|
|
484
479
|
.replace(/\0$/, '')
|
|
485
480
|
log.debug.proxy(`repl query: ${query.slice(0, 200)}`)
|
|
@@ -558,25 +553,32 @@ async function handleReplicationMessage(
|
|
|
558
553
|
|
|
559
554
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
560
555
|
const len = view.getInt32(1)
|
|
561
|
-
const query =
|
|
556
|
+
const query = textDecoder.decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
|
|
562
557
|
const upper = query.trim().toUpperCase()
|
|
563
558
|
|
|
564
559
|
// check if this is a START_REPLICATION command
|
|
565
560
|
if (upper.startsWith('START_REPLICATION')) {
|
|
566
561
|
await connection.detach()
|
|
567
562
|
|
|
563
|
+
let aborted = false
|
|
564
|
+
const abortController = new AbortController()
|
|
568
565
|
const writer = {
|
|
569
566
|
write(chunk: Uint8Array) {
|
|
570
|
-
if (!socket.destroyed) {
|
|
567
|
+
if (!socket.destroyed && !aborted) {
|
|
571
568
|
socket.write(chunk)
|
|
572
569
|
}
|
|
573
570
|
},
|
|
571
|
+
get closed() {
|
|
572
|
+
return socket.destroyed || aborted
|
|
573
|
+
},
|
|
574
574
|
}
|
|
575
575
|
|
|
576
576
|
// drain incoming standby status updates
|
|
577
577
|
socket.on('data', (_chunk: Buffer) => {})
|
|
578
578
|
|
|
579
579
|
socket.on('close', () => {
|
|
580
|
+
aborted = true
|
|
581
|
+
abortController.abort()
|
|
580
582
|
socket.destroy()
|
|
581
583
|
})
|
|
582
584
|
|
|
@@ -138,7 +138,11 @@ async function installTriggersOnAllTables(db: PGlite): Promise<void> {
|
|
|
138
138
|
count++
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
if (count > 0) {
|
|
142
|
+
log.pglite(`installed change tracking triggers on ${count} tables`)
|
|
143
|
+
} else {
|
|
144
|
+
log.debug.pglite(`no tables to install change tracking triggers on`)
|
|
145
|
+
}
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
/**
|