Database API v1 Migration Guide
Use this guide when upgrading a Lakebed capsule from the synchronous full-scan database API to database API v1.
Database API v1 makes database operations asynchronous, requires declared indexes for queries, and runs each handler inside one parent-owned transaction. Existing deployed artifacts keep their legacy behavior. The migration takes effect when the capsule is rebuilt and redeployed.
Syntax Changes
Legacy query:
schema: {
todos: table({
text: string(),
done: boolean().default(false),
ownerId: string()
})
},
queries: {
todos: query((ctx) =>
ctx.db.todos
.where("ownerId", ctx.auth.userId)
.orderBy("createdAt", "desc")
.all()
)
}
Database API v1 query:
schema: {
todos: table({
text: string(),
done: boolean().default(false),
ownerId: string()
}).index("by_owner", ["ownerId"])
},
queries: {
todos: query(async (ctx) => {
const todos = await ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId))
.order("desc")
.collect();
return todos;
})
}
Legacy mutation:
setTodoDone: mutation((ctx, id: string, done: boolean) => {
const todo = ctx.db.todos.get(id);
if (!todo || todo.ownerId !== ctx.auth.userId) {
return;
}
ctx.db.todos.update(id, { done });
})
Database API v1 mutation:
setTodoDone: mutation(async (ctx, id: string, done: boolean) => {
const todo = await ctx.db.todos.get(id);
if (!todo || todo.ownerId !== ctx.auth.userId) {
return;
}
await ctx.db.todos.update(id, { done });
})
Apply these replacements:
- Add
asyncto every query, mutation, or endpoint handler that usesctx.db. - Await
get,insert,update,delete,collect,take,first, andpaginate. Returning the database promise
directly from an expression-bodied async handler is also valid; never start a database operation without awaiting or returning it.
- Replace
where(...)with a declared.index(...)andwithIndex(...). - Replace
orderBy(field, direction)with index traversal andorder(direction). - Replace
all()withcollect(). - Replace
limit(count).all()withtake(count). - Use
first()when only one matching row is needed. - Use
paginate({ cursor, numItems })for unbounded lists.
Designing Indexes
Declare indexes on the table:
todos: table({
text: string(),
done: boolean().default(false),
ownerId: string()
})
.index("by_owner", ["ownerId"])
.index("by_owner_done", ["ownerId", "done"])
Then query the matching index:
const doneTodos = await ctx.db.todos
.withIndex("by_owner_done", (q) =>
q.eq("ownerId", ctx.auth.userId).eq("done", true)
)
.order("desc")
.collect();
Index rules:
- The fields passed to
withIndexmust follow the index field order. - Use zero or more
eqclauses, then optionally onegt,gte,lt, orlteclause on the next index field. - Every index has an implicit
createdAt, idsuffix for deterministic ordering. - Every table has a built-in
by_creationindex. Use it for an unfiltered newest-first or oldest-first feed. - Use
id("tableName")for references to another Lakebed table. Keep external identifiers such asctx.auth.userId
as string().
- Queries cannot perform full table scans. If a query filters or sorts by a field, declare an index that supports it.
Pagination
The server query accepts an argument with a pagination property:
messages: query(async (
ctx,
args: {
roomId: string;
pagination: { cursor: string | null; numItems: number };
}
) =>
ctx.db.messages
.withIndex("by_room", (q) => q.eq("roomId", args.roomId))
.order("desc")
.paginate(args.pagination)
)
The client supplies the non-pagination arguments:
const messages = usePaginatedQuery<Message>(
"messages",
{ roomId },
{ initialNumItems: 25 }
);
usePaginatedQuery adds the pagination property, accumulates loaded pages, and resets after reactive invalidation.
Data And Deployment
- Rebuilding creates a database API v1 artifact and a schema manifest.
- Existing rows are retained. Durable index entries are backfilled from the stored JSON rows during schema activation.
- Adding an index does not require rewriting application rows.
- Query handlers are read-only. Mutations and endpoints can write.
- A failed handler, timeout, quota failure, or store error rolls back the handler transaction.
- Preserve authorization checks while migrating. Indexes improve lookup; they do not replace ownership validation.
Agent Migration Prompt
Give the following prompt to an agent from the root of an existing Lakebed capsule:
Migrate this Lakebed capsule to database API v1. Work directly in the existing
capsule and preserve its user-visible behavior, authorization rules, routes,
query names, mutation names, and stored data shape.
Requirements:
1. Read server/index.ts, client/index.tsx, shared files, and any local AGENTS.md
before editing.
2. Keep the capsule structure: server/index.ts, client/index.tsx, and shared/.
Do not install packages, add a database service, or use Node built-ins.
3. Import schema helpers from lakebed/server. Keep fields declared with
table(), string(), boolean(), id("table"), and default() as appropriate.
4. Inspect every legacy query before choosing indexes. Add table indexes that
preserve its filters and ordering:
- equality filters become a composite index equality prefix;
- an optional range filter uses gt/gte/lt/lte on the next index field;
- createdAt and id are implicit index suffixes;
- use the built-in by_creation index for unfiltered creation-order queries.
5. Replace legacy database chains:
- where(...) -> withIndex("index_name", q => q.eq(...))
- orderBy(..., "asc" | "desc") -> order("asc" | "desc")
- all() -> collect()
- limit(n).all() -> take(n)
- single-result scans -> first()
6. Make every query, mutation, and endpoint that uses ctx.db asynchronous.
Await or directly return every get, insert, update, delete, collect, take,
first, and paginate operation. Do not leave fire-and-forget database calls.
7. Keep queries read-only. Keep writes in mutations or endpoints. Preserve all
input validation and re-check row ownership before updates or deletes.
8. Use id("tableName") only for references to another Lakebed table. Keep
external IDs, including ctx.auth.userId, as string().
9. For unbounded client lists, migrate the server query to paginate() and the
client to usePaginatedQuery(). The server argument must include:
pagination: { cursor: string | null; numItems: number }.
10. Update README snippets and shared row types when the schema or client API
changed. Stored rows still include id, createdAt, and updatedAt.
11. Search the capsule for remaining .where(, .orderBy(, .limit(, and .all(
calls. None should remain in migrated application code.
12. Run:
npx lakebed build . --target anonymous --json
Fix every reported diagnostic. Then run any existing project tests.
Do not weaken authorization to make the build pass. Do not replace indexed
queries with client-side filtering. Do not delete or rewrite existing data.
When finished, summarize:
- files changed;
- indexes added and which queries use them;
- async database calls migrated;
- pagination changes, if any;
- validation commands and results.