From 457d64bebab4c9f1a738c2cfb79c047e04c35ee7 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 15:03:13 +0000 Subject: [PATCH 1/2] [#257] Fix plot_count: reconcile storyline after plot upsert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After upserting a plot row, reconcile the parent storyline's plot_count (via COUNT(*)) and last_plot_time (via MAX block_timestamp). Uses idempotent queries — safe for duplicate indexer calls and backfill replays. Applied to both the real-time indexer (index/plot/route.ts) and the backfill cron (cron/backfill/route.ts). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/cron/backfill/route.ts | 26 ++++++++++++++++++++++++++ src/app/api/index/plot/route.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index d767eaf7..39e10a73 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -285,6 +285,32 @@ async function processPlotChained( if (plotError) { throw new Error(`Database error (plot): ${plotError.message}`); } + + // Reconcile parent storyline plot_count and last_plot_time (idempotent) + const storyId = Number(storylineId); + const [{ count }, { data: latestPlot }] = await Promise.all([ + supabase + .from("plots") + .select("*", { count: "exact", head: true }) + .eq("storyline_id", storyId), + supabase + .from("plots") + .select("block_timestamp") + .eq("storyline_id", storyId) + .order("block_timestamp", { ascending: false }) + .limit(1) + .single(), + ]); + + if (count !== null) { + await supabase + .from("storylines") + .update({ + plot_count: count, + ...(latestPlot?.block_timestamp ? { last_plot_time: latestPlot.block_timestamp } : {}), + }) + .eq("storyline_id", storyId); + } } async function processDonation( diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 14c3b579..5205004a 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -133,5 +133,31 @@ export async function POST(req: Request) { return error(`Database error: ${dbError.message}`, 500); } + // Reconcile parent storyline plot_count and last_plot_time (idempotent) + const storyId = Number(storylineId); + const [{ count }, { data: latestPlot }] = await Promise.all([ + supabase + .from("plots") + .select("*", { count: "exact", head: true }) + .eq("storyline_id", storyId), + supabase + .from("plots") + .select("block_timestamp") + .eq("storyline_id", storyId) + .order("block_timestamp", { ascending: false }) + .limit(1) + .single(), + ]); + + if (count !== null) { + await supabase + .from("storylines") + .update({ + plot_count: count, + ...(latestPlot?.block_timestamp ? { last_plot_time: latestPlot.block_timestamp } : {}), + }) + .eq("storyline_id", storyId); + } + return NextResponse.json({ success: true }); } From 44fc414959f148ee083de9e35bc370b30d097063 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 15:05:59 +0000 Subject: [PATCH 2/2] [#257] Extract shared reconcile helper, add error handling - Extract reconcileStorylinePlotCount to lib/reconcile.ts (DRY) - Throws on any Supabase error so callers can handle failures - Indexer route returns 500 on reconciliation failure - Backfill cron propagates error to per-event error counter - Addresses T2a (error handling) and T2b (dedup) review feedback Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/reconcile.ts | 52 ++++++++++++++++++++++++++++++ src/app/api/cron/backfill/route.ts | 26 ++------------- src/app/api/index/plot/route.ts | 30 ++++------------- 3 files changed, 61 insertions(+), 47 deletions(-) create mode 100644 lib/reconcile.ts diff --git a/lib/reconcile.ts b/lib/reconcile.ts new file mode 100644 index 00000000..5155095c --- /dev/null +++ b/lib/reconcile.ts @@ -0,0 +1,52 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "./supabase"; + +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. + * + * Throws on any Supabase error so callers can handle failures. + */ +export async function reconcileStorylinePlotCount( + supabase: SupabaseDB, + storylineId: number, +): Promise { + const [countResult, latestResult] = await Promise.all([ + supabase + .from("plots") + .select("*", { count: "exact", head: true }) + .eq("storyline_id", storylineId), + supabase + .from("plots") + .select("block_timestamp") + .eq("storyline_id", storylineId) + .order("block_timestamp", { ascending: false }) + .limit(1) + .single(), + ]); + + if (countResult.error) { + throw new Error(`Reconcile count error: ${countResult.error.message}`); + } + if (latestResult.error) { + throw new Error(`Reconcile latest plot error: ${latestResult.error.message}`); + } + + if (countResult.count === null) return; + + const { error: updateError } = await supabase + .from("storylines") + .update({ + plot_count: countResult.count, + ...(latestResult.data?.block_timestamp + ? { last_plot_time: latestResult.data.block_timestamp } + : {}), + }) + .eq("storyline_id", storylineId); + + if (updateError) { + throw new Error(`Reconcile update error: ${updateError.message}`); + } +} diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index 39e10a73..f0cbb2e6 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -6,6 +6,7 @@ import { storyFactoryAbi } from "../../../../../lib/contracts/abi"; import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; import { hashContent } from "../../../../../lib/content"; import { detectWriterType } from "../../../../../lib/contracts/erc8004"; +import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; import type { Database } from "../../../../../lib/supabase"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; @@ -287,30 +288,7 @@ async function processPlotChained( } // Reconcile parent storyline plot_count and last_plot_time (idempotent) - const storyId = Number(storylineId); - const [{ count }, { data: latestPlot }] = await Promise.all([ - supabase - .from("plots") - .select("*", { count: "exact", head: true }) - .eq("storyline_id", storyId), - supabase - .from("plots") - .select("block_timestamp") - .eq("storyline_id", storyId) - .order("block_timestamp", { ascending: false }) - .limit(1) - .single(), - ]); - - if (count !== null) { - await supabase - .from("storylines") - .update({ - plot_count: count, - ...(latestPlot?.block_timestamp ? { last_plot_time: latestPlot.block_timestamp } : {}), - }) - .eq("storyline_id", storyId); - } + await reconcileStorylinePlotCount(supabase, Number(storylineId)); } async function processDonation( diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 5205004a..5c6a479c 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -8,6 +8,7 @@ import { } from "../../../../../lib/contracts/abi"; import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; import { hashContent } from "../../../../../lib/content"; +import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; import type { Database } from "../../../../../lib/supabase"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; @@ -134,29 +135,12 @@ export async function POST(req: Request) { } // Reconcile parent storyline plot_count and last_plot_time (idempotent) - const storyId = Number(storylineId); - const [{ count }, { data: latestPlot }] = await Promise.all([ - supabase - .from("plots") - .select("*", { count: "exact", head: true }) - .eq("storyline_id", storyId), - supabase - .from("plots") - .select("block_timestamp") - .eq("storyline_id", storyId) - .order("block_timestamp", { ascending: false }) - .limit(1) - .single(), - ]); - - if (count !== null) { - await supabase - .from("storylines") - .update({ - plot_count: count, - ...(latestPlot?.block_timestamp ? { last_plot_time: latestPlot.block_timestamp } : {}), - }) - .eq("storyline_id", storyId); + try { + await reconcileStorylinePlotCount(supabase, Number(storylineId)); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown reconciliation error"; + console.error(`[index/plot] Reconciliation failed for storyline ${storylineId}: ${msg}`); + return error(`Reconciliation failed: ${msg}`, 500); } return NextResponse.json({ success: true });