Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

340 Commits

Folders and files

Repository files navigation

GraphCommerce Catalog Storefront

Magento keeps the write side: the Admin, the catalog tables and the indexers stay as they are. The catalog reads come from one document per product and store view in OpenSearch: the catalog GraphQL queries, and, per store view, the rendered category, search and product pages of a Luma or Hyvä theme. A field the documents do not carry is answered by the core resolver of that field, so the storefront code stays the same. The GraphQL schema gets only the fields of the required GraphQL Catalog package (Fields the package adds), which both paths answer.

magento/commerce-data-export is Adobe's exporter: it turns the catalog into feed rows for Adobe's SaaS services, as ordinary Magento indexers. This package implements the exporter's delivery seam and writes those rows into the documents (docs/feed-writer.md).

Install and enable

The shop runs Mage-OS or Magento Open Source 2.4.7 or later on PHP 8.1 to 8.4, with OpenSearch as the search engine of Stores > Configuration > Catalog > Catalog Search. The documents use that same connection through core's OpenSearch client. magento/commerce-data-export 103.4 or later comes from repo.magento.com, or from the Mage-OS fork mage-os/mageos-commerce-data-export, which an install without Adobe keys adds as composer path repositories, as .github/workflows/ci.yml does. The package requires graphcommerce/magento-graphql-catalog; a project that installs from git adds that repository next to this one.

  1. Install the package and compile. Composer autoload registers every module, and setup:upgrade enables them.

    composer require graphcommerce/magento-catalog-storefront
    bin/magento setup:upgrade
    bin/magento setup:di:compile
  2. Reindex the catalog search. GraphCommerce_CatalogStorefrontSearch writes the product id into the fulltext document and sorts every listing's tie-break on it; the install invalidates the fulltext indexer for that field.

    bin/magento indexer:reindex catalogsearch_fulltext
  3. Build the documents. catalog-storefront:rebuild exports the two scopes feeds again first: every other writer reads the store views with their media base URL, the website ids and the customer groups from their documents, and refuses a row whose website has none. It then stages a fresh index per entity and store view, truncates the feed tables so the exporter sends every row, runs the feed indexers and promotes the indices when they are through.

    bin/magento catalog-storefront:rebuild
  4. Keep the documents up to date. The feed indexers follow the catalog through their mview changelogs, so cron exports each change.

    bin/magento indexer:set-mode schedule \
      catalog_data_exporter_products catalog_data_exporter_product_prices \
      catalog_data_exporter_product_variants catalog_data_exporter_categories \
      catalog_data_exporter_product_attributes inventory_data_exporter_stock_status \
      catalog_data_exporter_product_reviews catalog_data_exporter_rating_metadata \
      scopes_website_data_exporter scopes_customergroup_data_exporter
  5. Serve the catalog GraphQL of a store view from the documents.

    bin/magento config:set --scope=stores --scope-code=default catalog/storefront_documents/serve_graphql 1
    bin/magento cache:flush
  6. Generate the storefront key. It unlocks the path header and the fallback report for a request that sends it.

    bin/magento config:set catalog/storefront_documents/key ""
    bin/magento config:show catalog/storefront_documents/key
  7. Check the result. catalog-storefront:status prints the documents per store view against the products of the website, and per feed its rows, the rows a retry is pending for, the last export and the indexer state. It fails on waiting rows and on an invalid indexer, so a deployment check can call it.

    bin/magento catalog-storefront:status
  8. Read one request's own answer. The key plus X-Catalog-Storefront: documents picks the path for that request alone, and extensions.catalogStorefront carries the path and every document read failure with its reason.

    curl -s https://shop.example/graphql \
      -H 'Content-Type: application/json' \
      -H 'X-Catalog-Storefront-Key: <key>' \
      -H 'X-Catalog-Storefront: documents' \
      -d '{"query":"{products(search:\"bag\",pageSize:24){items{sku name price_range{minimum_price{final_price{value}}}}}}"}'
  9. Compare both paths over the whole query set. catalog-storefront:parity runs every query of dev/parity/queries on the core path and on the document path and diffs the responses.

    bin/magento catalog-storefront:parity https://shop.example/graphql

    The queries name their products and categories through placeholders, which Model/Parity/Picks fills from the catalog: {{sku:configurable}}, {{skus:any:6}}, {{url_key:configurable}}, {{category_id:simple:special-price}}, {{category_url_path:any:children}}, {{option_uid:configurable}}, {{search_term}}, {{currency}} and {{cart_id}}. {{currency}} selects an allowed alternate currency with an exchange rate. Product picks use the core stock visibility filter. A kind is a product type or any, with a trait: special-price, tier-price, reviewed, links, fixed or fpt. A query the catalog cannot fill is skipped with the reason, as is a query whose # @requires-field ProductInterface.activity the schema does not hold. Add your own queries to a directory and run them with --queries.

