BETTER-CONVEX

Migrations

From DB

A step-by-step migration playbook from ctx.db to the ORM + cRPC

This guide shows how to migrate from native Convex database access (ctx.db) to the ORM (ctx.orm) without losing Convex's strengths.

We'll go step by step, from schema wiring to reads, joins, filtering, pagination, search, vector search, and writes.

The "after" snippets are cRPC-first, but the same ORM calls work in vanilla Convex query/mutation handlers.

If you're new to cRPC, start with /docs/server/procedures.

In ORM APIs, use public system fields id and createdAt. Convex internal _id and _creationTime are not part of the public ORM surface.

Why migrate now

Native Convex is excellent: explicit index usage, reactive queries, simple transactional writes, and clean JavaScript control flow.

The ORM adds another layer for teams that want safer defaults and more expressive relational reads:

  • Drizzle-style query and mutation builders.
  • Relation loading with with: instead of manual join assembly.
  • Runtime defaults, constraints, and RLS on ORM writes.
  • Strong type inference across schema, queries, and writes.

You are not giving up raw power. ctx.db still exists and stays useful as an escape hatch.

Migration Checklist

Use this rollout order so migration is predictable and low-risk:

  • Define tables and indexes with convexTable() for query paths you already use.
  • Define relations with defineRelations().
  • Attach ORM once in context as ctx.orm.
  • Migrate read endpoints (db.get, db.query) first.
  • Migrate relation-heavy endpoints (manual joins -> with:).
  • Migrate pagination and search/vector endpoints.
  • Migrate write endpoints (insert/patch/replace/delete -> ORM builders).
  • Turn on guardrails (defaults, constraints, RLS) where needed.
  • Keep db.system / ctx.db explicit for system tables and intentional raw paths.

Step 1: Prepare schema + ctx.orm once

We'll start by setting up schema and context once. This keeps every later endpoint change small and mechanical.

Define tables, indexes, and relations

convex/schema.ts
import { convexTable, defineRelations, defineSchema, text, boolean, integer, id, index, uniqueIndex } from 'better-convex/orm';

export const users = convexTable('users', {
  name: text().notNull(),
  email: text().notNull(),
  role: text(),
  age: integer(),
}, (t) => [uniqueIndex('by_email').on(t.email)]);

export const posts = convexTable('posts', {
  title: text().notNull(),
  content: text().notNull(),
  published: boolean(),
  userId: id('users'),
}, (t) => [index('by_user').on(t.userId)]);

export const relations = defineRelations({ users, posts }, (r) => ({
  users: { posts: r.many.posts() },
  posts: {
    author: r.one.users({ from: r.posts.userId, to: r.users.id }),
  },
}));

export default defineSchema({ users, posts });

This mirrors native Convex indexing discipline: index the fields you filter and sort by.

Attach ORM to context once

convex/lib/orm.ts
import type { GenericDatabaseReader, GenericDatabaseWriter } from 'convex/server';
import { createOrm } from 'better-convex/orm';
import { relations } from '../schema';

const orm = createOrm({ schema: relations });

export const withOrm = <Ctx extends {
  db: GenericDatabaseReader<any> | GenericDatabaseWriter<any>;
}>(ctx: Ctx) => {
  return { ...ctx, orm: orm.db(ctx) };
};
convex/lib/crpc.ts
const c = initCRPC
  .dataModel<DataModel>()
  .context({
    query: (ctx) => withOrm(ctx),
    mutation: (ctx) => withOrm(ctx),
  })
  .create();

Done once, every handler can read through ctx.orm.

Use createdAt: timestamp().notNull().defaultNow() on tables where your ORM rows should expose createdAt: Date. Without an explicit column, system createdAt stays number.

Step 2: Migrate core reads

Now we'll migrate the read paths you use every day: single-row lookups, lists, and filtered/sorted queries.

Read one document (db.get -> findFirst)

Before (native Convex `db.get`)
convex/functions/users.ts
import { query } from 'convex/server';
import { v } from 'convex/values';

export const getById = query({
  args: { userId: v.id('users') },
  handler: async (ctx, args) => {
    return ctx.db.get('users', args.userId);
  },
});
After (ORM + cRPC)
convex/functions/users.ts
import { z } from 'zod';
import { publicQuery } from '../lib/crpc';

export const getById = publicQuery
  .input(z.object({ userId: z.string() }))
  .query(async ({ ctx, input }) => {
    return ctx.orm.query.users.findFirst({ where: { id: input.userId } });
  });

The migration is direct: id equality through where is the ORM equivalent of a point lookup.

List/filter/order (db.query -> findMany)

Before (native Convex)
const posts = await ctx.db
  .query('posts')
  .withIndex('by_user', (q) => q.eq('userId', args.userId))
  .order('desc')
  .take(20);
