#!/usr/bin/env node /** * Bundle Health Checker * * Checks whether all manifest files for a given bundle actually exist in R2. * Spoofs the manifest download flow that devices perform, identifying files * that would cause download_manifest_file_fail on real devices. * * Usage: * node scripts/bundle-health.mjs * node scripts/bundle-health.mjs * * Examples: * node scripts/bundle-health.mjs 12345 * node scripts/bundle-health.mjs com.example.app 1.2.3 */ import { config } from 'dotenv' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { writeFileSync } from 'fs' import pg from 'pg' import { S3Client } from '@bradenmacdonald/s3-lite-client' const __dirname = dirname(fileURLToPath(import.meta.url)) config({ path: resolve(__dirname, '../internal/cloudflare/.env.prod') }) // ── Config ─────────────────────────────────────────────────────────────────── const DB_URL = process.env.MAIN_SUPABASE_DB_URL const S3_ENDPOINT = process.env.S3_ENDPOINT const S3_REGION = process.env.S3_REGION || 'auto' const S3_BUCKET = process.env.S3_BUCKET const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY const CONCURRENCY = 20 for (const [name, val] of Object.entries({ DB_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY })) { if (!val) { console.error(`Missing env var: ${name}`) process.exit(1) } } // ── Clients ────────────────────────────────────────────────────────────────── const pool = new pg.Pool({ connectionString: DB_URL, ssl: { rejectUnauthorized: false } }) const s3 = new S3Client({ endPoint: `https://${S3_ENDPOINT}`, accessKey: S3_ACCESS_KEY_ID, secretKey: S3_SECRET_ACCESS_KEY, region: S3_REGION, bucket: S3_BUCKET, pathStyle: true, }) // ── Resolve bundle ─────────────────────────────────────────────────────────── async function resolveAppVersionId(args) { if (args.length === 1 && /^\d+$/.test(args[0])) { return Number(args[0]) } if (args.length === 2) { const [appId, versionName] = args const res = await pool.query( `SELECT id FROM app_versions WHERE app_id = $1 AND name = $2 AND deleted = false LIMIT 1`, [appId, versionName], ) if (res.rows.length === 0) { console.error(`No bundle found for app_id="${appId}" version_name="${versionName}"`) process.exit(1) } return res.rows[0].id } console.error('Usage: node internal/bundle-health.mjs ') console.error(' node internal/bundle-health.mjs ') process.exit(1) } // ── Fetch manifest entries ─────────────────────────────────────────────────── async function fetchManifest(appVersionId) { const res = await pool.query( `SELECT id, file_name, file_hash, s3_path, file_size FROM manifest WHERE app_version_id = $1 ORDER BY file_name`, [appVersionId], ) return res.rows } // ── Fetch bundle metadata ──────────────────────────────────────────────────── async function fetchBundleMeta(appVersionId) { const res = await pool.query( `SELECT av.id, av.name, av.app_id, av.r2_path, av.checksum, av.manifest_count, av.created_at, av.deleted FROM app_versions av WHERE av.id = $1`, [appVersionId], ) if (res.rows.length === 0) { console.error(`app_version_id ${appVersionId} not found`) process.exit(1) } return res.rows[0] } // ── Check R2 file existence via HEAD ───────────────────────────────────────── async function checkFileInR2(s3Path) { try { const stat = await s3.statObject(s3Path, { headers: { 'Accept-Encoding': 'identity' }, }) const size = Number.isFinite(stat.size) ? stat.size : 0 return { exists: true, size, error: null } } catch (err) { const status = err?.statusCode ?? err?.status ?? null if (status === 404) { return { exists: false, size: 0, error: 'NOT_FOUND' } } return { exists: false, size: 0, error: `${err.message || err}` } } } // ── Run checks with bounded concurrency ────────────────────────────────────── async function checkAllFiles(entries) { const total = entries.length const results = new Array(total) let cursor = 0 let done = 0 let missingCount = 0 const isTTY = process.stderr.isTTY function printProgress() { const pct = ((done / total) * 100).toFixed(1) const msg = ` Checking: ${done}/${total} (${pct}%) — ${missingCount} missing so far` if (isTTY) { process.stderr.write(`\r${msg}`) } else if (done % 200 === 0 || done === total) { console.log(msg) } } async function worker() { while (cursor < entries.length) { const idx = cursor++ const entry = entries[idx] const r2Result = await checkFileInR2(entry.s3_path) results[idx] = { ...entry, r2_exists: r2Result.exists, r2_size: r2Result.size, r2_error: r2Result.error, size_mismatch: r2Result.exists && entry.file_size > 0 && r2Result.size !== entry.file_size, } if (!r2Result.exists) missingCount++ done++ printProgress() } } const workers = Array.from({ length: Math.min(CONCURRENCY, entries.length) }, () => worker()) await Promise.all(workers) if (isTTY) process.stderr.write('\n') return results } // ── Main ───────────────────────────────────────────────────────────────────── async function main() { const args = process.argv.slice(2) if (args.length === 0 || args.includes('--help') || args.includes('-h')) { console.log('Usage: node scripts/bundle-health.mjs ') console.log(' node scripts/bundle-health.mjs ') process.exit(0) } const appVersionId = await resolveAppVersionId(args) // 1. Bundle metadata const meta = await fetchBundleMeta(appVersionId) console.log('\n── Bundle Metadata ──────────────────────────────────────') console.log(` ID: ${meta.id}`) console.log(` App: ${meta.app_id}`) console.log(` Version: ${meta.name}`) console.log(` R2 path: ${meta.r2_path ?? '(none)'}`) console.log(` Checksum: ${meta.checksum ?? '(none)'}`) console.log(` Manifest count: ${meta.manifest_count ?? 0}`) console.log(` Created: ${meta.created_at}`) console.log(` Deleted: ${meta.deleted}`) // 2. Check full bundle zip in R2 if (meta.r2_path) { console.log('\n── Bundle Zip ──────────────────────────────────────────') const zipResult = await checkFileInR2(meta.r2_path) if (zipResult.exists) { console.log(` Zip exists: YES (${(zipResult.size / 1024).toFixed(1)} KB)`) } else { console.log(` Zip exists: NO (${zipResult.error})`) } } // 3. Fetch manifest entries const entries = await fetchManifest(appVersionId) if (entries.length === 0) { console.log('\n No manifest entries found. This bundle uses zip-only delivery.') await pool.end() return } console.log(`\n── Manifest Health Check (${entries.length} files) ─────────────────`) // 4. Check all files in R2 const startMs = Date.now() const results = await checkAllFiles(entries) const elapsedMs = Date.now() - startMs // 5. Classify results const missing = results.filter(r => !r.r2_exists) const sizeMismatch = results.filter(r => r.size_mismatch) const zeroSizeDb = results.filter(r => r.file_size === 0 || r.file_size === null) const healthy = results.filter(r => r.r2_exists && !r.size_mismatch) // 6. Report console.log(` Checked ${results.length} files in ${(elapsedMs / 1000).toFixed(1)}s`) console.log(` Healthy: ${healthy.length}`) console.log(` Missing in R2: ${missing.length}`) console.log(` Size mismatch: ${sizeMismatch.length}`) console.log(` DB size = 0: ${zeroSizeDb.length} (trigger may not have run yet)`) if (missing.length > 0) { console.log('\n── MISSING FILES (would cause download_manifest_file_fail) ──') for (const f of missing) { console.log(` ✗ ${f.file_name}`) console.log(` s3_path: ${f.s3_path}`) console.log(` file_hash: ${f.file_hash}`) console.log(` error: ${f.r2_error}`) } } if (sizeMismatch.length > 0) { console.log('\n── SIZE MISMATCHES (may cause download_manifest_checksum_fail) ──') for (const f of sizeMismatch) { console.log(` ! ${f.file_name}`) console.log(` s3_path: ${f.s3_path}`) console.log(` DB size: ${f.file_size} bytes`) console.log(` R2 size: ${f.r2_size} bytes`) } } // 7. Write CSV of missing files if (missing.length > 0) { const csvName = `bundle-health-${meta.app_id}-${meta.name.replace(/[^a-zA-Z0-9._+-]/g, '_')}.csv` const csvPath = resolve(__dirname, csvName) const header = 'file_name,file_hash,s3_path,db_file_size,error' const rows = missing.map(f => [f.file_name, f.file_hash, f.s3_path, f.file_size ?? 0, f.r2_error] .map(v => `"${String(v).replace(/"/g, '""')}"`) .join(','), ) writeFileSync(csvPath, [header, ...rows].join('\n') + '\n') console.log(`\n CSV written: ${csvPath}`) } // 8. Summary verdict console.log('\n── Verdict ─────────────────────────────────────────────') if (missing.length === 0 && sizeMismatch.length === 0) { console.log(' ✓ HEALTHY — all manifest files exist in R2') } else if (missing.length > 0) { console.log(` ✗ UNHEALTHY — ${missing.length}/${results.length} files missing in R2`) console.log(' Devices downloading this bundle via manifest will hit download_manifest_file_fail') } else { console.log(` ! WARNING — ${sizeMismatch.length} files have size mismatches`) console.log(' May cause download_manifest_checksum_fail on devices') } console.log() await pool.end() process.exit(missing.length > 0 ? 1 : 0) } main().catch((err) => { console.error('Fatal:', err) process.exit(2) })