The data/ module is the renderer’s data boundary. Every struct comes in
two layers:
- Raw data (
ProductData,MetafieldData, …), plainClone + Debugstructs returned by theDataSourcestrait. - Object wrappers (
ProductObject,MetafieldMap, …), implement MiniJinja’sObjecttrait so templates traverse them with dot notation (product.metafields.custom.rating). Some wrappers load lazily: the first property access fires a query throughDataSources, cached per-request viaOnceLock.
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(featuretest-utils) andtests/common/stub_ds.rs, fixed fixtures. Known gotcha: thetest-utilscopy ofload_metafieldstakes 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 (onlyidset) created insidespawn_blockingwhen a metafield of typeproduct_referenceresolves. The metafield chain works becauseMetafieldMapkeeps the owner ID; the shell’s core fields (name, price, …) are empty until fleshed out, a documentedponytail:(a fullload_productround-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 skipsentry_to_value. The dotted path works; no existing template uses the assign pattern. - Metaobject references,
metafield_value_to_minijinjahandles scalars only; reference-typed metaobject fields are not resolved. ThemeSettingsObject, a Phase 1 stub carrying a raw map; the full spec wants resolvedcolor_schemeswithto_css_variables(). CSS variable generation currently lives inengine/page.rs+asset/mod.rsfrom 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.