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

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

  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 produces a JSONL dump per table. Reshape it to your new schema (drop _creationTime if you don't need it; Lunora uses createdAt columns you populate yourself), then batch-insert via a Lunora mutation or a lunora run script.
  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.
  • 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 first-party 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.

See also