Litestore · Developer guide

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:

lib/rules/workflows.ts
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:

functions/domain-events.ts
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") return

So 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, sends

Poll 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:

  • severityToLevel
  • levelToSeverity
  • severityToTone
  • gradeToTone
  • gradeRank
  • maxSeverity

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.

On this page