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.
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +36 -3
- package/dist/index.js.map +1 -1
- package/dist/mutex.d.ts +8 -0
- package/dist/mutex.d.ts.map +1 -0
- package/dist/mutex.js +26 -0
- package/dist/mutex.js.map +1 -0
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +19 -33
- package/dist/pg-proxy.js.map +1 -1
- package/dist/replication/change-tracker.d.ts +7 -0
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +40 -3
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +136 -59
- package/dist/replication/handler.js.map +1 -1
- package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
- package/dist/replication/pgoutput-encoder.js +11 -1
- package/dist/replication/pgoutput-encoder.js.map +1 -1
- package/package.json +3 -3
- package/src/cli.ts +1 -1
- package/src/config.ts +1 -1
- package/src/index.ts +35 -3
- package/src/integration/integration.test.ts +24 -14
- package/src/mutex.ts +27 -0
- package/src/pg-proxy.ts +21 -34
- package/src/replication/change-tracker.test.ts +4 -4
- package/src/replication/change-tracker.ts +49 -3
- package/src/replication/handler.ts +176 -68
- package/src/replication/pgoutput-encoder.ts +9 -1
- package/src/replication/zero-compat.test.ts +7 -3
|
@@ -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<{
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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,
|