# Pond Server API Reference

**Use this as the quick contract when building a Pond capsule.** If you are an agent writing a capsule, this page is the authoritative list of the primitives you may call inside `server/index.ts`. Do not invent helpers that aren't here. Do not import arbitrary npm packages — capsule modules are bundled with esbuild and only `pond/server`, `pond/client`, plain relative TypeScript, and the Node built-ins that ship with the runtime are available.

This is the surface exported from `pond/server`. A capsule's `server/index.ts` calls into it to define schema, queries, mutations, and endpoints.

```ts
import { capsule, query, mutation, endpoint, table, string, number, boolean, json, text } from "pond/server"
```

---

## Column types

Returned by helpers and used inside `table()`.

### `string(): ColumnType`

A `TEXT` column.

### `number(): ColumnType`

A `REAL` column. (SQLite is flexible — both integers and floats round-trip.)

### `boolean(): ColumnType`

An `INTEGER` column. JS booleans are coerced to `0`/`1` on write and stay numeric on read; convert at the boundary if you need true booleans in your application code.

---

## Schema

### `table<T>(columns: T): T`

Identity function used as a marker. The shape of `columns` is what the runtime introspects to issue `CREATE TABLE`. Every table automatically gets:

- `id TEXT PRIMARY KEY` (UUID, generated by `insert()`)
- `createdAt TEXT DEFAULT (datetime('now'))`
- `updatedAt TEXT DEFAULT (datetime('now'))` (touched on every `update()`)

So a definition like:

```ts
schema: {
  messages: table({
    body: string(),
    author: string(),
  }),
}
```

produces a `messages(id, body, author, createdAt, updatedAt)` table.

**Validation.** Table names and column names must match `/^[A-Za-z_][A-Za-z0-9_]*$/`, be at most 64 chars, not begin with `_pond_`, and not be a SQLite reserved word.

---

## Capsule context

Every handler receives a `CapsuleContext` as its first argument.

```ts
interface CapsuleContext {
  auth: CapsuleAuth
  db: CapsuleDb
  env: Record<string, string>
  log: CapsuleLog
  ai: CapsuleAi
  blob: CapsuleBlob
  shopify: CapsuleShopify
}
```

### `ctx.auth`

Identity of the caller.

```ts
interface CapsuleAuth {
  isGuest: boolean
  userId: string
  displayName?: string
  picture?: string
  email?: string
}
```

`isGuest: true` means the caller has no signed session — either no cookie, an invalid JWT, or an expired session. The runtime fills in a guest identity via the dev server's guest switcher or a default `{ userId: "guest", displayName: "Guest" }` in production.

Sign-in providers, all opt-in via env vars on `.env.pond.server`:

- **Google** — set `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`. Users hit `/auth/google`. `userId` becomes `<google-sub>`.
- **GitHub** — set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, optional `GITHUB_REDIRECT_URI`. Users hit `/auth/github`. `userId` becomes `github:<gh-id>`.
- **Email magic links** — no extra config required. Capsule client `POST`s `{ email }` to `/auth/email/request`, the runtime stores a one-time 15-minute token in `_pond_magic_links`, then prints the verification link to logs unless `RESEND_API_KEY` + `EMAIL_FROM` are set (in which case it ships the link via Resend). The link lands on `/auth/email/verify?token=…` and creates a session. `userId` becomes `email:<addr>`.

You can enable any combination — sessions are JWT-signed against `POND_SESSION_SECRET` so they survive provider swaps.

### `ctx.db`

Proxy with one property per schema table:

```ts
ctx.db.messages.insert({ body: "hi", author: "alice" })
ctx.db.messages.get(id)
ctx.db.messages.update(id, { body: "edited" })
ctx.db.messages.delete(id)
ctx.db.messages.where("author", "alice").orderBy("createdAt", "desc").limit(50).all()
ctx.db.messages.all()
```

Each `CapsuleDbTable` exposes:

