orez 0.1.6 → 0.1.7

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 (53) hide show
  1. package/README.md +185 -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 +89 -45
  11. package/dist/cli.js.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +104 -17
  15. package/dist/index.js.map +1 -1
  16. package/dist/integration/test-permissions.d.ts +5 -0
  17. package/dist/integration/test-permissions.d.ts.map +1 -0
  18. package/dist/integration/test-permissions.js +89 -0
  19. package/dist/integration/test-permissions.js.map +1 -0
  20. package/dist/pg-proxy.js +2 -2
  21. package/dist/pg-proxy.js.map +1 -1
  22. package/dist/replication/change-tracker.d.ts.map +1 -1
  23. package/dist/replication/change-tracker.js +15 -13
  24. package/dist/replication/change-tracker.js.map +1 -1
  25. package/dist/replication/handler.d.ts.map +1 -1
  26. package/dist/replication/handler.js +27 -2
  27. package/dist/replication/handler.js.map +1 -1
  28. package/dist/sqlite-mode/index.d.ts +1 -0
  29. package/dist/sqlite-mode/index.d.ts.map +1 -1
  30. package/dist/sqlite-mode/index.js +1 -0
  31. package/dist/sqlite-mode/index.js.map +1 -1
  32. package/dist/sqlite-mode/native-binary.d.ts +11 -0
  33. package/dist/sqlite-mode/native-binary.d.ts.map +1 -0
  34. package/dist/sqlite-mode/native-binary.js +67 -0
  35. package/dist/sqlite-mode/native-binary.js.map +1 -0
  36. package/package.json +8 -2
  37. package/src/admin/log-store.ts +19 -9
  38. package/src/admin/server.ts +12 -0
  39. package/src/cli.ts +92 -43
  40. package/src/index.ts +117 -18
  41. package/src/integration/integration.test.ts +86 -15
  42. package/src/integration/native-binary.guard.test.ts +13 -0
  43. package/src/integration/native-startup.test.ts +44 -0
  44. package/src/integration/restore-live-stress.test.ts +437 -0
  45. package/src/integration/restore-reset.test.ts +135 -16
  46. package/src/integration/test-permissions.ts +111 -0
  47. package/src/pg-proxy.ts +2 -2
  48. package/src/replication/change-tracker.test.ts +1 -1
  49. package/src/replication/change-tracker.ts +16 -13
  50. package/src/replication/handler.test.ts +2 -2
  51. package/src/replication/handler.ts +30 -2
  52. package/src/sqlite-mode/index.ts +1 -0
  53. package/src/sqlite-mode/native-binary.ts +89 -0
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
 
@@ -126,6 +131,12 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
126
131
  const pidFile = resolve(config.dataDir, 'orez.pid')
127
132
  writeFileSync(pidFile, String(process.pid))
128
133
 
134
+ // write admin port file so pg_restore can find it
135
+ const adminFile = resolve(config.dataDir, 'orez.admin')
136
+ if (adminPort > 0) {
137
+ writeFileSync(adminFile, String(adminPort))
138
+ }
139
+
129
140
  // start pglite (separate instances for postgres, zero_cvr, zero_cdb)
130
141
  const instances = await createPGliteInstances(config)
131
142
  const db = instances.postgres
@@ -191,7 +202,7 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
191
202
  )
192
203
  zeroCacheProcess = result.process
193
204
  zeroEnv = result.env
194
- await waitForZeroCache(zeroConfig)
205
+ await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
195
206
 
196
207
  // start http proxy in front of zero-cache when admin is enabled
197
208
  if (httpLog) {
@@ -247,7 +258,7 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
247
258
  )
248
259
  zeroCacheProcess = result.process
249
260
  zeroEnv = result.env
250
- await waitForZeroCache(zeroConfig)
261
+ await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
251
262
  }
252
263
 
253
264
  // unified reset function for zero state
