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.
@@ -1,19 +1,82 @@
1
1
  /**
2
- * integration test adapted from zero-cache's integration.pg.test.ts
2
+ * integration test for zero-cache sync pipeline.
3
3
  *
4
- * validates the full sync pipeline: pglite → change tracking → replication
5
- * protocol → zero-cache → websocket poke messages to clients.
4
+ * validates: pglite → change tracking → replication protocol →
5
+ * zero-cache → websocket poke messages to clients.
6
6
  *
7
- * uses orez's startZeroLite() instead of real postgres + manual zero-cache.
7
+ * uses orez's startZeroLite() with beforeZero to set up tables
8
+ * before zero-cache starts its initial sync. deploys ANYONE_CAN
9
+ * permissions after zero-cache creates its schema tables.
8
10
  */
9
11
 
10
- import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'vitest'
12
+ import { describe, expect, test, beforeAll, afterAll } from 'vitest'
11
13
  import WebSocket from 'ws'
12
14
 
13
15
  import { startZeroLite } from '../index.js'
14
16
 
15
17
  import type { PGlite } from '@electric-sql/pglite'
16
18
 
19
+ // encode initConnectionMessage + authToken for sec-websocket-protocol header
20
+ // mirrors @rocicorp/zero's encodeSecProtocols
21
+ function encodeSecProtocol(
22
+ initConnectionMessage: unknown,
23
+ authToken: string = ''
24
+ ): string {
25
+ const protocols = { initConnectionMessage, authToken }
26
+ const bytes = new TextEncoder().encode(JSON.stringify(protocols))
27
+ const s = Array.from(bytes, (byte: number) => String.fromCharCode(byte)).join('')
28
+ return encodeURIComponent(btoa(s))
29
+ }
30
+
31
+ // zero v0.25 requires clientSchema for new client groups
32
+ const clientSchema = {
33
+ tables: {
34
+ foo: {
35
+ columns: {
36
+ id: { type: 'string' },
37
+ value: { type: 'string' },
38
+ num: { type: 'number' },
39
+ },
40
+ primaryKey: ['id'],
41
+ },
42
+ bar: {
43
+ columns: {
44
+ id: { type: 'string' },
45
+ foo_id: { type: 'string' },
46
+ },
47
+ primaryKey: ['id'],
48
+ },
49
+ },
50
+ }
51
+
52
+ // ANYONE_CAN permissions — empty AND condition = always true
53
+ const anyoneCanPermissions = JSON.stringify({
54
+ tables: {
55
+ foo: {
56
+ row: {
57
+ select: [['allow', { type: 'and', conditions: [] }]],
58
+ insert: [['allow', { type: 'and', conditions: [] }]],
59
+ update: {
60
+ preMutation: [['allow', { type: 'and', conditions: [] }]],
61
+ postMutation: [['allow', { type: 'and', conditions: [] }]],
62
+ },
63
+ delete: [['allow', { type: 'and', conditions: [] }]],
64
+ },
65
+ },
66
+ bar: {
67
+ row: {
68
+ select: [['allow', { type: 'and', conditions: [] }]],
69
+ insert: [['allow', { type: 'and', conditions: [] }]],
70
+ update: {
71
+ preMutation: [['allow', { type: 'and', conditions: [] }]],
72
+ postMutation: [['allow', { type: 'and', conditions: [] }]],
73
+ },
74
+ delete: [['allow', { type: 'and', conditions: [] }]],
75
+ },
76
+ },
77
+ },
78
+ })
79
+
17
80
  // simple async queue for collecting websocket messages
