Appearance
Commerce data & resolver internals
Chapter 16. The previous chapter handled shopper actions; this one handles the read side — how declared data becomes products, and how that drives cache purging.
How a page's declared data sources become products/collections/menus at render, and how that produces the surrogate cache tags the write side purges against. Code: packages/builder-core/src/commerce/{resolve,resolve-shopkit,account-data,search-data}.ts and page-builder/doc.ts.
The seam
A page declares its data once; a BindingResolver fetches it at render and returns { value, tags }. One interface, two implementations: StubResolver (deterministic samples, offline) and ShopkitResolver (real, over the external @shopkit/data-layer). storefrontResolver(env) picks one at boot — and in production it throws rather than boot on sample data (the "Sample product N went live" guard); local/CI fall back to the stub.
ts
interface BindingResolver {
fetch(source: DataSource, ctx: ResolveContext): Promise<ResolvedSource>;
}
interface ResolvedSource {
value: Record<string, unknown>;
tags: string[];
} // tags: col:<h>, prod:<id>, menu:<h>
interface ResolveContext {
tenantId: string;
routeParams?: Record<string, string>;
commerce?: { merchantId; storeId? } | null;
}interpolateParams deep-replaces route params (a PDP source's { handle: "{{params.handle}}" } becomes { handle: "blue-shirt" }) and is shared by both render paths.
The dispatch — ShopkitResolver
Each DataSource.type maps to one CommerceClient method; MENU resolves before the client null-check (a store can have nav even where product fetches are a no-op), and an unconnected tenant (null client) degrades to empty, never throws. Products pass through unmodified — prices stay in paise; display shaping happens at render.
DataSource.type | client method | value | tags |
|---|---|---|---|
COLLECTION | getCollection | { products } | col:<h> + prod:<id>… |
COLLECTION_BY_HANDLES | getCollectionsByHandles | { products } | col:<h>… + prod:<id>… |
PRODUCTS | getProducts | { products } | prod:<id>… |
PRODUCTS_BY_HANDLES | getProductsByHandles | { products } | prod:<id>… |
COLLECTIONS | getCollections | { collections } | col:* + col:<h>… |
PRODUCT | getProduct | flat product | prod:<id ?? handle> |
MENU | nav service (/pi/pc) | { menu } | menu:<handle> |
DATA_SOURCE_TYPES also declares COLLECTION_FILTERS, FETCH_REQUEST, GRAPHQL_REQUEST — these pass validation but both resolvers hit the default branch ({ value:{}, tags:[] }, a silent empty).
Tags → surrogate keys → purge
The read side stamps and the write side purges by the same string keys — the x-surrogate-keys header is the contract, no separate registry. Two structural tags join the data tags: tenantTag(tenantId) (t.<seg>, a publish purges the whole store) and pageTag(tenantId, path) (p.<seg>.<hash24>, one URL). seg() passes safe short segments through and sha256-hashes anything longer — an over-128-char surrogate tag silently fails to purge, leaving a page stale forever under the long shell TTL. The storefront emits:
x-surrogate-keys: <tenantTag> <pageTag> <dataTags…> <chromeTags…>
cache-control: public, s-maxage=300, stale-while-revalidate=86400chromeTags are the header/footer menu:<handle> tags — a menu change purges every page showing that chrome. COLLECTIONS also emits col:*, so any collection change must purge every page listing collections.
What actually purges today
This tag design is the intended model, but cache-tag purge is a Cloudflare Enterprise feature we don't have yet, and there's no tag→URL index. So today the platform purges by URL on publish, and the commerce webhook's tag purge is a no-op in prod (invalidated: []). Don't assume a backend change tag-purges the right pages live. See Control plane · webhook.
The @shopkit/data-layer boundary
The external npm package owns the canonical contract: createCommerceClient(config), ICommerceClient (every method returns IResponse<T> = { success, data, meta? }), and ICustomConfig (the custom-adapter shape buildCustomClient fills). In-repo code is only the thin wrappers (ShopkitResolver, the env factories). Two fetchers live outside the client: account-data.ts (orders go through the client; profile/addresses are plain /cs REST) and search-data.ts (the anonymous search gateway). Both are per-shopper, carry their own 4s AbortController, and emit no surrogate tags (no-store).
Two render paths — flat merge vs namespacing
Both fetch the same sources through the same resolver; they differ in how the value reaches a section:
theme path (renderThemePage) | page-builder path (resolvePage) | |
|---|---|---|
| fetch fan-out | Promise.all, no cap | mapLimit, cap 6 (Workers subrequest window) |
| shaping | flat spread into section data | routed into named binding namespaces |
| precedence | authored inst.data wins over bound | resolved data merges on top of authored |
| binding aware | no (keys land flat: { products }) | yes (reads the registry's binding names) |
| output | HTML + tags | a mutated PageDoc + tags |
So a theme section reads a top-level products; a page-builder section reads a namespaced product.* / price.*. The theme path's own comment flags Shopify-style namespacing as "a later slice; this is the safe interim."
Gotchas
- Prices are paise end-to-end, untouched — the
paiseAmounthelpers round but never ×100 (named that way so nobody feeds them rupees); themoneyfilter divides by 100. A stray conversion renders 100× off. - Production throws on the stub — a silent misconfig would serve "Sample product N" on a live store.
- The double product SEO fetch — a PDP resolves the product twice (the section +
buildSeoHead); a known follow-up. - The namespacing gap — the two render paths hand a section different context shapes and opposite precedence for the same source.
- Concurrency asymmetry — only
resolvePagecaps fan-out at 6; the theme path's unboundedPromise.allcan exceed the Workers subrequest window. col:*must purge every collection-listing page — easy to miss in blast-radius reasoning.- Unimplemented declared types (
COLLECTION_FILTERS,FETCH_REQUEST,GRAPHQL_REQUEST) silently resolve empty. - Account/search bypass the tag system — per-shopper, no-store, no surrogate keys.