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 |
| Nested function dir | internal.a.b.fn | internal.a_b.fn (flattened, see below) |
| 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) |
Nested function directories flatten into one underscore-joined key. Convex namespaces the generated API by directory nesting
(internal.agent.threads.listThreads); Lunora joins the path (internal.agent_threads.listThreads), because the namespace is also the runtime dispatch key
and has to be a single JS identifier.
The failure mode is misleading, so it is worth knowing before you start: a missed lookup reads Property 'agent' does not exist on type 'InternalApiTypes',
which looks like the function was never registered rather than registered under a different key. On one 92-table port this was 608 call sites.
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). The terminal's 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.
Consuming auth is the same; SETTING IT UP is a rewrite. ctx.auth reads identically, so your authQuery/authMutation call sites port unchanged, but
the two setups share no surface. Anyone arriving from a better-auth-on-Convex toolkit will find that defineAuth, getAuthUserId, getAuthUserIdentity,
getSession and getHeaders have no counterpart.
Lunora builds better-auth with createAuth + lunoraD1Adapter, mounted through .auth(...) on the generated app builder, with ensureMigrated handling
schema sync. Where a Convex toolkit persisted better-auth into Convex tables via generated CRUD, Lunora persists into D1. The options block
(providers, plugins, callbacks) ports across verbatim, because both stacks run better-auth underneath; everything around it does not. Start from
lunora-setup-auth.
Better-auth owns its own tables in D1, so drop them from the ported schema. Leaving them produces confusing duplicate-table errors.
Triggers were transactional; databaseHooks are not
If your Convex auth layer used inline triggers, each one ran with a
MutationCtx inside the same transaction as the auth write, so a failing
trigger rolled the write back. Lunora exposes better-auth's own
databaseHooks, which receive the row and better-auth's context, but nothing
that can reach ctx.db.
The workaround is a shard client calling internal mutations:
databaseHooks: {
user: {
create: {
after: async (user) => {
await createShardClient(env.SHARD).forShard(user.id).mutation("users:seedDefaults", { userId: user.id });
},
},
},
},That works, but the transactional guarantee is gone and cannot be
recovered: the auth row is committed by the time after runs. Every hook
body has to become idempotent and independently safe, because a half-created
user is invisible until production.
Two smaller traps: there is no user.delete databaseHook, so a cascade has
to be called explicitly from your account-deletion flow (nothing fails if you
forget), and a Convex toolkit's JWT-minting plugin has no successor.
Better-auth's own jwt() covers the JWKS endpoint a web client reads.
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.
ctx.storage is read-only in queries AND mutations. Convex allowed ctx.storage.delete(id) inside a mutation; Lunora types it as ReadOnlyStorage
there and puts delete / store / generateUploadUrl on actions only.
This is deliberate. A mutation runs in a Durable Object transaction that can roll back, and an R2 delete cannot: a mutation that deleted an object and then aborted would leave the row intact and the bytes gone. Schedule the delete instead, which is strictly better than the Convex original: the row deletion commits transactionally and the object cleanup runs only if it did.
Property 'delete' does not exist on type 'ReadOnlyStorage<"default">' is the error to expect. Note the same rule makes generateUploadUrl an action, which
changes the browser-facing contract: a client calling it as a mutation fails at runtime, not at compile time.
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 export --path ./convex-export --include-file-storageproduces a JSONL dump per table (plus_storage/, the file metadata + blob bytes if your app uses file storage). Pointlunora importstraight at that directory. It reads the export layout, so there is no reshaping step:lunora import ./convex-exportYour Convex
_ids carry across verbatim. The import path writes each row's supplied_idrather than minting a new one, so every foreign key referencing it stays valid and the whole thing is one pass. That is what makes self-referential and cross-table cycles (a folder'sparentId, a supersession chain) ordinary rows rather than special cases: there is nothing to remap, so there is no ordering problem to solve.File storage needs the opt-in.
lunora importskips the_storagetable by default (those rows describe blobs, not application data). To migrate the files too (uploading every blob to R2 with a sha256 + size check before write, then rewriting references), pass--with-storage:lunora import ./convex-export --with-storageBlobs are stored under content-hash keys (
sha256hex of the bytes), and every{ $storage: id }reference is rewritten automatically, at any depth. Plain-string columns that hold storage ids (validated withv.id("_storage")in Convex) are ambiguous against ordinary text, so they are rewritten only through alunora/import-convex.jsonmapping. Run--scanfirst and the CLI detects the candidate columns and writes that file for you to confirm. It imports nothing, and never overwrites a mapping you already have:lunora import ./convex-export --scanlunora/import-convex.json { "keyPrefix": "", // optional R2 key prefix, e.g. "convex/" "storageColumns": { "users": ["avatarId"], // table → columns holding storage ids }, }Re-running is safe: keys are content hashes, so blobs already present at the right size are mapped without being uploaded again. Anything the migration could not resolve is listed as a dangling storage reference and left untouched. It is reported, never guessed at.
A
snapshot.zipfromnpx convex export --path ./snapshot.zip --include-file-storageimports the same way (lunora import ./snapshot.zip --with-storage), and streams its tables out of the archive rather than unpacking them, so a large snapshot imports the same whether it is an archive or a directory.--verifychecks each table's inserted count against its source line count and fails on any dangling reference, exiting non-zero on a mismatch:lunora import ./convex-export --with-storage --verifyObjects up to 32 MiB take the checksum-verified admin upload, which digests the body and refuses to write on a mismatch. That covers essentially every image, document, and audio file. A larger blob cannot reach the worker at all, so it falls back to a signed
PUT. That fallback needs your app to have signed URLs configured (publicBaseUrl+signingSecret) and to serve thePUTroute those URLs address; Lunora does not mount one for you. It is also verified only after the write, by size plus SHA-256 where the bucket records one, and an object that fails is deleted rather than left behind. If you have blobs over 32 MiB and no signed-PUTroute, copy them across withwrangler r2 object putand add their columns to the mapping by hand.ctx.db.insertdoes the opposite: it discards a supplied_idand mints a fresh one. So a hand-rolled importer that batch-inserts through a mutation renumbers your data and breaks every foreign key, which is exactly the failure that makes a Convex migration look impossible. Uselunora import(or the admin import endpoint it calls) for a data load;ctx.db.insertis for new rows. -
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. See the worked example below. This is the highest-risk item on most ports, because the generator prints the crossing relations but the compensation is yours to write.
- 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 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.
Writing across a tier boundary
Assigning storage tiers is where the cross-shard caveat becomes concrete.
Take a relation whose two tables land in different tiers: userConnectors
(.shardBy("userId")) referencing connectorDefinitions (.global()), or a
.shardBy parent with a root child. That was one atomic Convex write and is
now two. The schema generator prints every crossing relation on each run; treat
that list as the work-list.
Prefer removing the crossing. Denormalising the shard key onto the child
(persistentChunks gaining a userId) moves both rows into the same Durable
Object and makes the write atomic again. Do this wherever the child is only
ever reached through the parent; it is cheaper than any compensation.
When the crossing is real, the sanctioned vehicle is a durable workflow, not a hand-rolled action fan-out:
// lunora/workflows.ts
export const linkConnector = defineWorkflow({
handler: async (ctx) => {
// Each step is checkpointed: a crash after step 1 resumes at step 2
// rather than re-running step 1.
const definition = await ctx.step.do("read-definition", async () => ctx.runQuery(internal.connectors.getDefinition, { key: ctx.params.key }));
await ctx.step.do("write-user-link", async () => ctx.runMutation(internal.connectors.linkForUser, { definition, userId: ctx.params.userId }));
},
});@lunora/workflow gives you the checkpoint log for free, which is the part
that makes partial failure recoverable. An action fan-out can work too, but
only if every step is idempotent and keyed: pass an idempotency key and
have each mutation no-op on a repeat, because the retry that a crash forces
will replay steps that already succeeded.
Watch for tables that shard on an optional column. A row with no value for the shard key has no owning shard, so backfill those columns before cutover. The generator prints these too.
If you are porting a Convex toolkit
This guide maps raw Convex to Lunora. A codebase built on a Convex toolkit (a Drizzle-style schema DSL, a tRPC-style procedure builder) has a second translation step this mapping does not cover. Two differences are worth knowing up front, because both are mechanical but touch every file:
- Relations may be schema-level in a toolkit, and are per-table in Lunora.
A single
defineSchema(...).relations(...)graph has to be transposed onto the owning table's.relations((r) => …). - The handler argument name differs. A tRPC-shaped toolkit hands the
handler
{ ctx, input }; Lunora hands it{ ctx, args }. Trivial per site, and hundreds of sites.
Budget for the schema DSL separately from the function bodies: the DSL translation is a codemod, the function bodies are not. Migrating from a Convex toolkit walks the whole second step.
A toolkit's json<T>()-style opaque column erases T at runtime. If you generate your Lunora schema by introspecting the toolkit's runtime validator
tree, every such column lands as v.any() and its Doc_* field as unknown, which then errors at every read in files that look unrelated to the schema.
Recover T by parsing the schema source, which is the only place it still exists. This is a data-correctness issue too, not only a typing one: the
importer writes what the schema declares, and v.any() is not v.array(v.string()).