Workflows
This page shows how Litestore sends follow-ups days after an event, and when to use a workflow instead of a cron.
A workflow lets Litestore follow up on something days after it happened, for example by emailing a shopper a day after they abandon a cart. Read this page when you want to add a delayed or repeated follow-up to an event.
A workflow pairs a domain event with an async function. The function receives the event payload and a durable step object.
All of the workflow code is in lib/rules/workflows.ts. It contains a type, a builder and a WORKFLOWS array with three entries. The domain-event handler in functions/domain-events.ts runs every entry that matches the event.
Litestore has no separate workflow engine. You write the order of steps and the delays as plain code that calls Inngest's own step.sleep and step.run. A follow-up that spans several days therefore needs no state machine, no scheduler table and no job to reconcile them.
The step object
The type has only two methods on purpose. A workflow can pause, and it can run a side effect whose result is remembered. It can do 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>
}Where you define a workflow, the workflow() builder gives the payload the type of its event. It then stores the result as a plain Workflow. You get type safety while writing a workflow, and the array can still hold workflows for different events. workflowsFor(event) is the only code that reads the array.
Where workflows run
Every domain-event function runs its workflows at the very end, after it has cleared caches, sent webhooks and run 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>,
})
}Each step id is prefixed with the workflow's name. Two workflows on the same event therefore can never use the same step id.
If no workflow matches an event, nothing runs.
The three built-in workflows
recovery-drip
The cart.abandoned event starts this workflow.
It waits abandoned_cart_followup_hours and sends a "Still thinking it over?" follow-up. It then waits abandoned_cart_coupon_hours minus the first delay and sends an email with a coupon.
Before each email it calls cartRecovered(cartId). If the cart now has an order or has been deleted, the workflow stops.
Before it sends the coupon, it reads the coupon again and runs getCouponState. Since the settings were first read, the code may have been disabled, expired or deleted, or it may have reached its usage limit. Litestore must never email a coupon code that no longer works.
review-request
The shipment.delivered event starts this workflow.
It waits review_request_days and sends a review request. It then waits review_reminder_days and sends one reminder, but only if the customer has not reviewed the order yet. It checks this with hasReviewedOrder(orderId, customerId).
onboarding-nudge
The customer.created event starts this workflow.
It waits signup_nudge_days and sends one "Anything catch your eye?" email.
The first thing it does is check where the customer came from:
if (payload.source !== "signup") returnA customer created by an admin or by an import therefore does not get the email.
What all three have in common
Each delay is a setting read through readSetting. A value of zero or less turns that step off. If signup_nudge_days is 0, the nudge never goes out. If review_reminder_days is 0, the customer gets the review request but no reminder. No delay is hardcoded.
All three send classification: "marketing" email through queueEmail. The unsubscribe check and the List-Unsubscribe headers in functions/email.send.ts therefore apply to them.
All three look up the customer's email address at the moment of sending, through customerContact. It returns null when the customer has no email on file.
The workflows check things again because hours or days have passed while they slept. The cart, order or coupon may have changed since the workflow started waiting.
Changing a setting does not change a workflow that is already waiting
A follow-up that is already waiting keeps the timing it started with. If you edit
abandoned_cart_followup_hours, only carts abandoned after the change use the new
value. Each delay is read inside a step.run, and Inngest remembers that value
from the first run.
functions/referral.reward.ts makes the same choice openly. It works out the wake-up date when the event is sent and puts it in the event payload, so editing a setting cannot leave a waiting run stuck.
Durable waits versus polling crons
Both durable waits and polling crons run work later. The abandoned-cart flow uses both, which makes the difference easy to see.
What starts it. A durable wait starts when a domain event arrives. A cron starts at a time on the clock.
How you write it. 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 what it is about. A durable wait does, because it keeps the payload while it sleeps. A cron does not, so it has to search for candidates on every run.
How it avoids doing work twice. A durable wait gets this for free, because Inngest remembers the result of each step id within a run. For a cron, you write it yourself. cron.abandoned-carts.ts does this by setting abandonedEmailSentAt before it does anything else.
What it costs when there is nothing to do. A durable wait costs nothing. A cron still runs and finds nothing.
Why crons are still needed
Crons exist to notice things.
No event is sent when a cart goes quiet, when a Stripe webhook never arrives, or when a shipment misses its estimated date. Nobody is there to send it.
So cron.abandoned-carts.ts checks for idle carts every 30 minutes and emits cart.abandoned for each. From then on, the recovery-drip workflow takes over with real durable waits:
cron notices an idle cart
↓
emits cart.abandoned
↓
recovery-drip sleeps, re-checks, sendsThe cron notices the cart, and the workflow follows up.
One thing to know when developing: the dev inline fallback in events/emitter.ts runs handler bodies inside the app without durable steps, so it skips workflows entirely. To test a follow-up locally, run bunx inngest-cli dev.
Other functions started by events
functions/ has five more functions that are neither crons nor domain-event handlers. Each one listens for its own event name and is registered on its own in app/api/inngest/route.ts.
agent.verify.ts
The agent/action.executed event starts this function. It checks the result of an AI action that was carried out: it measures before, waits 7 days, then measures again.
First it records a measurement for the tool right away and sets verifyAt. It then calls step.sleep for VERIFY_WINDOW ("7d") and measures again. It writes the outcome and a verdict of improved, unchanged or unmeasured, and finally sets the AgentRun to verified.
MEASURES has entries for proposeRefund and createDraftBlock. A tool without an entry gets an explicit { note: "no measure defined" } instead of being skipped silently.
referral.reward.ts
The referral/reward.hold event starts this function. It holds back a referrer's points until the refund window has passed instead of granting them right away. This stops people from buying, collecting the reward and then refunding.
It calls step.sleepUntil with the grantAt date from the event. When it wakes up, it checks again that the order:
- is not cancelled
- passes
isReferralQualifyingSale - has no open dispute
- has no existing grant marker
It also checks that the referrer still exists.
idempotency: "event.data.orderId" makes sure that a replayed webhook results in only one hold.
platform.failures.ts
The inngest/function.failed event starts this function. It is the listener for jobs that have failed for good.
When any function uses up its retries, this listener records it as a platform_job_failed Activity row. The platform-failure checks in server/admin/tasks/queries.ts count those rows.
If the function that failed is this listener itself, it only logs the failure and stops.
email.send.ts
The email/send event starts this function. It does the sending part of Litestore's email system, with retries: 3 and concurrency: { limit: 10 }.
It uses an active EmailTemplate from the database for the email type if one exists. Otherwise it falls back to the React component in emails/. It builds an idempotency key and passes it to Resend, and it adds List-Unsubscribe headers to marketing email.
Its onFailure writes the email_failed Activity row and a final EmailLog entry.
email.broadcast.ts
The email/broadcast event starts this function. It sends a newsletter written in the compose flow to every recipient that broadcastAudienceWhere selects. It sends in batches of 25 through Promise.allSettled.
Each recipient gets the idempotency key broadcast:{broadcastId}:{customerId}. If the function retries, nobody gets the email twice, but a genuinely new broadcast always goes out.
lib/workflow/ is something else
Despite its name, lib/workflow/ has nothing to do with durable execution.
It defines the shared way that admin screens describe "what to do next". The code is pure, does not use React and is safe to run in the browser.
types.ts
This file defines WorkflowSeverity (blocking | todo | nicety) and WorkflowIntent. A WorkflowIntent is a ranked next step. It has a stable kebab-case id, optional params for its label, and an optional blockedBy: { reason, href } that turns a button into a "fix" button.
The UI decides how things look. A resolver only returns the next step and how urgent it is, never a label or an icon.
severity.ts
This is the one place where the two standard urgency scales meet: WorkflowSeverity and AlertLevel (ok | warning | danger).
It exports these functions:
severityToLevellevelToSeverityseverityToTonegradeToTonegradeRankmaxSeverity
The file explains why. The repository had grown roughly 13 separate ways of describing the same urgency, and every other scale is now defined here as a mapping from these two.
copy.ts
This file holds the two messages every attention panel shows, NEEDS_ATTENTION and CAUGHT_UP, plus caughtUpMessage(scope?).
The storefront deliberately does not use these messages.
What a resolver may do
A resolver here is a pure function of a snapshot that was prepared beforehand. It must not query the database, use await or call functions like canPublishX().
Because of that, you can test each resolver with a table of inputs, and it is safe to run in the browser.
Choosing a mechanism
Use a workflow when you already know which cart, order or customer you are dealing with and the next step only has to wait, such as a cart that was just abandoned or an order that was just delivered.
Use a cron when nothing will tell you that the moment has come, and finding the carts, orders or customers is itself the job.
Use lib/workflow/ when you are not running background work at all, but deciding what to show an operator next.
A workflow follows one known item forward in time. A cron goes looking for items that 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.