orez 0.1.6 → 0.1.8

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 (69) hide show
  1. package/README.md +186 -225
  2. package/dist/admin/log-store.d.ts.map +1 -1
  3. package/dist/admin/log-store.js +17 -6
  4. package/dist/admin/log-store.js.map +1 -1
  5. package/dist/admin/server.d.ts +1 -0
  6. package/dist/admin/server.d.ts.map +1 -1
  7. package/dist/admin/server.js +10 -0
  8. package/dist/admin/server.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +96 -46
  11. package/dist/cli.js.map +1 -1
  12. package/dist/config.d.ts +1 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +1 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +158 -23
  19. package/dist/index.js.map +1 -1
  20. package/dist/integration/test-permissions.d.ts +7 -0
  21. package/dist/integration/test-permissions.d.ts.map +1 -0
  22. package/dist/integration/test-permissions.js +117 -0
  23. package/dist/integration/test-permissions.js.map +1 -0
  24. package/dist/pg-proxy.js +2 -2
  25. package/dist/pg-proxy.js.map +1 -1
  26. package/dist/replication/change-tracker.d.ts.map +1 -1
  27. package/dist/replication/change-tracker.js +15 -13
  28. package/dist/replication/change-tracker.js.map +1 -1
  29. package/dist/replication/handler.d.ts.map +1 -1
  30. package/dist/replication/handler.js +27 -2
  31. package/dist/replication/handler.js.map +1 -1
  32. package/dist/sqlite-mode/index.d.ts +1 -0
  33. package/dist/sqlite-mode/index.d.ts.map +1 -1
  34. package/dist/sqlite-mode/index.js +1 -0
  35. package/dist/sqlite-mode/index.js.map +1 -1
  36. package/dist/sqlite-mode/native-binary.d.ts +11 -0
  37. package/dist/sqlite-mode/native-binary.d.ts.map +1 -0
  38. package/dist/sqlite-mode/native-binary.js +67 -0
  39. package/dist/sqlite-mode/native-binary.js.map +1 -0
  40. package/dist/sqlite-mode/package-resolve.d.ts +6 -0
  41. package/dist/sqlite-mode/package-resolve.d.ts.map +1 -0
  42. package/dist/sqlite-mode/package-resolve.js +20 -0
  43. package/dist/sqlite-mode/package-resolve.js.map +1 -0
  44. package/dist/sqlite-mode/resolve-mode.d.ts +12 -7
  45. package/dist/sqlite-mode/resolve-mode.d.ts.map +1 -1
  46. package/dist/sqlite-mode/resolve-mode.js +27 -23
  47. package/dist/sqlite-mode/resolve-mode.js.map +1 -1
  48. package/package.json +8 -2
  49. package/src/admin/log-store.ts +19 -9
  50. package/src/admin/server.ts +12 -0
  51. package/src/cli.ts +99 -44
  52. package/src/config.ts +2 -0
  53. package/src/index.ts +186 -24
  54. package/src/integration/integration.test.ts +93 -15
  55. package/src/integration/native-binary.guard.test.ts +13 -0
  56. package/src/integration/native-startup.test.ts +44 -0
  57. package/src/integration/restore-live-stress.test.ts +433 -0
  58. package/src/integration/restore-reset.test.ts +136 -20
  59. package/src/integration/test-permissions.ts +147 -0
  60. package/src/pg-proxy.ts +2 -2
  61. package/src/replication/change-tracker.test.ts +1 -1
  62. package/src/replication/change-tracker.ts +16 -13
  63. package/src/replication/handler.test.ts +2 -2
  64. package/src/replication/handler.ts +30 -2
  65. package/src/sqlite-mode/index.ts +1 -0
  66. package/src/sqlite-mode/native-binary.ts +89 -0
  67. package/src/sqlite-mode/package-resolve.ts +17 -0
  68. package/src/sqlite-mode/resolve-mode.ts +31 -21
  69. package/src/sqlite-mode/sqlite-mode.test.ts +11 -5
package/src/index.ts CHANGED
@@ -23,6 +23,9 @@ import { createPGliteInstances, runMigrations } from './pglite-manager.js'
23
23
  import { findPort } from './port.js'
24
24
  import { installChangeTracking } from './replication/change-tracker.js'
