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:
createStoreResolvercaches hostnames (unknown ones too) forttlMs: 60_000.routeForStore(apps/storefront/src/lib/routing.ts) refuses paths that already start with/-sor/-p, passes/_next/and/api/through, and rewrites to/-p/<storeId>while the storefront password is on.- Server code in the storefront wraps
withStoreasinStore(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
failedand the order stays placed; the shopper can retry (a new attempt row,<orderId>:<attempt>idempotency key). reserveStockroutes into parcels. It isrouteAndReserve(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’sfulfillment_orderrows.placeOrderthen 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, settingwc_lkso the page beacon credits the link. lockCartOrReplaytakesSELECT … FOR UPDATEon 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 ofpayment_statusandfulfillment_status, and movesstatusbetweenopenandcompleted. - Side effects go through the outbox.
outbox_eventrows 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 areturnIdand are repeatable per order, so each return gets its ownreturn_*email;order.rtohas 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.
| Queue | Every | What it does |
|---|---|---|
{outbox}-relay | 5 s (plus nudges) | relayStoreOutbox: outbox rows → notifications, app events, restock alerts, domain routes |
{payments}-unpaid-expiry | 60 s | expireUnpaidOrders (asks the gateway first) and manual-transfer follow-ups |
{payments}-preorder-balance | 60 s | runBalanceSchedule: request, remind, expire pre-order balances |
{marketing}-tick | 30 s | Starts due campaigns, enrols and advances automations |
{shipping}-track | 30 min | Polls courier tracking for open shipments (stores_with_open_shipments()) |
{domains}-verify | 2 min | Verifies pending custom domains and asks for their HTTPS route (Custom domains) |
{system}-heartbeat | 60 s | Logs 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
| Pitfall | What happens | Do this instead |
|---|---|---|
Querying outside withStore | RLS 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 one | Breaks 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 directly | The next recompute overwrites it. | Change the underlying payment, refund or shipment row, then recomputeOrderStatuses. |