orez 0.0.48 → 0.0.50
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.d.ts.map +1 -1
- package/dist/cli.js +6 -56
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +0 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -5
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +91 -280
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +0 -9
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +1 -24
- package/dist/log.js.map +1 -1
- package/dist/mutex.d.ts.map +1 -1
- package/dist/mutex.js +2 -13
- package/dist/mutex.js.map +1 -1
- package/dist/pg-proxy.d.ts +2 -3
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +167 -377
- package/dist/pg-proxy.js.map +1 -1
- package/dist/pglite-manager.d.ts +0 -1
- package/dist/pglite-manager.d.ts.map +1 -1
- package/dist/pglite-manager.js +2 -8
- package/dist/pglite-manager.js.map +1 -1
- package/dist/replication/change-tracker.d.ts +0 -5
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +1 -43
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +16 -20
- package/dist/replication/handler.js.map +1 -1
- package/dist/vite-plugin.d.ts +0 -3
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +0 -24
- package/dist/vite-plugin.js.map +1 -1
- package/package.json +5 -4
- package/src/cli.ts +18 -70
- package/src/config.ts +0 -10
- package/src/index.ts +92 -309
- package/src/integration/integration.test.ts +264 -133
- package/src/log.ts +1 -25
- package/src/mutex.ts +2 -12
- package/src/pg-proxy.ts +187 -451
- package/src/pglite-manager.ts +2 -9
- package/src/replication/change-tracker.test.ts +114 -0
- package/src/replication/change-tracker.ts +4 -54
- package/src/replication/handler.ts +16 -33
- package/src/replication/pgoutput-encoder.test.ts +16 -7
- package/src/replication/zero-compat.test.ts +84 -211
- package/src/shim/hooks.mjs +1 -1
- package/src/vite-plugin.ts +0 -28
- package/src/wasm-sqlite.test.ts +1 -2
package/src/pglite-manager.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface PGliteInstances {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// create a single pglite instance with given dataDir suffix
|
|
19
|
-
|
|
19
|
+
async function createInstance(
|
|
20
20
|
config: ZeroLiteConfig,
|
|
21
21
|
name: string,
|
|
22
22
|
withExtensions: boolean
|
|
@@ -133,15 +133,8 @@ export async function runMigrations(db: PGlite, config: ZeroLiteConfig): Promise
|
|
|
133
133
|
continue
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
const filePath = join(migrationsDir, file)
|
|
137
|
-
if (!existsSync(filePath)) {
|
|
138
|
-
// .ts-only custom migrations are handled by the app's own migration runner
|
|
139
|
-
log.debug.orez(`skipping migration (no .sql file): ${name}`)
|
|
140
|
-
continue
|
|
141
|
-
}
|
|
142
|
-
|
|
143
136
|
log.debug.orez(`applying migration: ${name}`)
|
|
144
|
-
const sql = readFileSync(
|
|
137
|
+
const sql = readFileSync(join(migrationsDir, file), 'utf-8')
|
|
145
138
|
|
|
146
139
|
// split by drizzle's statement-breakpoint marker
|
|
147
140
|
const statements = sql
|
|
@@ -3,6 +3,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
installChangeTracking,
|
|
6
|
+
installTriggersOnShardTables,
|
|
7
|
+
purgeConsumedChanges,
|
|
6
8
|
getChangesSince,
|
|
7
9
|
getCurrentWatermark,
|
|
8
10
|
} from './change-tracker'
|
|
@@ -184,3 +186,115 @@ describe('change-tracker', () => {
|
|
|
184
186
|
expect(special[0].row_data).toMatchObject({ val: 'works' })
|
|
185
187
|
})
|
|
186
188
|
})
|
|
189
|
+
|
|
190
|
+
describe('shard table tracking', () => {
|
|
191
|
+
let db: PGlite
|
|
192
|
+
|
|
193
|
+
beforeEach(async () => {
|
|
194
|
+
db = new PGlite()
|
|
195
|
+
await db.waitReady
|
|
196
|
+
await installChangeTracking(db)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
afterEach(async () => {
|
|
200
|
+
await db.close()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('only tracks clients table in shard schemas, not replicas/mutations', async () => {
|
|
204
|
+
// zero-cache creates shard schemas like chat_0 with clients, replicas, mutations.
|
|
205
|
+
// only clients needs tracking — replicas/mutations changes crash zero-cache
|
|
206
|
+
// with "Unknown table chat_0.replicas" because they aren't in zero's schema.
|
|
207
|
+
await db.exec(`
|
|
208
|
+
CREATE SCHEMA chat_0;
|
|
209
|
+
CREATE TABLE chat_0.clients (
|
|
210
|
+
"clientGroupID" TEXT NOT NULL,
|
|
211
|
+
"clientID" TEXT NOT NULL,
|
|
212
|
+
"lastMutationID" BIGINT,
|
|
213
|
+
"userID" TEXT,
|
|
214
|
+
PRIMARY KEY ("clientGroupID", "clientID")
|
|
215
|
+
);
|
|
216
|
+
CREATE TABLE chat_0.replicas (
|
|
217
|
+
id TEXT PRIMARY KEY,
|
|
218
|
+
version TEXT,
|
|
219
|
+
cookie TEXT
|
|
220
|
+
);
|
|
221
|
+
CREATE TABLE chat_0.mutations (
|
|
222
|
+
id TEXT PRIMARY KEY,
|
|
223
|
+
"clientID" TEXT,
|
|
224
|
+
name TEXT,
|
|
225
|
+
args JSONB
|
|
226
|
+
);
|
|
227
|
+
`)
|
|
228
|
+
|
|
229
|
+
await installTriggersOnShardTables(db)
|
|
230
|
+
|
|
231
|
+
// insert into all three tables
|
|
232
|
+
await db.exec(
|
|
233
|
+
`INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 1)`
|
|
234
|
+
)
|
|
235
|
+
await db.exec(`INSERT INTO chat_0.replicas (id, version) VALUES ('r1', 'v1')`)
|
|
236
|
+
await db.exec(
|
|
237
|
+
`INSERT INTO chat_0.mutations (id, "clientID", name) VALUES ('m1', 'c1', 'sendMessage')`
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const changes = await getChangesSince(db, 0)
|
|
241
|
+
const tables = changes.map((c) => c.table_name)
|
|
242
|
+
|
|
243
|
+
// only clients should be tracked
|
|
244
|
+
expect(tables).toContain('chat_0.clients')
|
|
245
|
+
expect(tables).not.toContain('chat_0.replicas')
|
|
246
|
+
expect(tables).not.toContain('chat_0.mutations')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('purges consumed changes to prevent OOM', async () => {
|
|
250
|
+
// _zero_changes accumulates forever in 0.0.37. with wasm pglite,
|
|
251
|
+
// this eventually causes OOM. we need a purge mechanism.
|
|
252
|
+
await db.exec(`
|
|
253
|
+
CREATE TABLE public.items (id SERIAL PRIMARY KEY, val TEXT)
|
|
254
|
+
`)
|
|
255
|
+
await installChangeTracking(db)
|
|
256
|
+
|
|
257
|
+
// insert some data
|
|
258
|
+
for (let i = 0; i < 10; i++) {
|
|
259
|
+
await db.exec(`INSERT INTO public.items (val) VALUES ('item${i}')`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const changes = await getChangesSince(db, 0)
|
|
263
|
+
expect(changes).toHaveLength(10)
|
|
264
|
+
const lastWatermark = changes[changes.length - 1].watermark
|
|
265
|
+
|
|
266
|
+
// purge consumed changes up to the watermark we've processed
|
|
267
|
+
await purgeConsumedChanges(db, lastWatermark)
|
|
268
|
+
|
|
269
|
+
// after purge, no changes before that watermark should remain
|
|
270
|
+
const remaining = await getChangesSince(db, 0)
|
|
271
|
+
expect(remaining).toHaveLength(0)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('tracks tables created after initial installChangeTracking', async () => {
|
|
275
|
+
// simulate zero-cache creating shard schema AFTER replication starts.
|
|
276
|
+
// in production, zero-cache creates chat_0 schema + clients table
|
|
277
|
+
// after the replication connection is already established.
|
|
278
|
+
// the change tracker must pick up these new tables.
|
|
279
|
+
await db.exec(`
|
|
280
|
+
CREATE SCHEMA chat_0;
|
|
281
|
+
CREATE TABLE chat_0.clients (
|
|
282
|
+
"clientGroupID" TEXT NOT NULL,
|
|
283
|
+
"clientID" TEXT NOT NULL,
|
|
284
|
+
"lastMutationID" BIGINT,
|
|
285
|
+
PRIMARY KEY ("clientGroupID", "clientID")
|
|
286
|
+
);
|
|
287
|
+
`)
|
|
288
|
+
|
|
289
|
+
// re-running installTriggersOnShardTables should pick up new tables
|
|
290
|
+
await installTriggersOnShardTables(db)
|
|
291
|
+
|
|
292
|
+
await db.exec(
|
|
293
|
+
`INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 1)`
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const changes = await getChangesSince(db, 0)
|
|
297
|
+
expect(changes).toHaveLength(1)
|
|
298
|
+
expect(changes[0].table_name).toBe('chat_0.clients')
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -140,58 +140,6 @@ async function installTriggersOnAllTables(db: PGlite): Promise<void> {
|
|
|
140
140
|
log.debug.pglite(`installed change tracking triggers on ${count} tables`)
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/**
|
|
144
|
-
* re-install change tracking triggers on any public tables that don't have them.
|
|
145
|
-
* catches tables created between startup and replication start.
|
|
146
|
-
*/
|
|
147
|
-
export async function ensureChangeTrackingOnAllTables(db: PGlite): Promise<void> {
|
|
148
|
-
const pubName = process.env.ZERO_APP_PUBLICATIONS
|
|
149
|
-
let tables: { tablename: string }[]
|
|
150
|
-
|
|
151
|
-
if (pubName) {
|
|
152
|
-
const result = await db.query<{ tablename: string }>(
|
|
153
|
-
`SELECT tablename FROM pg_publication_tables
|
|
154
|
-
WHERE pubname = $1
|
|
155
|
-
AND schemaname = 'public'
|
|
156
|
-
AND tablename NOT LIKE '_zero_%'`,
|
|
157
|
-
[pubName]
|
|
158
|
-
)
|
|
159
|
-
tables = result.rows
|
|
160
|
-
} else {
|
|
161
|
-
const result = await db.query<{ tablename: string }>(
|
|
162
|
-
`SELECT tablename FROM pg_tables
|
|
163
|
-
WHERE schemaname = 'public'
|
|
164
|
-
AND tablename NOT IN ('migrations', '_zero_changes')
|
|
165
|
-
AND tablename NOT LIKE '_zero_%'`
|
|
166
|
-
)
|
|
167
|
-
tables = result.rows
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// find tables missing the change trigger
|
|
171
|
-
const triggered = await db.query<{ event_object_table: string }>(
|
|
172
|
-
`SELECT DISTINCT event_object_table FROM information_schema.triggers
|
|
173
|
-
WHERE trigger_name = '_zero_change_trigger'
|
|
174
|
-
AND event_object_schema = 'public'`
|
|
175
|
-
)
|
|
176
|
-
const hasTracker = new Set(triggered.rows.map((r) => r.event_object_table))
|
|
177
|
-
|
|
178
|
-
let count = 0
|
|
179
|
-
for (const { tablename } of tables) {
|
|
180
|
-
if (hasTracker.has(tablename)) continue
|
|
181
|
-
const quoted = quoteIdent(tablename)
|
|
182
|
-
await db.exec(`
|
|
183
|
-
CREATE TRIGGER _zero_change_trigger
|
|
184
|
-
AFTER INSERT OR UPDATE OR DELETE ON public.${quoted}
|
|
185
|
-
FOR EACH ROW EXECUTE FUNCTION public._zero_track_change();
|
|
186
|
-
`)
|
|
187
|
-
count++
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (count > 0) {
|
|
191
|
-
log.debug.pglite(`installed change tracking on ${count} new tables`)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
143
|
/**
|
|
196
144
|
* install change tracking triggers on tables in shard schemas.
|
|
197
145
|
* zero-cache creates shard schemas (e.g. chat_0) with clients/mutations
|
|
@@ -211,7 +159,7 @@ export async function installTriggersOnShardTables(db: PGlite): Promise<void> {
|
|
|
211
159
|
if (result.rows.length === 0) return
|
|
212
160
|
|
|
213
161
|
// only track `clients` — that's the table zero-cache expects in the
|
|
214
|
-
// replication stream (needed for .server promise resolution).
|
|
162
|
+
// replication stream (needed for .server promise resolution). other shard
|
|
215
163
|
// tables like `replicas` are zero-cache internal state and streaming them
|
|
216
164
|
// back causes "Unknown table" crashes in zero-cache's change-processor.
|
|
217
165
|
let count = 0
|
|
@@ -228,7 +176,9 @@ export async function installTriggersOnShardTables(db: PGlite): Promise<void> {
|
|
|
228
176
|
const qs = quoteIdent(nspname)
|
|
229
177
|
const qt = quoteIdent(event_object_table)
|
|
230
178
|
await db.exec(`DROP TRIGGER IF EXISTS _zero_change_trigger ON ${qs}.${qt}`)
|
|
231
|
-
log.debug.pglite(
|
|
179
|
+
log.debug.pglite(
|
|
180
|
+
`removed stale shard trigger from ${nspname}.${event_object_table}`
|
|
181
|
+
)
|
|
232
182
|
}
|
|
233
183
|
|
|
234
184
|
const tables = await db.query<{ tablename: string }>(
|
|
@@ -32,9 +32,6 @@ import {
|
|
|
32
32
|
import type { Mutex } from '../mutex.js'
|
|
33
33
|
import type { PGlite } from '@electric-sql/pglite'
|
|
34
34
|
|
|
35
|
-
// track concurrent replication handlers to detect reconnect-purge race
|
|
36
|
-
let activeHandlerCount = 0
|
|
37
|
-
|
|
38
35
|
export interface ReplicationWriter {
|
|
39
36
|
write(data: Uint8Array): void
|
|
40
37
|
}
|
|
@@ -265,11 +262,6 @@ export async function handleStartReplication(
|
|
|
265
262
|
db: PGlite,
|
|
266
263
|
mutex: Mutex
|
|
267
264
|
): Promise<void> {
|
|
268
|
-
activeHandlerCount++
|
|
269
|
-
const handlerId = activeHandlerCount
|
|
270
|
-
console.info(
|
|
271
|
-
`[orez-repl#${handlerId}] START_REPLICATION (active handlers: ${activeHandlerCount})`
|
|
272
|
-
)
|
|
273
265
|
log.debug.proxy('replication: entering streaming mode')
|
|
274
266
|
|
|
275
267
|
// send CopyBothResponse to enter streaming mode
|
|
@@ -461,10 +453,6 @@ export async function handleStartReplication(
|
|
|
461
453
|
mutex.release()
|
|
462
454
|
}
|
|
463
455
|
|
|
464
|
-
console.info(
|
|
465
|
-
`[orez-repl#${handlerId}] setup complete, starting poll (lastWatermark=${lastWatermark})`
|
|
466
|
-
)
|
|
467
|
-
|
|
468
456
|
// track which tables we've sent RELATION messages for
|
|
469
457
|
const sentRelations = new Set<string>()
|
|
470
458
|
let txCounter = 1
|
|
@@ -475,13 +463,26 @@ export async function handleStartReplication(
|
|
|
475
463
|
const pollIntervalCatchUp = 20
|
|
476
464
|
const batchSize = 2000
|
|
477
465
|
const purgeEveryN = 10
|
|
466
|
+
const shardRescanEveryN = 20
|
|
478
467
|
let running = true
|
|
479
468
|
let pollsSincePurge = 0
|
|
480
|
-
let
|
|
469
|
+
let pollsSinceShardRescan = 0
|
|
481
470
|
|
|
482
471
|
const poll = async () => {
|
|
483
472
|
while (running) {
|
|
484
473
|
try {
|
|
474
|
+
// periodically re-scan for new shard schemas (e.g. chat_0 created by zero-cache)
|
|
475
|
+
pollsSinceShardRescan++
|
|
476
|
+
if (pollsSinceShardRescan >= shardRescanEveryN) {
|
|
477
|
+
pollsSinceShardRescan = 0
|
|
478
|
+
await mutex.acquire()
|
|
479
|
+
try {
|
|
480
|
+
await installTriggersOnShardTables(db)
|
|
481
|
+
} finally {
|
|
482
|
+
mutex.release()
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
485
486
|
// acquire mutex to avoid conflicting with proxy connections
|
|
486
487
|
await mutex.acquire()
|
|
487
488
|
let changes: Awaited<ReturnType<typeof getChangesSince>>
|
|
@@ -511,10 +512,6 @@ export async function handleStartReplication(
|
|
|
511
512
|
continue
|
|
512
513
|
}
|
|
513
514
|
|
|
514
|
-
const tables = [...new Set(changes.map((c) => c.table_name))].join(',')
|
|
515
|
-
console.info(
|
|
516
|
-
`[orez-repl#${handlerId}] found ${changes.length} changes [${tables}] (wm ${lastWatermark}→${changes[changes.length - 1].watermark}, type=${typeof changes[0].watermark})`
|
|
517
|
-
)
|
|
518
515
|
await streamChanges(
|
|
519
516
|
changes,
|
|
520
517
|
writer,
|
|
@@ -534,23 +531,12 @@ export async function handleStartReplication(
|
|
|
534
531
|
try {
|
|
535
532
|
const purged = await purgeConsumedChanges(db, lastWatermark)
|
|
536
533
|
if (purged > 0) {
|
|
537
|
-
|
|
538
|
-
`[orez-repl#${handlerId}] purged ${purged} changes (wm<=${lastWatermark})`
|
|
539
|
-
)
|
|
534
|
+
log.debug.proxy(`purged ${purged} consumed changes`)
|
|
540
535
|
}
|
|
541
536
|
} finally {
|
|
542
537
|
mutex.release()
|
|
543
538
|
}
|
|
544
539
|
}
|
|
545
|
-
} else {
|
|
546
|
-
// throttled idle logging (every 10s)
|
|
547
|
-
const now = Date.now()
|
|
548
|
-
if (now - lastIdleLog > 10000) {
|
|
549
|
-
lastIdleLog = now
|
|
550
|
-
console.info(
|
|
551
|
-
`[orez-repl#${handlerId}] idle (lastWatermark=${lastWatermark}, type=${typeof lastWatermark})`
|
|
552
|
-
)
|
|
553
|
-
}
|
|
554
540
|
}
|
|
555
541
|
|
|
556
542
|
// send keepalive
|
|
@@ -574,10 +560,7 @@ export async function handleStartReplication(
|
|
|
574
560
|
|
|
575
561
|
log.debug.proxy('replication: starting poll loop')
|
|
576
562
|
await poll()
|
|
577
|
-
|
|
578
|
-
console.info(
|
|
579
|
-
`[orez-repl#${handlerId}] poll loop exited (remaining handlers: ${activeHandlerCount})`
|
|
580
|
-
)
|
|
563
|
+
log.debug.proxy('replication: poll loop exited')
|
|
581
564
|
}
|
|
582
565
|
|
|
583
566
|
async function streamChanges(
|
|
@@ -369,7 +369,9 @@ describe('pgoutput-encoder', () => {
|
|
|
369
369
|
describe('roundtrip: orez encoder → zero-cache parser', () => {
|
|
370
370
|
// absolute path bypasses package.json exports restriction
|
|
371
371
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
372
|
-
const {
|
|
372
|
+
const {
|
|
373
|
+
PgoutputParser,
|
|
374
|
+
} = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/logical-replication/pgoutput-parser.js')
|
|
373
375
|
|
|
374
376
|
// mock type parsers: unknown OIDs default to String (identity for text)
|
|
375
377
|
const typeParsers = { getTypeParser: () => String }
|
|
@@ -555,20 +557,27 @@ describe('pgoutput-encoder', () => {
|
|
|
555
557
|
it('LSN ordering: slot < streaming changes', () => {
|
|
556
558
|
// validates that streaming changes will be seen as "new" by zero-cache
|
|
557
559
|
let testLsn = 0x1000000n
|
|
558
|
-
const next = () => {
|
|
560
|
+
const next = () => {
|
|
561
|
+
testLsn += 0x100n
|
|
562
|
+
return testLsn
|
|
563
|
+
}
|
|
559
564
|
|
|
560
|
-
const slotLsn = next()
|
|
561
|
-
const beginLsn = next()
|
|
562
|
-
const commitLsn = next()
|
|
565
|
+
const slotLsn = next() // CREATE_REPLICATION_SLOT
|
|
566
|
+
const beginLsn = next() // first streaming BEGIN
|
|
567
|
+
const commitLsn = next() // first streaming COMMIT
|
|
563
568
|
|
|
564
569
|
expect(beginLsn).toBeGreaterThan(slotLsn)
|
|
565
570
|
expect(commitLsn).toBeGreaterThan(beginLsn)
|
|
566
571
|
|
|
567
572
|
// verify lexi version ordering is preserved
|
|
568
573
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
569
|
-
const {
|
|
574
|
+
const {
|
|
575
|
+
versionToLexi,
|
|
576
|
+
} = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/types/lexi-version.js')
|
|
570
577
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
571
|
-
const {
|
|
578
|
+
const {
|
|
579
|
+
toBigInt: lsnToBigInt,
|
|
580
|
+
} = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/lsn.js')
|
|
572
581
|
|
|
573
582
|
const slotHex = `00000000/${slotLsn.toString(16).padStart(8, '0')}`.toUpperCase()
|
|
574
583
|
const beginHex = `00000000/${beginLsn.toString(16).padStart(8, '0')}`.toUpperCase()
|