Litestore · Developer docs
Background work

Crons

Litestore runs scheduled background jobs on Inngest, and each job defines its schedule in its own file.

Litestore runs scheduled background jobs through Inngest. They publish scheduled content, retry failed deliveries, send digests and clean up old data. Read this page when you need to find out when a job runs or when you add a new one.

Postgres 15+Your data
RedisCache and rate limits
S3 storageImages
LitestoreOne Next.js app
StorefrontAdmin
ResendEmail and sign-in links
InngestBackground jobs
StripeOptional
Inngest calls every scheduled job in the app through the /api/inngest route.

Ten jobs/cron.* modules define 10 scheduled Inngest functions. Litestore registers each one in app/api/inngest/route.ts, and Inngest calls them all through /api/inngest.

You build each job with inngest.createFunction(config, { cron }, handler). The schedule is a string literal in the file. The database has no schedule table, and the admin has no screen for editing one.

Most jobs set TZ=Europe/Warsaw in their expression. The four frequent ones (*/10, */15 and */30 minutes) run on plain UTC expressions.

Schedule

Function (file)Cron expressionWhat it does
publishScheduledCollections (cron.publish-scheduled-collections.ts)TZ=Europe/Warsaw */5 * * * *Flips collections with isPublished: false and scheduledAt in the past to live. It checks canPublishCollection, stamps publishedAt, revalidates cache tags and emits collection.published.
stockCleanup (cron.stock-cleanup.ts)*/15 * * * *Calls releaseExpiredReservations so abandoned checkouts stop holding inventory. It is only a backstop, because executeCheckout already releases expired reservations inline. The job catches stores with no checkout traffic.
webhookRetries (cron.webhook-retries.ts)*/10 * * * *Drives processWebhookRetries() in modules/integrations/webhooks/delivery.ts, which re-sends outbound deliveries whose nextRetry is due. The interval sets the shortest real retry delay, since the backoff itself caps at 5 minutes.
abandonedCartDetection (cron.abandoned-carts.ts)*/30 * * * *Scans up to 100 carts idle past ABANDONED_CART_DELAY_HOURS and newer than ABANDONED_CART_MAX_AGE_DAYS (constants in platform/config/email-automations.ts). It claims each by stamping abandonedEmailSentAt, then emits cart.abandoned.
paymentReconcile (cron.payment-reconcile.ts)*/30 * * * *Catches lost webhooks. It asks Stripe about open OrderIntent rows older than an hour and replays handlePaymentCompleted for confirmed-paid sessions. It does the same for approved refunds that were never processed.
shipmentWatch (cron.shipment-watch.ts)TZ=Europe/Warsaw 0 8 * * *Emits shipment.delayed, shipment.tracking_missing and shipment.pickup_uncollected for in-transit shipments. The first two raise admin notifications, and the uncollected-pickup event also emails the customer.
dailyWorkflowChecks (cron.index-data.ts)TZ=Europe/Warsaw 0 9 * * *Pauses expired campaigns and measures collab activity. It also queues up to 100 win-back emails to customers lapsed past WINBACK_DAYS, which is 0 (off) by default.
currencyRatesRefresh (cron.currency-rates.ts)TZ=Europe/Warsaw 0 18 * * *Fetches ECB reference rates from frankfurter.dev and writes external_exchange_rates. It does nothing in manual exchange mode and on single-currency stores.
weeklyDigest (cron.weekly-digest.tsx)TZ=Europe/Warsaw 0 9 * * 1Emails store admins their revenue and orders against the previous week, pending items and top sellers.
dataCleanup (cron.data-cleanup.ts)TZ=Europe/Warsaw 0 4 * * 0Runs ten retention sweeps in one weekly run, described below.

Currency rates

currencyRatesRefresh runs on TZ=Europe/Warsaw 0 18 * * *. The job runs at 18:00 on purpose. The ECB publishes its rates at about 16:00 CET, and running two hours later means each fetch gets that day's rates instead of yesterday's.

There is no store-timezone setting

If the store is outside Europe/Warsaw, the weekly digest, shipment watch and workflow checks still run at 8am and 9am Warsaw time. Every TZ= prefix in jobs/ is hardcoded, as a comment in cron.index-data.ts notes. To change the time, you edit the cron strings.

Abandoned carts

