'use strict'; /** * RELEASE-AUTHORIZED model_snapshots INSERT CONTRACT — drift verifier. * * The committed contract is derived from the MIGRATION CHAIN, and it is the * RELEASE AUTHORITY: it says which columns this release is permitted to write. * Production is NOT the authority. A column that exists in production but in no * migration is drift, and drift does not silently become permission — that is * how an accidental artifact turns into schema. * * Classification: * RELEASE COLUMN MISSING IN PROD -> HARD FAILURE (exit 1). Retention will * 400 on the whole batch. * PROD-ONLY COLUMN -> DRIFT WARNING / RECORDED DEBT (exit 0). * OUTBOUND INSERT KEY OUTSIDE * THE RELEASE CONTRACT -> CONTRACT FAILURE. Enforced by * tests/unit/retentionSchemaContract.test.js * against the real .upsert() payload. * * This script is READ-ONLY. It never rewrites the contract from live schema. * Regenerating is a deliberate act: scripts/generate-schema-contract.js against * a disposable database with the migration chain applied. * * SUPABASE_URL=... SUPABASE_SERVICE_KEY=... node scripts/verify-schema-contract.js */ const fs = require('fs'); const path = require('path'); const { createClient } = require('@supabase/supabase-js'); const TABLE = process.env.SCHEMA_TABLE || 'model_snapshots'; async function main() { const contract = JSON.parse(fs.readFileSync( path.join(__dirname, '..', 'supabase', 'schema', `${TABLE}.columns.json`), 'utf8')); const url = process.env.SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) throw new Error('SUPABASE_URL + service key required'); const sb = createClient(url, key, { auth: { persistSession: false } }); // One row is enough to learn the live column set from the response shape. const { data, error } = await sb.from(TABLE).select('*').limit(1); if (error) throw new Error(`live read failed: ${error.message}`); if (!data || data.length === 0) throw new Error(`${TABLE} is empty — cannot infer live columns`); const live = new Set(Object.keys(data[0])); const missingInProd = contract.columns.filter((c) => !live.has(c)); const prodOnly = [...live].filter((c) => !contract.columns.includes(c)).sort(); console.log(`RELEASE-AUTHORIZED INSERT CONTRACT (${TABLE})`); console.log(` release columns (migration-derived) : ${contract.columns.length}`); console.log(` live production columns : ${live.size}`); console.log(` PROD-ONLY COLUMN (drift/debt) : ${prodOnly.length}${prodOnly.length ? ` -> ${prodOnly.join(', ')}` : ''}`); console.log(` RELEASE COLUMN MISSING IN PROD : ${missingInProd.length}${missingInProd.length ? ` -> ${missingInProd.join(', ')}` : ''}`); if (prodOnly.length) { console.log(' NOTE: prod-only columns are RECORDED DEBT. They are NOT release-authorized'); console.log(' and must not be added to the contract from live schema.'); } if (missingInProd.length) { console.error(' HARD FAILURE: a release-authorized column does not exist in production.'); process.exit(1); } console.log(' RESULT: PASS (no release column missing in production)'); process.exit(0); } if (require.main === module) { main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); }); }