RSS Amplifier

Radu Ciocan · Mar 1, 2026

Gotchas from Refactoring a ~50k lines of code in 12 hours using Claude Code.

0
Sign in to vote or save

Radu Ciocan · Radu Ciocan

TL;DR: How I used Claude Code to migrate ~50kLOC (tRPC + Drizzle + 3 CF Workers) to Convex in ~12h. The agent workflow that worked: have one agent write the plan, a fresh one review it, another implement, then a fresh one audit — repeat 3 times. Write tests for the old code before deleting it (they become the spec). The agent is excellent at mechanical translation but will forget middleware, drop validations, and skip ~35% of side effects. The audit cycles aren’t overhead — they’re the actual QA process.

I recently migrated SICAP.pro — a Romanian public procurement monitoring platform — from a tRPC + Drizzle (PlanetScale MySQL) stack to Convex. The project started in January 2024 and was almost entirely handwritten. Looking back, that already feels like an ancient way to build software.

The monorepo is around 50k lines of business logic spread across multiple domains: a Next.js app, Cloudflare Workers, a data indexer, email templates, and shared packages. Here’s what I learned migrating all of it in roughly 12 hours of focused work.

The stack before migration:

  • 42 tRPC procedures across 7 routers (alerts, notices, payments, suppliers, CPV codes, admin CMS, main app data)

  • 16 MySQL tables on PlanetScale, managed with Drizzle ORM

  • Vercel Workflows for webhook processing (Clerk + Stripe events, each with multi-step retry and durable execution)

  • 3 Cloudflare Workers: a WebSocket service with Durable Objects for real-time push, a matcher worker with its own Workflows for alert matching, and a document download proxy with R2 caching

  • A Hono API bridge — the matcher worker couldn’t access the database directly, so it made 6+ HTTP round-trips per alert through a bridge API baked into a Next.js route

  • Upstash Redis for rate limiting

  • React Query + Zustand for frontend state management

It worked. It was also a lot of plumbing.

Inter-service communication was... elaborate:

Every arrow is a secret to manage, an endpoint to maintain, and a failure mode to handle.

Less boilerplate. tRPC is excellent, but setting up the middleware chain (auth → rate limiting → subscription check → permission check), the transformer config, the React Query integration, and the separate server/client callers adds up. Multiply that by needing the same patterns in Cloudflare Workers that can’t even access your database, and you’re spending more time on infrastructure than features.

AI coding agents understand Convex well. This matters more than you’d think. Convex’s patterns are well-represented in LLM training data, and the documentation is comprehensive. Claude Code generated correct Convex functions on the first try far more often than it did with my custom tRPC middleware chain. When your AI pair programmer groks your framework, you ship faster.

Convex is open source. The runtime, the client libraries, and the components are all open. That removes a category of risk.

Batteries included. The things I was assembling from 5 different services — real-time subscriptions (replaces WebSocket + Durable Objects), Stripe integration (replaces 6 DB tables + 400 lines of sync code), workflow orchestration (replaces Vercel Workflows), rate limiting (replaces Upstash Redis), scheduled jobs (replaces Cloudflare cron) — Convex ships all of these as composable components.

Gone:

  • apps/matcher/ — replaced by Convex cron jobs with direct DB access

  • apps/wss/ — replaced by Convex’s built-in real-time reactivity

  • packages/db/ — entire Drizzle ORM layer, 16 table schemas, 25+ action functions

  • All tRPC infrastructure (routers, middleware, server/client callers)

  • Upstash Redis, Vercel Workflows SDK

  • ~15 environment variables

The matcher is the biggest win architecturally:

No network auth needed between services. No bridge API. No secrets for inter-service communication.

This is the part I wish someone had written up before I started. The migration itself isn’t conceptually hard — you’re moving business logic from one framework to another. The challenge is doing it completely and correctly across 40+ procedures, 16 tables, and a dozen webhook handlers.

Here’s the workflow that actually worked:

Start a session and ask the agent to analyze the existing codebase and write a detailed migration plan to a markdown file. Let it read every router, every schema file, every webhook handler.

Then open a new session and ask a fresh agent to review and improve the plan. The second agent catches assumptions the first one baked in. I ended up with a plan that mapped every tRPC procedure to its Convex equivalent, every webhook to its handler, and every database table to its new schema.

Write it all to a doc. I kept mine in docs/legacy/migration/convex-migration-plan.md. This becomes your source of truth.

Begin with the Convex functions that replace your tRPC procedures. The agent will work through them and stop at checkpoints to ask for feedback. Let it run.

Here’s the critical gotcha: after the agent finishes, open a new session and ask a different agent to audit the work.

Due to context window compactions during long sessions, the first agent will:

  • Forget to implement exact business logic from the original (subtle differences in error handling, missing validation rules)

  • Skip procedures entirely (I had 2 procedures just missing after the first pass)

  • Implement a simplified version that looks right but behaves differently

