GraphQL fragment 戦略(list / detail の責務分離)¶
packages/sdk/shop と apps/storefront の GraphQL fragment を「list 用 / detail 用」で分離するための運用ルール。
背景¶
一覧(list)と詳細(detail)で同じ fat fragment を使い回すと、
- list で 1 ページ分(最大 50 件など)× 各 Order × 各 OrderLine の deep nest(
productVariant.product.{assets[], facetValues[], collections[], description, customFields, consentConfig}、bundleItems、purchaseLimitRuleStates、payments[].metadata、fulfillments、taxSummary、promotions、customer.addresses等)が一括で返り、JSON が 1〜数 MB 単位になる。 - 受信後の deserialize / React 描画コストも増え、体感速度が悪化する。
これは「画面ごと」の問題ではなく、「fragment 設計レイヤ」の欠落として扱う。
ルール¶
- list 用と detail 用を別 fragment にする。命名規則:
- 詳細:
Order,Product,Customer等の素の名前。 - 一覧:
OrderListItem,ProductListItemのようにListItemサフィックスを付ける。 - list 用 fragment では
productVariant.product.*の deep nest を避ける。最低限のid/name/slugだけ取得し、画像は OrderLine 側のfeaturedAsset { preview, source }(Vendure native)から取る。 - 画像 / 商品名 / SKU / slug は order line snapshot を正本にする(
apps/storefront/src/lib/order-line-snapshot.tsの helper を参照)。snapshot が無い場合のみ liveproductVariantにフォールバックする。 - list クエリではサーバ側 filter / pagination を使う。クライアント側で「最初に全件取って絞り込む」運用は、
takeを超えるユーザでサイレント欠落の原因になる。例: 期間フィルタはOrderListOptions.filter.createdAt: { after }に乗せる。 - list 用に詳細を流用したくなったら、まず list 用 fragment を新設してから流用先を切り替える。逆方向(list 用を detail に流用)は OK(detail のクエリで補強する)。
実例¶
packages/sdk/shop/src/fragments/common.graphqlOrderListItem/OrderLineListItem— 一覧 (GetCustomerOrders) 専用。Order/OrderLine— 詳細 (GetOrderDetail,GetOrderByCode) 用。apps/storefront/src/components/account/account-order-list.tsx— 一覧でOrderListItemFragmentを使う。apps/storefront/src/components/pages/order-detail-page-content.tsx— 詳細でOrderFragmentを使う。relatedOrders(list 由来)はOrderListItemの subset として受け入れる。
list クエリの take 上限(2 段 + clamp + 静的検査)¶
shop-api の standard list query(ListQueryBuilder 経由。collections(options: { take }),
facets(options: {...}), products 等)は take が Vendure の shopListQueryLimit を超えると
runtime で初めて error.list-query-limit-exceeded (USER_INPUT_ERROR) になる。
runtime まで発覚しないのを避けるため、上限を SSOT 定数で管理し、静的検査と clamp の両方で守る。
SSOT 定数(@ritsubi/config)¶
SHOP_LIST_QUERY_LIMIT(= 200): サーバ上限。VendureapiOptions.shopListQueryLimitに設定する(apps/vendure-server/src/config/api-options.ts)。SHOP_LIST_SAFE_TAKE(= 100): storefront が list query で要求してよい運用上限。clampShopListTake(n): 動的に算出した take をSHOP_LIST_SAFE_TAKE以内へ丸めるヘルパー。
サーバ上限 (200) > クライアント上限 (100) の 2 段構成。想定外の take でも buffer 内なら 500 を出さず graceful degradation する余裕を持たせている。
2 つの上限を同時に満たす¶
list query の take は次の 2 つを 両方満たす必要がある。片方だけ見ると今回のように事故る。
- HardenPlugin の complexity 上限。production は
maxQueryComplexity=2500の strict 運用を正本にする。複雑度はchildComplexity + round(log(childComplexity) * take)。take 省略時は default 1000 が適用され超過するため明示必須。 shopListQueryLimit(上記サーバ上限)。
bug クラス別の防御¶
- リテラル take(例:
collections(options: { take: 400 }))→ pre-runtime の静的検査で捕捉。apps/storefront/src/lib/shop-list-take-contract.test.tsがoptions: { take: N }の N がSHOP_LIST_SAFE_TAKEを超えたら test で落とす。query には literal を直書きせず${SHOP_LIST_SAFE_TAKE}を参照する。 - 動的 take(実行時計算で variable 経由)→ 静的解析では原理的に捕捉できないため
clampShopListTakeを呼び口に通す。 例:visibility-shop.resolver.tsは client 供給のinput.takeをSHOP_LIST_QUERY_LIMITで頭打ちにする。 - Storefront 内の raw GraphQL query(
graphql-codegen管理外の template string)→scripts/ops/storefront-shop-api-operation-registry.mjsへの登録を必須にし、registry 駆動の static guard と live preflight で捕捉する。just storefront-graphql-complexity-preflight stagingはpreflight: trueの query を対象 Vendure Shop API へ送信し、HardenPlugin のQuery is too complexと schema mismatch (Cannot query field等) を deploy 前に fail させる。
Storefront Shop API operation registry¶
Storefront から Vendure Shop API へ投げる raw GraphQL operation は
scripts/ops/storefront-shop-api-operation-registry.mjs に登録する。これは HardenPlugin
複雑度事故を「新しい query を追加したが preflight 対象に入れ忘れた」という形で再発させないための境界。
登録には次を必須にする。
operationName相当のlabelkind: list | detail | mutationfileとoperationConstfixtureVariablesallowedFragmentspreflight(mutation は false)
pnpm run lint:storefront-shop-graphql-boundary は次を fail させる。
- registry 未登録の raw Shop API GraphQL operation
vendureFetch/vendureQueryへの inline raw query 渡し- list operation の detail fragment spread (
...Product/...ProductVariant) children { children { ... } }の nested collection treeactiveCustomer.orders -> lines -> productVariant -> productを 1 query で取る形
Storefront GraphQL complexity preflight¶
Storefront の Cloudflare deploy (just cloudflare-deploy-storefront <env>, just deploy-storefront-staging) は、
build / upload 前に次を自動実行する。
just storefront-graphql-complexity-preflight staging
この check は 実環境の Vendure Shop API を使う。HardenPlugin の complexity 計算は schema / field resolver 側の 定義に依存するため、AST の静的検査だけでは正確に再現しない。新しい raw query を追加した場合は、次をセットで行う。
- list / home / browse / cart recommendation など、複数件を返す query は詳細用 fragment を流用しない。
scripts/ops/storefront-shop-api-operation-registry.mjsに query とfixtureVariablesを追加する。preflight: trueの operation は自動的に live preflight 対象になる。node --test scripts/ops/storefront-graphql-complexity-preflight.test.mjsとjust storefront-graphql-complexity-preflight stagingを通す。
2026-06 の home incident では、activeCustomer.orders -> lines -> productVariant -> product を 1 query で取ったため、
take:1 でも HardenPlugin に拒否された。再購入リストは「注文履歴から variant id だけ取る query」と
「productVariantsWithVisibility で商品カード情報を取る query」の 2 段に分ける。
対象外¶
SearchInput.take(visibleProductsForBrowse等の custom search resolver)は DefaultSearchPlugin の search 経路でListQueryBuilderを介さずshopListQueryLimitの対象外。visibility browse の内部 overfetch (PRODUCTS_BROWSE_MAX_BATCH_SIZE=400) は意図的な先読みなので clamp しない。静的検査もoptions: { take }形に限定し SearchInput.take を誤検知しない。
codegen¶
packages/sdk/shopの SDK はpnpm run codegen:sharedで再生成。apps/storefrontの__generated__/*はapps/storefront/codegen.ts+pnpm exec nx run ritsubi-storefront:codegenで再生成。SSOT 編集後は両方を回す。
横展開チェックリスト¶
list クエリ全てに対して以下を確認:
-
GetCustomerOrders→OrderListItem適用済み -
GetActiveOrderContext(カート)→ 適用候補(別 PR) -
GetFavorites→ProductListItem化候補(別 PR) -
GetRecentlyViewedProducts→ 同上 -
GetHomeContent→ 同上