orez 0.0.48 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +6 -112
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +0 -5
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +0 -5
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts +0 -9
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +91 -280
  11. package/dist/index.js.map +1 -1
  12. package/dist/log.d.ts +0 -9
  13. package/dist/log.d.ts.map +1 -1
  14. package/dist/log.js +1 -24
  15. package/dist/log.js.map +1 -1
  16. package/dist/mutex.d.ts.map +1 -1
  17. package/dist/mutex.js +2 -13
  18. package/dist/mutex.js.map +1 -1
  19. package/dist/pg-proxy.d.ts +2 -3
  20. package/dist/pg-proxy.d.ts.map +1 -1
  21. package/dist/pg-proxy.js +167 -377
  22. package/dist/pg-proxy.js.map +1 -1
  23. package/dist/pglite-manager.d.ts +0 -1
  24. package/dist/pglite-manager.d.ts.map +1 -1
  25. package/dist/pglite-manager.js +2 -8
  26. package/dist/pglite-manager.js.map +1 -1
  27. package/dist/replication/change-tracker.d.ts +0 -6
  28. package/dist/replication/change-tracker.d.ts.map +1 -1
  29. package/dist/replication/change-tracker.js +1 -62
  30. package/dist/replication/change-tracker.js.map +1 -1
  31. package/dist/replication/handler.d.ts.map +1 -1
  32. package/dist/replication/handler.js +7 -66
  33. package/dist/replication/handler.js.map +1 -1
  34. package/dist/vite-plugin.d.ts +0 -3
  35. package/dist/vite-plugin.d.ts.map +1 -1
  36. package/dist/vite-plugin.js +0 -24
  37. package/dist/vite-plugin.js.map +1 -1
  38. package/package.json +5 -4
  39. package/src/cli.ts +18 -124
  40. package/src/config.ts +0 -10
  41. package/src/index.ts +92 -309
  42. package/src/integration/integration.test.ts +264 -133
  43. package/src/log.ts +1 -25
  44. package/src/mutex.ts +2 -12
  45. package/src/pg-proxy.ts +187 -451
  46. package/src/pglite-manager.ts +2 -9
  47. package/src/replication/change-tracker.ts +1 -83
  48. package/src/replication/handler.ts +6 -79
  49. package/src/replication/pgoutput-encoder.test.ts +0 -217
  50. package/src/replication/zero-compat.test.ts +1 -232
  51. package/src/shim/hooks.mjs +1 -1
  52. package/src/vite-plugin.ts +0 -28
  53. package/src/wasm-sqlite.test.ts +1 -2
@@ -1,82 +1,19 @@
1
1
  /**
2
- * integration test for zero-cache sync pipeline.
2
+ * integration test adapted from zero-cache's integration.pg.test.ts
3
3
  *
4
- * validates: pglite → change tracking → replication protocol →
5
- * zero-cache → websocket poke messages to clients.
4
+ * validates the full sync pipeline: pglite → change tracking → replication
5
+ * protocol → zero-cache → websocket poke messages to clients.
6
6
  *
7
- * uses orez's startZeroLite() with beforeZero to set up tables
8
- * before zero-cache starts its initial sync. deploys ANYONE_CAN
9
- * permissions after zero-cache creates its schema tables.
7
+ * uses orez's startZeroLite() instead of real postgres + manual zero-cache.
10
8
  */
11
9
 
12
- import { describe, expect, test, beforeAll, afterAll } from 'vitest'
10
+ import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'vitest'
13
11
  import WebSocket from 'ws'
14
12
 
15
13
  import { startZeroLite } from '../index.js'
16
14
 
17
15
  import type { PGlite } from '@electric-sql/pglite'
18
16
 