| Method                             | Description                                              |
| ---------------------------------- | -------------------------------------------------------- |
| `where(column, value)`             | Chainable WHERE clause. Multiple calls AND together.     |
| `orderBy(column, "asc" \| "desc")` | Chainable ORDER BY. Multiple calls compose.              |
| `limit(n)`                         | LIMIT clause.                                            |
| `all()`                            | Executes the query. Returns `any[]`.                     |
| `get(id)`                          | SELECT by id. Returns the row or `undefined`.            |
| `insert(data)`                     | INSERT with a generated UUID. Returns the inserted row.  |
| `update(id, data)`                 | UPDATE by id; sets `updatedAt`. Returns the updated row. |
| `delete(id)`                       | DELETE by id.                                            |

All column references go through the same identifier validation as schema definitions.

### `ctx.env`

Plain object loaded from `.env.pond.server` (KEY=VALUE format, `#` comments). Includes a derived `GOOGLE_REDIRECT_URI` when not set explicitly.

### `ctx.ai`

Built-in LLM primitive. Routes to the first provider configured in `.env.pond.server`:

1. `ANTHROPIC_API_KEY` → Claude
2. `OPENAI_API_KEY` → OpenAI
3. `HERMES_BASE_URL` → a local OpenAI-compatible endpoint (Hermes / vLLM / Ollama)

Force a provider with `AI_PROVIDER=anthropic|openai|hermes`.

```ts
const text = await ctx.ai.complete({ prompt: "Summarize this:", system: "Be terse." })
for await (const chunk of ctx.ai.stream({ prompt: "...", model: "claude-sonnet-4-6" })) {
  // yields one full string today; streaming wire format will land per-provider.
}
```

### `ctx.blob`

Per-deploy key/value blob store. Files live under `<deploy-dir>/.pond/blobs/`. Metadata (key, contentType, size) is mirrored in the `_pond_blobs` table.

```ts
await ctx.blob.put("avatars/me.png", bytes, { contentType: "image/png" })
const got = await ctx.blob.get("avatars/me.png")
await ctx.blob.delete("avatars/me.png")
const all = await ctx.blob.list("avatars/")
```

Capsule users can read blobs via `GET /api/blob/<key>` (subject to whatever auth you wrap on top — there is no built-in ACL).

### `ctx.shopify`

Built-in Shopify Admin API helper. Lets your capsule talk to a Shopify store using a Custom App's Admin API access token — no OAuth, no Partner registration required.

```ts
interface CapsuleShopify {
  graphql<T = any>(query: string, variables?: Record<string, unknown>): Promise<T>
}
```

#### Env vars

All read from `.env.pond.server` (or set via `pond env set` for hosted deploys):

