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:

directly from an expression-bodied async handler is also valid; never start a database operation without awaiting or returning it.

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:

as string().

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

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.