After (ORM)
const posts = await ctx.orm.query.posts.findMany({
  where: { userId: input.userId },
  orderBy: { createdAt: 'desc' },
  limit: 20,
});

In native Convex you always name the index with withIndex(...). In ORM, index-backed plans are inferred from where + orderBy when possible, but the same index design rules still matter.

Most findMany() calls must be explicitly sized: provide limit, use cursor pagination (cursor + limit), set schema defaultLimit, or explicitly opt in with allowFullScan. id / id in [...] lookups are the main exception.

Step 3: Replace manual joins with with:

Native Convex has no SQL joins, so relation loading means manual multi-query assembly. ORM with: removes that plumbing while keeping explicit schema relations.

one() relation (with: { author: true })

ORM (`with`)
const posts = await ctx.orm.query.posts.findMany({
  orderBy: { createdAt: 'desc' },
  limit: 20,
  with: { author: true },
});
Native Convex (manual join)
const posts = await ctx.db.query('posts').order('desc').take(20);

const authorIds = [...new Set(posts.map((p) => p.userId))];
const authors = await Promise.all(authorIds.map((id) => ctx.db.get('users', id)));
const authorById = new Map(authors.filter(Boolean).map((u) => [u.id, u]));

const postsWithAuthor = posts.map((p) => ({
  ...p,
  author: authorById.get(p.userId) ?? null,
}));

The manual pattern works, but with: keeps the same outcome with less boilerplate and clearer intent.

many() relation (with: { posts: { ... } })

ORM
const users = await ctx.orm.query.users.findMany({
  limit: 10,
  with: {
    posts: { limit: 5, orderBy: { createdAt: 'desc' } },
  },
});
Native Convex (indexed query per parent)
// Schema: index('by_user').on(t.userId) on posts
const users = await ctx.db.query('users').take(10);

const postsByUserId = new Map(
  await Promise.all(
    users.map(async (u) => [
      u.id,
      await ctx.db
        .query('posts')
        .withIndex('by_user', (q) => q.eq('userId', u.id))
        .order('desc')
        .take(5),
    ])
  )
);

const usersWithPosts = users.map((u) => ({
  ...u,
  posts: postsByUserId.get(u.id) ?? [],
}));

This is the same data shape. ORM gives you relation loading as a first-class query option.

Nested many-to-many (join table + payload)

ORM (nested `with`)
const usersWithGroups = await ctx.orm.query.users.findMany({
  limit: 10,
  with: {
    memberships: {
      columns: { role: true, joinedAt: true },
      with: { group: true },
    },
  },
});
Native Convex (manual join hops)
const users = await ctx.db.query('users').take(10);

const membershipsByUserId = new Map();
const allMemberships = [];
for (const u of users) {
  const rows = await ctx.db
    .query('memberships')
    .withIndex('by_user', (q) => q.eq('userId', u.id))
    .collect();
  membershipsByUserId.set(u.id, rows);
  allMemberships.push(...rows);
}

const groupIds = [...new Set(allMemberships.map((m) => m.groupId))];
const groups = await Promise.all(groupIds.map((id) => ctx.db.get('groups', id)));
const groupById = new Map(groups.filter(Boolean).map((g) => [g.id, g]));

const usersWithGroups = users.map((u) => ({
  ...u,
  memberships: (membershipsByUserId.get(u.id) ?? []).map((m) => ({
    role: m.role,
    joinedAt: m.joinedAt,
    group: groupById.get(m.groupId) ?? null,
  })),
}));

That's the core migration win for relational reads: same result, much less custom join code.

Relation existence and cross-table filters

ORM can express relation-based filters directly:

await ctx.orm.query.users.findMany({
  where: { posts: true },
  limit: 50,
});

await ctx.orm.query.users.findMany({
  where: { posts: { published: true } },
  limit: 50,
});

Native Convex equivalents are usually:

  • Denormalize (hasPosts, postCount, hasPublishedPosts) and index those fields.
  • Or run two-phase queries (query child table, dedupe parent IDs, load parents).

The denormalized path is usually the long-term scalable one.

Step 4: Filtering and index strategy

Now let's lock how to choose filter modes so migration stays fast at scale.

NeedNative ConvexORM
Indexed equality/range filteringwithIndex("...") range expressionobject where (index-compiled when possible)
Complex JS predicate filtering.filter(...) (table/index scan semantics)callback where + predicate(...) + explicit .withIndex(...)
Full-text relevance searchwithSearchIndex(...)findMany({ search })
Vector similarity searchctx.vectorSearch(...) in actionsfindMany({ vectorSearch }) (or native action pattern)

