Skip to Content
DevelopersCore conceptsRequest flow

4 min read

Request flow

A walkthrough of what actually happens, in code, when a shopper loads a page, a merchant clicks a button, and an order gets paid. Every name below is a real function or table; the Architecture page has the big picture and the Data model the tables.

Store isolation in one picture

Every tenant query runs inside withStore(db, storeId, fn) (packages/db/src/with-store.ts): a transaction that first runs select set_config('app.store_id', storeId, true). The store_isolation RLS policy on every tenant table compares store_id to that setting, and the runtime role app is NOBYPASSRLS. A query that forgets where store_id = … still can’t see another store’s rows. The only way across stores is a SECURITY DEFINER function that returns ids.

1. A shopper loads a page

apps/storefront/src/proxy.ts (Next.js 16’s renamed middleware) runs first:

  • createStoreResolver caches hostnames (unknown ones too) for ttlMs: 60_000.
  • routeForStore (apps/storefront/src/lib/routing.ts) refuses paths that already start with /-s or /-p, passes /_next/ and /api/ through, and rewrites to /-p/<storeId> while the storefront password is on.
  • Server code in the storefront wraps withStore as inStore (apps/storefront/src/lib/store-context.ts).

2. A merchant clicks a button

The admin talks to one oRPC handler, apps/admin/src/app/api/rpc/[[...rest]]/route.ts (RPCHandler(appRouter), prefix /api/rpc). Routers are mounted in packages/api/src/routers/_app.ts, so “Mark as paid” is POST /api/rpc/orders/setStatus.

Failure at each step has its own error: no active organisation → PRECONDITION_FAILED, not a member → FORBIDDEN “Not a member of this store”, no store → NOT_FOUND, missing permission → FORBIDDEN “Your role does not allow …”. Procedures are built with storeProcedure (no permission) or can(permission) (packages/api/src/procedures/store.ts).

The first three are “no store to work in” errors: they carry data.reason: 'no_store' and isNoStoreError(error) (exported from @commerce/api) is true for them only. Admin server pages catch with .catch(orNoStore(fallback)) (apps/admin/src/lib/store-guard.ts) and send only those to /select-store; a missing permission or any other failure is rethrown to the error page and error tracking. Never .catch(() => undefined) then redirect to the picker: the picker sends the member straight back, so any other error becomes an endless loop with nothing logged. The admin home guards with staff.me, which every role passes (store.current needs settings.read, which the Staff role does not have).

3. A shopper checks out and pays

Things worth knowing:

  • The order commits before the gateway is called. A gateway error marks the attempt failed and the order stays placed; the shopper can retry (a new attempt row, <orderId>:<attempt> idempotency key).
  • reserveStock routes into parcels. It is routeAndReserve (packages/core/src/order/routing-service.ts): it locks the variants’ stock rows, splits the cart over the locations that sell online (routing.ts), reserves per location and creates the order’s fulfillment_order rows. placeOrder then stores each parcel’s share of the money and its GST (taxParcels), and the order’s tax is their sum. See Architecture.
  • Short links skip the page route. GET /l/<slug> (app/-s/[storeId]/l/[slug]/route.ts) counts the click and redirects with the link’s UTM tags, setting wc_lk so the page beacon credits the link.
  • lockCartOrReplay takes SELECT … FOR UPDATE on the cart; a second submit of a converted cart returns the existing order (replayed: true) instead of a duplicate.
  • Every capture goes through applyPaymentStatus (packages/core/src/payments/confirm.ts): webhook, client confirm, “Mark as paid”, expiry reconciliation. Attempt statuses only move forward (nextPaymentStatus: created → pending → authorized → captured → refunded).
  • Order statuses are derived, never set. recomputeOrderStatuses (packages/core/src/order/recompute.ts) is the only writer of payment_status and fulfillment_status, and moves status between open and completed.
  • Side effects go through the outbox. outbox_event rows are written in the same transaction as the state change; the relay maps them to notification templates (order.paid → payment_received, order.fulfilled → order_shipped, …) and app hooks. Return events (return.requested, return.approved, return.rejected, return.received, return.refunded) carry a returnId and are repeatable per order, so each return gets its own return_* email; order.rto has no shopper message (the merchant sees the RTO task).

4. The worker’s clock

apps/worker/src/index.ts registers these repeatable jobs (upsertJobScheduler). Each one finds work across stores through a SECURITY DEFINER id list, then opens its own withStore per store.

QueueEveryWhat it does
{outbox}-relay5 s (plus nudges)relayStoreOutbox: outbox rows → notifications, app events, restock alerts, domain routes
{payments}-unpaid-expiry60 sexpireUnpaidOrders (asks the gateway first) and manual-transfer follow-ups
{payments}-preorder-balance60 srunBalanceSchedule: request, remind, expire pre-order balances
{marketing}-tick30 sStarts due campaigns, enrols and advances automations
{shipping}-track30 minPolls courier tracking for open shipments (stores_with_open_shipments())
{domains}-verify2 minVerifies pending custom domains and asks for their HTTPS route (Custom domains)
{system}-heartbeat60 sLogs a heartbeat

On-demand queues: {notifications}-send, {apps}-events, {apps}-jobs, {marketing}-send, {back-in-stock}-restock, {domains}-route (adds and deletes custom domains’ Dokploy routes). The worker’s HTTP side (Hono) serves POST /webhooks/payments/:provider/:storeId, POST /webhooks/shipments/:provider/:storeId (both rate limited per IP, 120 per minute) and GET /health.

Common pitfalls

PitfallWhat happensDo this instead
Querying outside withStoreRLS returns zero rows (no app.store_id), which looks like missing data.Always resolve storeId first, then withStore.
Deriving storeId from a row fetched without oneBreaks sharding rule 3 and can leak across stores.Get it from the host, the session’s organisation, or the job payload.
Enqueueing a job after commit“Committed but never enqueued” if the process dies.Write an outbox_event in the same transaction.
Setting payment_status directlyThe next recompute overwrites it.Change the underlying payment, refund or shipment row, then recomputeOrderStatuses.
Was this helpful?