19
- // encode initConnectionMessage + authToken for sec-websocket-protocol header
20
- // mirrors @rocicorp/zero's encodeSecProtocols
21
- function encodeSecProtocol(
22
- initConnectionMessage: unknown,
23
- authToken: string = ''
24
- ): string {
25
- const protocols = { initConnectionMessage, authToken }
26
- const bytes = new TextEncoder().encode(JSON.stringify(protocols))
27
- const s = Array.from(bytes, (byte: number) => String.fromCharCode(byte)).join('')
28
- return encodeURIComponent(btoa(s))
29
- }
30
-
31
- // zero v0.25 requires clientSchema for new client groups
32
- const clientSchema = {
33
- tables: {
34
- foo: {
35
- columns: {
36
- id: { type: 'string' },
37
- value: { type: 'string' },
38
- num: { type: 'number' },
39
- },
40
- primaryKey: ['id'],
41
- },
42
- bar: {
43
- columns: {
44
- id: { type: 'string' },
45
- foo_id: { type: 'string' },
46
- },
47
- primaryKey: ['id'],
48
- },
49
- },
50
- }
51
-
52
- // ANYONE_CAN permissions — empty AND condition = always true
53
- const anyoneCanPermissions = JSON.stringify({
54
- tables: {
55
- foo: {
56
- row: {
57
- select: [['allow', { type: 'and', conditions: [] }]],
58
- insert: [['allow', { type: 'and', conditions: [] }]],
59
- update: {
60
- preMutation: [['allow', { type: 'and', conditions: [] }]],
61
- postMutation: [['allow', { type: 'and', conditions: [] }]],
62
- },
63
- delete: [['allow', { type: 'and', conditions: [] }]],
64
- },
65
- },
66
- bar: {
67
- row: {
68
- select: [['allow', { type: 'and', conditions: [] }]],
69
- insert: [['allow', { type: 'and', conditions: [] }]],
70
- update: {
71
- preMutation: [['allow', { type: 'and', conditions: [] }]],
72
- postMutation: [['allow', { type: 'and', conditions: [] }]],
73
- },
74
- delete: [['allow', { type: 'and', conditions: [] }]],
75
- },
76
- },
77
- },
78
- })
79
-
80
17
  // simple async queue for collecting websocket messages
81
18
  class Queue<T> {
82
19
  private items: T[] = []
@@ -118,61 +55,49 @@ class Queue<T> {
118
55
  describe('orez integration', { timeout: 120000 }, () => {
119
56
  let db: PGlite
120
57
  let zeroPort: number
58
+ let pgPort: number
121
59
  let shutdown: () => Promise<void>
122
60
  let dataDir: string
123
61
 
124
62
  beforeAll(async () => {
125
63
  const testPgPort = 23000 + Math.floor(Math.random() * 1000)
126
- const testZeroPort = testPgPort + 1000
64
+ const testZeroPort = testPgPort + 100
127
65
 
128
66
  dataDir = `.orez-integration-test-${Date.now()}`
67
+ console.log(`[test] starting orez on pg:${testPgPort} zero:${testZeroPort}`)
129
68
  const result = await startZeroLite({
130
69
  pgPort: testPgPort,
131
70
  zeroPort: testZeroPort,
132
71
  dataDir,
133
72
  logLevel: 'info',
134
73
  skipZeroCache: false,
135
- beforeZero: async (pglite) => {
136
- await pglite.exec(`
137
- CREATE TABLE IF NOT EXISTS foo (
138
- id TEXT PRIMARY KEY,
139
- value TEXT,
140
- num INTEGER
141
- );
142
- CREATE TABLE IF NOT EXISTS bar (
143
- id TEXT PRIMARY KEY,
144
- foo_id TEXT
145
- );
146
- `)
147
- // insert test data before zero-cache starts so initial sync includes it
148
- await pglite.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
149
- 'seed1',
150
- 'hello',
151
- 42,
152
- ])
153
- await pglite.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
154
- 'seed2',
155
- 'world',
156
- 99,
157
- ])
158
- },
159
74
  })
160
75
 
161
76
  db = result.db
162
77
  zeroPort = result.zeroPort
78
+ pgPort = result.pgPort
163
79
  shutdown = result.stop
164
80
 
165
- await waitForZero(zeroPort, 90000)
81
+ console.log(`[test] orez started, creating tables`)
166
82
 
167
- // deploy ANYONE_CAN permissions after zero-cache creates its schema tables
168
- await db.query(
169
- `INSERT INTO zero.permissions (lock, hash, permissions)
170
- VALUES (true, $1, $2)
171
- ON CONFLICT (lock) DO UPDATE SET hash = $1, permissions = $2`,
172
- ['integration-test', anyoneCanPermissions]
173
- )
174
- // wait for permissions to replicate to zero-cache's sqlite replica
175
- await new Promise((r) => setTimeout(r, 3000))
83
+ // create test tables
84
+ await db.exec(`
85
+ CREATE TABLE IF NOT EXISTS foo (
86
+ id TEXT PRIMARY KEY,
87
+ value TEXT,
88
+ num INTEGER
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS bar (
92
+ id TEXT PRIMARY KEY,
93
+ foo_id TEXT
94
+ );
95
+ `)
96
+
97
+ console.log(`[test] tables created, waiting for zero-cache`)
98
+ // wait for zero-cache to be ready
99
+ await waitForZero(zeroPort, 90000)
100
+ console.log(`[test] zero-cache ready`)
176
101
  }, 120000)
