backend.js — the app's server-side backend
prompts/pkg/llms/backend.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
backend.js is an optional server-side file, a sibling of access.js. It
runs on the platform's servers — never in the browser — and gives the app three
server superpowers, each an exported function:
fetch(request, ctx)— answer HTTP requests at the app's/_api/...URL (webhooks from outside, orfetch("/_api/…")from the app itself).onChange(event, ctx)— react after a document write commits (derive, aggregate, moderate, fan out).scheduled(event, ctx)— run periodically (cleanup, digests, timers).
Export only the handlers the app needs. Most apps need no backend.js at
all — normal CRUD flows through Fireproof directly. Reach for it only when the
app needs a server-side action: accepting a webhook, deriving/aggregating
documents in reaction to writes, or periodic work. These docs are present by
default so you know the capability exists — their presence is not a
request to add a backend. If the user's request doesn't call for one, don't
create or mention backend.js.
When to use which handler — signal words
fetch— "webhook", "OAuth", "callback", "API endpoint", "receive", "form POST", "Stripe/GitHub/Sonos sends us…". Something external → us.onChange— "when X is created", "after a write", "notify", "send an email", "aggregate", "moderate", "sync to…". An internal write → a side effect:ctx.notifyfor every person the app emails, notifies or reminds, and a Zapier Catch Hook for a tool the person names (Slack, Google Sheets, a CRM).scheduled— "every N minutes", "poll", "check periodically", "cron", "cleanup", "digest". Timer-driven, no user in the loop.
A reminder that recurs at a wall-clock time — "every morning at 9", "each
Friday" — is a scheduled handler; onChange answers a write that just
happened.
An app can need more than one — a payments app wants fetch (webhook receipt)
and onChange (email on the new order). If none of these signal words fit
the request, the app needs no backend.js — don't emit one.
Decision rule: when live external data is the app's substance, use
backend.js + cache-into-db, never fetch-per-render; when external data is
only launch flavor, take a generation-time snapshot into seed.json instead.
Keep availability separate from a successful empty result. Inspect a successful source response before choosing its parser, then validate the fields the app uses. An empty validated response is data; an unavailable first read leaves the dependent decision pending. For example, a dispatch planner waiting for its closure calendar records that unavailable state and postpones calendar-dependent dispatch. A previously validated cache can support the declared fallback, with its freshness and failure visible in saved status.
Output format
backend.js is a separate file, exactly like access.js: one prose line, the
heading naming backend.js, then one complete fenced block below it (the
whole file — don't use SEARCH/REPLACE for it; re-emit the full file to change
it). Never put backend code inside an App.jsx block, and never import
backend.js from App.jsx — the browser can't run it.
Writes go through the access function
Every ctx.db.put/ctx.db.delete is enforced by the app's own access.js —
the exact gate user writes pass through — acting as the trigger's identity:
| Handler | Writes act as… |
|---|---|
onChange |
the user whose write triggered the event |
fetch |
the signed-in caller when verifiable — treat as possibly anonymous |
scheduled |
the app owner, in admin mode |
So the permission model stays in access.js, and backend.js writes must be
allowed by it for the acting identity. If a fetch handler writes, make sure
the access function permits that write for an anonymous caller (or design the
write to happen in onChange/scheduled, which carry stronger identities).
The access function can tell where a write came from: user.backend names the
handler that issued it — "scheduled", "onChange" or "fetch" — and is
absent for every write from a browser or the CLI, the app owner's own included.
What it does not change is identity. An onChange write still acts as the
triggering user, so anything it may write, that user's own client could write
too; onChange stays derivation and convenience, never privilege
escalation. For documents only the server should control, use scheduled and
gate them on the origin with ctx.requireBackend("scheduled"), which admits the
run and turns every ordinary client write away.
scheduled always runs in admin mode, the same override the owner gets
from the client's admin toggle: ctx.requireAccess(...), ctx.requireRole(...)
and ctx.requireBackend(...) all no-op, and its ctx.db.query reads are
unfiltered. The access function still runs on every write — its returned
channels and grants still route the doc, so members keep seeing what the cron
writes. user.backend is "scheduled", so ctx.requireBackend("scheduled")
passes for the run on its own merits. Two consequences worth designing
around: a scheduled sweep can read and rewrite every user's documents in
the app (access functions protect users from each other, not from the app
owner — the owner's automation touches all users' data); and because admin mode
skips requireAccess, a bug in a scheduled write fails silently-wrong rather
than forbidden — keep scheduled writes small and well-guarded. fetch and
onChange never get admin mode: they're user-triggerable, and a handler any
visitor can invoke must not run owner-privileged.
Taking on the authorization job: config.fetch.unfilteredReads
Because the fetch lane runs as the calling user, a fetch handler by default
reads nothing for a caller with no session, and cannot read an access-fn-bound
database without an opt-in. Some routes legitimately need to: a calendar feed at
GET /_api/faves.ics?t=<token> is fetched by a calendar app that carries no
login, and the docs it serves are private to one user. For those, backend.js
can opt in — per database, fetch lane only:
jsexport const config = {
fetch: {
unfilteredReads: {
dbs: ["faves"],
why: "serves per-user .ics calendar feeds authorized by an unguessable token",
},
},
};
This is an obligation, not a convenience. Inside that handler, for those
databases, you are taking on the authorization job the platform normally does:
both the anonymous refusal and the access-function scoping come off, so
ctx.db.query/ctx.db.get return every document in the named databases —
all users, all channels — to whoever called the route. The legitimate pattern is
a capability: an unguessable, per-user, revocable token in the URL or a
header, checked before you read, with the response scoped to exactly that
token's owner. "The URL is hard to guess" only holds if you mint the token
yourself with real entropy and never print it anywhere else.
Two things to design around. The opt-in is scoped to the lane, not the
route: backend.js exports one fetch handler that routes internally, so
every path inside it can read those databases — keep the check at the top and
name as few databases as the feature needs. And it is a static declaration:
dbs must be plain string literals and why a non-empty string literal saying
who is authorized and how, or the push is rejected. There is no call-site flag,
and no other lane can opt in.
ctx — what a handler gets
jsctx.appInfo; // { ownerHandle, appSlug } — this app's identity
ctx.userInfo; // { userHandle } or null — who the handler is acting as
ctx.secrets; // Record<string,string> — the owner's per-vibe secrets; frozen, {} when none
await ctx.db.put(doc, { db: "notes", id: "optional-id" }); // resolves to the doc id AFTER commit
await ctx.db.put(doc, { db: "jobs", id: doc._id, ifRev: doc._rev }); // commits only if nobody wrote the doc since you read it
await ctx.db.delete(docId, { db: "notes" });
const docs = await ctx.db.query({ db: "notes", field: "type", key: "note", limit: 100 }); // a filtered page
const doc = await ctx.db.get("state:cursor", { db: "notes" }); // one doc by _id, or null
const text = await ctx.callAI("prompt", { model: "openrouter/auto", max_tokens: 500 }); // server-side AI call
const json = JSON.parse(
await ctx.callAI("rate this", { schema: { properties: { verdict: { type: "string" }, score: { type: "number" } } } })
); // structured output
const res = await ctx.fetch("https://api.example.com/x", { headers }); // outbound HTTP — use this, not bare fetch()
await ctx.notify({ toHandle: "alice", body: "Your turn", dedupeKey: "game-7-turn-3", path: "#game-7" }); // one participant; `path` optional; throws on failure
ctx.log("info", "tick finished", { written: 3 }); // diagnostics — read back with: vibes-diy app logs owner/slug
const gh = await ctx.github.fetch("/repos/acme/site/issues", { method: "POST", body: { title: "Filed from the vibe" } }); // connected GitHub repo
ctx.github.fetch(path, init)calls the GitHub REST API as the GitHub App installation the owner connected to this vibe. It is the only GitHub surface a backend has:pathis a GitHub API path rooted at/(/repos/…, plus/installation/repositories),inittakesmethod,headersandbody(an object is JSON-encoded for you), and it resolves a normalResponse— sores.status,await res.json()andres.headers.get("link")all work, and an upstream 404 or 422 comes back as aResponse, not an error. There is no token to read. The installation credential is attached by the platform and never enters your code, so you cannot pass it to another library, log it, or store it — and every call the App makes on the owner's behalf is one the platform made. The proxy also enforces the binding: a/repos/{owner}/{repo}/…path must name a repository the owner actually connected to THIS vibe, so a path built from user-supplied text simply gets refused rather than reaching some other repo the installation happens to cover. Do not treat that refusal as your validation — check the repo name yourself before you build the path, and tell the user which repos this vibe is bound to (/installation/repositories). Calls outside the proxied surface reject with anErrorcarrying a shortreason(not_connected— the owner has not connected a repo,unmodeled_endpoint— a path outside/repos/…+/installation/repositories, or one naming a repo this vibe is not bound to,unsupported_method,streaming_unsupported,body_too_large,permission_not_granted— the installation lacks that permission, orupstream_error); branch onerr.reason, and tell the user which one it was.{ db }names the Fireproof database (same namesApp.jsxuses withuseFireproof). It is required — except insideonChange, where the database that triggered the event is the default.Always
awaitdb calls; they resolve only after the write commits (or throw when the access function denies it).Every read carries the doc's revision as
_rev.ctx.db.get, eachctx.db.queryrow and theonChangeevent'sdocinclude_revbeside_id. It is a reserved field the platform fills in on read and never stores, so spreading a read straight into a put is safe.ifRevmakes a write a compare-and-set.ctx.db.put(doc, { db, id, ifRev: doc._rev })commits only if the doc is still at the revision you read. Passnullto write only if the doc does not exist yet — a deleted doc counts as not existing, just asgetreturnsnullfor it. If anyone else wrote the doc in between, even with identical content, the put throws witherr.code === "conflict"anderr.currentRev(the revision there now,nullwhen the doc is gone), so of several racing handlers exactly one succeeds. The access function still runs on every put, so itsoldDoc.statuscheck plusifRevis an exclusive claim. A conflict is an answer, not a failure: someone else has the doc, so return. LeaveifRevout and a put is last-writer-wins, as always. This isbackend.jsonly:useFireproofwrites in the browser are local-first and keep last-writer-wins.js
try { await ctx.db.put({ ...event.doc, status: "running" }, { db: "jobs", id: event.docId, ifRev: event.doc._rev }); } catch (err) { if (err.code === "conflict") return; // another run claimed it first throw err; }ctx.db.get(id, { db })resolves the single document with that_id, ornullwhen it is missing or deleted. It is the right read whenever you can name the document — a tick's state doc, a token lookup, a singleton. Never scan a database looking for a doc whose id you already know.ctx.db.query({ db, field, key, keys, range, prefix, limit, after })reads a page of the database's latest docs. Every filter is optional and they combine:fieldnames the document field to match on, thenkey(one exact value),keys(any of several),range([from, to]) orprefix(a string, or an array prefix — charwise-encoded, same semantics the client's queries use) narrows it. Three edges to get right: nofieldmeans no filtering (you get a raw page of everything), a barefieldwith no selector still drops docs that lack that field, andprefixtakes the raw value — a plain string or array, never anything pre-encoded (the encoding is internal).limitonly clamps the page down; the 2000-doc cap stays. Ask for what the handler actually needs — reading the whole database and filtering in the handler wastes the cap and goes blind as the app grows. Sorting still happens in the handler.The result is a real
Arrayof docs that also destructures — bothconst docs = await ctx.db.query(...)andconst { docs, next } = await ctx.db.query(...)work.Paginate on
next, never on emptiness.nextis computed from the raw page before filtering, and only when that page came back full — so a narrowly filtered page can be empty and still carrynext. Stopping at the first empty page silently truncates the read:js
let after; do { const page = await ctx.db.query({ db: "faves", field: "type", key: "favorite", limit: 500, after }); for (const doc of page) { // … } after = page.next; } while (after);nextrides on the array itself, so read it before transforming: any array operation (.filter(),.map(), spread) returns a plain array with nonext. On the last page the property is absent, not undefined —"next" in pageisfalse.Both reads are read-gated as the acting identity, through the same gate: anonymous
fetchcallers are denied, and infetcha database bound to an access function cannot be read at all (keep the fetch lane's read databases unbound, or declareconfig.fetch.unfilteredReads). InonChangea bound database reads filtered to the triggering user's channels — the same per-reader filter the client's own reads use, so the handler sees exactly what the user who triggered it could see: neighbouring docs for dedup, rank and "don't repeat what's already there" checks, and nothing privileged. Inscheduled(admin mode) reads are unfiltered — access-fn-bound databases return every doc, across all users and channels. Made forscheduledsweeps: read, decide, thenput/delete.The one exception to that last sentence is the declared opt-in above,
config.fetch.unfilteredReads— afetchhandler that names a database there reads it unfiltered, anonymous callers included, and owes the authorization check itself. Nothing changes for writes, or for any lane or database you didn't name.ctx.secretscarries the owner's own per-vibe secrets, set in the vibe's settings page or withvibes-diy secrets set KEY. Use it to verify inbound credentials — a webhook's shared secret or token — or to authenticate outbound calls to a service the owner holds a key for (Authorization: Bearer ${ctx.secrets.KEY}to an admitted API). Never write a value anywhere users can read it (a doc, a response body). Only the owner can set or rotate secrets; handlers see the current values on every invocation. A visitor's own account (their GitHub, Threads, Instagram) is never a secret and never a pasted token — it is a connection: declare it inconfig.connections, let the platform hold the credential, and read it through@vibes.diy/socialorctx.connections(theconnectionsskill).Make every outbound HTTP call with
ctx.fetch(...), never a barefetch(...). It takes the same arguments as the webfetchand returns aResponse. Why not barefetch: a backend that exports afetchhandler creates a module-scopedfetchbinding, so a barefetch(url)anywhere in the file resolves to your own handler — afetchhandler calling out recurses into itself, and ascheduled/onChangecall passes the URL where aRequestis expected.ctx.fetchalways reaches the real HTTP client. It is policy-gated, and three lanes admit a call.A plain read of a public URL goes through.
ctx.fetch(url)with nomethod(orGET/HEAD), carrying at mostaccept/accept-languageand no credentials, reaches any ordinary https host fromfetch,onChangeandscheduledalike — so an RSS or Atom feed, a public JSON API, an.icsfile or a plain web page is readable today, with no key, no arrangement and nothing to configure. The platform makes the request itself, identified as this app, and serves a repeated read from a cache — for about five minutes by default, or for whatever lifetime the response asks for, held to between a minute and an hour. Nothing is cached for less than a minute, so a poll faster than that re-reads bytes the cache already holds."5m"is the sound default for a feed that states no lifetime of its own, and slower suits a feed that changes a few times a day; a feed asking to be re-read sooner (Cache-Control: max-age=60) is the one case a faster tick earns.A CORS-open endpoint takes anything a browser could send from the app's own page — same preflight rules, same
Access-Control-Allow-Origincheck, browser-forbidden headers likeCookiestripped. This is the lane aPOST, anAuthorization:header, or any custom header name travels on, so a write or a keyed API call needs the host itself to say yes.The platform's curated list carries server-to-server APIs that send no CORS headers (
api.github.comis seeded; additions are a PR to the platform repo).Emailing, notifying or reminding a person is
ctx.notify.ctx.notifyreaches a person in the app by handle — the owner, a member, a manager — on their bell, their push and their email, so "email the manager every Monday", "email me a weekly digest" and "send the owner a summary" are each ascheduledhandler that callsctx.notify. A recipient who is not in the app yet joins it through the app's invite: the person picker takes their handle, or takes their email address and mails them an invite, and once they arrive every notification reaches them. Keep the record in the app too, so the app's own screen shows what went out.A Catch Hook carries a record to a tool the person names. When the ask names Slack, Google Sheets, a CRM, Airtable, Notion, Clio, HubSpot or Zapier itself,
hooks.zapier.comis on the curated list for aPOSTto a Catch Hook, so the app hands the new record to that tool without holding its credentials. The owner makes a Zap whose trigger is "Webhooks by Zapier → Catch Hook", saves the hook URL as a secret (vibes-diy secrets set ZAPIER_HOOK_URL ...), and the backend posts JSON toctx.secrets.ZAPIER_HOOK_URL. The URL is the credential, so it stays in the secret, out of every document and response body. Write that path gated on the secret (if (!ctx.secrets.ZAPIER_HOOK_URL) return;), so the app works fully before any Zap exists, and say in your reply that connecting a Zap to the hook turns that delivery on. Treat any 2xx as accepted and mark the record sent; on anything else throw — a handler that returns normally has acknowledged the event, so the platform will not call it again for that write, while a thrownonChangeis re-delivered with backoff (a handful of tries over a few minutes) and a run that never succeeds lands in the app's logs.js
export async function onChange(event, ctx) { const doc = event.doc; if (event.deleted || doc.type !== "intake" || doc.sentToZapier) return; if (!ctx.secrets.ZAPIER_HOOK_URL) return; // no Zap connected yet: the record stays in the app const res = await ctx.fetch(ctx.secrets.ZAPIER_HOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: doc.type, name: doc.name, email: doc.email }), }); if (!res.ok) { ctx.log("warn", "zapier hook refused", { status: res.status }); throw new Error(`zapier hook ${res.status}`); // re-delivered with backoff } await ctx.db.put({ ...doc, sentToZapier: true }, { db: event.dbName }); }
A call outside all three returns
403 {"vibesEgressDenied":true,"gate":"floor"|"owner-blocked"|"cors"|"read-rate"|"rate-limit","host":...}, andgatesays which lane refused:corsis a write or a credentialed call to a host that never opted us in, andread-ratemeans the read lane's own tighter budget (10 per 10s, 60 per minute) is spent for the moment, which the next tick clears. Check for that shape and surface a useful error. Remedies: read it as a plainGET, use a CORS-open endpoint, request a curated-list addition, or ask about owner blessing. The creator-facing version of this gate — including what to do when a link is reported as taking more power than the app has today — is at https://good.vibes.diy/docs/your-app-has-a-backend, which is the page to send someone to. Limits: https on port 443 only, 15s per request, 10MB responses, per-vibe rate caps (30/10s, 300/min). Make all outbound calls before returning the Response — actx.fetch()fired while a streamed body is still being pulled after the handler returned is unsupported and gets denied. Interactive frontend AI calls stay inApp.jsxviacallAI; server-side AI belongs toctx.callAI.ctx.callAI(prompt, options?)is the server-side AI call. It resolves to the completion text (a plain string) — no API key needed and none exists in the handler; the platform makes the call and meters the cost against the account of the user who triggered the handler (the writer inonChange, the signed-in caller infetch), or the app owner when no user is involved (scheduledticks, anonymous webhooks). Options:model(default"openrouter/auto"),max_tokens,temperature,schema. No streaming. Schema mode works the same way it does inApp.jsx: pass{ schema: { properties: { … }, required: [ … ] } }and the platform constrains the model the best way that model supports (native JSON schema, tool mode, or a schema system message). The result still comes back as a string —JSON.parseit. When you pass a schema and name nomodel, the default shifts to a schema-capable model rather than"openrouter/auto". Budget: a handful of calls per invocation (currently 5) and ~64k prompt characters; a denied or failed call throws — catch it and degrade (e.g. write the doc back with an error field) rather than losing the event. Keep interactive/streaming AI inApp.jsxviacallAI; usectx.callAIwhen the result must be server-authoritative (moderation, digests, summaries users shouldn't be able to forge).ctx.notify({ toHandle, body, dedupeKey, path })sends one person a notification. It takes a single object;toHandle,bodyanddedupeKeyare required andpathis optional — match the object form in the example above, which is the calling convention (codegen has been observed passing these positionally). The message lands in that person's bell, inbox, and push notifications.toHandleis one handle, and the platform delivers to people already in this app: the owner, who always qualifies, an accepted invite or approved request, a handle named in the access function's persisted grant state, or somebody whose authenticated write reached the app (anonymous writes don't count). Invite, request, and writer membership resolve the person through their bound handle; the grant-state clause matches the named handle itself. Explicit revocations deny anyone but the owner. A passing check plus valid fields writes the notification into the inbox history of a recipient the platform can resolve — an unresolvable handle fails the call after the participation check. Their switches silence the live bell, the push, and the email — not the history.When the notification is the opt-in kind — your turn, your order shipped — seat the player the way a guest takes their own seat. A turn notification goes to a seat its owner took themselves. A join write where the added handle must equal
user.userHandlequalifies the writer by their own act. This is an app-side convention for notifiable seats, not a rule about read grants: chosen-peer grants that share without notifying — inviting a collaborator into a shared list — stay intentional, and a grant-named handle stays eligible whether or not its owner seated it.bodyis the whole message — plain text, 500 chars max. The platform titles it with the app's name and links it back to the app, so spend the body on what happened and who it concerns.dedupeKeynames the happening, so one event delivers once however many times the handler runs:"round-4-started","order-42-shipped","game-7-turn-3". The platform keys it per (app, recipient).pathsays WHERE in your app the notification points, so the bell and the email land on the thing it is about instead of your front page. It must be a#fragment —"#game-7","#order/42"— of at most 200 characters with no spaces or line breaks, and nothing else: a fragment can only ever address a position inside your own app, which is why it is the only form allowed. Omit it and the notification links to the app itself, exactly as before. A malformedpathfails the wholectx.notifycall (the promise rejects) rather than being quietly ignored — so a dead deep link is something you find out about, not something your users find out about.Read it back in
App.jsxfrom your own hash, and listen for changes, because a click while the app is already open moves the hash without reloading:jsx
const [target, setTarget] = useState(() => window.location.hash.slice(1)); useEffect(() => { const onHash = () => setTarget(window.location.hash.slice(1)); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); // target === "game-7" → open that gameawaitit and let it throw. Success resolves with nothing to inspect; a missing field, an over-long body, or a handle outside the app rejects the promise. Usually the surrounding work should survive an undeliverable message, so catch it and carry on:js
try { await ctx.notify({ toHandle: game.opponent, body: "Your turn", dedupeKey: `game-${game._id}-turn-${game.turn}`, path: `#game-${game._id}`, // lands them on THAT game, not the lobby }); } catch (err) { ctx.log("warn", "notify failed", { game: game._id, reason: err.message }); }Reach for it when one person needs to know one thing: your turn, your request was approved, your order shipped. Each user also controls a "messages from a vibe" switch, so treat a notification as a nudge toward the app rather than the place the information lives.
When everyone using the app should see something — the schedule changed, a new round started — write that change to a database the audience already reads. Their
useLiveQueryputs it on screen the moment it commits, for exactly the people entitled to see it, because the platform resolves that audience per reader at read time. The app's own screen is the source of truth; that's what makes it the right surface for anything shared.
ctx.log(level?, ...args)is the handler's diagnostic lane. It is the output that reaches the owner, who reads it back withvibes-diy app logs <owner>/<slug> --since 1h [--tail]— the isolate'sconsoleoutput is collected nowhere, so route anything worth seeing later throughctx.log. The level is optional and comes first ("debug","info","warn","error"); any other first argument is the message and the level defaults toinfo, soctx.log("cache miss", { key })andctx.log("warn", "feed empty", { url })both work. Log the shape of a run rather than its contents: document ids, counts, statuses, timings, and an error's message and code. Identifiers make the stream diagnostic while users' document bodies andctx.secretsvalues stay in the handler.
Worked external-data pattern — cache into the live database
A public feed reads fine from a handler, and a run can still come back empty — the host is down, the shape changed, the read budget is spent for the minute. Never fetch an external feed from render or a React effect. Poll once per tick, normalize a bounded number of rows into the exact database and document shape the UI queries, and keep the last successful rows when refresh fails. Seed a small honest snapshot before the first request so even a first-run denial has something useful to show.
This complete example makes one curated GitHub API request every 15 minutes
(well inside the documented caps), stores at most 20 normalized release docs
in nodeReleases, and records refresh state in that same database.
backend.js
jsexport const config = { scheduled: { interval: "15m" } };
const SNAPSHOT = [
{
_id: "release:snapshot-v22",
type: "release",
title: "Node.js 22 (saved snapshot)",
url: "https://nodejs.org/en/blog/release/v22.0.0",
publishedAt: "2024-04-24T00:00:00.000Z",
source: "saved-snapshot",
},
];
async function ensureSnapshot(ctx) {
// Point-read by id — the snapshot has a known `_id`, so there's nothing to scan for.
if (await ctx.db.get(SNAPSHOT[0]._id, { db: "nodeReleases" })) return;
for (const doc of SNAPSHOT) await ctx.db.put(doc, { db: "nodeReleases" });
}
// True only when every tracked field of the desired doc already matches what's
// stored — extra stored keys (like `_rev`) are ignored. This is what lets a
// tick that changes nothing write nothing.
function unchanged(existing, next) {
if (!existing) return false;
return Object.keys(next).every((k) => existing[k] === next[k]);
}
// The diff source is a POINT-READ per incoming id, not a query over the stored
// set: the work is bounded by what the feed sent (≤20 docs), never by how big
// the database has grown — so a db that outgrows a query page can never
// silently eject rows from the diff and turn this tick into a rewrite loop.
// The database is a parameter: every doc a helper writes has to land in the db
// the UI live-queries, and a helper with one baked-in name is how a status doc
// ends up somewhere nothing reads.
async function putIfChanged(ctx, next, db) {
const existing = await ctx.db.get(next._id, { db });
if (unchanged(existing, next)) return;
await ctx.db.put(next, { db });
}
export async function scheduled(event, ctx) {
ctx.log("debug", "refresh tick", { at: event.scheduledTime }); // proof of life, before any read
await ensureSnapshot(ctx);
// One request, no retry storm; ctx.fetch enforces 15s/10MB and egress caps.
const response = await ctx.fetch("https://api.github.com/repos/nodejs/node/releases?per_page=20", {
headers: { accept: "application/vnd.github+json" },
});
if (response.status === 403) {
const denied = await response
.clone()
.json()
.catch(() => null);
if (denied?.vibesEgressDenied === true) {
// A real state change — putIfChanged writes once on entering denial, then
// nothing while it persists. No per-tick timestamp, so no churn.
await putIfChanged(ctx, {
_id: "refresh:status",
type: "refreshStatus",
state: "egress-denied",
message: "showing saved data — live refresh unavailable",
}, "nodeReleases");
return; // preserve the seeded/last-good release docs
}
}
if (!response.ok) return; // transient failure: keep last-good rows, write nothing
const rows = await response.json().catch(() => null);
if (!Array.isArray(rows) || rows.length === 0) return; // empty upstream: abort, no writes
for (const row of rows.slice(0, 20)) {
if (!row?.id || !row?.html_url || !row?.published_at) continue;
// Stable per-item `_id` + diff: a row whose content is identical is skipped.
await putIfChanged(ctx, {
_id: `release:github-${row.id}`,
type: "release",
title: String(row.name || row.tag_name || "Node.js release"),
url: String(row.html_url),
publishedAt: String(row.published_at),
source: "github-live",
}, "nodeReleases");
}
await putIfChanged(ctx, {
_id: "refresh:status",
type: "refreshStatus",
state: "fresh",
message: "Live feed refreshed",
}, "nodeReleases");
}
App.jsx
jsximport React from "react";
import { useFireproof } from "use-fireproof";
import { Badge, Heading, RowCard, Stack, Text } from "@vibes.diy/pouf";
export default function App() {
const { useLiveQuery } = useFireproof("nodeReleases");
const { docs: releaseDocs } = useLiveQuery("type", { key: "release" });
const { docs: statusDocs } = useLiveQuery("type", { key: "refreshStatus" });
const releases = [...releaseDocs].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
const status = statusDocs.find((doc) => doc._id === "refresh:status");
return (
<main className="min-h-screen bg-background text-foreground font-sans mx-auto max-w-2xl p-6">
<Stack gap={5}>
<Heading level={1}>Node.js releases</Heading>
{status?.state === "egress-denied" && (
<div role="status">
<Badge tone="info">showing saved data — live refresh unavailable</Badge>
</div>
)}
<Stack gap={3}>
{releases.map((release) => (
<RowCard key={release._id}>
<a className="font-bold underline" href={release.url} target="_blank" rel="noreferrer">
{release.title}
</a>
<Text size="sm" muted>
{release.publishedAt.slice(0, 10)}
</Text>
</RowCard>
))}
</Stack>
</Stack>
</main>
);
}
The vibesEgressDenied body is a policy result, not proof that the upstream is
down. Catch that exact 403 shape and show the saved snapshot with honest copy —
"showing saved data — live refresh unavailable". Never clear cached rows,
render a broken screen, or silently label saved data as fresh.
Zero-churn discipline for scheduled ticks. A cron runs forever, so it must
not write forever. Give every item a stable _id and put only the rows
whose content actually changed — the putIfChanged diff above, which
point-reads each id it is about to write. Diff against point-reads of the
incoming ids, so the work is bounded by what arrived this tick, not by how
big the database has grown; reach for a keyed ctx.db.query only when the
stored set itself is the target (and then walk next to exhaustion before
diffing — a one-page diff source re-puts everything the page missed, forever). Never stamp a per-tick checkedAt/updatedAt/run
counter onto a doc: it makes every put look new, and each spurious put mints a
fresh revision and a sync-feed row that re-tickles every replica on every tick.
A failed or empty upstream fetch aborts with no writes, preserving the last-good
rows. Only a genuine content change — a new or updated row, a real state
transition — earns a write.
A tick's own memory is a document it reads by id. Whatever a scheduled
handler needs to know about what it already did — a cursor, a last-synced
marker, a per-run state — lives in one doc with a fixed _id, read with
ctx.db.get(id, { db }). Never re-derive that answer by scanning the database:
a sweep that diffs against its own whole-db read goes blind the moment the
database outgrows one page, and then rewrites everything on every tick, forever.
Keep a copy of the state in module scope as well, and read a missing or failed
state read as "don't know — skip this tick", never as "nothing has been done
yet": that second reading is exactly what restarts the endless rewrite. That
state doc is owner-only in access.js — it is what the tick believes about
itself, so keeping it to the owner is what keeps anyone else from steering the
tick.
fetch — the app's HTTP endpoint
Runs for requests to the app's /_api route. The request path is rooted after
/_api: a call to https://slug--owner.host/_api/webhooks/pay arrives with
pathname /webhooks/pay. Return a standard Response.
backend.js
jsexport async function fetch(request, ctx) {
const url = new URL(request.url);
if (url.pathname === "/rsvp" && request.method === "POST") {
const body = await request.json();
const id = await ctx.db.put({ kind: "rsvp", name: body.name, at: Date.now() }, { db: "rsvps" });
return new Response(JSON.stringify({ ok: true, id }), {
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
}
From App.jsx, call it with a relative fetch — no host needed:
App.jsx
jsconst res = await fetch("/_api/rsvp", { method: "POST", body: JSON.stringify({ name }) });
The platform's answers are worth recognising. 404 backend.js _api: not found
means the app has no fetch handler to run. When a handler throws, the caller
gets a 500 whose body names only the error class — backend.js threw: TypeError. Details are in this app's backend log: … — and the full message is
written to the app's log, where vibes-diy app logs <owner>/<slug> shows it. A
502 backend.js failed to run means the module never started, most often an
error at the top level of backend.js. So a route that expects trouble — an
upstream that can be down, input that can be malformed — catches it and answers
{ ok: false, reason } with a status the app can show, and the platform's 500
stays the signal for a bug to fix.
A connected GitHub repository's deliveries arrive here too, as a POST to
/_api/github-webhook, already verified by the platform, with the event name
in x-vibes-github-event, GitHub's delivery id in x-vibes-github-delivery
(key writes by it — a delivery can arrive twice), and an untrusted third-party
body; a 4xx ends the delivery, and a throw marks it failed (GitHub does not
retry it).
/_api opens to every caller once the app is published. Before its first
publish, and after the owner unpublishes it, the routes answer the app's own
people — its owner, its developers, and anyone it is shared with — and
everyone else gets 404 backend.js _api: not found without the handler
running. The page reaches its own routes in every state through apiFetch
(below), which arrives as the signed-in visitor. call_own_api, onChange
and scheduled keep running, and a connected GitHub webhook runs until the
owner unpublishes.
Who is calling: ctx.userInfo comes from the request, never a cookie
Cookies are not read. ctx.userInfo is filled in three ways:
- The app's own page, through
apiFetchfromuse-vibes. The platform makes the call for the signed-in visitor, so the handler runs as them:ctx.userInfois that visitor, theirctx.dbwrites passaccess.jsas them, andctx.connectionsspends their connection. A visitor who is not signed in arrives asctx.userInfo === null. - A caller outside the app — a script, another agent, a server — sends the
person's vibes.diy token as
Authorization: Bearer <token>. A missing, expired or unrecognised token is not an error: the handler runs withctx.userInfo === null, so a route that needs a person answers401itself. - The builder's own
call_own_apicheck (see the debugging skill) arrives already verified, as the app's owner, with no bearer header.
A plain fetch("/_api/...") from App.jsx carries no visitor, so the handler
sees ctx.userInfo === null. That suits routes that should not depend on who
is asking — reading a page, calling a service with a secret. When a route
needs to know the visitor — their own records, their connection, their
permissions — the page calls it with apiFetch. The path is the route under
/_api, and the answer reads like a Response:
App.jsx
jsximport { apiFetch } from "use-vibes";
const res = await apiFetch("/my-rsvp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ going: true }),
});
const mine = res.ok ? await res.json() : null;
A route's own error status resolves with ok: false; apiFetch rejects only
when the route could not be reached. Bodies are strings, and answers are for
JSON-sized results rather than exports. Screens that simply list the visitor's
own records still read best from the database hooks, which already know who is
signed in.
Writes a handler makes with ctx.db.put still pass through access.js as the
caller, so the app's rules hold for API writes too.
Bulk transformations run as a route
A change that touches many stored records is written once, as a named /_api route, and run
once — not looped from outside the app. Gate the route inside the handler on the caller being the
owner, declare config.fetch.unfilteredReads for the collections it reads, page with the cursor
ctx.db.query returns, and keep progress in a state document read by id so a second run continues
where the first stopped.
backend.js
jsexport const config = { fetch: { unfilteredReads: ["rsvps"] } };
export async function fetch(request, ctx) {
const url = new URL(request.url);
if (url.pathname !== "/admin/normalize") return new Response("not found", { status: 404 });
if (ctx.user?.userHandle !== ctx.ownerHandle) return new Response("forbidden", { status: 403 });
const page = await ctx.db.query("kind", { key: "rsvp", limit: 100, db: "rsvps" });
for (const doc of page) {
if (typeof doc.name === "string" && doc.name !== doc.name.trim()) {
await ctx.db.put({ ...doc, name: doc.name.trim() }, { db: "rsvps" });
}
}
return new Response(JSON.stringify({ ok: true, more: page.next ?? null }), {
headers: { "content-type": "application/json" },
});
}
Reading a page the person names — the /_api proxy
A page the person names is read in backend.js and handed to the app through /_api. A browser
reaches its own app and the hosts that publish CORS headers, so an ordinary web page — somebody's
homepage, a blog post, a competitor's landing page — is readable from a handler and from nowhere
else. This is the shape for every "scan this site", "check this URL", "summarize this page" ask:
the handler takes the address, reads it with ctx.fetch, strips the markup to text, and answers
with what it measured. Repeat reads of the same address come back from the read cache (about five
minutes), so a person re-scanning a site pays for one read.
The handler settles the address before reading it. The platform reads https: hosts, so an
address typed without a scheme, or with http:, is normalized to https: and anything else comes
back as a stated refusal rather than a thrown handler — a refusal the app can show is worth more
than a policy error the app has to guess at.
ok means TEXT CAME BACK. A 200 that strips to nothing — a page that renders itself in the
browser, a consent wall, a host answering with an empty body — is a read that produced no page, so
it answers ok: false with a reason. That is what makes page.ok a sound thing for the app to
branch on: a score or a summary is only ever computed from text the handler actually returned.
backend.js
jsexport async function fetch(request, ctx) {
const url = new URL(request.url);
if (url.pathname === "/page") {
const typed = (url.searchParams.get("url") ?? "").trim();
let parsed;
try {
parsed = new URL(/^[a-z]+:/i.test(typed) ? typed : `https://${typed}`);
} catch {
return Response.json({ ok: false, reason: "that does not look like a web address" }, { status: 400 });
}
// The platform reads https hosts, so http is normalized rather than refused.
if (parsed.protocol === "http:") parsed.protocol = "https:";
if (parsed.protocol !== "https:") {
return Response.json({ ok: false, reason: "only web pages can be read" }, { status: 400 });
}
const res = await ctx.fetch(parsed.href, { headers: { accept: "text/html" } });
const html = await res.text();
// Script and style bodies first — their contents are not page text — then the tags themselves.
const text = html
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 20000);
// A page that came back with no readable text is a read that produced no page.
if (res.ok === false || text.length === 0) {
const reason = res.ok ? "that page returned nothing we could read" : `that page answered ${res.status}`;
return Response.json({ ok: false, status: res.status, reason });
}
const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim() ?? "";
return Response.json({ ok: true, status: res.status, title, text });
}
return new Response("not found", { status: 404 });
}
From App.jsx, the page asks the handler for the address it was given:
App.jsx
jsconst res = await fetch(`/_api/page?url=${encodeURIComponent(url)}`);
const page = await res.json();
if (page.ok === false) {
setResult({ read: false, reason: page.reason });
return; // nothing is scored, summarized or graded from a page that was not read
}
setResult({ read: true, ...(await scorePage(page.title, page.text)) });
The refusal lands where the result would have gone — "we could not read that page: it returned nothing we could read" — so the person sees the read fail instead of a number computed from nothing.
onChange — react to committed writes
When the request says a record is kept on the server, or that the server reacts
to something, emit this handler. A person asking for activity to be recorded
centrally, for a tally the app maintains, or for something to happen whenever a
record lands is naming the server as the place it happens — that ask is the
trigger, and the shape is the activity-mirror example at the end of this file:
the client writes the document that triggers it, and onChange writes what the
server owes in response.
Runs after any document write to the app's databases commits (user writes and backend writes alike). The event:
backend.js
jsexport async function onChange(event, ctx) {
// event: { dbName, docId, doc, oldDoc, seq, deleted } — doc carries _id and _rev
if (event.dbName !== "votes" || event.deleted) return;
// Maintain a server-authoritative tally the UI reads but users can't forge.
await ctx.db.put({ _id: "tally-" + event.doc.pollId, kind: "tally", bump: event.seq }, { db: "tallies" });
}
Two rules keep change-reactions sane:
- Guard on
event.dbNamefirst. The handler fires for every database, so an unguarded write-back loops on itself. - Write derived docs to a different database than the one that triggered
the event. A backend write triggers
onChangeagain (one generation deeper); the platform stops the chain silently after four generations, but a tight same-database ping-pong is still wasted work. Different db + dbName guard makes loops structurally impossible.
onChange reads what the triggering user could read
A reaction usually needs the set its write joins — the siblings, the existing
keys, what's already there. ctx.db.query and ctx.db.get on a bound
database return exactly the docs in the triggering user's channels, through
the same per-reader filter the client's own reads use. Nothing privileged
ever comes back: a doc the triggerer couldn't open stays invisible, and reads
page like any other query (walk next to exhaustion).
jsexport async function onChange(event, ctx) {
if (event.dbName !== "links" || event.deleted) return;
// What did sibling drafts already say about this link? Only docs the
// triggering user could read come back — a private sibling stays hidden.
// Walk every page to exhaustion: a fully-filtered page still carries `next`,
// so stopping on an empty page would miss a sibling on a later one.
let after = null;
do {
const page = await ctx.db.query({ db: "drafts", field: "linkId", key: event.doc.linkId, limit: 100, after });
if (page.some((d) => d.angle === event.doc.angle)) return; // already covered
after = page.next;
} while (after);
await ctx.db.put({ _id: "draft-" + event.docId, linkId: event.doc.linkId, angle: event.doc.angle }, { db: "drafts" });
}
Jobs that finish even when a run crashes
A job that survives a crash carries its status, its claim and its lease on the document.
When each request is a unit of work ("resize every upload", "a queue of jobs
that keeps going if one fails"), give each job a doc with a status, let
onChange claim and finish it, and let a scheduled sweep take back expired
leases. The status guard is what makes writing back to the same database safe:
the handler acts only on pending, so every chain ends after one step.
Every transition is the same move: read the doc (the event's doc, or a
sweep's query row), then put the next status with ifRev: doc._rev. A
conflict means someone else moved the job first, so step aside.
access.jsholds the transition table, keyed onoldDoc.status:ctx.requireBackend("onChange")forpending → runningandrunning → done;ctx.requireBackend("scheduled")forrunning → pending(only onceoldDoc.leaseUntil < Date.now()) and→ failed; anything else throws.- The claim puts
runningwithifRev: event.doc._rev, so exactly one run claims each job. It also writes a freshclaimId,leaseUntilandattempts + 1. - The finish carries the same
claimId, andaccess.jsrefuses adonewhoseclaimIddiffers fromoldDoc.claimId. A slow run's lease can be taken back and the job claimed again while it works; the second claim's freshclaimIdis what makes the first run's finish refuse instead of overwrite. - The sweep releases an expired lease with
ifRev: job._revfrom its query, so it never overwrites a job that finished between the read and the write. - A lost claim returns rather than throws: a redelivered event carries the doc
as it first fired, so its
_revis stale and the claim conflicts. - Key the work's own writes by
jobId:attempt, since a job can run twice. - Keep each run short; a run past 15 minutes is delivered again while it is
still going, so chunk long work into
scheduledticks.
backend.js
jsexport const config = { scheduled: { interval: "30s" } };
const DB = "jobs", LEASE_MS = 30_000, MAX_ATTEMPTS = 3;
const rid = () => Math.random().toString(36).slice(2, 10);
// One transition: commit `doc` only if the job is still at revision `ifRev`.
async function tryPut(ctx, doc, ifRev) {
try { await ctx.db.put(doc, { db: DB, id: doc._id, ifRev }); return true; }
catch (err) {
if (err.code === "conflict") return false; // someone else moved this job first
ctx.log("info", "transition refused", { id: doc._id, reason: String(err?.message || err) });
return false;
}
}
export async function onChange(event, ctx) {
if (event.dbName !== DB || event.deleted || event.doc?.status !== "pending") return;
const job = { ...event.doc, _id: event.docId };
const claimed = { ...job, status: "running", attempts: (job.attempts || 0) + 1,
claimId: rid(), leaseUntil: Date.now() + LEASE_MS };
if (!(await tryPut(ctx, claimed, job._rev))) return; // lost the claim — ack, don't retry
const output = await doTheWork(job, `${job._id}:${claimed.attempts}`); // idempotent per jobId:attempt
await tryPut(ctx, { ...claimed, status: "done", output }); // same claimId, or access.js refuses it
}
export async function scheduled(event, ctx) {
ctx.log("debug", "lease sweep", { at: event.scheduledTime });
let after;
do {
const page = await ctx.db.query({ db: DB, field: "status", key: "running", limit: 500, after });
for (const job of page) {
if (!(job.leaseUntil < Date.now())) continue;
await tryPut(ctx, { ...job, status: job.attempts >= MAX_ATTEMPTS ? "failed" : "pending" }, job._rev);
}
after = page.next;
} while (after);
}
The full walkthrough, the matching access.js, and timings measured on
production are at https://good.vibes.diy/docs/patterns/doc-state-machines.
scheduled — periodic work
Requires a config export with a static string-literal interval between
"5s" and "1h" (e.g. "30s", "5m", "1h" — computed values are rejected):
backend.js
jsexport const config = { scheduled: { interval: "15m" } };
export async function scheduled(event, ctx) {
// event: { scheduledTime } — ISO timestamp of this tick
ctx.log("debug", "heartbeat tick", { at: event.scheduledTime }); // proof of life, every tick
await ctx.db.put({ kind: "heartbeat", at: event.scheduledTime }, { db: "status" });
}
Runs as the app owner in admin mode (see "Writes go through the access
function" above): it can read every doc in the app and its writes pass
requireAccess/requireRole automatically, while the access function's
channels and grants still route what it writes. Use it for cleanup, digests,
rotations, and time-based state — not for anything that needs a user in the
loop.
Times of day: the zone comes from the browser
A wall-clock time belongs to the person who set it. "Noon" means noon where
the owner lives. The app is running in their browser at the moment they choose
the hour, and the browser knows their zone exactly and for free — capture it
there, store it beside the hour, and have the tick render event.scheduledTime
into that zone before comparing. A stored IANA zone name also tracks daylight
saving on its own, which a stored hour-offset does not.
App.jsx — when the owner saves the schedule:
App.jsx
jsxawait merge({
hour: 12, // the hour they picked, in their own day
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // e.g. "America/Detroit"
});
backend.js — the tick compares in that zone:
backend.js
jsconst hourLocal = Number(
new Intl.DateTimeFormat("en-US", {
timeZone: settings.timeZone ?? "UTC",
hour: "2-digit",
hourCycle: "h23",
}).format(new Date(event.scheduledTime))
);
if (hourLocal !== settings.hour) return; // not their noon yet — nothing to do
The owner picks an hour in the day they actually live in, and the app does the arithmetic. Keep the schedule control in those terms too: label it with the zone you captured ("every day at 12:00, America/Detroit"), so the setting reads back the way they set it.
A timestamp you show a person is rendered in their zone, never the server's.
The rule above is about an hour the owner chose; this one is about every other
moment your app puts in front of them — a date in a notification body, a "next
event" label, a log line they read. A backend has no locale and no zone, so
toLocaleDateString() there silently means UTC, and a moment at 02:07 UTC is
already tomorrow for half the world. Pass the stored zone every time you format:
jsconst label = new Intl.DateTimeFormat("en-US", {
timeZone: settings.timeZone ?? "UTC",
month: "long",
day: "numeric",
}).format(new Date(moment));
And only say a thing you computed. "Tonight", "this morning", "in an hour" are claims about the reader's clock; if the code did not derive them from that reader's zone, name the moment instead and let them place it.
Recurring reminders on somebody's clock
A recurring reminder fires on each person's own clock, never the server's. Once a reminder repeats — "every weekday at 7" — the zone stops being one setting on one schedule document and becomes a property of each person the reminder is for. Everything below is per person, and none of it is the server's day or the server's hour.
Capture the zone as an effect, not as a question. Do not ask them for a timezone: the browser already knows it, so write it once when they first sign in and leave it alone. Store it where the reminder is addressed — on the person's own profile document when the list is shared — and guard the write so it only ever happens on a real change, or the effect re-fires forever.
Derive the weekday and the hour together, in that zone, from one
formatToParts call. getUTCDay()/getUTCHours() answer a question about
the machine the handler happens to be running on; at 07:00 in Los Angeles it is
already a different day in UTC, so a weekday gate on the server's clock silently
drops Monday mornings and fires on Saturday ones.
An unrecognised zone string throws. Intl.DateTimeFormat raises a
RangeError on a zone it does not know, so in a loop over people the formatting
goes in a try and a bad profile is skipped — one stale record must not stop
everybody else's reminder.
The unit of work is that person's local date (en-CA formats it as
YYYY-MM-DD), keyed per person, per "A tick that repeats is not a run that
repeats" above: the interval is only how often the handler wakes up.
App.jsx — write the zone once, when they arrive:
App.jsx
jsxuseEffect(() => {
if (!me?.userHandle) return;
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!timeZone || myProfile?.timeZone === timeZone) return; // already stored — don't churn
database.put({ _id: `profile:${me.userHandle}`, type: "profile", userHandle: me.userHandle, timeZone });
}, [me?.userHandle, myProfile?.timeZone]);
backend.js — evaluate each person in their own zone:
backend.js
jsexport const config = { scheduled: { interval: "15m" } };
const WEEKDAYS = new Set(["Mon", "Tue", "Wed", "Thu", "Fri"]);
export async function scheduled(event, ctx) {
const at = new Date(event.scheduledTime);
const profiles = await ctx.db.query({ db: DB, field: "type", key: "profile", limit: 500 });
ctx.log("debug", "reminder tick", { profiles: profiles.length });
for (const profile of profiles) {
const { timeZone, userHandle } = profile;
if (!timeZone || !userHandle) continue;
let weekday, hour, dateKey;
try {
// One formatter, one zone: the weekday and the hour must agree about the day.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
hour: "2-digit",
hourCycle: "h23",
}).formatToParts(at);
weekday = parts.find((p) => p.type === "weekday")?.value;
hour = Number(parts.find((p) => p.type === "hour")?.value);
dateKey = new Intl.DateTimeFormat("en-CA", { timeZone }).format(at); // "2026-09-18", their day
} catch {
continue; // unrecognised zone on one profile — skip the person, not the tick
}
if (!WEEKDAYS.has(weekday) || hour !== 7) continue;
const id = `morningReminder:${userHandle}:${dateKey}`; // the unit of work, per person
if (await ctx.db.get(id, { db: DB })) continue; // this morning is already done
await ctx.db.put(
{ _id: id, type: "morningReminder", ownerHandle: userHandle, date: dateKey, checkedAt: at.toISOString() },
{ db: DB }
);
}
}
An unattended tick reports what it did
An unattended tick earns its spend. Nobody is watching a scheduled run, so
it checks what it read before it spends anything: read the upstream first,
confirm the input set is non-empty (and that a 403 is not the
vibesEgressDenied policy body), and return before ctx.callAI when there is
nothing to work from. A model call on an empty input set produces confident text
about nothing, bills the owner for it, and does it again on the next tick,
forever.
Every tick leaves a trace, even the ones with nothing to do. Returning early
when there is no work is right — it saves a read and a spend — but a handler that
returns silently is indistinguishable from one that never ran, and app logs
is the only place an owner can tell. For a job that acts twice a month that means
weeks of silence that reads exactly like a dead alarm. So log once per tick,
before any early return:
backend.js
jsexport async function scheduled(event, ctx) {
const due = eventsDueNow(event.scheduledTime);
ctx.log("debug", "tick", { due: due.length }); // proof of life; no reads, no spend
if (due.length === 0) return;
…
}
One line, and "is this thing still alive?" becomes answerable on any cadence. The rarer the job, the more it needs this: for an infrequent handler, silence is the only symptom a stopped alarm ever produces.
A tick that repeats is not a run that repeats. The interval is how often the
handler is woken, not how often the work should happen: a 15m interval
wakes four times inside the owner's noon, so the handler names the unit of work —
here the local day — and keeps the key of the last unit it finished in a state
document it reads by id, plus a copy in module scope. A missing or failed state
read means "don't know — use the copy", never "nothing has run yet"; that
second reading is what bills the owner four times for one noon.
A status document that must be seen to change carries the tick's timestamp.
A write whose content is byte-identical to what is already stored is absorbed:
the platform stores no new revision, and with no new revision there is no live
event, so an open page holding that document sees nothing and keeps showing what
it already had. A tick that rewrites a fixed-id status doc with the same fields
every time is therefore invisible to a watching reader. Include the moment the
tick ran — checkedAt: new Date().toISOString() — and the write is a real
change, so the page updates. This is the reader's side of the state-document
discipline above: state you only ever read back yourself is better off constant,
and state somebody is watching has to move.
Firing on a real-world event
An averaged cycle is not a calendar. When the moment comes from nature rather than from a clock the owner set — a moon phase, a tide, sunrise, a planetary conjunction — the tempting shape is a mean period since a known epoch:
jsconst SYNODIC = 29.530588861; // mean lunation, in days
const t = KNOWN_NEW_MOON + k * SYNODIC * 86400000; // ← wrong by hours
The average is exact and the individual events are not. Orbits are elliptical and perturbed, so real events run early or late against their own mean. Measured for the lunar case: that formula is up to 18 hours out, and across the next six months it puts two of six new moons on the wrong calendar day. It also fails asymmetrically — full moons land within a couple of hours while new moons run ~15 hours late — so testing one and shipping both proves nothing.
An app that merely displays a countdown can live with this if it says "approximately". An app that notifies cannot: a notification is a claim that the thing is happening now, and firing a ±20-minute window around a mean instant lands it precisely on a time that is precisely wrong.
So: derive the event from a real algorithm with periodic-correction terms (the standard treatments are short — a dozen sine terms gets a lunar phase to within minutes), or fetch it from an ephemeris and cache it. If you can do neither, widen the claim rather than the window: give the day, say "around", and never attach a time-of-day word to it.
The same rule holds anywhere a natural cycle has a tidy average — the average is a description of the series, never a prediction of the next member of it.
Claim the unit of work before you spend on it, not after. Write the key
first, then do the expensive, externally visible part: an isolate that dies
between a finished draft and an unwritten claim comes back and buys the same day
again. Claiming first means a crashed run is skipped rather than retried. That
is the named exception to the lease-and-retry jobs under onChange above: when a
duplicate costs more than a gap — a paid call, a post, a message to a person —
skip rather than retry, because a missing day the status doc names beats a double
charge nobody notices.
Then say what happened. Write the run's outcome to a status doc the app reads,
and put the failure detail in ctx.log("error", …) for whoever inspects the
logs: the owner's only view of an unattended app is what it renders, so a
finished run leaves a visible record of what it did. A notification reports
the outcome the run actually had. Every doc a run writes — status included —
goes in the database the UI live-queries, or the record is invisible in exactly
the way this whole section is about. A timestamp written only when a run
genuinely produced something is a real state change and belongs in that doc —
unlike a per-tick checkedAt, which is the churn the zero-churn rule above is
about.
backend.js
jsexport const config = { scheduled: { interval: "15m" } };
const SHOW_DB = "show"; // the one database App.jsx live-queries
// The isolate's copy of the last day this handler finished. Survives between
// ticks on a warm isolate, and is the fallback when the state read says nothing.
let lastFinishedDay;
export async function scheduled(event, ctx) {
ctx.log("debug", "digest tick", { at: event.scheduledTime }); // proof of life, before any return
const settings = await ctx.db.get("schedule", { db: SHOW_DB });
const zone = settings?.timeZone ?? "UTC"; // captured in the owner's browser
const at = new Date(event.scheduledTime);
const hourLocal = Number(
new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "2-digit", hourCycle: "h23" }).format(at)
);
if (hourLocal !== settings?.hour) return; // not their hour yet — nothing to do
// "2026-08-27" in the owner's own day, which is the unit of work here.
const dayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: zone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(at);
const state = await ctx.db.get("run:state", { db: SHOW_DB });
const finished = state?.dayKey ?? lastFinishedDay; // absent read: trust the copy
if (finished === dayKey) return; // this day's draft is already written
const headlines = await readFeed(ctx); // [] when the fetch failed or the feed was empty
if (headlines.length === 0) {
ctx.log("error", "[digest] no headlines this tick", { at: event.scheduledTime });
await putIfChanged(
ctx,
{
_id: "run:status",
type: "runStatus",
state: "no-input",
message: "couldn't read the feed — no draft written this run",
},
SHOW_DB
);
return; // no model call, and no notification claiming a draft is ready
}
// Claim the day BEFORE spending anything, so an isolate that dies mid-draft can
// never come back and buy the same day again.
lastFinishedDay = dayKey;
await ctx.db.put({ _id: "run:state", type: "runState", dayKey }, { db: SHOW_DB });
try {
const draft = await ctx.callAI(`Write six one-liners about: ${headlines.join("; ")}`);
await ctx.db.put({ _id: `draft:${dayKey}`, type: "draft", draft }, { db: SHOW_DB });
await ctx.db.put(
{
_id: "run:status",
type: "runStatus",
state: "fresh",
lastSuccessAt: event.scheduledTime,
message: "today's draft is ready",
},
{ db: SHOW_DB }
);
await ctx.notify({
toHandle: ctx.appInfo.ownerHandle,
body: "Your six jokes are drafted and ready for the show.",
dedupeKey: `draft-${dayKey}`,
path: `#draft/${dayKey}`,
});
} catch (err) {
// The day stays claimed: the owner sees what went wrong and is not billed twice for it.
ctx.log("error", "[digest] draft failed after the day was claimed", { message: err.message });
await ctx.db.put(
{
_id: "run:status",
type: "runStatus",
state: "failed",
checkedAt: event.scheduledTime, // a second failure must still read as a new one
message: "today's draft didn't finish",
},
{ db: SHOW_DB }
);
}
}
The run's own bookkeeping is the server's, not a user's. ctx.requireBackend
is what separates a run's write from a client's: it passes only when the app's
own backend.js issued the write, and refuses every browser and CLI write
including the owner's own — while still no-opping under admin mode, so a hand
repair runs the function and keeps the routing. access.js is where that is
said:
access.js
js// access.js
export default function (doc, oldDoc, user, ctx) {
if (doc.type === "runState" || doc.type === "runStatus") {
ctx.requireBackend("scheduled");
return { channels: ["show"] };
}
// …the app's own document types…
throw { forbidden: "unknown document type" };
}
App.jsx writes the schedule document that tick reads — the hour the owner
picked and, beside it, the zone their own browser reported — and reads the same
database back, rendering the status beside the drafts so a failed run and a good
one never look alike:
App.jsx
jsxconst { useLiveQuery, database } = useFireproof("show");
async function saveSchedule(hour) {
// The hour is theirs and so is the zone: capture it here, store it beside the hour.
await database.put({
_id: "schedule",
type: "schedule",
hour,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
}
const { docs: statusDocs } = useLiveQuery("type", { key: "runStatus" });
const run = statusDocs.find((doc) => doc._id === "run:status");
// …
{
run?.state === "no-input" ? (
<p role="status">{run.message}</p>
) : (
<p role="status">Last draft written {run?.lastSuccessAt?.slice(0, 16)}</p>
);
}
A complete example — activity feed + owner-only digest
Users write note docs from App.jsx. The backend does two jobs: onChange
mirrors each note into a per-author activity entry (acting as that author —
same privilege, just automated), and a scheduled sweep maintains one digest
doc that access.js restricts to the owner, so no user can forge it.
backend.js
jsexport const config = { scheduled: { interval: "15m" } };
export async function onChange(event, ctx) {
if (event.dbName !== "notes" || event.deleted) return;
await ctx.db.put(
{ _id: "act-" + event.docId, kind: "activity", srcId: event.docId, by: ctx.userInfo, at: event.seq },
{ db: "activity" }
);
}
export async function scheduled(event, ctx) {
ctx.log("debug", "digest sweep", { at: event.scheduledTime }); // proof of life, before any return
// Acts as the app owner — access.js allows `digest` docs for the owner only,
// making this document genuinely server-controlled.
//
// What this tick already did is read BY ID, never re-derived from a scan, and
// the sweep is bounded by what is NEW rather than by how big the app has grown.
const state = await ctx.db.get("digest:state", { db: "digest" });
const since = state?.throughSeq ?? 0;
const fresh = await ctx.db.query({ db: "activity", field: "at", range: [since + 1, Number.MAX_SAFE_INTEGER], limit: 200 });
if (fresh.length === 0) return; // nothing happened — write nothing at all
const throughSeq = fresh.reduce((max, doc) => (doc.at > max ? doc.at : max), since);
const next = { _id: "digest", kind: "digest", headline: `${fresh.length} new notes`, throughSeq };
const existing = await ctx.db.get("digest", { db: "digest" });
if (existing?.headline === next.headline && existing?.throughSeq === next.throughSeq) return; // unchanged
await ctx.db.put(next, { db: "digest" });
await ctx.db.put({ _id: "digest:state", type: "digestState", throughSeq }, { db: "digest" });
}