orez 0.2.27 → 0.2.30

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 (157) hide show
  1. package/dist/cf-do/worker.d.ts +3 -0
  2. package/dist/cf-do/worker.d.ts.map +1 -1
  3. package/dist/cf-do/worker.js +37 -15
  4. package/dist/cf-do/worker.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +8 -0
  7. package/dist/index.js.map +1 -1
  8. package/package.json +3 -4
  9. package/src/admin/admin-data.test.ts +0 -348
  10. package/src/admin/http-proxy.ts +0 -252
  11. package/src/admin/log-store.ts +0 -192
  12. package/src/admin/server.ts +0 -471
  13. package/src/admin/ui.ts +0 -1322
  14. package/src/bench/proxy-throughput.bench.ts +0 -343
  15. package/src/bench/serial-mutations.bench.ts +0 -270
  16. package/src/browser.ts +0 -203
  17. package/src/cf-do/.wrangler/cache/cf.json +0 -1
  18. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  19. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  20. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  21. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
  22. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  23. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  24. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  25. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
  26. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
  27. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
  28. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
  29. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
  30. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
  31. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
  32. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
  33. package/src/cf-do/ARCHITECTURE.md +0 -93
  34. package/src/cf-do/CHAT_E2E.md +0 -213
  35. package/src/cf-do/watermark.test.ts +0 -103
  36. package/src/cf-do/watermark.ts +0 -118
  37. package/src/cf-do/worker.ts +0 -1041
  38. package/src/cf-do/wrangler.toml +0 -11
  39. package/src/cf-pglite/README.md +0 -19
  40. package/src/change-tracking.ts +0 -25
  41. package/src/child-process.test.ts +0 -147
  42. package/src/child-process.ts +0 -90
  43. package/src/cli-entry.ts +0 -72
  44. package/src/cli.test.ts +0 -40
  45. package/src/cli.ts +0 -1214
  46. package/src/config.ts +0 -150
  47. package/src/do-sql-tracking.test.ts +0 -19
  48. package/src/do-sql-tracking.ts +0 -19
  49. package/src/index.ts +0 -1215
  50. package/src/integration/integration.test.ts +0 -517
  51. package/src/integration/native-binary.guard.test.ts +0 -13
  52. package/src/integration/native-startup.test.ts +0 -44
  53. package/src/integration/replication-latency.test.ts +0 -428
  54. package/src/integration/restore-live-stress.test.ts +0 -433
  55. package/src/integration/restore-reset.test.ts +0 -400
  56. package/src/integration/restore.test.ts +0 -274
  57. package/src/integration/test-permissions.ts +0 -147
  58. package/src/load-config.ts +0 -46
  59. package/src/log.ts +0 -96
  60. package/src/mutex.ts +0 -47
  61. package/src/pg-proxy-browser.singledb.test.ts +0 -233
  62. package/src/pg-proxy-browser.ts +0 -2022
  63. package/src/pg-proxy-do-backend.test.ts +0 -3890
  64. package/src/pg-proxy-do-backend.ts +0 -7191
  65. package/src/pg-proxy.ts +0 -1087
  66. package/src/pg-sqlite-compiler/README.md +0 -53
  67. package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
  68. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
  69. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
  70. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
  71. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
  72. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
  73. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
  74. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
  75. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
  76. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
  77. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
  78. package/src/pg-sqlite-compiler/index.ts +0 -73
  79. package/src/pg-sqlite-compiler/integration.test.ts +0 -136
  80. package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
  81. package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
  82. package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
  83. package/src/pg-sqlite-compiler/passes/index.ts +0 -49
  84. package/src/pg-sqlite-compiler/passes/types.ts +0 -156
  85. package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
  86. package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
  87. package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
  88. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
  89. package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
  90. package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
  91. package/src/pg-sqlite-compiler/types.ts +0 -63
  92. package/src/pglite-ipc.test.ts +0 -116
  93. package/src/pglite-ipc.ts +0 -266
  94. package/src/pglite-manager.ts +0 -557
  95. package/src/pglite-web-proxy.test.ts +0 -57
  96. package/src/pglite-web-proxy.ts +0 -221
  97. package/src/pglite-web-worker.ts +0 -152
  98. package/src/pglite-worker-thread.ts +0 -253
  99. package/src/port.ts +0 -25
  100. package/src/process-title.ts +0 -9
  101. package/src/recovery.ts +0 -155
  102. package/src/replication/change-tracker.test.ts +0 -357
  103. package/src/replication/change-tracker.ts +0 -279
  104. package/src/replication/handler.test.ts +0 -511
  105. package/src/replication/handler.ts +0 -1190
  106. package/src/replication/pgoutput-encoder.test.ts +0 -697
  107. package/src/replication/pgoutput-encoder.ts +0 -373
  108. package/src/replication/tcp-replication.test.ts +0 -876
  109. package/src/replication/zero-compat.test.ts +0 -1150
  110. package/src/restore-stress.test.ts +0 -188
  111. package/src/s3-local.ts +0 -203
  112. package/src/shim/hooks.mjs +0 -120
  113. package/src/shim/register.mjs +0 -4
  114. package/src/sqlite-mode/apply-mode.ts +0 -224
  115. package/src/sqlite-mode/index.ts +0 -15
  116. package/src/sqlite-mode/native-binary.ts +0 -89
  117. package/src/sqlite-mode/package-resolve.ts +0 -17
  118. package/src/sqlite-mode/resolve-mode.ts +0 -80
  119. package/src/sqlite-mode/shim-template.ts +0 -159
  120. package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
  121. package/src/sqlite-mode/types.ts +0 -30
  122. package/src/vite-plugin.ts +0 -67
  123. package/src/wasm-sqlite.test.ts +0 -537
  124. package/src/worker/browser-admin.ts +0 -52
  125. package/src/worker/browser-build-config.test.ts +0 -71
  126. package/src/worker/browser-build-config.ts +0 -109
  127. package/src/worker/browser-embed-admin.test.ts +0 -75
  128. package/src/worker/browser-embed.ts +0 -345
  129. package/src/worker/cf-patches.ts +0 -384
  130. package/src/worker/embed-integration.test.ts +0 -321
  131. package/src/worker/index.ts +0 -138
  132. package/src/worker/shims/fastify.test.ts +0 -255
  133. package/src/worker/shims/fastify.ts +0 -306
  134. package/src/worker/shims/http-service.test.ts +0 -355
  135. package/src/worker/shims/http-service.ts +0 -293
  136. package/src/worker/shims/node-stub.ts +0 -290
  137. package/src/worker/shims/oxfmt.ts +0 -3
  138. package/src/worker/shims/postgres-browser.ts +0 -59
  139. package/src/worker/shims/postgres-socket.test.ts +0 -576
  140. package/src/worker/shims/postgres-socket.ts +0 -310
  141. package/src/worker/shims/postgres.test.ts +0 -364
  142. package/src/worker/shims/postgres.ts +0 -1454
  143. package/src/worker/shims/sqlite-browser.test.ts +0 -233
  144. package/src/worker/shims/sqlite-browser.ts +0 -175
  145. package/src/worker/shims/sqlite.test.ts +0 -786
  146. package/src/worker/shims/sqlite.ts +0 -978
  147. package/src/worker/shims/stream-browser.ts +0 -15
  148. package/src/worker/shims/ws-browser.test.ts +0 -205
  149. package/src/worker/shims/ws-browser.ts +0 -248
  150. package/src/worker/shims/ws.test.ts +0 -288
  151. package/src/worker/shims/ws.ts +0 -467
  152. package/src/worker/shims/zero-process-env.ts +0 -11
  153. package/src/worker/types.ts +0 -75
  154. package/src/worker/worker-integration.test.ts +0 -223
  155. package/src/worker/worker.test.ts +0 -136
  156. package/src/worker/zero-cache-embed-cf.ts +0 -463
  157. package/src/worker/zero-cache-embed.ts +0 -277