177
102
 
178
103
  afterAll(async () => {
@@ -185,27 +110,96 @@ describe('orez integration', { timeout: 120000 }, () => {
185
110
  }
186
111
  })
187
112
 
113
+ beforeEach(async () => {
114
+ // clean tables between tests
115
+ await db.exec(`DELETE FROM foo; DELETE FROM bar;`)
116
+ })
117
+
118
+ test('zero-cache starts and accepts websocket connections', async () => {
119
+ const ws = new WebSocket(
120
+ `ws://localhost:${zeroPort}/sync/v4/connect` +
121
+ `?clientGroupID=test-cg&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`
122
+ )
123
+
124
+ const connected = new Promise<void>((resolve, reject) => {
125
+ ws.on('open', resolve)
126
+ ws.on('error', reject)
127
+ setTimeout(() => reject(new Error('ws connect timeout')), 5000)
128
+ })
129
+
130
+ await connected
131
+
132
+ const firstMessage = await new Promise<unknown>((resolve) => {
133
+ ws.on('message', (data) => {
134
+ resolve(JSON.parse(data.toString()))
135
+ })
136
+ })
137
+
138
+ expect(firstMessage).toMatchObject(['connected', { wsid: 'ws1' }])
139
+
140
+ ws.close()
141
+ })
142
+
188
143
  test('initial sync delivers existing rows via poke', async () => {
144
+ // insert data before connecting
145
+ await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
146
+ 'row1',
147
+ 'hello',
148
+ 42,
149
+ ])
150
+
189
151
  const downstream = new Queue<unknown>()
190
152
  const ws = connectAndSubscribe(zeroPort, downstream, {
191
153
  table: 'foo',
192
154
  orderBy: [['id', 'asc']],
193
155
  })
194
156
 
195
- const poke = await waitForPokePart(downstream, 15000)
196
- const ids = poke.rowsPatch
197
- .filter((r: any) => r.op === 'put' && r.tableName === 'foo')
198
- .map((r: any) => r.value.id)
199
- .sort()
157
+ // drain until we get a pokePart with rowsPatch containing our data
158
+ const poke = await waitForPokePart(downstream, 30000)
159
+ expect(poke.rowsPatch).toEqual(
160
+ expect.arrayContaining([
161
+ expect.objectContaining({
162
+ op: 'put',
163
+ tableName: 'foo',
164
+ value: expect.objectContaining({
165
+ id: 'row1',
166
+ value: 'hello',
167
+ }),
168
+ }),
169
+ ])
170
+ )
200
171
 
201
- expect(ids).toContain('seed1')
202
- expect(ids).toContain('seed2')
172
+ ws.close()
173
+ })
174
+
175
+ test('live replication: insert triggers poke', async () => {
176
+ const downstream = new Queue<unknown>()
177
+ const ws = connectAndSubscribe(zeroPort, downstream, {
178
+ table: 'foo',
179
+ orderBy: [['id', 'asc']],
180
+ })
181
+
182
+ // drain initial connection + sync pokes
183
+ await drainInitialPokes(downstream)
184
+
185
+ // now insert data - this should trigger a replication poke
186
+ await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
187
+ 'live-row',
188
+ 'live-value',
189
+ 99,
190
+ ])
191
+
192
+ // wait for the replication poke
193
+ const poke = await waitForPokePart(downstream, 30000)
203
194
  expect(poke.rowsPatch).toEqual(
204
195
  expect.arrayContaining([
205
196
  expect.objectContaining({
206
197
  op: 'put',
207
198
  tableName: 'foo',
208
- value: expect.objectContaining({ id: 'seed1', value: 'hello' }),
199
+ value: expect.objectContaining({
200
+ id: 'live-row',
201
+ value: 'live-value',
202
+ }),
209
203
  }),
210
204
  ])
211
205
  )
