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 d767eaf7..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/"; @@ -285,6 +286,9 @@ async function processPlotChained( if (plotError) { throw new Error(`Database error (plot): ${plotError.message}`); } + + // Reconcile parent storyline plot_count and last_plot_time (idempotent) + 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 14c3b579..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/"; @@ -133,5 +134,14 @@ export async function POST(req: Request) { return error(`Database error: ${dbError.message}`, 500); } + // Reconcile parent storyline plot_count and last_plot_time (idempotent) + 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 }); }