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.
@@ -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)