Here are the guardrails to keep in mind during migration:

  • Native Convex requires explicit withIndex(...) to force index usage.
  • ORM compiles index plans from where/orderBy where possible, but still enforces sizing and scan guardrails.
  • Compound indexes follow prefix rules in both worlds: constrain leading index fields before trailing fields.
  • Predicate where in ORM must include explicit .withIndex(...) and should be bounded with maxScan in cursor mode.

Example predicate where in ORM:

const page = await ctx.orm.query.users
  .withIndex('by_email')
  .findMany({
    where: (_users, { predicate }) =>
      predicate((u) => u.email.endsWith('@example.com')),
    cursor: null,
    limit: 20,
    maxScan: 500,
  });

This makes scan behavior explicit instead of accidental.

Step 5: Pagination migration

Next we'll migrate pagination. The mapping is straightforward: native paginate becomes ORM cursor paging.

Before (`paginate(paginationOpts)`)
convex/functions/posts.ts
import { query } from 'convex/server';
import { paginationOptsValidator } from 'convex/server';

export const list = query({
  args: { paginationOpts: paginationOptsValidator },
  handler: async (ctx, args) => {
    return ctx.db.query('posts').order('desc').paginate(args.paginationOpts);
  },
});
After (`findMany({ cursor, limit })`)
convex/functions/posts.ts
import { z } from 'zod';
import { publicQuery } from '../lib/crpc';

export const list = publicQuery
  .input(z.object({ cursor: z.string().nullable() }))
  .query(async ({ ctx, input }) => {
    return ctx.orm.query.posts.findMany({
      orderBy: { createdAt: 'desc' },
      cursor: input.cursor,
      limit: 20,
    });
  });

The ORM page shape matches Convex pagination shape: { page, continueCursor, isDone }.

If you want standardized pagination input/output at the procedure layer, use publicQuery.paginated(...).

Like native Convex pagination, page boundaries are reactive. As rows are inserted or removed, a page can shrink or grow between refreshes.

Step 6: Full-text search migration

We'll now map native withSearchIndex calls to ORM search mode.

Before (`withSearchIndex`)
const messages = await ctx.db
  .query('messages')
  .withSearchIndex('search_body', (q) =>
    q.search('body', args.query).eq('channel', '#general')
  )
  .take(10);
After (`findMany({ search })`)
const messages = await ctx.orm.query.messages.findMany({
  search: {
    index: 'search_body',
    query: input.query,
    filters: { channel: '#general' },
  },
  cursor: null,
  limit: 10,
});

Search stays relevance-ordered in both models. You should not expect custom orderBy in search mode.

Step 7: Vector search migration

Vector search in native Convex is action-only. The migration keeps that shape: run vector search in an action, then hydrate documents via a query.

Before (native Convex)
convex/functions/posts.ts
import { v } from 'convex/values';
import { action, internalQuery } from 'convex/server';
import { internal } from './_generated/api';

export const fetchByIds = internalQuery({
  args: { ids: v.array(v.id('posts')) },
  handler: async (ctx, args) => {
    const rows = [];
    for (const id of args.ids) {
      const doc = await ctx.db.get('posts', id);
      if (doc) rows.push(doc);
    }
    return rows;
  },
});

export const similarPosts = action({
  args: { embedding: v.array(v.number()) },
  handler: async (ctx, args) => {
    const hits = await ctx.vectorSearch('posts', 'embedding_vec', {
      vector: args.embedding,
      limit: 10,
    });
    return ctx.runQuery(internal.posts.fetchByIds, {
      ids: hits.map((h) => h.id),
    });
  },
});
After (ORM + cRPC)
convex/functions/posts.ts
import { z } from 'zod';
import { internal } from './_generated/api';
import { publicAction, privateQuery } from '../lib/crpc';

export const fetchByIds = privateQuery
  .input(z.object({ ids: z.array(z.string()) }))
  .query(async ({ ctx, input }) => {
    return ctx.orm.query.posts.findMany({
      where: { id: { in: input.ids } },
      limit: input.ids.length,
      with: { author: true },
    });
  });

export const similarPosts = publicAction
  .input(z.object({ embedding: z.array(z.number()) }))
  .action(async ({ ctx, input }) => {
    const hits = await ctx.vectorSearch('posts', 'embedding_vec', {
      vector: input.embedding,
      limit: 10,
      filter: (q) => q.eq('type', 'news'),
    });

    const rows = await ctx.runQuery(internal.posts.fetchByIds, {
      ids: hits.map((h) => h.id),
    });

    const scoreById = new Map(hits.map((h) => [String(h.id), h._score]));
    return rows.map((row) => ({
      ...row,
      _score: scoreById.get(String(row.id)) ?? 0,
    }));
  });

This preserves the native Convex action pattern and adds cleaner ORM hydration logic.

Step 8: Write migration semantics

