23. A pipeline on a status
Let members propose assets for the gallery and run the steps that follow, announce, publish and thank, as triggers on the proposal's status.
Until now every function ran because somebody called it or a clock fired. A trigger runs a function because a document changed, whoever changed it and from wherever. This chapter builds a small pipeline on a submissions collection: a member proposes an asset, a curator approves it, and the rest happens on its own.
created ──► announce-submission tell the member it is waiting
status submitted → approved ──► publish-submission publish the asset, mark it published
status → published ──► thank-submitter thank the member
The collection
examples/config/shelf/main/submissions/collection.yaml
# Chapter 23: a pipeline on one document's status. Each step is a trigger of its
# own, with a function of its own, and a `when` that reads the *transition* — what
# the status was and what it is — so writing the same status again fires nothing and
# a function's own write can't loop back through it.
rules:
# A member sees and proposes their own; a curator sees every proposal.
read: user != nil && (document._meta.owner == user.id || hasRole(user, 'curator'))
create: user != nil && data.status == 'submitted' && data.decided_by == nil
# A curator approves, and nothing else: the one change a client may make is
# submitted -> approved. "published" and decided_by are the pipeline's.
update: >
user != nil && hasRole(user, 'curator')
&& document.status == 'submitted' && data.status == 'approved'
&& all(changed(), # in ['status'])
triggers:
# 1. A proposal arrives: tell the member that it is waiting.
announce:
events: [created]
function: main/announce-submission
fields: [asset_id] # the job carries asset_id; without `fields` it carries only ids
# 2. A curator approves: publish the asset and mark the proposal. `session: true`
# adds `by`, the curator, to the input; `ordered: true` makes this document's
# steps run one after another.
approve:
events: [updated]
when: "before.status == 'submitted' && document.status == 'approved'"
function: main/publish-submission
fields: [asset_id]
session: true
ordered: true
# 3. The function marked it published (its own write — a chained event, one
# level deeper): thank the member.
published:
events: [updated]
when: "before.status != 'published' && document.status == 'published'"
function: main/thank-submitter
fields: [asset_id]
ordered: true
# A proposal goes with the member, like their assets.
on_owner_delete:
action: deleteeventsarecreated,updatedandremoved.whenis an expression overdocument,beforeandchanged(), so it reads the transition: writingapprovedagain, or any other field, fires nothing.fieldslists the document fields the job input carries; without it only the ids do. Triggers never carry the whole document.session: trueaddsby, the user whose write caused the event (nullfor a function’s own write).ordered: trueruns one document’s jobs one at a time, in the order the changes happened.
The steps
examples/config/shelf/main/_functions/publish-submission/index.ts
// publish-submission: step 2 of the submission pipeline (chapter 23). A curator
// moved a proposal from submitted to approved; `by` is that curator. Publish the
// asset and mark the proposal published, which fires step 3.
//
// The trigger can deliver the same change again after a restart, so the function
// looks at where the proposal stands now and does nothing that was already done.
import { defineFunction } from "@backd/functions";
import type { DateTimeString } from "../backd.d.ts";
export default defineFunction("main/publish-submission", async (ctx) => {
const { doc, by } = ctx.input;
const main = ctx.admin.db("main");
const submissions = main.collection("submissions");
const submission = await submissions.get(doc.id);
if (submission.status !== "approved") return { skipped: `it is ${submission.status}` };
const assets = main.collection("assets");
const asset = await assets.get(submission.asset_id as string);
if (!asset.published_at) {
await assets.patch(asset.id, { published_at: new Date().toISOString() as DateTimeString }, { ifMatch: asset._meta.version });
}
// decided_by is the curator who approved: `by`, when a user did the approving.
await submissions.patch(doc.id, { status: "published", decided_by: by ?? null }, { ifMatch: submission._meta.version });
return { asset: asset.id, published_by: by ?? null };
});The function’s own write (status: published) is an event too, one level deeper, and it is what fires thank-submitter. Each step is small and checks where the document stands now, because a delivery can repeat after a restart: the same change has the same event id and the same job id, so a replay collapses into the job that already exists, and what slips through is made harmless by that check.
The inputs are typed: backd types generates SubmissionsTriggerInput, and the trigger functions are typed with admin access automatically.
Chains have a bound
Every write made by a trigger’s function carries _meta.chain = { root, depth }: the first event’s id and how many triggers deep it is. The pipeline above is depth 0 → 1 (publish) → 2 (thank). A realm stops a chain at triggers.max_chain (16 by default) and the dropped event is a gap you can see, never a silent loop.
Test and debug
Test a trigger function with fakeTrigger from @backd/functions-testing; see testing functions. In a running realm:
backd functions history --function main/publish-submission # each run, its input and result
backd triggers status # lag, backlog, the lease holder
backd triggers gaps # what could not be delivered
You should see
- A member’s
POSTwithstatus: "approved"is403; a curator’s change of anything butsubmitted → approvedis403. - Within seconds of a proposal the member has a notification; after the curator approves, the asset has
published_at, the proposal ispublishedwithdecided_bythe curator, and the member has one thank-you. - The proposal’s history shows
_meta.chain.depth0 for the curator’s write and 1 for the function’s, both with the sameroot.
Read Triggers for the delivery guarantees, the cost and how to operate them.