Why a read model

What core pays for, per request

Core builds a product list from the catalog tables at the moment the visitor asks for it. Each listed product costs a row in catalog_product_entity_<backend type> per attribute the page selects, a catalog_product_index_price row per customer group and website, a stock join, and, for a configurable, grouped or bundle product, the load of its children with their own attributes and prices. The cost follows the catalog shape: the large profile below holds 1 095 attributes per store view, 5 websites, 4 customer groups and 192 000 variants under 8 000 configurable products.

Measured with the GraphCommerce product list query (dev/parity/queries/13-graphcommerce-product-list.graphql), guest, prices excluding tax, median over the wire, no page cache, on the large profile of setup:performance:generate-fixtures (502 050 products in 5 websites, 3 000 categories, 20 catalog price rules):

Listing php-fpm, core php-fpm, documents worker, core worker, documents
24 items of a 12 463-product category 238 ms 93 ms 133 ms 41 ms
200 items of that category 1 433 ms 145 ms 624 ms 98 ms
200 items of a search over 308 000 visible products 1 549 ms 210 ms 519 ms 148 ms
200 items unfiltered 1 661 ms 194 ms 629 ms 130 ms

HTTP response caching

QueryUncacheable disables HTTP response caching for document-mode and keyed GraphQL requests. Their responses use Cache-Control: no-store, no-cache, must-revalidate, max-age=0. Public core responses retain Magento's cache policy. Clear stored HTTP responses on deployment:

bin/magento cache:clean full_page

Delivery purges product and category cache tags after each feed write.

What this package does

The cost is paid one time at the feed side, when the data changes. The exporter builds the row of every changed entity, hashes it and drops the row whose hash equals its last export; the rest reaches the writers (docs/feed-writer.md). On the large profile the document store takes 0.41 ms per products row, 0.21 ms per prices row, 0.75 ms per stock row and 0.86 ms per variants row: 15 minutes of the 64 minutes the 2.5 million products rows take, the rest being the exporter's own queries and hashing.

A request is then a search plus a document read. A listing page costs two OpenSearch round trips: core's product search, and one multi-search that takes the documents of the page with the price aggregations of their composites. The number of products in the catalog does not enter that request.

The arguments against it

"Varnish covers it." A miss pays the core path: 1 433 ms against 145 ms for a 200-item category listing on php-fpm. Every feed write empties the entries of the products it touched, which is what keeps the prices true.

"MySQL and indexer tuning is enough." The numbers above are measured with every core indexer complete: stock, price, category products, EAV and fulltext. A 24-item category listing still costs 238 ms on php-fpm, because the request itself joins the attribute and price rows of its 24 products. Tuning pays where the work is the search engine's: GraphCommerce_CatalogStorefrontSearch sorts the listing tie-break on a field instead of a painless script over every match (41 ms to 6 ms on an unfiltered listing over 308 000 visible products) and puts a result window over the catalog on the index (page 2000 of that listing: 2.4 seconds in 32 windows against 0.2 seconds in one query). That module needs no documents. The 238 ms of the 24-item listing stay on the request: they are PHP time and database time, in core's own resolvers.

"Our catalog is small." The cost per request follows the attributes, store views, customer groups and children of a product, not the number of products. On the demo catalog the same 200-item query takes 816 ms on the core path against 112 ms on the document path (php-fpm), and 425 ms against 78 ms on a FrankenPHP worker. Customer groups are the clearest example: with a catalog rule on all groups, 4 groups against 54 take the prices feed from 4 694 to 15 794 rows, a rule product's document from 6 KB to 23 KB, and the 200-item listing on the worker from 78 ms to 86 ms, because a product's rows of every group arrive in one batch and the document keeps one nested entry per group.