So the workflow is:

  1. Agent 1: implement backend migration

  2. /clear → Agent 2: audit — “check if all 42 tRPC procedures are covered and use the same business logic”

  3. Agent 2 writes findings to a markdown report

  4. /clear → Agent 3: implement fixes from the report

  5. Repeat steps 2-4 until the audit comes back clean

I went through 3 full audit cycles before the gap analysis showed zero missing procedures and zero business logic mismatches.

Every audit should produce a markdown file. My migration generated:

  • convex-trpc-gap-analysis.md — 25 issues across critical/high/medium/low

  • trpc-convex-parity-report.md — full 42-procedure matrix with match status

  • convex-trpc-migration-report.md — all issues with fix status

  • webhook-parity-report.md — 35 issues across Clerk and Stripe handlers

  • frontend-trpc-to-convex-audit.md — hooks still using tRPC

  • matcher-migration-analysis.md — Cloudflare Worker vs Convex comparison

  • data-migration-analysis.md — table-by-table migration status

These reports are invaluable. They’re the diff between “I think we migrated everything” and “here’s a signed-off checklist proving it.”

Across my audits, the bugs fell into predictable categories:

Missing middleware. tRPC’s middleware chain (auth → rate limit → subscription → permissions) is declarative — you define it once and every procedure gets it. In Convex, each function needs to call these checks explicitly. The agent forgot rate limiting in every single function on the first pass. It forgot subscription checks in 10+ functions.

Validation gaps. Zod schemas like z.string().min(3).max(30) became bare v.string() in Convex validators. The agent either forgot runtime validation entirely or implemented it inconsistently.

Subtle logic inversions. One example: the enterprise plan alert limit. tRPC treated undefined in the limits table as “unlimited.” The agent wrote const limit = ALERT_LIMITS[productId] ?? 0, which made enterprise users unable to create any alerts because 0 means “zero allowed.”

Missing external service calls. The agent would port the core DB logic but skip the PostHog event, the Loops contact update, or the Customer.io sync that happened alongside it. In the Stripe webhook handlers alone, the first pass was missing PostHog, Customer.io, and Loops integrations for every single event — about 35% of the actual work.

Return shape mismatches. tRPC returns { id, createdAt: Date }. Convex returns { _id, _creationTime: number }. The agent sometimes adapted the frontend, sometimes didn’t.

Here’s the thing: the original codebase had zero tests for the tRPC procedures. None. It was handwritten over months, and testing was always “I’ll get to it later.”

Before touching any Convex code, I asked the agent to write tests for the existing tRPC procedures first. This sounds backwards — why write tests for code you’re about to delete? — but it’s the single best decision I made during the migration. Those tests became the specification for what the Convex implementation needed to do.

The agent designed a contract testing pattern that made this work cleanly:

The key insight is the TestAdapter interface. It defines every operation the test suite needs — alerts.create(), notices.search(), payments.createCustomer(), etc. — without knowing whether tRPC or Convex is behind it:

Then each contract file defines the actual test behavior in a framework-agnostic way:

Notice the error matcher regex: /intre 3|3 caractere|too_small|min/i. It accepts both tRPC’s Zod error format and Convex’s ConvexError message format. The contracts don’t care which framework throws the error — they just verify the behavior.

The Convex test files are thin wrappers that wire contracts to the convex-test adapter:

The result: 7 contract files, 7 Convex test files, ~1,200 lines of tests covering all 42 procedures plus permission checks and user isolation. The Convex adapter uses convex-test (Convex’s official test harness) with mocked external services (Elasticsearch, Stripe, Clerk).

The tests caught real bugs. The ?? 0 enterprise plan issue? Caught by a permission test. Missing subscription checks on admin procedures? Caught by the authentication contract. The agent’s tendency to drop validation constraints when porting from Zod to Convex validators? Caught by the input validation tests.

Writing tests for code you’re about to delete feels wasteful. It’s not. Those tests are the only thing that tells you the new code does the same thing as the old code.

The raw numbers look dramatic, but the 79k deletions include a lot of non-business-logic code: Drizzle migration files, generated SQL schemas, lock file churn, and boilerplate that accumulated over two years. The actual business logic deleted was closer to the ~30k LOC the project had — tRPC routers, middleware, Cloudflare Workers, database actions, webhook handlers. What matters more is the ratio: 21k lines of Convex code replaced all of it, and the new code does more (real-time reactivity, durable workflows, automatic Stripe sync) with less plumbing.

~12 hours logged in WakaTime. For context, the original codebase took months to build. Even accounting for the fact that you’re not designing from scratch, manually rewriting 50 procedures + 16 webhook handlers + the frontend hooks would have taken weeks.

Half a billion tokens. $250 in API costs (which I didn’t actually pay since I’m on the Max plan). The cache read ratio is wild — 458M cached tokens out of 475M total means the agent was referencing the same code over and over, which makes sense when you’re doing iterative audit cycles.

A few years ago, this migration would have been a multi-week project for a senior engineer. Now it’s a weekend with an AI pair programmer. The cost and time is a tiny fraction of what it would have been even two years ago.

