All pages

Fulfillment

Fulfillment is the side effect a purchase has to produce, and it is delivered by one column on the purchases row. fulfillment_state holds pending, in_progress, delivered or failed, and a flush claims one pending row per statement, so the row that comes back is the one this process owns. There is no queue table, no scheduler and no lock beside it.

For FFD the side effect is a GitHub collaborator invite. For your product it is tenant provisioning, a license key, a Discord invite, or whatever you hand somebody once their money has moved, and the machinery below does not know the difference.

The state column is the queue

The claim is one statement, and it takes one row:

const oldestPending = db
  .select({ id: purchases.id })
  .from(purchases)
  .where(eq(purchases.fulfillmentState, 'pending'))
  .orderBy(purchases.createdAt)
  .limit(1)
  .for('update', { skipLocked: true })

const [row] = await db
  .update(purchases)
  .set({
    fulfillmentState: 'in_progress',
    fulfillmentAttemptedAt: new Date(),
    fulfillmentAttempts: sql`${purchases.fulfillmentAttempts} + 1`,
  })
  .where(
    and(eq(purchases.id, oldestPending), eq(purchases.fulfillmentState, 'pending')),
  )
  .returning({ id: purchases.id, email: purchases.email, /* ... */ })

A row that comes back is yours and a row that does not was someone else's, which is the whole of the concurrency control. FOR UPDATE SKIP LOCKED inside the subquery is what makes two flushes take two different rows instead of one taking everything, and it holds under two webhook deliveries arriving at two instances at the same moment, with no coordination, no fulfillments table, no Redis and no scheduler.

The loop delivers that row, writes its outcome, and asks for the next one until nothing comes back or a stop condition fires. One statement per row costs more round trips than one statement for the whole batch, which at this product's volume is not a cost, and it buys the property that nothing unattempted is ever claimed.

Careful

Never read then write. A SELECT ... WHERE fulfillment_state = 'pending' followed by an UPDATE is exactly the race this exists to remove: two deliveries of checkout.session.completed both read "still pending" and both send an invite. Splitting the claim across two statements reopens it, however small the window looks.

purchases_pending_idx is a partial index over created_at where the state is pending, because the flush only ever asks for the oldest pending row and pending rows are the rare ones. Its predicate is Drizzle's eq() rather than a SQL string, so renaming the column is a type error rather than an index that silently stops matching.

Four states, in one column

State Written by Means
pending the row's default, and every requeue in the queue
in_progress the claim being delivered, or stranded by a crash
delivered a 201 or 204 from GitHub the invite landed
failed a terminal outcome or a null username out of the queue, waiting on a person

Every reader is an eq or an inArray on that column, so there is one rule rather than a derivation restated in three places. FULFILLMENT_STATES in packages/shared/src/responses.ts is the runtime array both the column type and the wire type read.

The two timestamps beside it each carry one fact. fulfillment_attempted_at is written by the claim and says when this row's most recent attempt began. fulfilled_at is written on each successful delivery, so a forced re-send that then fails at GitHub leaves it in place and still moves the state to failed, which is what puts that row on the admin list.

in_progress and stranded-by-a-crash are indistinguishable by design. Telling them apart needs a staleness timeout, which is a timer in disguise and would re-invite a row whose request is merely slow. A forced admin re-trigger is the recovery path, and force exists because that judgement is the operator's: no query can make it, and an unforced requeue of a row a flush still owns is the duplicate delivery the column prevents. Re-sending a delivered row is forced for the same reason from the other end: the invite landed, so sending it again is a second external attempt rather than a repair. The claim prevents concurrent attempts, not every duplicate external side effect: a crash after vendor success can leave local state incomplete, and requeue can send the courtesy email again.

Four triggers, and no timer

Nothing in this repo schedules a flush. It runs when something happens:

  1. The Stripe webhook, after a checkout session is recorded.
  2. Clerk user.created or user.updated. The webhook does identity work only, because a Purchase already knows its owner. It still flushes, because a Purchase parked on a null GitHub username waits on nothing else.
  3. The buyer's own correction, POST /fulfillment/requeue, which writes a new username to a purchase the caller owns and then flushes.
  4. The admin re-trigger, POST /admin/users/:id/fulfillment/retrigger, which sets the row back to pending and then flushes. The requeue is one conditional update, and unforced its predicate is inArray(state, ['pending', 'failed']). A row a flush still owns is refused, because requeuing it is what lets a second flush deliver the same purchase again, and force is how an operator asks for that second attempt on purpose.

