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.
Files changed (54) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +6 -56
  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 -5
  28. package/dist/replication/change-tracker.d.ts.map +1 -1
  29. package/dist/replication/change-tracker.js +1 -43
  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 +16 -20
  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 -70
  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.test.ts +114 -0
  48. package/src/replication/change-tracker.ts +4 -54
  49. package/src/replication/handler.ts +16 -33
  50. package/src/replication/pgoutput-encoder.test.ts +16 -7
  51. package/src/replication/zero-compat.test.ts +84 -211
  52. package/src/shim/hooks.mjs +1 -1
  53. package/src/vite-plugin.ts +0 -28
  54. package/src/wasm-sqlite.test.ts +1 -2
@@ -16,7 +16,7 @@ export interface PGliteInstances {
16
16
  }
17
17
 
18
18
  // create a single pglite instance with given dataDir suffix
19
- export async function createInstance(
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(filePath, 'utf-8')
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). other shard
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(`removed stale shard trigger from ${nspname}.${event_object_table}`)
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 lastIdleLog = 0
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
- console.info(
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
- activeHandlerCount--
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 { PgoutputParser } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/logical-replication/pgoutput-parser.js')
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 = () => { testLsn += 0x100n; return testLsn }
560
+ const next = () => {
561
+ testLsn += 0x100n
562
+ return testLsn
563
+ }
559
564
 
560
- const slotLsn = next() // CREATE_REPLICATION_SLOT
561
- const beginLsn = next() // first streaming BEGIN
562
- const commitLsn = next() // first streaming COMMIT
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 { versionToLexi } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/types/lexi-version.js')
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 { toBigInt: lsnToBigInt } = require('/Users/n8/orez/node_modules/@rocicorp/zero/out/zero-cache/src/services/change-source/pg/lsn.js')
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()