Now we'll map writes carefully, because semantics differ most here.

Insert (db.insert -> insert().values())

Before (native Convex)
convex/functions/users.ts
import { mutation } from 'convex/server';
import { v } from 'convex/values';

export const createUser = mutation({
  args: { name: v.string(), email: v.string() },
  handler: async (ctx, args) => {
    return ctx.db.insert('users', { name: args.name, email: args.email });
  },
});
After (ORM + cRPC)
convex/functions/users.ts
import { z } from 'zod';
import { publicMutation } from '../lib/crpc';
import { users } from '../schema';

export const createUser = publicMutation
  .input(z.object({ name: z.string(), email: z.string().email() }))
  .mutation(async ({ ctx, input }) => {
    const [row] = await ctx.orm
      .insert(users)
      .values({ name: input.name, email: input.email })
      .returning({ id: users.id });
    return row.id;
  });

Native insert returns an ID directly. ORM returns void unless you call .returning(...).

Patch/replace semantics (db.patch / db.replace -> update().set().where())

import { eq, unsetToken } from 'better-convex/orm';
const { userId, ...patch } = input;

// Patch-style update: undefined keys are ignored.
await ctx.orm.update(users).set(patch).where(eq(users.id, userId));

// Native Convex equivalent of removing a field via patch({ field: undefined }):
await ctx.orm.update(users).set({ nickname: unsetToken }).where(eq(users.id, userId));

// Replace-style update: set all fields explicitly.
await ctx.orm
  .update(users)
  .set({ name: input.name, email: input.email, role: input.role })
  .where(eq(users.id, userId));

Native Convex treats patch({ field: undefined }) as field removal. ORM uses unsetToken for that explicit unset behavior.

Also watch output shapes during migration: nullable ORM columns come back as null, while native Convex often represents missing fields as undefined.

Delete (db.delete -> delete().where())

Before (native Convex)
await ctx.db.delete('users', args.userId);
After (ORM)
await ctx.orm.delete(users).where(eq(users.id, input.userId));

Upsert conflict mapping

ORM (`onConflictDoUpdate`)
const [row] = await ctx.orm
  .insert(users)
  .values({ email: input.email, name: input.name })
  .onConflictDoUpdate({
    target: users.email,
    set: { name: input.name },
  })
  .returning({ id: users.id });
Native Convex (manual unique lookup + patch/insert)
// Schema: index('by_email').on(t.email) on users
const existing = await ctx.db
  .query('users')
  .withIndex('by_email', (q) => q.eq('email', input.email))
  .unique();

if (existing) {
  await ctx.db.patch('users', existing.id, { name: input.name });
  return existing.id;
}
return await ctx.db.insert('users', { email: input.email, name: input.name });

Step 9: Guarantees and caveats

Let's close with the guarantees you gain and the caveats you still need to own.

What ORM adds

  • Runtime constraint/default enforcement on ORM mutation paths.
  • Optional RLS policy enforcement on ORM paths.
  • Drizzle-style mutation/query ergonomics.

What still bypasses enforcement

ctx.db is still raw native access and bypasses ORM constraints/defaults/RLS.

Use ctx.db intentionally. If you mix ctx.db writes into an ORM-migrated codebase, you're opting out of ORM enforcement on those paths.

Uniqueness under concurrency

Runtime uniqueness checks are best-effort within one mutation. Concurrent mutations can still race.

Use idempotent patterns (onConflictDoNothing / onConflictDoUpdate), retries, or serialize writes by key when strict guarantees are required.

System-table parity

System tables remain raw passthrough access in both models:

const files = await ctx.orm.system.query('_storage').take(20);
const jobs = await ctx.orm.system.query('_scheduled_functions').collect();

This maps directly to native ctx.db.system.get/query semantics.

Rollout playbook

If you're migrating an existing app, this order keeps risk low and progress measurable.

  1. Migrate read-only endpoints that already have clear index paths.
  2. Migrate relation-heavy endpoints and delete manual join assembly.
  3. Migrate writes table by table (insert, then update, then delete, then upserts).
  4. Migrate search endpoints.
  5. Migrate vector action flows.
  6. Enable stricter defaults/constraints/RLS where needed.

Done criteria

Use this checklist to know migration is complete:

  • App tables read through ctx.orm.query.* by default.
  • List endpoints are explicitly sized (limit or cursor pagination).
  • Manual join assembly is replaced by with: where practical.
  • Patch/remove semantics are explicit (unsetToken where needed).
  • Search/vector endpoints follow mode constraints and documented patterns.
  • Any remaining ctx.db usage is intentional and documented in code comments.

That's it. You now have an ORM migration path that preserves Convex strengths while reducing repetitive query and write plumbing.

Next Steps

On this page