Litestore · Developer guide

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 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 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
createdAt

It 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:

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, 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 past ACTIVITY_CLEANUP.retentionDays, batched, excluding anything matching DURABLE_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, and awaiting_settlement quotes still unsettled after 7 days (a lost async_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: an ImportRun stuck at running with no checkpoint write for an hour is marked failed, so failure detection can see it.
  • purge-old-runs: settled ImportRun and AgentRun rows past 180 days — errorReport embeds 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: EmailLog rows older than 180 days, batched. EmailLog is a debugging trail, not the consent ledger — that is Activity.
  • purge-idle-carts: unconverted carts untouched for 90 days, cascading to CartItem. Carts with an orderId are kept forever; they carry the order's UTM and referral attribution.
  • purge-orphan-review-uploads: S3 objects under review-media/ older than a week that no review's externalImages references.
  • purge-orphan-library-uploads: S3 objects under media/ older than a week that no Media.storageKey references.

message-media/ is deliberately not swept. Those attachments are not Media rows, so their references cannot be verified.

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.

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:

  • dailyWorkflowChecks
  • hourlyQuickChecks
  • webhookRetries
  • shipmentWatch
  • currencyRatesRefresh

What a new cron gets, and what it does not

Two things come for free:

  • Exhausted retries land in functions/platform.failures.ts as a platform_job_failed Activity row
  • The Prisma middleware in services/inngest.ts supplies db on 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.

On this page