Vibes DIY
Vibes DIY / Docs
creator docs · pattern

Doc-status state machines

The problem: work that spans multiple actors — a user requests something, a server process picks it up, an external worker does a slow step, something posts the result. Traditionally: a queue, a consumer, a status API, and a dashboard to see what's stuck.

The pattern: one document per unit of work, with a status field walking a small state machine. Every actor reads the statuses it owns, does its step, and writes the doc forward.

The platform's own mention-builds bot (the story) coordinates three actors this way, through a mention doc in its requests database:

pending-build → building → built → replied
       with terminals: skipped (guardrails), build-failed, error
  • The vibe's scheduled handler ingests Bluesky mentions and writes pending-build docs (after guardrails — dedupe, caps, moderation).
  • A GitHub Actions cron picks up pending-build via the CLI, flips it to building, runs codegen, writes built (or build-failed).
  • The next scheduled tick sees built, posts the in-thread reply, writes replied with the permalink.

Same shape in meta-hub's publisher: publish-request docs walk pending → items-created → carousel-created → done | error, and you can inject work from anywhere the CLI runs:

shnpx vibes-diy db put --vibe you/your-hub --db requests '{
  "kind": "publish-request", "platform": "ig", "slug": "my-post",
  "images": ["https://…/slide-1.jpg"], "caption": "…",
  "status": "pending", "attempts": 0, "createdAt": "2026-07-07T00:00:00Z"
}'

The result lands on the same doc a few ticks later — status, permalink, or a lastError you can actually read.

Rules that keep it honest

  • One writer per status. Decide which actor may write each transition (the builder lane writes building/built/build-failed; the vibe writes everything else) and enforce it in access.js, keyed on the status the document is leaving. The worked example below puts the whole transition table there.
  • Statuses are terminal or owned — never ambient. A worker that takes a job writes down how long it owns it (a leaseUntil on the doc). When the lease runs out, the job goes back to the pool for the next worker, bounded by an attempts counter on the doc. Timeouts and retry budgets live on the document, visible to the dashboard.
  • Failures are states, not exceptions. build-failed, error, skipped are first-class: nothing disappears, and the ledger doubles as the audit log.
  • Idempotent ids. Derive _id from the upstream event (the Bluesky record key, the request slug) so re-ingesting is a no-op instead of a duplicate.

Jobs that start the moment they land: onChange plus a lease sweep

The examples above advance on scheduled ticks or an outside cron. When a job should start as soon as someone queues it, let onChange do the work and a scheduled sweep do the recovery:

pending → running → done
   ↑         │
   └─────────┘  lease expired (sweep)      running → failed  after N attempts
  • Every transition is read, then write with ifRev. Each document a handler reads carries its revision as _rev. Writing with ifRev: doc._rev commits only if nobody moved the job since that read; otherwise the write throws err.code === "conflict" and the handler steps aside. The backend guide covers revisions.
  • onChange claims and finishes. It acts only on pending docs and claims with ifRev: event.doc._rev, so exactly one run gets each job. The claim writes running with a fresh claimId, a leaseUntil, and attempts + 1. The run does the work, then writes done carrying the same claimId.
  • A scheduled sweep recovers. Every tick it looks at running jobs; any whose lease has expired goes back to pending (which fires onChange again), or to failed once it has used its attempts. It writes with the _rev from its query, so it never overwrites a job that finished between the read and the write.
  • access.js holds the transition table, keyed on oldDoc.status, and names which handler may write each edge with ctx.requireBackend(...).

access.js — the transition table

jsexport function jobs(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "sign in" };
  if (doc.type !== "job") throw { forbidden: "unknown document type" };
  const out = { channels: ["jobs"], grant: { public: ["jobs"] } };
  if (!oldDoc) {
    if (doc.status !== "pending" || doc.attempts !== 0) throw { forbidden: "a new job starts pending with 0 attempts" };
    return out;
  }
  // `input` is a string (the image URL). An object arrives as a fresh value on
  // every read, so compare structured input field by field, never by reference.
  if (doc.input !== oldDoc.input) throw { forbidden: "a job's input is fixed" };
  const from = oldDoc.status;
  const to = doc.status;
  // Checked against the head this write replaces. The claim's `ifRev` makes it
  // exclusive; `done` must still carry the claimId the doc holds now, because a
  // slow run's lease can be taken back and the job claimed again while it works.
  if (from === "pending" && to === "running") {
    ctx.requireBackend("onChange");
    if (doc.attempts !== (oldDoc.attempts || 0) + 1) throw { forbidden: "claim bumps attempts by one" };
    if (!doc.claimId || doc.claimId === oldDoc.claimId) throw { forbidden: "claim needs a fresh claimId" };
    return out;
  }
  if (from === "running" && to === "done") {
    ctx.requireBackend("onChange");
    if (doc.claimId !== oldDoc.claimId) throw { forbidden: "stale claim" };
    if (doc.attempts !== oldDoc.attempts) throw { forbidden: "attempts fixed while running" };
    return out;
  }
  if (from === "running" && to === "pending") {
    ctx.requireBackend("scheduled");
    if (!(oldDoc.leaseUntil < Date.now())) throw { forbidden: "lease still held" };
    return out;
  }
  if ((from === "pending" || from === "running") && to === "failed") {
    ctx.requireBackend("scheduled");
    return out;
  }
  throw { forbidden: `no transition ${from} -> ${to}` };
}

