From 114179318ec014d923ce97177cdd3b53be1d92f3 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 2 Apr 2026 16:24:40 +0100 Subject: [PATCH 1/3] [#763] Fix plot count +1: deduplicate plots, add unique constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 00030: deletes duplicate (storyline_id, plot_index) rows keeping the earliest entry, adds unique constraint to prevent recurrence, and re-reconciles all plot counts - Updates all plot upsert calls (indexer, storyline indexer, backfill) to use onConflict: "storyline_id,plot_index" with ignoreDuplicates instead of tx_hash,log_index — prevents duplicate genesis plots - Updates reconcile.ts comment to document the constraint dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/reconcile.ts | 2 ++ src/app/api/cron/backfill/route.ts | 4 +-- src/app/api/index/plot/route.ts | 2 +- src/app/api/index/storyline/route.ts | 2 +- .../00030_dedupe_plots_unique_constraint.sql | 29 +++++++++++++++++++ 5 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/00030_dedupe_plots_unique_constraint.sql diff --git a/lib/reconcile.ts b/lib/reconcile.ts index 5155095c..862b9773 100644 --- a/lib/reconcile.ts +++ b/lib/reconcile.ts @@ -6,6 +6,8 @@ type SupabaseDB = SupabaseClient; /** * Reconcile a storyline's plot_count and last_plot_time from the plots table. * Uses COUNT(*) and MAX(block_timestamp) — idempotent and safe for replays. + * Relies on the unique constraint on (storyline_id, plot_index) to prevent + * duplicate rows that would inflate the count. * * Throws on any Supabase error so callers can handle failures. */ diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index a87a26db..d25233b7 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -302,7 +302,7 @@ async function processStorylineCreated( }; const { error: plotError } = await supabase .from("plots") - .upsert(plotRow, { onConflict: "tx_hash,log_index" }); + .upsert(plotRow, { onConflict: "storyline_id,plot_index", ignoreDuplicates: true }); if (plotError) { throw new Error(`Database error (genesis plot): ${plotError.message}`); } @@ -360,7 +360,7 @@ async function processPlotChained( const { error: plotError } = await supabase .from("plots") - .upsert(row, { onConflict: "tx_hash,log_index" }); + .upsert(row, { onConflict: "storyline_id,plot_index", ignoreDuplicates: true }); if (plotError) { throw new Error(`Database error (plot): ${plotError.message}`); } diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 5831166a..5f411691 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -129,7 +129,7 @@ export async function POST(req: Request) { const { error: dbError } = await supabase.from("plots").upsert( row, - { onConflict: "tx_hash,log_index" } + { onConflict: "storyline_id,plot_index", ignoreDuplicates: true } ); if (dbError) { diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 27aad5ef..257f8359 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -173,7 +173,7 @@ export async function POST(req: Request) { const { error: plotDbError } = await supabase.from("plots").upsert( plotRow, - { onConflict: "tx_hash,log_index" } + { onConflict: "storyline_id,plot_index", ignoreDuplicates: true } ); if (plotDbError) { diff --git a/supabase/migrations/00030_dedupe_plots_unique_constraint.sql b/supabase/migrations/00030_dedupe_plots_unique_constraint.sql new file mode 100644 index 00000000..cb5a3d3e --- /dev/null +++ b/supabase/migrations/00030_dedupe_plots_unique_constraint.sql @@ -0,0 +1,29 @@ +-- Deduplicate plots rows with the same (storyline_id, plot_index), keeping the +-- earliest entry (lowest id). Then add a unique constraint to prevent recurrence. +-- Finally re-reconcile plot_count from the deduplicated data. + +-- Step 1: Delete duplicate rows, keeping the one with the smallest id per pair +DELETE FROM plots +WHERE id NOT IN ( + SELECT MIN(id) + FROM plots + GROUP BY storyline_id, plot_index +); + +-- Step 2: Add unique constraint to prevent future duplicates +ALTER TABLE plots + ADD CONSTRAINT plots_storyline_plot_unique UNIQUE (storyline_id, plot_index); + +-- Step 3: Re-reconcile plot_count and last_plot_time from deduplicated data +UPDATE storylines s +SET plot_count = sub.cnt, + last_plot_time = sub.latest +FROM ( + SELECT storyline_id, + COUNT(*) AS cnt, + MAX(block_timestamp) AS latest + FROM plots + GROUP BY storyline_id +) sub +WHERE s.storyline_id = sub.storyline_id + AND (s.plot_count IS DISTINCT FROM sub.cnt OR s.last_plot_time IS DISTINCT FROM sub.latest); From 283917f18fba0a838db9c27b5bf535a8b4dde59e Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 2 Apr 2026 16:29:54 +0100 Subject: [PATCH 2/3] [#763] Fix T2a review: allow plot indexer to update existing rows Genesis insert paths (storyline indexer, backfill genesis) keep ignoreDuplicates: true since they insert bare rows without title. Plot indexer and backfill plot paths use regular upsert so they can update rows with full data (title, etc.) if a genesis row exists. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/cron/backfill/route.ts | 2 +- src/app/api/index/plot/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index d25233b7..9aa6e17d 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -360,7 +360,7 @@ async function processPlotChained( const { error: plotError } = await supabase .from("plots") - .upsert(row, { onConflict: "storyline_id,plot_index", ignoreDuplicates: true }); + .upsert(row, { onConflict: "storyline_id,plot_index" }); if (plotError) { throw new Error(`Database error (plot): ${plotError.message}`); } diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 5f411691..3790dedb 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -129,7 +129,7 @@ export async function POST(req: Request) { const { error: dbError } = await supabase.from("plots").upsert( row, - { onConflict: "storyline_id,plot_index", ignoreDuplicates: true } + { onConflict: "storyline_id,plot_index" } ); if (dbError) { From 7f9e139fbed9726bcd4241ffb1b4e95355974207 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 2 Apr 2026 16:33:05 +0100 Subject: [PATCH 3/3] [#763] Fix T2a review: prefer richer row when deduplicating plots Migration now uses DISTINCT ON with ordering that prefers rows with non-empty title over bare genesis rows, then latest id as tiebreaker. This preserves title data from the plot indexer instead of keeping the earlier bare genesis row. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../00030_dedupe_plots_unique_constraint.sql | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/supabase/migrations/00030_dedupe_plots_unique_constraint.sql b/supabase/migrations/00030_dedupe_plots_unique_constraint.sql index cb5a3d3e..f2df6ab8 100644 --- a/supabase/migrations/00030_dedupe_plots_unique_constraint.sql +++ b/supabase/migrations/00030_dedupe_plots_unique_constraint.sql @@ -1,13 +1,17 @@ --- Deduplicate plots rows with the same (storyline_id, plot_index), keeping the --- earliest entry (lowest id). Then add a unique constraint to prevent recurrence. +-- Deduplicate plots rows with the same (storyline_id, plot_index), preferring +-- the row with the richest data (non-null title first, then latest id as tiebreaker). +-- Then add a unique constraint to prevent recurrence. -- Finally re-reconcile plot_count from the deduplicated data. --- Step 1: Delete duplicate rows, keeping the one with the smallest id per pair +-- Step 1: For each (storyline_id, plot_index) group, keep the best row: +-- prefer rows with a non-empty title, then the latest id as tiebreaker. DELETE FROM plots WHERE id NOT IN ( - SELECT MIN(id) + SELECT DISTINCT ON (storyline_id, plot_index) id FROM plots - GROUP BY storyline_id, plot_index + ORDER BY storyline_id, plot_index, + (COALESCE(title, '') != '') DESC, + id DESC ); -- Step 2: Add unique constraint to prevent future duplicates