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.
@@ -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-install change tracking on tables created by on-db-ready
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
- // first wait for the pokePart with data
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 = new TextEncoder()
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 new TextDecoder().decode(data.subarray(queryStart, offset))
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 = new TextEncoder()
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 = new TextEncoder()
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
- let query = new TextDecoder().decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
170
-
171
- let modified = false
172
- for (const rw of QUERY_REWRITES) {
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 query = extractParseQuery(data)
186
- if (query) {
187
- let newQuery = query
188
- let modified = false
189
- for (const rw of QUERY_REWRITES) {
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 = new TextDecoder().decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
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 = new TextEncoder()
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 new TextDecoder().decode(data.subarray(strStart, pos))
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 = new TextDecoder()
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 = new TextDecoder().decode(data.subarray(5, 1 + len - 1)).replace(/\0$/, '')
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
- log.debug.pglite(`installed change tracking triggers on ${count} tables`)
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
  /**