"A read model gets out of sync." Three mechanisms answer for it. The exporter compares the hash of every row with its last export, so a full reindex re-sends what changed and writes nothing for the rest. A document read failure stops the read. A request with the storefront key also reports its reason. Every fallback report fails the parity gate. catalog-storefront:parity diffs both paths query by query, and --soak repeats the accepted queries with the customer, the page size, the sku list and the path order varied per request: 3 000 requests, 0 differences, memory slope 250.7 KB per 100 requests against a 1 500 KB limit (docs/validation/2026-09-15-worker-soak-local.json). catalog-storefront:status fails while a feed row waits or a feed indexer is invalid.

What is served from documents

The path header and the setting decide per request. Every plugin below gates on Model/Mode first, so X-Catalog-Storefront: core runs core alone.

Read path Module Required document data
products search, filter and sort ...GraphQl every selected product
categories, categoryList, a product's categories ...GraphQl supported category filters and page bounds
aggregations, layered navigation labels, price step ...GraphQl, base store attributes and selected categories
custom_attributesV2, custom_attributeV2, attributesList, the attribute of a value or an aggregation ...GraphQl supported attribute filters, the attribute document of the code
swatch of an aggregation option, an attribute option and a selected option ...SwatchesGraphQl the attribute documents
media_gallery, url_rewrites ...GraphQl media URLs and URL rewrites
related_products, upsell_products, crosssell_products ...GraphQl linked products
price_range, price, price_tiers, tier_prices, fixed_product_taxes ...PriceGraphQl prices; composite fixed product taxes raise an error
stock_status, only_x_left_in_stock, quantity, min_sale_qty, max_sale_qty ...InventoryGraphQl product stock fields
configurable_options, variants, configurable_product_options_selection ...ConfigurableProductGraphQl configurable options and variants
items and price_details of a bundle ...BundleProductGraphQl bundle options and selections
items of a grouped product ...GroupedProductGraphQl grouped children
downloadable_product_links, downloadable_product_samples ...DownloadableGraphQl links and sample ids
rating_summary, review_count, reviews ...ReviewGraphQl reviews and page bounds
The products of cart items, wish list items and order items ...QuoteGraphQl, ...WishlistGraphQl, ...SalesGraphQl every requested item product
Rendered category and search listing pages, product detail pages ...ProductFrontend every selected product; unsupported detail option types raise an error
A configurable's children, super attributes and lowest price on a rendered page ...ConfigurableProductFrontend variant documents and configurable attributes
Bundle options, selections and price ranges on a rendered page ...BundleProductFrontend child documents, price rows and stock limits
Grouped children and quantity validation on a rendered page ...GroupedProductFrontend child documents and stock limits
Downloadable links and samples on a rendered page ...DownloadableFrontend link ids and sample ids

Fields the package adds

The package requires GraphCommerce GraphQL Catalog (graphcommerce/magento-graphql-catalog), which defines these fields and answers them from core. The read model serves them from documents; X-Catalog-Storefront: core gets core's answer, and the parity gate compares both paths (dev/parity/queries/34-* and 35-*).

Field Defined by Served from documents by
ProductInterface.custom_attributeV2(attribute_code) GraphQlCatalog ...GraphQl (the custom_attributesV2 plugin)
AttributeValueInterface.attribute, Aggregation.attribute GraphQlCatalog ...GraphQl
swatch on AggregationOptionInterface, CustomAttributeOptionInterface, AttributeSelectedOptionInterface GraphQlCatalogSwatches ...Swatches / ...SwatchesGraphQl
configurable_product_options_selection(followFilters) GraphQlCatalogConfigurableProduct ...ConfigurableProductGraphQl (the selection plugin, over the variant documents)
stock_sources GraphQlCatalogInventory ...Inventory (the sources of the stock feed row) / ...InventoryGraphQl (the stock prefiller)

Reference

