Migrating from a Convex toolkit

The second translation step a Drizzle-style schema DSL and a tRPC-style procedure builder add on top of the raw Convex mapping.

Last updated:

Migrating from Convex maps raw Convex to Lunora. A codebase built on a Convex toolkit — one that layers a Drizzle-style schema DSL and a tRPC-style procedure builder over Convex — has a second translation step on top of that, and it is the larger of the two. This page is that step.

Read the Convex guide first; everything here assumes it.

Written from a real port: 314 backend files, 92 tables, 24 schema modules in the toolkit's DSL, ~470 handlers. The counts below are from that migration, and are here to help you size yours rather than to be precise about anyone else's.

Two translations, not one

Budget them separately — they have very different shapes.

LayerFromEffort
Schema DSLconvexTable("t", {...}, (t) => [index(...)])mechanical, codemod-able, touches every schema file
Writesorm.insert(t).values({...}).returning({...})mechanical, touches every mutation
ProceduresauthMutation.meta({...}).input(...).mutation(...)mostly mechanical (see the shape deltas below)
Auth setupgenerated defineAuth + inline triggersa rewrite — see the Convex guide's Auth section

The DSL and write translations are codemods. The auth layer is not.

Schema: transposing the relation graph

A toolkit DSL typically declares relations schema-level, in one graph alongside the tables. Lunora declares them per-table, so the graph has to be transposed onto the owning table:

// Before — one relations() graph beside the tables
threadPins: convexTable("threadPins", {
    threadId: id("threads").notNull(),
    createdAt: integer().notNull(),
}, (t) => [index("by_thread").on(t.threadId)]);

// After — the relation lives on the table that owns the field
threadPins: defineTable({
    threadId: v.id("threads"),
    createdAt: v.number(),
})
    .index("by_thread", ["threadId"])
    .relations((r) => ({ thread: r.one("threads", { field: "threadId" }) })),

.triggers() maps across per-table too (before/after × insert/update/delete), and schema-module composition maps onto defineSchemaExtension + defineSchema().extend().

A json<T>()-style opaque column erases T at runtime. If you generate the Lunora schema by introspecting the toolkit's runtime validator tree — which is right for everything else — every such column arrives as v.any() and its Doc_* field as unknown, producing a TS2339/TS18046 at every read in files that look unrelated to the schema.

Recover T by parsing the schema source, the only place it still exists. This is a data-correctness issue as well as a typing one: the importer writes what the schema declares, and v.any() is not v.array(v.string()).

Procedures: the shape deltas

Both builders are chainable and the mapping is close, but four differences hit every file:

  • { ctx, input }{ ctx, args }. The handler's destructured argument is renamed. Trivial per site; hundreds of sites.
  • .meta({...}) exists in Lunora too. Per-procedure metadata merges, is readable from middleware as ctx.meta, and is visible to codegen — so a rate-limit registry generated by walking that metadata keeps working.
  • The object form is not callable. internalMutation({ args, handler }) is Convex's shape; Lunora's builders are chainable (internalMutation.input({...}).mutation(handler)). Leaving the object form in place produces TS2349: not callable and, worse, degrades every parameter inside it to implicit any — which is where a big share of a port's TS7006/TS7031 mass comes from.
  • .filter() takes a predicate, not a query builder. Convex passes a builder (q.eq(q.field("status"), "connected")); Lunora passes the document ((document) => document.status === "connected").

Get .filter() right by hand or with a renderer that can prove the translation. A mistranslated predicate silently returns the wrong rows rather than failing to compile, so a codemod that guesses is worse than one that reports what it could not convert.

Writes: .returning() does not survive

The ORM's insert returns rows; ctx.db.insert returns the id directly.

// Before
const [row] = await orm.insert(threadPins).values({ ... }).returning({ id: threadPins.id });
row!.id;

// After
const rowId = await ctx.db.insert("threadPins", { ... });

The array destructure is the trap. const [row] = await ctx.db.insert(...) compiles — it silently grabs the first character of the id string.

Components decompose by hand

Convex components are mountable mini-backends with their own tables, functions and durability. Lunora has no component system, so each one is taken apart, and what comes out is not always equivalent. The pattern worth internalising: a component bundles a capability with a guarantee, and the capability is usually easy while the guarantee is usually not.

ComponentCapabilityGuaranteeIn Lunora
workflowrun stepsper-step checkpoint/resume@lunora/workflow
rate limitingnamed limitsdurable buckets@lunora/ratelimit
aggregatecount/iterateincremental maintenancectx.db.count() + an index — usually not needed
emailsend an emaildurable queue + retry@lunora/mail
action cachingmemoise by argsTTL invalidationa table recipe

The ones that come out whole are the ones where Lunora already has the primitive.

Check whether the component is actually used before porting it. On the reference migration, an aggregate component had 67 maintenance calls feeding a counter nothing ever read, and another was mounted with zero references anywhere. Both were deleted rather than ported, and the aggregate reads that were live turned out to be .count() and one ordered index — both native. Roughly 70 call sites disappeared instead of moving.

See also