Skip to content
DocsmigratingDocumentation

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

ConcernConvexLunora
HostingConvex CloudYour Cloudflare account
Backend runtimeConvex (V8 isolate)Cloudflare Workers + Durable Objects
Default storageConvex DBOne Durable Object's SQLite (__root__)
Cross-tenant datatables.global() tables in D1
Per-tenant datatables.shardBy("field") tables in DOs
Schema fileconvex/schema.tslunora/schema.ts
Generated APIconvex/_generated/apilunora/_generated/api
Nested function dirinternal.a.b.fninternal.a_b.fn (flattened, see below)
Function dirconvex/*.tslunora/*.ts
Codegen triggerconvex devlunora codegen (or the Vite plugin)
React hooksconvex/react@lunora/react
Server SDKconvex/server@lunora/server
Validatorsconvex/values (v.*)@lunora/values (v.*)
AuthConvex Auth, Clerk, etc.@lunora/auth (built-in)
Scheduled functionsctx.scheduler.runAfterctx.scheduler.runAfter (same shape)
File storagectx.storagectx.storage (backed by R2)
SubscriptionsWebSocketWebSocket (Durable Object hibernated)
Deploynpx convex deploylunora 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

  1. Move files:

    • convex/ → lunora/
    • convex/schema.ts exports default → lunora/schema.ts exports schema
  2. Rewrite imports:

    • convex/server → @lunora/server
    • convex/values → @lunora/server (or @lunora/values)
    • convex/react → @lunora/react
    • ./_generated/server → @lunora/server
    • ../convex/_generated/api → ../lunora/_generated/api
  3. 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.

  4. Generate the initial migration: any .global() table needs an INSERT INTO channels … FROM <exported.jsonl> step. Run lunora migrate generate init to get the schema SQL, then add a one-off data-import migration alongside it.

  5. Export Convex data: npx convex export --path ./convex-export --include-file-storage produces a JSONL dump per table (plus _storage/, the file metadata + blob bytes if your app uses file storage). Point lunora import straight at that directory. It reads the export layout, so there is no reshaping step:

    lunora import ./convex-export

    Your Convex _ids carry across verbatim. The import path writes each row's supplied _id rather 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's parentId, 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 import skips the _storage table 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-storage

    Blobs are stored under content-hash keys (sha256 hex of the bytes), and every { $storage: id } reference is rewritten automatically, at any depth. Plain-string columns that hold storage ids (validated with v.id("_storage") in Convex) are ambiguous against ordinary text, so they are rewritten only through a lunora/import-convex.json mapping. Run --scan first 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 --scan
    lunora/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.zip from npx convex export --path ./snapshot.zip --include-file-storage imports 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. --verify checks 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 --verify

    Objects 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 the PUT route 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-PUT route, copy them across with wrangler r2 object put and add their columns to the mapping by hand.

    ctx.db.insert does the opposite: it discards a supplied _id and 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. Use lunora import (or the admin import endpoint it calls) for a data load; ctx.db.insert is for new rows.

  6. Wire the React provider: replace <ConvexProvider client={…}> with <LunoraProvider client={new LunoraClient({ url })}>. URL is your Worker's *.workers.dev hostname.

  7. Deploy: lunora deploy builds and calls wrangler deploy (data migrations are opt-in: --migrate, plus --migrate-yes in 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 the x-d1-bookmark header 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/studio ships a studio that lunora dev serves at /__lunora (data browser, function runner, metrics, migrations, scheduled jobs, and more), gated by your own LUNORA_ADMIN_TOKEN. Cloudflare's DO browser, the D1 console, and lunora run remain 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&lt;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()).