Commands

Command Does
catalog-storefront:rebuild [product|category|attribute|review|rating ...] Stages a fresh index per store view, truncates the feed tables of the entity, runs its feed indexers, promotes the indices. --promote promotes what a feed run outside the command filled.
catalog-storefront:status Documents per store view and per feed the rows, the waiting rows, the last export and the indexer state. Fails on waiting rows or an invalid indexer.
catalog-storefront:parity <endpoint> Both paths over dev/parity/queries, diffed. --queries, --header, --dump, --report, --attempts, --warm, --candidate-endpoint, --insecure, and the soak options --soak, --token, --token-file, --probe-header, --probe-share, --memory-sample, --memory-slope, --worker-container, --worker-process.
catalog-storefront:parity:listing <base url> Renders dev/parity/listing-pages.txt on both paths and compares the lines. --pages, --dump, --warm, --host, --header.
catalog-storefront:parity:detail <base url> The same for dev/parity/detail-pages.txt. --marker names the text a real product page carries, so an error page is not judged.

Settings

Stores > Configuration > Catalog > Catalog > Catalog Storefront Document Store:

Path Default Effect
catalog/storefront_documents/index_enabled 1 The writers of these modules store each feed batch. Another package that consumes the same feeds has its own flag.
catalog/storefront_documents/serve_graphql 0, per store view The catalog GraphQL reads come from documents.
catalog/storefront_documents/serve_plp 0, per store view Rendered category and search listing pages build their products from documents.
catalog/storefront_documents/serve_pdp 0, per store view A rendered product detail page takes its product from a document.
catalog/storefront_documents/cache_category_pages 1, per store view 0 keeps category pages out of the page cache, Varnish and built-in alike: every visit renders the page. Flush the page cache after a change.
catalog/storefront_documents/key Generated on a save with the field empty The value of the X-Catalog-Storefront-Key header that unlocks the path header and the fallback report.
catalog/storefront_documents/index_prefix catalog_storefront The first part of every index name.

Stores > Configuration > Catalog > Catalog > Catalog Storefront Search, from GraphCommerce_CatalogStorefrontSearch, which runs without the document store:

Path Default Effect
catalog/storefront_search/entity_id_sort 1 The listing tie-break sorts on the product id field of the fulltext document. Needs a fulltext reindex, which the install schedules.
catalog/storefront_search/record_search_terms 0 Magento writes the search term of every search. Off, the search terms report and the suggestions keep their current state.
catalog/storefront_search/result_window 0 The hits the engine serves with one query. 0 leaves the engine's own limit of 10 000. A save puts the window on the product search index of every store view.

Stores > Configuration > Catalog > Catalog > Layered Navigation > Price Navigation Step Calculation takes a fourth value, Single range: the price aggregation is one option from the lowest to the highest price of the result, which is what a price slider reads. Core's other modes run two or three more search queries per listing for the intervals.

Headers

Header Value Effect
X-Catalog-Storefront-Key The configured key The request may pick its path and gets the fallback report.
X-Catalog-Storefront documents or core The path of this request. It is a factor of the response cache id, of the resolver result cache keys and of the page cache id.

Indices

Each entity and store view has one index behind a read alias <prefix>_<entity>_<store view code> and a write alias <prefix>_<entity>_<store view code>_write, for example catalog_storefront_product_default. The entities are product, category, attribute, review and rating. The two scopes feeds write <prefix>_website_global and <prefix>_customer_group_global. Every mapping is dynamic: false and maps only the fields a request filters, sorts or aggregates on (EntityMappings in src/CatalogStorefront/etc/di.xml); every other field stays in _source. A mapping change reaches an index through setup:di:compile and then catalog-storefront:rebuild <entity>.

Extend

Add a field to the product document

Declare the field on the exporter's record with a provider, in the etc/et_schema.xml of your module. The provider takes the keys the using nodes name and answers one row per entity and store view, under the key the exporter joins on. Provider/TaxClass is the same shape against the EAV tables.

<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_DataExporter:etc/et_schema.xsd">
    <record name="Product">
        <field name="rentalDays" type="Int" provider="Vendor\Rental\Model\DataExporter\Provider\RentalDays">
            <using field="productId"/>
            <using field="storeViewCode"/>
        </field>
    </record>