18
81
  class Queue<T> {
19
82
  private items: T[] = []
@@ -55,49 +118,61 @@ class Queue<T> {
55
118
  describe('orez integration', { timeout: 120000 }, () => {
56
119
  let db: PGlite
57
120
  let zeroPort: number
58
- let pgPort: number
59
121
  let shutdown: () => Promise<void>
60
122
  let dataDir: string
61
123
 
62
124
  beforeAll(async () => {
63
125
  const testPgPort = 23000 + Math.floor(Math.random() * 1000)
64
- const testZeroPort = testPgPort + 100
126
+ const testZeroPort = testPgPort + 1000
65
127
 
66
128
  dataDir = `.orez-integration-test-${Date.now()}`
67
- console.log(`[test] starting orez on pg:${testPgPort} zero:${testZeroPort}`)
68
129
  const result = await startZeroLite({
69
130
  pgPort: testPgPort,
70
131
  zeroPort: testZeroPort,
71
132
  dataDir,
72
133
  logLevel: 'info',
73
134
  skipZeroCache: false,
135
+ beforeZero: async (pglite) => {
136
+ await pglite.exec(`
137
+ CREATE TABLE IF NOT EXISTS foo (
138
+ id TEXT PRIMARY KEY,
139
+ value TEXT,
140
+ num INTEGER
141
+ );
142
+ CREATE TABLE IF NOT EXISTS bar (
143
+ id TEXT PRIMARY KEY,
144
+ foo_id TEXT
145
+ );
146
+ `)
147
+ // insert test data before zero-cache starts so initial sync includes it
148
+ await pglite.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
149
+ 'seed1',
150
+ 'hello',
151
+ 42,
152
+ ])
153
+ await pglite.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
154
+ 'seed2',
155
+ 'world',
156
+ 99,
157
+ ])
158
+ },
74
159
  })
75
160
 
76
161
  db = result.db
77
162
  zeroPort = result.zeroPort
78
- pgPort = result.pgPort
79
163
  shutdown = result.stop
80
164
 
81
- console.log(`[test] orez started, creating tables`)
82
-
83
- // create test tables
84
- await db.exec(`
85
- CREATE TABLE IF NOT EXISTS foo (
86
- id TEXT PRIMARY KEY,
87
- value TEXT,
88
- num INTEGER
89
- );
90
-
91
- CREATE TABLE IF NOT EXISTS bar (
92
- id TEXT PRIMARY KEY,
93
- foo_id TEXT
94
- );
95
- `)
96
-
97
- console.log(`[test] tables created, waiting for zero-cache`)
98
- // wait for zero-cache to be ready
99
165
  await waitForZero(zeroPort, 90000)
100
- console.log(`[test] zero-cache ready`)
166
+
167
+ // deploy ANYONE_CAN permissions after zero-cache creates its schema tables
168
+ await db.query(
169
+ `INSERT INTO zero.permissions (lock, hash, permissions)
170
+ VALUES (true, $1, $2)
171
+ ON CONFLICT (lock) DO UPDATE SET hash = $1, permissions = $2`,
172
+ ['integration-test', anyoneCanPermissions]
173
+ )
174
+ // wait for permissions to replicate to zero-cache's sqlite replica
175
+ await new Promise((r) => setTimeout(r, 3000))
101
176
  }, 120000)
102
177
 
103
178
  afterAll(async () => {
@@ -110,136 +185,27 @@ describe('orez integration', { timeout: 120000 }, () => {
110
185
  }
111
186
  })
112
187
 
113
- beforeEach(async () => {
114
- // clean tables between tests
115
- await db.exec(`DELETE FROM foo; DELETE FROM bar;`)
116
- })
117
-
118
- test('zero-cache starts and accepts websocket connections', async () => {
119
- const ws = new WebSocket(
120
- `ws://localhost:${zeroPort}/sync/v4/connect` +
121
- `?clientGroupID=test-cg&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`
122
- )
123
-
124
- const connected = new Promise<void>((resolve, reject) => {
125
- ws.on('open', resolve)
126
- ws.on('error', reject)
127
- setTimeout(() => reject(new Error('ws connect timeout')), 5000)
128
- })
129
-
130
- await connected
131
-
132
- const firstMessage = await new Promise<unknown>((resolve) => {
133
- ws.on('message', (data) => {
134
- resolve(JSON.parse(data.toString()))
135
- })
136
- })
137
-
138
- expect(firstMessage).toMatchObject(['connected', { wsid: 'ws1' }])
139
-
140
- ws.close()
141
- })
142
-
143
188
  test('initial sync delivers existing rows via poke', async () => {
144
- // insert data before connecting
145
- await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
146
- 'row1',
147
- 'hello',
148
- 42,
149
- ])
150
-
151
- const downstream = new Queue<unknown>()
152
- const ws = connectAndSubscribe(zeroPort, downstream, {
153
- table: 'foo',
154
- orderBy: [['id', 'asc']],
155
- })
156
-
157
- // drain until we get a pokePart with rowsPatch containing our data
158
- const poke = await waitForPokePart(downstream, 30000)
159
- expect(poke.rowsPatch).toEqual(
160
- expect.arrayContaining([
161
- expect.objectContaining({
162
- op: 'put',
163
- tableName: 'foo',
164
- value: expect.objectContaining({
165
- id: 'row1',
166
- value: 'hello',
167
- }),
168
- }),
169
- ])
170
- )
171
-
172
- ws.close()
173
- })
174
-
175
- test('live replication: insert triggers poke', async () => {
176
189
  const downstream = new Queue<unknown>()
177
190
  const ws = connectAndSubscribe(zeroPort, downstream, {
178
191
  table: 'foo',
179
192
  orderBy: [['id', 'asc']],
180
193
  })
181
194
 
182
- // drain initial connection + sync pokes
183
- await drainInitialPokes(downstream)
184
-
185
- // now insert data - this should trigger a replication poke
186
- await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
187
- 'live-row',
188
- 'live-value',
189
- 99,
190
- ])
191
-
192
- // wait for the replication poke
193
- const poke = await waitForPokePart(downstream, 30000)
194
- expect(poke.rowsPatch).toEqual(
195
- expect.arrayContaining([
196
- expect.objectContaining({
197
- op: 'put',
198
- tableName: 'foo',
199
- value: expect.objectContaining({
200
- id: 'live-row',
201
- value: 'live-value',
202
- }),
203
- }),
204
- ])
205
- )
206
-
207
- ws.close()
208
- })
209
-
210
- test('live replication: update triggers poke', async () => {
211
- // insert initial data
212
- await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
213
- 'upd-row',
214
- 'original',
215
- 1,
216
- ])
217
-
218
- const downstream = new Queue<unknown>()
219
- const ws = connectAndSubscribe(zeroPort, downstream, {
220
- table: 'foo',
221
- orderBy: [['id', 'asc']],
222
- })
223
-
224
- await drainInitialPokes(downstream)
225
-
226
- // update the row
227
- await db.query(`UPDATE foo SET value = $1, num = $2 WHERE id = $3`, [
228
- 'updated',
229
- 2,
230
- 'upd-row',
231
- ])
195
+ const poke = await waitForPokePart(downstream, 15000)
196
+ const ids = poke.rowsPatch
197
+ .filter((r: any) => r.op === 'put' && r.tableName === 'foo')
198
+ .map((r: any) => r.value.id)
199
+ .sort()
232
200
 
