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 through a Worker-signed
PUT URL (so your storage rules still apply) and downloads use getUrl against
your publicBaseUrl. getPresignedUrl is the direct-to-R2 escape hatch.
ctx.storage has no immediate delete in a mutation. Convex allowed ctx.storage.delete(id) inside a mutation; Lunora types it as MutationStorage
there — the read surface plus deleteAfterCommit(key) — and puts delete / store / generateUploadUrl on actions only. A query gets ReadOnlyStorage.
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. deleteAfterCommit(key) queues the object cleanup to run only if the transaction commits, so no
scheduling of your own is needed.
Property 'delete' does not exist on type 'MutationStorage<"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={new LunoraClient({ url })}>. URL is your Worker's*.workers.devhostname. -
Deploy:
lunora deploybuilds and callswrangler deploy(data migrations are opt-in:--migrate, plus--migrate-yesin production). 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.run(internal.connectors.getDefinition, { key: ctx.params.key }));
await ctx.step.do("write-user-link", async () => ctx.run(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()).