Skip to content

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: delete
  • events are created, updated and removed. when is an expression over document, before and changed(), so it reads the transition: writing approved again, or any other field, fires nothing.
  • fields lists the document fields the job input carries; without it only the ids do. Triggers never carry the whole document.
  • session: true adds by, the user whose write caused the event (null for a function’s own write).
  • ordered: true runs 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 POST with status: "approved" is 403; a curator’s change of anything but submitted → approved is 403.
  • Within seconds of a proposal the member has a notification; after the curator approves, the asset has published_at, the proposal is published with decided_by the curator, and the member has one thank-you.
  • The proposal’s history shows _meta.chain.depth 0 for the curator’s write and 1 for the function’s, both with the same root.

Read Triggers for the delivery guarantees, the cost and how to operate them.

Edit this page · Last updated 16 Oct 2026