Skip to content

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.typeclient methodvaluetags
COLLECTIONgetCollection{ products }col:<h> + prod:<id>…
COLLECTION_BY_HANDLESgetCollectionsByHandles{ products }col:<h>… + prod:<id>…
PRODUCTSgetProducts{ products }prod:<id>…
PRODUCTS_BY_HANDLESgetProductsByHandles{ products }prod:<id>…
COLLECTIONSgetCollections{ collections }col:* + col:<h>…
PRODUCTgetProductflat productprod:<id ?? handle>
MENUnav 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=86400

chromeTags 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-outPromise.all, no capmapLimit, cap 6 (Workers subrequest window)
shapingflat spread into section datarouted into named binding namespaces
precedenceauthored inst.data wins over boundresolved data merges on top of authored
binding awareno (keys land flat: { products })yes (reads the registry's binding names)
outputHTML + tagsa 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

  1. Prices are paise end-to-end, untouched — the paiseAmount helpers round but never ×100 (named that way so nobody feeds them rupees); the money filter divides by 100. A stray conversion renders 100× off.
  2. Production throws on the stub — a silent misconfig would serve "Sample product N" on a live store.
  3. The double product SEO fetch — a PDP resolves the product twice (the section + buildSeoHead); a known follow-up.
  4. The namespacing gap — the two render paths hand a section different context shapes and opposite precedence for the same source.
  5. Concurrency asymmetry — only resolvePage caps fan-out at 6; the theme path's unbounded Promise.all can exceed the Workers subrequest window.
  6. col:* must purge every collection-listing page — easy to miss in blast-radius reasoning.
  7. Unimplemented declared types (COLLECTION_FILTERS, FETCH_REQUEST, GRAPHQL_REQUEST) silently resolve empty.
  8. Account/search bypass the tag system — per-shopper, no-store, no surrogate keys.