Litestore · Developer guide
Background work

Crons

Find out what each scheduled background job does, when it runs, and how to add a new one.

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 want to know when a job runs or when you add a new one.

Twelve functions/cron.* modules define 13 scheduled Inngest functions. Each one is registered in app/api/inngest/route.ts, and Inngest calls them all through /api/inngest.

Each job is built 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. Two run on plain UTC expressions.

The 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, checks canPublishCollection, stamps publishedAt, revalidates cache tags and emits collection.published.
publishSocialPosts (cron.publish-social-posts.ts)TZ=Europe/Warsaw */15 * * * *Calls publishDueScheduledPosts from lib/social-media/scheduling, logs per-platform failures.
stockCleanup (cron.stock-cleanup.ts)*/15 * * * *Calls releaseExpiredReservations so abandoned checkouts stop holding inventory. A backstop only — executeCheckout already releases expired reservations inline, so this catches stores with no checkout traffic.
webhookRetries (cron.webhook-retries.ts)*/10 * * * *Drives webhookService.processRetries(), re-sending outbound deliveries whose nextRetry is due. The interval is the effective retry latency floor, 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, claims each by stamping abandonedEmailSentAt, then emits cart.abandoned.
paymentReconcile (cron.payment-reconcile.ts)*/30 * * * *Lost-webhook safety net. Asks Stripe about open OrderIntent rows older than an hour and replays handlePaymentCompleted for confirmed-paid sessions; does the same for approved-but-unprocessed refunds.
hourlyQuickChecks (cron.index-data.ts)TZ=Europe/Warsaw 0 * * * *Runs bulkRetryFailedPosts() — the canonical social retry path, which enforces the 3-attempt cap and the staggered reschedule.
alertDigest (cron.alert-digest.ts)0 8 * * *Calls deliverAlerts(), grading every situation against the merchant's own alert thresholds and emailing staff one folded digest of what escalated.
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; the uncollected-pickup one also emails the customer.
dailyWorkflowChecks (cron.index-data.ts)TZ=Europe/Warsaw 0 9 * * *Auto-pauses expiring campaigns, grades order aging, counts scheduled and failed social posts, measures collab activity, and queues up to 100 win-back emails to customers lapsed past winback_days.
currencyRatesRefresh (cron.currency-rates.ts)TZ=Europe/Warsaw 0 18 * * *Fetches ECB reference rates from frankfurter.dev and writes external_exchange_rates plus exchange_rates_updated_at. A no-op in manual exchange mode and on single-currency stores.
weeklyDigest (cron.weekly-digest.tsx)TZ=Europe/Warsaw 0 9 * * 1Emails store admins revenue and orders against the previous week, pending items, top sellers and the AI task queue count.
dataCleanup (cron.data-cleanup.ts)TZ=Europe/Warsaw 0 4 * * 0Ten retention sweeps in one weekly run — see below.

The one expression that is not inline

currencyRatesRefresh reads its schedule from CURRENCY_RATE_PROVIDER.refreshCron in lib/currency-rate-provider.ts:

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 your store is outside Europe/Warsaw, the daily digest, shipment watch and workflow checks still run at 8am and 9am Warsaw time. Every TZ= prefix in functions/ is hardcoded, as a comment in cron.index-data.ts notes. To change the time, you edit the cron strings.

Abandoned carts are a poll, not a timer

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.

How the job measures idle time

The job measures idle time from lastCustomerActivityAt in lib/carts/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.

Claim before acting

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:

functions/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.

Where the cron stops

The job does one thing: it emits a cart.abandoned domain event.

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.

What the weekly cleanup deletes

dataCleanup is the largest job in functions/. Each retention window is a named constant at the top of the file.

The job runs ten step.run sweeps:

  • cleanup-activity-logs deletes Activity rows older than ACTIVITY_CLEANUP.retentionDays, in batches. It skips anything that matches DURABLE_ACTIVITY_TYPE_PREFIXES, so money ledgers, price changes, settings, campaign budgets and refund decisions are never deleted.
  • purge-order-intents deletes 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-logs deletes 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-runs marks an ImportRun as failed when it has been stuck at running for an hour without writing a checkpoint. Failure detection can then see it.
  • purge-old-runs deletes settled ImportRun and AgentRun rows older than 180 days. Their errorReport contains raw source rows, so it counts as personal data.
  • purge-expired-auth-artifacts deletes 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-logs deletes EmailLog rows older than 180 days, in batches. EmailLog is a debugging trail. The consent record is kept in Activity, not here.
  • purge-idle-carts deletes carts that never became orders and have not been touched for 90 days, along with their CartItem rows. Carts with an orderId are kept forever, because they store the order's UTM and referral attribution.
  • purge-orphan-review-uploads deletes S3 objects under review-media/ that are older than a week and that no review's externalImages points to.
  • purge-orphan-library-uploads deletes 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 a cron

  1. Create functions/cron.<name>.ts and export the function.
  2. Add the export to the functions array in app/api/inngest/route.ts.

Don't skip step 2. If you define a cron but never add it to that array, Inngest never runs it.

Five of the current jobs once shipped in exactly that state before someone registered them. The route still has the comment // Previously-defined-but-unregistered jobs (now live): above these five:

  • dailyWorkflowChecks
  • hourlyQuickChecks
  • webhookRetries
  • shipmentWatch
  • currencyRatesRefresh

What you get for free, and what you don't

A new cron gets two things without extra work. When it runs out of retries, functions/platform.failures.ts records a platform_job_failed Activity row. And the Prisma middleware in services/inngest.ts 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