| Var                   | Required | Default   | Description                                                                                                                            |
| --------------------- | -------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `SHOPIFY_SHOP`        | yes      | —         | Your store's `.myshopify.com` domain. Accepts `my-store` (bare name) or `my-store.myshopify.com` or `https://my-store.myshopify.com/`. |
| `SHOPIFY_TOKEN`       | yes      | —         | The Admin API access token from a Shopify Custom App (Settings → Apps and sales channels → Develop apps).                              |
| `SHOPIFY_API_VERSION` | no       | `2025-01` | A [stable Shopify Admin API version](https://shopify.dev/docs/api/usage/versioning).                                                   |

#### Example

```ts
const { products } = await ctx.shopify.graphql<{
  products: { edges: Array<{ node: { id: string; title: string } }> }
}>(`{ products(first: 10) { edges { node { id title } } } }`)
```

#### Error handling

- If `SHOPIFY_SHOP` or `SHOPIFY_TOKEN` is missing, throws `"Set SHOPIFY_SHOP and SHOPIFY_TOKEN via \`pond env set\` to use ctx.shopify.graphql()"`.
- On a non-2xx HTTP response, throws with the Shopify error message (e.g. `"Shopify API error: Invalid API key or access token"`).
- On a GraphQL `errors` array in a 200 response, throws with the first error message (e.g. `"Shopify GraphQL error: Field 'blah' doesn't exist on type 'QueryRoot'"`).

#### Deploy requirement

Anonymous deploys block outbound `fetch` by design (see the security model in `deploy/HARDENING.md`). A Shopify capsule must be **claimed** (authenticated) before `ctx.shopify.graphql()` can reach the Shopify Admin API. After claiming with `pond claim <deployId>`, set env vars via `pond env set <deployId> SHOPIFY_SHOP=... SHOPIFY_TOKEN=...` and restart the deploy.

#### Scaffolding

`pond new <name> --template shopify` creates a capsule with a `products` query, a Preact product-table UI, and placeholder env vars in `.env.pond.server`. See the generated `server/index.ts` for the full instructions.

### `ctx.log`

```ts
ctx.log.info("user signed up", { userId: u.id })
ctx.log.error("payment failed", { reason: err.message })
```

In dev, entries stream over SSE on `/__pond/logs` and stay in memory (most recent 200). In prod (`pond start`), entries also append to `<deploy-dir>/.pond/logs.ndjson` with rotation at 5 MB.

---

## Handlers

### `query<TArgs, TResult>(handler: (ctx: CapsuleContext, ...args: TArgs) => TResult | Promise<TResult>): QueryHandler<TArgs, TResult>`

Wraps a function to mark it as a query. The return value is JSON-serialized. Mounted at two routes:

- `GET /api/query/:name` — for the no-arg case. Cacheable, shows up cleanly in the network panel.
- `POST /api/query/:name` with `{ args: [...] }` — for parameterized reads. Arguments are spread into the handler.

```ts
queries: {
  // No-arg read — hit via useQuery("messages").
  messages: query((ctx) => ctx.db.messages.orderBy("createdAt", "desc").all()),

  // Parameterized read — hit via useQuery("postById", id).
  postById: query((ctx, id: string) => ctx.db.posts.get(id)),

  // Multiple args work the same way — useQuery("search", keyword, limit).
  search: query((ctx, keyword: string, limit: number) =>
    ctx.db.posts.where("title", keyword).limit(limit).all()
  ),
}
```

On the client, `useQuery(name, ...args)` picks GET when called with no args and POSTs when args are passed. Args are part of the cache key, so changing them refetches automatically.

### `mutation<TArgs, TResult>(handler: (ctx: CapsuleContext, ...args: TArgs) => TResult | Promise<TResult>): MutationHandler<TArgs, TResult>`

Wraps a function to mark it as a mutation. Mounted at `POST /api/mutation/:name`. Arguments come from the JSON request body's `args` field (an array).

```ts
mutations: {
  sendMessage: mutation((ctx, body: string) =>
    ctx.db.messages.insert({ body, author: ctx.auth.userId })
  ),
}
```

### `endpoint(opts: { method: string; path: string }, handler: EndpointHandler): EndpointDefinition`

For custom HTTP routes — Stripe webhooks, REST APIs, etc. The handler receives the context and an `EndpointRequest`, and returns an `EndpointResponse`.

```ts
endpoints: {
  webhook: endpoint({ method: "POST", path: "/webhooks/stripe" }, async (ctx, req) => {
    const body = await req.text()
    // verify signature, dispatch...
    return json({ ok: true })
  }),
}
```

#### `EndpointRequest`

```ts
interface EndpointRequest {
  headers: Headers
  query: Record<string, string>
  json<T>(): Promise<T>
  text(): Promise<string>
  bytes(): Promise<ArrayBuffer>
}
```

#### Response helpers

- `json(body, init?)` → `application/json` with status 200 by default.
- `text(body, init?)` → `text/plain` with status 200 by default.

`init` is `{ status?: number; headers?: Record<string, string> }`.

---

## `capsule(def): CapsuleDefinition`

The top-level wrapper. Every `server/index.ts` exports the result as default.

```ts
export default capsule({
  schema: { ... },
  queries: { ... },
  mutations: { ... },
  endpoints: { ... },        // optional
  sockets: {                 // optional — WebSocket handlers
    chat: socket((ctx, ws) => {
      ws.on("message", (data) => ws.send(`heard: ${data}`))
    }),
  },
  allowedOrigins: ["..."],   // optional — additional CORS origins
  rateLimit: {               // optional — enforced before the handler runs
    addItem: { perMinute: 60, by: "user" },
    "/api/webhook": { perMinute: 1000, by: "ip" },
  },
  public: true,              // optional — opt into /gallery + pond fork
  static: true,              // optional — serve as a pure static site (no worker)
  title: "...",              // optional — public listing title
  description: "...",        // optional — public listing description
})
```

By default the capsule's HTTP endpoints reject cross-origin browser requests. List extra origins in `allowedOrigins` to opt them in.

### Static deploys (`static: true`)

Set `static: true` to deploy as a pure static site: the host serves your prebuilt `client/index.tsx` (as `client.html`) straight from disk and **never boots a capsule worker**. There is no SQLite, auth, AI, blob storage, or `queries`/`mutations`/`endpoints` — `/api/*` returns `404`. In return a static deploy costs no running process, doesn't count against the host's max-active-capsules cap, and scales infinitely at rest. Use it for landing pages, docs, and other content-only sites. A `static: true` deploy **requires** a `client/index.tsx`; the `schema`/`queries`/`mutations` fields are still required by the format but may be empty (`{}`).

### WebSockets

Each entry in `sockets` mounts at `/api/socket/<name>` on the same port as the rest of the capsule. The handler receives the same `ctx` your queries and mutations get (so `ctx.auth`, `ctx.db`, `ctx.ai`, `ctx.blob` all work) plus a `SocketLike`:

```ts
interface SocketLike {
  send(data: string): void
  close(code?: number, reason?: string): void
  on(event: "message", listener: (data: string) => void): void
  on(event: "close", listener: () => void): void
}
```

Clients connect at `wss://<deployId>.pond.run/api/socket/<name>`. The hosted control plane forwards the HTTP Upgrade to the deploy via a raw TCP pipe — no special config needed.

### Public capsules + fork

When you mark a capsule `public: true`, the host exposes:

- `GET /api/public-deploys` — listed at `/gallery` (the bare host renders a small JS client over this).
- `GET /api/public-deploys/:id/source` — unauthenticated source-tree export.

Anyone with the URL can run `pond fork https://<id>.pond.run` to scaffold a local copy ready for `pond dev`.

### Rate limits

`rateLimit[routeName]` matches query/mutation/endpoint names. `by: "user"` keys on the session cookie; `by: "ip"` (default) keys on the client IP. Buckets are rolling, in-process — restart resets them. Over-limit requests get a `429 Rate limited` with `Retry-After: 60`.

### Schema migrations

The runtime tracks tables in `_pond_migrations`. On boot:

- New tables are created with `CREATE TABLE IF NOT EXISTS …`.
- New columns are added via `ALTER TABLE ADD COLUMN`.
- Removing a column from your schema raises an error — to actually drop or rename a column you must run `pond db migrate --drop <table>.<col>` (or `--rename …`) explicitly. The runtime refuses destructive changes silently.

### Schema additive change example

```ts
// Before
schema: {
  items: table({ body: string() })
}
// After — adds `done` automatically the next time the capsule boots
schema: {
  items: table({ body: string(), done: boolean() })
}
```

### `/__pond/metrics`

Every running capsule exposes a Prometheus text-format snapshot at `/__pond/metrics`. Counters: `pond_route_requests_total`, `pond_route_errors_total`, `pond_route_duration_ms` (rolling p50/p95/p99). No auth — wrap with whatever proxy auth you use externally.

---

## Lifecycle

1. `pond dev` or `pond start` esbuilds `server/index.ts` into `.pond/server.mjs` (dev) or imports `.pond/deploy-bundle.mjs` (prod).
2. The runtime creates / opens SQLite at `.pond/data.db` with WAL mode.
3. For each table in `def.schema`, the runtime runs `CREATE TABLE IF NOT EXISTS …` (only the first time per table; tracked in `_pond_migrations`).
4. Routes get mounted:
   - `GET /api/query/<name>` for each entry in `def.queries`
   - `POST /api/mutation/<name>` for each entry in `def.mutations`
   - `<method> <path>` for each entry in `def.endpoints`
   - Plus the built-in `/auth/google`, `/auth/google/callback`, `/auth/me`, `/auth/signout`.
5. The runtime serves on the chosen port (default 3000).

There is no migration system beyond "create on first sight" right now. Schema changes that aren't additive require manual SQL against `.pond/data.db`.

---

## Related docs

- [`docs/client-reference.md`](./client-reference.md) — client-side hooks (`pond/client`).
- `README.md` — overall introduction, quickstart, hosted control plane, security model.
- `CONTRIBUTING.md` — local dev loop, where to add code, PR checklist.
