Crons
Learn what each of the 13 Inngest crons in functions/ does, the exact schedule it runs on, and why abandoned-cart recovery starts from a 30-minute poll rather than a durable timer.
Twelve functions/cron.* modules define 13 scheduled Inngest functions. All are registered in app/api/inngest/route.ts and all are served from /api/inngest.
Each one is inngest.createFunction(config, { cron }, handler), so the schedule is a string literal in the file — there is no schedule table in the database and no admin surface that edits one.
Most are pinned to TZ=Europe/Warsaw. 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 CURRENCY_RATE_PROVIDER.refreshCron from lib/currency-rate-provider.ts:
TZ=Europe/Warsaw 0 18 * * *18:00 is chosen deliberately, after the ECB's ~16:00 CET publication, so each fetch picks up that day's rates rather than yesterday's.
There is no store-timezone setting
A store operating outside Europe/Warsaw gets its daily digest, shipment watch
and workflow checks at Warsaw's 8am and 9am: every TZ= prefix in functions/
is hardcoded, as a comment in cron.index-data.ts notes. Changing that means
editing the cron strings.
Abandoned carts are a poll, not a timer
abandonedCartDetection runs */30 * * * * and queries for carts.
It is not a durable wait scheduled when a cart goes quiet. Nothing schedules anything at cart-idle time, because there is no event at cart-idle time.
The clock it measures
The clock is lastCustomerActivityAt from lib/carts/activity.ts — the max of three values:
non-deleted item updatedAt
checkoutStartedAt
createdAtIt is explicitly not Cart.updatedAt. Background writes for reservation, attribution, coupon, shipping, cleanup and cart merge all bump updatedAt, and would otherwise reset the abandonment clock every time the system touched the row.
Claim before acting
A poll can overlap itself, and Inngest can retry it. So the job claims the 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, so the next run retries the cart instead of leaving it marked sent with no email queued.
Where the cron stops
The cron's only output is a cart.abandoned domain event.
What happens after — the follow-up touch and the coupon touch, hours later — is the recovery-drip workflow using step.sleep, which is a durable wait.
The two mechanisms are stacked on purpose: poll to detect, durable sleep to follow up.
Guest carts are out of reach. The query requires customer: { email: { not: null } }, and the source notes there is no cart-level email column — recovering a guest cart would need a Cart.email migration.
What the weekly cleanup deletes
dataCleanup is the largest job in functions/, and every retention window is a named constant at the top of the file.
Its ten step.run sweeps:
cleanup-activity-logs: Activity rows pastACTIVITY_CLEANUP.retentionDays, batched, excluding anything matchingDURABLE_ACTIVITY_TYPE_PREFIXES— money ledgers, price changes, settings, campaign budgets and refund decisions are never purged.purge-order-intents: abandoned quotes a day past expiry, consumed quotes, andawaiting_settlementquotes still unsettled after 7 days (a lostasync_payment_failed).purge-webhook-delivery-logs: deliveries older than 30 days, 1,000 at a time; a still-retrying delivery is kept regardless of age.sweep-stalled-import-runs: anImportRunstuck atrunningwith no checkpoint write for an hour is marked failed, so failure detection can see it.purge-old-runs: settledImportRunandAgentRunrows past 180 days —errorReportembeds raw source rows, so it is a PII store.purge-expired-auth-artifacts: expired sessions and verification tokens a week past expiry, invitations a month past, so the admin list can still show "expired — re-invite".purge-email-logs:EmailLogrows older than 180 days, batched.EmailLogis a debugging trail, not the consent ledger — that is Activity.purge-idle-carts: unconverted carts untouched for 90 days, cascading toCartItem. Carts with anorderIdare kept forever; they carry the order's UTM and referral attribution.purge-orphan-review-uploads: S3 objects underreview-media/older than a week that no review'sexternalImagesreferences.purge-orphan-library-uploads: S3 objects undermedia/older than a week that noMedia.storageKeyreferences.
message-media/ is deliberately not swept. Those attachments are not Media rows, so their references cannot be verified.
Adding a cron
- Create
functions/cron.<name>.tsand export the function. - Add the export to the
functionsarray inapp/api/inngest/route.ts.
Step 2 is not paperwork. A cron that is defined but never added to that array does not exist as far as Inngest is concerned.
Five of the current jobs shipped in exactly that state before being registered. The route still carries the comment // Previously-defined-but-unregistered jobs (now live): above these five:
dailyWorkflowCheckshourlyQuickCheckswebhookRetriesshipmentWatchcurrencyRatesRefresh
What a new cron gets, and what it does not
Two things come for free:
- Exhausted retries land in
functions/platform.failures.tsas aplatform_job_failedActivity row - The Prisma middleware in
services/inngest.tssuppliesdbon the handler arguments
What it does not get is idempotency.
A cron run can overlap the previous one and can be retried, so if the work is a counter, a send or a claim, the safety has to be in the query itself:
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.
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.