orez 0.0.18 → 0.0.19

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.
@@ -9,13 +9,18 @@
9
9
 
10
10
  import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'vitest'
11
11
  import WebSocket from 'ws'
12
+
12
13
  import { startZeroLite } from '../index.js'
14
+
13
15
  import type { PGlite } from '@electric-sql/pglite'
14
16
 
15
17
  // simple async queue for collecting websocket messages
16
18
  class Queue<T> {
17
19
  private items: T[] = []
18
- private waiters: Array<{ resolve: (v: T) => void; timer?: ReturnType<typeof setTimeout> }> = []
20
+ private waiters: Array<{
21
+ resolve: (v: T) => void
22
+ timer?: ReturnType<typeof setTimeout>
23
+ }> = []
19
24
 
20
25
  enqueue(item: T) {
21
26
  const waiter = this.waiters.shift()
@@ -113,7 +118,7 @@ describe('orez integration', { timeout: 120000 }, () => {
113
118
  test('zero-cache starts and accepts websocket connections', async () => {
114
119
  const ws = new WebSocket(
115
120
  `ws://localhost:${zeroPort}/sync/v4/connect` +
116
- `?clientGroupID=test-cg&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
121
+ `?clientGroupID=test-cg&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`
117
122
  )
118
123
 
119
124
  const connected = new Promise<void>((resolve, reject) => {
@@ -161,7 +166,7 @@ describe('orez integration', { timeout: 120000 }, () => {
161
166
  value: 'hello',
162
167
  }),
163
168
  }),
164
- ]),
169
+ ])
165
170
  )
166
171
 
167
172
  ws.close()
@@ -196,7 +201,7 @@ describe('orez integration', { timeout: 120000 }, () => {
196
201
  value: 'live-value',
197
202
  }),
198
203
  }),
199
- ]),
204
+ ])
200
205
  )
201
206
 
202
207
  ws.close()
@@ -236,7 +241,7 @@ describe('orez integration', { timeout: 120000 }, () => {
236
241
  value: 'updated',
237
242
  }),
238
243
  }),
239
- ]),
244
+ ])
240
245
  )
241
246
 
242
247
  ws.close()
@@ -267,7 +272,7 @@ describe('orez integration', { timeout: 120000 }, () => {
267
272
  op: 'del',
268
273
  tableName: 'foo',
269
274
  }),
270
- ]),
275
+ ])
271
276
  )
272
277
 
273
278
  ws.close()
@@ -289,8 +294,8 @@ describe('orez integration', { timeout: 120000 }, () => {
289
294
  `concurrent-${i}`,
290
295
  `value-${i}`,
291
296
  i,
292
- ]),
293
- ),
297
+ ])
298
+ )
294
299
  )
295
300
 
296
301
  // collect all poke parts within a window
@@ -316,11 +321,11 @@ describe('orez integration', { timeout: 120000 }, () => {
316
321
  function connectAndSubscribe(
317
322
  port: number,
318
323
  downstream: Queue<unknown>,
319
- query: Record<string, unknown>,
324
+ query: Record<string, unknown>
320
325
  ): WebSocket {
321
326
  const ws = new WebSocket(
322
327
  `ws://localhost:${port}/sync/v4/connect` +
323
- `?clientGroupID=test-cg-${Date.now()}&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
328
+ `?clientGroupID=test-cg-${Date.now()}&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`
324
329
  )
325
330
 
326
331
  ws.on('message', (data) => {
@@ -334,7 +339,7 @@ describe('orez integration', { timeout: 120000 }, () => {
334
339
  {
335
340
  desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
336
341
  },
337
- ]),
342
+ ])
338
343
  )
339
344
  })
340
345
 
@@ -363,7 +368,7 @@ describe('orez integration', { timeout: 120000 }, () => {
363
368
 
364
369
  async function waitForPokePart(
365
370
  downstream: Queue<unknown>,
366
- timeoutMs = 10000,
371
+ timeoutMs = 10000
367
372
  ): Promise<Record<string, any>> {
368
373
  const deadline = Date.now() + timeoutMs
369
374
  while (Date.now() < deadline) {
@@ -379,7 +384,7 @@ describe('orez integration', { timeout: 120000 }, () => {
379
384
 
380
385
  async function collectPokeRows(
381
386
  downstream: Queue<unknown>,
382
- windowMs = 5000,
387
+ windowMs = 5000
383
388
  ): Promise<any[]> {
384
389
  const rows: any[] = []
385
390
  const deadline = Date.now() + windowMs
@@ -392,7 +397,12 @@ describe('orez integration', { timeout: 120000 }, () => {
392
397
  rows.push(...msg[1].rowsPatch)
393
398
  // check if more poke parts come quickly
394
399
  const more = (await downstream.dequeue('timeout' as any, 2000)) as any
395
- if (more !== 'timeout' && Array.isArray(more) && more[0] === 'pokePart' && more[1]?.rowsPatch) {
400
+ if (
401
+ more !== 'timeout' &&
402
+ Array.isArray(more) &&
403
+ more[0] === 'pokePart' &&
404
+ more[1]?.rowsPatch
405
+ ) {
396
406
  rows.push(...more[1].rowsPatch)
397
407
  }
398
408
  break
package/src/mutex.ts ADDED
@@ -0,0 +1,27 @@
1
+ // simple mutex for serializing pglite access
2
+ export class Mutex {
3
+ private locked = false
4
+ private queue: Array<() => void> = []
5
+
6
+ async acquire(): Promise<void> {
7
+ if (!this.locked) {
8
+ this.locked = true
9
+ return
10
+ }
11
+ return new Promise<void>((resolve) => {
12
+ this.queue.push(resolve)
13
+ })
14
+ }
15
+
16
+ release(): void {
17
+ const next = this.queue.shift()
18
+ if (next) {
19
+ next()
20
+ } else {
21
+ this.locked = false
22
+ }
23
+ }
24
+ }
25
+
26
+ // shared mutex instance for serializing pglite access across proxy and replication handler
27
+ export const pgMutex = new Mutex()
package/src/pg-proxy.ts CHANGED
@@ -32,26 +32,33 @@ const QUERY_REWRITES: Array<{ match: RegExp; replace: string }> = [
32
32
  match: /current_setting\s*\(\s*'wal_level'\s*\)/gi,
33
33
  replace: "'logical'::text",
34
34
  },
35
- // strip READ ONLY from BEGIN
35
+ // strip READ ONLY from BEGIN (pglite is single-session, no read-only transactions)
36
36
  {
37
37
  match: /\bREAD\s+ONLY\b/gi,
38
38
  replace: '',
39
39
  },
40
+ // strip ISOLATION LEVEL from any query (pglite is single-session, isolation is meaningless)
41
+ // catches: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE, BEGIN ISOLATION LEVEL SERIALIZABLE, etc.
42
+ {
43
+ match:
44
+ /\bISOLATION\s+LEVEL\s+(SERIALIZABLE|REPEATABLE\s+READ|READ\s+COMMITTED|READ\s+UNCOMMITTED)\b/gi,
45
+ replace: '',
46
+ },
47
+ // strip bare SET TRANSACTION (after ISOLATION LEVEL is removed, this becomes a no-op statement)
48
+ {
49
+ match: /\bSET\s+TRANSACTION\s*;/gi,
50
+ replace: ';',
51
+ },
40
52
  // redirect pg_replication_slots to our fake table
41
53
  {
42
54
  match: /\bpg_replication_slots\b/g,
43
55
  replace: 'public._zero_replication_slots',
44
56
  },
45
- // SET TRANSACTION queries: rewrite to SELECT 1 (PGlite doesn't support them,
46
- // and no-op interception doesn't work for extended protocol Bind/Execute)
47
- {
48
- match: /^\s*SET\s+TRANSACTION\b.*$/gis,
49
- replace: 'SELECT 1',
50
- },
51
57
  ]
52
58
 
53
- // queries to intercept and return no-op success
54
- const NOOP_QUERY_PATTERNS: RegExp[] = []
59
+ // queries to intercept and return no-op success (synthetic SET response)
60
+ // pglite rejects SET TRANSACTION if any query (e.g. SET search_path) ran first
61
+ const NOOP_QUERY_PATTERNS: RegExp[] = [/^\s*SET\s+TRANSACTION\b/i, /^\s*SET\s+SESSION\b/i]
55
62
 
56
63
  /**
57
64
  * extract query text from a Parse message (0x50).
@@ -239,32 +246,9 @@ function stripReadyForQuery(data: Uint8Array): Uint8Array {
239
246
  return result
240
247
  }
241
248
 
242
- // simple mutex for serializing pglite access
243
- class Mutex {
244
- private locked = false
245
- private queue: Array<() => void> = []
246
-
247
- async acquire(): Promise<void> {
248
- if (!this.locked) {
249
- this.locked = true
250
- return
251
- }
252
- return new Promise<void>((resolve) => {
253
- this.queue.push(resolve)
254
- })
255
- }
256
-
257
- release(): void {
258
- const next = this.queue.shift()
259
- if (next) {
260
- next()
261
- } else {
262
- this.locked = false
263
- }
264
- }
265
- }
249
+ import { pgMutex } from './mutex.js'
266
250
 
267
- const mutex = new Mutex()
251
+ const mutex = pgMutex
268
252
 
269
253
  // module-level search_path tracking
270
254
  let currentSearchPath = 'public'
@@ -434,6 +418,9 @@ async function handleReplicationMessage(
434
418
  const response = await handleReplicationQuery(query, db)
435
419
  if (response) return response
436
420
 
421
+ // apply query rewrites (e.g. SET TRANSACTION → SELECT 1) before forwarding
422
+ data = interceptQuery(data)
423
+
437
424
  // fall through to pglite for unrecognized queries
438
425
  await mutex.acquire()
439
426
  try {
@@ -33,7 +33,7 @@ describe('change-tracker', () => {
33
33
  const changes = await getChangesSince(db, 0)
34
34
  expect(changes).toHaveLength(1)
35
35
  expect(changes[0].op).toBe('INSERT')
36
- expect(changes[0].table_name).toBe('items')
36
+ expect(changes[0].table_name).toBe('public.items')
37
37
  expect(changes[0].row_data).toMatchObject({ name: 'a', value: 1 })
38
38
  expect(changes[0].old_data).toBeNull()
39
39
  })
@@ -116,8 +116,8 @@ describe('change-tracker', () => {
116
116
 
117
117
  const changes = await getChangesSince(db, 0)
118
118
  const tables = new Set(changes.map((c) => c.table_name))
119
- expect(tables).toContain('items')
120
- expect(tables).toContain('other')
119
+ expect(tables).toContain('public.items')
120
+ expect(tables).toContain('public.other')
121
121
  })
122
122
 
123
123
  it('handles rapid inserts (50 rows)', async () => {
@@ -178,7 +178,7 @@ describe('change-tracker', () => {
178
178
  await db.exec(`INSERT INTO public."my""table" (val) VALUES ('works')`)
179
179
 
180
180
  const changes = await getChangesSince(db, 0)
181
- const special = changes.filter((c) => c.table_name === 'my"table')
181
+ const special = changes.filter((c) => c.table_name === 'public.my"table')
182
182
  expect(special).toHaveLength(1)
183
183
  expect(special[0].op).toBe('INSERT')
184
184
  expect(special[0].row_data).toMatchObject({ val: 'works' })
@@ -45,18 +45,21 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
45
45
  // create trigger function
46
46
  await db.exec(`
47
47
  CREATE OR REPLACE FUNCTION public._zero_track_change() RETURNS TRIGGER AS $$
48
+ DECLARE
49
+ qualified_name TEXT;
48
50
  BEGIN
51
+ qualified_name := TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME;
49
52
  IF TG_OP = 'DELETE' THEN
50
53
  INSERT INTO public._zero_changes (table_name, op, old_data)
51
- VALUES (TG_TABLE_NAME, 'DELETE', row_to_json(OLD)::jsonb);
54
+ VALUES (qualified_name, 'DELETE', row_to_json(OLD)::jsonb);
52
55
  RETURN OLD;
53
56
  ELSIF TG_OP = 'UPDATE' THEN
54
57
  INSERT INTO public._zero_changes (table_name, op, row_data, old_data)
55
- VALUES (TG_TABLE_NAME, 'UPDATE', row_to_json(NEW)::jsonb, row_to_json(OLD)::jsonb);
58
+ VALUES (qualified_name, 'UPDATE', row_to_json(NEW)::jsonb, row_to_json(OLD)::jsonb);
56
59
  RETURN NEW;
57
60
  ELSIF TG_OP = 'INSERT' THEN
58
61
  INSERT INTO public._zero_changes (table_name, op, row_data)
59
- VALUES (TG_TABLE_NAME, 'INSERT', row_to_json(NEW)::jsonb);
62
+ VALUES (qualified_name, 'INSERT', row_to_json(NEW)::jsonb);
60
63
  RETURN NEW;
61
64
  END IF;
62
65
  RETURN NULL;
@@ -137,6 +140,49 @@ async function installTriggersOnAllTables(db: PGlite): Promise<void> {
137
140
  log.debug.pglite(`installed change tracking triggers on ${count} tables`)
138
141
  }
139
142
 
143
+ /**
144
+ * install change tracking triggers on tables in shard schemas.
145
+ * zero-cache creates shard schemas (e.g. chat_0) with clients/mutations
146
+ * tables that track mutation confirmations. these must be replicated
147
+ * for .server promises to resolve.
148
+ */
149
+ export async function installTriggersOnShardTables(db: PGlite): Promise<void> {
150
+ const result = await db.query<{ nspname: string }>(
151
+ `SELECT nspname FROM pg_namespace
152
+ WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public')
153
+ AND nspname NOT LIKE 'pg_%'
154
+ AND nspname NOT LIKE 'zero_%'
155
+ AND nspname NOT LIKE '_zero_%'
156
+ AND nspname NOT LIKE '%/%'`
157
+ )
158
+
159
+ if (result.rows.length === 0) return
160
+
161
+ let count = 0
162
+ for (const { nspname } of result.rows) {
163
+ const tables = await db.query<{ tablename: string }>(
164
+ `SELECT tablename FROM pg_tables WHERE schemaname = $1`,
165
+ [nspname]
166
+ )
167
+
168
+ for (const { tablename } of tables.rows) {
169
+ const quotedSchema = quoteIdent(nspname)
170
+ const quotedTable = quoteIdent(tablename)
171
+ await db.exec(`
172
+ DROP TRIGGER IF EXISTS _zero_change_trigger ON ${quotedSchema}.${quotedTable};
173
+ CREATE TRIGGER _zero_change_trigger
174
+ AFTER INSERT OR UPDATE OR DELETE ON ${quotedSchema}.${quotedTable}
175
+ FOR EACH ROW EXECUTE FUNCTION public._zero_track_change();
176
+ `)
177
+ count++
178
+ }
179
+ }
180
+
181
+ if (count > 0) {
182
+ log.debug.pglite(`installed change tracking on ${count} shard tables`)
183
+ }
184
+ }
185
+
140
186
  export async function getChangesSince(
141
187
  db: PGlite,
142
188
  watermark: number,