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.
- package/README.md +186 -225
- package/dist/admin/log-store.d.ts.map +1 -1
- package/dist/admin/log-store.js +17 -6
- package/dist/admin/log-store.js.map +1 -1
- package/dist/admin/server.d.ts +1 -0
- package/dist/admin/server.d.ts.map +1 -1
- package/dist/admin/server.js +10 -0
- package/dist/admin/server.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +96 -46
- 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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +158 -23
- package/dist/index.js.map +1 -1
- package/dist/integration/test-permissions.d.ts +7 -0
- package/dist/integration/test-permissions.d.ts.map +1 -0
- package/dist/integration/test-permissions.js +117 -0
- package/dist/integration/test-permissions.js.map +1 -0
- package/dist/pg-proxy.js +2 -2
- package/dist/pg-proxy.js.map +1 -1
- package/dist/replication/change-tracker.d.ts.map +1 -1
- package/dist/replication/change-tracker.js +15 -13
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +27 -2
- package/dist/replication/handler.js.map +1 -1
- package/dist/sqlite-mode/index.d.ts +1 -0
- package/dist/sqlite-mode/index.d.ts.map +1 -1
- package/dist/sqlite-mode/index.js +1 -0
- package/dist/sqlite-mode/index.js.map +1 -1
- package/dist/sqlite-mode/native-binary.d.ts +11 -0
- package/dist/sqlite-mode/native-binary.d.ts.map +1 -0
- package/dist/sqlite-mode/native-binary.js +67 -0
- package/dist/sqlite-mode/native-binary.js.map +1 -0
- package/dist/sqlite-mode/package-resolve.d.ts +6 -0
- package/dist/sqlite-mode/package-resolve.d.ts.map +1 -0
- package/dist/sqlite-mode/package-resolve.js +20 -0
- package/dist/sqlite-mode/package-resolve.js.map +1 -0
- package/dist/sqlite-mode/resolve-mode.d.ts +12 -7
- package/dist/sqlite-mode/resolve-mode.d.ts.map +1 -1
- package/dist/sqlite-mode/resolve-mode.js +27 -23
- package/dist/sqlite-mode/resolve-mode.js.map +1 -1
- package/package.json +8 -2
- package/src/admin/log-store.ts +19 -9
- package/src/admin/server.ts +12 -0
- package/src/cli.ts +99 -44
- package/src/config.ts +2 -0
- package/src/index.ts +186 -24
- package/src/integration/integration.test.ts +93 -15
- package/src/integration/native-binary.guard.test.ts +13 -0
- package/src/integration/native-startup.test.ts +44 -0
- package/src/integration/restore-live-stress.test.ts +433 -0
- package/src/integration/restore-reset.test.ts +136 -20
- package/src/integration/test-permissions.ts +147 -0
- package/src/pg-proxy.ts +2 -2
- package/src/replication/change-tracker.test.ts +1 -1
- package/src/replication/change-tracker.ts +16 -13
- package/src/replication/handler.test.ts +2 -2
- package/src/replication/handler.ts +30 -2
- package/src/sqlite-mode/index.ts +1 -0
- package/src/sqlite-mode/native-binary.ts +89 -0
- package/src/sqlite-mode/package-resolve.ts +17 -0
- package/src/sqlite-mode/resolve-mode.ts +31 -21
- 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
|
-
//
|
|
111
|
-
let sqliteMode = resolveSqliteMode(config.disableWasmSqlite)
|
|
112
|
-
let sqliteModeConfig = resolveSqliteModeConfig(
|
|
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
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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
|
|
654
|
-
|
|
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
|
-
|
|
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/
|
|
121
|
-
`?clientGroupID
|
|
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/
|
|
328
|
-
`?clientGroupID
|
|
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
|
+
})
|