orez 0.2.27 → 0.2.29

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 (150) hide show
  1. package/package.json +3 -4
  2. package/src/admin/admin-data.test.ts +0 -348
  3. package/src/admin/http-proxy.ts +0 -252
  4. package/src/admin/log-store.ts +0 -192
  5. package/src/admin/server.ts +0 -471
  6. package/src/admin/ui.ts +0 -1322
  7. package/src/bench/proxy-throughput.bench.ts +0 -343
  8. package/src/bench/serial-mutations.bench.ts +0 -270
  9. package/src/browser.ts +0 -203
  10. package/src/cf-do/.wrangler/cache/cf.json +0 -1
  11. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  12. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  13. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  14. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
  15. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  16. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  17. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  18. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
  19. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
  20. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
  21. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
  22. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
  23. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
  24. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
  25. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
  26. package/src/cf-do/ARCHITECTURE.md +0 -93
  27. package/src/cf-do/CHAT_E2E.md +0 -213
  28. package/src/cf-do/watermark.test.ts +0 -103
  29. package/src/cf-do/watermark.ts +0 -118
  30. package/src/cf-do/worker.ts +0 -1041
  31. package/src/cf-do/wrangler.toml +0 -11
  32. package/src/cf-pglite/README.md +0 -19
  33. package/src/change-tracking.ts +0 -25
  34. package/src/child-process.test.ts +0 -147
  35. package/src/child-process.ts +0 -90
  36. package/src/cli-entry.ts +0 -72
  37. package/src/cli.test.ts +0 -40
  38. package/src/cli.ts +0 -1214
  39. package/src/config.ts +0 -150
  40. package/src/do-sql-tracking.test.ts +0 -19
  41. package/src/do-sql-tracking.ts +0 -19
  42. package/src/index.ts +0 -1215
  43. package/src/integration/integration.test.ts +0 -517
  44. package/src/integration/native-binary.guard.test.ts +0 -13
  45. package/src/integration/native-startup.test.ts +0 -44
  46. package/src/integration/replication-latency.test.ts +0 -428
  47. package/src/integration/restore-live-stress.test.ts +0 -433
  48. package/src/integration/restore-reset.test.ts +0 -400
  49. package/src/integration/restore.test.ts +0 -274
  50. package/src/integration/test-permissions.ts +0 -147
  51. package/src/load-config.ts +0 -46
  52. package/src/log.ts +0 -96
  53. package/src/mutex.ts +0 -47
  54. package/src/pg-proxy-browser.singledb.test.ts +0 -233
  55. package/src/pg-proxy-browser.ts +0 -2022
  56. package/src/pg-proxy-do-backend.test.ts +0 -3890
  57. package/src/pg-proxy-do-backend.ts +0 -7191
  58. package/src/pg-proxy.ts +0 -1087
  59. package/src/pg-sqlite-compiler/README.md +0 -53
  60. package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
  61. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
  62. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
  63. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
  64. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
  65. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
  66. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
  67. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
  68. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
  69. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
  70. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
  71. package/src/pg-sqlite-compiler/index.ts +0 -73
  72. package/src/pg-sqlite-compiler/integration.test.ts +0 -136
  73. package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
  74. package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
  75. package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
  76. package/src/pg-sqlite-compiler/passes/index.ts +0 -49
  77. package/src/pg-sqlite-compiler/passes/types.ts +0 -156
  78. package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
  79. package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
  80. package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
  81. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
  82. package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
  83. package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
  84. package/src/pg-sqlite-compiler/types.ts +0 -63
  85. package/src/pglite-ipc.test.ts +0 -116
  86. package/src/pglite-ipc.ts +0 -266
  87. package/src/pglite-manager.ts +0 -557
  88. package/src/pglite-web-proxy.test.ts +0 -57
  89. package/src/pglite-web-proxy.ts +0 -221
  90. package/src/pglite-web-worker.ts +0 -152
  91. package/src/pglite-worker-thread.ts +0 -253
  92. package/src/port.ts +0 -25
  93. package/src/process-title.ts +0 -9
  94. package/src/recovery.ts +0 -155
  95. package/src/replication/change-tracker.test.ts +0 -357
  96. package/src/replication/change-tracker.ts +0 -279
  97. package/src/replication/handler.test.ts +0 -511
  98. package/src/replication/handler.ts +0 -1190
  99. package/src/replication/pgoutput-encoder.test.ts +0 -697
  100. package/src/replication/pgoutput-encoder.ts +0 -373
  101. package/src/replication/tcp-replication.test.ts +0 -876
  102. package/src/replication/zero-compat.test.ts +0 -1150
  103. package/src/restore-stress.test.ts +0 -188
  104. package/src/s3-local.ts +0 -203
  105. package/src/shim/hooks.mjs +0 -120
  106. package/src/shim/register.mjs +0 -4
  107. package/src/sqlite-mode/apply-mode.ts +0 -224
  108. package/src/sqlite-mode/index.ts +0 -15
  109. package/src/sqlite-mode/native-binary.ts +0 -89
  110. package/src/sqlite-mode/package-resolve.ts +0 -17
  111. package/src/sqlite-mode/resolve-mode.ts +0 -80
  112. package/src/sqlite-mode/shim-template.ts +0 -159
  113. package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
  114. package/src/sqlite-mode/types.ts +0 -30
  115. package/src/vite-plugin.ts +0 -67
  116. package/src/wasm-sqlite.test.ts +0 -537
  117. package/src/worker/browser-admin.ts +0 -52
  118. package/src/worker/browser-build-config.test.ts +0 -71
  119. package/src/worker/browser-build-config.ts +0 -109
  120. package/src/worker/browser-embed-admin.test.ts +0 -75
  121. package/src/worker/browser-embed.ts +0 -345
  122. package/src/worker/cf-patches.ts +0 -384
  123. package/src/worker/embed-integration.test.ts +0 -321
  124. package/src/worker/index.ts +0 -138
  125. package/src/worker/shims/fastify.test.ts +0 -255
  126. package/src/worker/shims/fastify.ts +0 -306
  127. package/src/worker/shims/http-service.test.ts +0 -355
  128. package/src/worker/shims/http-service.ts +0 -293
  129. package/src/worker/shims/node-stub.ts +0 -290
  130. package/src/worker/shims/oxfmt.ts +0 -3
  131. package/src/worker/shims/postgres-browser.ts +0 -59
  132. package/src/worker/shims/postgres-socket.test.ts +0 -576
  133. package/src/worker/shims/postgres-socket.ts +0 -310
  134. package/src/worker/shims/postgres.test.ts +0 -364
  135. package/src/worker/shims/postgres.ts +0 -1454
  136. package/src/worker/shims/sqlite-browser.test.ts +0 -233
  137. package/src/worker/shims/sqlite-browser.ts +0 -175
  138. package/src/worker/shims/sqlite.test.ts +0 -786
  139. package/src/worker/shims/sqlite.ts +0 -978
  140. package/src/worker/shims/stream-browser.ts +0 -15
  141. package/src/worker/shims/ws-browser.test.ts +0 -205
  142. package/src/worker/shims/ws-browser.ts +0 -248
  143. package/src/worker/shims/ws.test.ts +0 -288
  144. package/src/worker/shims/ws.ts +0 -467
  145. package/src/worker/shims/zero-process-env.ts +0 -11
  146. package/src/worker/types.ts +0 -75
  147. package/src/worker/worker-integration.test.ts +0 -223
  148. package/src/worker/worker.test.ts +0 -136
  149. package/src/worker/zero-cache-embed-cf.ts +0 -463
  150. 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
- })