Workflows
Learn how the three WORKFLOWS in lib/rules/workflows.ts use step.sleep for durable multi-touch follow-ups, how that differs from the polling crons, and what the other event-driven background functions do.
A workflow is a domain event plus an async function that receives the payload and a durable step surface.
lib/rules/workflows.ts holds the whole engine — a type, a builder, and a WORKFLOWS array with three entries. The domain-event handler seam in functions/domain-events.ts runs whichever entries match the event.
There is no separate workflow runtime. Sequencing and delays are plain code against Inngest's own step.sleep and step.run, which is why a multi-day drip needs no state machine, no scheduler table and no reconciliation job.
The step surface
The type is deliberately two methods wide. A workflow can suspend, and it can run a memoised side effect — nothing else:
export type WorkflowStep = {
/** Suspend for a duration ("3d", "2h") — the reaction resumes later, durably. */
sleep: (id: string, duration: string) => Promise<void>
/** Run + memoise a side effect so it fires exactly once across resumes. */
run: <T>(id: string, fn: () => Promise<T>) => Promise<T>
}
export type Workflow = {
event: DomainEventType
name: string
run: (payload: unknown, step: WorkflowStep) => Promise<void>
}The workflow() builder types the payload to its event at the definition site, then erases it to Workflow for storage. Authoring is type-safe; the array is heterogeneous. workflowsFor(event) is the only reader.
Where they are invoked
The seam sits at the end of every domain-event function, after cache invalidation, webhook dispatch and the handler body:
for (const flow of workflowsFor(eventType)) {
await flow.run(stripPayloadMeta(payload), {
sleep: (id, duration) => step.sleep(`${flow.name}:${id}`, duration),
run: <T>(id: string, fn: () => Promise<T>) =>
step.run(`${flow.name}:${id}`, fn) as Promise<T>,
})
}Every step id is namespaced by the workflow name, so two workflows chained to the same event cannot collide on step ids.
An event with no workflow runs nothing.
The three shipped workflows
recovery-drip
Triggered by cart.abandoned.
It sleeps abandoned_cart_followup_hours, sends a "Still thinking it over?" follow-up, then sleeps abandoned_cart_coupon_hours minus the first delay and sends a coupon touch.
Before both touches it calls cartRecovered(cartId) and stops if the cart has an order or was deleted.
Before sending the coupon it re-reads the coupon and runs getCouponState. The code may have been disabled, expired, deleted or hit its usage limit since the config was read, and a dead code must never be emailed.
review-request
Triggered by shipment.delivered.
It sleeps review_request_days and sends a review request, then sleeps review_reminder_days and sends one reminder — but only if no review exists, which it checks with hasReviewedOrder(orderId, customerId).
onboarding-nudge
Triggered by customer.created.
It sleeps signup_nudge_days and sends one "Anything catch your eye?" nudge.
It also filters at the top:
if (payload.source !== "signup") returnSo a customer created by an admin or an import does not get the nudge.
What all three share
Every delay is a setting read through readSetting, and a non-positive value disables that leg. signup_nudge_days of 0 means the nudge never sends; review_reminder_days of 0 sends the request without a reminder. Nothing is hardcoded.
All three send classification: "marketing" mail through queueEmail, so the unsubscribe suppression and the List-Unsubscribe headers in functions/email.send.ts apply.
All three resolve the customer's contact at send time through customerContact, which returns null when there is no email on file.
The re-checks exist because hours or days passed while the workflow slept. The world the workflow woke up in is not the world it went to sleep in.
A settings change does not re-time an in-flight run
A drip already sleeping keeps the cadence it started with, so editing
abandoned_cart_followup_hours only affects carts abandoned after the change.
Each delay is read inside a step.run, and Inngest memoises the value on first
execution.
functions/referral.reward.ts makes the same choice explicitly — the wake date is computed at emit time and carried in the event payload, so a settings edit cannot strand an in-flight sleep.
Durable waits versus polling crons
Both mechanisms produce delayed work, and the abandoned-cart flow uses both, which makes the contrast concrete.
What starts it. A durable wait starts when a domain event arrives. A cron starts because the clock said so.
How it is written. A durable wait is await step.sleep("wait", "24h") inside a workflow. A cron is a { cron: "*/30 * * * *" } trigger in functions/cron.*.ts.
Whether it knows its subject. A durable wait does — it holds the payload across the sleep. A cron does not, so it re-queries for candidates on every run.
Idempotency. A durable wait gets it free: Inngest memoises each step id per run. A cron's is yours to write — cron.abandoned-carts.ts claims by stamping abandonedEmailSentAt first.
Cost when idle. A durable wait costs nothing when there is nothing to do. A cron run happens anyway and finds nothing.
Why crons exist at all
Detection is the reason.
Nothing emits an event when a cart goes quiet, when a Stripe webhook fails to arrive, or when a shipment misses its estimate. There is no actor to emit it.
So cron.abandoned-carts.ts polls every 30 minutes for idle carts and emits cart.abandoned; from that point on the recovery-drip workflow takes over with real durable sleeps:
cron notices an idle cart
↓
emits cart.abandoned
↓
recovery-drip sleeps, re-checks, sendsPoll to notice, sleep to follow up.
One consequence worth knowing: the dev inline fallback in events/emitter.ts runs handler bodies in process with no durable step surface, so workflows are skipped entirely there. Run bunx inngest-cli dev to exercise a drip locally.
Other event-driven functions
Five functions in functions/ are neither crons nor domain-event handlers. They trigger on their own event names and are registered individually in app/api/inngest/route.ts.
agent.verify.ts
Triggered by agent/action.executed. It is the baseline → 7 day → outcome loop for executed AI actions.
It snapshots a per-tool measure immediately, stamps verifyAt, step.sleeps VERIFY_WINDOW ("7d"), re-measures, and writes outcome plus a verdict of improved, unchanged or unmeasured, then flips the AgentRun to verified.
MEASURES covers proposeRefund and createDraftBlock. A tool with no entry gets an explicit { note: "no measure defined" } rather than a silent skip.
referral.reward.ts
Triggered by referral/reward.hold. It holds a referrer's points for the refund window instead of minting them instantly, which is what stops buy-refund farming.
It step.sleepUntils the grantAt date carried in the event, then re-checks that the order:
- is not cancelled
- passes
isReferralQualifyingSale - has no open dispute
- has no existing grant marker
and that the referrer still exists.
idempotency: "event.data.orderId" collapses a replayed webhook effect to one hold.
platform.failures.ts
Triggered by inngest/function.failed. This is the dead-letter listener.
Any function that exhausts its retries becomes a platform_job_failed Activity row, countable by the platform-failure detections in server/admin/tasks/queries.ts.
It logs and returns early if the failed function id is itself the listener.
email.send.ts
Triggered by email/send. It is the async half of the email control plane, retries: 3 with concurrency: { limit: 10 }.
It prefers an active database EmailTemplate for the type and falls back to the React component in emails/, builds an idempotency key that it forwards to Resend, and attaches List-Unsubscribe headers to marketing-class mail.
Its onFailure writes the email_failed Activity row and a terminal EmailLog entry.
email.broadcast.ts
Triggered by email/broadcast. It fans a compose-flow newsletter out to whatever broadcastAudienceWhere selects, in batches of 25 through Promise.allSettled.
Each recipient gets the idempotency key broadcast:{broadcastId}:{customerId}, so a function retry re-sends nothing while a genuinely new broadcast always goes out.
lib/workflow/ is a different thing
Despite the name, lib/workflow/ has nothing to do with durable execution.
It is the shared "what happens next" vocabulary for operator-facing surfaces, and it is pure, React-free and client-safe.
types.ts
Defines WorkflowSeverity (blocking | todo | nicety) and WorkflowIntent — a ranked next step with a stable kebab-case id, optional params for the label, and an optional blockedBy: { reason, href } that flips a CTA into "fix" mode.
Presentation lives at the UI edge. A resolver returns what is next and how urgent, never a label or an icon.
severity.ts
The one place the two canonical urgency scales meet: WorkflowSeverity and AlertLevel (ok | warning | danger).
It exports:
severityToLevellevelToSeverityseverityToTonegradeToTonegradeRankmaxSeverity
The file states the reason plainly — the repository grew roughly 13 ad-hoc severity vocabularies for the same idea, and every other scale is now a projection defined here.
copy.ts
Holds the two strings every attention surface says: NEEDS_ATTENTION, CAUGHT_UP, and caughtUpMessage(scope?).
Storefront voice is deliberately not these.
What a resolver may do
A resolver in this system is a pure function of a precomputed snapshot — no database lookups, no await, no canPublishX() calls inside it.
That is what makes each one table-testable and safe to run on the client.
Choosing a mechanism
Reach for a workflow when you already know the subject and the next touch is a matter of time — the cart that was just abandoned, the order that was just delivered.
Reach for a cron when nothing will tell you the moment arrived, and finding the subjects is itself the job.
Reach for lib/workflow/ when the answer is not background work at all, but what an operator should be shown next.
A workflow follows one subject forward; a cron goes looking for subjects nobody announced.
Related
Crons
Learn what each of the 13 Inngest crons in functions/ does, the exact schedule it runs on, and why abandoned-cart recovery starts from a 30-minute poll rather than a durable timer.
Inngest
Learn how Litestore builds its Inngest client, what the /api/inngest route registers, why no Inngest environment variable is declared in env.ts, and what the dev inline fallback does when a send fails.
Events
Learn how the 56 domain events publish through Inngest, how handlers stay idempotent, what the crons do, and why delivery is best-effort rather than guaranteed.
Crons
Learn what each of the 13 Inngest crons in functions/ does, the exact schedule it runs on, and why abandoned-cart recovery starts from a 30-minute poll rather than a durable timer.
Learn how Litestore renders, queues and sends email through Resend, how database templates override the React components in emails/, and what the EmailLog ledger records.