@@ -312,6 +323,38 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
312
323
  await instances.cvr.waitReady
313
324
  await instances.cdb.waitReady
314
325
  log.orez('CVR/CDB recreated')
326
+
327
+ // remove stale zero shard schemas from upstream; these can outlive CVR/CDB
328
+ // and cause dispatcher errors after full reset.
329
+ const shardSchemas = await db.query<{ schemaname: string }>(
330
+ `SELECT DISTINCT schemaname
331
+ FROM pg_tables
332
+ WHERE tablename IN ('clients', 'replicas', 'mutations')
333
+ AND schemaname NOT IN (
334
+ 'pg_catalog',
335
+ 'information_schema',
336
+ 'pg_toast',
337
+ 'public',
338
+ '_orez'
339
+ )
340
+ AND schemaname NOT LIKE 'pg_%'`
341
+ )
342
+ for (const { schemaname } of shardSchemas.rows) {
343
+ const quoted = '"' + schemaname.replace(/"/g, '""') + '"'
344
+ await db.exec(`DROP SCHEMA IF EXISTS ${quoted} CASCADE`)
345
+ }
346
+ if (shardSchemas.rows.length > 0) {
347
+ log.orez(`dropped ${shardSchemas.rows.length} stale shard schema(s)`)
348
+ }
349
+
350
+ // clear upstream replication tracking so zero-cache starts from a
351
+ // clean change stream baseline after full reset.
352
+ await db.exec(`TRUNCATE _orez._zero_changes`).catch(() => {})
353
+ await db.exec(`TRUNCATE _orez._zero_replication_slots`).catch(() => {})
354
+ await db
355
+ .exec(`ALTER SEQUENCE _orez._zero_watermark RESTART WITH 1`)
356
+ .catch(() => {})
357
+ log.orez('cleared upstream replication tracking state')
315
358
  }
316
359
 
317
360
  // always clean up replica file
@@ -331,12 +374,13 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
331
374
  DATABASE_URL: upstreamUrl,
332
375
  OREZ_PG_PORT: String(config.pgPort),
333
376
  })
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
377
  }
339
378
 
379
+ // always re-install change tracking after a full reset so public table
380
+ // triggers reflect any schema changes introduced by restore.
381
+ log.debug.orez('re-installing change tracking after full reset')
382
+ await installChangeTracking(db)
383
+
340
384
  // restart zero-cache
341
385
  log.orez('starting zero-cache...')
342
386
  // use internal port when http proxy is enabled
@@ -350,7 +394,7 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
350
394
  zeroCacheProcess = result.process
351
395
  zeroEnv = result.env
352
396
 
353
- await waitForZeroCache(zeroConfig)
397
+ await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
354
398
  log.orez(`zero state reset complete (${mode})`)
355
399
  log.zero(`ready ${port(config.zeroPort, 'magenta')}`)
356
400
  } catch (err: any) {
@@ -365,14 +409,22 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
365
409
  }
366
410
  }
367
411
 
368
- // handle SIGUSR1 to reset zero state (sent by pg_restore)
412
+ // handle SIGUSR1 to reset zero state (sent by pg_restore after restore completes)
369
413
  if (!config.skipZeroCache) {
370
414
  process.on('SIGUSR1', () => {
371
- log.orez('received SIGUSR1')
415
+ log.orez('received SIGUSR1 - full reset')
372
416
  resetZeroState('full').catch((err) => {
373
417
  log.orez(`SIGUSR1 reset failed: ${err?.message || err}`)
374
418
  })
375
419
  })
420
+
421
+ // handle SIGUSR2 to quiesce zero-cache (sent by pg_restore before restore starts)
422
+ process.on('SIGUSR2', () => {
423
+ log.orez('received SIGUSR2 - stopping zero-cache for restore')
424
+ killZeroCache().catch((err) => {
425
+ log.orez(`SIGUSR2 stop failed: ${err?.message || err}`)
426
+ })
427
+ })
376
428
  }
