Litestore · Developer guide
Background work

Events

This page shows how Litestore reacts when something happens in your store and where to add a reaction of your own.

When something happens in your store, such as an order being paid, a cart being abandoned or a return being requested, Litestore reacts to it through one shared system. Read this page when you want your own code to run in response to something in the store.

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

Using one system for all of this is a deliberate choice. This page shows where to add your own reaction and which of the four available kinds it should be.

Domain events

events/types.ts defines 56 event names, each with its own payload type. They include these groups:

  • 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

You can find the full list in the DOMAIN_EVENT_TYPES tuple at the bottom of that file. It is declared as const satisfies readonly DomainEventType[], so the array and the union type can never fall out of step.

One way to send an event

events/emitter.ts is the only place that sends events.

It sends each event to Inngest as domain/{type} and adds a _meta envelope with these fields:

eventType
emittedAt
eventId

Each delivery gets its own eventId. Later, the code that prevents duplicate work uses this id as its key.

Litestore deliberately has no in-process listener registry. A second way of delivering events, which redelivered events differently, is what caused a whole class of bugs where activity was counted twice.

One handler per event

In functions/domain-events.ts, each handler is tied to one event by name. That handler does all the work for the event. For a new order, one handler sends the confirmation email, writes the activity row and alerts the operator:

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 and runs the same steps in this order:

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

Whoever writes a handler does not need to remember to clear caches or send webhooks. The wrapper does both for every event.

The completeness check

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

  • an event in DOMAIN_EVENT_TYPES has no createDomainEventHandler call
  • a handler constant is missing from the domainEventFunctions array

In either case, Inngest would never register the function. The webhook that the developer API offers for that event would then quietly deliver nothing.

Durable and scheduled work

functions/ contains two other kinds of work. Both are served from /api/inngest.

Polling crons

The functions/cron.* modules check for work on a schedule:

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

Litestore finds abandoned carts by checking on a schedule, not with a timer. The cron looks for carts that have been idle longer than the operator's abandoned_cart_delay_hours setting and emits cart.abandoned for each one.

A schedule is needed because nothing sends an event when a cart goes quiet, so there is nothing to start a timer from.

Durable waits

A durable wait is a step.sleep inside a workflow.

WORKFLOWS in lib/rules/workflows.ts links an event to an async function. The function receives the event payload and durable steps it can call. A delay is ordinary code, and it continues correctly after a deploy.

Litestore ships three workflows:

  • the abandoned-cart recovery emails, started by cart.abandoned
  • a review request and a reminder, started by shipment.delivered
  • an onboarding nudge, started by customer.created

Every delay comes from a setting. None of them is hardcoded.

A retry can run the same handler twice

Each emitted event has one eventId. Before a handler does something that must not happen twice, it claims the key evt:{eventId}:{slot} through claimIdempotencyKey. If Inngest retries the step, the activity row or counter is not applied a second time. Email sends pass an idempotency key to Resend for the same reason.

Delivery is best-effort

Inside a request, emit registers the send with Next's after. Vercel then keeps the function running until the send finishes, without delaying the response. Outside a request, emit awaits the send directly.

This still does not guarantee delivery:

handler emits
    ↓
send registered with `after`
    ↓
crash mid-send, or waitUntil budget overrun
    ↓
event dropped, nothing retries it

To guarantee at-least-once delivery, Litestore would need a transactional outbox table, and it does not have one.

In development without an INNGEST_EVENT_KEY, a failed send falls back to running the same handler code inline, so events are not quietly lost. Production never uses this fallback, because a send that timed out may still have reached Inngest.

Task kinds

Operator tasks are the third kind of reaction. Litestore works them out when needed instead of storing them.

Each task kind pairs a query that detects the problem with the exact filter that its link opens. This has two results:

  • The counts always match. The number on the dashboard and the list behind it use the same condition, so they can never disagree.
  • Tasks clear themselves. When the problem is fixed, the task disappears. Nobody has to mark it done.

Litestore ships 41 task kinds, defined as the AdminTaskData union in server/admin/tasks/queries.ts. To add one, you write three things:

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

The compiler tells you about each part you have not finished.

Some event handlers create tasks indirectly. When a webhook endpoint is disabled after repeated failures, it shows up as a webhooksDisabled task. A failed import shows up as importFailed.

Outbound webhooks

Outside systems subscribe to events by registering a webhook in Settings → Developer.

services/webhooks/delivery.ts sends the webhooks. It runs from the dispatch-webhooks step of every domain-event handler. Each payload is:

  • signed with HMAC (v1)
  • retried up to five times, with backoff that grows from 1 second to 5 minutes
  • logged with its status and number of attempts

cron.webhook-retries.ts picks up any deliveries still waiting every ten minutes.

If the last 15 completed deliveries to an endpoint all failed, Litestore disables it and raises an operator task. A single success within those 15 keeps it active.

Where to add a reaction

To run a side effect when something happens, write a handler for the domain event in functions/domain-events.ts. The wrapper clears caches and sends webhooks for you.

To send a delayed follow-up, or several follow-ups over time, add an entry to WORKFLOWS in lib/rules/workflows.ts and use step.sleep. The delay is durable, so a deploy does not lose it.

To run work on a schedule, add an Inngest cron in functions/. Use a cron when no event tells you about the thing you care about, for example when you are looking for something that did not happen.

To show the operator a to-do, add a task kind. That means a detection query and the filter its link opens.

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

The right choice does not depend on how long the work takes. Ask yourself this instead:

What starts the work: an event arriving, the clock, or an operator opening a screen?

Next Steps

On this page