Litestore · Developer guide

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.

Litestore reacts to things happening — an order paid, a cart abandoned, a return requested — through one substrate.

domainEvents.emit publishes to Inngest, one Inngest function per event type runs every consumer, and a repository check fails the build if an event type has no handler.

One substrate is the deliberate part. This page covers where to add a reaction of your own, and which of the four available shapes it should take.

Domain events

events/types.ts defines 56 event names and one payload type each. They cover:

  • Orders — order.created, order.paid, order.refunded, order.disputed
  • Shipments — shipment.shipped, shipment.delivered, shipment.pickup_ready, shipment.pickup_uncollected
  • Inventory, carts, customers, products and collections
  • Reviews — review.submitted, review.approved
  • Returns, wishlists, campaigns and collabs
  • payment.capture_recorded and the social-posting events

The full list is the DOMAIN_EVENT_TYPES tuple at the bottom of that file, declared as const satisfies readonly DomainEventType[] so the array and the union cannot drift.

One producer

events/emitter.ts is the only producer entry point.

It sends the event to Inngest as domain/{type} and stamps a _meta envelope carrying:

eventType
emittedAt
eventId

The eventId is per delivery, and it is what the idempotency machinery keys against later.

There is deliberately no in-process listener registry. A second delivery substrate with different redelivery semantics is what produced the double-counted activity bug class.

One handler per event

Handlers bind to events by name in functions/domain-events.ts, and each owns its side effects end to end — the order-confirmation email, the activity row and the operator alert for a new order all live in one handler:

functions/domain-events.ts
export const onOrderCreated = createDomainEventHandler("order.created", async payload => {
  // one owner for the confirmation email, the activity row, the operator alert
})

createDomainEventHandler wraps every handler in the same sequence:

  1. invalidate-caches
  2. dispatch-webhooks
  3. process-event
  4. Any workflows chained to that event

Cache invalidation and webhook fan-out are therefore not something a handler author remembers to do. They happen because the wrapper does them, on every event.

The completeness check

scripts/check-event-completeness.ts runs as part of bun run verify and fails the build in two cases:

  • A member of DOMAIN_EVENT_TYPES with no createDomainEventHandler call
  • A handler const missing from the domainEventFunctions array

In either case Inngest would never register the function, and the webhook the developer API advertises would silently deliver nothing.

Durable and scheduled work

Two different mechanisms live in functions/, both served from /api/inngest.

Polling crons

The functions/cron.* modules poll on a schedule:

  • cron.abandoned-carts.ts and cron.payment-reconcile.ts — every 30 minutes
  • cron.webhook-retries.ts — every 10 minutes
  • cron.stock-cleanup.ts and cron.publish-social-posts.ts — every 15 minutes
  • cron.shipment-watch.ts and cron.alert-digest.ts — daily at 8am
  • cron.data-cleanup.ts — weekly

Abandoned-cart detection is a poll, not a timer. It scans for carts idle past the operator's abandoned_cart_delay_hours and emits cart.abandoned.

Nothing schedules that check when a cart goes quiet, because nothing emits an event when a cart goes quiet.

Durable waits

Durable waits are the other mechanism, and they are step.sleep inside a workflow.

WORKFLOWS in lib/rules/workflows.ts chains an event to an async function that receives the payload and a durable step surface, so a delay is plain code that survives a deploy.

Three ship:

  • The abandoned-cart recovery drip on cart.abandoned
  • A review request and reminder on shipment.delivered
  • An onboarding nudge on customer.created

Every delay reads from a setting — nothing is hardcoded.

Retries can run the same handler twice

Each emit carries one eventId. Handlers claim their non-idempotent effects against evt:{eventId}:{slot} through claimIdempotencyKey, so an Inngest retry that re-runs the step cannot double-apply an activity row or a counter. Email sends pass an idempotency key through to Resend for the same reason.

Delivery is best-effort

emit registers the send with Next's after, so Vercel keeps the runtime alive without blocking the response, and awaits it directly outside a request scope.

That is not the same as a guarantee:

handler emits

send registered with `after`

crash mid-send, or waitUntil budget overrun

event dropped, nothing retries it

At-least-once delivery would need a transactional outbox table, which is not implemented.

In development with no INNGEST_EVENT_KEY, a failed send falls back to running the same handler bodies inline, so events do not silently go nowhere. That fallback never applies in production, where a timed-out send may still have been accepted.

Task kinds

Operator tasks are the third kind of reaction — computed, not stored.

A task kind pairs a detection query with the exact filter its link opens, and two properties follow from that:

  • Consistent counts — the count on the dashboard and the list behind it compose the same predicate, so they can never disagree.
  • Self-clearing — tasks resolve themselves when the underlying condition resolves. There is nothing to mark done.

41 kinds ship, defined as the AdminTaskData union in server/admin/tasks/queries.ts. Adding one means three things:

  1. The detection query
  2. The intent filter for its destination list
  3. Its entry in the Record<AdminTaskKind, …> maps in lib/tasks/definitions.ts

The compiler reports each one you have not completed.

Several event handlers feed tasks indirectly. A webhook endpoint auto-disabled after failures surfaces as webhooksDisabled; a failed import as importFailed.

Outbound webhooks

External subscribers register webhooks in Settings → Developer.

Delivery runs in services/webhooks/delivery.ts, from the dispatch-webhooks step of every domain-event handler. Payloads are:

  • HMAC-signed (v1)
  • Retried up to five times with exponential backoff from 1 second to 5 minutes
  • Logged with status and attempts

cron.webhook-retries.ts picks up the stragglers every ten minutes.

An endpoint whose last 15 completed deliveries all failed is auto-disabled — a single success anywhere in that window keeps it alive — and that raises an operator task.

Where to add a reaction

For a side effect when something happens, write a handler on the domain event in functions/domain-events.ts. Cache invalidation and webhook dispatch come with the wrapper.

For a delayed or multi-touch follow-up, add a WORKFLOWS entry in lib/rules/workflows.ts and use step.sleep. The delay is durable; a deploy does not lose it.

For work on a schedule, add an Inngest cron in functions/. Use this when there is no event to react to — when the thing you care about is an absence, not an occurrence.

For a to-do the operator should see, add a task kind: a detection query plus the intent filter its link opens.

To notify an external system, register a webhook subscription. That needs no code at all.

The choice is not really about how long the work takes. It is:

What starts it — an event arriving, the clock, or an operator opening a screen?

Next Steps

On this page