</config>
namespace Vendor\Rental\Model\DataExporter\Provider;

use Magento\Framework\App\ResourceConnection;

class RentalDays
{
    public function __construct(private readonly ResourceConnection $resourceConnection)
    {
    }

    public function get(array $values): array
    {
        $ids = array_unique(array_map(static fn(array $value) => (int)$value['productId'], $values));
        $connection = $this->resourceConnection->getConnection();
        $days = $connection->fetchPairs(
            $connection->select()
                ->from($this->resourceConnection->getTableName('vendor_rental_product'), ['product_id', 'days'])
                ->where('product_id IN (?)', $ids)
        );
        $output = [];
        foreach ($values as $value) {
            $id = (int)$value['productId'];
            if (!isset($days[$id])) {
                continue;
            }
            $output[$value['storeViewCode'] . '_' . $id] = [
                'productId' => $value['productId'],
                'storeViewCode' => $value['storeViewCode'],
                'rentalDays' => (int)$days[$id],
            ];
        }

        return $output;
    }
}

The products writer stores each feed field it does not name in its own NOT_STORED list, so rentalDays is on the document after bin/magento setup:di:compile and bin/magento catalog-storefront:rebuild product. Serve it with a prefiller, which fills the field on the product value the executor hands to the child fields:

namespace Vendor\Rental\Model\Prefill;

use GraphCommerce\CatalogStorefrontGraphQlApi\Read\PrefillerInterface;
use GraphCommerce\CatalogStorefrontGraphQlApi\Read\PrefillRequest;

class RentalDays implements PrefillerInterface
{
    public function fill(array $models, array $documents, PrefillRequest $request): array
    {
        if (!$request->selects('rental_days')) {
            return [];
        }

        return array_map(
            static fn(array $document) => ['rental_days' => $document['rentalDays'] ?? null],
            $documents
        );
    }
}
<type name="GraphCommerce\CatalogStorefrontGraphQl\Model\DocumentHydration">
    <arguments>
        <argument name="prefillers" xsi:type="array">
            <item name="rental" xsi:type="object">Vendor\Rental\Model\Prefill\RentalDays</item>
        </argument>
    </arguments>
</type>
<type name="GraphCommerce\CatalogStorefrontGraphQl\Plugin\Query\RoutePrefilledFields">
    <arguments>
        <argument name="prefilledFields" xsi:type="array">
            <item name="ProductInterface" xsi:type="array">
                <item name="rental_days" xsi:type="string">rental_days</item>
            </item>
        </argument>
    </arguments>
</type>

A field the prefiller leaves empty routes to the core resolver, so a product without a document keeps core's answer.

Add a writer

A writer takes the rows of one feed and stores them. It reads its rows and its own document store, and nothing else. Register it under the feed name, next to the writers that are there: the delivery calls every writer of the feed with the batch. Writer/Stock is a complete example of a slice writer, and src/CatalogStorefrontInventory/etc/di.xml of its registration:

<type name="GraphCommerce\CatalogStorefront\Model\Document\Delivery">
    <arguments>
        <argument name="writers" xsi:type="array">
            <item name="products" xsi:type="array">
                <item name="rental" xsi:type="object">Vendor\Rental\Model\Document\Writer\Rental</item>
            </item>
        </argument>
        <argument name="flags" xsi:type="array">
            <item name="rental" xsi:type="string">vendor_rental/documents/index_enabled</item>
        </argument>
        <argument name="identities" xsi:type="array">
            <item name="products" xsi:type="array">
                <item name="cat_p" xsi:type="array">
                    <item name="productId" xsi:type="string">productId</item>
                </item>
            </item>
        </argument>
    </arguments>
</type>

The writer name is a family: flags gives the family a configuration path, and the delivery skips it while that path is off. identities names the cache tags and the row keys the batch purges after the write.

docs/feed-writer.md holds the full path from a MySQL write to a document: the feeds, their providers, the change tracking and every writer with its index.

Other extension points