377
429
 
378
430
  const stop = async () => {
@@ -388,6 +440,9 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
388
440
  try {
389
441
  unlinkSync(pidFile)
390
442
  } catch {}
443
+ try {
444
+ unlinkSync(adminFile)
445
+ } catch {}
391
446
  log.debug.orez('stopped')
392
447
  }
393
448
 
@@ -402,6 +457,8 @@ export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
402
457
  httpLog,
403
458
  zeroEnv,
404
459
  restartZero: config.skipZeroCache ? undefined : restartZeroCache,
460
+ // stop zero-cache without restart (for pg_restore to safely modify schema)
461
+ stopZero: config.skipZeroCache ? undefined : killZeroCache,
405
462
  // cache-only reset: just replica file (fast, for minor sync issues)
406
463
  resetZero: config.skipZeroCache ? undefined : () => resetZeroState('cache-only'),
407
464
  // full reset: CVR/CDB + replica (for schema changes, used by pg_restore via SIGUSR1)
@@ -594,7 +651,14 @@ async function startZeroCache(
594
651
  const child = spawn(zeroCacheBin, [], {
595
652
  env,
596
653
  stdio: ['ignore', 'pipe', 'pipe'],
597
- })
654
+ }) as ZeroChildProcess
655
+ child.__orezTail = []
656
+
657
+ const pushTail = (line: string) => {
658
+ const tail = child.__orezTail!
659
+ tail.push(line)
660
+ if (tail.length > 80) tail.splice(0, tail.length - 80)
661
+ }
598
662
 
599
663
  // detect log level from zero-cache output