25
25
  import {
26
+ formatNativeBootstrapInstructions,
27
+ hasMissingNativeBinarySignature,
28
+ inspectNativeSqliteBinary,
26
29
  resolveSqliteMode,
27
30
  resolveSqliteModeConfig,
28
31
  type SqliteMode,
@@ -32,6 +35,8 @@ import {
32
35
  import type { ZeroLiteConfig } from './config.js'
33
36
  import type { PGlite } from '@electric-sql/pglite'
34
37
 
38
+ type ZeroChildProcess = ChildProcess & { __orezTail?: string[] }
39
+
35
40
  export { getConfig, getConnectionString } from './config.js'
36
41
  export type { Hook, LogLevel, ZeroLiteConfig } from './config.js'
37
42
 
@@ -70,6 +75,60 @@ async function runHook(
70
75
  })
71
76
  }
72
77
 
78
+ function getManagedPublicationConfig(): { names: string[]; managedByOrez: boolean } {
79
+ const existing = process.env.ZERO_APP_PUBLICATIONS?.trim()
80
+ if (existing) {
81
+ const names = existing
82
+ .split(',')
83
+ .map((s) => s.trim())
84
+ .filter(Boolean)
85
+ return { names, managedByOrez: false }
86
+ }
87
+
88
+ const appId = (process.env.ZERO_APP_ID || 'zero').trim() || 'zero'
89
+ const fallback = `orez_${appId}_public`
90
+ process.env.ZERO_APP_PUBLICATIONS = fallback
91
+ return { names: [fallback], managedByOrez: true }
92
+ }
93
+
94
+ async function syncManagedPublications(
95
+ db: PGlite,
96
+ names: string[],
97
+ managedByOrez: boolean
98
+ ): Promise<void> {
99
+ if (!managedByOrez || names.length === 0) return
100
+
101
+ const tables = await db.query<{ tablename: string }>(
102
+ `SELECT tablename
103
+ FROM pg_tables
104
+ WHERE schemaname = 'public'
105
+ AND tablename NOT LIKE '_zero_%'`
106
+ )
107
+ const publicTables = tables.rows
108
+ .map((r) => r.tablename)
109
+ .filter((t) => !t.startsWith('_'))
110
+
111
+ for (const pub of names) {
112
+ const quotedPub = '"' + pub.replace(/"/g, '""') + '"'
113
+ await db.exec(`CREATE PUBLICATION ${quotedPub}`).catch(() => {})
114
+
115
+ if (publicTables.length === 0) continue
116
+ const inPub = await db.query<{ tablename: string }>(
117
+ `SELECT tablename
118
+ FROM pg_publication_tables
119
+ WHERE pubname = $1
120
+ AND schemaname = 'public'`,
121
+ [pub]
122
+ )
123
+ const inPubSet = new Set(inPub.rows.map((r) => r.tablename))
124
+ const toAdd = publicTables.filter((t) => !inPubSet.has(t))
125
+ if (toAdd.length === 0) continue
126
+ const tableList = toAdd.map((t) => `"public"."${t.replace(/"/g, '""')}"`).join(', ')
127
+ await db.exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE ${tableList}`)
128
+ log.debug.orez(`added ${toAdd.length} table(s) to publication "${pub}"`)
129
+ }
130
+ }
131
+
73
132
  // resolvePackage moved to sqlite-mode/resolve-mode.ts
74
133
  import { resolvePackage } from './sqlite-mode/resolve-mode.js'
75
134
 
@@ -107,18 +166,20 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
107
166
  log.debug.orez(`data dir: ${resolve(config.dataDir)}`)
108
167
 
109
168
  // resolve sqlite mode config early (used for shim application and cleanup)
110
- // requested wasm can fall back to native if required packages are missing.
111
- let sqliteMode = resolveSqliteMode(config.disableWasmSqlite)
112
- let sqliteModeConfig = resolveSqliteModeConfig(config.disableWasmSqlite)
169
+ // auto-detects native if available, falls back to wasm
170
+ let sqliteMode = resolveSqliteMode(config.disableWasmSqlite, config.forceWasmSqlite)
171
+ let sqliteModeConfig = resolveSqliteModeConfig(
172
+ config.disableWasmSqlite,
173
+ config.forceWasmSqlite
174
+ )
113
175
  if (sqliteMode === 'wasm' && !sqliteModeConfig) {
114
176
  log.orez(
115
- 'warning: wasm sqlite requested but dependencies are missing, falling back to native sqlite'
177
+ 'warning: wasm sqlite requested but dependencies are missing, falling back to native'
116
178
  )
117
179
  sqliteMode = 'native'
118
180
  config.disableWasmSqlite = true
119
- sqliteModeConfig = resolveSqliteModeConfig(true)
181
+ sqliteModeConfig = resolveSqliteModeConfig(true, false)
120
182
  }
121
- log.orez(`sqlite: ${sqliteMode}`)
122
183
 
123
184
  mkdirSync(config.dataDir, { recursive: true })
124
185
 
@@ -126,12 +187,23 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
126
187
  const pidFile = resolve(config.dataDir, 'orez.pid')
127
188
  writeFileSync(pidFile, String(process.pid))
128
189
 
190
+ // write admin port file so pg_restore can find it
191
+ const adminFile = resolve(config.dataDir, 'orez.admin')
192
+ if (adminPort > 0) {
193
+ writeFileSync(adminFile, String(adminPort))
194
+ }
195
+
129
196
  // start pglite (separate instances for postgres, zero_cvr, zero_cdb)
130
197
  const instances = await createPGliteInstances(config)
131
198
  const db = instances.postgres
199
+ const managedPub = getManagedPublicationConfig()
200
+ if (managedPub.managedByOrez) {
201
+ log.debug.orez(`using managed publication: ${managedPub.names.join(', ')}`)
202
+ }
132
203
 
133
204
  // run migrations (on postgres instance only)
134
205
  const migrationsApplied = await runMigrations(db, config)
206
+ await syncManagedPublications(db, managedPub.names, managedPub.managedByOrez)
135
207
 
136
208
  // install change tracking (on postgres instance only)
137
209
  log.debug.orez('installing change tracking')
@@ -162,6 +234,7 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
162
234
  })
163
235
 
164
236
  // re-install change tracking on tables created by on-db-ready
237
+ await syncManagedPublications(db, managedPub.names, managedPub.managedByOrez)
165
238
  log.debug.orez('re-installing change tracking after on-db-ready')
166
239
  await installChangeTracking(db)
167
240
  }
@@ -191,7 +264,7 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
191
264
  )
192
265
  zeroCacheProcess = result.process
193
266
  zeroEnv = result.env
194
- await waitForZeroCache(zeroConfig)
267
+ await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
195
268
 
196
269
  // start http proxy in front of zero-cache when admin is enabled
197
270
  if (httpLog) {
@@ -247,7 +320,7 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
247
320
  )
248
321
  zeroCacheProcess = result.process
249
322
  zeroEnv = result.env
250
- await waitForZeroCache(zeroConfig)
323
+ await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
251
324
  }
252
325
 
253
326
  // unified reset function for zero state
@@ -312,6 +385,38 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
312
385
  await instances.cvr.waitReady
313
386
  await instances.cdb.waitReady
314
387
  log.orez('CVR/CDB recreated')
388
+
389
+ // remove stale zero shard schemas from upstream; these can outlive CVR/CDB
390
+ // and cause dispatcher errors after full reset.
391
+ const shardSchemas = await db.query<{ schemaname: string }>(
392
+ `SELECT DISTINCT schemaname
393
+ FROM pg_tables
394
+ WHERE tablename IN ('clients', 'replicas', 'mutations')
395
+ AND schemaname NOT IN (
396
+ 'pg_catalog',
397
+ 'information_schema',
398
+ 'pg_toast',
399
+ 'public',
400
+ '_orez'
401
+ )
402
+ AND schemaname NOT LIKE 'pg_%'`
403
+ )
404
+ for (const { schemaname } of shardSchemas.rows) {
405
+ const quoted = '"' + schemaname.replace(/"/g, '""') + '"'
406
+ await db.exec(`DROP SCHEMA IF EXISTS ${quoted} CASCADE`)
407
+ }
408
+ if (shardSchemas.rows.length > 0) {
409
+ log.orez(`dropped ${shardSchemas.rows.length} stale shard schema(s)`)
410
+ }
411
+
412
+ // clear upstream replication tracking so zero-cache starts from a
413
+ // clean change stream baseline after full reset.
414
+ await db.exec(`TRUNCATE _orez._zero_changes`).catch(() => {})
415
+ await db.exec(`TRUNCATE _orez._zero_replication_slots`).catch(() => {})
416
+ await db
417
+ .exec(`ALTER SEQUENCE _orez._zero_watermark RESTART WITH 1`)
418
+ .catch(() => {})
419
+ log.orez('cleared upstream replication tracking state')
315
420
  }
316
421
 
317
422
  // always clean up replica file
@@ -331,12 +436,14 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
331
436
  DATABASE_URL: upstreamUrl,
332
437
  OREZ_PG_PORT: String(config.pgPort),
333
438
  })
334
-
335
- // re-install change tracking on any tables created/modified by on-db-ready
336
- log.debug.orez('re-installing change tracking after on-db-ready')
337
- await installChangeTracking(db)
338
439
  }
339
440
 
441
+ // always re-install change tracking after a full reset so public table
442
+ // triggers reflect any schema changes introduced by restore.
443
+ await syncManagedPublications(db, managedPub.names, managedPub.managedByOrez)
444
+ log.debug.orez('re-installing change tracking after full reset')
445
+ await installChangeTracking(db)
446
+
340
447
  // restart zero-cache
341
448
  log.orez('starting zero-cache...')
342
449
  // use internal port when http proxy is enabled
@@ -350,7 +457,7 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
350
457
  zeroCacheProcess = result.process
351
458
  zeroEnv = result.env
352
459
 
353
- await waitForZeroCache(zeroConfig)
460
+ await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
354
461
  log.orez(`zero state reset complete (${mode})`)
355
462
  log.zero(`ready ${port(config.zeroPort, 'magenta')}`)
356
463
  } catch (err: any) {
@@ -365,14 +472,22 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
365
472
  }
366
473
  }
367
474
 
368
- // handle SIGUSR1 to reset zero state (sent by pg_restore)
475
+ // handle SIGUSR1 to reset zero state (sent by pg_restore after restore completes)
369
476
  if (!config.skipZeroCache) {
370
477
  process.on('SIGUSR1', () => {
371
- log.orez('received SIGUSR1')
478
+ log.orez('received SIGUSR1 - full reset')
372
479
  resetZeroState('full').catch((err) => {
373
480
  log.orez(`SIGUSR1 reset failed: ${err?.message || err}`)
374
481
  })
375
482
  })
483
+
484
+ // handle SIGUSR2 to quiesce zero-cache (sent by pg_restore before restore starts)
485
+ process.on('SIGUSR2', () => {
486
+ log.orez('received SIGUSR2 - stopping zero-cache for restore')
487
+ killZeroCache().catch((err) => {
488
+ log.orez(`SIGUSR2 stop failed: ${err?.message || err}`)
489
+ })
490
+ })
376
491
  }
377
492
 
378
493
  const stop = async () => {
@@ -388,6 +503,9 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
388
503
  try {
389
504
  unlinkSync(pidFile)
390
505
  } catch {}
506
+ try {
507
+ unlinkSync(adminFile)
508
+ } catch {}
391
509
  log.debug.orez('stopped')
392
510
  }
393
511
 
@@ -402,6 +520,8 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
402
520
  httpLog,
403
521
  zeroEnv,
404
522
  restartZero: config.skipZeroCache ? undefined : restartZeroCache,
523
+ // stop zero-cache without restart (for pg_restore to safely modify schema)
524
+ stopZero: config.skipZeroCache ? undefined : killZeroCache,
405
525
  // cache-only reset: just replica file (fast, for minor sync issues)
406
526
  resetZero: config.skipZeroCache ? undefined : () => resetZeroState('cache-only'),
407
527
  // full reset: CVR/CDB + replica (for schema changes, used by pg_restore via SIGUSR1)
@@ -594,7 +714,14 @@ async function startZeroCache(
594
714
  const child = spawn(zeroCacheBin, [], {
595
715
  env,
596
716
  stdio: ['ignore', 'pipe', 'pipe'],
597
- })
717
+ }) as ZeroChildProcess
718
+ child.__orezTail = []
719
+
720
+ const pushTail = (line: string) => {
721
+ const tail = child.__orezTail!
722
+ tail.push(line)
723
+ if (tail.length > 80) tail.splice(0, tail.length - 80)
724
+ }
598
725
 
599
726
  // detect log level from zero-cache output
600
727
  const detectLevel = (line: string, fallback: string): string => {
@@ -618,21 +745,28 @@ async function startZeroCache(
618
745
  child.stdout?.on('data', (data: Buffer) => {
619
746
  const lines = data.toString().trim().split('\n')
620
747
  for (const line of lines) {
621
- log.debug.zero(line)
622
- logStore?.push('zero', detectLevel(line, 'info'), line)
748
+ pushTail(`stdout: ${line}`)
749
+ const level = detectLevel(line, 'info')
750
+ if (level === 'warn' || level === 'error') log.zero(line)
751
+ else log.debug.zero(line)
752
+ logStore?.push('zero', level, line)
623
753
  }
624
754
  })
625
755
 
626
756
  child.stderr?.on('data', (data: Buffer) => {
627
757
  const lines = data.toString().trim().split('\n')
628
758
  for (const line of lines) {
629
- log.debug.zero(line)
630
- logStore?.push('zero', detectLevel(line, 'error'), line)
759
+ pushTail(`stderr: ${line}`)
760
+ const level = detectLevel(line, 'error')
761
+ if (level === 'warn' || level === 'error') log.zero(line)
762
+ else log.debug.zero(line)
763
+ logStore?.push('zero', level, line)
631
764
  }
632
765
  })
633
766
 
634
767
  child.on('exit', (code) => {
635
768
  if (code !== 0 && code !== null) {
769
+ pushTail(`exit: code ${code}`)
636
770
  log.zero(`exited with code ${code}`)
637
771
  logStore?.push('zero', 'error', `exited with code ${code}`)
638
772
  }
@@ -643,20 +777,48 @@ async function startZeroCache(
643
777
 
644
778
  async function waitForZeroCache(
645
779
  config: ZeroLiteConfig,
646
- timeoutMs = 60000
780
+ zeroProcess?: ChildProcess | null,
781
+ timeoutMs = 60000,
782
+ sqliteMode: SqliteMode = resolveSqliteMode(config.disableWasmSqlite)
647
783
  ): Promise<void> {
648
784
  const start = Date.now()
649
785
  const url = `http://127.0.0.1:${config.zeroPort}/`
650
786
 
651
787
  while (Date.now() - start < timeoutMs) {
788
+ if (zeroProcess && zeroProcess.exitCode !== null) {
789
+ const tail = (zeroProcess as ZeroChildProcess).__orezTail
790
+ const details = tail?.length ? `\n${tail.slice(-20).join('\n')}` : ''
791
+ throw new Error(
792
+ `zero-cache exited with code ${zeroProcess.exitCode}${details}${nativeStartupDiagnostics(details, sqliteMode)}`
793
+ )
794
+ }
795
+
652
796
  try {
653
- const res = await fetch(url)
654
- if (res.ok) return
797
+ const controller = new AbortController()
798
+ const timer = setTimeout(() => controller.abort(), 1000)
799
+ const res = await fetch(url, { signal: controller.signal })
800
+ clearTimeout(timer)
801
+ // zero may return 404 on "/" while still being healthy.
802
+ if (res.ok || res.status === 404) return
655
803
  } catch {
656
804
  // not ready yet
657
805
  }
658
806
  await new Promise((r) => setTimeout(r, 500))
659
807
  }
660
808
 
661
- log.zero('health check timed out, continuing anyway')
809
+ const tail = (zeroProcess as ZeroChildProcess | null | undefined)?.__orezTail
810
+ const details = tail?.length ? `\n${tail.slice(-20).join('\n')}` : ''
811
+ throw new Error(
812
+ `zero-cache health check timed out after ${timeoutMs}ms${details}${nativeStartupDiagnostics(details, sqliteMode)}`
813
+ )
814
+ }
815
+
816
+ function nativeStartupDiagnostics(details: string, sqliteMode: SqliteMode): string {
817
+ if (sqliteMode !== 'native') return ''
818
+ if (!details) return ''
819
+ if (!hasMissingNativeBinarySignature(details)) return ''
820
+
821
+ const check = inspectNativeSqliteBinary()
822
+ const instructions = formatNativeBootstrapInstructions(check)
823
+ return `\n\nnative sqlite startup diagnostics:\n${instructions}`
662
824
  }
@@ -11,9 +11,37 @@ import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'vitest'
11
11
  import WebSocket from 'ws'
12
12
 
13
13
  import { startZeroLite } from '../index.js'
14
+ import { installChangeTracking } from '../replication/change-tracker.js'
15
+ import {
16
+ ensureTablesInPublications,
17
+ hasNonNullPermissions,
18
+ installAllowAllPermissions,
19
+ } from './test-permissions.js'
14
20
 
15
21
  import type { PGlite } from '@electric-sql/pglite'
16
22
 
23
+ const SYNC_PROTOCOL_VERSION = 45
24
+ const CLIENT_SCHEMA = {
25
+ tables: {
26
+ foo: {
27
+ columns: {
28
+ id: { type: 'string' },
29
+ value: { type: 'string' },
30
+ num: { type: 'number' },
31
+ },
32
+ primaryKey: ['id'],
33
+ },
34
+ },
35
+ }
36
+
37
+ function encodeSecProtocols(
38
+ initConnectionMessage: unknown,
39
+ authToken: string | undefined
40
+ ): string {
41
+ const payload = JSON.stringify({ initConnectionMessage, authToken })
42
+ return encodeURIComponent(Buffer.from(payload, 'utf-8').toString('base64'))
43
+ }
44
+
17
45
  // simple async queue for collecting websocket messages
18
46
  class Queue<T> {
19
47
  private items: T[] = []
@@ -57,6 +85,8 @@ describe('orez integration', { timeout: 120000 }, () => {
57
85
  let zeroPort: number
58
86
  let pgPort: number
59
87
  let shutdown: () => Promise<void>
88
+ let restartZero: (() => Promise<void>) | undefined
89
+ let resetZeroFull: (() => Promise<void>) | undefined
60
90
  let dataDir: string
61
91
 
62
92
  beforeAll(async () => {
@@ -77,6 +107,8 @@ describe('orez integration', { timeout: 120000 }, () => {
77
107
  zeroPort = result.zeroPort
78
108
  pgPort = result.pgPort
79
109
  shutdown = result.stop
110
+ restartZero = result.restartZero
111
+ resetZeroFull = result.resetZeroFull
80
112
 
81
113
  console.log(`[test] orez started, creating tables`)
82
114
 
@@ -93,6 +125,43 @@ describe('orez integration', { timeout: 120000 }, () => {
93
125
  foo_id TEXT
94
126
  );
95
127
  `)
128
+ await ensureTablesInPublications(db, ['foo', 'bar'])
129
+ const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
130
+ if (pubName) {
131
+ const quotedPub = '"' + pubName.replace(/"/g, '""') + '"'
132
+ await db
133
+ .exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public"."foo"`)
134
+ .catch(() => {})
135
+ await db
136
+ .exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public"."bar"`)
137
+ .catch(() => {})
138
+ await installChangeTracking(db)
139
+ }
140
+ await installAllowAllPermissions(db, ['foo', 'bar'])
141
+ expect(await hasNonNullPermissions(db)).toBe(true)
142
+ if (resetZeroFull) {
143
+ await resetZeroFull()
144
+ } else if (restartZero) {
145
+ await restartZero()
146
+ }
147
+ const pubNameAfterReset = process.env.ZERO_APP_PUBLICATIONS?.trim()
148
+ if (pubNameAfterReset) {
149
+ const pubRows = await db.query<{ tablename: string }>(
150
+ `SELECT tablename
151
+ FROM pg_publication_tables
152
+ WHERE pubname = $1
153
+ AND schemaname = 'public'`,
154
+ [pubNameAfterReset]
155
+ )
156
+ expect(pubRows.rows.map((r) => r.tablename)).toEqual(
157
+ expect.arrayContaining(['foo', 'bar'])
158
+ )
159
+ const shardCfg = await db.query<{ publications: string[] }>(
160
+ `SELECT publications FROM "zero_0"."shardConfig" WHERE lock = true`
161
+ )
162
+ expect(shardCfg.rows[0]?.publications || []).toContain(pubNameAfterReset)
163
+ }
164
+ expect(await hasNonNullPermissions(db)).toBe(true)
96
165
 
97
166
  console.log(`[test] tables created, waiting for zero-cache`)
98
167
  // wait for zero-cache to be ready
@@ -116,9 +185,16 @@ describe('orez integration', { timeout: 120000 }, () => {
116
185
  })
117
186
 
118
187
  test('zero-cache starts and accepts websocket connections', async () => {
188
+ const cg = `test-cg-${Date.now()}`
189
+ const cid = `test-client-${Date.now()}`
190
+ const secProtocol = encodeSecProtocols(
191
+ ['initConnection', { desiredQueriesPatch: [] }],
192
+ undefined
193
+ )
119
194
  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`
195
+ `ws://localhost:${zeroPort}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
196
+ `?clientGroupID=${cg}&clientID=${cid}&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
197
+ secProtocol
122
198
  )
123
199
 
124
200
  const connected = new Promise<void>((resolve, reject) => {
@@ -323,26 +399,28 @@ describe('orez integration', { timeout: 120000 }, () => {
323
399
  downstream: Queue<unknown>,
324
400
  query: Record<string, unknown>
325
401
  ): WebSocket {
402
+ const cg = `test-cg-${Date.now()}`
403
+ const cid = `test-client-${Date.now()}`
404
+ const secProtocol = encodeSecProtocols(
405
+ [
406
+ 'initConnection',
407
+ {
408
+ desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
409
+ clientSchema: CLIENT_SCHEMA,
410
+ },
411
+ ],
412
+ undefined
413
+ )
326
414
  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`
415
+ `ws://localhost:${port}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
416
+ `?clientGroupID=${cg}&clientID=${cid}&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
417
+ secProtocol
329
418
  )
330
419
 
331
420
  ws.on('message', (data) => {
332
421
  downstream.enqueue(JSON.parse(data.toString()))
333
422
  })
334
423
 
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
424
  return ws
347
425
  }
348
426
 
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import {
4
+ formatNativeBootstrapInstructions,
5
+ inspectNativeSqliteBinary,
6
+ } from '../sqlite-mode/native-binary.js'
7
+
8
+ describe('native sqlite binary guard', () => {
9
+ test('better_sqlite3.node is present before native integration tests', () => {
10
+ const check = inspectNativeSqliteBinary()
11
+ expect(check.found, formatNativeBootstrapInstructions(check)).toBe(true)
12
+ })
13
+ })
@@ -0,0 +1,44 @@
1
+ import { rmSync } from 'node:fs'
2
+
3
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest'
4
+
5
+ import { startZeroLite } from '../index.js'
6
+
7
+ describe('native sqlite startup integration', { timeout: 120_000 }, () => {
8
+ let shutdown: (() => Promise<void>) | undefined
9
+ let zeroPort = 0
10
+ let dataDir = ''
11
+
12
+ beforeAll(async () => {
13
+ const basePort = 29000 + Math.floor(Math.random() * 1000)
14
+ dataDir = `.orez-native-startup-test-${Date.now()}`
15
+
16
+ const started = await startZeroLite({
17
+ pgPort: basePort,
18
+ zeroPort: basePort + 100,
19
+ dataDir,
20
+ logLevel: 'warn',
21
+ skipZeroCache: false,
22
+ disableWasmSqlite: true,
23
+ })
24
+
25
+ shutdown = started.stop
26
+ zeroPort = started.zeroPort
27
+ })
28
+
29
+ afterAll(async () => {
30
+ if (shutdown) await shutdown()
31
+ if (dataDir) {
32
+ try {
33
+ rmSync(dataDir, { recursive: true, force: true })
34
+ } catch {
35
+ // ignore cleanup failures in test teardown
36
+ }
37
+ }
38
+ })
39
+
40
+ test('zero-cache responds in native mode', async () => {
41
+ const response = await fetch(`http://127.0.0.1:${zeroPort}/`)
42
+ expect([200, 404]).toContain(response.status)
43
+ })
44
+ })