Build, dev and test
Bundle functions, iterate in dev mode, call them through a running backd and unit-test their logic.
Building
Functions are bundled before backd starts: each function, with everything it imports (lib/, npm and JSR packages), becomes one JavaScript file.
CONFIG_DIR=./config backd functions build
# bundled blog/main/stats → stats-3f2a9c0d1e4b5a6c.js (1843 bytes)
On Windows and macOS,
backditself runs, but its executor doesn’t (see what runs where): develop with the local stack’s containers, which include one.The build runs
deno bundleand needs Deno 2.9.7 on thePATH(or the binary named byDENO); it refuses any other version, because bundles must match the Deno that runs them. Without Deno installed, run it in the official Deno image with a Linuxbackdbinary (from the release archives):docker run --rm -v "$PWD/config:/config" -v "$PWD/backd:/usr/local/bin/backd:ro" \ -e CONFIG_DIR=/config docker.io/denoland/deno:alpine-2.9.7 backd functions buildBundles go to
_functions/.build/, one file per function named by its content hash, listed in.build/manifest.jsonwith the Deno version and a hash of the project’s sources. The same sources always give the same bundles.TypeScript is compiled, not type-checked;
backd functions build --checktype-checks too.Dependencies are downloaded only at build time. When
deno.lockexists, the build fails if it’s out of date, so every build uses exactly the pinned versions.If a function fails to bundle, the build stops and the previous bundles stay in place.
At startup, backd (serve, provision) refuses to run when a database’s bundles are missing, were built with another Deno version, don’t match the current sources, or were changed after the build, and says which database to rebuild. Build in CI before deploying, or commit .build/ with the sources.
Dev mode
BACKD_DEV=true follows your config files while you edit them: function code is rebuilt and schemas, rules and the other config are reloaded, with no restart and no backd functions build by hand:
CONFIG_DIR=./config BACKD_DEV=true HTTP_ADDR=127.0.0.1:8080 backd serve
- Every second,
backdchecks the files underCONFIG_DIR(hidden entries such as.gitand.build, andnode_modules, are skipped):realm.yaml; each collection’sschema.json,collection.yaml,rules.yamlandindexes.json;function.yamland function code;app-config/; email, push and page templates; consent texts. - When something changed, the whole config is loaded and validated exactly as at startup, function sources that changed are bundled with the same
deno bundlethatbackd functions builddoes, and index changes are applied as at provisioning. Only then is the new config swapped in: requests already running finish on the old one, and the next ones use the new one. The log saysconfig reloadedwith the new fingerprint, the realms and the files that changed. - A change that doesn’t validate (a syntax error in a function, a broken
schema.json) is refused: the previous config keeps running, the errors are logged, and the realm’s configuration view in the admin API and UI shows them as areload_failedwarning until the files are fixed. A failed change isn’t retried until a file changes again. - A standalone
backd workerwithBACKD_DEV=truefollows the config as well: when a change loads and validates, it restarts its job loop on the new config (jobs running at that moment are cancelled and queued again when their lease expires), and it keeps trying while the new config doesn’t load yet, because the API builds the bundles after a change. The--with-workerloop insideservekeeps the config it started with; restart to give it a new one. The development stack runs the API and the worker apart for this. - After each reload, every functions project’s
backd.d.tsis rewritten, so the types ofdefineFunctionfollow your schemas. backdrefuses to start withBACKD_DEV=trueunlessHTTP_ADDRis bound to localhost (127.0.0.1:port,[::1]:portorlocalhost:port) — dev mode must never be reachable off the machine — and logs a warning while it runs, as a reminder this is a local-only, hot-reload exception. The one exception is a container whose ports are published on the host’s127.0.0.1only (the local docker stack): it setsBACKD_DEV_ANY_ADDR=true, andbackdlogs a warning.- Functions with
dev_only: truerun only in dev mode: withoutBACKD_DEV=true,backdrefuses to start and names the function.
Testing a function through backd
backd functions invoke calls a function through a running backd, exactly the route and rules any other caller goes through — not a local shortcut — and prints its output:
backd functions invoke --function shop/orders/checkout --input cart.json --url https://api.example.com
# logs:
# [log] charging card for 1250
# time: 118ms (executor: 84ms)
# {
# "order": "o7",
# "total": 1250
# }
--input <file>reads the request body from a JSON file (-for standard input); without it, the body isnull, same as an empty request.- An internal function has no
_funcroute;invokethen runs it through the admin API (and says so), which needs an admin API key or an admin session. - It authenticates the same way as
backd user/backd apikey: a session stored bybackd login, orBACKD_API_KEY. --as <email>calls on that user’s behalf (an admin API key only, viaX-Backd-On-Behalf-Of); without it, calls as whatever credential is active.- With
BACKD_DEV=trueon the server, the response also carries the function’s console logs and its time inside the executor (X-Backd-Dev-Logs,X-Backd-Dev-Duration-Ms);invokeprints them to standard error, output stays on standard output alone so it’s safe to pipe. These headers are never sent outside dev mode — a production call never leaks another caller’s function logs.
Unit testing a function’s logic
@backd/functions-testing gives deno test a fake ctx, so a function’s own logic (validation, error codes, what it writes) can be tested without a running backd, an executor, or a network:
import { createContext } from "@backd/functions-testing";
import handler from "./index.ts";
Deno.test("checkout charges the cart's total", async () => {
const { ctx, store } = createContext({ input: { cart: "c1" } });
store.seed("shop", "carts", [{ id: "c1", items: [{ price: 500, qty: 2 }] }]);
const output = await handler(ctx);
assertEquals(output.total, 1000);
});
ctx.db/ctx.admin.dbare backed by an in-memory store shaped like the JS client: the same methods, and the sameNotFoundError/VersionMismatchErrorclasses on a missing document or a failedifMatch.collection.files(id, field)is faked too (put,get,delete,link, andversion(name)for image versions), over the store’s memory: see Files in functions.emailMessage(overrides)builds a message in the shape a realm’s delivery function receives asctx.input;createContext({ email: true })gives a functionctx.email.send, recorded and checked like backd would (a missing or systemkind, a message without exactly one ofto_userandto, too many recipients, and anEmailLimitErrorpast the invocation cap;sentEmails()lists them), andfakeEmail()is that fake on its own. It allows any recipient, as backd does: test that your function never takes the recipient or the text from its input.createContext({ config: { features: { export: { enabled: true } } } })gives the functionctx.config.get(path)andall()over the values you give (nested likeschema.json);configWrite: truealso givesctx.admin.config.set()andhistory(), which change those values and are listed byconfigChanges().reset()is not faked: it needs the schema’s defaults, so useset()to put one back.createContext({ admin: true, orgs: true })givesctx.admin.orgsover organizations kept in memory (start from some withorgs: { orgs: [{ id, name, claims, members, teams }] }), checked as backd checks them (names and slugs are unique, the last manager stays), andorgChanges()lists what the function did.ctx.user.orgsis whateveruser.orgsyou pass. See Organizations in functions.createContext({ push: true })gives a functionctx.push.send, recorded and checked like backd would before queueing (nobody, or more than one way, named; anaudiencethe function didn’t declare; a kind outsidepush: { kinds }; a field backd refuses; a payload over a provider’s size), each failure aFunctionErrorwith backd’s code;sentPushes()lists what was sent, andfakePush({ kinds, audience })is that fake on its own. It sends nothing and counts no limits.ctx.step()andctx.progress()are recorded:const { ctx, steps } = createContext(), thensteps()lists each step with itsname,total,message, lastcurrentand everyupdatesentry, so a test can check that a long job reports how far it got. They refuse what the runner does (an empty name, a negative number).fakeTrigger({ event, collection, before, after, fields, by })builds the input of a trigger function exactly as backd does: ids andchangedalways,before/afteronly for the paths infields(or["*"]),byas you give it, andtruncatedover 256 KiB. It refuses an event that could not happen (a creation with abefore). Pass it as the first argument of the handler:await onPaid(fakeTrigger({ event: "updated", collection: "orders", before, after }), ctx). Test that a handler is idempotent by calling it twice with the same input: a replayed change has the sameid.ctx.callis faked per test:const { ctx, fakeCall, calls } = createContext({ calls: ["send-receipt"] }), thenfakeCall("send-receipt", async (input) => ({ sent: true })). A fake can return a value, throwctx.error(...)(the caller sees the sameFunctionErroras with a real callee), returnfakeJob({ output })for an async callee, or throwcallTimedOut();calls("send-receipt")lists what the function called, with its input and options. Calling a name the test didn’t fake throws, and withcallsset, a name outside it fails withcall_not_declaredlike backd does.store.declareReferences("blog", "posts", { author: { field: "_meta.owner", collection: "profiles", key: "_meta.owner" }, tags: { collection: "tags", many: true } })makesget(id, { expand })andlist({ expand })work as on backd (same syntax,_expanded, positions,truncatedand the same errors), andstore.declareReadRule("blog", "profiles", (doc, { user }) => …)says who may read a collection’s documents, so an unreadable reference isnullforctx.dband there forctx.admin.db.createContext({ expand: { maxDocs: 10 } })lowers the limits.ctx.db(...).collection(...).aggregate({ groupBy, metrics, tz, orderBy, limit })works in memory with backd’s groups,nullgroups, exact decimal sums as strings and date buckets in a time zone;store.declareAggregate("shop", "orders", { group_by: [...], metrics: [...] })makes it refuse undeclared fields like backd.search(text)and{ $search }inlistandaggregateare an approximation: whole words and phrases found in the fields ofstore.declareTextIndex("blog", "articles", { title: 10, body: 1 }), ranked by weight, with no stemming or languages. Test what your function does with the results, not the ranking.- Apart from that predicate it has no access rules (
ctx.dbhere has full access) and only a practical subset of the query language: good for testing what your function does, not a substitute for the server’s own rule tests. - The cookbook’s functions have tests you can copy (
order_total/index.test.ts,export_orders/index.test.ts,lib/lib.test.ts), run in CI withmake functions-testing-test; what the fake can’t do (batch writes,ctx.request, rules, idempotency) is exercised byTestWorkshopExample, which runs every function for real. - Not published yet: import it by a relative path, or copy
src/index.jsinto your project. See its README for the full API and what it deliberately leaves out.
Editor types
backd functions types reads every function’s input.schema.json and output.schema.json and prints JSDoc typedefs, best-effort, for editor autocomplete on ctx.input and the return value:
CONFIG_DIR=./config backd functions types > _functions/types.d.ts
/** @typedef {{"cart": string, "note": string=}} ShopOrdersCheckoutInput */
/** @typedef {{"order": string, "total": number}} ShopOrdersCheckoutOutput */
Paste the output into a .js/.ts file your editor picks up (or redirect it into one, as above); a function without either schema is skipped.