diff --git a/backend/scripts/2026-06-08-delete-orphan-compatibility-answers.ts b/backend/scripts/2026-06-08-delete-orphan-compatibility-answers.ts new file mode 100644 index 00000000..3a30c3b0 --- /dev/null +++ b/backend/scripts/2026-06-08-delete-orphan-compatibility-answers.ts @@ -0,0 +1,72 @@ +import chalk from 'chalk' +import {runScript} from './run-script' + +// Deletes "orphaned" compatibility answers — rows whose `question_id` points at +// a compatibility_prompts row that no longer exists. These accumulated because +// neither answer table had a foreign key on question_id, so deleting a prompt +// left its answers behind. Covers both answer tables: +// - compatibility_answers (multiple-choice answers) +// - compatibility_answers_free (free-response answers) +// +// The companion migration +// supabase/migrations/20250101005800_add_compatibility_answers_question_fk.sql +// adds `question_id -> compatibility_prompts(id) ON DELETE CASCADE` so this +// can't recur. That migration also clears orphans inline (a FK can't be added +// while violating rows exist), so this script is mainly for a quick ad-hoc +// cleanup / dry-run audit before the migration is applied. +// +// Dry-run by default: set DELETE=1 in the environment to actually delete. +// Safe to re-run; idempotent. + +const TABLES = ['compatibility_answers', 'compatibility_answers_free'] as const + +runScript(async ({pg}) => { + const dryRun = process.env.DELETE !== '1' + + console.log( + chalk.cyan( + `Finding orphaned compatibility answers (question_id with no matching prompt)… ${ + dryRun ? chalk.yellow('(DRY RUN — set DELETE=1 to delete)') : chalk.red('(DELETING)') + }`, + ), + ) + + let grandTotal = 0 + for (const table of TABLES) { + const orphanCount = await pg.one( + `SELECT count(*)::int AS n + FROM ${table} a + WHERE NOT EXISTS ( + SELECT 1 FROM compatibility_prompts p WHERE p.id = a.question_id + )`, + [], + (r: {n: number}) => r.n, + ) + + console.log(chalk.blue(` ${table}: ${orphanCount} orphaned answer(s).`)) + grandTotal += orphanCount + + if (orphanCount === 0 || dryRun) continue + + const deleted = await pg.result( + `DELETE FROM ${table} a + WHERE NOT EXISTS ( + SELECT 1 FROM compatibility_prompts p WHERE p.id = a.question_id + )`, + [], + (r) => r.rowCount, + ) + console.log(chalk.green(` [DELETED] ${deleted} row(s) from ${table}`)) + } + + if (dryRun) { + console.log( + chalk.yellow(`\nDry run complete — ${grandTotal} orphaned row(s) found, none deleted.`), + ) + } else { + console.log(chalk.cyan('\n── Deletion complete ───────────────────────────────')) + console.log(chalk.green(` Orphans removed : ${grandTotal}`)) + console.log(chalk.cyan('────────────────────────────────────────────────────')) + } + process.exit(0) +}) diff --git a/backend/scripts/2026-06-08-delete-unanswered-compatibility-prompts.ts b/backend/scripts/2026-06-08-delete-unanswered-compatibility-prompts.ts new file mode 100644 index 00000000..054e9430 --- /dev/null +++ b/backend/scripts/2026-06-08-delete-unanswered-compatibility-prompts.ts @@ -0,0 +1,98 @@ +import chalk from 'chalk' +import {runScript} from './run-script' + +// Deletes every row in `compatibility_prompts` answered by fewer than N +// distinct people. +// +// "Answered by a person" means that person's user id appears as a creator_id in +// either answer table for the prompt: +// - compatibility_answers (multiple-choice answers) +// - compatibility_answers_free (free-response answers) +// We count DISTINCT creator_id across both tables (a single person who answered +// in both still counts once), then delete prompts whose count is < N. N defaults +// to 1 (so the default deletes only prompts nobody answered); set THRESHOLD=k to +// delete every prompt with fewer than k distinct answerers. +// +// Deleting a prompt cascades to compatibility_prompts_translations +// (fk_question ON DELETE CASCADE), so its translations are removed too. This +// reclaims DB storage and cuts egress from clients fetching dead prompts. +// +// Dry-run by default: set DELETE=1 in the environment to actually delete. +// Safe to re-run; processed in batches to avoid long-running transactions. + +// See compatibility_prompts_rows.sql and compatibility_answers_rows.csv (private) for backup +// with all the 2800 prompts and answers in case we need to restore some of them. + +const BATCH_SIZE = 500 + +runScript(async ({pg}) => { + const dryRun = process.env.DELETE !== '1' + + const threshold = 2 + + console.log( + chalk.cyan( + `Finding compatibility prompts answered by fewer than ${threshold} people… ${ + dryRun ? chalk.yellow('(DRY RUN — set DELETE=1 to delete)') : chalk.red('(DELETING)') + }`, + ), + ) + + const ids = await pg.map( + `SELECT p.id, p.question, coalesce(ac.answerers, 0)::int AS answerers + FROM compatibility_prompts p + LEFT JOIN ( + SELECT question_id, count(DISTINCT creator_id) AS answerers + FROM ( + -- multiple_choice = -1 means the person skipped the prompt; don't count it + SELECT question_id, creator_id FROM compatibility_answers + WHERE multiple_choice IS DISTINCT FROM -1 + UNION + SELECT question_id, creator_id FROM compatibility_answers_free + WHERE multiple_choice IS DISTINCT FROM -1 + ) u + GROUP BY question_id + ) ac ON ac.question_id = p.id + WHERE coalesce(ac.answerers, 0) <= $1 + ORDER BY p.id`, + [threshold], + (row: {id: string; question: string; answerers: number}) => row, + ) + + console.log( + chalk.blue(`Found ${ids.length} prompt(s) answered by fewer than ${threshold} people.`), + ) + if (ids.length < 2000) { + for (const {id, question, answerers} of ids) { + console.log(chalk.gray(` id=${id} (${answerers} answerer(s)) — ${question}`)) + } + } + + if (ids.length === 0) { + console.log(chalk.green('Nothing to delete.')) + process.exit(0) + } + + if (dryRun) { + console.log(chalk.yellow('\nDry run complete — no rows deleted.')) + process.exit(0) + } + + let totalDeleted = 0 + const allIds = ids.map((r) => r.id) + for (let i = 0; i < allIds.length; i += BATCH_SIZE) { + const batch = allIds.slice(i, i + BATCH_SIZE) + const deleted = await pg.result( + `DELETE FROM compatibility_prompts WHERE id = ANY($1::bigint[])`, + [batch], + (r) => r.rowCount, + ) + totalDeleted += deleted + console.log(chalk.green(` [DELETED] batch of ${deleted} prompt(s)`)) + } + + console.log(chalk.cyan('\n── Deletion complete ───────────────────────────────')) + console.log(chalk.green(` Deleted : ${totalDeleted}`)) + console.log(chalk.cyan('────────────────────────────────────────────────────')) + process.exit(0) +}) diff --git a/backend/scripts/debug_supabase_egress.sql b/backend/scripts/debug_supabase_egress.sql index 6149cd61..f66b68a9 100644 --- a/backend/scripts/debug_supabase_egress.sql +++ b/backend/scripts/debug_supabase_egress.sql @@ -1,4 +1,5 @@ -- Get expensive queries (in terms of total rows) +-- Run in Supabase -> SQL editor -> Export -> Copy as CSV SELECT query, calls, total_exec_time, @@ -6,16 +7,20 @@ SELECT query, 1.0 * total_exec_time / calls AS avg_time FROM pg_stat_statements ORDER BY rows DESC -LIMIT 10; +LIMIT 20; -- Get row size (in bytes) SELECT count(*) as sampled_rows, pg_size_pretty(sum(pg_column_size(t.*))) as total_data_size, - pg_size_pretty(avg(pg_column_size(t.*))) as avg_row_size + pg_size_pretty(avg(pg_column_size(t.*))) as bytes_per_row FROM ( -- Paste your actual query here, but add a LIMIT SELECT * from user_events LIMIT 1000) t; --- Multiply row size by the number of rows to get the get total egress size for each query (since pg_stat_statement was last reset) \ No newline at end of file +-- Multiply row size by the number of rows to get the get total egress size for each query (since pg_stat_statement was last reset) +-- Do this in Supabase Largest Queries.xlsx (private file) + +-- Reset pg_stat_statements (for next time, to have stats starting from today) +SELECT pg_stat_statements_reset(); \ No newline at end of file diff --git a/backend/supabase/compatibility_answers.sql b/backend/supabase/compatibility_answers.sql index 28b40c02..305f273f 100644 --- a/backend/supabase/compatibility_answers.sql +++ b/backend/supabase/compatibility_answers.sql @@ -1,23 +1,31 @@ -CREATE TABLE IF NOT EXISTS compatibility_answers ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - created_time TIMESTAMPTZ DEFAULT now() NOT NULL, - creator_id TEXT NOT NULL, - explanation TEXT, - importance INTEGER NOT NULL, - multiple_choice INTEGER NOT NULL, - pref_choices INTEGER[] NOT NULL, - question_id BIGINT NOT NULL +CREATE TABLE IF NOT EXISTS compatibility_answers +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + explanation TEXT, + importance INTEGER NOT NULL, + multiple_choice INTEGER NOT NULL, + pref_choices INTEGER[] NOT NULL, + question_id BIGINT NOT NULL ); ALTER TABLE compatibility_answers ADD CONSTRAINT compatibility_answers_creator_id_fkey FOREIGN KEY (creator_id) - REFERENCES users(id) + REFERENCES users (id) + ON DELETE CASCADE; + +ALTER TABLE compatibility_answers + ADD CONSTRAINT compatibility_answers_question_id_fkey + FOREIGN KEY (question_id) + REFERENCES compatibility_prompts (id) ON DELETE CASCADE; -- Row Level Security -ALTER TABLE compatibility_answers ENABLE ROW LEVEL SECURITY; +ALTER TABLE compatibility_answers + ENABLE ROW LEVEL SECURITY; ALTER TABLE compatibility_answers ADD CONSTRAINT unique_question_creator diff --git a/backend/supabase/compatibility_answers_free.sql b/backend/supabase/compatibility_answers_free.sql index 79043f2d..953aca0e 100644 --- a/backend/supabase/compatibility_answers_free.sql +++ b/backend/supabase/compatibility_answers_free.sql @@ -14,6 +14,12 @@ ALTER TABLE compatibility_answers_free REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE compatibility_answers_free + ADD CONSTRAINT compatibility_answers_free_question_id_fkey + FOREIGN KEY (question_id) + REFERENCES compatibility_prompts(id) + ON DELETE CASCADE; + -- Row Level Security ALTER TABLE compatibility_answers_free ENABLE ROW LEVEL SECURITY;