package/src/recovery.ts DELETED
@@ -1,155 +0,0 @@
1
- /**
2
- * recovery helpers for zero state corruption and other startup issues.
3
- * centralizes error detection and recovery logic to avoid scattering it throughout the codebase.
4
- */
5
-
6
- import { mkdirSync, rmSync } from 'node:fs'
7
- import { resolve } from 'node:path'
8
-
9
- import {
10
- isChildProcessRunning,
11
- killProcessTree,
12
- waitForChildProcessExit,
13
- } from './child-process.js'
14
- import { log } from './log.js'
15
- import { createPGliteWorker } from './pglite-manager.js'
16
-
17
- import type { PGlite } from '@electric-sql/pglite'
18
- import type { ChildProcess } from 'node:child_process'
19
-
20
- export interface RecoveryContext {
21
- config: { dataDir: string; useWorkerThreads?: boolean }
22
- instances: {
23
- postgres: PGlite
24
- cvr: PGlite
25
- cdb: PGlite
26
- }
27
- zeroCacheProcess: ChildProcess | null
28
- }
29
-
30
- /**
31
- * detect CDC corruption errors from zero-cache output.
32
- * these occur when zero-cache crashes mid-transaction, leaving duplicate
33
- * watermark entries in the changeLog table.
34
- */
35
- export function hasCdcCorruptionSignature(details: string): boolean {
36
- if (!details) return false
37
- // duplicate key in changeLog table (CDC state corruption)
38
- if (details.includes('changeLog_pkey') && details.includes('duplicate key')) {
39
- return true
40
- }
41
- // duplicate key with watermark pattern
42
- if (details.includes('23505') && details.includes('watermark')) {
43
- return true
44
- }
45
- return false
46
- }
47
-
48
- /**
49
- * recover from CDC corruption by resetting CVR/CDB state.
50
- * this is called when zero-cache fails to start due to duplicate changeLog entries.
51
- */
52
- export async function recoverFromCdcCorruption(ctx: RecoveryContext): Promise<void> {
53
- const { config, instances, zeroCacheProcess } = ctx
54
-
55
- log.orez('detected CDC state corruption, auto-recovering...')
56
-
57
- // kill the failed zero-cache process
58
- if (isChildProcessRunning(zeroCacheProcess)) {
59
- if (zeroCacheProcess.pid) killProcessTree(zeroCacheProcess.pid, 'SIGKILL')
60
- else zeroCacheProcess.kill('SIGKILL')
61
- await waitForChildProcessExit(zeroCacheProcess, 1000)
62
- }
63
-
64
- // close and delete CVR/CDB instances
65
- await instances.cvr.close().catch(() => {})
66
- await instances.cdb.close().catch(() => {})
67
-
68
- for (const dir of ['pgdata-cvr', 'pgdata-cdb']) {
69
- try {
70
- rmSync(resolve(config.dataDir, dir), { recursive: true, force: true })
71
- } catch {}
72
- }
73
- log.orez('deleted corrupted CVR/CDB data')
74
-
75
- // delete replica file
76
- const replicaPath = resolve(config.dataDir, 'zero-replica.db')
77
- for (const suffix of ['', '-shm', '-wal', '-wal2']) {
78
- try {
79
- rmSync(replicaPath + suffix, { force: true })
80
- } catch {}
81
- }
82
-
83
- // recreate CVR/CDB instances
84
- if (config.useWorkerThreads) {
85
- const cvrProxy = createPGliteWorker(resolve(config.dataDir, 'pgdata-cvr'), 'cvr')
86
- const cdbProxy = createPGliteWorker(resolve(config.dataDir, 'pgdata-cdb'), 'cdb')
87
- await Promise.all([cvrProxy.waitReady, cdbProxy.waitReady])
88
- instances.cvr = cvrProxy as unknown as PGlite
89
- instances.cdb = cdbProxy as unknown as PGlite
90
- } else {
91
- const { PGlite: PGliteCtor } = await import('@electric-sql/pglite')
92
- mkdirSync(resolve(config.dataDir, 'pgdata-cvr'), { recursive: true })
93
- mkdirSync(resolve(config.dataDir, 'pgdata-cdb'), { recursive: true })
94
- instances.cvr = new PGliteCtor({
95
- dataDir: resolve(config.dataDir, 'pgdata-cvr'),
96
- relaxedDurability: true,
97
- })
98
- instances.cdb = new PGliteCtor({
99
- dataDir: resolve(config.dataDir, 'pgdata-cdb'),
100
- relaxedDurability: true,
101
- })
102
- await instances.cvr.waitReady
103
- await instances.cdb.waitReady
104
- }
105
- log.orez('recreated CVR/CDB instances')
106
-
107
- // clear upstream replication tracking
108
- const db = instances.postgres
109
- await db.exec(`TRUNCATE _orez._zero_changes`).catch(() => {})
110
- await db.exec(`TRUNCATE _orez._zero_replication_slots`).catch(() => {})
111
- await db.exec(`ALTER SEQUENCE _orez._zero_watermark RESTART WITH 1`).catch(() => {})
112
-
113
- // drop stale shard schemas
114
- const shardSchemas = await db.query<{ schemaname: string }>(
115
- `SELECT DISTINCT schemaname FROM pg_tables
116
- WHERE tablename IN ('clients', 'replicas', 'mutations')
117
- AND schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'public', '_orez')
118
- AND schemaname NOT LIKE 'pg_%'`
119
- )
120
- for (const { schemaname } of shardSchemas.rows) {
121
- await db.exec(`DROP SCHEMA IF EXISTS "${schemaname.replace(/"/g, '""')}" CASCADE`)
122
- }
123
-
124
- log.orez('CDC corruption recovery complete')
125
- }
126
-
127
- /**
128
- * proactively clean CDC state on startup to prevent duplicate key errors.
129
- * this handles cases where orez was killed (SIGKILL) mid-transaction,
130
- * leaving stale watermarks in the changeLog table.
131
- *
132
- * in dev mode, it's safe to drop all CDC schemas - zero-cache will recreate them.
133
- */
134
- export async function cleanCdcStateOnStartup(cdb: PGlite): Promise<void> {
135
- try {
136
- // find all CDC schemas (e.g. chat_0/cdc, startchat_0/cdc)
137
- const result = await cdb.query<{ nspname: string }>(
138
- `SELECT nspname FROM pg_namespace WHERE nspname LIKE '%/cdc'`
139
- )
140
-
141
- if (result.rows.length === 0) {
142
- return // no CDC schemas to clean
143
- }
144
-
145
- for (const { nspname } of result.rows) {
146
- const quoted = '"' + nspname.replace(/"/g, '""') + '"'
147
- await cdb.exec(`DROP SCHEMA IF EXISTS ${quoted} CASCADE`)
148
- }
149
-
150
- log.debug.orez(`cleaned ${result.rows.length} CDC schema(s) on startup`)
151
- } catch (err: any) {
152
- // non-fatal - zero-cache might still work
153
- log.debug.orez(`CDC cleanup warning: ${err?.message || err}`)
154
- }
155
- }
@@ -1,357 +0,0 @@
1
- import { PGlite } from '@electric-sql/pglite'
2
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
3
-
4
- import {
5
- installChangeTracking,
6
- installTriggersOnShardTables,
7
- resetShardSchemaCache,
8
- purgeConsumedChanges,
9
- getChangesSince,
10
- getCurrentWatermark,
11
- } from './change-tracker'
12
-
13
- describe('change-tracker', () => {
14
- let db: PGlite
15
-
16
- beforeEach(async () => {
17
- db = new PGlite()
18
- await db.waitReady
19
- await db.exec(`
20
- CREATE TABLE public.items (
21
- id SERIAL PRIMARY KEY,
22
- name TEXT NOT NULL,
23
- value INTEGER
24
- )
25
- `)
26
- await installChangeTracking(db)
27
- })
28
-
29
- afterEach(async () => {
30
- await db.close()
31
- })
32
-
33
- it('captures INSERT', async () => {
34
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('a', 1)`)
35
-
36
- const changes = await getChangesSince(db, 0)
37
- expect(changes).toHaveLength(1)
38
- expect(changes[0].op).toBe('INSERT')
39
- expect(changes[0].table_name).toBe('public.items')
40
- expect(changes[0].row_data).toMatchObject({ name: 'a', value: 1 })
41
- expect(changes[0].old_data).toBeNull()
42
- })
43
-
44
- it('captures UPDATE with old + new data', async () => {
45
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('a', 1)`)
46
- await db.exec(`UPDATE public.items SET value = 99 WHERE name = 'a'`)
47
-
48
- const changes = await getChangesSince(db, 0)
49
- expect(changes).toHaveLength(2)
50
- expect(changes[1].op).toBe('UPDATE')
51
- expect(changes[1].row_data).toMatchObject({ value: 99 })
52
- expect(changes[1].old_data).toMatchObject({ value: 1 })
53
- })
54
-
55
- it('captures DELETE with old data', async () => {
56
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('a', 1)`)
57
- await db.exec(`DELETE FROM public.items WHERE name = 'a'`)
58
-
59
- const changes = await getChangesSince(db, 0)
60
- expect(changes).toHaveLength(2)
61
- expect(changes[1].op).toBe('DELETE')
62
- expect(changes[1].old_data).toMatchObject({ name: 'a', value: 1 })
63
- expect(changes[1].row_data).toBeNull()
64
- })
65
-
66
- it('watermarks increase monotonically', async () => {
67
- for (let i = 0; i < 5; i++) {
68
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('item${i}', ${i})`)
69
- }
70
-
71
- const changes = await getChangesSince(db, 0)
72
- expect(changes).toHaveLength(5)
73
- for (let i = 1; i < changes.length; i++) {
74
- expect(changes[i].watermark).toBeGreaterThan(changes[i - 1].watermark)
75
- }
76
- })
77
-
78
- it('getChangesSince filters by watermark', async () => {
79
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('a', 1)`)
80
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('b', 2)`)
81
-
82
- const all = await getChangesSince(db, 0)
83
- const afterFirst = await getChangesSince(db, all[0].watermark)
84
-
85
- expect(afterFirst).toHaveLength(1)
86
- expect(afterFirst[0].row_data).toMatchObject({ name: 'b' })
87
- })
88
-
89
- it('respects limit', async () => {
90
- for (let i = 0; i < 10; i++) {
91
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('x', ${i})`)
92
- }
93
-
94
- const limited = await getChangesSince(db, 0, 3)
95
- expect(limited).toHaveLength(3)
96
- })
97
-
98
- it('getCurrentWatermark returns 0 before any inserts', async () => {
99
- const wm = await getCurrentWatermark(db)
100
- expect(wm).toBe(0)
101
- })
102
-
103
- it('getCurrentWatermark advances', async () => {
104
- // first insert consumes the initial sequence value
105
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('x', 1)`)
106
- const before = await getCurrentWatermark(db)
107
- expect(before).toBeGreaterThan(0)
108
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('y', 2)`)
109
- const after = await getCurrentWatermark(db)
110
- expect(after).toBeGreaterThan(before)
111
- })
112
-
113
- it('tracks multiple tables', async () => {
114
- await db.exec(`CREATE TABLE public.other (id SERIAL PRIMARY KEY, label TEXT)`)
115
- await installChangeTracking(db) // reinstall picks up new table
116
-
117
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('a', 1)`)
118
- await db.exec(`INSERT INTO public.other (label) VALUES ('b')`)
119
-
120
- const changes = await getChangesSince(db, 0)
121
- const tables = new Set(changes.map((c) => c.table_name))
122
- expect(tables).toContain('public.items')
123
- expect(tables).toContain('public.other')
124
- })
125
-
126
- it('handles rapid inserts (50 rows)', async () => {
127
- for (let i = 0; i < 50; i++) {
128
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('r${i}', ${i})`)
129
- }
130
-
131
- const changes = await getChangesSince(db, 0)
132
- expect(changes).toHaveLength(50)
133
-
134
- for (let i = 1; i < changes.length; i++) {
135
- expect(changes[i].watermark).toBeGreaterThan(changes[i - 1].watermark)
136
- }
137
- })
138
-
139
- it('does not track internal _zero_ tables', async () => {
140
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('x', 1)`)
141
-
142
- const changes = await getChangesSince(db, 0)
143
- // only the items insert, not the _zero_changes insert that the trigger itself caused
144
- const internalChanges = changes.filter((c) => c.table_name.startsWith('_zero_'))
145
- expect(internalChanges).toHaveLength(0)
146
- })
147
-
148
- it('respects empty configured publication (tracks no public tables)', async () => {
149
- const prev = process.env.ZERO_APP_PUBLICATIONS
150
- process.env.ZERO_APP_PUBLICATIONS = 'zero_scope'
151
- try {
152
- await db.exec(`CREATE PUBLICATION "zero_scope"`)
153
- await installChangeTracking(db) // reinstall picks up publication scope
154
- await db.exec(`TRUNCATE _orez._zero_changes`)
155
-
156
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('x', 1)`)
157
- const changes = await getChangesSince(db, 0)
158
- expect(changes).toHaveLength(0)
159
- } finally {
160
- if (prev === undefined) delete process.env.ZERO_APP_PUBLICATIONS
161
- else process.env.ZERO_APP_PUBLICATIONS = prev
162
- }
163
- })
164
-
165
- it('handles NULL column values', async () => {
166
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('nulltest', NULL)`)
167
-
168
- const changes = await getChangesSince(db, 0)
169
- expect(changes[0].row_data).toMatchObject({ name: 'nulltest', value: null })
170
- })
171
-
172
- it('handles multi-row update', async () => {
173
- await db.exec(
174
- `INSERT INTO public.items (name, value) VALUES ('a', 1), ('b', 2), ('c', 3)`
175
- )
176
- await db.exec(`UPDATE public.items SET value = value * 10`)
177
-
178
- const changes = await getChangesSince(db, 0)
179
- const updates = changes.filter((c) => c.op === 'UPDATE')
180
- expect(updates).toHaveLength(3)
181
- })
182
-
183
- it('preserves change ordering across mixed operations', async () => {
184
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('a', 1)`)
185
- await db.exec(`UPDATE public.items SET value = 2 WHERE name = 'a'`)
186
- await db.exec(`INSERT INTO public.items (name, value) VALUES ('b', 3)`)
187
- await db.exec(`DELETE FROM public.items WHERE name = 'a'`)
188
-
189
- const changes = await getChangesSince(db, 0)
190
- const ops = changes.map((c) => c.op)
191
- expect(ops).toEqual(['INSERT', 'UPDATE', 'INSERT', 'DELETE'])
192
- })
193
-
194
- it('tracks tables with special characters in names', async () => {
195
- await db.exec(`CREATE TABLE public."my""table" (id SERIAL PRIMARY KEY, val TEXT)`)
196
- await installChangeTracking(db)
197
-
198
- await db.exec(`INSERT INTO public."my""table" (val) VALUES ('works')`)
199
-
200
- const changes = await getChangesSince(db, 0)
201
- const special = changes.filter((c) => c.table_name === 'public.my"table')
202
- expect(special).toHaveLength(1)
203
- expect(special[0].op).toBe('INSERT')
204
- expect(special[0].row_data).toMatchObject({ val: 'works' })
205
- })
206
- })
207
-
208
- describe('shard table tracking', () => {
209
- let db: PGlite
210
-
211
- beforeEach(async () => {
212
- resetShardSchemaCache()
213
- db = new PGlite()
214
- await db.waitReady
215
- await installChangeTracking(db)
216
- })
217
-
218
- afterEach(async () => {
219
- await db.close()
220
- })
221
-
222
- it('only tracks mutation-confirmation tables in shard schemas', async () => {
223
- // zero-cache creates shard schemas like chat_0 with clients, replicas, mutations.
224
- // clients advance lmid and mutations carry server results; replicas stays internal.
225
- // with "Unknown table chat_0.replicas" because they aren't in zero's schema.
226
- await db.exec(`
227
- CREATE SCHEMA chat_0;
228
- CREATE TABLE chat_0.clients (
229
- "clientGroupID" TEXT NOT NULL,
230
- "clientID" TEXT NOT NULL,
231
- "lastMutationID" BIGINT,
232
- "userID" TEXT,
233
- PRIMARY KEY ("clientGroupID", "clientID")
234
- );
235
- CREATE TABLE chat_0.replicas (
236
- id TEXT PRIMARY KEY,
237
- version TEXT,
238
- cookie TEXT
239
- );
240
- CREATE TABLE chat_0.mutations (
241
- id TEXT PRIMARY KEY,
242
- "clientID" TEXT,
243
- name TEXT,
244
- args JSONB
245
- );
246
- `)
247
-
248
- await installTriggersOnShardTables(db)
249
-
250
- // insert into all three tables
251
- await db.exec(
252
- `INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 1)`
253
- )
254
- await db.exec(`INSERT INTO chat_0.replicas (id, version) VALUES ('r1', 'v1')`)
255
- await db.exec(
256
- `INSERT INTO chat_0.mutations (id, "clientID", name) VALUES ('m1', 'c1', 'sendMessage')`
257
- )
258
-
259
- const changes = await getChangesSince(db, 0)
260
- const tables = changes.map((c) => c.table_name)
261
-
262
- // only mutation-confirmation tables should be tracked
263
- expect(tables).toContain('chat_0.clients')
264
- expect(tables).toContain('chat_0.mutations')
265
- expect(tables).not.toContain('chat_0.replicas')
266
- })
267
-
268
- it('purges consumed changes to prevent OOM', async () => {
269
- // _zero_changes accumulates forever in 0.0.37. with wasm pglite,
270
- // this eventually causes OOM. we need a purge mechanism.
271
- await db.exec(`
272
- CREATE TABLE public.items (id SERIAL PRIMARY KEY, val TEXT)
273
- `)
274
- await installChangeTracking(db)
275
-
276
- // insert some data
277
- for (let i = 0; i < 10; i++) {
278
- await db.exec(`INSERT INTO public.items (val) VALUES ('item${i}')`)
279
- }
280
-
281
- const changes = await getChangesSince(db, 0)
282
- expect(changes).toHaveLength(10)
283
- const lastWatermark = changes[changes.length - 1].watermark
284
-
285
- // purge consumed changes up to the watermark we've processed
286
- await purgeConsumedChanges(db, lastWatermark)
287
-
288
- // after purge, no changes before that watermark should remain
289
- const remaining = await getChangesSince(db, 0)
290
- expect(remaining).toHaveLength(0)
291
- })
292
-
293
- it('tracks tables created after initial installChangeTracking', async () => {
294
- // simulate zero-cache creating shard schema AFTER replication starts.
295
- // in production, zero-cache creates chat_0 schema + clients table
296
- // after the replication connection is already established.
297
- // the change tracker must pick up these new tables.
298
- await db.exec(`
299
- CREATE SCHEMA chat_0;
300
- CREATE TABLE chat_0.clients (
301
- "clientGroupID" TEXT NOT NULL,
302
- "clientID" TEXT NOT NULL,
303
- "lastMutationID" BIGINT,
304
- PRIMARY KEY ("clientGroupID", "clientID")
305
- );
306
- `)
307
-
308
- // re-running installTriggersOnShardTables should pick up new tables
309
- await installTriggersOnShardTables(db)
310
-
311
- await db.exec(
312
- `INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 1)`
313
- )
314
-
315
- const changes = await getChangesSince(db, 0)
316
- expect(changes).toHaveLength(1)
317
- expect(changes[0].table_name).toBe('chat_0.clients')
318
- })
319
-
320
- it('tracks shard tables created after the schema was scanned', async () => {
321
- await db.exec(`CREATE SCHEMA chat_0`)
322
-
323
- // first scan sees the schema but no internal tables yet.
324
- await installTriggersOnShardTables(db)
325
-
326
- await db.exec(`
327
- CREATE TABLE chat_0.clients (
328
- "clientGroupID" TEXT NOT NULL,
329
- "clientID" TEXT NOT NULL,
330
- "lastMutationID" BIGINT,
331
- PRIMARY KEY ("clientGroupID", "clientID")
332
- );
333
- CREATE TABLE chat_0.mutations (
334
- "clientGroupID" TEXT NOT NULL,
335
- "clientID" TEXT NOT NULL,
336
- "mutationID" BIGINT NOT NULL,
337
- result JSONB,
338
- PRIMARY KEY ("clientGroupID", "clientID", "mutationID")
339
- );
340
- `)
341
-
342
- await installTriggersOnShardTables(db)
343
-
344
- await db.exec(
345
- `INSERT INTO chat_0.clients ("clientGroupID", "clientID", "lastMutationID") VALUES ('cg1', 'c1', 1)`
346
- )
347
- await db.exec(
348
- `INSERT INTO chat_0.mutations ("clientGroupID", "clientID", "mutationID", result) VALUES ('cg1', 'c1', 1, '{}')`
349
- )
350
-
351
- const changes = await getChangesSince(db, 0)
352
- expect(changes.map((change) => change.table_name).sort()).toEqual([
353
- 'chat_0.clients',
354
- 'chat_0.mutations',
355
- ])
356
- })
357
- })