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.
| Layer | From | Effort |
|---|---|---|
| Schema DSL | convexTable("t", {...}, (t) => [index(...)]) | mechanical, codemod-able, touches every schema file |
| Writes | orm.insert(t).values({...}).returning({...}) | mechanical, touches every mutation |
| Procedures | authMutation.meta({...}).input(...).mutation(...) | mostly mechanical (see the shape deltas below) |
| Auth setup | generated defineAuth + inline triggers | a 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 asctx.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 producesTS2349: not callableand, worse, degrades every parameter inside it to implicitany— which is where a big share of a port'sTS7006/TS7031mass 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.
| Component | Capability | Guarantee | In Lunora |
|---|---|---|---|
| workflow | run steps | per-step checkpoint/resume | @lunora/workflow |
| rate limiting | named limits | durable buckets | @lunora/ratelimit |
| aggregate | count/iterate | incremental maintenance | ctx.db.count() + an index — usually not needed |
| send an email | durable queue + retry | @lunora/mail | |
| action caching | memoise by args | TTL invalidation | a 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
- Migrating from Convex — the base mapping, including auth, storage and the flattened API namespaces
- Monorepos and external IaC — if the toolkit app lives in a repo with other Workers
@lunora/workflow— the vehicle for anything that used to be one atomic cross-document write