/* * Audit broken app_versions using export files only: * - Broken = missing zip AND missing manifest (from audit_storage exports) * - List broken versions connected to default channel * - List broken versions not referenced by any channel * * Usage: bun scripts/audit_unused_versions.ts */ import { createClient } from '@supabase/supabase-js' import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts' const ENV_FILE = './internal/cloudflare/.env.prod' const OUTPUT_DIR = './tmp/unused_versions' const BROKEN_DEFAULT_OUT = `${OUTPUT_DIR}/broken_versions_default_channel.json` const BROKEN_UNREFERENCED_OUT = `${OUTPUT_DIR}/broken_versions_unreferenced.json` const SUMMARY_OUT = `${OUTPUT_DIR}/summary.json` const MISSING_VERSIONS_IN = './tmp/r2_audit/missing_versions.json' const MISSING_MANIFESTS_IN = './tmp/r2_audit/missing_manifests.json' const PAGE_SIZE = 1000 function loadEnv(filePath: string) { const envText = Bun.file(filePath).text() return envText.then((text) => { const env: Record = {} for (const line of text.split('\n')) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const idx = trimmed.indexOf('=') if (idx <= 0) continue const key = trimmed.slice(0, idx) const value = trimmed.slice(idx + 1) env[key] = value } return env }) } function chunkArray(items: T[], size: number) { const chunks: T[][] = [] for (let i = 0; i < items.length; i += size) { chunks.push(items.slice(i, i + size)) } return chunks } async function loadJsonArray(path: string): Promise { try { const text = await Bun.file(path).text() return JSON.parse(text) } catch { return null } } async function main() { const env = await loadEnv(ENV_FILE) const supabase = createClient( env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, { auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false } }, ) await Bun.write(`${OUTPUT_DIR}/.gitkeep`, '') const missingVersions = await loadJsonArray<{ id: number | string; app_id?: string; owner_org?: string; name?: string; r2_path?: string | null }>(MISSING_VERSIONS_IN) const missingManifests = await loadJsonArray<{ app_version_id: number | string }>(MISSING_MANIFESTS_IN) const missingVersionIds = new Set() if (missingVersions) { for (const row of missingVersions) { const id = Number(row.id) if (!Number.isNaN(id)) missingVersionIds.add(id) } } const missingManifestVersionIds = new Set() if (missingManifests) { for (const row of missingManifests) { const id = Number(row.app_version_id) if (!Number.isNaN(id)) missingManifestVersionIds.add(id) } } if (!missingVersions || !missingManifests) { console.log('\nWarning: missing audit inputs. Run scripts/audit_storage.ts first if you need broken-only filtering.') } // Process only broken versions derived from audit_storage exports. const brokenVersionIdList = Array.from(missingVersionIds) const versionMap = new Map() let totalVersionsLoaded = 0 const versionIdChunks = chunkArray(brokenVersionIdList, PAGE_SIZE) for (const idChunk of versionIdChunks) { const { data, error } = await supabase .from('app_versions') .select('id, app_id, owner_org, name, deleted, r2_path, manifest_count') .in('id', idChunk) if (error) { console.error('Error fetching broken app_versions:', error) process.exit(1) } if (!data || data.length === 0) continue for (const row of data) { versionMap.set(row.id, { app_id: row.app_id, owner_org: row.owner_org, name: row.name, deleted: row.deleted, r2_path: row.r2_path ?? null, manifest_count: row.manifest_count ?? 0, }) } totalVersionsLoaded += data.length } const brokenAppIds = Array.from(new Set(Array.from(versionMap.values()).map(v => v.app_id))) const appMap = new Map() const appIdChunks = chunkArray(brokenAppIds, PAGE_SIZE) for (const appChunk of appIdChunks) { const { data, error } = await supabase .from('apps') .select('app_id, owner_org, default_upload_channel') .in('app_id', appChunk) if (error) { console.error('Error fetching apps:', error) process.exit(1) } if (!data || data.length === 0) continue for (const row of data) { appMap.set(row.app_id, { owner_org: row.owner_org, default_upload_channel: row.default_upload_channel, }) } } const channelVersions = new Set() const channelsByApp = new Map>() for (const appChunk of appIdChunks) { let from = 0 while (true) { const to = from + PAGE_SIZE - 1 const { data, error } = await supabase .from('channels') .select('id, app_id, owner_org, name, version') .in('app_id', appChunk) .range(from, to) if (error) { console.error('Error fetching channels:', error) process.exit(1) } if (!data || data.length === 0) break for (const row of data) { channelVersions.add(row.version) const list = channelsByApp.get(row.app_id) ?? [] list.push({ id: row.id, name: row.name, version: row.version, owner_org: row.owner_org }) channelsByApp.set(row.app_id, list) } from += PAGE_SIZE if (data.length < PAGE_SIZE) break } } const brokenDefaultChannelVersions: Array<{ id: number app_id: string owner_org: string name: string r2_path: string | null channel_id: number channel_name: string }> = [] const brokenUnreferencedVersions: Array<{ id: number; app_id: string; owner_org: string; name: string; r2_path: string | null }> = [] for (const [id, version] of versionMap.entries()) { if (version.deleted) continue const hasManifest = version.manifest_count > 0 const manifestMissing = missingManifestVersionIds.has(id) const isBroken = hasManifest ? manifestMissing : true if (!isBroken) continue const channels = channelsByApp.get(version.app_id) ?? [] const defaultChannelName = appMap.get(version.app_id)?.default_upload_channel const defaultChannel = defaultChannelName ? channels.find(c => c.name === defaultChannelName) : null if (defaultChannel && defaultChannel.version === id) { brokenDefaultChannelVersions.push({ id, app_id: version.app_id, owner_org: version.owner_org, name: version.name, r2_path: version.r2_path, channel_id: defaultChannel.id, channel_name: defaultChannel.name, }) continue } if (!channelVersions.has(id)) { brokenUnreferencedVersions.push({ id, app_id: version.app_id, owner_org: version.owner_org, name: version.name, r2_path: version.r2_path, }) } } const appsWithBroken = new Set() for (const row of brokenDefaultChannelVersions) appsWithBroken.add(row.app_id) for (const row of brokenUnreferencedVersions) appsWithBroken.add(row.app_id) const summary = { generatedAt: new Date().toISOString(), counts: { apps: appMap.size, channels: Array.from(channelsByApp.values()).reduce((acc, list) => acc + list.length, 0), versions: totalVersionsLoaded, brokenDefaultChannelVersions: brokenDefaultChannelVersions.length, brokenUnreferencedVersions: brokenUnreferencedVersions.length, appsWithBrokenVersions: appsWithBroken.size, }, } await Bun.write(BROKEN_DEFAULT_OUT, JSON.stringify(brokenDefaultChannelVersions, null, 2)) await Bun.write(BROKEN_UNREFERENCED_OUT, JSON.stringify(brokenUnreferencedVersions, null, 2)) await Bun.write(SUMMARY_OUT, JSON.stringify(summary, null, 2)) console.log('\n=== Done ===') console.log(`Broken versions on default channel: ${brokenDefaultChannelVersions.length}`) console.log(`Broken versions unreferenced by any channel: ${brokenUnreferencedVersions.length}`) console.log(`Apps with broken versions in lists: ${appsWithBroken.size}`) console.log('Outputs:') console.log(` ${BROKEN_DEFAULT_OUT}`) console.log(` ${BROKEN_UNREFERENCED_OUT}`) console.log(` ${SUMMARY_OUT}`) } await main()