600
664
  const detectLevel = (line: string, fallback: string): string => {
@@ -618,21 +682,28 @@ async function startZeroCache(
618
682
  child.stdout?.on('data', (data: Buffer) => {
619
683
  const lines = data.toString().trim().split('\n')
620
684
  for (const line of lines) {
621
- log.debug.zero(line)
622
- logStore?.push('zero', detectLevel(line, 'info'), line)
685
+ pushTail(`stdout: ${line}`)
686
+ const level = detectLevel(line, 'info')
687
+ if (level === 'warn' || level === 'error') log.zero(line)
688
+ else log.debug.zero(line)
689
+ logStore?.push('zero', level, line)
623
690
  }
624
691
  })
625
692
 
626
693
  child.stderr?.on('data', (data: Buffer) => {
627
694
  const lines = data.toString().trim().split('\n')
628
695
  for (const line of lines) {
629
- log.debug.zero(line)
630
- logStore?.push('zero', detectLevel(line, 'error'), line)
696
+ pushTail(`stderr: ${line}`)
697
+ const level = detectLevel(line, 'error')
698
+ if (level === 'warn' || level === 'error') log.zero(line)
699
+ else log.debug.zero(line)
700
+ logStore?.push('zero', level, line)
631
701
  }
632
702
  })
633
703
 
634
704
  child.on('exit', (code) => {
635
705
  if (code !== 0 && code !== null) {
706
+ pushTail(`exit: code ${code}`)
636
707
  log.zero(`exited with code ${code}`)
637
708
  logStore?.push('zero', 'error', `exited with code ${code}`)
638
709
  }
@@ -643,20 +714,48 @@ async function startZeroCache(
643
714
 
644
715
  async function waitForZeroCache(
645
716
  config: ZeroLiteConfig,
646
- timeoutMs = 60000
717
+ zeroProcess?: ChildProcess | null,
718
+ timeoutMs = 60000,
719
+ sqliteMode: SqliteMode = resolveSqliteMode(config.disableWasmSqlite)
647
720
  ): Promise<void> {
648
721
  const start = Date.now()
649
722
  const url = `http://127.0.0.1:${config.zeroPort}/`
650
723
 
651
724
  while (Date.now() - start < timeoutMs) {
725
+ if (zeroProcess && zeroProcess.exitCode !== null) {
726
+ const tail = (zeroProcess as ZeroChildProcess).__orezTail
727
+ const details = tail?.length ? `\n${tail.slice(-20).join('\n')}` : ''
728
+ throw new Error(
729
+ `zero-cache exited with code ${zeroProcess.exitCode}${details}${nativeStartupDiagnostics(details, sqliteMode)}`
730
+ )
731
+ }
732
+
652
733
  try {
653
- const res = await fetch(url)
654
- if (res.ok) return
734
+ const controller = new AbortController()
735
+ const timer = setTimeout(() => controller.abort(), 1000)
736
+ const res = await fetch(url, { signal: controller.signal })
737
+ clearTimeout(timer)
738
+ // zero may return 404 on "/" while still being healthy.
739
+ if (res.ok || res.status === 404) return
655
740
  } catch {
656
741
  // not ready yet
657
742
  }
658
743
  await new Promise((r) => setTimeout(r, 500))
659
744
  }
660
745
 
661
- log.zero('health check timed out, continuing anyway')
746
+ const tail = (zeroProcess as ZeroChildProcess | null | undefined)?.__orezTail
747
+ const details = tail?.length ? `\n${tail.slice(-20).join('\n')}` : ''
748
+ throw new Error(
749
+ `zero-cache health check timed out after ${timeoutMs}ms${details}${nativeStartupDiagnostics(details, sqliteMode)}`
750
+ )
751
+ }
752
+
753
+ function nativeStartupDiagnostics(details: string, sqliteMode: SqliteMode): string {
754
+ if (sqliteMode !== 'native') return ''
755
+ if (!details) return ''
756
+ if (!hasMissingNativeBinarySignature(details)) return ''
757
+
758
+ const check = inspectNativeSqliteBinary()
759
+ const instructions = formatNativeBootstrapInstructions(check)
760
+ return `\n\nnative sqlite startup diagnostics:\n${instructions}`
662
761
  }
@@ -11,9 +11,21 @@ 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 { installAllowAllPermissions } from './test-permissions.js'
14
16
 
15
17
  import type { PGlite } from '@electric-sql/pglite'
16
18
 
19
+ const SYNC_PROTOCOL_VERSION = 45
20
+
21
+ function encodeSecProtocols(
22
+ initConnectionMessage: unknown,
23
+ authToken: string | undefined
24
+ ): string {
25
+ const payload = JSON.stringify({ initConnectionMessage, authToken })
26
+ return encodeURIComponent(Buffer.from(payload, 'utf-8').toString('base64'))
27
+ }
28
+
17
29
  // simple async queue for collecting websocket messages
18
30
  class Queue<T> {
19
31
  private items: T[] = []
@@ -57,6 +69,7 @@ describe('orez integration', { timeout: 120000 }, () => {
57
69
  let zeroPort: number
58
70
  let pgPort: number
59
71
  let shutdown: () => Promise<void>
72
+ let restartZero: (() => Promise<void>) | undefined
60
73
  let dataDir: string
61
74
 
62
75
  beforeAll(async () => {
@@ -77,6 +90,7 @@ describe('orez integration', { timeout: 120000 }, () => {
77
90
  zeroPort = result.zeroPort
78
91
  pgPort = result.pgPort
79
92
  shutdown = result.stop
93
+ restartZero = result.restartZero
80
94
 
81
95
  console.log(`[test] orez started, creating tables`)
82
96
 
@@ -93,6 +107,22 @@ describe('orez integration', { timeout: 120000 }, () => {
93
107
  foo_id TEXT
94
108
  );
95
109
  `)
110
+ const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
111
+ if (pubName) {
112
+ const quotedPub = '"' + pubName.replace(/"/g, '""') + '"'
113
+ await db
114
+ .exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public"."foo"`)
115
+ .catch(() => {})
116
+ await db
117
+ .exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE "public"."bar"`)
118
+ .catch(() => {})
119
+ await installChangeTracking(db)
120
+ }
121
+ await installAllowAllPermissions(db, ['foo', 'bar'])
122
+ if (restartZero) {
123
+ await restartZero()
124
+ }
125
+ await ensureClientGroup(zeroPort, 'test-cg')
96
126
 
97
127
  console.log(`[test] tables created, waiting for zero-cache`)
98
128
  // wait for zero-cache to be ready
@@ -116,9 +146,14 @@ describe('orez integration', { timeout: 120000 }, () => {
116
146
  })
117
147
 
118
148
  test('zero-cache starts and accepts websocket connections', async () => {
149
+ const secProtocol = encodeSecProtocols(
150
+ ['initConnection', { desiredQueriesPatch: [] }],
151
+ undefined
152
+ )
119
153
  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`
154
+ `ws://localhost:${zeroPort}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
155
+ `?clientGroupID=test-cg&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
156
+ secProtocol
122
157
  )
123
158
 
124
159
  const connected = new Promise<void>((resolve, reject) => {
@@ -323,26 +358,25 @@ describe('orez integration', { timeout: 120000 }, () => {
323
358
  downstream: Queue<unknown>,
324
359
  query: Record<string, unknown>
325
360
  ): WebSocket {
361
+ const secProtocol = encodeSecProtocols(
362
+ [
363
+ 'initConnection',
364
+ {
365
+ desiredQueriesPatch: [{ op: 'put', hash: 'q1', ast: query }],
366
+ },
367
+ ],
368
+ undefined
369
+ )
326
370
  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`
371
+ `ws://localhost:${port}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
372
+ `?clientGroupID=test-cg&clientID=test-client&wsid=ws1&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
373
+ secProtocol
329
374
  )
330
375
 
331
376
  ws.on('message', (data) => {
332
377
  downstream.enqueue(JSON.parse(data.toString()))
333
378
  })
334
379
 
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
380
  return ws
347
381
  }
348
382
 
@@ -423,3 +457,40 @@ async function waitForZero(port: number, timeoutMs = 30000) {
423
457
  }
424
458
  throw new Error(`zero-cache not ready on port ${port} after ${timeoutMs}ms`)
425
459
  }
460
+
461
+ async function ensureClientGroup(port: number, clientGroupID: string): Promise<void> {
462
+ const secProtocol = encodeSecProtocols(
463
+ ['initConnection', { desiredQueriesPatch: [], clientSchema: { tables: {} } }],
464
+ undefined
465
+ )
466
+ await new Promise<void>((resolve, reject) => {
467
+ const ws = new WebSocket(
468
+ `ws://localhost:${port}/sync/v${SYNC_PROTOCOL_VERSION}/connect` +
469
+ `?clientGroupID=${clientGroupID}&clientID=test-client&wsid=ws-bootstrap&schemaVersion=1&baseCookie=&ts=${Date.now()}&lmid=0`,
470
+ secProtocol
471
+ )
472
+
473
+ const timer = setTimeout(() => {
474
+ try {
475
+ ws.close()
476
+ } catch {}
477
+ reject(new Error('client-group bootstrap timeout'))
478
+ }, 7000)
479
+
480
+ ws.once('message', (data) => {
481
+ clearTimeout(timer)
482
+ const msg = JSON.parse(data.toString())
483
+ const isError = Array.isArray(msg) && msg[0] === 'error'
484
+ ws.close()
485
+ if (isError) {
486
+ reject(new Error(`client-group bootstrap failed: ${JSON.stringify(msg)}`))
487
+ } else {
488
+ resolve()
489
+ }
490
+ })
491
+ ws.once('error', (err) => {
492
+ clearTimeout(timer)
493
+ reject(err)
494
+ })
495
+ })
496
+ }
@@ -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
+ })