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 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

  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={createLunoraClient({ url })}>. URL is your Worker's *.workers.dev hostname.

  7. Deploy: lunora deploy builds, runs migrations, calls wrangler 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 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.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&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()).