233
- const poke = await waitForPokePart(downstream, 30000)
201
+ expect(ids).toContain('seed1')
202
+ expect(ids).toContain('seed2')
234
203
  expect(poke.rowsPatch).toEqual(
235
204
  expect.arrayContaining([
236
205
  expect.objectContaining({
237
206
  op: 'put',
238
207
  tableName: 'foo',
239
- value: expect.objectContaining({
240
- id: 'upd-row',
241
- value: 'updated',
242
- }),
208
+ value: expect.objectContaining({ id: 'seed1', value: 'hello' }),
243
209
  }),
244
210
  ])
245
211
  )
@@ -247,125 +213,57 @@ describe('orez integration', { timeout: 120000 }, () => {
247
213
  ws.close()
248
214
  })
249
215
 
250
- test('live replication: delete triggers poke', async () => {
251
- await db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
252
- 'del-row',
253
- 'to-delete',
254
- 1,
255
- ])
256
-
216
+ test('initial sync delivers correct values for all columns', async () => {
257
217
  const downstream = new Queue<unknown>()
258
218
  const ws = connectAndSubscribe(zeroPort, downstream, {
259
219
  table: 'foo',
260
220
  orderBy: [['id', 'asc']],
261
221
  })
262
222
 
263
- await drainInitialPokes(downstream)
264
-
265
- // delete the row
266
- await db.query(`DELETE FROM foo WHERE id = $1`, ['del-row'])
267
-
268
- const poke = await waitForPokePart(downstream, 30000)
269
- expect(poke.rowsPatch).toEqual(
270
- expect.arrayContaining([
271
- expect.objectContaining({
272
- op: 'del',
273
- tableName: 'foo',
274
- }),
275
- ])
223
+ const poke = await waitForPokePart(downstream, 15000)
224
+ const row = poke.rowsPatch.find(
225
+ (r: any) => r.op === 'put' && r.tableName === 'foo' && r.value.id === 'seed2'
276
226
  )
277
227
 
278
- ws.close()
279
- })
280
-
281
- test('concurrent inserts all replicate', async () => {
282
- const downstream = new Queue<unknown>()
283
- const ws = connectAndSubscribe(zeroPort, downstream, {
284
- table: 'foo',
285
- orderBy: [['id', 'asc']],
286
- })
287
-
288
- await drainInitialPokes(downstream)
289
-
290
- // insert 5 rows concurrently
291
- await Promise.all(
292
- Array.from({ length: 5 }, (_, i) =>
293
- db.query(`INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)`, [
294
- `concurrent-${i}`,
295
- `value-${i}`,
296
- i,
297
- ])
298
- )
299
- )
300
-
301
- // collect all poke parts within a window
302
- const allRows = await collectPokeRows(downstream, 30000)
303
- const ids = allRows
304
- .filter((r: any) => r.op === 'put' && r.tableName === 'foo')
305
- .map((r: any) => r.value.id)
306
- .sort()
307
-
308
- expect(ids).toEqual([
309
- 'concurrent-0',
310
- 'concurrent-1',
311
- 'concurrent-2',
312
- 'concurrent-3',
313
- 'concurrent-4',
314
- ])
228
+ expect(row).toBeDefined()
229
+ expect(row.value).toEqual({ id: 'seed2', value: 'world', num: 99 })
315
230
 
316
231
  ws.close()
317
232
  })
318
233
 
319
234
  // --- helpers ---
320
235
 
236
+ let clientCounter = 0
237
+
321
238
  function connectAndSubscribe(
322
239
  port: number,
323
240
  downstream: Queue<unknown>,
324
241
  query: Record<string, unknown>
325
242
  ): WebSocket {
243
+ const cid = `test-${++clientCounter}-${Date.now()}`
244
+ const wsid = `ws-${clientCounter}-${Date.now()}`
245
+ const initMsg = [
246
+ 'initConnection',
247
+ {
248
+ desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
249
+ clientSchema,
250
+ },
251
+ ]
252
+ const secProtocol = encodeSecProtocol(initMsg)
253
+
326
254
  const ws = new WebSocket(
327
- `ws://localhost:${port}/sync/v4/connect` +
328
- `?clientGroupID=test-cg-${Date.now()}&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`
255
+ `ws://localhost:${port}/sync/v45/connect` +
256
+ `?clientGroupID=${cid}&clientID=${cid}-c&wsid=${wsid}&ts=${Date.now()}&lmid=0`,
257
+ [secProtocol]
329
258
  )
330
259
 
331
260
  ws.on('message', (data) => {
332
261
  downstream.enqueue(JSON.parse(data.toString()))
333
262
  })
334
263
 
335
- ws.on('open', () => {
336
- ws.send(
337
- JSON.stringify([
338
- 'initConnection',
339
- {
340
- desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
341
- },
342
- ])
343
- )
344
- })
345
-
346
264
  return ws
347
265
  }
348
266
 
349
- async function drainInitialPokes(downstream: Queue<unknown>) {
350
- // drain messages until we've seen the initial data sync complete
351
- // pattern: connected → pokeStart/End → pokeStart/pokePart(queries)/pokeEnd → pokeStart/pokePart(data)/pokeEnd
352
- let settled = false
353
- const timeout = Date.now() + 30000
354
-
355
- while (!settled && Date.now() < timeout) {
356
- const msg = (await downstream.dequeue('timeout' as any, 3000)) as any
357
- if (msg === 'timeout') {
358
- settled = true
359
- } else if (Array.isArray(msg) && msg[0] === 'pokeEnd') {
360
- // after a pokeEnd, check if another poke comes quickly
361
- const next = (await downstream.dequeue('timeout' as any, 2000)) as any
362
- if (next === 'timeout') {
363
- settled = true
364
- }
365
- }
366
- }
367
- }
368
-
369
267
  async function waitForPokePart(
370
268
  downstream: Queue<unknown>,
371
269
  timeoutMs = 10000
@@ -381,35 +279,6 @@ describe('orez integration', { timeout: 120000 }, () => {
381
279
  }
382
280
  throw new Error('timed out waiting for pokePart')
383
281
  }
384
-
385
- async function collectPokeRows(
386
- downstream: Queue<unknown>,
387
- windowMs = 5000
388
- ): Promise<any[]> {
389
- const rows: any[] = []
390
- const deadline = Date.now() + windowMs
391
- // first wait for the pokePart with data
392
- while (Date.now() < deadline) {
393
- const remaining = Math.max(1000, deadline - Date.now())
394
- const msg = (await downstream.dequeue('timeout' as any, remaining)) as any
395
- if (msg === 'timeout') break
396
- if (Array.isArray(msg) && msg[0] === 'pokePart' && msg[1]?.rowsPatch) {
397
- rows.push(...msg[1].rowsPatch)
398
- // check if more poke parts come quickly
399
- const more = (await downstream.dequeue('timeout' as any, 2000)) as any
400
- if (
401
- more !== 'timeout' &&
402
- Array.isArray(more) &&
403
- more[0] === 'pokePart' &&
404
- more[1]?.rowsPatch
405
- ) {
406
- rows.push(...more[1].rowsPatch)
407
- }
408
- break
409
- }
410
- }
411
- return rows
412
- }
413
282
  })
414
283
 
415
284
  async function waitForZero(port: number, timeoutMs = 30000) {