From 2230ef2ae0d9213a3a933a3c755c5a218bdfe9c2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 2 Apr 2026 08:42:37 +0100 Subject: [PATCH 1/3] [#739] Fix plot_count +1 bug: reconcile after genesis plot insertion Root cause: StorylineCreated handler (indexer + backfill) hardcodes plot_count: 1 but never calls reconcileStorylinePlotCount(), unlike the PlotChained handler which does. This causes drift when genesis plots are processed, leading to plot_count being off by +1. Fix: Add reconcileStorylinePlotCount() call after genesis plot upsert in both the indexer (index/storyline/route.ts) and the backfill cron (cron/backfill/route.ts), matching the existing pattern used by the PlotChained handler. Reading Mode was unaffected because it counts actual chapter objects rather than relying on the cached plot_count field. Fixes #739 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/cron/backfill/route.ts | 3 +++ src/app/api/index/storyline/route.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index 955a5cc9..a87a26db 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -307,6 +307,9 @@ async function processStorylineCreated( throw new Error(`Database error (genesis plot): ${plotError.message}`); } + // Reconcile plot_count from actual plots rows (prevents genesis double-count) + await reconcileStorylinePlotCount(supabase, Number(storylineId)); + return { genesisPlotFailed: false }; } diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 1fb9e3e2..27aad5ef 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -12,6 +12,7 @@ import { detectWriterType } from "../../../../../lib/contracts/erc8004"; import { hashContent } from "../../../../../lib/content"; import { GENRES, LANGUAGES } from "../../../../../lib/genres"; import type { Database } from "../../../../../lib/supabase"; +import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; const IPFS_TIMEOUT_MS = 10_000; @@ -179,5 +180,8 @@ export async function POST(req: Request) { return error(`Database error (genesis plot): ${plotDbError.message}`, 500); } + // Reconcile plot_count from actual plots rows (prevents genesis double-count) + await reconcileStorylinePlotCount(supabase, Number(storylineId)); + return NextResponse.json({ success: true }); } From b9afdc3a94d68acf86fba44f9158f67f171cd482 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 2 Apr 2026 08:46:09 +0100 Subject: [PATCH 2/3] [#739] Add one-time reconcile-plots endpoint for existing storylines Adds POST /api/cron/reconcile-plots that iterates all storylines and reconciles plot_count from the actual plots table. Idempotent and safe to run multiple times. Fixes inflated plot_count on already-indexed storylines that were affected before the genesis reconciliation fix. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/cron/reconcile-plots/route.ts | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/app/api/cron/reconcile-plots/route.ts diff --git a/src/app/api/cron/reconcile-plots/route.ts b/src/app/api/cron/reconcile-plots/route.ts new file mode 100644 index 00000000..007adb69 --- /dev/null +++ b/src/app/api/cron/reconcile-plots/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; +import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; +import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; + +/** + * One-time reconciliation endpoint: re-counts plot_count for ALL storylines + * from the plots table. Safe to run multiple times (idempotent). + * + * POST /api/cron/reconcile-plots + */ +export async function POST() { + const supabase = createServerClient(); + if (!supabase) { + return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); + } + + // Fetch all storyline IDs + const { data: storylines, error: fetchError } = await supabase + .from("storylines") + .select("storyline_id") + .eq("contract_address", STORY_FACTORY.toLowerCase()); + + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }); + } + + if (!storylines || storylines.length === 0) { + return NextResponse.json({ reconciled: 0 }); + } + + let reconciled = 0; + const errors: string[] = []; + + for (const s of storylines) { + try { + await reconcileStorylinePlotCount(supabase, s.storyline_id); + reconciled++; + } catch (err) { + errors.push(`storyline ${s.storyline_id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + return NextResponse.json({ reconciled, total: storylines.length, errors }); +} From 40bef026b53b11c351f1f20ee300f1da532e7b96 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Thu, 2 Apr 2026 08:48:14 +0100 Subject: [PATCH 3/3] [#739] Add CRON_SECRET auth guard to reconcile-plots endpoint Matches the same auth pattern used by the backfill cron route. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/cron/reconcile-plots/route.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/api/cron/reconcile-plots/route.ts b/src/app/api/cron/reconcile-plots/route.ts index 007adb69..6a6125f3 100644 --- a/src/app/api/cron/reconcile-plots/route.ts +++ b/src/app/api/cron/reconcile-plots/route.ts @@ -9,7 +9,20 @@ import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; * * POST /api/cron/reconcile-plots */ -export async function POST() { +/** Cron authorization — fail closed in production when CRON_SECRET is unset */ +function verifyCron(req: Request): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) { + return process.env.NODE_ENV !== "production"; + } + const authHeader = req.headers.get("authorization"); + return authHeader === `Bearer ${secret}`; +} + +export async function POST(req: Request) { + if (!verifyCron(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } const supabase = createServerClient(); if (!supabase) { return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });