Capsule Database

New Lakebed artifacts use database API v1. Database calls are async, queries require declared indexes, and every handler runs inside one parent-owned transaction.

Indexes

import { boolean, capsule, id, query, string, table } from "lakebed/server";

export default capsule({
  schema: {
    rooms: table({ name: string() }),
    users: table({ displayName: string() }),
    messages: table({
      roomId: id("rooms"),
      authorId: id("users"),
      body: string(),
      pinned: boolean().default(false)
    })
      .index("by_room", ["roomId"])
      .index("by_room_pinned", ["roomId", "pinned"])
      .index("by_author", ["authorId"])
  },
  queries: {
    recent: query(async (ctx, args: { roomId: string }) =>
      ctx.db.messages
        .withIndex("by_room", (q) => q.eq("roomId", args.roomId))
        .order("desc")
        .take(20)
    )
  }
});

Every index has an implicit createdAt, id suffix. This makes traversal deterministic even when declared fields and timestamps are equal. Ranges support an equality prefix followed by gt, gte, lt, or lte on the next index field. by_creation is available on every table.

Each declared index field has one schema-defined scalar type: string, boolean, or id (encoded as a string). Lakebed uses the same canonical byte encoding for those values in memory, source-runtime, and Postgres execution.

Pagination

paginate({ cursor, numItems }) uses an authenticated keyset cursor. A cursor is tied to the deploy, schema, query arguments, index, range, and order. Each page has a repeatable snapshot; the cursor does not preserve one historical snapshot across separate requests.

const result = await ctx.db.messages
  .withIndex("by_room", (q) => q.eq("roomId", args.roomId))
  .order("desc")
  .paginate(args.pagination);

On the client, usePaginatedQuery(name, args) supplies a trailing pagination argument, keeps every loaded page subscribed, accumulates results, and resets to the first page after reactive invalidation.

Relationships

Use id("table") fields and bounded application-level traversal. Lakebed does not expose SQL joins.

const page = await ctx.db.messages
  .withIndex("by_room", (q) => q.eq("roomId", args.roomId))
  .take(20);

const enriched = [];
for (const message of page) {
  enriched.push({ ...message, author: await ctx.db.users.get(message.authorId as string) });
}

Many-to-many relationships use an explicit join table with indexes for both directions.

Consistency

Migrating Legacy Code

Add async to handlers, add await to every database terminal, declare indexes, and replace:

Existing immutable artifacts keep the legacy snapshot runtime. Redeploying compiles database API v1 and backfills durable index entries from the existing JSON rows without rewriting application data.

See the Database API v1 migration guide for complete before-and-after examples and a copy-paste prompt for migrating a capsule with an agent.