- 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 infunctions/:
createMessage is callable at POST /api/fn/createMessage.
Context object
ctx gives you:
A mutation handler IS the transaction — everyctx.db.*call inside the handler shares one BEGIN/COMMIT, and a thrown error rolls everything back atomically. There’s noctx.db.transact([...])because the handler already wraps your writes; if you need batch atomicity from outside a mutation, use the HTTP/api/transactendpoint or callrunMutationfrom an action.
Auth (secure by default)
Every function declares who can call it. The framework enforces this before the handler runs — a missingif (!ctx.auth.userId) check no longer leaks data, because the runtime made the check first.
| 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/.... |
401 AUTH_REQUIRED for "user" / "guest", 403 FORBIDDEN for "admin". Admin sessions bypass every mode (same convention as policies).
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:
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:
- 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 === trueskips the gate the same way it bypasses entity-route policies. Ops scripts and thectx.auth.elevate({ admin: true })path inside verified webhooks still work everywhere.
- Now (v0.3.161+) —
ctx.db.unsafe.*is callable. Mark known cross-tenant paths with it. Plainctx.db.*still bypasses policies (existing behavior); strict mode is opt-in via env. - v0.4 — strict mode default-on. Apps that didn’t migrate get
POLICY_DENIEDon the calls that genuinely need cross-tenant access; the fix is to mark thoseunsafe.
Validators
v.* describes expected argument shapes:
Queries
Actions
Use for side effects outside the database:Calling functions from the client
Errors
Throw typed errors that propagate to the client with structured codes:{ 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.