Skip to main content
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/:
// 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:
ctx.auth.userId   // string (when auth: "user" | "admin") OR string | null (when auth: "public" | "guest")
ctx.auth.email    // string | null
ctx.auth.roles    // string[]

ctx.db.insert(entity, data)
ctx.db.get(entity, id)
ctx.db.query(entity, 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)

ctx.error(code, message)              // throw typed error → rolls back
ctx.scheduler.runAfter(delayMs, ...)  // schedule (deferred until COMMIT)
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.
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:
ModeReaches 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).
// 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:
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:
export default action({
  auth: "public",  // webhook — provider doesn't sign in
  async handler(ctx, args) {
    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.
    // 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", 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 modeauth.isAdmin === true skips the gate the same way it bypasses entity-route policies. Ops scripts and the ctx.auth.elevate({ admin: true }) path inside verified webhooks still work everywhere.
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:
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 for the full list.

Queries

// 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.

Actions

Use for side effects outside the database:
// 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 can read and write the database but are not transactional — if you need atomicity, use a mutation.

Calling functions from the client

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:
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 for the canonical list.

Next

Live queries

How query subscriptions stay in sync.

Validators

All argument shapes v.* supports.