Problem
When a storyline/plot tx succeeds on-chain but the indexer API call fails (500, network error, browser crash), the user sees an error with no way to retry. The on-chain tx is permanent but the DB record is missing. The backfill cron will eventually catch it, but it won't have client-side metadata (genre, language, content fallback).
Reference Implementation
dropcast has a battle-tested pattern for this exact scenario:
hooks/useCreateIntent.ts — localStorage-based intent storage with tx hash persistence + retry logic
app/create/page.tsx (lines ~3346–3485) — Recovery modal UI ("Finalize Campaign" / "Resume Creation")
hooks/useFundCampaign.ts — Transaction error parsing (distinguishes user-rejected vs sent-but-failed)
Key pattern (from dropcast):
- Before tx: Save intent to localStorage (content, metadata, indexer route)
- After tx confirms: Persist tx hash to localStorage
- Indexer call: Attempt POST to indexer API
- If indexer fails: Intent stays in localStorage with error + retry count
- On page reload: Recovery UI appears — "Your story was published on-chain but indexing failed. Retry?"
- User clicks retry: Re-sends the indexer POST with the same tx hash + metadata
- On success: Clear localStorage intent, redirect to story page
- 409 / duplicate: Treat as idempotent success (indexer upserts by tx_hash anyway)
Implementation
1. Create src/hooks/usePublishIntent.ts
localStorage-based intent that tracks:
interface PublishIntent {
txHash: string | null;
content: string;
metadata: Record<string, string>; // genre, language, etc.
indexerRoute: string;
uploadKeyPrefix: string;
createdAt: number;
retryCount: number;
lastError: string | null;
}
saveIntent() — called before wallet confirmation
persistTxHash(hash) — called after tx confirms
clearIntent() — called after successful indexing
readPendingIntent() — check for unfinished intents on page load
2. Integrate into usePublish.ts
Wire the intent lifecycle into the existing 5-state flow:
- Before step 2 (confirming):
saveIntent()
- After step 3 (pending → receipt):
persistTxHash(hash)
- After step 4 (indexing success):
clearIntent()
- On indexer error: keep intent, set error
3. Recovery UI on create/chain pages
On mount, check readPendingIntent(). If a pending intent exists (has txHash but indexer never succeeded):
- Show a recovery banner/modal: "Your previous story was published on-chain but indexing failed"
- Show tx hash (linked to explorer)
- "Retry Indexing" button → re-sends POST to indexer with saved metadata
- "Dismiss" button → clears intent (user accepts backfill cron will handle it, minus metadata)
- Max retry count (5) with warning after exceeded
4. Error classification
- User rejected tx: Clear intent immediately (no recovery needed)
- Tx sent but receipt poll failed: Persist intent with
txHash, show recovery
- Tx confirmed, indexer 500/network error: Persist intent, show recovery
- Indexer 409 / duplicate: Treat as success, clear intent
Files to modify
src/hooks/usePublishIntent.ts — new: localStorage intent management
src/hooks/usePublish.ts — integrate intent lifecycle
src/app/create/page.tsx — add recovery UI on mount
src/app/story/[storylineId]/chain/page.tsx — add recovery UI for plot chaining too
Reference files (dropcast — read these for the full pattern)
/Users/cho/Projects/dropcast/hooks/useCreateIntent.ts
/Users/cho/Projects/dropcast/hooks/useFundCampaign.ts
/Users/cho/Projects/dropcast/app/create/page.tsx (lines ~3346–3485)
Acceptance criteria
Problem
When a storyline/plot tx succeeds on-chain but the indexer API call fails (500, network error, browser crash), the user sees an error with no way to retry. The on-chain tx is permanent but the DB record is missing. The backfill cron will eventually catch it, but it won't have client-side metadata (genre, language, content fallback).
Reference Implementation
dropcasthas a battle-tested pattern for this exact scenario:hooks/useCreateIntent.ts— localStorage-based intent storage with tx hash persistence + retry logicapp/create/page.tsx(lines ~3346–3485) — Recovery modal UI ("Finalize Campaign" / "Resume Creation")hooks/useFundCampaign.ts— Transaction error parsing (distinguishes user-rejected vs sent-but-failed)Key pattern (from dropcast):
Implementation
1. Create
src/hooks/usePublishIntent.tslocalStorage-based intent that tracks:
saveIntent()— called before wallet confirmationpersistTxHash(hash)— called after tx confirmsclearIntent()— called after successful indexingreadPendingIntent()— check for unfinished intents on page load2. Integrate into
usePublish.tsWire the intent lifecycle into the existing 5-state flow:
saveIntent()persistTxHash(hash)clearIntent()3. Recovery UI on create/chain pages
On mount, check
readPendingIntent(). If a pending intent exists (has txHash but indexer never succeeded):4. Error classification
txHash, show recoveryFiles to modify
src/hooks/usePublishIntent.ts— new: localStorage intent managementsrc/hooks/usePublish.ts— integrate intent lifecyclesrc/app/create/page.tsx— add recovery UI on mountsrc/app/story/[storylineId]/chain/page.tsx— add recovery UI for plot chaining tooReference files (dropcast — read these for the full pattern)
/Users/cho/Projects/dropcast/hooks/useCreateIntent.ts/Users/cho/Projects/dropcast/hooks/useFundCampaign.ts/Users/cho/Projects/dropcast/app/create/page.tsx(lines ~3346–3485)Acceptance criteria
npm run typecheckpasses