> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Functions

> Queries, mutations, and actions — your server-side RPC.

A **function** is server TypeScript, RPC-callable from the client. Pylon runs them in a Bun process managed by the runtime.

Three flavors:

* **Query** — read-only, can subscribe to changes
* **Mutation** — writes through the transactional path
* **Action** — arbitrary side effects (HTTP calls, emails, file ops)

## Writing a function

Create a file in `functions/`:

```ts theme={null}
// functions/createMessage.ts
import { mutation, v } from "@pylonsync/functions";

export default mutation({
  // Every function declares an auth mode. "user" is the default;
  // see the Auth section below. The framework enforces this before
  // the handler runs, so `ctx.auth.userId` is `string` here.
  args: {
    roomId: v.id("Room"),
    body: v.string(),
  },
  async handler(ctx, args) {
    const id = await ctx.db.insert("Message", {
      roomId: args.roomId,
      authorId: ctx.auth.userId,
      body: args.body,
      sentAt: new Date().toISOString(),
    });
    return { id };
  },
});
```

The filename becomes the RPC name — `createMessage` is callable at `POST /api/fn/createMessage`.

## Context object

`ctx` gives you:

```ts theme={null}
ctx.auth.userId   // string (when auth: "user" | "admin") OR string | null (when auth: "public" | "guest")
ctx.auth.isAdmin  // boolean
ctx.auth.tenantId // string | null  (selected org)
ctx.auth.elevate({ admin: true, reason: "..." })  // promote AFTER verifying a webhook/HMAC
// No ctx.auth.email / ctx.auth.roles on the handler ctx — fetch the user row for email:
//   const u = await ctx.db.get("User", ctx.auth.userId)

// ctx.db — on queries (read-only) and mutations (read + write). NOT on actions.
ctx.db.insert(entity, data)
ctx.db.get(entity, id)
ctx.db.query(entity, filter?)
ctx.db.list(entity)                   // whole entity (no filter)
ctx.db.update(entity, id, patch)
ctx.db.delete(entity, id)
ctx.db.link(entity, id, relation, targetId)
ctx.db.unlink(entity, id, relation)

// Mutations + actions only (not queries):
ctx.error(code, message)              // throw typed error → mutation rolls back
ctx.scheduler.runAfter(delayMs, ...)  // schedule (deferred until COMMIT)
```

The three flavors expose different context surfaces:

* **Query** — `ctx.db` (read-only), `ctx.auth`, `ctx.env`, `ctx.requireMember`.
* **Mutation** — the above plus writes on `ctx.db`, `ctx.scheduler`, `ctx.error`, `ctx.llm`, `ctx.connections`, `ctx.stream`.
* **Action** — **no `ctx.db`.** Reach the database through `ctx.runQuery(name, args)` / `ctx.runMutation(name, args)` (each runs as its own transaction). Actions also get `ctx.scheduler`, `ctx.error`, `ctx.email`, `ctx.llm`, `ctx.connections`, and `ctx.request` (raw HTTP request — the only ctx that has it, so webhook signature checks live in actions).

> A mutation handler IS the transaction — every `ctx.db.*` call inside the handler shares one BEGIN/COMMIT, and a thrown error rolls everything back atomically. There's no `ctx.db.transact([...])` because the handler already wraps your writes; if you need batch atomicity from outside a mutation, use the HTTP `/api/transact` endpoint or call `runMutation` from an action.

## Auth (secure by default)

Every function declares **who can call it**. The framework enforces this *before* the handler runs — a missing `if (!ctx.auth.userId)` check no longer leaks data, because the runtime made the check first.

```ts theme={null}
export default mutation({
  auth: "user",   // default — signed-in user required
  args: { ... },
  async handler(ctx, args) {
    // ctx.auth.userId is `string`, not `string | null`. The
    // redundant null check is gone.
    await ctx.db.insert("Note", { authorId: ctx.auth.userId, ... });
  },
});
```

Four modes:

| Mode                   | Reaches the handler when…                     | Use it for                                                                             |
| ---------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------- |
| `"user"` (**default**) | Real signed-in user. Guest sessions rejected. | Almost everything.                                                                     |
| `"public"`             | Anyone, including unauthenticated callers.    | Healthchecks, landing-form submits, webhook receivers that verify their own signature. |
| `"guest"`              | Guest session OR real user.                   | Cart-style pre-login state.                                                            |
| `"admin"`              | `ctx.auth.isAdmin === true`.                  | Ops endpoints, dangerous helpers exposed via `/api/fn/...`.                            |

When the mode doesn't match the caller, the request is rejected before the handler runs — `401 AUTH_REQUIRED` for `"user"` / `"guest"`, `403 FORBIDDEN` for `"admin"`. Admin sessions bypass every mode (same convention as policies).

```ts theme={null}
// Public endpoint — has to be explicit, never the default.
export default action({
  auth: "public",
  async handler(ctx) {
    // ctx.auth.userId is `string | null`. Check it if you need it.
    return { ok: true };
  },
});
```

**Why this matters for actions specifically:** policies gate `ctx.db.*` reads and writes, but **policies don't gate `action` handlers**. An action that charges Stripe, sends email, or hits a private API has no default protection — except `auth`. Forgetting this single field is the canonical action-shaped vulnerability in any TypeScript backend. Pylon makes it the secure default.

`internal: true` functions ignore `auth` — they're unreachable over HTTP and inherit the wrapping handler's context.

## `ctx.db` honors policies (strict mode)

By default, `ctx.db.*` inside a function bypasses entity policies — server code is trusted. That's the historical Pylon behavior and it's still the default.