@@ -213,57 +207,165 @@ describe('orez integration', { timeout: 120000 }, () => {
213
207
  ws.close()
214
208
  })
215
209
 
216
- test('initial sync delivers correct values for all columns', async () => {
210
+ test('live replication: update triggers poke', async () => {
211
+ // insert initial data
212
+ await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
213
+ 'upd-row',
214
+ 'original',
215
+ 1,
216
+ ])
217
+
217
218
  const downstream = new Queue<unknown>()
218
219
  const ws = connectAndSubscribe(zeroPort, downstream, {
219
220
  table: 'foo',
220
221
  orderBy: [['id', 'asc']],
221
222
  })
222
223
 
223
- const poke = await waitForPokePart(downstream, 15000)
224
- const row = poke.rowsPatch.find(
225
- (r: any) => r.op === 'put' && r.tableName === 'foo' && r.value.id === 'seed2'
224
+ await drainInitialPokes(downstream)
225
+
226
+ // update the row
227
+ await db.query(`UPDATE foo SET value = $1, num = $2 WHERE id = $3`, [
228
+ 'updated',
229
+ 2,
230
+ 'upd-row',
231
+ ])
232
+
233
+ const poke = await waitForPokePart(downstream, 30000)
234
+ expect(poke.rowsPatch).toEqual(
235
+ expect.arrayContaining([
236
+ expect.objectContaining({
237
+ op: 'put',
238
+ tableName: 'foo',
239
+ value: expect.objectContaining({
240
+ id: 'upd-row',
241
+ value: 'updated',
242
+ }),
243
+ }),
244
+ ])
226
245
  )
227
246
 
228
- expect(row).toBeDefined()
229
- expect(row.value).toEqual({ id: 'seed2', value: 'world', num: 99 })
247
+ ws.close()
248
+ })
249
+
250
+ test('live replication: delete triggers poke', async () => {
251
+ await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
252
+ 'del-row',
253
+ 'to-delete',
254
+ 1,
255
+ ])
256
+
257
+ const downstream = new Queue<unknown>()
258
+ const ws = connectAndSubscribe(zeroPort, downstream, {
259
+ table: 'foo',
260
+ orderBy: [['id', 'asc']],
261
+ })
262
+
263
+ await drainInitialPokes(downstream)
264
+
265
+ // delete the row
266
+ await db.query(`DELETE FROM foo WHERE id = $1`, ['del-row'])
267
+
268
+ const poke = await waitForPokePart(downstream, 30000)
269
+ expect(poke.rowsPatch).toEqual(
270
+ expect.arrayContaining([
271
+ expect.objectContaining({
272
+ op: 'del',
273
+ tableName: 'foo',
274
+ }),
275
+ ])
276
+ )
230
277
 
231
278
  ws.close()
232
279
  })
233
280
 
234
- // --- helpers ---
281
+ test('concurrent inserts all replicate', async () => {
282
+ const downstream = new Queue<unknown>()
283
+ const ws = connectAndSubscribe(zeroPort, downstream, {
284
+ table: 'foo',
285
+ orderBy: [['id', 'asc']],
286
+ })
235
287
 
236
- let clientCounter = 0
288
+ await drainInitialPokes(downstream)
289
+
290
+ // insert 5 rows concurrently
291
+ await Promise.all(
292
+ Array.from({ length: 5 }, (_, i) =>
293
+ db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
294
+ `concurrent-${i}`,
295
+ `value-${i}`,
296
+ i,
297
+ ])
298
+ )
299
+ )
300
+
301
+ // collect all poke parts within a window
302
+ const allRows = await collectPokeRows(downstream, 30000)
303
+ const ids = allRows
304
+ .filter((r: any) => r.op === 'put' && r.tableName === 'foo')
305
+ .map((r: any) => r.value.id)
306
+ .sort()
307
+
308
+ expect(ids).toEqual([
309
+ 'concurrent-0',
310
+ 'concurrent-1',
311
+ 'concurrent-2',
312
+ 'concurrent-3',
313
+ 'concurrent-4',
314
+ ])
315
+
316
+ ws.close()
317
+ })
318
+
319
+ // --- helpers ---
237
320
 