This is the biggest architectural difference from tRPC. In tRPC, a query procedure can call Elasticsearch, format the results, and return everything in one round trip. In Convex, query functions can only read from the Convex database. Anything external (Elasticsearch, Stripe API, etc.) must be an action.

This means patterns like “load user’s alerts from DB + load notice details from Elasticsearch” become two calls: a reactive query for the DB data and an action for the external data. Your frontend needs to orchestrate both.

Not a deal-breaker, but it changes how you think about data loading.

Convex mutations are sequential. There’s no INSERT INTO ... VALUES (row1), (row2), (row3). Each write is a separate ctx.db.insert() or ctx.db.patch().

For an operation like “mark all 300 notices as read,” the tRPC version runs 1 SQL UPDATE. The Convex version runs 300 individual patches. This is the idiomatic Convex pattern, and it’s fast enough for our scale, but it’s something to watch if you have bulk operations on large datasets.

Convex’s rate limiter can only run inside mutations (it needs to write to the database). This means reactive queries — the ones that auto-update when data changes — can’t be rate limited at the function level. For us, this means alert list and admin queries have no rate limiting, while all mutations and actions do.

Going from MySQL foreign keys to Convex document references is mostly painless, but there are sharp edges:

  • No JOIN operations. If you need data from two tables, you make two queries and join in application code.

  • No ON DUPLICATE KEY UPDATE. Instead, you check for existence first, then insert or patch. This is fine but verbose.

  • No GROUP BY or aggregate functions. If you need counts or sums, you either pre-compute them in mutations (using @convex-dev/aggregate) or scan and count in application code.

  • Convex document IDs (_id) are different from your old primary keys. Every foreign key reference needs remapping during data migration.

The @convex-dev/stripe component eliminated roughly 1,200 lines of code:

  • 5 database table definitions (products, prices, customers, subscriptions, checkouts)

  • 25+ database action functions for syncing Stripe data

  • Webhook signature verification boilerplate

  • Audit logging for sync operations

All of this becomes “install the component, point Stripe webhooks at it, done.” You only write custom handlers for side effects — updating Clerk metadata, sending analytics events, triggering email sequences.

The @convex-dev/workflow component is a direct replacement for Vercel’s Workflow SDK. Every webhook handler I had — Clerk user lifecycle, Stripe subscription state machine, invoice processing — was a multi-step workflow with per-step retry and exponential backoff. The pattern maps 1:1: each workflow.define() call replaces a workflow file, each step gets its own retry config, and FatalError (non-retryable) becomes throwing with retry disabled on that step.

The bigger win is for the matcher. The old Cloudflare Worker ran an 8-step workflow pipeline (fetch users, fetch alerts, search ES, dedup, insert notices, fetch details, send emails, push notifications) with per-step durability — if step 5 failed, steps 1-4 didn’t re-execute. Without the workflow component, the Convex matcher was a single internalAction with try/catch blocks. Transient failures (ES timeout, Resend rate limit) would silently skip alerts with no retry. Converting to @convex-dev/workflow restored durable per-step execution, and combining it with @convex-dev/workpool added parallel user processing with controlled concurrency — something the old sequential Cloudflare Worker never had.

The single most satisfying part of the migration: deleting the entire WebSocket infrastructure. No more Durable Objects, no more connection management, no push protocol, no WebSocketProvider in the React tree.

When the matcher inserts new notices into Convex, any component using useQuery(api.notices.list) automatically re-renders with the new data. That’s it. The entire apps/wss/ directory and its Cloudflare Worker deployment pipeline just vanished.

Without hesitation. The resulting codebase is dramatically simpler. The number of services went from 8 to 4. The number of environment variables dropped by 15. The inter-service communication diagram went from a web of Bearer tokens to “Convex talks to itself internally.”

The migration workflow with Claude Code — plan, implement, audit, fix, repeat — is replicable. The key insight is that AI agents are excellent at the mechanical translation work but unreliable at preserving every edge case in business logic. The audit cycles aren’t overhead; they’re the actual quality assurance process.

If you’re considering a similar migration, my advice:

  1. Write the plan as a document. Don’t keep it in your head or in a chat window.

  2. Budget 3x the audit cycles you think you need. I thought one pass would be enough. It took three.

  3. Treat the migration reports as deliverables. They’re proof that the migration is complete, not just paperwork.

  4. Start with the backend. Get every procedure working before touching the frontend. The frontend migration is mechanical once the backend is solid.

  5. Let Convex components do the heavy lifting. Don’t reimplement what @convex-dev/stripe, @convex-dev/workflow, and @convex-dev/rate-limiter give you for free.

The era of handwriting your own WebSocket server, webhook verification, and database sync code is ending. The tools are good enough now that the right move is to stand on their shoulders.

If you’ve done a similar migration with an AI agent — or tried and gave up — I’d like to hear what worked and what didn’t. What’s your audit workflow?

I’ll be writing more about shipping production SaaS with AI agents. Subscribe to get the next one.

No posts

Read the original on ciocan.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.