Argument On Adds
feeds Model/Feeds The feed indexers of an entity, for the rebuild and the status command. The rebuild runs the entities in this order.
ranges Model/Read/PriceRanges A PriceRangeInterface per product type id.
mappings EntityMappings The fields of an entity a request filters, sorts or aggregates on.
judges The parity command A JudgeInterface verdict per query, with both responses in hand.

Another search engine implements ProductDocumentStorageInterface and MetadataDocumentStorageInterface and sets the preferences in its own di.xml, as GraphCommerce_CatalogStorefrontOpenSearch does.

docs/skills/catalog-storefront-compatibility/SKILL.md is the guide for a module that adds catalog data: what to build at index time and at request time, and how to prove it with the parity gate. An LLM loads it as a skill.

Modules

Each directory under src/ is a Magento module and a composer package (graphcommerce/module-catalog-storefront-<name>) that depends on the core modules it plugs into; the root package replaces them all. Enable the modules the shop's product types and features need, next to the two Api modules, OpenSearch, the two Price modules, the base module and the GraphQl module.

Module Adds
...Api The contracts: document stores, feed writers, document fields, product documents, price ranges
...GraphQlApi The GraphQL contracts: prefillers, hydration, parity judges
...OpenSearch The document stores on OpenSearch, through core's client
GraphCommerce_CatalogStorefront Feed delivery with the cache purge, the writers of the products, prices, categories, attributes and scopes feeds, documents to models, the fallback report, the storefront key, the rebuild and status commands
...GraphQl Listings, layered navigation, categories, media, URL rewrites, custom attributes and one by code, linked products, the request path, the parity command
...Price / ...PriceGraphQl Display currency and tax at read time through core's tax service / the prices prefiller and the customer's tax address
...Inventory / ...InventoryGraphQl The stock slice / the stock fields
...ConfigurableProduct / ...GraphQl / ...Frontend Variants and the configurable range / options, variants, selection / a configurable on a rendered page
...BundleProduct / ...GraphQl / ...Frontend Bundle feed fields and range / bundle items and price details / rendered bundle options and prices
...GroupedProduct / ...GraphQl / ...Frontend Grouped range / grouped items / rendered children and quantity validation
...Downloadable / ...GraphQl / ...Frontend The downloadable range / links and samples / rendered links and samples
...Review / ...GraphQl / ...Frontend Review and rating feeds / rating summary, breakdown and reviews / the rating collection of the rendered review form
...SnowdogMenu Snowdog's menus as documents from a feed, and the menus, nodes and node data of a rendered page from them, every node type. Registered only where Snowdog_Menu is installed
...Swatches / ...GraphQl The swatch of every option on the attribute documents / the swatch fields of GraphQL Catalog from them
...QuoteGraphQl The display fields of a cart item's product
...WishlistGraphQl Wish list item products, one document request per list
...SalesGraphQl Order item products, one document request per order
...ProductFrontend Rendered listing and product detail pages from documents, and their two parity commands
...Search Cheaper core fulltext listings, each behind a setting. Runs without the document store
...Worker The built objects a FrankenPHP worker keeps between requests. Costs a cache read per request under php-fpm
...Elasticsuite The document store on the cluster Smile ElasticSuite is configured with
...Explorer The path switcher in the GraphCommerce_GraphiQLAdminHtml API explorer
...Adminhtml Catalog > Catalog Storefront: the store views, customer group price keys and feed state, read-only
...Profiler A MageOS_Profiler span per document store request
...StatementGate The SQL statements of a keyed request in its report, and the parity verdict that fails a document path which read the database

Tests

phpunit.xml.dist runs the unit tests of every module against the Magento installation that holds the package (MAGENTO_ROOT, else the project two levels up). The writer tests under Test/Unit/Model/Document hold the document contract: a feed batch in, the documents out. .github/workflows/ci.yml runs them on the latest Mage-OS release, installs the sample data with OpenSearch, MySQL and Valkey, adds the fixtures of the query set, exports the feeds and runs the parity gate in five price setups, each as a guest, as a signed-in customer and on a second website. dev/parity/freshness.php then changes a price, a fixed product tax and a stock status and reads the documents back, so a table or a setting the feeds do not watch shows up.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages