9. On a schedule
schedule: cron runs a function with nobody calling — ctx.admin.db always, no user, no input. cleanup sweeps expired shares.
Some work shouldn’t wait for a person: the morning digest, the nightly sweep. One line in function.yaml and the function runs itself.
schedule
# digest/function.yaml — the same function as chapter 8, plus:
schedule: "@daily"
Cron expressions work too ("0 7 * * *", UTC unless you add a timezone). A scheduled run has no caller: ctx.user is null, ctx.input is null, ctx.db acts as an anonymous caller — and ctx.admin.db is always available, recorded as func:shelf/main/digest. Review a scheduled function as if it declared admin: true (Cron).
That’s also why digest’s input schema accepts null and the code defaults since_days — the scheduled run sends nothing.
Add that line to digest/function.yaml and rebuild. To watch the clock without waiting a day, set schedule: "* * * * *" for a minute: docker compose logs backd shows scheduled job queued and the digest’s own function log line at the next minute mark — then put @daily back. Backd then runs it by itself in the background process (--with-worker is in the tutorial’s compose file); there is nothing else to start.
cleanup
Expired share links are litter; notifications read a month ago are stale. Nobody should click “clean” — the clock does it:
examples/config/shelf/main/_functions/cleanup/function.yaml
# Deletes expired share links and read notifications older than a month.
#
# `internal: true`: nobody can call it over HTTP — the schedule, and admin
# invokes run by hand, are its only triggers. Both give it ctx.admin.db:
# a scheduled run has no caller and always gets it; `admin: true` is for
# HTTP callers, so it isn't set here.
#
# backd functions invoke --function shelf/main/cleanup
internal: true
mode: async
schedule: "0 3 * * *" # every day at 03:00 UTC
timeout: 10minternal: true + schedule: — no HTTP route, no caller, runs at 03:00 UTC. Note admin: isn’t set: it exists for HTTP callers, and this function has none. (The same file is why mode: async + timeout: show up together — sweeping many documents takes longer than a request should.)
examples/config/shelf/main/_functions/cleanup/index.ts
// cleanup: sweep what time invalidates — expired share links, and
// notifications already read and a month old. A scheduled run has no
// caller and no input; ctx.admin.db is always there.
import type { Context } from "../lib/types.ts";
const KEEP_READ_NOTIFICATIONS = 30 * 86_400_000; // 30 days
export default async function handler(ctx: Context) {
const db = ctx.admin.db("main");
const now = new Date().toISOString();
const staleBefore = new Date(Date.now() - KEEP_READ_NOTIFICATIONS).toISOString();
let shares = 0;
// $ne: null because a null expires_at (a link that never dies) must not
// match "expires before now".
for await (const s of db.collection("shares").iterate({ where: { expires_at: { $ne: null, $lt: now } } })) {
await db.collection("shares").delete(s.id);
shares++;
}
let notifications = 0;
for await (
const n of db.collection("notifications").iterate({
where: { read_at: { $ne: null }, "_meta.created_at": { $lt: staleBefore } },
})
) {
await db.collection("notifications").delete(n.id);
notifications++;
}
console.log(`cleanup: removed ${shares} expired shares, ${notifications} old notifications`);
return { shares, notifications };
}fetch the chapter's files (optional — the listings above are the same files)
for f in main/_functions/cleanup/function.yaml main/_functions/cleanup/index.ts main/_functions/cleanup/index.test.ts; do
mkdir -p "config/shelf/$(dirname "$f")"
curl -fsSL "https://fernandezvara.github.io/backd/tutorial/config/shelf/$f" -o "config/shelf/$f"
doneRunning by hand
You don’t wait for the clock to know it works. The admin API runs any function by hand, internal ones included, as its schedule would — with the operator’s $OP session from chapter 4 (the CLI form, backd functions invoke --function shelf/main/cleanup, does the same from a machine with a stored login):
curl -s -X POST http://localhost:8080/v1/shelf/_admin/functions/main/cleanup/invoke \
-H "Authorization: Bearer $OP" -H 'Content-Type: application/json' -d '{}'
# 202 {"id":"…","status":"queued", …} — a job; poll GET /v1/shelf/main/_jobs/<id> (same $OP) until "status":"done"
The app does the same from the Admin view: backd.admin.invokeFunction('main/cleanup'), and its result (or job) under Run by hand. That’s the second invoke path in this tutorial: db.fn for functions with a route and an invoke rule; admin.invokeFunction for everything else — invoke rules and rate_limit don’t apply, so guard it to admins in the UI.
You should see
- The
invokecall above answers with a job; expired shares and month-old read notifications are gone. - Run by hand in the app runs either function and shows the job or result —
cleanupincluded, despite having no HTTP route. docker compose logs backd | grep "function log"the morning after shows the scheduled digest ran itself.deno testcovers cleanup’s keep/delete split.
Next: chapter 10 — email for real: templates, a delivery function, and the digest going out by mail.