238
321
  function connectAndSubscribe(
239
322
  port: number,
240
323
  downstream: Queue<unknown>,
241
324
  query: Record<string, unknown>
242
325
  ): WebSocket {
243
- const cid = `test-${++clientCounter}-${Date.now()}`
244
- const wsid = `ws-${clientCounter}-${Date.now()}`
245
- const initMsg = [
246
- 'initConnection',
247
- {
248
- desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
249
- clientSchema,
250
- },
251
- ]
252
- const secProtocol = encodeSecProtocol(initMsg)
253
-
254
326
  const ws = new WebSocket(
255
- `ws://localhost:${port}/sync/v45/connect` +
256
- `?clientGroupID=${cid}&clientID=${cid}-c&wsid=${wsid}&ts=${Date.now()}&lmid=0`,
257
- [secProtocol]
327
+ `ws://localhost:${port}/sync/v4/connect` +
328
+ `?clientGroupID=test-cg-${Date.now()}&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`
258
329
  )
259
330
 
260
331
  ws.on('message', (data) => {
261
332
  downstream.enqueue(JSON.parse(data.toString()))
262
333
  })
263
334
 
335
+ ws.on('open', () => {
336
+ ws.send(
337
+ JSON.stringify([
338
+ 'initConnection',
339
+ {
340
+ desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
341
+ },
342
+ ])
343
+ )
344
+ })
345
+
264
346
  return ws
265
347
  }
266
348
 
349
+ async function drainInitialPokes(downstream: Queue<unknown>) {
350
+ // drain messages until we've seen the initial data sync complete
351
+ // pattern: connected → pokeStart/End → pokeStart/pokePart(queries)/pokeEnd → pokeStart/pokePart(data)/pokeEnd
352
+ let settled = false
353
+ const timeout = Date.now() + 30000
354
+
355
+ while (!settled && Date.now() < timeout) {
356
+ const msg = (await downstream.dequeue('timeout' as any, 3000)) as any
357
+ if (msg === 'timeout') {
358
+ settled = true
359
+ } else if (Array.isArray(msg) && msg[0] === 'pokeEnd') {
360
+ // after a pokeEnd, check if another poke comes quickly
361
+ const next = (await downstream.dequeue('timeout' as any, 2000)) as any
362
+ if (next === 'timeout') {
363
+ settled = true
364
+ }
365
+ }
366
+ }
367
+ }
368
+
267
369
  async function waitForPokePart(
268
370
  downstream: Queue<unknown>,
269
371
  timeoutMs = 10000
@@ -279,6 +381,35 @@ describe('orez integration', { timeout: 120000 }, () => {
279
381
  }
280
382
  throw new Error('timed out waiting for pokePart')
281
383
  }
384
+
385
+ async function collectPokeRows(
386
+ downstream: Queue<unknown>,
387
+ windowMs = 5000
388
+ ): Promise<any[]> {
389
+ const rows: any[] = []
390
+ const deadline = Date.now() + windowMs
391
+ // first wait for the pokePart with data
392
+ while (Date.now() < deadline) {
393
+ const remaining = Math.max(1000, deadline - Date.now())
394
+ const msg = (await downstream.dequeue('timeout' as any, remaining)) as any
395
+ if (msg === 'timeout') break
396
+ if (Array.isArray(msg) && msg[0] === 'pokePart' && msg[1]?.rowsPatch) {
397
+ rows.push(...msg[1].rowsPatch)
398
+ // check if more poke parts come quickly
399
+ const more = (await downstream.dequeue('timeout' as any, 2000)) as any
400
+ if (
401
+ more !== 'timeout' &&
402
+ Array.isArray(more) &&
403
+ more[0] === 'pokePart' &&
404
+ more[1]?.rowsPatch
405
+ ) {
406
+ rows.push(...more[1].rowsPatch)
407
+ }
408
+ break
409
+ }
410
+ }
411
+ return rows
412
+ }
282
413
  })
