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 expression | What 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 * * 1 | Emails 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 * * 0 | Ten 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
createdAtThe 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:
// 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) continueIf 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-logsdeletes Activity rows older thanACTIVITY_CLEANUP.retentionDays, in batches. It skips anything that matchesDURABLE_ACTIVITY_TYPE_PREFIXES, so money ledgers, price changes, settings, campaign budgets and refund decisions are never deleted.purge-order-intentsdeletes abandoned quotes a day after they expire, quotes that were already used, andawaiting_settlementquotes that are still unsettled after 7 days. That last case means anasync_payment_failedevent 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 anImportRunas failed when it has been stuck atrunningfor an hour without writing a checkpoint. Failure detection can then see it.purge-old-runsdeletes settledImportRunandAgentRunrows older than 180 days. TheirerrorReportcontains 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-logsdeletesEmailLogrows older than 180 days, in batches.EmailLogis a debugging trail. The consent record is kept in Activity, not here.purge-idle-cartsdeletes carts that never became orders and have not been touched for 90 days, along with theirCartItemrows. Carts with anorderIdare kept forever, because they store the order's UTM and referral attribution.purge-orphan-review-uploadsdeletes S3 objects underreview-media/that are older than a week and that no review'sexternalImagespoints to.purge-orphan-library-uploadsdeletes S3 objects undermedia/that are older than a week and that noMedia.storageKeypoints 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
- Create
functions/cron.<name>.tsand export the function. - Add the export to the
functionsarray inapp/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:
dailyWorkflowCheckshourlyQuickCheckswebhookRetriesshipmentWatchcurrencyRatesRefresh
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.
Related
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.
Workflows
Learn how the three WORKFLOWS in lib/rules/workflows.ts use step.sleep for durable multi-touch follow-ups, how that differs from the polling crons, and what the other event-driven background functions do.
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.