Skip to content

Data, DataSources, context, lazy graph

How the renderer gets data without a database, the StorefrontContext, and lazy metafields.

Updated View as Markdown

The data/ module is the renderer’s data boundary. Every struct comes in two layers:

  1. Raw data (ProductData, MetafieldData, …), plain Clone + Debug structs returned by the DataSources trait.
  2. Object wrappers (ProductObject, MetafieldMap, …), implement MiniJinja’s Object trait so templates traverse them with dot notation (product.metafields.custom.rating). Some wrappers load lazily: the first property access fires a query through DataSources, cached per-request via OnceLock.

The DataSources trait

The single abstraction over all data access, the crate has no database dependency:

#[async_trait]
pub trait DataSources: Send + Sync + 'static {
    async fn load_product(&self, id: Uuid) -> Result<ProductData>;
    async fn load_product_by_slug(&self, slug: &str) -> Result<ProductData>;
    async fn load_product_metafields(&self, product_id: Uuid) -> Result<Vec<MetafieldData>>;
    async fn load_product_variants(&self, product_id: Uuid) -> Result<Vec<VariantData>>;
    async fn load_variant_metafields(&self, variant_id: Uuid) -> Result<Vec<MetafieldData>>;
    async fn load_metaobject(&self, id: Uuid) -> Result<MetaobjectData>;
    async fn load_collection(&self, handle: &str) -> Result<CollectionData>;
    async fn load_collection_metafields(&self, collection_id: Uuid) -> Result<Vec<MetafieldData>>;
    async fn load_metafields(&self, owner_id: Uuid, owner_type: &str, store_id: Uuid) -> Result<Vec<MetafieldData>>;
    async fn load_shop(&self, store_id: Uuid) -> Result<ShopData>;
    async fn load_cart(&self, cart_token: Option<&str>) -> Result<CartData>;  // empty cart for None
    async fn load_nav_collections(&self, store_id: Uuid) -> Result<Vec<CollectionSummary>>;
    async fn load_menu(&self, handle: &str) -> Result<MenuData>;
    async fn load_page(&self, handle: &str) -> Result<PageData>;
    async fn load_blog(&self, handle: &str) -> Result<BlogData>;
    async fn load_article(&self, blog_handle: &str, article_handle: &str) -> Result<ArticleData>;
}

Naming: load_* fetches secondary data by owner ID (load_product_metafields); load_<resource> / load_<resource>_by_<key> look up primary resources. Implementations must be Send + Sync + 'static so the trait object can live in an Arc inside spawn_blocking closures.

Two implementations exist:

  • crates/ringroad (the API binary), SeaORM against PostgreSQL.
  • test_utils::StubDataSources (feature test-utils) and tests/common/stub_ds.rs, fixed fixtures. Known gotcha: the test-utils copy of load_metafields takes 2 args while the trait (and the CLI stubs) take 3, it only compiles because the feature is off by default.

StorefrontContext, the template root

Built once per request, cloned into every node render (cheap: object wrappers share Arcs). Fields and template keys:

Template key Rust field Notes
store store.slug A plain string, not an object
shop ShopObject name, currency_code, currency_symbol, locale, domain, description, is_publicly_visible, logo_url
settings ThemeSettingsObject raw settings map; any key resolves
product Option<ProductObject> None off product pages
collection Option<CollectionObject> None off collection pages
collections CollectionsListObject handle-keyed nav collections
cart Option<CartObject> None unless a cart token is present
page PageObject handle, title, content_html
search SearchObject query, results_count
blog / article BlogObject / Option<ArticleObject> article published_at is RFC 3339
linklists LinklistsObject handle-keyed menus
request RequestObject path, page_type
locale LocaleObject code (stub)
extra HashMap<String, Value> route-handler data bag

UUIDs are stringified for templates everywhere. Optional string fields (description, featured_image, logo_url, image_url, compare_at_price) render empty under Lenient when None. The full field-level reference is in Globals.

Product and variants

ProductObject has two construction paths:

  • from_preloaded(row, metafields, variants, ds), the async route handler pre-fetches product, metafields, and variants before rendering; template access is synchronous.
  • lazy_ref(id, ds), a hollow shell (only id set) created inside spawn_blocking when a metafield of type product_reference resolves. The metafield chain works because MetafieldMap keeps the owner ID; the shell’s core fields (name, price, …) are empty until fleshed out, a documented ponytail: (a full load_product round-trip inside the sync section is possible but not yet needed).

VariantObject exposes id, title, price (minor units), available, and a lazy metafields map.

The lazy metafield map

MetafieldMap is the heart of the lazy data graph. Pre-loaded (from_rows) it’s a plain map; lazy (new) it holds owner identity + Arc<dyn DataSources>, and the first property access calls load_metafields via tokio::runtime::Handle::block_on, safe only inside tokio::task::spawn_blocking, caching in OnceLock for the rest of the request.

Template access patterns:

{{ product.metafields.custom.rating }}        {# dotted: namespace.key #}
{{ product.metafields.custom }}               {# namespace sub-object #}
{{ product.metafields.custom.artist }}        {# metaobject_reference → lazy object #}

entry_to_value converts by metatype: text/url/color → string, number_integer → i64, number_decimal → f64, boolean → bool, date/date_time → string, product_reference → lazy ProductObject (from GID gid://shopify/Product/{uuid}), metaobject_reference → lazy Metaobject, list.product_reference → array of lazy products, anything else → JSON string.

Known gaps (ponytails)

  • Namespace sub-object references, {% assign ns = product.metafields.custom %}{{ ns.featured_product.name }} returns the raw GID string, not a product: the namespace path skips entry_to_value. The dotted path works; no existing template uses the assign pattern.
  • Metaobject references, metafield_value_to_minijinja handles scalars only; reference-typed metaobject fields are not resolved.
  • ThemeSettingsObject, a Phase 1 stub carrying a raw map; the full spec wants resolved color_schemes with to_css_variables(). CSS variable generation currently lives in engine/page.rs + asset/mod.rs from the manifest configs instead.
  • LocaleObject, stub; the full locale/translation system is a later phase.

Why spawn_blocking matters

Lazy loading only works inside spawn_blocking because Handle::block_on must not run on an async worker. The pre-loaded path is the route handler alternative: fetch eagerly in async land, render synchronously. Theme authors can’t trigger lazy loads from template logic, they consume whatever the route handler pre-loaded plus whatever extra.* carries.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close