283
414
 
284
415
  async function waitForZero(port: number, timeoutMs = 30000) {
package/src/log.ts CHANGED
@@ -25,17 +25,6 @@ export function setLogLevel(level: LogLevel) {
25
25
  currentLevel = level
26
26
  }
27
27
 
28
- type LogListener = (source: string, level: LogLevel, msg: string) => void
29
- const listeners: LogListener[] = []
30
-
31
- export function addLogListener(fn: LogListener) {
32
- listeners.push(fn)
33
- return () => {
34
- const idx = listeners.indexOf(fn)
35
- if (idx !== -1) listeners.splice(idx, 1)
36
- }
37
- }
38
-
39
28
  function prefix(label: string, color: string): string {
40
29
  return `${BOLD}${color}[${label}]${RESET}`
41
30
  }
@@ -48,12 +37,6 @@ export function port(n: number, color: keyof typeof COLORS): string {
48
37
  function makeLogger(label: string, color: string, level: LogLevel = 'info') {
49
38
  const p = prefix(label, color)
50
39
  return (...args: unknown[]) => {
51
- // always notify listeners (they capture everything for admin ui)
52
- if (listeners.length > 0) {
53
- const msg = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')
54
- for (const fn of listeners) fn(label, level, msg)
55
- }
56
- // only print to terminal if level passes filter
57
40
  if (LEVEL_PRIORITY[level] <= LEVEL_PRIORITY[currentLevel]) {
58
41
  console.info(p, ...args)
59
42
  }
@@ -64,15 +47,8 @@ export const log = {
64
47
  orez: makeLogger('orez', COLORS.cyan, 'warn'),
65
48
  pglite: makeLogger('pglite', COLORS.green, 'warn'),
66
49
  proxy: makeLogger('pg-proxy', COLORS.yellow, 'warn'),
67
- zero: makeLogger('zero', COLORS.magenta, 'warn'),
50
+ zero: makeLogger('zero-cache', COLORS.magenta, 'warn'),
68
51
  s3: makeLogger('orez/s3', COLORS.blue, 'warn'),
69
- info: {
70
- orez: makeLogger('orez', COLORS.cyan, 'info'),
71
- pglite: makeLogger('pglite', COLORS.green, 'info'),
72
- proxy: makeLogger('pg-proxy', COLORS.yellow, 'info'),
73
- zero: makeLogger('zero', COLORS.magenta, 'info'),
74
- s3: makeLogger('orez/s3', COLORS.blue, 'info'),
75
- },
76
52
  debug: {
77
53
  orez: makeLogger('orez', COLORS.cyan, 'debug'),
78
54
  pglite: makeLogger('pglite', COLORS.green, 'debug'),
package/src/mutex.ts CHANGED
@@ -1,8 +1,4 @@
1
- // simple mutex for serializing pglite access.
2
- // uses setImmediate/setTimeout between releases to prevent event loop
3
- // starvation when multiple connections queue up — without this, releasing
4
- // the mutex resolves the next waiter as a microtask, which causes a chain
5
- // of synchronous pglite executions that blocks all I/O processing.
1
+ // simple mutex for serializing pglite access
6
2
  export class Mutex {
7
3
  private locked = false
8
4
  private queue: Array<() => void> = []
@@ -20,13 +16,7 @@ export class Mutex {
20
16
  release(): void {
21
17
  const next = this.queue.shift()
22
18
  if (next) {
23
- // yield to event loop so I/O events (socket reads/writes) are processed
24
- // before the next waiter acquires the mutex
25
- if (typeof setImmediate !== 'undefined') {
26
- setImmediate(next)
27
- } else {
28
- setTimeout(next, 0)
29
- }
19
+ next()
30
20
  } else {
31
21
  this.locked = false
32
22
  }