Litestore · Developer guide

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.

Inngest is the single background substrate. Domain events, crons, durable sleeps and async email all run through it.

Litestore builds the client in services/inngest.ts with an id and one middleware, and serves every function from one route, app/api/inngest/route.ts.

No Inngest key is declared in env.ts, so a store boots and serves without one. The scheduled and queued work just never runs — which is a deliberate trade, and the rest of this page is largely about its consequences.

The client

services/inngest.ts is the whole client.

It names itself after config.site.slug ("litestore" in config/site.ts) and attaches one middleware that hands every function run the shared Prisma client:

services/inngest.ts
const prismaMiddleware = new InngestMiddleware({
  name: "Prisma Middleware",
  init: () => ({
    onFunctionRun: () => ({
      transformInput: () => ({ ctx: { db } }),
    }),
  }),
})

export const inngest = new Inngest({
  id: config.site.slug,
  middleware: [prismaMiddleware],
})

That middleware is why function handlers can destructure db from their arguments — async ({ step, db, logger }) => … in cron.index-data.ts and cron.publish-social-posts.ts — instead of importing ~/services/db.

Both styles are in the repository. The middleware is what makes the destructured one work.

What the client does not do

Three things are absent on purpose:

  • No event schema registration
  • No EventSchemas generic
  • No signing key passed here

Event payload types are enforced at the producer instead:

  • events/types.ts for domain events
  • A per-function type …Event export for the rest — EmailSendEvent in functions/email.send.ts, EmailBroadcastEvent, ReferralRewardHoldEvent

The serve route

app/api/inngest/route.ts exports GET, POST and PUT from serve() and caps execution at 60 seconds:

app/api/inngest/route.ts
export const maxDuration = 60

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [
    sendEmailFunction,
    sendBroadcastFunction,
    publishSocialPosts,
    // …
    ...domainEventFunctions,
  ],
})

The array lists 18 named functions plus the spread of domainEventFunctions — one per domain event type, 56 of them, built by createDomainEventHandler in functions/domain-events.ts.

The 18 named functions are:

  • 13 crons (functions/cron.*)
  • Two email functions — email.send.ts and email.broadcast.ts
  • Three event-driven jobs — agentVerify, platformFunctionFailed and referralRewardHoldFunction

What maxDuration actually caps

maxDuration = 60 is the per-invocation budget, not the per-job budget.

A function that splits its work into step.run calls gets 60 seconds per step, and a step.sleep ends the invocation entirely.

Jobs that make serial external calls size their batches against it. cron.payment-reconcile.ts caps a run at 25 Stripe lookups (RECONCILE_BATCH_SIZE) for exactly this reason.

Function ids

Ids follow ${config.site.slug}.<name>:

litestore.data-cleanup
litestore.email-send

Two functions do not use the prefix:

alert-digest     (alertDigest)
agent-verify     (agentVerify)

Domain-event functions are domain-<event> with the dot replaced:

domain-order-created

Environment variables

env.ts declares no Inngest variable.

The only Inngest env name read anywhere in the source is INNGEST_EVENT_KEY, and it is read off process.env directly in events/emitter.ts to decide whether the dev inline fallback applies. It is never validated at boot.

Nothing fails without Inngest, and nothing scheduled runs

An unconfigured deployment boots, serves the storefront and admin, and commits orders normally. Everything downstream of inngest.send stops — queued email, webhook dispatch, cache invalidation from domain events, all 13 crons, every durable workflow — and emitter.ts logs those failures, otherwise silently.

The dev inline fallback

Locally, with no bunx inngest-cli dev running and no event key set, inngest.send fails and events would go nowhere — including cache invalidation.

events/emitter.ts detects that exact case and runs the same handler bodies in process instead:

events/emitter.ts
export function isInlineFallbackEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
  return env.NODE_ENV === "development" && !env.INNGEST_EVENT_KEY && !env.VITEST
}

All three conditions matter.

Production is excluded because a timed-out send may still have been accepted by Inngest, so running inline there could double-deliver.

Vitest is excluded because unit tests must not execute real handler side effects — database writes and email sends.

What it runs

When it triggers, deliver() dynamically imports runDomainEventInline from functions/domain-events.ts and executes the same three things the Inngest function would:

  1. invalidateCachesForDomainEvent
  2. webhookService.dispatchEvent
  3. The handler body

It reuses the emitter's envelope, so the eventId that keys side-effect dedupe is identical either way.

The dynamic import exists for two reasons: to keep the server graph off the normal path, and to avoid a static emitter ⇄ functions/domain-events cycle.

What it does not run

The fallback does not run workflows.

workflowsFor(eventType) needs a real step.sleep, and inline execution has no durable step surface:

event emitted in dev, no event key

inline: caches invalidated, webhooks dispatched, handler body runs

workflows: skipped entirely

So the recovery drip, the review request and the onboarding nudge never fire from the fallback. Start the Inngest dev server to exercise them locally.

Failure handling

Retries are configured per function, not globally:

retries: 3   email.send.ts, cron.currency-rates.ts
retries: 2   most crons
retries: 1   shipmentWatch, dailyWorkflowChecks
retries: 0   hourlyQuickChecks

When a function exhausts its retries, Inngest emits the system event inngest/function.failed.

functions/platform.failures.ts is the one listener for it. It turns that silent death into a platform_job_failed Activity row, which the platform-failure detections in server/admin/tasks/queries.ts count as operator work.

It guards against a feedback loop by logging only when the failed function id is itself the dead-letter listener.

sendEmailFunction adds its own onFailure on top. It writes an email_failed Activity row and a terminal recordEmailLog entry, so a dead send is queryable and retryable rather than an entry in an Inngest dashboard nobody opens.

That is the pattern worth copying when you add a background function. The useful question is not whether the job retries — Inngest handles that — it is:

When the last retry fails, who finds out?

On this page