The accepted consequence is that worst-case recovery latency is unbounded. A fulfillment that failed transiently waits for whichever of the four fires next, and at low volume that can be a long time. The buyer's correction is the remedy for a wrong username, and the admin button is the remedy for everything else. That trade stops being the right one the moment a human reading support email stops being your remediation path.

What is retried, and what never is

The classification is the contract, and it matters more than the HTTP call:

Outcome Statuses What happens
delivered 201, 204 state delivered, fulfilled_at set, invite email sent
terminal 404, 422 state failed on the first failure, leaves the queue
transient 403, 5xx, DNS, TLS, timeout state back to pending, flush stops

204 is success. It means the account is already a collaborator, which is what a resend of an accepted invite looks like. The reference accepted only 201, so re-inviting somebody who already had access looked like a failure forever.

404 is terminal because it means no such GitHub account, and retrying a typo never succeeds. There is deliberately no attempt cap on the transient class: one GitHub outage would burn the budget on rows that were never bad and then stop retrying them forever. Terminal classification ends a retry, never exhaustion.

The flush is sequential rather than parallel, because the first row is the probe. If GitHub is down for one row it is down for all of them, and hammering a service that is already failing is how a 403 rate cap turns into a longer one.

A transient outcome puts one row back and stops. The vendor never returned a decision about that row, so it goes back to pending and the loop ends without claiming the next one. There is no tail to hand back, because a row is only ever claimed immediately before it is attempted. fulfillment_attempts still counts that attempt, since the claim is what increments it.

The invite is sent after the delivered write

await markDelivered(row.id)
await sendInvite({ to: row.email, githubHandle: row.githubHandle, /* ... */ })

That order is deliberate. sendInvite reports rather than throws, and a mail that failed must not cost the buyer access they already have. Writing delivered first does not make the row requeueable, because an unforced requeue matches pending or failed and the row is now neither, which is why this mail is the one that takes no email_deliveries claim of its own. It is also why a requeue doubles as "resend the invite mail": the second GitHub call is a 204, which is success, which sends the mail again.

The flush itself reports rather than throws, for the same reason: all four callers are side effects of something that already happened, and a redelivered webhook cannot fix GitHub. It can only re-run everything around it.

The username is checked before the money moves

The GitHub username arrives in the body of POST /checkout/session, so it is collected on a surface this repository owns rather than on Stripe's page. The api puts it in Checkout Session metadata, and the webhook reads it off the re-fetched Session. Stripe collects nothing, so there is no field there to mistype.

A username is existence-checked before the Session is created. apps/api/src/lib/fulfillment/check-github-handle.ts reads GET /users/{username} with GITHUB_TOKEN and rejects only what GitHub actively denies: a login it answers 404 for, and a login that turns out to be an organization, which exists but can never accept a collaborator invite. Both are one 400 with the code github_handle_unknown, and no Session and no charge exist for it.

GitHub is the only judge of whether a login exists. The api does not pattern-match the username first, because a rule that is wrong about one grandfathered account would refuse that buyer's money outright instead of degrading, and a malformed string is simply a login GitHub answers 404 for. The shared request schema bounds its length and nothing else.

Every other outcome accepts. An unset GITHUB_TOKEN, a rate cap, a timeout or a 5xx logs fulfillment.handle.unverified and lets the sale continue, because unavailable validation must not block a payment. GITHUB_TOKEN is optional, and a deployment without it behaves exactly as this repository behaved before the check existed.

The check is authenticated because GitHub allows 60 unauthenticated requests per hour per address, and your api calls from one address.

A buyer with no GitHub account pays with a null username. The step at /checkout offers that explicitly rather than making them invent one, and says what follows. Fulfillment parks that row on its first attempt with fulfillment_last_error set to "No GitHub handle was recorded for this purchase.", which is the same behaviour a null username always had.

Correcting a username