abandonedCartDetection runs on */30 * * * * and queries the database for idle carts. Litestore does not start a durable timer when a cart goes quiet. Nothing happens at the moment a cart goes idle, so there is no event to start a timer from. The job has to look for idle carts instead.

Idle time

The job measures idle time from lastCustomerActivityAt in modules/checkout/cart-activity.ts. That value is the latest of three timestamps:

non-deleted item updatedAt
checkoutStartedAt
createdAt

The job deliberately ignores Cart.updatedAt. Litestore's own background writes change updatedAt when they reserve stock, record attribution, apply a coupon, set shipping, clean up or merge carts. If the job used updatedAt, every one of those writes would reset the idle time even though the shopper did nothing.

Claims

Two runs of the job can overlap, and Inngest can retry a run. To avoid sending the event twice, the job claims each cart before it emits anything:

jobs/cron.abandoned-carts.ts
// Claim-then-act: atomically mark as processed BEFORE emitting so a
// retry or overlapping cron run can't re-send the abandoned event.
const claim = await db.cart.updateMany({
  where: { id: cart.id, abandonedEmailSentAt: null },
  data: { abandonedEmailSentAt: new Date() },
})
if (claim.count === 0) continue

If the emit then throws, the job clears abandonedEmailSentAt again. The next run picks the cart up again, instead of leaving it marked as sent with no email queued.

Cron scope

The job emits a cart.abandoned domain event and does nothing else. The recovery-drip workflow handles everything after that. It sends a follow-up and, hours later, a coupon, and it waits between them with step.sleep, which is a durable wait.

Litestore combines the two on purpose. A poll finds the abandoned cart, and a durable sleep spaces out the follow-ups.

The job cannot reach guest carts. The query requires customer: { email: { not: null } }, and the source notes that carts have no email column of their own. Recovering guest carts would need a migration that adds Cart.email.

Weekly cleanup

dataCleanup is the largest job in jobs/. Each retention window is a named constant at the top of the file. The job runs ten step.run sweeps.

SweepWhat it deletes or changes
cleanup-activity-logsDeletes Activity rows older than ACTIVITY_CLEANUP.retentionDays, in batches. It skips anything that matches DURABLE_ACTIVITY_TYPE_PREFIXES, so it never deletes money ledgers, price changes, settings, campaign budgets or refund decisions.
purge-order-intentsDeletes abandoned quotes a day after they expire, quotes that were already used, and awaiting_settlement quotes that are still unsettled after 7 days. That last case means an async_payment_failed event was lost.
purge-webhook-delivery-logsDeletes deliveries older than 30 days, 1,000 at a time. It keeps a delivery that is still retrying, however old it is.
sweep-stalled-import-runsMarks an ImportRun as failed when it has been stuck at running for an hour without writing a checkpoint, so failure detection can find it.
purge-old-runsDeletes settled ImportRun and AgentRun rows older than 180 days. Their errorReport contains raw source rows, so it counts as personal data.
purge-expired-auth-artifactsDeletes sessions and verification tokens a week after they expire, and invitations a month after. The delay lets the admin list keep showing "expired — re-invite".
purge-email-logsDeletes EmailLog rows older than 180 days, in batches. EmailLog is a debugging trail, and Litestore keeps the consent record in Activity instead.
purge-idle-cartsDeletes carts that never became orders and have not been touched for 90 days, along with their CartItem rows. It keeps carts with an orderId forever, because they store the order's UTM and referral attribution.
purge-orphan-review-uploadsDeletes S3 objects under review-media/ that are older than a week and that no review's externalImages points to.
purge-orphan-library-uploadsDeletes S3 objects under media/ that are older than a week and that no Media.storageKey points to.

The job deliberately leaves message-media/ alone. Those attachments are not Media rows, so the job has no way to check whether anything still uses them.

Adding crons

Cron file

Write the function with inngest.createFunction and export it.

Inngest registration

Add the export to the functions array in app/api/inngest/route.ts. If you define a cron but never add it to that array, Inngest never runs it.

Built-in behavior

A new cron gets two things without extra work. When it runs out of retries, jobs/platform.failures.ts records a platform_job_failed Activity row. The Prisma middleware in platform/inngest.ts also passes db to your handler.

A new cron does not get idempotency. A run can overlap the previous one and can be retried. If your job increments a counter, sends something or claims a row, the query itself has to prevent doing it twice.

Make the job safe to run twice

Write the claim-then-act pattern yourself, the way cron.abandoned-carts.ts does.

On this page