**Strict mode** flips the default: every `ctx.db.get/query/insert/update/delete/lookup/search` runs through the policy engine using the function's caller auth, exactly as if the same operation came in through `/api/entities/*`. Enable per deploy:

```sh theme={null}
PYLON_STRICT_FN_POLICIES=1 pylon dev
```

When strict mode is on, the canonical Pylon-shaped IDOR — "I wrote `getRecording.ts` that does `ctx.db.get('Recording', args.id)` and forgot to check tenant ownership" — becomes impossible. The policy on Recording fires, sees that the caller isn't a member of the row's org, and the call returns `POLICY_DENIED` before the row leaves the database.

For the legitimate cross-tenant cases (admin tools, webhook receivers post signature verification, scheduled cron sweeps), use the explicit escape hatch:

```ts theme={null}
// functions/stripeWebhook.ts
// The webhook is an action — only actions get ctx.request (the raw
// body a signature check needs). Actions have no ctx.db, so the DB
// work runs in an internal mutation the action hands off to.
export default action({
  auth: "public",  // webhook — provider doesn't sign in
  async handler(ctx) {
    const sig = ctx.request!.headers["stripe-signature"];
    const ok = verifyStripeSignature(secret, ctx.request!.rawBody, sig);
    if (!ok) throw ctx.error("INVALID_SIGNATURE", "bad sig");
    // Trust boundary crossed — the webhook is the trusted caller.
    await ctx.runMutation("settleStripeEvent", { customerId });
  },
});
```

```ts theme={null}
// functions/settleStripeEvent.ts — internal: unreachable over HTTP
export default mutation({
  internal: true,
  args: { customerId: v.string() },
  async handler(ctx, args) {
    // Use ctx.db.unsafe to read any org's row regardless of who's
    // calling. Comment justifies the bypass for code review.
    const org = await ctx.db.unsafe.lookup("Org", "stripeCustomerId", args.customerId);
    // ...
  },
});
```

Rules of thumb:

* **Plain `ctx.db.*`** — the default. Acts as the caller. Use for anything that should reflect the user's view of the data.
* **`ctx.db.unsafe.*`** — explicit bypass. Use for webhooks, cron sweeps, admin tooling, and anything that genuinely needs cross-tenant reads. Every call should have a justifying comment.
* **Admin contexts bypass strict mode** — `auth.isAdmin === true` skips the gate the same way it bypasses entity-route policies. Ops scripts and the `ctx.auth.elevate({ admin: true, reason: "..." })` path inside verified webhooks still work everywhere (the `reason` is mandatory and audited).

Strict mode is **opt-in for now** (one minor cycle) so existing apps can migrate at their own pace. The rollout plan:

1. **Now (v0.3.161+)** — `ctx.db.unsafe.*` is callable. Mark known cross-tenant paths with it. Plain `ctx.db.*` still bypasses policies (existing behavior); strict mode is opt-in via env.
2. **v0.4** — strict mode default-on. Apps that didn't migrate get `POLICY_DENIED` on the calls that genuinely need cross-tenant access; the fix is to mark those `unsafe`.

## Validators

`v.*` describes expected argument shapes:

```ts theme={null}
v.string()              v.int()                v.float()
v.boolean()             v.datetime()           v.id("User")
v.optional(v.string())  v.array(v.string())    v.literal("open" | "closed")
v.object({ k: v.string() })
```

See [SDK reference](/reference/validators) for the full list.

## Queries

```ts theme={null}
// functions/listIssues.ts
import { query, v } from "@pylonsync/functions";

export default query({
  args: { teamId: v.id("Team") },
  async handler(ctx, args) {
    return ctx.db.query("Issue", { teamId: args.teamId });
  },
});
```

Queries are **live** by default — the React client subscribes and re-runs on relevant changes. See [Live queries](/concepts/live-queries).

## Actions

Use for side effects outside the database:

```ts theme={null}
// functions/sendEmail.ts
import { action, v } from "@pylonsync/functions";

export default action({
  args: { to: v.string(), subject: v.string() },
  async handler(ctx, args) {
    await fetch("https://api.resend.com/emails", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.RESEND_KEY}` },
      body: JSON.stringify({ to: args.to, subject: args.subject }),
    });
    return { ok: true };
  },
});
```

Actions have no `ctx.db`. They reach the database through `ctx.runQuery(name, args)` / `ctx.runMutation(name, args)` — each of those runs as its own transaction, but the action as a whole is **not** atomic. If a sequence of writes must commit or roll back together, do them inside a single mutation and have the action call it once.

## Calling functions from the client

```tsx theme={null}
import { callFn } from "@pylonsync/react";

async function onSend() {
  const { id } = await callFn("createMessage", {
    roomId: "01H...",
    body: "hello",
  });
  console.log("created", id);
}
```

## Errors

Throw typed errors that propagate to the client with structured codes:

```ts theme={null}
if (!ctx.auth.userId) {
  throw ctx.error("UNAUTHENTICATED", "log in first");
}
if (msg.length > 2000) {
  throw ctx.error("INVALID_ARGS", "message too long");
}
```

The client receives `{ code, message }` and can render different UI per code. See [Error codes](/reference/errors) for the canonical list.

## Next

<CardGroup cols={2}>
  <Card title="Live queries" icon="radio" href="/concepts/live-queries">
    How query subscriptions stay in sync.
  </Card>

  <Card title="Validators" icon="check" href="/reference/validators">
    All argument shapes `v.*` supports.
  </Card>
</CardGroup>
