# Lakebed Docs Full Text This file concatenates the current public docs in source order for agents that prefer one fetch. --- title: Lakebed Docs source: docs/README.md url: https://docs.lakebed.dev/ markdown: https://docs.lakebed.dev/index.md raw: https://docs.lakebed.dev/raw/docs/README.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 ``` `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, 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 { capsule, endpoint, json, mutation, query, string, table, text } from "lakebed/server"; export default capsule({ schema: { messages: table({ body: string(), authorId: string() }).index("by_author", ["authorId"]) }, queries: { messages: query(async (ctx) => ctx.db.messages .withIndex("by_author", (q) => q.eq("authorId", ctx.auth.userId)) .order("desc") .collect() ) }, mutations: { sendMessage: mutation(async (ctx, body: string) => ctx.db.messages.insert({ body, authorId: ctx.auth.userId }) ) }, 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.messages.insert({ body: payload.body, authorId: "webhook" }); return json({ ok: true }); }) } }); ``` Server handlers receive: - `ctx.auth`: the current guest or Google identity. - `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. Filter rows by `ctx.auth.userId` when data belongs to a user, and re-check ownership inside mutations before updates or deletes. Anonymous deploys run the bundled server JavaScript in a restricted source runtime by default, so ordinary JavaScript authorization checks stay authoritative. 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()`. For database details, see the [database guide](./database.md). To upgrade an older capsule that uses `where`, `orderBy`, `limit`, `all`, or synchronous database calls, use the [Database API v1 migration guide](./database-migration.md). ## Client Contract The client exports `App` from `client/index.tsx`. ```tsx import { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client"; type Message = { id: string; body: string; authorId: string; createdAt: string; updatedAt: string; }; export function App() { const auth = useAuth(); const messages = useQuery("messages"); const sendMessage = useMutation<[body: string], void>("sendMessage"); return (
{auth.isLoading ? ( Checking session ) : auth.isGuest ? ( ) : ( )}
{JSON.stringify(messages, null, 2)}
); } ``` - `useQuery("name")` subscribes to a server query. - `useMutation("name")` calls a server mutation and returns a promise. - `useAuth()` reads the current client identity. Use `auth.isLoading` to avoid showing signed-out UI while Lakebed confirms a stored session. - ``, `signInWithGoogle()`, and `signOut()` provide the built-in Google auth path. - Use ``, ``, ``, ``, `useParams()`, `useLocation()`, and `useNavigate()` for client-side pages. - Use Tailwind classes directly in JSX. V0 does not have CSS files, CSS modules, PostCSS, or a Tailwind build step. 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. ## Auth And Env Every app starts with guest auth. In dev, set the current global guest identity with: ```sh npx lakebed auth as alice ``` For per-tab identities, add `?lakebed_guest=alice` or `?lakebed_guest=bob` to the app URL. 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](./auth.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, and runtime version. ## 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 ``` ## 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. - Local state resets when `npx lakebed dev` restarts. - User-uploaded files use built-in object storage ([storage.md](./storage.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. ## Read Next - [`capsule-api.md`](./capsule-api.md): detailed app-author API. - [`auth.md`](./auth.md): the identity contract — stable user ids, sign-in guarantees, and profile data rules. - [`storage.md`](./storage.md): built-in object storage for user-uploaded files. - [`reference.md`](./reference.md): capsule runtime, API, CLI, and deploy reference. - [`examples`](../examples): working capsules that show the intended app shape. - [`docs.json`](/docs.json), [`llms.txt`](/llms.txt), and [`llms-full.txt`](/llms-full.txt): machine-readable docs entrypoints. --- title: Lakebed Reference source: docs/reference.md url: https://docs.lakebed.dev/reference/ markdown: https://docs.lakebed.dev/reference/index.md raw: https://docs.lakebed.dev/raw/docs/reference.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 { boolean, capsule, mutation, query, string, table } from "lakebed/server"; ``` Export one default `capsule()` call: ```ts export default capsule({ schema: { todos: table({ text: string(), done: boolean().default(false), ownerId: string() }).index("by_owner", ["ownerId"]) }, queries: { todos: query(async (ctx) => ctx.db.todos .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId)) .order("desc") .collect() ) }, mutations: { addTodo: mutation(async (ctx, text: string) => ctx.db.todos.insert({ text, done: false, ownerId: ctx.auth.userId }) ) } }); ``` Server handlers receive: - `ctx.auth`: current identity. - `ctx.db`: table access for the capsule database. - `ctx.env`: server-only env values. - `ctx.log`: structured logs captured by Lakebed. ## Data API Tables are declared with `table({ ...fields })`. V0 field helpers are: - `string()` - `boolean()` - `id("table")` - `.default(value)` on a field - `.index(name, fields)` on a table 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](./database.md) for index and consistency details. Use the [Database API v1 migration guide](./database-migration.md) for before-and-after syntax and a copy-paste agent prompt. Treat queries and mutations as the source of truth. Filter user-owned data by `ctx.auth.userId`, and re-check ownership inside every mutation that changes or deletes an existing row. Anonymous deploys run bundled server JavaScript in a restricted source runtime by default, so ordinary control flow such as `get(id)` plus `if (!row || row.ownerId !== ctx.auth.userId) return` is preserved instead of approximated by IR. ## 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. ## Client API ```tsx import { ErrorBoundary, Link, Route, Router, Routes, SignInWithGoogle, navigate, signInWithGoogle, signOut, useAuth, useLocation, useMutation, useNavigate, useParams, usePaginatedQuery, useQuery } from "lakebed/client"; ``` - `useQuery("name")`: subscribe to a server query. - `usePaginatedQuery("name", args, { initialNumItems })`: subscribe to cursor pages, accumulate them, and expose `loadMore()`/`reset()`. - `useMutation("name")`: call a server mutation. - ``: 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. - ``: render the built-in Google sign-in button. - `signInWithGoogle()`: start Google sign-in from custom UI. - `signOut()`: return to guest auth. - ``, ``, 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. Mutation calls return promises: ```tsx const addTodo = useMutation<[text: string], void>("addTodo"); await addTodo("Ship the app"); ``` 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} /> ); } ``` Declared server endpoints take precedence over client routes for direct HTTP requests. ## Auth Every capsule starts with guest auth. Set the local guest identity globally: ```sh npx lakebed auth as alice ``` Set identity per browser tab: ```txt http://localhost:3000/?lakebed_guest=alice ``` Auth shape on client and server: ```ts type Auth = { userId: string; subject?: string; // same immutable key as userId for authenticated identities identityAliases?: string[]; // migration-only audience-scoped aliases displayName: string; provider: "guest" | "google"; isGuest: boolean; isAuthenticated: boolean; isLoading?: boolean; // client-only email?: string; emailVerified?: boolean; picture?: string; }; ``` ## 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. ## Styling Use Tailwind classes directly in JSX. V0 does not support CSS files, CSS modules, PostCSS, Tailwind config, or a CSS build pipeline. ## Runtime Inspection 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 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 ``` 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 is open on localhost. 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, and runtime version. ## CLI ```sh npx lakebed new [name] [--template todo] [--no-git] npx lakebed create [name] [--template todo] [--no-git] npx lakebed dev [capsule-dir] [--port 3000] npx lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json] npx lakebed deploy [capsule-dir] [--api ] [--public-inspect] [--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 inspect [--api ] [--inspect-token ] [--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 ] ``` ## 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. Hosted anonymous deploys enforce the advertised state byte limit during mutation commit, cap logs by entry count and bytes, and rate-limit deploy creation, app requests, and app mutations. Unclaimed deploys expire: shortly after expiry they stop serving, and about a week later they are permanently deleted along with their data, logs, and server env. Claim a deploy to keep it. --- title: Capsule Database source: docs/database.md url: https://docs.lakebed.dev/database/ markdown: https://docs.lakebed.dev/database/index.md raw: https://docs.lakebed.dev/raw/docs/database.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`, or `id` (encoded as a string). Lakebed uses the same canonical byte encoding for those values in memory, source-runtime, and Postgres execution. ## Pagination `paginate({ cursor, numItems })` uses an authenticated keyset cursor. A cursor is tied to the deploy, schema, query arguments, index, range, and order. Each page has a repeatable snapshot; the cursor does not preserve one historical snapshot across separate requests. ```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. ## 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 as string) }); } ``` Many-to-many relationships use an explicit join table with indexes for both directions. ## Consistency - Query handlers receive a read-only database and one repeatable snapshot. - Mutations and endpoints commit row and index writes atomically. - 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](./database-migration.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 raw: https://docs.lakebed.dev/raw/docs/database-migration.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: ```ts schema: { todos: table({ text: string(), done: boolean().default(false), ownerId: string() }).index("by_owner", ["ownerId"]) }, queries: { todos: query(async (ctx) => { const todos = await ctx.db.todos .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId)) .order("desc") .collect(); return todos; }) } ``` Legacy mutation: ```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 todo = await ctx.db.todos.get(id); if (!todo || todo.ownerId !== ctx.auth.userId) { return; } await ctx.db.todos.update(id, { done }); }) ``` Apply these replacements: - Add `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: string() }) .index("by_owner", ["ownerId"]) .index("by_owner_done", ["ownerId", "done"]) ``` Then query the matching index: ```ts const doneTodos = await ctx.db.todos .withIndex("by_owner_done", (q) => q.eq("ownerId", ctx.auth.userId).eq("done", true) ) .order("desc") .collect(); ``` Index rules: - The fields passed to `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 another Lakebed table. Keep external identifiers such as `ctx.auth.userId` as `string()`. - Queries cannot perform full table scans. If a query filters or sorts by a field, declare an index that supports it. ## Pagination The server query accepts an argument with a `pagination` property: ```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. - Query handlers are read-only. Mutations and endpoints can write. - A failed handler, timeout, quota failure, or store error rolls back the handler transaction. - Preserve authorization checks while migrating. Indexes improve lookup; they do not replace ownership validation. ## Agent Migration Prompt Give the following prompt to an agent from the root of an existing Lakebed capsule: ```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(), id("table"), and default() as appropriate. 4. Inspect every legacy query before choosing indexes. Add table indexes that preserve its filters and ordering: - equality filters become a composite index equality prefix; - an optional range filter uses gt/gte/lt/lte on the next index field; - createdAt and id are implicit index suffixes; - use the built-in by_creation index for unfiltered creation-order queries. 5. Replace legacy database chains: - where(...) -> withIndex("index_name", q => q.eq(...)) - orderBy(..., "asc" | "desc") -> order("asc" | "desc") - all() -> collect() - limit(n).all() -> take(n) - single-result scans -> first() 6. Make every query, mutation, and endpoint that uses ctx.db asynchronous. Await or directly return every get, insert, update, delete, collect, take, first, and paginate operation. Do not leave fire-and-forget database calls. 7. Keep queries read-only. Keep writes in mutations or endpoints. Preserve all input validation and re-check row ownership before updates or deletes. 8. Use id("tableName") only for references to another Lakebed table. Keep external IDs, including ctx.auth.userId, as string(). 9. For unbounded client lists, migrate the server query to paginate() and the client to usePaginatedQuery(). The server argument must include: pagination: { cursor: string | null; numItems: number }. 10. Update README snippets and shared row types when the schema or client API changed. Stored rows still include id, createdAt, and updatedAt. 11. Search the capsule for remaining .where(, .orderBy(, .limit(, and .all( calls. None should remain in migrated application code. 12. Run: npx lakebed build . --target anonymous --json Fix every reported diagnostic. Then run any existing project tests. Do not weaken authorization to make the build pass. Do not replace indexed queries with client-side filtering. Do not delete or rewrite existing data. When finished, summarize: - files changed; - indexes added and which queries use them; - async database calls migrated; - pagination changes, if any; - validation commands and results. ``` --- title: Capsule API source: docs/capsule-api.md url: https://docs.lakebed.dev/capsule-api/ markdown: https://docs.lakebed.dev/capsule-api/index.md raw: https://docs.lakebed.dev/raw/docs/capsule-api.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, 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 { boolean, capsule, mutation, query, string, table } from "lakebed/server"; import { cleanTodoText } from "../shared/todo"; export default capsule({ schema: { todos: table({ text: string(), done: boolean().default(false), ownerId: string() }).index("by_owner", ["ownerId"]) }, queries: { todos: query(async (ctx) => ctx.db.todos .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId)) .order("desc") .collect() ) }, mutations: { addTodo: mutation(async (ctx, text: string) => { const cleanText = cleanTodoText(text); if (!cleanText) { return; } await ctx.db.todos.insert({ text: cleanText, done: false, ownerId: ctx.auth.userId }); }), setTodoDone: mutation(async (ctx, id: string, done: boolean) => { const todo = await ctx.db.todos.get(id); if (!todo || todo.ownerId !== ctx.auth.userId) { return; } await ctx.db.todos.update(id, { done }); }) } }); ``` 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. Anonymous deploys preserve this model by running the bundled server JavaScript in a restricted source runtime. IR should be treated as a future optimization only when it can preserve the full handler semantics. ## Use Shared Code Carefully Good shared code: ```ts export type Todo = { id: string; text: string; done: boolean; ownerId: string; createdAt: string; updatedAt: string; }; 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 { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client"; import { cleanTodoText, type Todo } from "../shared/todo"; export function App() { const auth = useAuth(); const todos = useQuery("todos"); const addTodo = useMutation<[text: string], void>("addTodo"); const setTodoDone = useMutation<[id: string, done: boolean], void>("setTodoDone"); const authLabel = auth.displayName; const authStatus = auth.isLoading && auth.isGuest ? "checking session" : `signed in as ${authLabel}`; 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.isLoading && auth.picture ? ( ) : null}