backend.js — claim, work, finish, sweep

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 { ok: true }; }
  catch (err) {
    if (err.code === "conflict") return { ok: false, reason: "someone else moved this job first" };
    return { ok: false, reason: String(err?.message || err?.forbidden || err) };
  }
}

export async function onChange(event, ctx) {
  if (event.dbName !== DB || event.deleted) return;
  const job = event.doc;
  if (!job || job.type !== "job" || job.status !== "pending") return; // only pending jobs are work
  const now = Date.now();
  const claimed = { ...job, _id: event.docId, status: "running", attempts: (job.attempts || 0) + 1,
    claimId: rid(), leaseUntil: now + LEASE_MS };
  const claim = await tryPut(ctx, claimed, job._rev);
  if (!claim.ok) {
    ctx.log("info", "claim lost", { id: event.docId, reason: claim.reason });
    return; // ack — another run has this job
  }
  const output = await resize(job.input, `${event.docId}:${claimed.attempts}`); // idempotent per jobId:attempt
  const done = await tryPut(ctx, { ...claimed, status: "done", output });
  if (!done.ok) ctx.log("warn", "done write refused", { id: event.docId, reason: done.reason });
}

export async function scheduled(event, ctx) {
  ctx.log("debug", "sweep", { at: event.scheduledTime });
  const now = Date.now();
  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.type !== "job" || !(job.leaseUntil < now)) continue;
      await tryPut(ctx, { ...job, status: (job.attempts || 0) >= MAX_ATTEMPTS ? "failed" : "pending" }, job._rev);
    }
    after = page.next;
  } while (after);
}

resize stands for your work. Four details carry the weight:

  • ifRev makes the claim exclusive; the claimId keeps the finish honest. The oldDoc check in access.js alone is not a lock, so two claims could both commit. With ifRev: event.doc._rev only one does. The done write carries the claimId because onChange cannot read the job back, and a slow run's lease may have been taken back and the job claimed again while it worked.
  • A lost claim returns; it doesn't throw. Throwing asks the platform to redeliver, and the redelivered event carries the doc as it was when it first fired — pending, with the _rev it had then. That claim conflicts, so returning is the quick way to the same place.
  • Make the work idempotent per jobId:attempt. A job can run more than once — a raced claim, a lease that expired on a slow run — so key anything the work writes or sends by that pair.
  • The status guard makes writing back to the same database safe. A backend write fires onChange again, and the sweep's writes do too; the handler ignores anything that isn't pending, so every chain ends after one step.

What we measured

We ran this shape on production as qa/job-state-machine on 2026-09-24, with a 5-second sweep, a 30-second lease, three attempts, and eleven jobs:

What happened What we saw
A 3-second job, start to finish pending → running → done in 9.0–9.4 s; the commit reached onChange in 3.8–5.4 s
The handler threw after claiming Redelivered ~11 s later (a 10 s backoff) with the original pending doc; the transition table refused it, and the sweep brought the job home — done about 41 s after the throw (lease + up to one tick + a re-claim)
The worker claimed and never finished The sweep returned each attempt 4.0–4.9 s after its lease expired, and marked the job failed after three attempts (2 min 15 s)
Two claims raced (before ifRev) Both claims committed; the loser's done was refused as a stale claim, so the job finished once. The loser's work still ran, and the job waited for the sweep. Claiming with ifRev refuses the second claim before any work starts
The backend's and the sweep's own writes Each fired onChange again; the pending guard ended every chain

The sweep ticked on its 5-second schedule exactly. Recovery takes about one lease plus one sweep interval, so pick both for how long a stuck job may sit: the example above uses a 30-second sweep.

Sizing onChange work

  • Keep each run short, and chunk long work into scheduled. The delivery queue gives a run 15 minutes, then delivers the event again while the first run may still be going — which a lease shorter than your work turns into a second worker.
  • Handlers run in batches of up to ten, in order, so one slow run delays the jobs queued behind it.
  • Decide with the event you were handed. An onChange run can't ctx.db.get a database bound to an access function, so it claims with the event's own _rev — the conflict tells it another run won — and the claimId on the done write is how a slow run finds out it lost its lease.

When a duplicate costs more than a gap

Retrying is the right default for work you can safely repeat. For a paid or externally visible action — a charge, a post, a message to a person — a second run is worse than a missing one. There, claim the unit of work first and skip rather than retry: a day the status doc names as missed beats a double charge nobody notices. The daily-digest example in the backend reference is that shape.

What you get for free

Because the "queue" is a Fireproof database: the dashboard is a useLiveQuery over the same docs (see the operator console pattern), every transition syncs live to whoever's watching, the access function gates who can inject work, and the CLI gives any script or CI runner authenticated access with no extra API (the CI lane pattern).

Two actors never need to share a secret — they only share document shapes. The Bluesky credential in mention-hub never leaves the vibe's vault; the CI runner holds only a device cert. The database is the interface.