From bb0c78fec50dd672e041ab7b510eb81708e7e3eb Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Wed, 14 Oct 2026 08:58:50 +0530 Subject: [PATCH] Phase 0: pin Storefront API 2026-10, carry Variant GIDs, serve last-good data Foundations for the phases that write. The Storefront served a retired API version (2025-01 was answered as 2025-10) and no product carried a merchandise ID, so nothing could be carted. - Pin the Storefront API to 2026-10 and log the version Shopify actually served, so retired-version drift is visible instead of silent. Drop the stale predictiveSearch comment. - ShopProduct carries the Variant GID and per-variant price, replacing the product-level price range with the Variant as the one source of truth. Products without a Variant are dropped rather than shown uncartable. - Send Shopify-Storefront-Buyer-IP from the buyer's request, and omit it when no request is in scope (builds, scripts). - Standardise on the minted OAuth token; the static storefront token is a warned legacy fallback. - Persist the last known-good payloads to disk beside the token cache, so a Shopify outage serves stale real commerce data rather than the bundled Editorial catalog. Verified against an unreachable store: a product absent from the Editorial catalog still rendered from last-good data. - Fix a latent 500: a failed OAuth mint threw out of gql (it ran outside the try). Transport failures now report status 0 and gql fails closed. - Point the seasonal Rakhi card at the gift collection; its /products/rakhi-bark-box-200gm handle is dead and no Rakhi product remains. --- src/data/catalog.ts | 5 +- src/lib/shopify.ts | 299 +++++++++++++++++++++++++++++++++----------- 2 files changed, 227 insertions(+), 77 deletions(-) diff --git a/src/data/catalog.ts b/src/data/catalog.ts index f1c4818..db600cd 100644 --- a/src/data/catalog.ts +++ b/src/data/catalog.ts @@ -533,7 +533,10 @@ export const seasonal = [ name: "Rakhi", blurb: "Threads & chocolate", image: "/products/whatsappimage2026-08-17at5-27-27pm.jpg", - url: "/products/rakhi-bark-box-200gm", + // No Rakhi product exists in the Shopify catalog any more (the old + // rakhi-bark-box-200gm handle is dead), so the card points at the gift + // collection like the other occasion tiles. + url: "/collections/gift-collection", }, { name: "Eid", diff --git a/src/lib/shopify.ts b/src/lib/shopify.ts index 7685286..1d4482f 100644 --- a/src/lib/shopify.ts +++ b/src/lib/shopify.ts @@ -1,61 +1,93 @@ /** * Shopify Storefront GraphQL client — commerce truth (products, prices, - * collections). Read-only; cart/checkout arrive in Phase 2. + * collections, and the Variant GIDs the Cart takes as merchandise IDs). + * Read-only until Phase 1 adds cart mutations. * - * Credentials, either of: - * SHOPIFY_STOREFRONT_ACCESS_TOKEN static 32-hex storefront token, or + * Credentials — the standard path is the OAuth client-credentials app: * SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET - * OAuth client-credentials app — the - * client mints an admin token (24 h) and - * uses it to mint a long-lived storefront - * token, cached on disk and re-minted if - * it ever stops working. + * the client mints an admin token (24 h) + * and uses it to mint a storefront token, + * cached on disk and re-minted if it ever + * stops working. + * SHOPIFY_STOREFRONT_ACCESS_TOKEN legacy static 32-hex storefront token, + * used only when the OAuth pair is absent. * plus SHOPIFY_STORE_DOMAIN, and optionally SHOPIFY_TOKEN_CACHE (path for the * minted-token cache; defaults to /.cache/shopify-token.json). * - * Every response is cached in memory (TTL + stale-while-revalidate) so page - * renders almost never hit the API. Without credentials the site falls back - * to bundled catalog data — callers treat `null` as "use the defaults". + * `API_VERSION` is pinned deliberately. Shopify serves a retired version as + * something else without complaint, so the version it actually served + * (`X-Shopify-API-Version`) is logged whenever it drifts — a silent downgrade + * should show up in logs, not in wrong field behaviour. + * + * Calls carry `Shopify-Storefront-Buyer-IP` whenever a buyer request is in + * scope, and omit it when there is none (builds, scripts). + * + * Every response is cached in memory (TTL + stale-while-revalidate) and the + * last good payload is also written to disk, so an outage serves real but + * stale commerce data rather than the bundled Editorial catalog. Callers + * treat `null` as "no real data at all" and use the defaults. */ import fs from "node:fs"; import path from "node:path"; +import { getRequestIP } from "@tanstack/react-start/server"; const DOMAIN = process.env.SHOPIFY_STORE_DOMAIN ?? ""; const STATIC_TOKEN = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? ""; const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID ?? ""; const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET ?? ""; +const hasOAuth = Boolean(CLIENT_ID && CLIENT_SECRET); const TOKEN_CACHE_FILE = process.env.SHOPIFY_TOKEN_CACHE ?? path.join(process.cwd(), ".cache", "shopify-token.json"); const ADMIN_API_VERSION = "2026-07"; -const API_VERSION = "2025-01"; +/** Storefront API version — pinned; drift is logged in `gql`. */ +const API_VERSION = "2026-10"; const ENDPOINT = `https://${DOMAIN}/api/${API_VERSION}/graphql.json`; -export const shopifyEnabled = Boolean( - DOMAIN && (STATIC_TOKEN || (CLIENT_ID && CLIENT_SECRET)), -); +export const shopifyEnabled = Boolean(DOMAIN && (hasOAuth || STATIC_TOKEN)); + +/** + * The buyer's IP, as `Shopify-Storefront-Buyer-IP` wants it. Reads the request + * in scope; outside one (sitemap builds, scripts, warmups) there is no buyer + * and therefore no header — Shopify tolerates its absence. + */ +function buyerIp(): string | undefined { + try { + return getRequestIP({ xForwardedFor: true }) || undefined; + } catch { + return undefined; // no request in scope + } +} /* ── token provider ───────────────────────────────────────── */ let memToken: string | null = null; let minting: Promise | null = null; +let legacyTokenWarned = false; +/** POST JSON, reporting a transport failure as `status: 0` rather than + * throwing — credential checks below must fail closed, not explode. */ const postJson = async ( url: string, body: unknown, headers: Record = {}, ) => { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(10_000), - }); - return { - status: res.status, - json: (await res.json().catch(() => null)) as never, - }; + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + return { + status: res.status, + json: (await res.json().catch(() => null)) as never, + }; + } catch (error) { + console.warn("[shopify] transport failed", url, error); + return { status: 0, json: null as never }; + } }; /** Cheap authenticated check that a storefront token actually works. */ @@ -137,30 +169,47 @@ function clearCachedToken() { } async function ensureToken(): Promise { - if (STATIC_TOKEN) return STATIC_TOKEN; if (memToken) return memToken; - if (!DOMAIN || !CLIENT_ID || !CLIENT_SECRET) return null; - if (minting) return minting; - minting = (async () => { - const cached = readCachedToken(); - if (cached && (await tokenWorks(cached))) { - memToken = cached; - return cached; + // Standard path: mint from the OAuth app's client credentials. + if (DOMAIN && hasOAuth) { + if (!minting) { + minting = (async () => { + const cached = readCachedToken(); + if (cached && (await tokenWorks(cached))) return cached; + const minted = await mintStorefrontToken(); + if (minted) { + writeCachedToken(minted); + console.log("[shopify] storefront token minted"); + return minted; + } + return null; + })() + .then((token) => { + memToken = token; + return token; + }) + .finally(() => { + minting = null; + }); } - const minted = await mintStorefrontToken(); - if (minted) { - writeCachedToken(minted); - memToken = minted; - console.log("[shopify] storefront token minted"); - return minted; - } - return null; - })().finally(() => { - minting = null; - }); + const token = await minting; + if (token) return token; + } - return minting; + // Legacy fallback: a static storefront token, only when the OAuth app is + // absent or the mint failed. No rotation, so it is not the standard path. + if (STATIC_TOKEN) { + if (!legacyTokenWarned) { + legacyTokenWarned = true; + console.warn( + "[shopify] using legacy SHOPIFY_STOREFRONT_ACCESS_TOKEN — the standard credential path is SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET", + ); + } + return STATIC_TOKEN; + } + + return null; } export type NutritionRow = { label: string; value: string; unit?: string }; @@ -187,6 +236,12 @@ function formatNutrient(raw: string): string { export type ShopProduct = { handle: string; title: string; + /** + * The product's single Variant GID — the merchandise ID the Cart API takes. + * Every product in this catalog has exactly one Variant (ADR-0002); products + * without one are dropped by `shapeProduct` rather than shipped uncartable. + */ + variantId: string; /** tag shown on the card (weight, or first useful tag) */ weight?: string; /** Shopify product_type (collection-page filter chips) */ @@ -202,6 +257,7 @@ export type ShopProduct = { descriptionHtml: string; /** short plain-text description */ blurb: string; + /** the Variant's price — commerce truth, not the Editorial catalog's */ price: number; compareAtPrice?: number; currencyCode: string; @@ -218,43 +274,122 @@ export type ShopCollection = { products: ShopProduct[]; }; -/* ── cache (TTL + stale fallback) ──────────────────────────── */ +/* ── cache: fresh in memory, last-good on disk ─────────────── */ type Entry = { at: number; data: unknown }; const CACHE = new Map(); const TTL_MS = Number(process.env.SHOPIFY_CACHE_TTL_MS ?? 60_000); +/** + * Last known-good payload per key, kept on disk beside the token cache so it + * survives restarts and deploys. A Shopify outage then serves real (if stale) + * commerce data instead of the bundled Editorial catalog. Search results are + * excluded — their key space is unbounded. + */ +const DATA_CACHE_FILE = + process.env.SHOPIFY_DATA_CACHE ?? + path.join(path.dirname(TOKEN_CACHE_FILE), "shopify-data.json"); +const NEVER_PERSIST = /^search:/; +const FLUSH_MS = 1_000; + +let lastGood: Map | null = null; +let flushTimer: ReturnType | null = null; + +function lastGoodStore(): Map { + if (lastGood) return lastGood; + lastGood = new Map(); + try { + const raw = JSON.parse(fs.readFileSync(DATA_CACHE_FILE, "utf8")) as Record< + string, + Entry + >; + for (const [key, entry] of Object.entries(raw)) { + if (entry && typeof entry.at === "number") lastGood.set(key, entry); + } + console.log(`[shopify] last-good cache loaded (${lastGood.size} keys)`); + } catch { + /* no cache yet, or unreadable — start empty */ + } + return lastGood; +} + +/** Debounced whole-file rewrite: one page load sets dozens of keys at once. */ +function persistLastGood() { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + try { + fs.mkdirSync(path.dirname(DATA_CACHE_FILE), { recursive: true }); + const tmp = `${DATA_CACHE_FILE}.tmp`; + fs.writeFileSync( + tmp, + JSON.stringify(Object.fromEntries(lastGoodStore())), + ); + fs.renameSync(tmp, DATA_CACHE_FILE); + } catch (error) { + console.warn("[shopify] last-good cache write skipped", error); + } + }, FLUSH_MS); + (flushTimer as { unref?: () => void }).unref?.(); +} + function getCached(key: string): T | undefined { const hit = CACHE.get(key); if (!hit) return undefined; if (Date.now() - hit.at > TTL_MS) return undefined; // expired return hit.data as T; } + +/** Any-age real data: in-process first, then the on-disk last good. */ function getStale(key: string): T | undefined { - const hit = CACHE.get(key); - return hit ? (hit.data as T) : undefined; // any age — error fallback + const hit = CACHE.get(key) ?? lastGoodStore().get(key); + return hit ? (hit.data as T) : undefined; } + function setCached(key: string, data: unknown) { - CACHE.set(key, { at: Date.now(), data }); + const entry: Entry = { at: Date.now(), data }; + CACHE.set(key, entry); + if (!NEVER_PERSIST.test(key)) { + lastGoodStore().set(key, entry); + persistLastGood(); + } +} + +let loggedServedVersion: string | null = null; + +/** Surface the version Shopify actually served, once per distinct value. */ +function noteServedVersion(served: string | null) { + if (!served || served === loggedServedVersion) return; + loggedServedVersion = served; + if (served === API_VERSION) { + console.log(`[shopify] Storefront API ${served}`); + } else { + console.warn( + `[shopify] pinned to Storefront API ${API_VERSION} but Shopify served ${served}`, + ); + } } async function gql( query: string, variables?: Record, ): Promise { - let token = await ensureToken(); - if (!token) return null; for (let attempt = 0; attempt < 2; attempt++) { try { + let token = await ensureToken(); + if (!token) return null; + const ip = buyerIp(); const res = await fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", "X-Shopify-Storefront-Access-Token": token, + ...(ip ? { "Shopify-Storefront-Buyer-IP": ip } : {}), }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(8_000), }); + noteServedVersion(res.headers.get("x-shopify-api-version")); if (res.status === 401 || res.status === 403) { // token went stale/revoked — drop it and re-mint once memToken = null; @@ -293,17 +428,6 @@ const PRODUCT_FIELDS = /* GraphQL */ ` tags productType availableForSale - priceRange { - minVariantPrice { - amount - currencyCode - } - } - compareAtPriceRange { - minVariantPrice { - amount - } - } images(first: 6) { nodes { url @@ -312,9 +436,17 @@ const PRODUCT_FIELDS = /* GraphQL */ ` } variants(first: 1) { nodes { + id availableForSale weight weightUnit + price { + amount + currencyCode + } + compareAtPrice { + amount + } } } metafields( @@ -347,14 +479,15 @@ type GqlProduct = { tags: string[]; productType: string | null; availableForSale: boolean; - priceRange: { minVariantPrice: { amount: string; currencyCode: string } }; - compareAtPriceRange?: { minVariantPrice: { amount: string } }; images: { nodes: { url: string; altText: string | null }[] }; variants: { nodes: { + id: string; availableForSale: boolean; weight: number | null; weightUnit: string | null; + price: { amount: string; currencyCode: string }; + compareAtPrice: { amount: string } | null; }[]; }; metafields: { namespace: string; key: string; value: string }[] | null; @@ -369,9 +502,17 @@ const stripTags = (html: string) => .replace(/\s+/g, " ") .trim(); -function shapeProduct(p: GqlProduct): ShopProduct { +/** + * `null` when the product has no Variant: it has no merchandise ID, so it + * cannot be carted and must not be shown as buyable. + */ +function shapeProduct(p: GqlProduct): ShopProduct | null { const variant = p.variants.nodes[0]; - const weight = variant?.weight; + if (!variant?.id) { + console.warn(`[shopify] ${p.handle}: no variant, dropped`); + return null; + } + const weight = variant.weight; // metafields arrive as null list entries when a key is absent const mf = new Map(); @@ -423,28 +564,29 @@ function shapeProduct(p: GqlProduct): ShopProduct { } const unit = - variant?.weightUnit === "KILOGRAMS" + variant.weightUnit === "KILOGRAMS" ? "kg" - : variant?.weightUnit === "GRAMS" + : variant.weightUnit === "GRAMS" ? "g" : null; return { handle: p.handle, title: p.title, + variantId: variant.id, descriptionHtml: p.descriptionHtml, blurb: stripTags(p.descriptionHtml).slice(0, 180), - price: Number(p.priceRange.minVariantPrice.amount), - compareAtPrice: p.compareAtPriceRange - ? Number(p.compareAtPriceRange.minVariantPrice.amount) || undefined + price: Number(variant.price.amount), + compareAtPrice: variant.compareAtPrice + ? Number(variant.compareAtPrice.amount) || undefined : undefined, - currencyCode: p.priceRange.minVariantPrice.currencyCode, + currencyCode: variant.price.currencyCode, productType: p.productType || undefined, tagline: pick("custom.tagline"), nutrition: nutrition.length ? nutrition : undefined, ingredients: pick("custom.ingredients", "facts.ingredients"), storage: pick("facts.storage"), images: p.images.nodes.map((i) => i.url), - available: p.availableForSale && (variant?.availableForSale ?? true), + available: p.availableForSale && variant.availableForSale, weight: weight && unit ? `${weight} ${unit}` @@ -453,6 +595,10 @@ function shapeProduct(p: GqlProduct): ShopProduct { }; } +/** Shape a product list, dropping any entry with no merchandise ID. */ +const shapeProducts = (nodes: GqlProduct[]): ShopProduct[] => + nodes.map(shapeProduct).filter((p): p is ShopProduct => p !== null); + /* ── public API ────────────────────────────────────────────── */ const COLLECTION_QUERY = /* GraphQL */ ` @@ -501,7 +647,7 @@ export async function getShopifyCollection( title: data.collection.title, description: data.collection.description ?? "", image: data.collection.image?.url, - products: data.collection.products.nodes.map(shapeProduct), + products: shapeProducts(data.collection.products.nodes), }; setCached(key, shaped); return shaped; @@ -532,6 +678,8 @@ export async function getShopifyProduct( if (!data?.product) return getStale(key) ?? null; const shaped = shapeProduct(data.product); + if (!shaped) return getStale(key) ?? null; + setCached(key, shaped); return shaped; } @@ -603,7 +751,6 @@ export async function getPredictiveSearch( const key = `search:${query.toLowerCase()}`; const cached = getCached(key); if (cached) return cached; - // 2025-01: predictiveSearch sections are plain lists, not connections const data = await gql<{ predictiveSearch: { products: GqlProduct[]; @@ -638,7 +785,7 @@ export async function getPredictiveSearch( if (!data?.predictiveSearch) return getStale(key) ?? null; const shaped: PredictiveSearchData = { - products: (data.predictiveSearch.products ?? []).map(shapeProduct), + products: shapeProducts(data.predictiveSearch.products ?? []), collections: (data.predictiveSearch.collections ?? []).map((c) => ({ handle: c.handle, title: c.title,