Migrating from Convex
Side-by-side mapping — schema, queries, mutations, actions, React hooks.
Last updated:
Lunora borrows Convex's authoring shape on purpose: if you've used Convex, you
already know most of Lunora. The wire transport, runtime, and storage are
different, and where Convex defines a function with an object
(query({ args, handler })), Lunora uses a typed, chainable builder form
(query.input({...}).query(handler)) instead. The file layout, the
defineSchema / query / mutation / action factories, and the React hooks
are intentionally familiar.
This guide is a side-by-side mapping plus the specific gotchas you'll hit when porting an existing Convex app.
At a glance
| Concern | Convex | Lunora |
|---|---|---|
| Hosting | Convex Cloud | Your Cloudflare account |
| Backend runtime | Convex (V8 isolate) | Cloudflare Workers + Durable Objects |
| Default storage | Convex DB | One Durable Object's SQLite (__root__) |
| Cross-tenant data | tables | .global() tables in D1 |
| Per-tenant data | tables | .shardBy("field") tables in DOs |
| Schema file | convex/schema.ts | lunora/schema.ts |
| Generated API | convex/_generated/api | lunora/_generated/api |
| Function dir | convex/*.ts | lunora/*.ts |
| Codegen trigger | convex dev | lunora codegen (or the Vite plugin) |
| React hooks | convex/react | @lunora/react |
| Server SDK | convex/server | @lunora/server |
| Validators | convex/values (v.*) | @lunora/values (v.*) |
| Auth | Convex Auth, Clerk, etc. | @lunora/auth (built-in) |
| Scheduled functions | ctx.scheduler.runAfter | ctx.scheduler.runAfter (same shape) |
| File storage | ctx.storage | ctx.storage (backed by R2) |
| Subscriptions | WebSocket | WebSocket (Durable Object hibernated) |
| Deploy | npx convex deploy | lunora deploy (wraps wrangler deploy) |
Schema
The DSL is the same. defineSchema and defineTable come from
@lunora/server, indexes use the same (name, fields, { unique? }) shape,
validators are byte-compatible.
Convex:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
messages: defineTable({
channelId: v.id("channels"),
text: v.string(),
}).index("by_channel", ["channelId"]),
});Lunora:
import { defineSchema, defineTable, v } from "lunorash/server";
export const schema = defineSchema({
messages: defineTable({
channelId: v.id("channels"),
text: v.string(),
})
.shardBy("channelId")
.index("by_channel", ["channelId"]),
});The two new keywords are .shardBy(field) and .global(). Convex hides this
decision behind its hosted database; Lunora surfaces it because the answer
determines which Durable Object owns the row. See
Concepts: sharding for the decision tree.
Queries, mutations, actions
Same factories, same ctx properties. The difference is the authoring form.
Convex passes an { args, handler } object; Lunora uses a chainable builder
(.input({...}) then a .query / .mutation / .action terminal).
Convex:
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { channelId: v.id("channels") },
handler: async (ctx, { channelId }) => {
return ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.collect();
},
});Lunora:
import { mutation, query, v } from "@/lunora/_generated/server";
export const list = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }) => {
return ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.collect();
});Single import path (@lunora/server) instead of two
(./_generated/server + convex/values). Where Convex passes an object
({ args, handler }), Lunora uses a typed, chainable builder form: declare
inputs with .input({...}), then close with a .query / .mutation /
.action terminal whose handler takes a single { ctx, args } argument. The
handler body itself is identical.
React
// Convex
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
// Lunora
import { useMutation, useQuery } from "@lunora/react";
import { api } from "../lunora/_generated/api";Hook signatures match: useQuery(api.file.fn, args) returns undefined
while loading, then the typed result; useMutation(api.file.fn) returns a
typed function. usePaginatedQuery and useAction mirror Convex too.
Auth
Convex pairs with external providers (Clerk, Auth0, Convex Auth). Lunora
ships @lunora/auth, an opt-in add-on that handles
sessions, password and OAuth flows, and rotating refresh tokens inside a
SessionDO. The ctx.auth shape is the same.
Scheduler
ctx.scheduler.runAfter(ms, fn, args) works identically. Under the hood
Lunora persists the schedule in a SchedulerDO instead of Convex's hosted
queue.
File storage
ctx.storage.generateUploadUrl() / ctx.storage.getUrl(id) mirror the
Convex shape. Lunora backs them with R2: uploads go straight to R2 via
a presigned URL, and downloads stream from the Worker.
Subscriptions
Both products implement the same observable mental model: a query is a subscription, mutations broadcast deltas, the client re-renders. The difference is the routing layer. Convex broadcasts via its hosted service; Lunora broadcasts via the owning Durable Object using WebSocket hibernation so idle subscribers cost zero CPU. See Real-time.
Porting checklist
- Move files:
convex/→lunora/convex/schema.tsexports default →lunora/schema.tsexportsschema
- Rewrite imports:
convex/server→@lunora/serverconvex/values→@lunora/server(or@lunora/values)convex/react→@lunora/react./_generated/server→@lunora/server../convex/_generated/api→../lunora/_generated/api
- Decide sharding: every table needs
.global(),.shardBy(field), or neither (stays in__root__). Start with neither; promote when you hit the 1 GiB warning. Details: Sharding. - Generate the initial migration: any
.global()table needs anINSERT INTO channels … FROM <exported.jsonl>step. Runlunora migrate generate initto get the schema SQL, then add a one-off data-import migration alongside it. - Export Convex data:
npx convex exportproduces a JSONL dump per table. Reshape it to your new schema (drop_creationTimeif you don't need it; Lunora usescreatedAtcolumns you populate yourself), then batch-insert via a Lunora mutation or alunora runscript. - Wire the React provider: replace
<ConvexProvider client={…}>with<LunoraProvider client={createLunoraClient({ url })}>. URL is your Worker's*.workers.devhostname. - Deploy:
lunora deploybuilds, runs migrations, callswrangler deploy. Your data plane is now in your own Cloudflare account.
Caveats
- No transactions across shards. A mutation runs inside one Durable Object; cross-shard writes need a saga or an action that fans out. Convex gives you cross-document transactions inside its DB; Lunora doesn't.
- D1 eventual consistency on replicas. Reads from
.global()tables use the D1 Sessions API. Pass thex-d1-bookmarkheader to get read-your-writes; otherwise you may read a slightly stale replica. - Self-hosted studio, not managed. There's no hosted control plane like
Convex's. Instead
@lunora/studioships a first-party studio thatlunora devserves at/__lunora(data browser, function runner, metrics, migrations, scheduled jobs, and more), gated by your ownLUNORA_ADMIN_TOKEN. Cloudflare's DO browser, the D1 console, andlunora runremain available for ad-hoc work. See @lunora/studio.
See also
- Concepts: schema
- Concepts: sharding
- @lunora/cli —
lunora migrate generate,lunora run - Architecture