orez 0.0.37 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/replication/change-tracker.d.ts +5 -0
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +70 -0
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +3 -1
- package/dist/replication/handler.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +3 -1
- package/src/config.ts +2 -0
- package/src/index.ts +8 -1
- package/src/integration/integration.test.ts +133 -264
- package/src/integration/restore.test.ts +274 -0
- package/src/replication/change-tracker.ts +81 -0
- package/src/replication/handler.ts +4 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* integration test for pg_restore through a running orez instance.
|
|
3
|
+
*
|
|
4
|
+
* generates a pg_dump-style SQL file, starts fresh orez, restores via wire
|
|
5
|
+
* protocol, then verifies data via wire queries + zero-cache websocket sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync, unlinkSync, rmSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { loadModule } from 'pgsql-parser'
|
|
13
|
+
import postgres from 'postgres'
|
|
14
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
15
|
+
|
|
16
|
+
import { execDumpFile } from '../cli.js'
|
|
17
|
+
import { startZeroLite } from '../index.js'
|
|
18
|
+
|
|
19
|
+
import type { PGlite } from '@electric-sql/pglite'
|
|
20
|
+
|
|
21
|
+
// generate a pg_dump-style SQL file with our test schema + data
|
|
22
|
+
function generateDump(): string {
|
|
23
|
+
const lines: string[] = []
|
|
24
|
+
|
|
25
|
+
// preamble (mimics pg_dump)
|
|
26
|
+
lines.push('SET statement_timeout = 0;')
|
|
27
|
+
lines.push("SET client_encoding = 'UTF8';")
|
|
28
|
+
lines.push('SET standard_conforming_strings = on;')
|
|
29
|
+
lines.push('')
|
|
30
|
+
|
|
31
|
+
// tables
|
|
32
|
+
lines.push(`CREATE TABLE items (
|
|
33
|
+
id integer NOT NULL,
|
|
34
|
+
name text NOT NULL,
|
|
35
|
+
data text,
|
|
36
|
+
score integer DEFAULT 0
|
|
37
|
+
);`)
|
|
38
|
+
lines.push('')
|
|
39
|
+
lines.push(
|
|
40
|
+
`CREATE SEQUENCE items_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;`
|
|
41
|
+
)
|
|
42
|
+
lines.push(`ALTER SEQUENCE items_id_seq OWNED BY items.id;`)
|
|
43
|
+
lines.push(
|
|
44
|
+
`ALTER TABLE ONLY items ALTER COLUMN id SET DEFAULT nextval('items_id_seq'::regclass);`
|
|
45
|
+
)
|
|
46
|
+
lines.push('')
|
|
47
|
+
|
|
48
|
+
lines.push(`CREATE TABLE tags (
|
|
49
|
+
id integer NOT NULL,
|
|
50
|
+
item_id integer,
|
|
51
|
+
label text NOT NULL
|
|
52
|
+
);`)
|
|
53
|
+
lines.push('')
|
|
54
|
+
lines.push(
|
|
55
|
+
`CREATE SEQUENCE tags_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;`
|
|
56
|
+
)
|
|
57
|
+
lines.push(`ALTER SEQUENCE tags_id_seq OWNED BY tags.id;`)
|
|
58
|
+
lines.push(
|
|
59
|
+
`ALTER TABLE ONLY tags ALTER COLUMN id SET DEFAULT nextval('tags_id_seq'::regclass);`
|
|
60
|
+
)
|
|
61
|
+
lines.push('')
|
|
62
|
+
|
|
63
|
+
// view + function
|
|
64
|
+
lines.push(`CREATE VIEW item_summary AS
|
|
65
|
+
SELECT i.id, i.name, count(t.id) AS tag_count
|
|
66
|
+
FROM items i LEFT JOIN tags t ON t.item_id = i.id
|
|
67
|
+
GROUP BY i.id, i.name;`)
|
|
68
|
+
lines.push('')
|
|
69
|
+
lines.push(
|
|
70
|
+
`CREATE FUNCTION item_count() RETURNS integer LANGUAGE sql AS $$SELECT count(*)::integer FROM items$$;`
|
|
71
|
+
)
|
|
72
|
+
lines.push('')
|
|
73
|
+
|
|
74
|
+
// COPY items data (200 rows)
|
|
75
|
+
lines.push('COPY items (id, name, data, score) FROM stdin;')
|
|
76
|
+
for (let i = 0; i < 200; i++) {
|
|
77
|
+
const id = i + 1
|
|
78
|
+
const name = i % 7 === 0 ? `O'Brien's item #${i}` : `item-${i}`
|
|
79
|
+
const data = i % 11 === 0 ? '\\N' : `data-${'x'.repeat(100)}-${i}`
|
|
80
|
+
const score = i * 10
|
|
81
|
+
// COPY text format: tab-separated, \N for NULL, backslash escapes
|
|
82
|
+
lines.push(`${id}\t${escapeCopy(name)}\t${data}\t${score}`)
|
|
83
|
+
}
|
|
84
|
+
lines.push('\\.')
|
|
85
|
+
lines.push('')
|
|
86
|
+
|
|
87
|
+
// COPY tags data (50 rows)
|
|
88
|
+
lines.push('COPY tags (id, item_id, label) FROM stdin;')
|
|
89
|
+
for (let i = 0; i < 50; i++) {
|
|
90
|
+
lines.push(`${i + 1}\t${(i % 200) + 1}\ttag-${i}`)
|
|
91
|
+
}
|
|
92
|
+
lines.push('\\.')
|
|
93
|
+
lines.push('')
|
|
94
|
+
|
|
95
|
+
// constraints (pg_dump adds these after data)
|
|
96
|
+
lines.push('ALTER TABLE ONLY items ADD CONSTRAINT items_pkey PRIMARY KEY (id);')
|
|
97
|
+
lines.push('ALTER TABLE ONLY tags ADD CONSTRAINT tags_pkey PRIMARY KEY (id);')
|
|
98
|
+
lines.push(
|
|
99
|
+
'ALTER TABLE ONLY tags ADD CONSTRAINT tags_item_id_fkey FOREIGN KEY (item_id) REFERENCES items(id);'
|
|
100
|
+
)
|
|
101
|
+
lines.push('')
|
|
102
|
+
|
|
103
|
+
// sequence values
|
|
104
|
+
lines.push(`SELECT pg_catalog.setval('items_id_seq', 200, true);`)
|
|
105
|
+
lines.push(`SELECT pg_catalog.setval('tags_id_seq', 50, true);`)
|
|
106
|
+
lines.push('')
|
|
107
|
+
|
|
108
|
+
return lines.join('\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// escape a value for COPY text format
|
|
112
|
+
function escapeCopy(val: string): string {
|
|
113
|
+
return val.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('restore integration', { timeout: 120_000 }, () => {
|
|
117
|
+
let db: PGlite
|
|
118
|
+
let pgPort: number
|
|
119
|
+
let zeroPort: number
|
|
120
|
+
let shutdown: () => Promise<void>
|
|
121
|
+
let dataDir: string
|
|
122
|
+
let dumpFile: string
|
|
123
|
+
|
|
124
|
+
beforeAll(async () => {
|
|
125
|
+
await loadModule()
|
|
126
|
+
|
|
127
|
+
// write dump file
|
|
128
|
+
dumpFile = join(tmpdir(), `orez-restore-test-${Date.now()}.sql`)
|
|
129
|
+
writeFileSync(dumpFile, generateDump())
|
|
130
|
+
dataDir = `.orez-restore-test-${Date.now()}`
|
|
131
|
+
|
|
132
|
+
// start orez without zero-cache (restore doesn't need sync)
|
|
133
|
+
const fresh = await startZeroLite({
|
|
134
|
+
pgPort: 25000 + Math.floor(Math.random() * 1000),
|
|
135
|
+
zeroPort: 26000 + Math.floor(Math.random() * 1000),
|
|
136
|
+
dataDir,
|
|
137
|
+
logLevel: 'warn',
|
|
138
|
+
skipZeroCache: true,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
db = fresh.db
|
|
142
|
+
pgPort = fresh.pgPort
|
|
143
|
+
zeroPort = fresh.zeroPort
|
|
144
|
+
shutdown = fresh.stop
|
|
145
|
+
|
|
146
|
+
// restore via wire protocol
|
|
147
|
+
const sql = postgres({
|
|
148
|
+
host: '127.0.0.1',
|
|
149
|
+
port: pgPort,
|
|
150
|
+
user: 'user',
|
|
151
|
+
password: 'password',
|
|
152
|
+
database: 'postgres',
|
|
153
|
+
max: 1,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const wireDb = { exec: (query: string) => sql.unsafe(query) as Promise<unknown> }
|
|
157
|
+
const result = await execDumpFile(wireDb, dumpFile)
|
|
158
|
+
console.log(
|
|
159
|
+
`[restore-test] restored: ${result.executed} executed, ${result.skipped} skipped`
|
|
160
|
+
)
|
|
161
|
+
await sql.end()
|
|
162
|
+
}, 60_000)
|
|
163
|
+
|
|
164
|
+
afterAll(async () => {
|
|
165
|
+
if (shutdown) await shutdown()
|
|
166
|
+
if (dataDir) {
|
|
167
|
+
try {
|
|
168
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
if (dumpFile) {
|
|
172
|
+
try {
|
|
173
|
+
unlinkSync(dumpFile)
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('tables exist and row counts match', async () => {
|
|
179
|
+
const sql = wireClient()
|
|
180
|
+
try {
|
|
181
|
+
const items = await sql`SELECT count(*) as cnt FROM items`
|
|
182
|
+
expect(Number(items[0].cnt)).toBe(200)
|
|
183
|
+
|
|
184
|
+
const tags = await sql`SELECT count(*) as cnt FROM tags`
|
|
185
|
+
expect(Number(tags[0].cnt)).toBe(50)
|
|
186
|
+
} finally {
|
|
187
|
+
await sql.end()
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('data integrity preserved (quotes, nulls, large values)', async () => {
|
|
192
|
+
const sql = wireClient()
|
|
193
|
+
try {
|
|
194
|
+
const quoted =
|
|
195
|
+
await sql`SELECT name FROM items WHERE name LIKE ${"O'Brien%"} LIMIT 1`
|
|
196
|
+
expect(quoted[0].name).toContain("O'Brien")
|
|
197
|
+
|
|
198
|
+
const nulls = await sql`SELECT count(*) as cnt FROM items WHERE data IS NULL`
|
|
199
|
+
expect(Number(nulls[0].cnt)).toBeGreaterThan(0)
|
|
200
|
+
|
|
201
|
+
const scores = await sql`SELECT min(score) as lo, max(score) as hi FROM items`
|
|
202
|
+
expect(Number(scores[0].lo)).toBe(0)
|
|
203
|
+
expect(Number(scores[0].hi)).toBe(1990)
|
|
204
|
+
} finally {
|
|
205
|
+
await sql.end()
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('views work after restore', async () => {
|
|
210
|
+
const sql = wireClient()
|
|
211
|
+
try {
|
|
212
|
+
const summary = await sql`SELECT * FROM item_summary ORDER BY id LIMIT 3`
|
|
213
|
+
expect(summary.length).toBe(3)
|
|
214
|
+
expect(summary[0]).toHaveProperty('name')
|
|
215
|
+
expect(summary[0]).toHaveProperty('tag_count')
|
|
216
|
+
} finally {
|
|
217
|
+
await sql.end()
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('functions work after restore', async () => {
|
|
222
|
+
const sql = wireClient()
|
|
223
|
+
try {
|
|
224
|
+
const result = await sql`SELECT item_count() as cnt`
|
|
225
|
+
expect(Number(result[0].cnt)).toBe(200)
|
|
226
|
+
} finally {
|
|
227
|
+
await sql.end()
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('foreign keys intact', async () => {
|
|
232
|
+
const sql = wireClient()
|
|
233
|
+
try {
|
|
234
|
+
const joined =
|
|
235
|
+
await sql`SELECT t.label, i.name FROM tags t JOIN items i ON i.id = t.item_id LIMIT 1`
|
|
236
|
+
expect(joined.length).toBe(1)
|
|
237
|
+
|
|
238
|
+
// FK enforced — inserting with nonexistent item_id should fail
|
|
239
|
+
try {
|
|
240
|
+
await sql`INSERT INTO tags (item_id, label) VALUES (99999, 'bad')`
|
|
241
|
+
expect.unreachable('should have thrown FK violation')
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
expect(err.message).toContain('foreign key')
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
await sql.end()
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('new inserts via wire protocol work after restore', async () => {
|
|
251
|
+
const sql = wireClient()
|
|
252
|
+
try {
|
|
253
|
+
await sql`INSERT INTO items (name, score) VALUES ('post-restore', 9999)`
|
|
254
|
+
const result = await sql`SELECT * FROM items WHERE name = 'post-restore'`
|
|
255
|
+
expect(result.length).toBe(1)
|
|
256
|
+
expect(Number(result[0].score)).toBe(9999)
|
|
257
|
+
} finally {
|
|
258
|
+
await sql.end()
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// --- helpers ---
|
|
263
|
+
|
|
264
|
+
function wireClient() {
|
|
265
|
+
return postgres({
|
|
266
|
+
host: '127.0.0.1',
|
|
267
|
+
port: pgPort,
|
|
268
|
+
user: 'user',
|
|
269
|
+
password: 'password',
|
|
270
|
+
database: 'postgres',
|
|
271
|
+
max: 1,
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
})
|
|
@@ -67,6 +67,35 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
|
|
|
67
67
|
$$ LANGUAGE plpgsql;
|
|
68
68
|
`)
|
|
69
69
|
|
|
70
|
+
// auto-install change tracking on tables created after startup (e.g. via restore
|
|
71
|
+
// or wire protocol). uses a DDL event trigger that fires on CREATE TABLE.
|
|
72
|
+
await db.exec(`
|
|
73
|
+
CREATE OR REPLACE FUNCTION public._zero_auto_track() RETURNS event_trigger AS $$
|
|
74
|
+
DECLARE
|
|
75
|
+
obj record;
|
|
76
|
+
BEGIN
|
|
77
|
+
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands()
|
|
78
|
+
WHERE command_tag = 'CREATE TABLE'
|
|
79
|
+
LOOP
|
|
80
|
+
IF obj.schema_name = 'public'
|
|
81
|
+
AND obj.object_identity NOT LIKE '%._zero_%'
|
|
82
|
+
AND obj.object_identity NOT LIKE '%.migrations'
|
|
83
|
+
THEN
|
|
84
|
+
EXECUTE format(
|
|
85
|
+
'CREATE TRIGGER _zero_change_trigger AFTER INSERT OR UPDATE OR DELETE ON %s FOR EACH ROW EXECUTE FUNCTION public._zero_track_change()',
|
|
86
|
+
obj.object_identity
|
|
87
|
+
);
|
|
88
|
+
END IF;
|
|
89
|
+
END LOOP;
|
|
90
|
+
END;
|
|
91
|
+
$$ LANGUAGE plpgsql;
|
|
92
|
+
|
|
93
|
+
DROP EVENT TRIGGER IF EXISTS _zero_auto_track_trigger;
|
|
94
|
+
CREATE EVENT TRIGGER _zero_auto_track_trigger ON ddl_command_end
|
|
95
|
+
WHEN TAG IN ('CREATE TABLE')
|
|
96
|
+
EXECUTE FUNCTION public._zero_auto_track();
|
|
97
|
+
`)
|
|
98
|
+
|
|
70
99
|
// install triggers on all public tables
|
|
71
100
|
await installTriggersOnAllTables(db)
|
|
72
101
|
}
|
|
@@ -140,6 +169,58 @@ async function installTriggersOnAllTables(db: PGlite): Promise<void> {
|
|
|
140
169
|
log.debug.pglite(`installed change tracking triggers on ${count} tables`)
|
|
141
170
|
}
|
|
142
171
|
|
|
172
|
+
/**
|
|
173
|
+
* re-install change tracking triggers on any public tables that don't have them.
|
|
174
|
+
* catches tables created between startup and replication start.
|
|
175
|
+
*/
|
|
176
|
+
export async function ensureChangeTrackingOnAllTables(db: PGlite): Promise<void> {
|
|
177
|
+
const pubName = process.env.ZERO_APP_PUBLICATIONS
|
|
178
|
+
let tables: { tablename: string }[]
|
|
179
|
+
|
|
180
|
+
if (pubName) {
|
|
181
|
+
const result = await db.query<{ tablename: string }>(
|
|
182
|
+
`SELECT tablename FROM pg_publication_tables
|
|
183
|
+
WHERE pubname = $1
|
|
184
|
+
AND schemaname = 'public'
|
|
185
|
+
AND tablename NOT LIKE '_zero_%'`,
|
|
186
|
+
[pubName]
|
|
187
|
+
)
|
|
188
|
+
tables = result.rows
|
|
189
|
+
} else {
|
|
190
|
+
const result = await db.query<{ tablename: string }>(
|
|
191
|
+
`SELECT tablename FROM pg_tables
|
|
192
|
+
WHERE schemaname = 'public'
|
|
193
|
+
AND tablename NOT IN ('migrations', '_zero_changes')
|
|
194
|
+
AND tablename NOT LIKE '_zero_%'`
|
|
195
|
+
)
|
|
196
|
+
tables = result.rows
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// find tables missing the change trigger
|
|
200
|
+
const triggered = await db.query<{ event_object_table: string }>(
|
|
201
|
+
`SELECT DISTINCT event_object_table FROM information_schema.triggers
|
|
202
|
+
WHERE trigger_name = '_zero_change_trigger'
|
|
203
|
+
AND event_object_schema = 'public'`
|
|
204
|
+
)
|
|
205
|
+
const hasTracker = new Set(triggered.rows.map((r) => r.event_object_table))
|
|
206
|
+
|
|
207
|
+
let count = 0
|
|
208
|
+
for (const { tablename } of tables) {
|
|
209
|
+
if (hasTracker.has(tablename)) continue
|
|
210
|
+
const quoted = quoteIdent(tablename)
|
|
211
|
+
await db.exec(`
|
|
212
|
+
CREATE TRIGGER _zero_change_trigger
|
|
213
|
+
AFTER INSERT OR UPDATE OR DELETE ON public.${quoted}
|
|
214
|
+
FOR EACH ROW EXECUTE FUNCTION public._zero_track_change();
|
|
215
|
+
`)
|
|
216
|
+
count++
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (count > 0) {
|
|
220
|
+
log.debug.pglite(`installed change tracking on ${count} new tables`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
143
224
|
/**
|
|
144
225
|
* install change tracking triggers on tables in shard schemas.
|
|
145
226
|
* zero-cache creates shard schemas (e.g. chat_0) with clients/mutations
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getChangesSince,
|
|
12
12
|
getCurrentWatermark,
|
|
13
13
|
installTriggersOnShardTables,
|
|
14
|
+
ensureChangeTrackingOnAllTables,
|
|
14
15
|
type ChangeRecord,
|
|
15
16
|
} from './change-tracker.js'
|
|
16
17
|
import {
|
|
@@ -284,6 +285,9 @@ export async function handleStartReplication(
|
|
|
284
285
|
// "already in transaction" errors when they interleave.
|
|
285
286
|
await mutex.acquire()
|
|
286
287
|
try {
|
|
288
|
+
// install change tracking triggers on any tables created after startup
|
|
289
|
+
await ensureChangeTrackingOnAllTables(db)
|
|
290
|
+
|
|
287
291
|
// install change tracking triggers on shard schema tables (e.g. chat_0.clients)
|
|
288
292
|
// these track zero-cache's lastMutationID for .server promise resolution
|
|
289
293
|
await installTriggersOnShardTables(db)
|