{authStatus}

{!auth.isLoading && auth.isGuest ? ( ) : !auth.isLoading ? ( ) : null}
void onSubmit(event)}>
    {todos.map((todo) => (
  • ))}
); } ``` Client rules: - Export `App`. - Call queries by the names defined in `server/index.ts`. - Call mutations 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 Tailwind classes in JSX for styling. 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, direct HTTP requests hit the endpoint first. ## Auth Use auth through Lakebed APIs only. Server: ```ts ctx.auth.userId; ctx.auth.displayName; ctx.auth.picture; ctx.auth.email; ``` Client: ```tsx const auth = useAuth(); ``` Guest auth is available immediately. To test multiple local users, use separate URLs: ```txt http://localhost:3000/?lakebed_guest=alice http://localhost:3000/?lakebed_guest=bob ``` To add Google sign-in, render `` or call `signInWithGoogle()` from a custom button. After sign-in, server handlers receive the verified identity on `ctx.auth`. ## 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/`. ## 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. --- title: Identity and authentication source: docs/auth.md url: https://docs.lakebed.dev/auth/ markdown: https://docs.lakebed.dev/auth/index.md raw: https://docs.lakebed.dev/raw/docs/auth.md --- # Identity and authentication Lakebed Auth is the built-in identity layer for capsules. Guest auth works with zero setup, and Google sign-in is first-party: no OAuth dashboards, no keys to configure, no redirect URIs to register. Render `` and it works, in dev and in hosted deploys. ## Identity contract An authenticated identity has one durable authorization key: ```ts ctx.auth.userId === ctx.auth.subject; // google:usr_... ``` `userId` is opaque and immutable. It does not encode the deploy hostname, so a generated deploy URL and a custom domain resolve to the same `userId`. Key all user-owned data on it. `ctx.auth.identityAliases` lists audience-scoped legacy aliases (`google:ps_...`), including at least the current audience's alias even for apps that never changed hostnames. Aliases exist only so an app with rows keyed before stable subjects existed can find and rewrite them. Never key new data on an alias; always use `userId`. Email, `emailVerified`, name, and picture are profile data. A verified email may help your app locate a pending invitation, but it is never an authorization key and never evidence of access — not even as a fallback while verification is unavailable. Email changes do not change `userId`, and two accounts with the same email remain distinct users. ## What your app can rely on - Tokens are bound to their origin. A token issued for one hostname cannot be replayed at another. - Sign-in state distinguishes a bad token from an unreachable verifier. Public capsules fall back to an unauthenticated guest when verification fails or is unavailable; an outage does not erase the browser's stored session, so authentication recovers on its own when service returns. - Revoking access takes effect immediately for new requests and live WebSocket subscriptions. Signing out clears private query caches in the browser. - Signing in again after revocation restores the same `userId`, but previously issued tokens stay dead. - Deleting an account invalidates it: subsequent verification of its tokens fails. - When an app requests profile fields, the user approves first. Unapproved profile data is never exposed to the app. ## Auth shape ```ts type Auth = { userId: string; subject?: string; // same immutable key as userId for authenticated identities identityAliases?: string[]; // migration-only audience-scoped aliases displayName: string; provider: "guest" | "google"; isGuest: boolean; isAuthenticated: boolean; isLoading?: boolean; // client-only email?: string; emailVerified?: boolean; picture?: string; }; ``` ## Local dev `npx lakebed dev` uses first-party Lakebed Auth automatically, including real Google sign-in on localhost. Nothing to configure. For guest identities in dev, set the global guest with `npx lakebed auth as alice`, or use per-tab identities with `?lakebed_guest=alice` in the app URL. ## Migrating from older identities Identity records created by the previous external broker are not automatically linked to first-party `google:usr_...` identities; users must sign in again. If application data is keyed by an older provider subject, migrate it through explicit reauthorization or account linking. Never infer a link from email. `identityAliases` does not cover external-broker subjects; it only lists Lakebed Auth pairwise aliases. --- title: Object Storage source: docs/storage.md url: https://docs.lakebed.dev/storage/ markdown: https://docs.lakebed.dev/storage/index.md raw: https://docs.lakebed.dev/raw/docs/storage.md --- # Object Storage Lakebed has built-in object storage for user-uploaded files: avatars, attachments, documents. File bytes go browser ↔ runner ↔ S3-compatible bucket directly. They never pass through your queries, mutations, endpoints, or the isolate. Storage is a plain HTTP surface served at your app's own origin, plus a tiny client SDK for uploads and deletes. There is no `ctx.storage` server-side API in this version. Reads are just URLs. ## 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 you can use directly, for example in ``. - `size` and `contentType`: the stored byte length and MIME type. Pass `{ public: true }` to make the object readable by anyone with the URL. Omit it (the default) to keep the object private to signed-in users of this deploy. ## Reads Are Just URLs You do not need the SDK to read a file. Drop the returned `url` into an element: ```tsx ``` A public URL renders for anyone, signed in or not. A private URL requires a signed-in user of this deploy. The SDK is only needed for uploads and deletes. ## HTTP Surface The runner serves these at your app's origin. The client SDK calls them for you, but they are plain HTTP if you need them directly. ```http POST /storage # upload GET /storage/public/ # public download (no auth) GET /storage/private/ # private download (signed-in user) DELETE /storage/public/ # delete DELETE /storage/private/ # delete ``` - `POST /storage` — auth required. Pass the token the client already holds as `?lakebed_token=`. Add `?public=true` to make the object public; the default is private. The request body is the raw file bytes, and the `Content-Type` header sets the stored type. Returns `201` with JSON `{ key, url, size, contentType }`. - `GET /storage/public/` — no auth. Served to anyone, including signed-out visitors. Cached with `Cache-Control: public, max-age=3600` (a moderate TTL so a deleted object does not linger in shared caches for long). - `GET /storage/private/` — requires a signed-in user of this deploy (token query param). - `DELETE /storage/public/` or `DELETE /storage/private/` — auth required. Idempotent. ## Auth And Safety - Uploads and deletes require a real signed-in Google account with a verified email. Guests cannot upload, so every object is attributable. Each stored object records the uploader's id, email, and timestamp in its metadata. - Public objects are readable by anyone with the URL. The `id` is an unguessable random token, so treat the URL as a capability: holding it is permission to read. - Private objects require a signed-in user of the deploy. The app keeps private keys secret, typically by storing them in its database tied to ownership and only returning them from queries the owner is allowed to run. ## 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//` (developer session or token) — the path suffix is the object's `key`. This is an owner override — it works on any object in the deploy, regardless of uploader. Use it to reclaim quota from unwanted uploads. - **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 | Not a verified signed-in account, or not allowed. | | `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 } from "lakebed/server"; export default capsule({ schema: { users: table({ ownerId: string(), avatarUrl: string().optional() }).index("by_owner", ["ownerId"]) }, queries: { me: query(async (ctx) => ctx.db.users .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId)) .first() ) }, mutations: { setAvatar: mutation(async (ctx, avatarUrl: string) => { const user = await ctx.db.users .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.userId)) .first(); if (!user) { await ctx.db.users.insert({ ownerId: ctx.auth.userId, avatarUrl }); return; } await ctx.db.users.update(user.id, { avatarUrl }); }) } }); ``` Client: ```tsx import { createClient } from "lakebed/client"; import type app from "../server"; const client = createClient(); export function App() { 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)} />
); } ``` The avatar is public, so it renders for any visitor, signed in or not. The server only stores the URL string; the bytes live in the bucket. ## Local Dev `npx lakebed dev` uses an in-memory store that resets when the dev process restarts. It also relaxes the verified-account requirement, so you can test uploads locally without Google sign-in. Hosted deploys store objects in Lakebed-managed buckets. There is nothing to configure. ## 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. --- title: Lakebed Examples source: examples/README.md url: https://docs.lakebed.dev/examples/ markdown: https://docs.lakebed.dev/examples/index.md raw: https://docs.lakebed.dev/raw/examples/README.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`. - Use `ctx.auth.userId` for user-owned rows. - 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. - Use `useQuery` and `useMutation` rather than inventing a client API. - 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](../docs/database-migration.md). ## Examples - [`todo`](./todo): per-user rows, ownership checks, checkbox mutation, clear-completed mutation. - [`guestbook`](./guestbook): shared feed, author metadata from auth, bounded text validation. Run the checked-in todo example: ```sh npx lakebed dev examples/todo ``` Open separate tabs with different guest identities: ```txt http://localhost:3000/?lakebed_guest=alice http://localhost:3000/?lakebed_guest=bob ``` --- title: Todo Example source: examples/todo/README.md url: https://docs.lakebed.dev/examples/todo/ markdown: https://docs.lakebed.dev/examples/todo/index.md raw: https://docs.lakebed.dev/raw/examples/todo/README.md --- # Todo Example This capsule shows the smallest useful Lakebed app pattern: authenticated per-user data with server-owned mutations. ## What It Shows - A `todos` table with `text`, `done`, and `ownerId`. - A `todos` query filtered by `ctx.auth.userId`. - 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`, `useQuery`, `useMutation`, and ``. - A pure shared helper for todo text normalization. ## Server Pattern ```ts queries: { todos: query(async (ctx) => ctx.db.todos .withIndex("by_owner", (q) => q.eq("ownerId", ctx.auth.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 todo = await ctx.db.todos.get(id); if (!todo || todo.ownerId !== ctx.auth.userId) { return; } await ctx.db.todos.update(id, { done }); ``` ## Run It Run the checked-in example: ```sh npx lakebed auth as alice npx lakebed dev examples/todo ``` Open: ```txt http://localhost:3000 ``` To compare two users, open: ```txt http://localhost:3000/?lakebed_guest=alice http://localhost:3000/?lakebed_guest=bob ``` 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 raw: https://docs.lakebed.dev/raw/examples/guestbook/README.md --- # Guestbook Example This capsule shows a shared feed where every signed entry stores author metadata from Lakebed auth. ## What It Shows - An `entries` table with `body`, `authorId`, `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`, `useQuery`, `useMutation`, 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 await ctx.db.entries.insert({ body: trimmed, authorId: ctx.auth.userId, authorName: ctx.auth.displayName, authorPicture: ctx.auth.picture ?? "" }); ``` Do not accept `authorId`, `authorName`, `authorPicture`, or other trusted metadata from the client. ## Run It Run the checked-in example: ```sh npx lakebed auth as alice npx lakebed dev examples/guestbook ``` Open: ```txt http://localhost:3000 ``` To see shared updates from multiple identities, open: ```txt http://localhost:3000/?lakebed_guest=alice http://localhost:3000/?lakebed_guest=bob ``` Then inspect state: ```sh npx lakebed db dump --port 3000 npx lakebed logs --port 3000 ```