Appearance
Request & data flow
Chapter 4. You know the pieces and how stores stay apart. Now follow a single request through them — a shopper loading a page, and a merchant publishing a change.
Two journeys explain how the system behaves: a shopper loading a page, and a merchant publishing a change.
Shopper → HTML (the read path)
Two things the diagram is careful about:
The KV lookup has three outcomes, and "not found" is really two different things:
KV outcome Stored under host:{host}What happens DB hit? Found { "t": "tenant_123" }(1h)use the tenantId, carry on No Known-bad { "t": null }(60s)branded 404 straight away No Not in KV key absent ask the DB once, then cache back Yes (once) The known-bad cache is what stops a flood of bad-host requests (typos, scanners) from hammering Postgres; real stores are also pre-warmed into KV on domain verify, so a genuine first request is normally a hit.
"Cache miss" means no fresh copy — not an empty cache. An expired copy may still be present, kept on purpose so there's something to serve if the origin later dies. Order of preference: fresh in cache → fresh from origin → stale from cache → branded 503.
At the edge — apps/edge/src/worker.ts (logic in packages/edge-core)
- Resolve tenant —
host → tenantIdfrom Workers KV (TENANTS), with a DB fallback that only matches a verified domain (domains WHERE host=… AND verified=true). No tenant → 404 "Store not found". - Guard — block internal
/__*paths; requireORIGIN_URL(else a branded 503 — the edge never renders). - Serve or proxy — serve from
caches.defaulton a hit; on a miss, proxy to the origin with a signedx-edge-auth+x-ratio-tenant, behind a per-isolate circuit breaker, and cache the origin response. - Sanitize — strip internal
x-*headers before the client; emit an access log + a metrics point.
At the origin — apps/origin/src/index.ts (one big app.all('*'))
The pipeline, in order (order matters — auth and reserved-path gates come before rendering):
- correlation id + tracing + access log
/health,/ready(unauthenticated probes)edgeAuthOk(...)— the origin is private; fail closed 403 without the edge secret/assets/*,/__stats, well-known / PWA manifest- reserved-path handling:
/accountlogin gate, swallow/checkout·/api/*, with carve-outs forTenant(tenantId).getTenant()→ 404 if missing/suspended/robots.txt,/sitemap.xml(via@ratio/seo)/api/island/*— the only per-user path (an island: a cached shell carries an inert placeholder that's hydrated client-side; defined in Rendering & themes), alwaysno-store- interactive commerce:
/cart*,/order-confirmation,/account*,/search— allno-store POST /checkout— the GoKwik handshake- fall-through →
renderStorefront(...)— the cacheable page
The page render — apps/origin/src/handlers/storefront.ts
The bundle-theme path (primary, when the tenant has a live bundle theme):
matchRoute(path)→ pick the template (home / collection / product / page / …).themeStore.loadLiveCompiled(tenantId)— load the compiled bundle from S3 (in-memory LRU). A structural miss or a broken layout fails loud (500) so the edge serves stale — never a silent cached 404.- Render, in parallel: the page sections (
renderThemePage, each theme section via the untrusted isolate; each first-party section via the registry), the chrome (header/footer groups), and the SEO head. - Resolve integrations (
composeStorefront→ cart / checkout / search head + body + CSP). - Wrap it all in the theme's own
layout/theme.liquid(renderThemeLayout) — the theme owns the whole document. - Emit surrogate cache tags (
x-surrogate-keys= tenant + page + data + menu tags) andcache-control: public, s-maxage=300, swr=86400+ a strict CSP.
If there's no live bundle theme, it degrades to the legacy page-builder path (pageStore.getLive → composePage) with the same tags / CSP model.
How data reaches a section — the resolver seam
A page template declares dataSources (e.g. COLLECTION_BY_HANDLES { handles: ['{{params.handle}}'] }). At render:
resolvePage(doc, registry, resolver, ctx)interpolates route params, fan-out fetches (concurrency 6), and injects each result into its section's binding namespace.- Each fetch returns cache tags (
col:*,prod:*,menu:<handle>) that become the page's surrogate keys — so when that product/collection changes, exactly the right pages purge. - The resolver is injected: real
ShopkitResolver(over the GoKwik backend) when configured, elseStubResolver(sample data for local dev; production throws rather than serve samples). See Commerce.
When things fail — the resilience path
Most page-views never reach the origin (a cache hit). But when the origin or the DB is slow or down, the edge must still serve something. It degrades in tiers instead of erroring:
- T1 — origin down/slow: a per-isolate circuit breaker trips; the edge serves the last-good cached page (marked
x-ratio-stale) instead of failing. This is why a broken render must fail loud (500) at the origin — so the edge serves stale rather than caching a bad page. - T2 — Postgres down: tenant routing still works because the edge reads
host → tenantIdfrom KV, which survives a DB outage (the DB is only hit on a KV miss). - Last resort: no cached copy to fall back on → a branded 503, never a raw error.
- Hard limit: writes (
/cart,/checkout) can't be faked from cache — they need the origin + Postgres up.
Merchant → live (the write path)
Two surfaces edit the same draft; an owner then publishes it live.
The edit loop — authoring a draft
A merchant edits in the code editor (a Monaco file tree) or the visual customizer (a live-preview iframe with in-canvas selection). Both write the same draft bundle. The customizer loop:
- The
data-rt-*selection markers are injected only in editor mode — they never reach a live storefront. - The code editor path is simpler: open a file → edit →
PUT draft(the samesaveDraft+ CAS), with live preview.
Publish — draft to live
Publish is owner-only and atomic — nothing changes unless validation passes:
- Edit — the code editor or the visual customizer edits the theme's files (both write the same draft bundle via the admin API).
- Save —
PUT .../draft→ThemeStore.saveDraftwith optimistic concurrency (a409if another editor saved first). Draft = a mutable source bundle in S3. - Preview —
POST .../previewrenders the in-flight buffer through the same render path (the customizer sets aneditorflag that wraps sections with selection markers — editor-only, never on the live storefront). - Publish (owner-only) — compile + minify, validate (full-document layout, required templates, section references), freeze immutable
published/{source,compiled}bundles, and flip the live pointer (tenants.live_theme_id/version) in Postgres. - Invalidate — purge the tenant's edge cache and warm the new version.
Base propagation (improve the shared base, roll it into stores) rides the same primitives: republish-base cuts a new base version from code → rebase-to-latest-base moves each store onto it, preserving its overrides (base ⊕ overrides is a file-level merge). See base propagation runbook.
Interactive shopper flows
The read path is cacheable HTML. Everything a shopper does — cart, login, checkout, account, search — is per-user and no-store, handled by the origin's commerce handlers (apps/origin/src/handlers/). See Commerce for the seams behind these.
Add to cart
The drawer is the cart (there's no cart page). Three cookies, easy to conflate — an httpOnly server cart, a JS-readable X-Cart-Token mirror for the widget, and an rt_cart_present marker.
Login (KwikPass) & account
Login (KwikPass) and checkout are independent toggles — a store isn't switched to phone login just because it can sell.
Checkout
Search
GET /search?q=… (and a header focus-popup) hits search.ts behind the activeSearch provider seam — native product search, no-store, no JavaScript required.
Platform flows
Cache invalidation — how a change reaches shoppers
Every cached page carries surrogate tags (tenant · page · prod:* · col:* · menu:*). A change purges exactly the tags it touches, so only the affected pages refresh — not the whole store:
The durable outbox (page_purge_outbox) means a purge is never lost if the purge call fails — it's retried.
What actually purges today
Cloudflare cache-tag purge (what the diagram shows) is an Enterprise feature and needs a tag→URL index we don't have yet. So today the platform purges by URL on publish, and the commerce webhook's tag purge is a no-op in prod (it returns the tags with invalidated: []). The tag design above is the intended model; per-URL purge is what runs now. (See Control plane · webhook and PWA · purge.)
Onboarding — a new store
Seconds, no repo/build/deploy — a store is rows + a seeded bundle. The store is live on the default theme from creation — status is active from the start (pending / suspended exist in the column but no code writes them; the wizard's later steps adopt a chosen base and republish). See Admin web · onboarding.
Every flow at a glance
| Flow | Who | Detail |
|---|---|---|
| Page load (read path) | shopper | this page ↑ |
| Add to cart | shopper | this page ↑ · Commerce |
| Login (KwikPass) & account | shopper | this page ↑ · Commerce |
| Checkout | shopper | this page ↑ · Commerce |
| Search | shopper | this page ↑ · Commerce |
| Island hydration | shopper | Rendering & themes |
| Edit loop (customizer) | merchant | this page ↑ |
| Publish | merchant/owner | this page ↑ |
| Onboarding (new store) | merchant | this page ↑ |
| Commerce connect | merchant | Commerce |
| Custom domain connect | merchant | Infrastructure |
| AI assistant | merchant/agent | Control plane & AI |
| Cache invalidation | platform | this page ↑ |
| KV sync (host→tenant) | platform | Infrastructure |
| Base propagation | platform | Runbooks |
| Deploy (canary) | platform | Runbooks |