Validation narrows the window, it does not close it: an account is renamed, deleted, or lets its invite expire unaccepted. So the correction path stays open, and it belongs to the buyer.

The buyer's surface is the invite panel on /dashboard. It gives a failed purchase an editable username and a resend, and it will not offer the button for an unchanged username, because the api refuses one. GET /fulfillment deliberately never returns fulfillment_last_error, so the panel writes its own sentence from the state instead: a row parked on a null username reads as waiting for one, and a row whose username GitHub rejected reads as needing a correction.

POST /fulfillment/requeue takes a purchase id and a new username. Ownership is a predicate on the read, so a purchase id belonging to somebody else reads as a 404 rather than a permission error. The route refuses three things:

Refusal Code Why
the row is not failed fulfillment_not_failed delivered, or already in the queue
the username is unchanged github_handle_unchanged resending a username that failed cannot succeed
GitHub was called under five minutes ago requeue_cooldown this is a public button that calls a vendor

The cooldown reads fulfillment_attempted_at on a failed row, because that timestamp is when GitHub was last called. A row parked on a null username never reached GitHub, so it has no cooldown and the first username can be supplied immediately.

requeueCooldownUntil in apps/api/src/lib/fulfillment/fulfillment-state.ts derives that window once and both readers use it: the route's own refusal, and cooldownUntil on GET /fulfillment. An elapsed window is null rather than a past instant, so neither reader compares two clocks to learn there is nothing to wait for.

Publishing the instant is what lets the dashboard disable the button instead of letting the buyer press it into a 429. It is an ISO 8601 timestamp rather than a number of seconds, which would already be stale when it was serialized, and it is not a relaxation of the rule that keeps fulfillment_last_error admin-only: when a button works is a fact about the button, while the vendor's reason for the failure is not.

A valid correction writes the username, sets the state back to pending and clears the error in one predicated update, then flushes. The predicate is the same one the read applied, because everything between them is a network call and the row can be claimed in the meantime. The new username goes through the same existence check as checkout. The route answers with the caller's whole fulfillment list, already reflecting that flush, so the panel repaints from the response rather than polling for it.

GET /admin/fulfillments/attention is how you find a row to repair. Every other read over purchases is keyed to one user or one id, so before this list existed an administrator had to already suspect somebody. The list holds the failed rows and the in_progress rows together, newest attempt first, each carrying the attemptedAt instant its most recent attempt began. It returns 50 of them and does not page, so at that count you are looking at a symptom rather than at individual buyers.

An in_progress row is on the list whether a flush is delivering it right now or a crash stranded it between the claim and the outcome, because nothing in the api can tell those two apart. attemptedAt is what you read instead: one attempt runs for about twenty seconds, so a start a few seconds old is a live send and a start minutes old is a stranded row. force on the re-send is the recovery, and no threshold reclaims a row on its own.

POST /admin/purchases/:id/link is a different repair, for a Purchase recorded without an owner. It assigns ownership of a payment and never grants or edits an Entitlement. A Purchase with no owner cannot be re-sent from the attention list, because the re-trigger route is addressed by user id.

Swapping it for your own

apps/api/src/lib/fulfillment/invite-collaborator.ts is the seam. Everything else in that folder is delivery machinery and none of it knows what a GitHub repository is.

Replace that one file, keep the three-way InviteOutcome, and the claim, the classification, the flush, the state column and the requeue routes all come along unchanged. The step-by-step version is Swap fulfillment.

inviteCollaborator and githubConfig are deliberately not re-exported from the folder's barrel. The invite is called by the flush or not at all, because calling it directly is calling it without the claim, which bypasses the exclusive claim.

Where the decisions live

  • docs/adr/0038-fulfillment-state-is-a-column.md, the state column, the one-row claim, and the visible stranded row
  • docs/adr/0007-atomic-claim-for-side-effects.md, why a side effect is claimed at all, and the triggers
  • docs/adr/0013-api-hardening-baseline.md, the retry classification
  • docs/adr/0031-identity-precedes-payment.md, why the username is collected and checked before Stripe, and who may correct it
  • docs/adr/0004-webhook-as-trigger.md, why the flush is called from a webhook at all

The recipes for changing any of this start at Add an api route.

Updated