Writing a function
A function's layout, its handler, the ctx object, reading and writing data, errors and schemas.
Layout
Each database may have one _functions directory: a Deno project for that application’s functions.
<realm>/<database>/
posts/ collections, as usual
_functions/
deno.json the project's Deno config (imports), optional
deno.lock pinned dependency versions, written by Deno
lib/ code shared by the functions (not a function)
stats/ one folder per function, named like collections
function.yaml how the function runs (all keys optional)
index.ts the code: index.ts or index.js
input.schema.json optional JSON Schema of the input
output.schema.json optional JSON Schema of the output
.build/ bundles, written by `backd functions build`
- Function names follow the same rules as collection names;
libis reserved for shared code. - The module’s default export is the function: it receives the call’s context and returns the output (any JSON value).
type Context = {
input: unknown;
user: { id: string; email: string; email_verified: boolean; roles: string[] } | null;
};
export default async function handler(ctx: Context) {
return { hello: ctx.user?.email ?? "anonymous" };
}
Create a starter with:
CONFIG_DIR=./config backd template function --realm blog --database main --name stats
The ctx object
export default async function handler(ctx) {
ctx.input // the request body (sync/async only; see ctx.request for webhook)
ctx.request // webhook only: { body, headers } — see Webhooks
ctx.user // { id, email, email_verified, roles }, or null
ctx.idempotencyKey // the Idempotency-Key header, or null
ctx.requestId // the request's X-Request-ID
ctx.secrets.STRIPE_KEY // a declared secret (see Secrets)
ctx.db("orders") // the realm's data, as the caller (see below)
ctx.admin.db("orders") // full access: admin: true, and always for scheduled runs
ctx.call("send-receipt", input) // another function of this database, listed in `calls` (see Internal functions)
ctx.email.send({ kind, to_user, data }) // a custom email: only with `email: true` (see Email)
ctx.admin.db("orders").batch([...]) // several writes, atomically (see below)
ctx.step("send", { total: 500 }) // a named step of a long job (see Jobs: Reporting progress)
ctx.progress(120, "sending") // how far the current step got
throw ctx.error(409, "out_of_stock", "Not enough stock", [{ path: "items[0]", reason: "sold out" }])
}
ctx.inputis the request body (sync/asynconly);ctx.requestreplaces it forwebhook— see Webhooks for its shape and a full example.ctx.db(name)is a database of the JavaScript client, acting as the caller: their access rules and ownership apply, exactly as if they made the request. For an API key caller, it has the key’s full access. It also hasbatch(operations), for changing several documents (even across collections) all-or-nothing — “create order and decrement stock” can’t half-happen.ctx.admin.db(name)exists only whenfunction.yamlhasadmin: true(and always in scheduled runs, which have no caller). It has full access to the realm’s data, whatever the caller may do; what it writes records the function (func:<realm>/<database>/<name>) in_meta.created_byandupdated_by.Both work only during the call: their credentials expire at the function’s deadline, and stop working at once if the caller is disabled or their key is revoked.
ctx.error(status, code, message, details)makes an error for the caller: a 4xx status, a lower-case code, a message, and optional details as[{path, reason}]. Anything else the function throws is a500 function_failed.Whatever the function logs with
consolegoes tobackd’s log ("msg":"function log"), tagged with the request ID and the function.ctx.email.send()exists only whenfunction.yamlhasemail: true. It queues an email of one of the realm’s own kinds, to a user (to_user) or addresses (to,cc,bcc), and returns the job’s{ id, status }. Anyone the function can be made to write to receives mail from your domain: read Sending email from functions.ctx.secretsholds the secrets the function declares, decrypted, keyed exactly as declared.
Who is calling: sessions and identity
A function never receives the caller’s session token, and nothing in the request can choose who the function acts as. backd authenticates the request first, exactly as for any other route, and only then runs the function (after the invoke rule allows the call). From that authenticated caller it builds two things, which the function gets and can’t change:
ctx.user:{ id, email, email_verified, roles }of the signed-in user, read from the database when the call starts. It isnullwhen there is no user: an anonymous call, or an API key that isn’t acting on behalf of anyone.ctx.db: a client that acts as the caller. Its credential is a short-lived token signed bybackdwithBACKD_CALLBACK_KEY, naming the realm, the function and the caller’s user or API key. It works only on the internal listener (the public API refuses it), and stops working at the function’s deadline plus 5 seconds.
What that means for each kind of call:
| Who calls | ctx.user | ctx.db acts as | ctx.admin.db |
|---|---|---|---|
| A signed-in user | that user | that user: their rules and ownership apply | only with admin: true |
| An API key | null | the key: full access to the data routes | only with admin: true |
An API key with X-Backd-On-Behalf-Of | that user | that user (the key’s own access isn’t added) | only with admin: true |
Nobody (anonymous, where invoke allows it, always for webhooks) | null | an anonymous caller | only with admin: true |
| An async job | its caller, read again when the job runs | the same, read again when the job runs | only with admin: true |
| A scheduled run | null | an anonymous caller | always |
Some details worth knowing:
ctx.useris a snapshot,ctx.dbis live. If an administrator disables the user, or revokes the API key, while the function runs, the nextctx.dbcall is refused. A job whose caller was deleted before it runs starts withctx.userset tonulland acts as an anonymous caller.- A function can’t exceed its caller through
ctx.db: every read and write goes through the same rules as a request made by the caller. The one deliberate exception isctx.admin.db, below. - The token reaches only the realm’s data routes, not databases of other realms, and only while the call runs. A function that leaks it leaks, for seconds, what its caller could already do.
- Decide with
ctx.user, don’t copy it from the input. If a function needs to know who is asking (for an owner field, a role check), readctx.user.idandctx.user.roles. Auser_idin the input proves nothing.
export default async function handler(ctx) {
if (!ctx.user) throw ctx.error(401, "sign_in_required", "Sign in first");
if (!ctx.user.roles.includes("staff")) throw ctx.error(403, "staff_only", "Staff only");
// ctx.db("orders") acts as ctx.user: the rules still apply to every call
}
Reading and writing data
ctx.db("main") and ctx.admin.db("main") are databases of the JavaScript client: .collection(name) gives the same methods you would use from a browser, only server-side.
| Call | Does |
|---|---|
get(id) | one document (throws a not-found error if the caller can’t read it) |
list({ where, orderBy, limit, skip, after }) | one page: { items, has_more, next_cursor }; limit is 1–100; pass next_cursor back as after for the next page |
iterate({ where, orderBy }) | every match, page by page: for await (const doc of collection.iterate({ … })) |
create(doc), patch(id, patch), replace(id, doc), delete(id) | one write; patch(id, patch, { ifMatch: doc._meta.version }) fails if someone changed the document meanwhile |
db.batch([{ op, collection, … }]) | up to 100 writes across collections in one transaction: all or none |
where is the query language: { status: "draft", "_meta.created_at": { $lt: cutoff } }. Through ctx.db, the caller’s read rule also filters every list, so a function can’t leak what its caller couldn’t see.
Errors
Throw ctx.error(status, code, message, details?) for anything the caller should understand: a 4xx status, a lower-case code, a message and optional [{path, reason}] details. The caller gets exactly that (for a job, it lands in the job’s result). Anything else the function throws, including a failed ctx.db call, becomes a generic 500 function_failed, with the stack trace in backd’s log only.
examples/config/workshop/main/_functions/lib/relay.ts
// ctx.db and ctx.admin.db throw the JavaScript client's own errors
// (NotFoundError, ForbiddenError, VersionMismatchError, ...) when a call
// fails. Uncaught, those become a generic `500 function_failed`: only what a
// function throws with ctx.error(...) reaches its caller with a meaningful
// status. relay() re-throws a caught client error as the equivalent
// ctx.error, so the caller sees exactly what they'd get calling the
// collection themselves (most often a 404 or a 409).
export function relay(err: unknown, error: (status: number, code: string, message: string) => Error): never {
const e = err as { status?: number; code?: string; message?: string };
throw error(e.status ?? 500, e.code ?? "function_failed", e.message ?? "unexpected error");
}try {
order = await ctx.db("main").collection("orders").get(order_id);
} catch (err) {
relay(err, ctx.error); // 404 for an order that isn't theirs, 409 for a conflict, …
}
Input and output schemas
Put input.schema.json and output.schema.json next to function.yaml and backd validates both: input that doesn’t match answers 400 validation_error before your code runs, and an output that doesn’t match answers 500 invalid_output (and is logged). They also feed editor types.
examples/config/workshop/main/_functions/order_total/input.schema.json
{
"type": "object",
"properties": { "order_id": { "type": "string", "minLength": 1 } },
"required": ["order_id"],
"additionalProperties": false
}examples/config/workshop/main/_functions/order_total/output.schema.json
{
"type": "object",
"properties": {
"order_id": { "type": "string" },
"subtotal": { "type": "integer" },
"tax": { "type": "integer" },
"total": { "type": "integer" }
},
"required": ["order_id", "subtotal", "tax", "total"]
}Schemas don’t apply to webhook functions, which take a raw body. A scheduled run has no input, so a schema for a function that is also called by hand must allow null (see daily_digest in the cookbook).
Privileged changes: admin and batch
Some changes no user rule should allow: marking an order refunded, moving money, granting a role. Put them in a function with admin: true, restrict who may call it with invoke, and make the change atomic with batch:
examples/config/workshop/main/_functions/refund/function.yaml
# A privileged, atomic change no customer rule may allow: mark the order
# refunded and record the refund together, or neither.
#
# - `admin: true` gives ctx.admin.db, full access recorded as this function.
# - Only staff may call it (API keys always may).
# - `idempotency: required`: a retried request with the same
# Idempotency-Key gets the first answer back instead of refunding twice.
admin: true
# It emails the customer through the realm's templates and delivery function.
email: true
# It may call these functions of the database, and no others. refund_receipt is
# internal and async: refund queues it and doesn't wait.
calls: [refund_receipt]
invoke: "user != nil && hasRole(user, 'staff')"
idempotency: requiredexamples/config/workshop/main/_functions/refund/index.ts
// refund: mark a paid order refunded and record the refund, in one
// transaction. `ifMatch` makes the order's patch fail if anyone changed the
// order since we read it, which aborts the whole batch: nothing is written.
// Afterwards it asks the internal refund_receipt function to write the receipt
// and emails the customer, through the realm's `refund-issued` template.
import { relay } from "../lib/relay.ts";
import type { Context, Doc } from "../lib/types.ts";
export default async function handler(ctx: Context) {
const { order_id, reason } = ctx.input as { order_id: string; reason?: string };
const db = ctx.admin.db("main");
let order: Doc;
try {
order = await db.collection("orders").get(order_id);
} catch (err) {
relay(err, ctx.error);
}
if (order.status !== "paid") {
throw ctx.error(409, "not_refundable", `An order that is ${order.status} can't be refunded`);
}
let refund: Doc;
try {
[, refund] = await db.batch([
{ op: "patch", collection: "orders", id: order_id, patch: { status: "refunded" }, ifMatch: order._meta.version },
{
op: "create",
collection: "refunds",
document: { order_id, amount: order.amount, reason: reason ?? "", refunded_by: ctx.user?.email ?? "api-key" },
},
]);
} catch (err) {
relay(err, ctx.error);
}
console.log(`refunded order ${order_id}`);
// The receipt is written by an internal function, as a job: the refund is
// already done, so a slow or failing receipt never undoes it. The key makes
// a retried refund queue the same job instead of a second one.
const job = await ctx.call("refund_receipt", { refund_id: refund.id, order_id, amount: order.amount }, { idempotencyKey: `receipt-${refund.id}` });
// The customer is told by email: a custom kind of the realm
// (email/refund-issued/), sent to the order's owner, a user of the realm. The
// recipient comes from the order, never from the request; a failure here
// (a limit, a template problem) must not undo or hide a refund that happened.
try {
await ctx.email.send({ kind: "refund-issued", to_user: order._meta.owner, data: { order_id, amount: (order.amount / 100).toFixed(2), reason: reason ?? "" } });
} catch (err) {
console.log(`the customer was not emailed: ${err instanceof Error ? err.message : err}`);
}
return { refund_id: refund.id, order_id, amount: order.amount, receipt_job: job.id };
}ctx.admin.dbhas full access, whatever the caller may do. What it writes records the function (func:<realm>/<database>/<name>) in_meta.created_byandupdated_by, so the change is attributable.ifMatch: order._meta.versionmakes the patch fail if another request changed the order after the function read it, which aborts the whole batch: no half-refunds.idempotency: requiredmakes a retried request with the sameIdempotency-Keyreturn the first answer instead of refunding twice (Idempotency).
Logging
Whatever the function writes with console.log, console.error and friends is captured, masked of any declared secret’s exact value, tagged with the request id, and kept in the function’s log for a week by default. Never log a document’s contents you would not want in a log.
Shared code and dependencies
Put code several functions share in _functions/lib/ (it is not a function): the cookbook’s lib/csv.ts, lib/signature.ts and lib/relay.ts are examples. Import npm and JSR packages the Deno way (import { z } from "npm:zod", or a mapping in deno.json); they are downloaded and pinned at build time, never at run time, and the function’s network access is limited to what it declares.