# Lakebed Docs Full Text
This file contains every docs page in one place. It is large and most fetch tools truncate it. To read one page or section, use the index at https://docs.lakebed.dev/llms.txt instead. New to Lakebed? Start with [the agent start guide](https://lakebed.dev/agents.md).
---
title: Lakebed Docs
source: docs/README.md
url: https://docs.lakebed.dev/
markdown: https://docs.lakebed.dev/index.md
---
# Lakebed Docs
Lakebed is an agent-native CLI and runtime for building small full-stack TypeScript apps called capsules.
If you are an agent building with Lakebed, treat the capsule directory as the whole app. Write the server contract, write the Preact client, run the Lakebed CLI, inspect the runtime state, and deploy without leaving code.
## Start Here
Create and run a capsule:
```sh
npx lakebed new my-app --template todo
cd my-app
npx lakebed dev
```
To use the current directory instead, run `npx lakebed init`. Use it in an empty directory or a new repository. `init` keeps hidden files, README, LICENSE, `AGENTS.md`, and `CLAUDE.md`. It adds the Lakebed lines to the end of an existing `.gitignore`, `AGENTS.md`, and `CLAUDE.md`. If the directory has any other files, `init` stops and writes nothing, because Lakebed treats the whole directory as one app. In that case, run `npx lakebed new my-app` to put the capsule in a subdirectory. When `init` creates the git repository, the first commit holds only the files that Lakebed wrote or updated. `npx lakebed new
` follows the same rules when `` already exists.
`npx lakebed create` is an alias for `npx lakebed new`. New capsules get a git repository and initial commit unless they are created inside an existing git repository or `--no-git` is passed.
A Lakebed v0 capsule has this shape:
```txt
server/index.ts
client/index.tsx
shared/
.env.lakebed.server
```
- `server/index.ts`: schema, queries, mutations, actions, and external endpoints. Import only from `lakebed/server` and pure relative files.
- `client/index.tsx`: the Preact UI entrypoint. Import hooks and auth UI from `lakebed/client`.
- `shared/`: pure TypeScript shared by server and client. Do not import Lakebed runtimes, DOM APIs, Node built-ins, secrets, or env values here.
- `.env.lakebed.server`: optional server-only environment variables exposed to server handlers through `ctx.env`.
## Server Contract
Every capsule exports a default `capsule()` definition from `server/index.ts`.
```ts
import { action, capsule, endpoint, json, mutation, query, string, table, text, userId } from "lakebed/server";
export default capsule({
schema: {
messages: table({
body: string(),
authorId: userId()
}).index("by_author", ["authorId"]),
webhookMessages: table({ body: string() })
},
queries: {
messages: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.messages
.withIndex("by_author", (q) => q.eq("authorId", userId))
.order("desc")
.collect();
})
},
mutations: {
sendMessage: mutation(async (ctx, body: string) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.messages.insert({
body,
authorId: userId
});
})
},
actions: {
summarizeMessages: action(async (ctx, label: string) => {
const { userId } = ctx.auth.requireIdentity();
const messages = await ctx.db.messages
.withIndex("by_author", (q) => q.eq("authorId", userId))
.collect();
return { count: messages.length, label };
})
},
endpoints: {
webhook: endpoint({ method: "POST", path: "/webhooks/incoming" }, async (ctx, req) => {
if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
return text("unauthorized", { status: 401 });
}
const payload = await req.json<{ body: string }>();
await ctx.db.webhookMessages.insert({ body: payload.body });
return json({ ok: true });
})
}
});
```
Server handlers receive:
- `ctx.auth`: the current guest, signed-in account, or no-session state. Use `requireIdentity()` or `requireSignedIn()` before private data access.
- `ctx.db`: async table access with declared indexes, `withIndex`, `order`, `collect`, `take`, `first`, `paginate`, `get`, `insert`, `update`, and `delete`.
- `ctx.env`: server-only values from `.env.lakebed.server`.
- `ctx.log`: structured logs captured by the runtime.
Make queries and mutations server-authoritative. Get a verified caller with `ctx.auth.requireIdentity()`, filter user-owned rows by its `userId`, and re-check ownership before updates or deletes. Declare user references with `userId()` so they transfer when a guest signs in. The reference type does not grant access to rows.
Use `endpoints` for webhooks and external services. Endpoint handlers receive the same `ctx` as queries and mutations plus a request object with `headers`, `query`, `text()`, `json()`, and `bytes()`. Return `json()`, `text()`, `empty()`, or `redirect()`.
For database details, see the [database guide](https://docs.lakebed.dev/database/index.md). To upgrade an older capsule that uses `where`,
`orderBy`, `limit`, `all`, or synchronous database calls, use the
[Database API v1 migration guide](https://docs.lakebed.dev/database-migration/index.md).
## Client Contract
The client exports `App` from `client/index.tsx`.
```tsx
import { canAccessApp, createClient, retryAuth, SignInWithGoogle, signOut, useAuth } from "lakebed/client";
import type app from "../server/index";
const client = createClient();
export function App() {
const auth = useAuth();
if (auth.isLoading) {
return Checking session;
}
if (!canAccessApp()) {
return (
{auth.error ?
>
);
}
```
- `createClient()` infers declared handler names, arguments, and results from the server definition. Import the server definition with `import type` so client code does not load server code.
- `client.useQuery("name")` subscribes to a server query and returns `undefined` until its first result arrives.
- `client.useMutation("name")` calls a server mutation and returns a promise.
- `client.useAction("name")` calls a read-only server action and returns a promise.
- `client.usePaginatedQuery("name", args, options)` subscribes to a query that returns `.paginate(...)` results.
- `useAuth()` reads the current client identity. Use `auth.isLoading` to avoid showing signed-out UI while Lakebed confirms a stored session.
- `canAccessApp()` is true when session setup finished without an error, a guest or account exists, and the app's sign-in policy is met. Gate data components on it. Show `auth.error` with a `retryAuth()` button, and keep `` available to replace an expired or revoked token.
- ``, `signInWithGoogle()`, and `signOut()` provide the built-in Google auth path.
- Use ``, ``, ``, ``, `useParams()`, `useLocation()`, and `useNavigate()` for client-side pages.
- Use complete Tailwind class names in JSX. Lakebed compiles CSS automatically from client files and their imports. V0 does not support CSS files, CSS modules, PostCSS, or Tailwind config.
Client routes are app-relative and work in dev and hosted deploys:
```tsx
import { Link, Route, Router, Routes, useParams } from "lakebed/client";
function ItemPage() {
const { id } = useParams<{ id: string }>();
return Item {id};
}
export function App() {
return (
Open item
Home} />
} />
Not found} />
);
}
```
Use server `endpoints` for HTTP APIs and webhooks. If a `GET` endpoint and a client route use the same path, the endpoint handles direct HTTP requests first.
## Actions
Use actions for server work that reads the database without changing it. Define each action in `server/index.ts` with `action()` and call it from a client component:
```tsx
const summarizeMessages = client.useAction("summarizeMessages");
const result = await summarizeMessages("inbox");
```
The action receives `ctx.auth`, a read-only `ctx.db`, `ctx.env`, and `ctx.log`. Arguments and results must be JSON-compatible. Each action allows up to 16 KiB of arguments and 48 KiB of results, and stops after five seconds. Hosted actions count against both request and mutation limits. Outbound `fetch` and hosted server env require a claimed deploy.
## Auth And Env
Guest access uses protected browser sessions. Use `auth.isGuest` and `auth.isSignedIn` to distinguish guests from accounts. `ctx.auth.requireIdentity()` accepts either. `ctx.auth.requireSignedIn()` requires an account. Set `auth: { requireSignIn: true }` on the capsule to block app data operations before sign-in.
Store Lakebed user IDs with `userId()`, not `string()`. Those references follow a guest to their account automatically. Existing account rows remain. Use `auth.onGuestUpgrade` only when your app needs to resolve conflicting records. Shared data must use an intentional shared query, not a shared user ID.
For named local test identities:
```sh
npx lakebed auth as alice
```
For named per-tab tests, add `?lakebed_guest=alice` or `?lakebed_guest=bob` to the app URL. Named test identities do not work in hosted apps and cannot upgrade to accounts. `npx lakebed auth reset` removes the CLI override. Use the default protected session to test guest upgrades.
Google sign-in uses first-party Lakebed Auth in dev and hosted apps, with one immutable
`ctx.auth.userId` across deploy hostnames. Each token remains bound to its exact origin, and email
is profile and invitation data only. See the [identity and authentication contract](https://docs.lakebed.dev/auth/index.md).
Server-only env belongs in `.env.lakebed.server`:
```txt
OPENAI_API_KEY=sk-...
STRIPE_WEBHOOK_SECRET=whsec_...
```
Read it only from server handlers:
```ts
queries: {
settings: query((ctx) => ({
hasOpenAiKey: Boolean(ctx.env.OPENAI_API_KEY)
}))
}
```
`npx lakebed dev` loads server env locally. Hosted server env syncs only after a deploy is claimed, and deploy sync replaces the hosted env with the file contents. Env values are not exposed to client code or embedded in anonymous artifacts.
## Inspect The Runtime
While `npx lakebed dev` is running:
```sh
npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed db export --port 3000 --out backup.json
npx lakebed logs --port 3000
```
For hosted or locally deployed apps, pass a deploy id or URL:
```sh
npx lakebed inspect
npx lakebed db dump
npx lakebed db export --out backup.json
npx lakebed logs
```
Use these before guessing. `db dump` is bounded for inspection; `db export` walks every table in bounded pages and atomically writes a complete backup. Local `npx lakebed dev` inspection is open on localhost. Hosted deploys keep app manifests, state, table names, logs, and usage private by default. The CLI sends developer auth for a committed `lakebed.json` binding or reads the saved anonymous claim token from `.lakebed/deploy.json`. Database export always requires deploy-management authorization, even when ordinary inspection is public. Non-private hosted manifests expose only the app name, deploy id, client bundle hash, runtime version, and favicon metadata when the app has one.
## Deploy
From inside a capsule:
```sh
npx lakebed deploy
```
Anonymous deploys work first. Claim the deploy when the app needs hosted server env or outbound server-side `fetch`, then run `npx lakebed deploy` again. Anonymous deploys do not rewrite guarded JavaScript mutations into weaker IR.
For a portable owned deploy, run `npx lakebed auth login` before the first deploy. Lakebed writes a root-level `lakebed.json` containing only `deployId`; commit it so fresh checkouts update the same app. Create a deploy-scoped CI credential with `npx lakebed token create --name github-actions`, or create an owner-wide credential with `npx lakebed token create --personal --name local-automation`. When using a custom `--api`, set `LAKEBED_TOKEN_API` to the same canonical origin before supplying `LAKEBED_TOKEN`. The canonical origin is the scheme and host, plus a port when it is non-standard, without a path or query. For example, with `--api https://api.example.com`, set `LAKEBED_TOKEN_API=https://api.example.com`.
Inspection for hosted deploys is private by default. For demos where public data and logs are intentional, deploy with:
```sh
npx lakebed deploy --public-inspect
```
After a hosted deploy is claimed, reserve a Lakebed-owned app subdomain from the capsule directory:
```sh
npx lakebed domains add my-app.lakebed.app
```
Manage owned deployments with:
```sh
npx lakebed deploy list
npx lakebed deploy archive
npx lakebed deploy restore
npx lakebed deploy terminate --yes
```
Archiving stops the app until you restore it. Termination stops the app and prevents future updates. Both operations retain the deploy record, stored data, logs, usage records, server env, domain mappings, and referenced artifact. Terminated deploys cannot be restored.
Unclaimed deploys expire and are deleted about one week after expiry. Claimed deploys do not expire.
### Bring your own domain
Boosted plans can attach a domain you own to a claimed, active deploy. Free plans get `*.lakebed.app` only. Add the hostname, set the DNS records it prints, then poll status until it goes `active`:
```sh
npx lakebed domains add app.example.com
npx lakebed domains status app.example.com
npx lakebed domains list
```
`domains add` prints a table of DNS records to create at your registrar: a `CNAME` that points the hostname at Lakebed, and a `TXT` record that proves you own it. Create them exactly as printed. For an apex domain (`example.com`, no subdomain), use your registrar's `ALIAS`/`ANAME` or CNAME-flattening record instead of a static `A` record.
Then run `npx lakebed domains status app.example.com` and keep re-running it. The status walks `pending_dns` to `verified` to `active`. It can also land in `failed`, `disabled`, or `deleting`. Exit codes let an agent loop on the command: `0` when active, `2` while pending, `1` otherwise.
A `failed` domain reports why in its status detail. Wrong or missing DNS records and certificate errors that Lakebed can retry both recover on their own: correct the records and keep polling. If the detail says the registration was removed by the provider, polling will not help. Run `npx lakebed domains remove app.example.com` and add it again.
Verification uses the `TXT` record, so the `CNAME` can already resolve while requests still return 404. That is expected. Keep polling. DNS changes can take a while to propagate. TLS is issued automatically once verification passes, with nothing to configure. Add each hostname separately, because wildcards are not supported. Remove a domain (custom or `.lakebed.app`) with `npx lakebed domains remove `.
Signed-in users keep the same `userId` across the generated `*.lakebed.app` URL and your custom domain. If the app is archived or terminated its custom domains are released; restoring the app does not bring them back, so re-add them.
## Current Limits
- One server entry: `server/index.ts`.
- One client entry: `client/index.tsx`.
- App code can use relative imports, `lakebed/server`, `lakebed/client`, and Lakebed-provided Preact modules.
- Arbitrary npm imports inside capsule code are not supported yet.
- Node built-ins are not available inside capsule modules.
- Database fields support `string()`, `boolean()`, `number()`, `id(...)`, and `userId()`. Chain `.optional()` or `.default(value)` on any field.
- Local state resets when `npx lakebed dev` restarts.
- User-uploaded files use built-in object storage ([storage.md](https://docs.lakebed.dev/storage/index.md)); `lakebed dev` keeps them in memory and resets on restart.
- Anonymous deploys disable outbound server-side `fetch`.
- Non-empty `.env.lakebed.server` files require a claimed deploy before hosted env can sync.
Resource limits are separate from the capability limits above. [`limits.md`](https://docs.lakebed.dev/limits/index.md) lists free-plan resource limits, their errors, and what to change. Paid plans can have higher limits. See the [handler capability table](https://docs.lakebed.dev/capsule-api/index.md#handler-capabilities) before choosing a handler for external API calls or ingest jobs.
## Read Next
- [`capsule-api.md`](https://docs.lakebed.dev/capsule-api/index.md): detailed app-author API.
- [`limits.md`](https://docs.lakebed.dev/limits/index.md): request, database, action, storage, log, and server env limits with their errors.
- [`auth.md`](https://docs.lakebed.dev/auth/index.md): protected guests, sign-in requirements, user references, and guest upgrades.
- [`storage.md`](https://docs.lakebed.dev/storage/index.md): built-in object storage for user-uploaded files.
- [`reference.md`](https://docs.lakebed.dev/reference/index.md): capsule runtime, API, CLI, and deploy reference.
- [`examples`](https://docs.lakebed.dev/examples/index.md): working capsules that show the intended app shape.
- [`llms.txt`](/llms.txt): index of every page and section. Fetch one page from it instead of the full text.
- [`docs.json`](/docs.json): the same index as JSON with heading anchors.
---
title: Lakebed Reference
source: docs/reference.md
url: https://docs.lakebed.dev/reference/
markdown: https://docs.lakebed.dev/reference/index.md
---
# Lakebed Reference
Use this as the quick contract when building a Lakebed capsule.
## Capsule
A capsule is one complete Lakebed app: source, server API, client UI, state, auth, logs, and deploy URL.
V0 expects this directory shape:
```txt
server/index.ts
client/index.tsx
shared/
.env.lakebed.server
```
- `server/index.ts` exports the capsule definition.
- `client/index.tsx` exports the Preact `App` component.
- `shared/` contains pure TypeScript used by both sides.
- `.env.lakebed.server` is optional server-only configuration.
There is no `lakebed.config.ts` in v0.
## Module Boundaries
- Server code imports from `lakebed/server`.
- Client code imports from `lakebed/client`.
- Shared code imports only pure relative TypeScript.
- App code can import relative files and Lakebed-provided Preact modules.
- App code cannot import arbitrary npm packages yet.
- Capsule modules cannot use Node built-ins.
- Shared code must not read env, secrets, DOM APIs, Node APIs, or Lakebed runtime APIs.
## Server API
```ts
import { action, boolean, capsule, mutation, query, string, table, userId } from "lakebed/server";
```
Export one default `capsule()` call:
```ts
export default capsule({
auth: { requireSignIn: false },
schema: {
todos: table({
text: string(),
done: boolean().default(false),
ownerId: userId()
}).index("by_owner", ["ownerId"])
},
queries: {
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.order("desc")
.collect();
})
},
mutations: {
addTodo: mutation(async (ctx, text: string) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos.insert({
text,
done: false,
ownerId: userId
});
})
},
actions: {
summarize: action(async (ctx, label: string) => {
const { userId } = ctx.auth.requireIdentity();
const todos = await ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
return { count: todos.length, label };
})
}
});
```
`capsule()` accepts these keys, and nothing else:
| Key | Type | Purpose |
| --- | --- | --- |
| `name` | `string` | App title used for the browser tab. Defaults to `Lakebed Capsule`. |
| `favicon` | `string` | Relative path to an `.svg` or `.ico` file in the capsule, at most 64 KiB. Without it, Lakebed uses `favicon.svg` or `favicon.ico` at the capsule root. If neither file exists, the capsule serves no favicon. `lakebed new` writes a starter `favicon.svg` so a fresh capsule has one. |
| `auth` | `CapsuleAuth` | `{ requireSignIn?, onGuestUpgrade? }`. See [require sign-in](https://docs.lakebed.dev/auth/index.md#require-sign-in-for-the-app) and [guest upgrades](https://docs.lakebed.dev/auth/index.md#user-references-and-guest-upgrades). |
| `schema` | table map | Tables declared with `table({ ...fields })`. |
| `queries` | handler map | Read-only handlers declared with `query()`. |
| `mutations` | handler map | Read-write handlers declared with `mutation()`. |
| `actions` | handler map | Read-only handlers declared with `action()`, called directly by the client. |
| `endpoints` | endpoint map | HTTP routes declared with `endpoint()`. |
```ts
export default capsule({
name: "Todo",
favicon: "assets/icon.svg",
// schema, queries, mutations, actions, and endpoints follow
});
```
The favicon path must stay inside the capsule. Absolute paths, `..` segments, and extensions other than `.svg` and `.ico` fail the build.
Server handlers receive:
- `ctx.auth`: guest, signed-in account, or no-session state, with `requireIdentity()` and `requireSignedIn()` guards.
- `ctx.db`: table access for the capsule database.
- `ctx.env`: server-only env values.
- `ctx.log`: structured logs captured by Lakebed, with `info`, `warn`, and `error`.
Actions and queries have read-only database access. Mutations and endpoints can write to the database. An endpoint with `readOnly: true` cannot write.
`query()`, `mutation()`, and `action()` only wrap a handler so TypeScript can infer its `ctx`. `endpoint()` does more. It rejects a non-boolean `readOnly`, then returns an `EndpointDefinition` that holds `kind`, the upper-cased `method`, the `path`, and `readOnly`. Lakebed reads that object to register the route. The [capsule API guide](https://docs.lakebed.dev/capsule-api/index.md) shows each one in a full capsule.
## Data API
Tables are declared with `table({ ...fields })`. Field helpers are:
| Helper | Row type | Notes |
| --- | --- | --- |
| `string()` | `string` | |
| `boolean()` | `boolean` | |
| `number()` | `number` | Finite float64. `NaN` and `Infinity` are rejected on write. |
| `id("table")` | `string` | Row id from the named table. |
| `userId()` | `string` | Lakebed user reference that follows a guest upgrade. |
| `.default(value)` | same | Fills the value when an insert omits the field. |
| `.optional()` | `T \| undefined` | The field may be absent. Pass `null` to clear it. |
| `.index(name, fields)` | | Declares an index on a table. |
```ts
scores: table({
playerId: id("players"),
points: number(),
note: string().optional(),
streak: number().optional().default(0)
}).index("by_points", ["points"])
```
`number()`, `userId()`, and `.optional()` need a database API v1 artifact. Every new build
produces one. Rebuild and redeploy older capsules before using them.
Index order is numeric for `number()` fields. Absent optional values sort first. See the
[database guide](https://docs.lakebed.dev/database/index.md#field-types) for the full sort rules and the schema-change rule.
Every stored row includes:
- `id`
- `createdAt`
- `updatedAt`
Table methods are async. Use `withIndex(name, range)`, then `order("asc" | "desc")` and one terminal:
`collect()`, `take(count)`, `first()`, or `paginate(options)`. Direct `get`, `insert`, `update`, and `delete`
are also awaited. Legacy `where`, `orderBy`, `limit`, and `all` calls are rejected for new artifacts.
See the [database guide](https://docs.lakebed.dev/database/index.md) for index and consistency details. Use the
[Database API v1 migration guide](https://docs.lakebed.dev/database-migration/index.md) for before-and-after syntax and a copy-paste agent prompt.
Treat queries and mutations as the source of truth. Get a verified caller with `ctx.auth.requireIdentity()`, filter user-owned data by its `userId`, and re-check ownership before changes or deletes. `userId()` does not enforce ownership. Shared queries must deliberately omit the owner filter.
## External Endpoints
Use `endpoint({ method, path }, handler)` for webhooks and other services that call your app over HTTP.
```ts
import { endpoint, json, text } from "lakebed/server";
endpoints: {
stripeWebhook: endpoint({ method: "POST", path: "/webhooks/stripe" }, async (ctx, req) => {
if (req.headers.get("x-webhook-secret") !== ctx.env.STRIPE_WEBHOOK_SECRET) {
return text("unauthorized", { status: 401 });
}
const body = await req.text();
ctx.log.info("stripe webhook received", { bytes: body.length });
return json({ ok: true });
})
}
```
Endpoint handlers receive `ctx.auth`, `ctx.db`, `ctx.env`, and `ctx.log`. The request exposes `method`, `path`, `url`, `headers.get(name)`, `query`, `text()`, `json()`, and `bytes()`. Successful endpoint calls can write to the database and publish subscribed queries. Use `.env.lakebed.server` secrets for webhook checks.
Return one of four response helpers: `json(value, options?)`, `text(value, options?)`, `empty(options?)` for a `204`, or `redirect(url, options?)` for a `302`. All four take `{ status?, headers? }`. See [endpoint responses](https://docs.lakebed.dev/capsule-api/index.md#return-endpoint-responses).
An endpoint reads a Lakebed identity from the reserved `X-Lakebed-Token` header. Browser code gets that token from `getIdentity()`. See [endpoint credentials](https://docs.lakebed.dev/auth/index.md#custom-endpoint-credentials).
Set `readOnly: true` for an endpoint that only reads the database:
```ts
endpoint({ method: "GET", path: "/api/items", readOnly: true }, async (ctx) =>
json(await ctx.db.items.withIndex("by_creation").collect())
);
```
Read-only endpoints can run at the same time as other reads and mutations. Lakebed rejects database writes from these handlers. They use request quota, not mutation quota. `GET` and `HEAD` endpoints default to read-only. Other methods default to writable. Set `readOnly: false` explicitly if a `GET` or `HEAD` endpoint must write. This default applies when an app is built with the updated SDK. Existing deployed artifacts keep their original behavior.
Custom endpoint `Authorization` headers belong to the app, including Bearer and Basic webhook credentials. Send a Lakebed identity token in `X-Lakebed-Token`, without a `Bearer` prefix. Lakebed verifies and removes that reserved header before calling the handler. Same-origin guest cookies also identify the caller. See [endpoint credentials](https://docs.lakebed.dev/auth/index.md#custom-endpoint-credentials).
## Client API
```tsx
import {
canAccessApp,
createClient,
ErrorBoundary,
getIdentity,
Link,
Route,
Router,
Routes,
SignInWithGoogle,
navigate,
retryAuth,
signInWithGoogle,
signOut,
useAction,
useAuth,
useLocation,
useMutation,
useNavigate,
useParams,
usePaginatedQuery,
useQuery
} from "lakebed/client";
```
- `createClient()`: infer query, mutation, action, and paginated-query names, arguments, and results from the server definition.
- `useQuery("name")`: subscribe to a server query. Returns `undefined` until the first result arrives.
- `usePaginatedQuery("name", args, { initialNumItems })`: subscribe to cursor pages, accumulate them, and expose `loadMore()`/`reset()`.
- `useMutation("name")`: call a server mutation.
- `useAction("name")`: call a read-only server action.
- ``: catch query failures and show a retryable fallback. Lakebed wraps the generated app root with this boundary automatically.
- `useAuth()`: read the current client identity. Use `auth.isLoading` to avoid showing signed-out UI while Lakebed confirms a stored session.
- `canAccessApp()`: true when session setup finished without an error, a guest or account exists, and the app's sign-in policy is met. Gate data components on it.
- `getIdentity()`: read the current identity snapshot, including its optional token for custom endpoint requests. Send that token only to your app's origin.
- `retryAuth()`: retry failed session setup, a failed sign-out, or a guest upgrade shown by `auth.error`. It does not renew an expired or revoked token. Show `` next to it.
- ``: render the built-in Google sign-in button.
- `signInWithGoogle()`: start Google sign-in from custom UI.
- `signOut()`: end the signed-in session. The app returns to a fresh guest session if guests are allowed.
- ``, ``, and ``: render client-side pages.
- ``: navigate without a page reload. Paths are app-relative locally and on hosted app subdomains.
- `useParams()`, `useLocation()`, `useNavigate()`, and `navigate()`: read and change the current client route.
At most 4 mutation and action calls are in flight at once on a connection. Later calls wait in the client and go out in call order as earlier ones finish, so fast input such as drag-and-drop does not fail. Calls in flight at the same time can finish in a different order, so await a call before a call that depends on its result. A call that has not gone out yet fails with an error that says "the server did not apply it" when the connection closes or the signed-in user changes. It is not sent later. Read the current state, then call it again if it still applies. A call that already went out keeps the old rule: if the connection closes first, its result is unknown. At most 1,000 calls can wait. Put many rows in one mutation instead of one call per row.
The typed client reads the server definition through a type-only import:
```tsx
import { createClient } from "lakebed/client";
import type app from "../server/index";
const client = createClient();
const todos = client.useQuery("todos");
const addTodo = client.useMutation("addTodo");
const summarize = client.useAction("summarize");
await addTodo("Ship the app");
const summary = await summarize("today");
```
`client.useQuery("todos")` returns `undefined` until the first result arrives. Mutation and action calls return promises. Query, mutation, and action arguments and results must be JSON-compatible.
On a hosted app, the client also sends anonymous timing counts to Lakebed over the app connection: how long page loads, first query results, mutations, and reconnects take. It sends only histogram counts, with no user IDs, query names, arguments, results, or URLs.
Treat query results as read-only, including nested objects and arrays. Subscribers share each result. Copy values before you edit or sort them. For example, use `[...todos].sort(...)` instead of `todos.sort(...)`. Lakebed freezes query results during `lakebed dev` to catch accidental changes. Use mutations to change stored data.
The standalone `useQuery`, `useMutation`, `useAction`, and `usePaginatedQuery` hooks remain available when callers provide their own types. Standalone `useQuery` has the same loading contract as `client.useQuery`: it returns `undefined` until the first result arrives.
Paginated query handlers accept a trailing argument whose `pagination` property is passed to `.paginate(...)`:
```ts
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)
)
```
```tsx
const messages = usePaginatedQuery("messages", { roomId }, { initialNumItems: 25 });
return (
<>
{messages.page.map((message) =>
{message.body}
)}
{!messages.isDone ? : null}
>
);
```
Router example:
```tsx
function TodoPage() {
const { id } = useParams<{ id: string }>();
return Todo {id};
}
export function App() {
return (
Open todo
Home} />
} />
Not found} />
);
}
```
A server endpoint takes precedence over a client route only when both its path and HTTP method match the request. Opening or reloading a URL makes a `GET` request. Client router navigation stays in the loaded app. Keep API paths separate from page paths. See [client routes](https://docs.lakebed.dev/capsule-api/index.md#build-the-client).
## Actions
`action(handler)` declares an action in the `actions` map of a capsule. `useAction(name)` and `client.useAction(name)` return a function that runs the named action.
Action handlers receive `ctx.auth`, read-only `ctx.db`, `ctx.env`, and `ctx.log`. Actions do not write to the database or trigger query invalidation. Outbound `fetch` and hosted server env require a claimed deploy.
The [limits page](https://docs.lakebed.dev/limits/index.md) defines action argument, result, and runtime limits for local development and hosted deployments. The [handler capability table](https://docs.lakebed.dev/capsule-api/index.md#handler-capabilities) compares database writes, fetch, env, auth, and quota use across handlers.
Hosted actions count against both the request limit and the mutation limit. Closing the client connection cancels actions that have not completed.
## Auth
Guest access uses protected browser sessions by default. User IDs are opaque references, not session credentials.
| State | `userId` | `provider` | `isGuest` | `isSignedIn` |
| --- | --- | --- | --- | --- |
| No session | `null` | `null` | `false` | `false` |
| Guest | `string` | `"guest"` | `true` | `false` |
| Account | `string` | `"google"` | `false` | `true` |
`isAuthenticated` remains an alias for `isSignedIn`. `displayName` is present in each state. Signed-in identities can include `subject`, migration-only `identityAliases`, `email`, `emailVerified`, and `picture`. Profile data does not prove access.
Server guards:
```ts
const identity = ctx.auth.requireIdentity(); // Guest or account, with a user ID.
const account = ctx.auth.requireSignedIn(); // Signed-in account only.
```
Set `auth: { requireSignIn: true }` in `capsule()` to block all app data operations before sign-in. The shell and auth routes remain available. The client exposes `auth.requireSignIn`, `auth.isLoading`, and `auth.error` for UI state. Wait for session setup, show errors with a `retryAuth()` button, and mount private data components only when the session satisfies the app policy.
Store Lakebed user references with `userId()`. Those fields transfer automatically on a verified guest upgrade. Existing account rows remain. Set `auth.onGuestUpgrade` for app-specific conflict rules before automatic transfer. Plain strings and external data do not transfer. See the [auth guide](https://docs.lakebed.dev/auth/index.md) for the transaction and session contracts.
Named local test overrides use `npx lakebed auth as alice` or `?lakebed_guest=alice`. They are not available in hosted apps and cannot upgrade to accounts. `npx lakebed auth reset` restores normal protected sessions. Use the default browser session to test a real upgrade.
## Server Env
Put server-only values in `.env.lakebed.server`:
```txt
OPENAI_API_KEY=sk-...
```
Read them from server handlers:
```ts
query((ctx) => Boolean(ctx.env.OPENAI_API_KEY));
```
`npx lakebed dev` loads this file locally. Hosted env syncs only after the deploy is claimed. Sync is replace-based: keys removed from `.env.lakebed.server` are removed from the hosted deploy.
Env values are not exposed to client code and are not embedded in anonymous artifacts.
### CLI environment variables
`.env.lakebed.server` is your app's env. These four variables configure the Lakebed CLI on your machine or in your CI, where there is no saved login. They never reach your app or the hosted service. Every one is optional.
| Name | Effect |
| --- | --- |
| `LAKEBED_TOKEN` | Credential from `npx lakebed token create`. Used instead of the saved developer login. |
| `LAKEBED_TOKEN_API` | The one API origin allowed to receive `LAKEBED_TOKEN`. Required when you also pass a custom `--api`. |
| `LAKEBED_DEPLOY_API` | Default deploy API origin. `--api` overrides it. |
| `LAKEBED_INSPECT_TOKEN` | Token for `inspect`, `db list`, `db dump`, `db export`, `logs`, and `storage list` against a hosted deploy. `--inspect-token` overrides it. |
```sh
LAKEBED_TOKEN=lkb_... npx lakebed deploy
LAKEBED_INSPECT_TOKEN=lkb_... npx lakebed db export --out backup.json
```
## Styling
Use Tailwind classes directly in JSX. Lakebed compiles them automatically during `dev`, `build`, and `deploy`. New builds include compiled CSS without loading the Tailwind browser compiler. Redeploy older apps with the current CLI to use compiled CSS.
Write complete class names in client files or shared files imported by the client. Conditional classes such as `done ? "text-neutral-500" : "text-white"` work. Constructed names such as `bg-${color}-500` do not. For values loaded at runtime, use inline styles or select from a map of complete class names in the client source.
V0 does not support CSS files, CSS modules, PostCSS, or Tailwind config. You do not need a separate CSS build command.
## Runtime Inspection
`npx lakebed dev` and the hosted runner serve the same inspection routes with the same JSON shapes. The inspect loop is identical: build locally, read a route, deploy, read the same route.
While `npx lakebed dev` is running:
```sh
npx lakebed inspect --port 3000
npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed db export --port 3000 --out backup.json
npx lakebed logs --port 3000
```
For a deployed app:
```sh
npx lakebed inspect
npx lakebed db list
npx lakebed db dump
npx lakebed db export --out backup.json
npx lakebed logs
```
The CLI reads these `GET` routes on the app origin. Call them directly with `curl` when the CLI is not enough.
| Route | Returns |
| --- | --- |
| `/__lakebed/manifest` | App name, deploy id, URL, runtime version, inspect policy, query, mutation, action, and endpoint names, schema, favicon, limits, database index and read metrics. |
| `/__lakebed/usage` | `limits`, `state` with stored `stateRows` and `stateBytes`, and `usage` with the daily `requests` and `mutations` counters. |
| `/__lakebed/url` | `basePath` and the app `url`. |
| `/__lakebed/db/tables` | Table names, row `counts`, and database index state. |
| `/__lakebed/db` | Bounded row dump per table plus `truncated`. Stops at the `rowsReturned` limit. |
| `/__lakebed/db/export` | Backup metadata. Add `?table=&schemaHash=` for pages. |
| `/__lakebed/logs` | The last 100 log entries as `{ at, level, message, data }`. |
| `/__lakebed/storage` | Uploaded objects with size, visibility, and uploader. |
Local `/__lakebed/usage` counts requests and mutations the same way the hosted runner does, so an agent can see how close an app is to a daily limit before deploying. `npx lakebed dev` only counts. It never rejects on a daily limit.
Two routes exist only on the hosted runner: `/__lakebed/access/status` (restricted access users) and `/__lakebed/report` (abuse reports). `npx lakebed dev` answers them with `404` and a JSON body with `hostedOnly: true` and a hint to run `npx lakebed deploy`, so an agent knows the route exists and what to do.
Local state is in-memory and resets when `npx lakebed dev` restarts.
`db dump` is a bounded inspection view. `db export` scans the built-in `by_creation` index in bounded pages and writes a versioned JSON backup through a same-directory temporary file before atomically replacing the destination. Tables are ordered by name; rows are ordered by immutable `(createdAt, id)` ascending; row object keys are serialized canonically. Each page is a separate store snapshot, so the complete backup is not a point-in-time transaction: concurrent inserts after the cursor may appear, deletes not yet visited may disappear, and updates reflect the page that reads them. A schema change, authorization failure, network/write error, or handled interruption fails the export, preserves an existing destination, and removes the temporary file.
Local inspection needs no token. `npx lakebed dev` binds to `127.0.0.1`, so only programs on your machine can reach it. `--host 0.0.0.0` serves the whole network, and then every inspection route, including row dumps and logs, is open to that network. Hosted inspection is private by default for manifests, table names, row dumps, logs, and usage. Run hosted inspection commands from the capsule directory so the CLI can find `lakebed.json` or `.lakebed/deploy.json` in the working directory tree and send developer auth from the binding or saved anonymous claim token. Direct HTTP callers can use `Authorization: Bearer `. Non-private hosted manifests expose only app name, deploy id, client bundle hash, runtime version, and favicon metadata (content type, hash, route, and source path) when the app has one.
## CLI
```sh
npx lakebed new [name] [--template todo] [--no-git]
npx lakebed create [name] [--template todo] [--no-git]
npx lakebed init [--template todo] [--no-git]
npx lakebed dev [capsule-dir] [--port 3000] [--host 127.0.0.1]
npx lakebed build [capsule-dir] [--target claimed|anonymous] [--out .lakebed/artifacts/app.json] [--json]
npx lakebed deploy [capsule-dir] [--api ] [--public-inspect] [--json]
npx lakebed deploy list [--api ] [--json]
npx lakebed deploy archive [--api ] [--json]
npx lakebed deploy restore [--api ] [--json]
npx lakebed deploy terminate --yes [--api ] [--json]
npx lakebed claim [capsule-dir] [--api ] [--json]
npx lakebed auth login [--api ] [--json]
npx lakebed auth status [--api ] [--json]
npx lakebed auth logout [--api ]
npx lakebed token create --name [--personal] [--api ] [--json]
npx lakebed token list [--api ] [--json]
npx lakebed token revoke [--api ]
npx lakebed domains add [--api ] [--json]
npx lakebed domains status [--api ] [--json]
npx lakebed domains list [--api ] [--json]
npx lakebed domains remove [--api ] [--json]
npx lakebed inspect [deploy-id-or-url] [--port 3000] [--api ] [--inspect-token ] [--usage] [--json]
npx lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
npx lakebed auth as
npx lakebed auth reset
npx lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token ]
npx lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token ]
npx lakebed db export [deploy-id-or-url] [--port 3000] [--inspect-token ] [--out ]
npx lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token ]
npx lakebed storage list [deploy-id-or-url] [--prefix ] [--port 3000] [--inspect-token ] [--json]
npx lakebed storage get [deploy-id-or-url] [--out ] [--port 3000] [--json]
npx lakebed storage put [deploy-id-or-url] [--public] [--content-type ] [--port 3000] [--json]
npx lakebed storage delete [deploy-id-or-url] [--port 3000] [--json]
npx lakebed users list [deploy-id] [--api ] [--json]
npx lakebed users approve [deploy-id] [--api ] [--json]
npx lakebed users deny [deploy-id] [--api ] [--json]
npx lakebed users remove [deploy-id] [--api ] [--json]
```
## Build behavior
`npx lakebed build` builds locally without sign-in or a deploy. It defaults to `--target claimed`, which allows server-side `fetch`. Use `--target anonymous` only to check anonymous deploy restrictions. Building does not publish the app or change deploy ownership. Hosted `fetch` and server env still require an owned or claimed deploy.
## Deploy Behavior
`npx lakebed deploy` can publish an anonymous deploy first.
Claim the deploy before relying on hosted server env or outbound server-side `fetch`, then run `npx lakebed deploy` again. Anonymous deploys intentionally disable those capabilities while preserving server handler control flow in the source runtime.
Run `npx lakebed auth login` before the first deploy to create an owned app. The CLI writes `lakebed.json` at the capsule root with only `deployId`; commit it for fresh-checkout and CI deploys. `npx lakebed token create --name github-actions` returns a deploy-scoped CI credential once. Use `npx lakebed token create --personal --name local-automation` when automation needs an owner-wide credential instead. Supply the returned value as `LAKEBED_TOKEN`. For a custom API origin, `LAKEBED_TOKEN_API` must match the canonical `--api` origin exactly. The canonical origin is the scheme and host, plus a non-standard port when present, without a path or query.
Hosted deploy inspection is `private` by default. Use `npx lakebed deploy --public-inspect` only for demos where making data and logs public is intentional.
Claimed deploys can reserve Lakebed-owned app subdomains with `npx lakebed domains add my-app.lakebed.app`. Reserved product names such as `api`, `admin`, `docs`, and `www` cannot be registered.
`npx lakebed deploy list` shows owned deploys and their lifecycle status. `npx lakebed deploy archive ` stops an app without deleting its resources. `npx lakebed deploy restore ` makes an archived app available again.
`npx lakebed deploy terminate --yes` stops an app and prevents later deploy updates. Without `--yes`, the command prints a confirmation prompt and does not terminate the app. A terminated deploy cannot be restored.
Archive and terminate retain the deploy record, stored data, logs, usage records, server env, domain mappings, and referenced artifact. Archived and terminated app URLs do not serve traffic or inspection routes. The CLI does not provide a command to delete retained deployment data.
Hosted anonymous deploys enforce the state byte limit during mutation commit, cap logs by entry count and bytes, and limit deploy creation, app requests, and app mutations. [`limits.md`](https://docs.lakebed.dev/limits/index.md) describes default resource limits and their errors. Unclaimed deploys expire and stop serving. About one week after expiry, Lakebed deletes unclaimed deploys with their data, logs, and server env. Claimed deploys do not expire.
## Custom Domains
`domains` manages the hostnames attached to a claimed, active deploy. `*.lakebed.app` subdomains are free on every plan. Attaching a domain you own (`app.example.com`, or an apex `example.com`) requires a boosted plan; the free plan is capped at 0 custom domains and the pro plan at 5 per account.
```sh
npx lakebed domains add [--api ] [--json]
npx lakebed domains status [--api ] [--json]
npx lakebed domains list [--api ] [--json]
npx lakebed domains remove [--api ] [--json]
```
`add` registers the hostname and prints the DNS records to create: a `CNAME` pointing the hostname at Lakebed and a `TXT` record that proves ownership. Create them exactly as printed, then run `status` until the domain is active. For an apex domain (no subdomain), use your registrar's `ALIAS`/`ANAME` or CNAME-flattening record instead of a static `A` record. The hostname must not be under `lakebed.app` or `lakebed.dev`. Wildcards are unsupported, so add each hostname individually.
`status` re-checks verification and certificate issuance and drives the domain through its lifecycle: `pending_dns` → `verified` → `active`. It can also report `failed`, `disabled`, or `deleting`. Exit codes make it loopable: `0` active, `2` pending, `1` failed.
`failed` is not terminal. Fix the DNS records and keep polling; retryable certificate failures re-issue on their own. A hostname whose registration the provider removed is the exception: remove and re-add it. `disabled` and `deleting` are terminal. Requests to a `failed` hostname get a 404, and a `disabled` or `deleting` hostname gets a 410.
`list` prints every domain on the deploy. `remove` detaches a hostname; it works for `.lakebed.app` subdomains too.
Verification uses the `TXT` record: the `CNAME` can resolve while the hostname still returns 404 until the `TXT` record is observed. This is expected, so keep re-running `status`. DNS propagation can take time. TLS certificates are issued automatically after verification, with no further action. Signed-in users keep the same `userId` across the generated `*.lakebed.app` URL and the custom domain. Archiving or terminating an app releases its custom domains; restoring the app does not resurrect them, so re-add them.
### HTTP
```txt
POST /v1/deploys/:id/domains {hostname}
GET /v1/deploys/:id/domains
GET /v1/deploys/:id/domains/:hostname (refresh)
DELETE /v1/deploys/:id/domains/:hostname
```
For a custom domain the response envelope adds `status`, `statusDetail`, `certificateStatus`, `dnsRecords` (an array of `{name, type, value}`), and `lastCheckedAt`. The same fields back `--json` output.
Stable error codes:
- `lakebed_custom_domain_plan_required` (HTTP 402): the account's plan does not allow custom domains (free plan is 0).
- `lakebed_custom_domain_cap_exceeded` (HTTP 409): the account is at its custom-domain cap (5 on pro).
Reserved product names (`api`, `admin`, `docs`, `www`, and similar) remain unregisterable as `.lakebed.app` subdomains; see [Deploy Behavior](https://docs.lakebed.dev/reference/index.md#deploy-behavior).
---
title: Capsule Database
source: docs/database.md
url: https://docs.lakebed.dev/database/
markdown: https://docs.lakebed.dev/database/index.md
---
# 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
```ts
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`, `number`, `id`, or `userId`. IDs
and user references are encoded as strings. Lakebed uses the same canonical byte encoding for those values in memory,
source-runtime, and Postgres execution.
## Field types
| Helper | Row type | Notes |
| --- | --- | --- |
| `string()` | `string` | No NUL characters or unpaired surrogates. |
| `boolean()` | `boolean` | |
| `number()` | `number` | Any finite float64. Integers and decimals are one type. `NaN` and `Infinity` are rejected on write. |
| `id("table")` | `string` | A row id from the named table. |
| `userId()` | `string` | A Lakebed user reference that follows a guest upgrade. See [auth](https://docs.lakebed.dev/auth/index.md). |
Chain `.default(value)` to fill a value when an insert omits the field. Chain `.optional()` to allow the field to be
absent. The two chain in either order.
### Numbers
```ts
scores: table({
playerId: id("players"),
points: number(),
streak: number().default(0)
}).index("by_points", ["points"])
```
`number()` stores a JavaScript number. Index order is numeric, so `-10` sorts before `2` and `2` before `10`.
Range clauses compare numerically: `q.gte("points", 100)` returns rows with `points` of `100` or more.
Store timestamps with `number()` as Unix epoch milliseconds. Use `Date.now()` for the current time and `Date.parse(value)` for an external timestamp, then reject a non-finite result. Numeric index ranges compare times without depending on string formats or time zone offsets.
### Optional fields
```ts
todos: table({
text: string(),
dueAt: number().optional(),
priority: number().optional()
}).index("by_priority", ["priority"])
```
An optional field can be left out of `insert`. The stored row has no key for it, so `row.priority` reads as
`undefined` and the row type is `priority?: number`. Pass `null` to `insert` or `update` to mean "no value". An
`update` with `null` removes the field from the row. Stored rows never contain `null`.
An optional field with a default always has a value. `number().optional().default(0)` reads as `number`, and
clearing it with `null` restores the default.
### Sort rules
Within one index field, values sort in this order:
1. Absent (an optional field with no value). Absent sorts first in `asc` and last in `desc`.
2. `false`, then `true`.
3. Strings by UTF-8 byte order.
4. Numbers by numeric value. `-0` and `0` are the same key.
To find rows where an optional field is absent, use `q.eq("priority", null)`. Range clauses such as `gt` and `lte`
only match present values.
### Schema changes with existing rows
Adding a required field to a table that already has rows fails on deploy with
`Missing value for
.`. Make the new field `.optional()` or give it a `.default(value)` instead. Both
work without rewriting stored rows, and both can be indexed right away.
## Collect or paginate
`count()` returns the number of matching index entries without loading row bodies:
```ts
const count = await ctx.db.todos.withIndex("by_creation", q => q.gte("createdAt", since)).count();
```
Each match counts as one row read. The call uses one scan and does not use the rows returned or bytes read budgets.
The shared handler limit of 5000 rows read still applies, including across several `count()` calls. A count that
exceeds the remaining budget throws instead of returning a partial count. Use stored summary rows for larger ranges.
`collect()` returns every matching row if the handler stays within its database limits. The default limit is 1000 rows
returned by database calls per handler. `collect()` throws
`collect() exceeded the 1000 row return limit. Use paginate().` when one call finds too many rows. Several calls can
throw `Database rowsReturned limit exceeded (1000).` when their combined result exceeds the budget. Both failures roll
back the transaction. Use `collect()` only when your app keeps the result set below that limit.
For a growing result set, use `take(n)` for a fixed window or `paginate()` for a scrolling list. The default limits also
allow at most 5000 rows read and 4 MiB read per handler, so an unindexed `collect()` can fail before it reaches 1000
returned rows. See [`limits.md`](https://docs.lakebed.dev/limits/index.md) for the other resource limits.
`first()` returns the first matching row, or `null` when no row matches. Use `.order("desc").first()` for the latest row in an index range. `take(n)` returns at most `n` rows. Both use the same database read budgets as `collect()`.
Several `paginate()` calls share the row return budget within one handler. A pagination loop inside one handler does not bypass it. Paginate across separate calls for a growing list. Neither `take(n)` nor one page gives an exact count of rows outside that window. See [dashboard counts](https://docs.lakebed.dev/database/index.md#dashboard-counts) for totals stored during ingest.
## 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.
```ts
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.
## Dashboard counts
For totals larger than one handler can read, store a count per UTC day alongside the source rows. Insert the source row and increment its count in the same writable handler. Writes on one deploy are serialized, so an indexed lookup followed by an insert is safe from concurrent ingest calls. Indexes do not enforce uniqueness. All writers must use this check.
This server fetches one page of closed GitHub PRs, keeps merged PRs, and updates daily counts. It uses the existing API. No data passes through a browser.
```ts
import { capsule, endpoint, json, number, query, table, text } from "lakebed/server";
const DAY = 86_400_000;
const PAGE_SIZE = 25;
export default capsule({
auth: { requireSignIn: false },
schema: {
prs: table({ number: number(), mergedAt: number() })
.index("by_number", ["number"])
.index("by_merged", ["mergedAt"]),
days: table({ day: number(), count: number() }).index("by_day", ["day"])
},
endpoints: {
ingest: endpoint({ method: "POST", path: "/api/ingest", readOnly: false }, async (ctx, req) => {
const secret = ctx.env.INGEST_SECRET;
if (!secret || req.headers.get("authorization") !== `Bearer ${secret}`) {
return text("unauthorized", { status: 401 });
}
const page = Number(req.query.get("page") ?? "1");
if (!Number.isSafeInteger(page) || page < 1) {
return text("invalid page", { status: 400 });
}
const token = ctx.env.GITHUB_TOKEN;
if (!token) throw new Error("Set GITHUB_TOKEN in .env.lakebed.server.");
const response = await fetch(
`https://api.github.com/repos/pingdotgg/t3code/pulls?state=closed&sort=updated&direction=desc&per_page=${PAGE_SIZE}&page=${page}`,
{ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } }
);
if (!response.ok) throw new Error(`GitHub returned ${response.status}.`);
const items: unknown = await response.json();
if (!Array.isArray(items) || items.length > PAGE_SIZE) throw new Error("Invalid GitHub page.");
let inserted = 0;
for (const value of items) {
const item: unknown = value;
if (typeof item !== "object" || item === null || !("number" in item) || !("merged_at" in item)) {
throw new Error("Invalid GitHub PR.");
}
if (item.merged_at === null) continue;
if (typeof item.number !== "number" || !Number.isSafeInteger(item.number) ||
typeof item.merged_at !== "string") throw new Error("Invalid merged PR.");
const mergedAt = Date.parse(item.merged_at);
if (!Number.isFinite(mergedAt)) throw new Error("Invalid merge time.");
const existing = await ctx.db.prs.withIndex("by_number", (q) => q.eq("number", item.number)).first();
if (existing) continue;
const day = Math.floor(mergedAt / DAY) * DAY;
const bucket = await ctx.db.days.withIndex("by_day", (q) => q.eq("day", day)).first();
await ctx.db.prs.insert({ number: item.number, mergedAt });
if (bucket) {
if (typeof bucket.count !== "number") throw new Error("Invalid daily count.");
await ctx.db.days.update(bucket.id, { count: bucket.count + 1 });
} else {
await ctx.db.days.insert({ day, count: 1 });
}
inserted += 1;
}
ctx.log.info("GitHub ingest", { page, inserted });
return json({ inserted, nextPage: items.length === PAGE_SIZE ? page + 1 : null });
})
},
queries: {
daily: query(async (ctx, start: number) => {
if (!Number.isSafeInteger(start) || start % DAY !== 0) throw new Error("Use a UTC day boundary.");
return ctx.db.days
.withIndex("by_day", (q) => q.gte("day", start).lt("day", start + 14 * DAY))
.take(14);
})
}
});
```
Set `INGEST_SECRET` and `GITHUB_TOKEN` in `.env.lakebed.server`, keep the file out of Git, and claim and redeploy before hosted use. Keep the ingest secret in the external caller too. Call `POST /api/ingest?page=1` with `Authorization: Bearer `, then follow `nextPage` across separate requests. Retry failed pages with backoff. Repeating a page does not count a PR twice. Each call uses at most two index scans and two writes per PR, so this batch size leaves room within the [handler limits](https://docs.lakebed.dev/limits/index.md).
For the client, pass a UTC midnight timestamp to `client.useQuery("daily", start)`. Fill missing days with zero and draw the returned counts. Sum complete day buckets for a phase that starts at midnight. For a phase boundary within a day, query source rows for the partial day. Use a separate paginated query on `by_merged` for recent PR details. Update time-based query arguments when the visible window changes, because the clock alone does not invalidate a subscription.
This is a batching and count example, not a complete GitHub mirror. GitHub pages can move while ingestion runs. Revisit overlapping pages, periodically reconcile the full retained window, and record the last successful refresh for the UI. Do not stop at the first old merge when sorting by update time. For a first backfill, the external caller must continue across pages. Lakebed has no durable continuation queue or built-in scheduler yet.
This example keeps only PR numbers and merge times and does not prune. Add bounded retention work before running it indefinitely. Delete old source rows and their expired count buckets in the same transaction. Keep buckets needed for historic rates. Rolling windows with partial days require source rows for those days, or finer buckets. A query cannot read unlimited rows by selecting fewer fields. There is no projection API. Subscriptions can share query execution, but there is no configurable time-based query cache. Each subscriber still uses the request quota.
## Relationships
Use `id("table")` fields and bounded application-level traversal. Lakebed does not expose SQL joins.
```ts
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) });
}
```
Many-to-many relationships use an explicit join table with indexes for both directions.
## User references
Use `userId()` for a Lakebed user reference. Use `id("table")` for a row in an app table. These are different references, even if your app has a table named `users`.
```ts
import { string, table, userId } from "lakebed/server";
const todos = table({
text: string(),
ownerId: userId()
}).index("by_owner", ["ownerId"]);
```
`userId()` stores an opaque nonempty string. It marks the field for automatic transfer when a protected guest signs in. Plain strings do not get this behavior. Writes and queries store and match the value you give them. Do not write a retired guest ID after its upgrade. Use the caller's current `userId`.
The field does not authorize reads or writes. Use `ctx.auth.requireIdentity()` for a verified guest or account, filter by its `userId`, and check ownership before updates or deletes. Use `ctx.auth.requireSignedIn()` when guests must not use an operation.
Automatic transfer preserves existing account rows. Use `auth.onGuestUpgrade` for app-specific merge rules, such as keeping existing account settings. The hook and reference transfer run in one transaction. See the [auth guide](https://docs.lakebed.dev/auth/index.md).
User references and upgrade hooks require database API v1. Rebuild and redeploy older capsules before using them. Changing a field from `string()` to `userId()` does not assign old shared guest data to a visitor.
## Consistency
- Query handlers receive a read-only database and one repeatable snapshot.
- Mutations and endpoints commit row and index writes atomically.
- An endpoint with `readOnly: true` uses read-only database access and cannot write.
- Mutations read their own writes.
- Writes are serialized per deploy in v1.
- Worker failure, timeout, quota failure, or store errors roll back the session.
## Migrating Legacy Code
Add `async` to handlers, add `await` to every database terminal, declare indexes, and replace:
- `where(...).orderBy(...).limit(...).all()` with `withIndex(...).order(...).take(...)`
- `all()` with `collect()` or `paginate()`
- synchronous `get`, `insert`, `update`, and `delete` with awaited calls
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](https://docs.lakebed.dev/database-migration/index.md) for complete before-and-after examples and a
copy-paste prompt for migrating a capsule with an agent.
---
title: Database API v1 Migration Guide
source: docs/database-migration.md
url: https://docs.lakebed.dev/database-migration/
markdown: https://docs.lakebed.dev/database-migration/index.md
---
# 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:
```ts
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, with `userId` imported from `lakebed/server`:
```ts
schema: {
todos: table({
text: string(),
done: boolean().default(false),
ownerId: userId()
}).index("by_owner", ["ownerId"])
},
queries: {
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
const todos = await ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.order("desc")
.collect();
return todos;
})
}
```
Legacy mutation:
```ts
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:
```ts
setTodoDone: mutation(async (ctx, id: string, done: boolean) => {
const { userId } = ctx.auth.requireIdentity();
const todo = await ctx.db.todos.get(id);
if (!todo || todo.ownerId !== userId) {
return;
}
await ctx.db.todos.update(id, { done });
})
```
Apply these replacements:
- Add `async` to every query, mutation, or endpoint handler that uses `ctx.db`.
- Await `get`, `insert`, `update`, `delete`, `collect`, `take`, `first`, and `paginate`. 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(...)` and `withIndex(...)`.
- Replace `orderBy(field, direction)` with index traversal and `order(direction)`.
- Replace `all()` with `collect()`.
- Replace `limit(count).all()` with `take(count)`.
- Use `first()` when only one matching row is needed.
- Use `paginate({ cursor, numItems })` for unbounded lists.
## Designing Indexes
Declare indexes on the table:
```ts
todos: table({
text: string(),
done: boolean().default(false),
ownerId: userId()
})
.index("by_owner", ["ownerId"])
.index("by_owner_done", ["ownerId", "done"])
```
Then query the matching index:
```ts
const { userId } = ctx.auth.requireIdentity();
const doneTodos = await ctx.db.todos
.withIndex("by_owner_done", (q) =>
q.eq("ownerId", userId).eq("done", true)
)
.order("desc")
.collect();
```
Index rules:
- The fields passed to `withIndex` must follow the index field order.
- Use zero or more `eq` clauses, then optionally one `gt`, `gte`, `lt`, or `lte` clause on the next index field.
- Every index has an implicit `createdAt, id` suffix for deterministic ordering.
- Every table has a built-in `by_creation` index. Use it for an unfiltered newest-first or oldest-first feed.
- Use `id("tableName")` for references to an app table and `userId()` for Lakebed user references. Use `string()` for ordinary text and external identifiers.
- 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:
```ts
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:
```tsx
const messages = usePaginatedQuery(
"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.
- Adding a required field to a table that already has rows fails with `Missing value for
.`. Make the
new field `.optional()` or give it a `.default(value)`. Both activate without rewriting rows.
- Query handlers are read-only. Mutations and endpoints can write, unless the endpoint sets `readOnly: true`.
- 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.
## Guest identities and stored user IDs
New guests have separate protected browser sessions. Get the caller with `ctx.auth.requireIdentity()` before using its non-null `userId`. Use `ctx.auth.requireSignedIn()` for account-only operations, or set `auth: { requireSignIn: true }` to block all app data access before sign-in.
Change Lakebed user fields from `string()` to `userId()`. Stored IDs remain strings, but these fields now participate in automatic guest upgrades. Plain strings, JSON text, and external data do not transfer. Review any app-specific conflict rules before enabling upgrades. The [auth guide](https://docs.lakebed.dev/auth/index.md) shows `auth.onGuestUpgrade` for those rules.
Old `guest:local` rows were shared. They must not become the first signed-in visitor's personal data. Lakebed does not auto-claim them. Preserve them until you choose an explicit policy. If the app intentionally shares rows, express that with a shared query rather than a shared guest owner.
## Agent Migration Prompt
Give the following prompt to an agent from the root of an existing Lakebed capsule:
```text
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(), number(), id("table"), and userId() as
appropriate. Chain .optional() or .default(value) on a field when needed.
A new field on a table with existing rows must be .optional() or have a
.default().
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") for app table references and userId() for Lakebed user
references. Keep ordinary text and external IDs as string(). Use
ctx.auth.requireIdentity() before private access, or ctx.auth.requireSignedIn() for
account-only operations. Do not assign old guest:local rows to a visitor.
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 . --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.
```
---
title: Capsule API
source: docs/capsule-api.md
url: https://docs.lakebed.dev/capsule-api/
markdown: https://docs.lakebed.dev/capsule-api/index.md
---
# Capsule API
This page shows the API shape an agent should use when authoring a Lakebed app.
## File Layout
```txt
server/index.ts
client/index.tsx
shared/
```
Use `server/index.ts` for schema, queries, mutations, actions, and external endpoints. Use `client/index.tsx` for the Preact UI. Put validation helpers, types, and constants in `shared/` when both sides need them.
## Define The Server
```ts
import { action, boolean, capsule, mutation, query, string, table, userId } from "lakebed/server";
import { cleanTodoText } from "../shared/todo";
export default capsule({
auth: { requireSignIn: false },
schema: {
todos: table({
text: string(),
done: boolean().default(false),
ownerId: userId()
}).index("by_owner", ["ownerId"])
},
queries: {
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.order("desc")
.collect();
})
},
mutations: {
addTodo: mutation(async (ctx, text: string) => {
const { userId } = ctx.auth.requireIdentity();
const cleanText = cleanTodoText(text);
if (!cleanText) {
return;
}
await ctx.db.todos.insert({
text: cleanText,
done: false,
ownerId: userId
});
}),
setTodoDone: mutation(async (ctx, id: string, done: boolean) => {
const { userId } = ctx.auth.requireIdentity();
const todo = await ctx.db.todos.get(id);
if (!todo || todo.ownerId !== userId) {
return;
}
await ctx.db.todos.update(id, { done });
})
},
actions: {
summarizeTodos: action(async (ctx, label: string) => {
const { userId } = ctx.auth.requireIdentity();
const todos = await ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
return { count: todos.length, label };
})
}
});
```
The important pattern is server authority:
- Queries decide which rows the client can read.
- Mutations validate input before writing.
- Mutations re-check ownership before changing existing rows.
- Client code never writes directly to tables.
`userId()` marks a Lakebed user reference. Those references transfer when a protected guest signs in. The field does not replace owner filters or authorize access by itself.
## Handler capabilities
| Capability | Query | Mutation | Action | Endpoint |
| --- | --- | --- | --- | --- |
| Read `ctx.db` | Yes | Yes | Yes | Yes |
| Write `ctx.db` | No | Yes | No | Writable endpoints only |
| Global `fetch()` | Local or claimed deploy | Local or claimed deploy | Local or claimed deploy | Local or claimed deploy |
| `ctx.env` | Local or claimed deploy | Local or claimed deploy | Local or claimed deploy | Local or claimed deploy |
| `ctx.auth`, `ctx.log` | Yes | Yes | Yes | Yes |
| Hosted daily quota | Request | Request + mutation | Request + mutation | Request, plus mutation for writable endpoints |
| Execution budget | Transaction | Transaction | Action and database limits | Transaction |
Use global `fetch(url, options)`, not `ctx.fetch`. A writable endpoint can fetch external data and write rows in the same handler. No browser or action-to-mutation handoff is needed. See the [dashboard ingest example](https://docs.lakebed.dev/database/index.md#dashboard-counts).
Every database handler runs in a transaction. A fetch inside a mutation or writable endpoint uses its time budget and can hold up other writes on that deploy. Keep each ingest call small. Database rollback cannot undo an external request. Queries can fetch, but an external data change does not invalidate a query subscription. Fetch into stored rows when the UI needs reactive updates.
The mutation quota counts handler calls, not individual row writes. A writable endpoint uses it even if that invocation writes no rows. Actions also use it despite having read-only database access. The [limits page](https://docs.lakebed.dev/limits/index.md) defines all numeric budgets and errors.
`ctx.auth` describes the caller and can have no identity for an external HTTP caller. Keep `auth.requireSignIn` off for a public dashboard, and protect its ingest endpoint with an app secret. A deploy token from `lakebed token create` authorizes CLI operations, not app endpoint identity. See [endpoint credentials](https://docs.lakebed.dev/auth/index.md#custom-endpoint-credentials).
Anonymous deploys have no hosted server env and cannot fetch. Claim and redeploy before using either. Local dev reads `.env.lakebed.server`. Fetch must run inside a handler, not at module scope. The `auth.onGuestUpgrade` hook cannot fetch even on a claimed deploy.
Lakebed does not yet provide scheduled handlers, delayed jobs, or a server API for calling another handler. For periodic ingest, an external scheduler can call a protected endpoint. Return a cursor from each bounded call and let that caller request the next batch. See [dashboard counts](https://docs.lakebed.dev/database/index.md#dashboard-counts) for the current pattern and its limits.
## Use Shared Code Carefully
Good shared code:
```ts
export function cleanTodoText(value: string): string {
return value.trim().slice(0, 160);
}
```
Keep `shared/` pure. Do not import `lakebed/server`, `lakebed/client`, Preact, DOM APIs, Node built-ins, env values, or secrets from shared files.
## Build The Client
```tsx
import { canAccessApp, createClient, retryAuth, SignInWithGoogle, signOut, useAuth } from "lakebed/client";
import type app from "../server/index";
import { cleanTodoText } from "../shared/todo";
const client = createClient();
export function App() {
const auth = useAuth();
if (auth.isLoading) {
return Checking session;
}
if (!canAccessApp()) {
return (
{auth.error ?
{auth.error}
:
Sign in to use this app.
}
{auth.error ? : null}
);
}
return ;
}
function TodoApp() {
const auth = useAuth();
const todos = client.useQuery("todos") ?? [];
const addTodo = client.useMutation("addTodo");
const setTodoDone = client.useMutation("setTodoDone");
const authLabel = auth.displayName;
const authStatus = auth.isSignedIn ? `Signed in as ${authLabel}` : "Using this browser";
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const data = new FormData(form);
const text = cleanTodoText(String(data.get("text") ?? ""));
if (!text) {
return;
}
await addTodo(text);
form.reset();
}
return (
{auth.picture ? (
) : null}
{authStatus}
{auth.isGuest ? (
) : (
)}
{auth.isGuest ?
Sign in to keep your todos with your account.
: null}
{todos.map((todo) => (
))}
);
}
```
Client rules:
- Export `App`.
- Import the server definition with `import type`, then call `createClient()` to infer handler names, arguments, and results.
- Call queries by the names defined in `server/index.ts`. `client.useQuery` returns `undefined` until the first result arrives, so narrow it or default it with `?? []`.
- Call mutations by the names defined in `server/index.ts`.
- Call actions by the names defined in `server/index.ts`.
- Await mutations when the UI should wait for the server write.
- Use the built-in client router for multiple pages.
- Use complete Tailwind class names in JSX for styling. Lakebed compiles CSS automatically from client files and their imports. Use inline styles for values loaded at runtime.
Client routes use Preact components and app-relative paths:
```tsx
import { Link, Route, Router, Routes, useParams } from "lakebed/client";
function TodoPage() {
const { id } = useParams<{ id: string }>();
return Todo {id};
}
export function App() {
return (
Open todo
Home} />
} />
Not found} />
);
}
```
Use server endpoints for HTTP APIs and webhooks. If a `GET` endpoint and a client route use the same path, opening that URL or reloading it returns the endpoint response instead of the app shell. A `` navigation within an already loaded app stays in the client router. Use separate paths, such as `/api/summary` for the endpoint and `/summary` for the page, so reloads and shared links work.
## Handle client errors
Lakebed wraps the generated app root in ``. Add your own around a subtree when you want a different fallback:
```tsx
import { ErrorBoundary } from "lakebed/client";
(
{error.message}
)}
>
;
```
`ErrorBoundaryProps` is `{ children, fallback? }`. `fallback` receives the error and a `retry` function that re-renders the subtree. Without it, the boundary shows Lakebed's default retry message.
## Return endpoint responses
An endpoint handler returns one of four response helpers from `lakebed/server`:
| Helper | Result |
| --- | --- |
| `json(value, options?)` | JSON body, status `200`, `Content-Type: application/json; charset=utf-8`. |
| `text(value, options?)` | Plain text body, status `200`, `Content-Type: text/plain; charset=utf-8`. |
| `empty(options?)` | No body, status `204`. |
| `redirect(url, options?)` | No body, status `302`, `Location: `. |
Every helper takes the same `EndpointResponseOptions`: `{ status?, headers? }`. Set `status` to override the default code, and `headers` to add or replace headers.
```ts
import { empty, endpoint, redirect } from "lakebed/server";
endpoints: {
ping: endpoint({ method: "POST", path: "/api/ping" }, async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
await ctx.db.pings.insert({ ownerId: userId });
return empty();
}),
docsLink: endpoint({ method: "GET", path: "/go/docs", readOnly: true }, () =>
redirect("https://docs.lakebed.dev", { status: 301 })
)
}
```
## Log from server handlers
`ctx.log` writes structured entries that `npx lakebed logs` reads back. It has three levels and an optional JSON data argument:
```ts
ctx.log.info("order created", { id: order.id });
ctx.log.warn("payment retried", { attempt });
ctx.log.error("payment failed", { reason });
```
Lakebed keeps a bounded log buffer per deploy and drops the oldest entries when it fills. See [`limits.md`](https://docs.lakebed.dev/limits/index.md) for the entry, byte, and per-entry sizes. `npx lakebed dev` keeps the same number of entries in memory and applies no byte limits. `/__lakebed/logs` and `npx lakebed logs` return the last 100 entries on both.
## Run actions
Actions run server code with read-only database access. Call an action from a client component:
```tsx
const summarizeTodos = client.useAction("summarizeTodos");
const summary = await summarizeTodos("today");
```
The server handler receives `ctx.auth`, read-only `ctx.db`, `ctx.env`, and `ctx.log`. Action arguments and results must be JSON-compatible and fit the [action size and runtime limits](https://docs.lakebed.dev/limits/index.md).
Hosted actions count against request and mutation limits. Anonymous deployments cannot use outbound `fetch` or hosted server env. Claim the deployment before an action uses either capability.
## Auth
Use auth through Lakebed APIs only.
Server:
```ts
const identity = ctx.auth.requireIdentity(); // Guest or signed-in account.
identity.userId;
identity.displayName;
identity.picture;
identity.email;
const account = ctx.auth.requireSignedIn(); // Rejects guests and missing sessions.
```
Client:
```tsx
const auth = useAuth();
auth.isGuest;
auth.isSignedIn;
```
Default guests have separate protected browser sessions. Their user IDs are not credentials. `isGuest` and `isSignedIn` are both false without a session. `isAuthenticated` is a compatibility alias for `isSignedIn`.
Set `auth: { requireSignIn: true }` to block all app data operations before sign-in. The shell and auth routes still load. Use `auth.requireSignIn` on the client to choose which UI to show. The server policy enforces access.
Store user references with `userId()`, not `string()`. Lakebed transfers these references during a verified guest upgrade. Use `auth.onGuestUpgrade` for app-specific conflicts before automatic transfer. See the [auth guide](https://docs.lakebed.dev/auth/index.md) for the transaction contract and an example.
To test named local users, use separate URLs:
```txt
http://localhost:3000/?lakebed_guest=alice
http://localhost:3000/?lakebed_guest=bob
```
Named guests are local test overrides and cannot upgrade. Use the default browser session to test guest sign-in and transfer.
To add Google sign-in, render `` or call `signInWithGoogle()` from a custom button. Use `auth.error` and `retryAuth()` to show and retry a failed session setup or guest upgrade. Keep sign-in available with the error. Retry cannot renew an expired or revoked token.
## Server Env
Add server-only values at the capsule root:
```txt
# .env.lakebed.server
OPENAI_API_KEY=sk-...
```
Read them only from server handlers:
```ts
queries: {
hasOpenAiKey: query((ctx) => Boolean(ctx.env.OPENAI_API_KEY))
}
```
External endpoints can use the same env binding for webhook secrets:
```ts
import { endpoint, json, text } from "lakebed/server";
endpoints: {
webhook: endpoint({ method: "POST", path: "/webhooks/incoming" }, async (ctx, req) => {
if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
return text("unauthorized", { status: 401 });
}
return json({ ok: true });
})
}
```
Do not put secrets in `client/` or `shared/`.
Custom endpoints can read app-owned Bearer or Basic credentials from `Authorization`. Lakebed account identity uses the separate reserved `X-Lakebed-Token` header. See [endpoint credentials](https://docs.lakebed.dev/auth/index.md#custom-endpoint-credentials) for the request format and guest cookie behavior.
## Types
`lakebed/server` and `lakebed/client` export types as well as values. You need them only when you write a helper that takes `ctx`, a request, or a client result. Everything else is inferred.
Type a helper that runs inside a handler:
```ts
import { mutation, type ServerContext } from "lakebed/server";
async function markDone(ctx: ServerContext, id: string): Promise {
await ctx.db.todos.update(id, { done: true });
}
mutations: {
finish: mutation((ctx, id: string) => markDone(ctx, id))
}
```
`lakebed/server` types:
| Type | What it describes |
| --- | --- |
| `QueryServerContext` | The `ctx` a `query()` handler receives, and the `ctx` a `readOnly: true` `endpoint()` handler receives. |
| `ServerContext` | The `ctx` a `mutation()` handler or a writable `endpoint()` handler receives. Its `db` can write. |
| `ActionServerContext` | The `ctx` an `action()` handler receives. |
| `AuthContext` | `ctx.auth`: the caller plus the `requireIdentity()` and `requireSignedIn()` guards. |
| `CapsuleAuth` | The `auth` key of `capsule()`: `{ requireSignIn?, onGuestUpgrade? }`. |
| `GuestUpgrade` | The `{ guestUserId, userId }` an `onGuestUpgrade` handler receives. |
| `DbContext` | Read-only `ctx.db`, used by queries and actions. |
| `MutationDbContext` | Writable `ctx.db`, used by mutations and endpoints that are not `readOnly`. |
| `LogContext` | `ctx.log`, with `info`, `warn`, and `error`. |
| `EndpointRoute` | The first argument of `endpoint()`: `{ method, path, readOnly? }`. |
| `EndpointRequest` | The `req` an endpoint handler receives. |
| `EndpointHeaders` | `req.headers`, with `get`, `has`, and `entries`. |
| `EndpointResponse` | What `json`, `text`, `empty`, and `redirect` return. |
| `EndpointResponseOptions` | `{ status?, headers? }` for those four helpers. |
| `EndpointDefinition` | What `endpoint()` returns, so an `endpoints` map can be typed. |
| `Id` | A row id tagged with the table it points at. |
| `Field` | One column: what `string()`, `boolean()`, `number()`, `id()`, and `userId()` return. |
| `TableDefinition` | What `table()` returns. |
| `Schema` | The `schema` map of table definitions. |
| `DefaultSchema` | The schema a capsule gets when it declares none. |
| `PaginationOptions` | `{ cursor, numItems }`, the argument of `.paginate()`. |
| `PaginationResult` | `{ page, isDone, continueCursor }`, the result of `.paginate()`. |
| `Capsule` | What `capsule()` returns. |
| `CapsuleFavicon` | The `favicon` key, a relative path string. |
| `SchemaOf`, `ReadDatabaseOf`, `WriteDatabaseOf` | Pull the schema or a database shape out of a capsule definition. |
| `QueryName`, `QueryArgs`, `QueryResult` | The declared query names and their argument and result types. |
| `MutationName`, `MutationArgs`, `MutationResult` | The same for mutations. |
| `ActionName`, `ActionArgs`, `ActionResult` | The same for actions. |
`lakebed/client` types:
| Type | What it describes |
| --- | --- |
| `LakebedClient` | What `createClient()` returns. |
| `PaginatedQueryResult` | What `usePaginatedQuery` returns: a page plus `loadMore()` and `reset()`. |
| `ErrorBoundaryProps` | The props of ``. |
| `StorageClient` | `client.storage`. See [`storage.md`](https://docs.lakebed.dev/storage/index.md). |
| `UploadedObject` | What `client.storage.upload` resolves to. |
| `AuthValue` | What `useAuth()` returns: the identity plus `isLoading`, `error`, and `requireSignIn`. |
| `IdentityClaims` | The claims `decodeIdentityClaims` returns. See [`auth.md`](https://docs.lakebed.dev/auth/index.md). |
## Run And Inspect
```sh
npx lakebed dev
npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed logs --port 3000
```
The database is local and in-memory during `npx lakebed dev`. Restarting dev resets it.
Hosted inspection is private by default. Run hosted inspection commands from the capsule directory so Lakebed can find `lakebed.json` or `.lakebed/deploy.json` in the working directory tree and send developer auth from the binding or saved anonymous claim token. Non-private hosted manifests expose only non-sensitive deploy metadata.
## Deploy
```sh
npx lakebed deploy
```
If the app uses `.env.lakebed.server` or outbound server-side `fetch`, claim the deploy and run `npx lakebed deploy` again so Lakebed can publish the source-backed server path.
For a portable owned deploy, run `npx lakebed auth login` before the first deploy. Commit the generated root-level `lakebed.json`, which contains only the deploy id.
Use `npx lakebed deploy --public-inspect` only for demos where making hosted data and logs public is intentional.
After you sign in, manage owned deployments with:
```sh
npx lakebed deploy list
npx lakebed deploy archive
npx lakebed deploy restore
npx lakebed deploy terminate --yes
```
An archived app stops serving until you restore it. A terminated app stops serving, cannot receive updates, and cannot be restored. Both commands retain the deploy record, data, logs, usage records, server env, domain mappings, and referenced artifact.
Unclaimed deploys expire and are deleted about one week after expiry. Claimed deploys do not expire.
---
title: Limits
source: docs/limits.md
url: https://docs.lakebed.dev/limits/
markdown: https://docs.lakebed.dev/limits/index.md
---
# Limits
Every Lakebed deploy runs with resource limits. This page lists the values a default deploy gets, the error you see when
you cross one, and what to change in your capsule.
The numbers below are free-plan defaults. Claiming a deploy does not raise the free-plan limits.
Paid plans can have higher values.
## Default limits
| Limit | Default | When you hit it | What to do |
| --- | --- | --- | --- |
| Requests per deploy per day | `10000` | HTTP 429 with `Retry-After` and `X-RateLimit-*` headers. Body has `code: "lakebed_quota_exceeded"` and `bucket: "requests"`. | Wait for the reset at 00:00 UTC, or cut round trips. One subscription that stays open beats polling. |
| Mutations per deploy per day | `1000` | HTTP 429, `bucket: "mutations"`. Mutations and actions use this quota and also count as requests. | Batch writes into one mutation instead of one call per row. |
| Rows returned per handler | `1000` | Error is `collect() exceeded the 1000 row return limit. Use paginate().` for one collection, or `Database rowsReturned limit exceeded (1000).` for combined reads. | Use `paginate({ cursor, numItems })` instead of `collect()`, or `take(n)` with a small `n`. |
| Rows read per handler | `5000` | Handler throws `Database rowsRead limit exceeded (5000).` | Add an index and use `withIndex` so the query stops scanning rows it throws away. |
| Bytes read per handler | `4 MiB` | Handler throws `Database bytesRead limit exceeded (4194304).` | Return fewer rows, or move large text into object storage. |
| Direct `get` calls per handler | `1000` | Handler throws `Database directGets limit exceeded (1000).` | Replace per-row `get` loops with one indexed query. |
| Index scans per handler | `100` | Handler throws `Database scanCalls limit exceeded (100).` | Fold repeated queries into a single indexed range. |
| Writes per handler | `1000` | Handler throws `Database writes limit exceeded (1000).` | Split the work across several mutations. |
| Bytes per field value | `64 KiB` | Write throws `Value for
. exceeds 65536 bytes.` | Upload the payload with `client.storage.upload` and keep the returned key in the row. |
| Rows stored per deploy | `16384` | Mutation commit fails with a 429, `bucket: "state_rows"`. | Delete rows you no longer need. |
| Bytes stored per deploy | `1 MiB` | Mutation commit fails with a 429, `bucket: "state_bytes"`. | Delete rows, or shorten stored values. |
| Transaction time | `5000 ms` | The transaction is cancelled and rolls back. | Do less per handler. Move slow work to an action. |
| Single statement time | `4500 ms` | The statement is cancelled and the transaction rolls back. | Add an index for the query. |
| Row lock wait | `1000 ms` | The write fails and rolls back when another write holds the row. | Retry, and avoid many clients writing the same row at once. |
| Action arguments | `16 KiB` | Deploy log shows `Action arguments exceed 16384 bytes.` | Pass an id and read the data inside the action. |
| Action result | `48 KiB` | Deploy log shows `Action result exceeds 49152 bytes.` | Return only the fields the client needs. |
| Action runtime | `5000 ms` | The action is cancelled. Deploy log shows `Action exceeded 5000ms.` | Keep outbound calls short. Split long work into several actions. |
| Stored file size | `5 MiB` | Upload fails with HTTP 413 and `code: "too_large"`. | Compress or resize before upload. |
| Stored bytes per developer | `100 MiB` | Upload fails with HTTP 413 and `code: "quota_exceeded"`. | Delete objects you no longer serve. |
| Log entries kept | `1000` | The oldest entries are dropped. No error. | Log less per request, or read logs sooner. |
| Log bytes kept | `256 KiB` | The oldest entries are dropped. No error. | Log less per request. |
| Bytes per log entry | `16 KiB` | Long messages are cut. If the entry is still too large, `data` is replaced with an object that has `truncated: true`. | Log a summary, not the whole object. |
| Artifact size | `1 MiB` | `npx lakebed build` fails and reports the artifact's byte size and the `1048576` byte deploy limit. | Remove unused code and large inline data from the capsule. |
| Server env keys | `64` | Deploy fails with `serverEnv.values may include at most 64 keys.` | Combine related values into one JSON key. |
| Server env key length | `128 bytes` | Deploy fails with `serverEnv.values. exceeds 128 bytes.` | Use a shorter name. |
| Server env value length | `16 KiB` | Deploy fails with `serverEnv.values. exceeds 16384 bytes.` | Keep long payloads out of env. |
| Server env total size | `64 KiB` | Deploy fails with `serverEnv.values exceeds 65536 bytes.` Keys and values both count. | Remove keys you do not read. |
## Reading a quota error
HTTP quota errors use this body:
```json
{
"code": "lakebed_quota_exceeded",
"bucket": "requests",
"current": 10001,
"error": "requests quota exceeded. Limit: 10000.",
"limit": 10000,
"resetAt": "2026-01-02T00:00:00.000Z",
"retryAfterSeconds": 41230,
"suggestion": "Retry after reset or reduce request frequency."
}
```
App WebSocket errors put this body in `quota`. The outer message has `op: "error"`, `ok: false`, the request `id`,
and the same `code` and `error`. Read `message.quota.bucket`, `message.quota.current`, and
`message.quota.retryAfterSeconds` for WebSocket quota details.
HTTP daily quota responses also carry `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
`X-RateLimit-Reset`. Read `bucket` to find the row in the table above. Do not retry a daily quota in a tight loop.
Wait for `retryAfterSeconds`. Stored row and byte quotas have no timed reset. Their `resetAt` and `retryAfterSeconds`
are `null`.
Per-handler database limits fail the handler and roll back its transaction. Stored row and byte limits use the quota
errors in the table.
Action size and time limits fail the action. Hosted clients receive `Lakebed action failed.`. Read the deploy log for
the detailed limit message. Local calls include the detailed message. An action failure does not undo outbound side
effects. For example, Lakebed checks the result size after the action runs. Do not assume a failed action is safe to retry.
## Checking usage
`npx lakebed deploy` prints the artifact, state, request, and mutation limits for the deploy it just published.
Run `npx lakebed inspect --usage` to see stored rows and bytes, and today's request and mutation
counts, beside their limits. Daily counts use UTC. Stored rows and bytes have no timed reset.
Use `--usage --json` for the complete usage response, including recorded daily counters.
For local development, omit the deploy target and use `--port` if needed. Local daily counts are not enforced.
`npx lakebed inspect ` prints per-read database metrics for the deployed capsule:
```txt
read by_room: returned=20 read=20 bytes=4096
read direct: returned=1 read=812 bytes=190334 full-scan
```
`returned`, `read`, and `bytes` are the counters behind the `rowsReturned`, `rowsRead`, and `bytesRead` rows in the
table. A `full-scan` marker means the query had no usable index, and it is the usual cause of a `rowsRead` failure.
## Notes
The hosted cloud also applies a short-window burst limit per client and a separate daily cap per client IP. Normal app
traffic stays well under both. A load test does not.
`npx lakebed dev` does not apply the daily request and mutation quotas, so local iteration is not throttled. The
per-handler database limits, action limits, value size limits, and stored row and byte limits apply locally, so local
tests can catch these failures. Local wording differs: `lakebed dev` reports the stored limits as
`State row limit exceeded (16384).` and `State byte limit exceeded (1048576).` instead of a 429.
See [`database.md`](https://docs.lakebed.dev/database/index.md) for `collect()` and `paginate()`, [`storage.md`](https://docs.lakebed.dev/storage/index.md) for object storage
errors, and [`reference.md`](https://docs.lakebed.dev/reference/index.md) for deploy behavior.
---
title: Identity and authentication
source: docs/auth.md
url: https://docs.lakebed.dev/auth/
markdown: https://docs.lakebed.dev/auth/index.md
---
# Identity and authentication
Lakebed creates protected browser sessions for guests without app setup. Google sign-in is built in. Render `` in dev or a hosted app. You do not need OAuth keys or a separate auth service.
## Guest and signed-in users
Use `ctx.auth` on the server and `useAuth()` on the client. Check `isGuest` and `isSignedIn` separately.
| State | `userId` | `isGuest` | `isSignedIn` |
| --- | --- | --- | --- |
| No session | `null` | `false` | `false` |
| Guest session | A user ID | `true` | `false` |
| Signed-in account | A user ID | `false` | `true` |
A protected guest session does not satisfy a sign-in requirement. Neither flag is true when there is no verified session. On the client, wait for `auth.isLoading` to become false before showing the current state. `isAuthenticated` is an older alias for `isSignedIn`. Use `isSignedIn`.
"No session" is a normal state on the client. It happens when the app requires sign-in, when session setup failed, and when a guest signed in earlier and the account token is gone from this browser. In the last case the retired guest cookie stays as proof of the transfer, so signing in again restores the account. Do not treat "no session" as "guest".
Gate data components on `canAccessApp()` from `lakebed/client`. It is true when session setup finished without an error, a guest or account exists, and the app's sign-in policy is met. Read `auth.requireSignIn` and `auth.error` for the copy you show in the gated state. The server still enforces access.
In the gated state show a Retry button and ``. Retry calls `retryAuth()` to handle temporary failures and a failed sign-out. Google sign-in replaces an expired or revoked token so a pending guest upgrade can finish. A "new guest session" button can call `signOut()` to cancel a pending upgrade and start a fresh guest. Show it only when the app allows guests and there is no session. Warn that the old guest data will be inaccessible from this browser. It does not delete the data.
The server issues and verifies guest session credentials. The browser keeps them in an HttpOnly cookie. `userId` is a separate, opaque identifier, not a secret or proof of access. Do not create your own guest IDs, send a user ID as a credential, or infer sign-in from an ID prefix.
Use a current browser for shared guest sessions across tabs. Browsers without Web Locks can create separate guests if tabs start at the same time.
Separate browser profiles have separate sessions. Guest sessions are specific to the app origin.
### Session and data lifetime
The browser's guest credential expires 30 days after creation. Clearing browser data removes it. Signing out clears or replaces the cookie. A successful upgrade keeps the cookie for retries, but it no longer grants guest or account access.
Session records have a separate lifetime. Lakebed can remove expired records for guests who never upgraded. It keeps completed upgrade links so `userId()` references still resolve to the account.
Credential expiry, sign-out, and session-record cleanup do not delete app rows. Sign in before losing the guest credential to keep access to your data through an account.
Deleting a deployment removes its guest session records and app database rows. Termination alone does not delete them. Expired anonymous deployments can be deleted later. Local database contents reset when `lakebed dev` restarts.
An anonymous deployment and a guest user are different things. Deployment ownership controls who manages the app. Guest and signed-in sessions identify people who use the app.
## Require an identity
Use `requireIdentity()` when an operation allows guests and signed-in accounts. It returns an identity with a non-null `userId`, or rejects the request.
```ts
const { userId } = ctx.auth.requireIdentity();
const todos = await ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.collect();
```
Use `requireSignedIn()` when an operation requires an account. It rejects guests and requests without a session.
```ts
const { userId } = ctx.auth.requireSignedIn();
```
Always filter user-owned reads and check row ownership before updates or deletes. The auth guard identifies the caller. It does not authorize access to every row.
## Custom endpoint credentials
On custom endpoints, `Authorization` belongs to your app. Lakebed passes it to `req.headers.get("authorization")` unchanged, so webhooks can use Bearer or Basic credentials. That header does not establish a Lakebed identity on a custom endpoint.
To supply a signed-in Lakebed identity, send the raw identity token in the reserved `X-Lakebed-Token` header. Do not add a `Bearer` prefix. Both headers can appear on the same request:
```http
X-Lakebed-Token:
Authorization: Bearer
```
Lakebed verifies and removes `X-Lakebed-Token` before the endpoint runs. Read the identity from `ctx.auth` and use the auth guards. Same-origin browser requests can use their guest session cookie, but a guest does not satisfy `requireSignedIn()`.
For a custom browser request, read the token after session setup. Send it only to your app's origin:
```ts
import { getIdentity } from "lakebed/client";
const token = getIdentity().token;
const response = await fetch("/api/status", {
headers: token ? { "X-Lakebed-Token": token } : {}
});
```
Built-in auth and storage routes still accept `Authorization: Bearer `. The client SDK handles credentials for its built-in calls.
`getIdentity()` returns `{ userId, token?, expired? }` read from browser storage. `userId` is `null` when there is no session. `expired` is `true` when the stored token is past its lifetime, so send the request without a token and let the user sign in again.
`decodeIdentityClaims(token)` returns that token's `IdentityClaims`, or `null`. It reads the payload and does not check the signature, so use it for display only. Never decide access with it. The server verifies every token before it builds `ctx.auth`.
## Require sign-in for the app
Set the policy on the capsule:
```ts
export default capsule({
auth: { requireSignIn: true },
// schema, queries, mutations, actions, and endpoints
});
```
Lakebed checks this policy on the server before app data operations run. Guests and requests without a session cannot run queries, mutations, actions, endpoints, or use the app's stored-file routes. The app shell and auth routes remain available so users can sign in. Client code and static assets are not secrets.
The default is `requireSignIn: false`. That permits guest access where the app's handlers allow it. Use `requireSignedIn()` inside selected handlers when only some operations require an account. Hiding a button in the client is not an access check.
Use the current SDK to change this policy, then rebuild and redeploy. To allow guests again, set `auth: { requireSignIn: false }`.
The app-wide policy also blocks external webhook callers that do not have a signed-in Lakebed session. For an app that accepts webhooks, leave the app-wide policy off, guard user operations, and verify webhook credentials in each webhook handler.
## User references and guest upgrades
Store references to Lakebed users with `userId()` from `lakebed/server`:
```ts
import { boolean, string, table, userId } from "lakebed/server";
const todos = table({
text: string(),
done: boolean().default(false),
ownerId: userId()
}).index("by_owner", ["ownerId"]);
```
Fields declared with `userId()` follow a guest to their account after sign-in. Lakebed verifies both sessions before transfer. Your app must not accept a caller-supplied guest ID as proof. The same flow works for a new account and an existing account.
The todo starter needs no migration hook. It transfers the guest's `ownerId` references to the signed-in user. Existing account todos remain, and guest todos join the list. Each row keeps its own ID.
Reference transfer and an optional upgrade hook commit in one transaction. A failure leaves the guest data unchanged. Retrying the upgrade does not transfer the same guest twice. Old guest credentials cannot access the signed-in account after transfer.
Only fields declared with `userId()` transfer. Lakebed does not inspect plain strings, JSON text, concatenated keys, cached profile names, or external services for user IDs. Use `id("table")` for app table references and `string()` for ordinary text or external identifiers.
`userId()` does not create an ownership rule. A shared feed can store `authorId: userId()` and deliberately query all entries without an owner filter. Do not represent shared state with a fake user such as `"global"`.
### Choose how conflicting records combine
Automatic transfer preserves rows. It cannot decide which settings to keep when a guest and an existing account both have settings.
Use `auth.onGuestUpgrade` for that decision. The hook receives verified `guestUserId` and `userId` values. It runs before automatic reference transfer, in the same database transaction. For example, keep existing account settings and discard guest settings only when the account already has a row:
```ts
import { capsule, string, table, userId } from "lakebed/server";
export default capsule({
schema: {
settings: table({
ownerId: userId(),
theme: string()
}).index("by_owner", ["ownerId"])
},
auth: {
onGuestUpgrade: async (ctx, { guestUserId, userId }) => {
const accountSettings = await ctx.db.settings
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.first();
if (!accountSettings) {
return;
}
const guestSettings = await ctx.db.settings
.withIndex("by_owner", (q) => q.eq("ownerId", guestUserId))
.collect();
for (const settings of guestSettings) {
await ctx.db.settings.delete(settings.id);
}
}
}
});
```
If there are no account settings, automatic transfer keeps the guest settings. Keep this hook to database merge rules. Do not use outbound `fetch` in this hook. The hosted runtime rejects it. Do not send email, charge a customer, or make other external changes from the hook. Its database work can roll back or retry. External side effects cannot roll back with it.
## Signed-in identity and profile data
A signed-in account has one stable authorization key. Its `userId` equals `subject`. It does not encode the deploy hostname, so a generated app URL and a custom domain resolve to the same signed-in `userId`. A guest upgrade changes guest references to this account ID.
`ctx.auth.identityAliases` lists older audience-scoped aliases. Use them only to migrate rows created before stable account IDs existed. Never key new data on an alias.
Email, `emailVerified`, name, and picture are profile data. A verified email can help locate a pending invitation. It is not an authorization key or evidence of access. Email changes do not change `userId`, and two accounts with the same email remain distinct users.
Google sign-in requests profile data by default. To sign in without requesting email, name, or picture, set `requestPii` to `false`:
```tsx
await signInWithGoogle({ requestPii: false });
```
`requestProfile={false}` is also supported. If both options are set, `requestPii` takes precedence. Lakebed exposes profile fields only after the user approves the request.
Signed-in tokens are bound to their exact origin. A bad token or unavailable verifier cannot grant signed-in access. A verifier outage does not erase the browser's stored token. Revocation applies to new requests and live subscriptions. Signing out clears private query caches. Signing in after revocation restores the same account ID, but old tokens stay invalid. Deleting an account invalidates its tokens.
## Local development
`npx lakebed dev` uses protected guest sessions and real Google sign-in on localhost. No auth configuration is needed.
For repeatable local tests, use named guest overrides:
```sh
npx lakebed auth as alice
npx lakebed auth reset
```
You can also use `?lakebed_guest=alice` and `?lakebed_guest=bob` in separate tabs. Named overrides are test identities, not protected browser sessions. They work only in local development and cannot upgrade to an account. `auth reset` restores normal protected browser sessions. Test a real guest upgrade with the default session and no named override.
## Upgrade an existing capsule
Rebuild and redeploy existing capsules with the current SDK. Older client bundles do not run the protected guest-session setup.
Change fields that contain Lakebed user IDs from `string()` to `userId()`. Stored values are strings. When applying the new schema, Lakebed also resolves guest references from upgrades it previously verified. It does not infer account ownership from unknown IDs. Review IDs stored in plain strings, JSON text, or external systems separately.
`userId` and `provider` are `null` when there is no session. Use `ctx.auth.requireIdentity()` or `ctx.auth.requireSignedIn()` before using the caller's user ID. Custom endpoint callers must send Lakebed tokens in `X-Lakebed-Token`. `Authorization` remains available for app credentials.
Old `guest:local` rows belonged to the shared guest. Lakebed never assigns them to the first visitor who signs in. Choose an explicit policy for that old shared data. Do not guess ownership from a name, email, or the browser that happens to visit next.
Identities from the previous external auth provider are not automatically linked to current accounts. Require explicit reauthorization or account linking for those migrations. Never infer a link from email. `identityAliases` covers older Lakebed account aliases, not external-provider subjects.
## Restricted apps and `lakebed users`
Restricted access, where only people you approve can open an app, cannot be turned on in the alpha: `capsule()` has no option for it. For a deploy that is already restricted, `npx lakebed users list`, `approve`, `deny`, and `remove` manage who may open it, by user id or email, from the capsule directory or with a deploy id. On a public deploy these commands say there is nothing to manage.
---
title: Object storage
source: docs/storage.md
url: https://docs.lakebed.dev/storage/
markdown: https://docs.lakebed.dev/storage/index.md
---
# Object storage
Lakebed has built-in object storage for user-uploaded files such as avatars, attachments, and documents. File bytes never pass through your queries, mutations, or custom application endpoints.
Storage uses HTTP routes at your app's origin and a client SDK for uploads and deletes. There is no `ctx.storage` server-side API in this version.
## Upload and delete from the client
The SDK lives on your Lakebed client as `client.storage`:
```ts
const { key, url } = await client.storage.upload(file, { public: true });
await client.storage.delete(key);
```
`upload` takes a browser `File` or `Blob`. It returns:
- `key`: `"public/"` or `"private/"`. Store this if you ever want to delete the object.
- `url`: an absolute URL for the stored object. Public URLs work directly in `` when the app allows public reads. Private URLs require an authenticated request.
- `size` and `contentType`: the stored byte length and MIME type.
Pass `{ public: true }` to make the object public. Anyone with the URL can read it if the app allows guests and the deployment is unrestricted. Omit the option to keep the object private to signed-in users of this deployment.
`client.storage` has type `StorageClient`, and `upload` resolves to an `UploadedObject`. Import both from `lakebed/client` when you pass the result through your own functions:
```ts
import type { UploadedObject } from "lakebed/client";
async function saveAvatar(object: UploadedObject): Promise {
await setAvatar(object.url);
}
```
Hosted uploads and deletes require a claimed deployment and a signed-in Google account with a verified email. Local development does not have these requirements.
## Read stored objects
Use a public object's returned `url` directly in an element:
```tsx
```
Public URLs still follow the app's access policy. A private URL requires an authenticated request from a signed-in user of the same deployment. The returned private URL does not include authentication, so it does not work as an `` or other anonymous browser request. The SDK does not provide a private object download method.
If the capsule sets `auth: { requireSignIn: true }`, stored-file routes also require sign-in, including public object routes. Restricted deployments also require an approved account. A plain `` request does not add a sign-in token. To serve images without tokens, use an unrestricted deployment, leave the app-wide sign-in policy off, and guard private operations in their handlers. Changing the policy does not remove copies already downloaded or cached.
## HTTP routes
These routes are available at your app's origin. The client SDK uses them for uploads and deletes.
Built-in storage routes accept a Lakebed identity token in `Authorization: Bearer `. Custom app endpoints use a [separate header for Lakebed identity](https://docs.lakebed.dev/auth/index.md#custom-endpoint-credentials).
```http
POST /storage # upload
GET /storage/public/ # public download, subject to app access policy
GET /storage/private/ # private download (signed-in user)
DELETE /storage/public/ # delete
DELETE /storage/private/ # delete
```
- `POST /storage` requires a claimed deployment and an authenticated Google account with a verified email. Add `?public=true` to make the object public. Objects are private by default. The request body contains the file bytes, and the `Content-Type` header sets the stored type. Returns `201` with JSON `{ key, url, size, contentType }`.
- `GET /storage/public/` requires no sign-in only on unrestricted deployments with `requireSignIn: false`. Those responses use `Cache-Control: public, max-age=3600`. Sign-in-only or restricted apps require an account that passes the app's access policy and use `Cache-Control: private, no-store`.
- `GET /storage/private/` requires an authenticated request from a signed-in user of the same deployment.
- `DELETE /storage/public/` or `DELETE /storage/private/` requires a claimed deployment and an authenticated Google account with a verified email. Deletes are idempotent.
## Authentication and safety
- Hosted uploads and deletes require a claimed deployment and a signed-in Google account with a verified email. Guests cannot upload. Each stored object records the uploader's ID, email, and upload time.
- On unrestricted apps that allow guests, anyone with a public object's URL can read it. The `id` is an unguessable random token, so treat the URL as a read credential. Sign-in-only and restricted apps still enforce their access policy.
- Private objects require an authenticated request from a signed-in user of the same deployment. Store private object keys in your database and return them only from queries that verify ownership.
## Blocked file types
Storage is for user files (images, documents, media), not code. Uploads are rejected with `415 blocked_type` when either:
- the declared `Content-Type` is an executable, installer, script, HTML, or JavaScript type (for example `application/x-msdownload`, `application/java-archive`, `text/html`, `text/javascript`), or
- the file's leading bytes identify an executable regardless of declared type: Windows PE (`.exe`/`.dll`), ELF, Mach-O, Java class files, and shebang scripts, plus obvious HTML documents.
Serving is hardened independently: every download is sent with `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and anything stored with an HTML or JavaScript content type is served as `application/octet-stream` with `Content-Disposition: attachment`, never renderable. SVG is allowed and safe to embed with ``; the sandbox CSP prevents script execution if one is opened directly.
## Developer dashboard and moderation
Deploy owners can see and moderate everything their users uploaded:
- **Dashboard**: `/deploys//storage` on the Lakebed dashboard host lists each file's key, visibility, size, type, uploader, and age, with per-file delete.
- **Owner delete**: `DELETE /v1/me/deploys//storage//` requires a developer session or token. The path suffix is the object's `key`. Deployment owners can delete any object, regardless of who uploaded it.
- **Inspect route**: `GET /__lakebed/storage` returns the same listing as JSON. Like `db/export`, it always requires credentials (claim token, developer token, or admin session) because listings include uploader emails.
## Limits
- 5 MiB per file.
- 100 MiB total stored per developer, summed across all of their deploys. Deleting files frees space.
- Exceeding either returns `413`.
## Errors
Failures return JSON `{ error: { code, message } }`.
| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | Malformed request. |
| `forbidden` | 403 | Deployment is unclaimed, the account is not verified, or access is denied. |
| `not_found` | 404 | No such object. |
| `too_large` | 413 | File exceeds 5 MiB. |
| `quota_exceeded` | 413 | Developer is over 100 MiB total. |
| `blocked_type` | 415 | Executables, HTML, and scripts cannot be uploaded. |
| `unavailable` | 503 | Storage is temporarily unavailable; retry later. |
## Example: Profile picture
Upload the file as public, save the returned `url` on the user's row with a normal mutation, then render it for any visitor.
Server:
```ts
import { capsule, mutation, query, string, table, userId } from "lakebed/server";
export default capsule({
schema: {
users: table({
ownerId: userId(),
avatarUrl: string().default("")
}).index("by_owner", ["ownerId"])
},
queries: {
me: query(async (ctx) => {
const { userId } = ctx.auth.requireSignedIn();
return ctx.db.users
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.first();
})
},
mutations: {
setAvatar: mutation(async (ctx, avatarUrl: string) => {
const { userId } = ctx.auth.requireSignedIn();
const user = await ctx.db.users
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.first();
if (!user) {
await ctx.db.users.insert({ ownerId: userId, avatarUrl });
return;
}
await ctx.db.users.update(user.id, { avatarUrl });
})
}
});
```
Client:
```tsx
import { createClient, retryAuth, SignInWithGoogle, useAuth } from "lakebed/client";
import type app from "../server/index";
const client = createClient();
export function App() {
const auth = useAuth();
if (auth.isLoading) {
return Checking session;
}
if (auth.error || !auth.isSignedIn) {
return (
{auth.error ?
{auth.error}
: null}
{auth.error ? : null}
);
}
return ;
}
function Profile() {
const me = client.useQuery("me");
const setAvatar = client.useMutation("setAvatar");
async function onPick(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) {
return;
}
const { url } = await client.storage.upload(file, { public: true });
await setAvatar(url);
}
return (
{me?.avatarUrl ? : null}
void onPick(event)} />
);
}
```
Keep Google sign-in available when `auth.error` is set. `retryAuth()` cannot renew an expired or revoked token for a pending guest upgrade. Signing in again can finish that upgrade.
The avatar is public. Use an unrestricted deployment so it renders for any visitor. The table stores a Lakebed user reference and the URL string. The handlers require sign-in because hosted uploads need a signed-in account. The app-wide policy stays off so public image requests can load without a token.
## Local development
Files uploaded with `npx lakebed dev` disappear when the development server restarts. Local uploads do not require Google sign-in.
## Not included
This version is intentionally small. Build app-specific behavior by storing the returned keys in your own tables.
- No server-side `ctx.storage` API.
- No presigned URLs.
- No multipart, resumable, or streaming uploads.
- No public listing API.
- No image transforms.
Apps that need indexing or per-file authorization store the returned `key` in their own tables and enforce ownership in queries and mutations.
## From the CLI
`npx lakebed storage` reaches the same objects your app does, without a browser:
```sh
npx lakebed storage list --port 3000
npx lakebed storage list --prefix public/
npx lakebed storage put ./avatar.png --public
npx lakebed storage get public/ --out ./avatar.png
npx lakebed storage delete public/
```
With no deploy id the commands talk to `npx lakebed dev` on `--port` (3000 by default). Pass a deploy id or URL to reach a hosted app. Every command takes `--json`. `get --json` also needs `--out`, because the file bytes cannot share standard output with the JSON.
- `list` prints key, size, type, and uploader, then the total count and bytes. Local listings need no credentials. Hosted listings include uploader emails, so they need credentials: run the command from the capsule directory after `npx lakebed auth login`, or pass `--inspect-token` (`LAKEBED_INSPECT_TOKEN` in CI). Hosted `delete` needs the deploy owner's developer credentials: `npx lakebed auth login` or `LAKEBED_TOKEN`. The server lists the newest 500 files, so `--prefix` filters that page and says when older files were not checked.
- `put` uploads one file under the 5 MiB limit and prints the assigned key and URL. Storage assigns the key, so there is no way to choose it. Add `--public` for a public object and `--content-type` to override the type guessed from the file extension.
- `get` writes the bytes to `--out`, or to standard output when `--out` is omitted.
- `delete` removes one object by key. Use a deploy ID when a hosted URL does not match the current capsule's saved or bound deploy.
Against `npx lakebed dev` the CLI acts as the guest from `npx lakebed auth as `, or `guest:cli` when none is set, so every command works locally. Two operations are hosted-limited, because the routes behind them belong to app users rather than to you as the developer. Hosted `put` needs a signed-in account with a verified email, so upload from the app with `client.storage.upload` instead. Hosted `get` works for public keys when the app does not require sign-in and is not restricted. Private keys, and public keys on a sign-in-only app, fail with 401 or 403. Hosted `list` and `delete` both work: `delete` goes through the owner route, so you can remove any object in your app regardless of who uploaded it.
---
title: Lakebed Examples
source: examples/README.md
url: https://docs.lakebed.dev/examples/
markdown: https://docs.lakebed.dev/examples/index.md
---
# Lakebed Examples
Use these capsules as patterns for app-building agents. Each example is a complete Lakebed app with the same file layout your generated app should use:
```txt
server/index.ts
client/index.tsx
shared/
```
## What To Copy
- Put schema, queries, and mutations in `server/index.ts`.
- Keep validation helpers and shared types in `shared/`.
- Export `App` from `client/index.tsx`.
- Declare Lakebed user references with `userId()`. Read the caller's identity with `ctx.auth.requireIdentity()` and filter user-owned rows by its `userId`.
- Use `isGuest` and `isSignedIn` to distinguish protected guests from accounts. Use `requireSignedIn()` for account-only operations.
- Set `auth: { requireSignIn: true }` to block all app data access before sign-in.
- Declare indexes for filtered queries and use `withIndex`, `order`, and an async terminal such as `collect` or `take`.
- Await database reads and writes in server handlers.
- Create one `createClient()` and use `client.useQuery` and `client.useMutation` rather than inventing a client API. `client.useQuery` returns `undefined` until the first result arrives.
- Style with Tailwind classes in JSX.
Both checked-in examples use database API v1. For an older capsule that still uses `where`, `orderBy`, `limit`, `all`,
or synchronous database calls, follow the [Database API v1 migration guide](https://docs.lakebed.dev/database-migration/index.md).
## Examples
- [`todo`](https://docs.lakebed.dev/examples/todo/index.md): per-user rows, ownership checks, checkbox mutation, clear-completed mutation.
- [`guestbook`](https://docs.lakebed.dev/examples/guestbook/index.md): shared feed, author metadata from auth, bounded text validation.
Both examples use `userId()` fields, so guest references transfer on Google sign-in. Todo lists combine with an existing account's todos. Guestbook entries remain shared, and their author references follow the account. Stored author names and pictures are snapshots and do not change during transfer.
Run the checked-in todo example:
```sh
npx lakebed dev examples/todo
```
Guest access uses protected browser sessions by default. For local tests only, open separate tabs with named guest overrides:
```txt
http://localhost:3000/?lakebed_guest=alice
http://localhost:3000/?lakebed_guest=bob
```
Named overrides cannot upgrade to accounts. Test sign-in and transfer with the default browser session instead. See the [auth guide](https://docs.lakebed.dev/auth/index.md) for session limits and merge hooks.
---
title: Todo Example
source: examples/todo/README.md
url: https://docs.lakebed.dev/examples/todo/
markdown: https://docs.lakebed.dev/examples/todo/index.md
---
# Todo Example
This capsule shows personal todo lists for protected guests and signed-in accounts. Each browser session starts with its own list. Google sign-in keeps that list with the account.
## What It Shows
- A `todos` table with `text`, `done`, and `ownerId: userId()`.
- A `todos` query filtered by the user ID from `ctx.auth.requireIdentity()`.
- An `addTodo` mutation that cleans input before insert.
- A `setTodoDone` mutation that checks row ownership before update.
- A `clearDone` mutation that deletes only the current user's completed rows.
- A Preact UI using `useAuth`, `createClient()`, and ``.
- A pure shared helper for todo text normalization.
## Server Pattern
```ts
queries: {
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
.order("desc")
.collect();
})
}
```
Use this pattern whenever rows belong to a single user. The client should not receive rows it does not own.
For mutations, fetch the row and check ownership before changing it:
```ts
const { userId } = ctx.auth.requireIdentity();
const todo = await ctx.db.todos.get(id);
if (!todo || todo.ownerId !== userId) {
return;
}
await ctx.db.todos.update(id, { done });
```
The `userId()` field makes reference transfer automatic after a verified guest upgrade. No hook is needed for todos. Guest todos join any existing account todos. The field does not replace ownership checks.
Set `auth: { requireSignIn: true }` in the server to block data access until sign-in. For a single account-only operation, use `ctx.auth.requireSignedIn()` instead. The client uses `isGuest` and `isSignedIn` for UI state.
Clearing browser data or an expired guest session ends guest access. Sign in before that happens to keep account access to the todos. Local todos reset when the dev server restarts.
## Run It
Run the checked-in example:
```sh
npx lakebed dev examples/todo
```
Open:
```txt
http://localhost:3000
```
To compare two named local test users, open:
```txt
http://localhost:3000/?lakebed_guest=alice
http://localhost:3000/?lakebed_guest=bob
```
Named overrides cannot upgrade to an account. Remove the query parameter to test guest sign-in and transfer. If you set a CLI override with `npx lakebed auth as alice`, run `npx lakebed auth reset` first.
Then inspect state:
```sh
npx lakebed db dump --port 3000
npx lakebed logs --port 3000
```
---
title: Guestbook Example
source: examples/guestbook/README.md
url: https://docs.lakebed.dev/examples/guestbook/
markdown: https://docs.lakebed.dev/examples/guestbook/index.md
---
# Guestbook Example
This capsule shows an intentionally shared feed. Guests and signed-in users can post. Everyone who can use the app can read the same entries.
## What It Shows
- An `entries` table with `body`, `authorId: userId()`, `authorName`, and `authorPicture`.
- A shared `entries` query ordered by newest first.
- A `sign` mutation that trims and bounds user input.
- Server-side authorship from `ctx.auth`, not from client-submitted fields.
- A Preact UI using `useAuth`, `createClient()`, and ``.
## Server Pattern
Use shared feeds when every user can read the same rows:
```ts
queries: {
entries: query(async (ctx) => ctx.db.entries.withIndex("by_creation").order("desc").take(50))
}
```
Still keep writes server-authoritative:
```ts
const author = ctx.auth.requireIdentity();
await ctx.db.entries.insert({
body: trimmed,
authorId: author.userId,
authorName: author.displayName,
authorPicture: author.picture ?? ""
});
```
Do not accept `authorId`, `authorName`, `authorPicture`, or other trusted metadata from the client.
Sharing comes from the query's lack of an owner filter. It does not use a shared guest ID or a fake global user. The `authorId` reference follows a guest to their account on sign-in. `authorName` and `authorPicture` are stored snapshots, so reference transfer does not replace them.
To allow only signed-in accounts to post, use `ctx.auth.requireSignedIn()` in the `sign` mutation. To require sign-in before reading or writing any app data, set `auth: { requireSignIn: true }` on the capsule.
## Run It
Run the checked-in example:
```sh
npx lakebed dev examples/guestbook
```
Open:
```txt
http://localhost:3000
```
To see shared updates from named local test identities, open:
```txt
http://localhost:3000/?lakebed_guest=alice
http://localhost:3000/?lakebed_guest=bob
```
Named overrides cannot upgrade to accounts. Use the default browser session with no override to test guest upgrades.
Then inspect state:
```sh
npx lakebed db dump --port 3000
npx lakebed logs --port 3000
```