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.dbexplicit 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
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
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) };
};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)
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);
},
});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)
const posts = await ctx.db
.query('posts')
.withIndex('by_user', (q) => q.eq('userId', args.userId))
.order('desc')
.take(20);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 })
const posts = await ctx.orm.query.posts.findMany({
orderBy: { createdAt: 'desc' },
limit: 20,
with: { author: true },
});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: { ... } })
const users = await ctx.orm.query.users.findMany({
limit: 10,
with: {
posts: { limit: 5, orderBy: { createdAt: 'desc' } },
},
});// 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)
const usersWithGroups = await ctx.orm.query.users.findMany({
limit: 10,
with: {
memberships: {
columns: { role: true, joinedAt: true },
with: { group: true },
},
},
});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.
| Need | Native Convex | ORM |
|---|---|---|
| Indexed equality/range filtering | withIndex("...") range expression | object where (index-compiled when possible) |
| Complex JS predicate filtering | .filter(...) (table/index scan semantics) | callback where + predicate(...) + explicit .withIndex(...) |
| Full-text relevance search | withSearchIndex(...) | findMany({ search }) |
| Vector similarity search | ctx.vectorSearch(...) in actions | findMany({ 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/orderBywhere possible, but still enforces sizing and scan guardrails. - Compound indexes follow prefix rules in both worlds: constrain leading index fields before trailing fields.
- Predicate
wherein ORM must include explicit.withIndex(...)and should be bounded withmaxScanin 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.
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);
},
});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.
const messages = await ctx.db
.query('messages')
.withSearchIndex('search_body', (q) =>
q.search('body', args.query).eq('channel', '#general')
)
.take(10);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.
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),
});
},
});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())
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 });
},
});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())
await ctx.db.delete('users', args.userId);await ctx.orm.delete(users).where(eq(users.id, input.userId));Upsert conflict mapping
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 });// 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.
- Migrate read-only endpoints that already have clear index paths.
- Migrate relation-heavy endpoints and delete manual join assembly.
- Migrate writes table by table (
insert, thenupdate, thendelete, then upserts). - Migrate search endpoints.
- Migrate vector action flows.
- 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 (
limitor cursor pagination). - Manual join assembly is replaced by
with:where practical. - Patch/remove semantics are explicit (
unsetTokenwhere needed). - Search/vector endpoints follow mode constraints and documented patterns.
- Any remaining
ctx.dbusage 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.