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.
- package/dist/cf-do/worker.d.ts +3 -0
- package/dist/cf-do/worker.d.ts.map +1 -1
- package/dist/cf-do/worker.js +37 -15
- package/dist/cf-do/worker.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/admin/admin-data.test.ts +0 -348
- package/src/admin/http-proxy.ts +0 -252
- package/src/admin/log-store.ts +0 -192
- package/src/admin/server.ts +0 -471
- package/src/admin/ui.ts +0 -1322
- package/src/bench/proxy-throughput.bench.ts +0 -343
- package/src/bench/serial-mutations.bench.ts +0 -270
- package/src/browser.ts +0 -203
- package/src/cf-do/.wrangler/cache/cf.json +0 -1
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
- package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
- package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
- package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
- package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
- package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
- package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
- package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
- package/src/cf-do/ARCHITECTURE.md +0 -93
- package/src/cf-do/CHAT_E2E.md +0 -213
- package/src/cf-do/watermark.test.ts +0 -103
- package/src/cf-do/watermark.ts +0 -118
- package/src/cf-do/worker.ts +0 -1041
- package/src/cf-do/wrangler.toml +0 -11
- package/src/cf-pglite/README.md +0 -19
- package/src/change-tracking.ts +0 -25
- package/src/child-process.test.ts +0 -147
- package/src/child-process.ts +0 -90
- package/src/cli-entry.ts +0 -72
- package/src/cli.test.ts +0 -40
- package/src/cli.ts +0 -1214
- package/src/config.ts +0 -150
- package/src/do-sql-tracking.test.ts +0 -19
- package/src/do-sql-tracking.ts +0 -19
- package/src/index.ts +0 -1215
- package/src/integration/integration.test.ts +0 -517
- package/src/integration/native-binary.guard.test.ts +0 -13
- package/src/integration/native-startup.test.ts +0 -44
- package/src/integration/replication-latency.test.ts +0 -428
- package/src/integration/restore-live-stress.test.ts +0 -433
- package/src/integration/restore-reset.test.ts +0 -400
- package/src/integration/restore.test.ts +0 -274
- package/src/integration/test-permissions.ts +0 -147
- package/src/load-config.ts +0 -46
- package/src/log.ts +0 -96
- package/src/mutex.ts +0 -47
- package/src/pg-proxy-browser.singledb.test.ts +0 -233
- package/src/pg-proxy-browser.ts +0 -2022
- package/src/pg-proxy-do-backend.test.ts +0 -3890
- package/src/pg-proxy-do-backend.ts +0 -7191
- package/src/pg-proxy.ts +0 -1087
- package/src/pg-sqlite-compiler/README.md +0 -53
- package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
- package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
- package/src/pg-sqlite-compiler/index.ts +0 -73
- package/src/pg-sqlite-compiler/integration.test.ts +0 -136
- package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
- package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
- package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
- package/src/pg-sqlite-compiler/passes/index.ts +0 -49
- package/src/pg-sqlite-compiler/passes/types.ts +0 -156
- package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
- package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
- package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
- package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
- package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
- package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
- package/src/pg-sqlite-compiler/types.ts +0 -63
- package/src/pglite-ipc.test.ts +0 -116
- package/src/pglite-ipc.ts +0 -266
- package/src/pglite-manager.ts +0 -557
- package/src/pglite-web-proxy.test.ts +0 -57
- package/src/pglite-web-proxy.ts +0 -221
- package/src/pglite-web-worker.ts +0 -152
- package/src/pglite-worker-thread.ts +0 -253
- package/src/port.ts +0 -25
- package/src/process-title.ts +0 -9
- package/src/recovery.ts +0 -155
- package/src/replication/change-tracker.test.ts +0 -357
- package/src/replication/change-tracker.ts +0 -279
- package/src/replication/handler.test.ts +0 -511
- package/src/replication/handler.ts +0 -1190
- package/src/replication/pgoutput-encoder.test.ts +0 -697
- package/src/replication/pgoutput-encoder.ts +0 -373
- package/src/replication/tcp-replication.test.ts +0 -876
- package/src/replication/zero-compat.test.ts +0 -1150
- package/src/restore-stress.test.ts +0 -188
- package/src/s3-local.ts +0 -203
- package/src/shim/hooks.mjs +0 -120
- package/src/shim/register.mjs +0 -4
- package/src/sqlite-mode/apply-mode.ts +0 -224
- package/src/sqlite-mode/index.ts +0 -15
- package/src/sqlite-mode/native-binary.ts +0 -89
- package/src/sqlite-mode/package-resolve.ts +0 -17
- package/src/sqlite-mode/resolve-mode.ts +0 -80
- package/src/sqlite-mode/shim-template.ts +0 -159
- package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
- package/src/sqlite-mode/types.ts +0 -30
- package/src/vite-plugin.ts +0 -67
- package/src/wasm-sqlite.test.ts +0 -537
- package/src/worker/browser-admin.ts +0 -52
- package/src/worker/browser-build-config.test.ts +0 -71
- package/src/worker/browser-build-config.ts +0 -109
- package/src/worker/browser-embed-admin.test.ts +0 -75
- package/src/worker/browser-embed.ts +0 -345
- package/src/worker/cf-patches.ts +0 -384
- package/src/worker/embed-integration.test.ts +0 -321
- package/src/worker/index.ts +0 -138
- package/src/worker/shims/fastify.test.ts +0 -255
- package/src/worker/shims/fastify.ts +0 -306
- package/src/worker/shims/http-service.test.ts +0 -355
- package/src/worker/shims/http-service.ts +0 -293
- package/src/worker/shims/node-stub.ts +0 -290
- package/src/worker/shims/oxfmt.ts +0 -3
- package/src/worker/shims/postgres-browser.ts +0 -59
- package/src/worker/shims/postgres-socket.test.ts +0 -576
- package/src/worker/shims/postgres-socket.ts +0 -310
- package/src/worker/shims/postgres.test.ts +0 -364
- package/src/worker/shims/postgres.ts +0 -1454
- package/src/worker/shims/sqlite-browser.test.ts +0 -233
- package/src/worker/shims/sqlite-browser.ts +0 -175
- package/src/worker/shims/sqlite.test.ts +0 -786
- package/src/worker/shims/sqlite.ts +0 -978
- package/src/worker/shims/stream-browser.ts +0 -15
- package/src/worker/shims/ws-browser.test.ts +0 -205
- package/src/worker/shims/ws-browser.ts +0 -248
- package/src/worker/shims/ws.test.ts +0 -288
- package/src/worker/shims/ws.ts +0 -467
- package/src/worker/shims/zero-process-env.ts +0 -11
- package/src/worker/types.ts +0 -75
- package/src/worker/worker-integration.test.ts +0 -223
- package/src/worker/worker.test.ts +0 -136
- package/src/worker/zero-cache-embed-cf.ts +0 -463
- 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
|
-
})
|