Ctrl K
ComunidadGuías…

scripts/bundle-health.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <app_version_id>
 *   node scripts/bundle-health.mjs <app_id> <version_name>
 *
 * 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 <app_version_id>')
  console.error('       node internal/bundle-health.mjs <app_id> <version_name>')
  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 <app_version_id>')
    console.log('       node scripts/bundle-health.mjs <app_id> <version_name>')
    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)
})