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.
This commit is contained in:
tanshu committed 2026-10-14 08:58:50 +05:30
1 parent 1120a35fd1
commit bb0c78fec5
2 files changed
+227 -77

No files matched your search

+4 -1
View File
@@ -533,7 +533,10 @@ export const seasonal = [
name: "Rakhi", name: "Rakhi",
blurb: "Threads & chocolate", blurb: "Threads & chocolate",
image: "/products/whatsappimage2026-08-17at5-27-27pm.jpg", 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", name: "Eid",
+223 -76
View File
@@ -1,61 +1,93 @@
/** /**
* Shopify Storefront GraphQL client — commerce truth (products, prices, * 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: * Credentials — the standard path is the OAuth client-credentials app:
* SHOPIFY_STOREFRONT_ACCESS_TOKEN static 32-hex storefront token, or
* SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET * SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET
* OAuth client-credentials app — the * the client mints an admin token (24 h)
* client mints an admin token (24 h) and * and uses it to mint a storefront token,
* uses it to mint a long-lived storefront * cached on disk and re-minted if it ever
* token, cached on disk and re-minted if * stops working.
* 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 * plus SHOPIFY_STORE_DOMAIN, and optionally SHOPIFY_TOKEN_CACHE (path for the
* minted-token cache; defaults to <cwd>/.cache/shopify-token.json). * minted-token cache; defaults to <cwd>/.cache/shopify-token.json).
* *
* Every response is cached in memory (TTL + stale-while-revalidate) so page * `API_VERSION` is pinned deliberately. Shopify serves a retired version as
* renders almost never hit the API. Without credentials the site falls back * something else without complaint, so the version it actually served
* to bundled catalog data — callers treat `null` as "use the defaults". * (`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 fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { getRequestIP } from "@tanstack/react-start/server";
const DOMAIN = process.env.SHOPIFY_STORE_DOMAIN ?? ""; const DOMAIN = process.env.SHOPIFY_STORE_DOMAIN ?? "";
const STATIC_TOKEN = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? ""; const STATIC_TOKEN = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID ?? ""; const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET ?? ""; const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET ?? "";
const hasOAuth = Boolean(CLIENT_ID && CLIENT_SECRET);
const TOKEN_CACHE_FILE = const TOKEN_CACHE_FILE =
process.env.SHOPIFY_TOKEN_CACHE ?? process.env.SHOPIFY_TOKEN_CACHE ??
path.join(process.cwd(), ".cache", "shopify-token.json"); path.join(process.cwd(), ".cache", "shopify-token.json");
const ADMIN_API_VERSION = "2026-07"; 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`; const ENDPOINT = `https://${DOMAIN}/api/${API_VERSION}/graphql.json`;
export const shopifyEnabled = Boolean( export const shopifyEnabled = Boolean(DOMAIN && (hasOAuth || STATIC_TOKEN));
DOMAIN && (STATIC_TOKEN || (CLIENT_ID && CLIENT_SECRET)),
); /**
* 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 ───────────────────────────────────────── */ /* ── token provider ───────────────────────────────────────── */
let memToken: string | null = null; let memToken: string | null = null;
let minting: Promise<string | null> | null = null; let minting: Promise<string | null> | 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 ( const postJson = async (
url: string, url: string,
body: unknown, body: unknown,
headers: Record<string, string> = {}, headers: Record<string, string> = {},
) => { ) => {
const res = await fetch(url, { try {
method: "POST", const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...headers }, method: "POST",
body: JSON.stringify(body), headers: { "Content-Type": "application/json", ...headers },
signal: AbortSignal.timeout(10_000), body: JSON.stringify(body),
}); signal: AbortSignal.timeout(10_000),
return { });
status: res.status, return {
json: (await res.json().catch(() => null)) as never, 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. */ /** Cheap authenticated check that a storefront token actually works. */
@@ -137,30 +169,47 @@ function clearCachedToken() {
} }
async function ensureToken(): Promise<string | null> { async function ensureToken(): Promise<string | null> {
if (STATIC_TOKEN) return STATIC_TOKEN;
if (memToken) return memToken; if (memToken) return memToken;
if (!DOMAIN || !CLIENT_ID || !CLIENT_SECRET) return null;
if (minting) return minting;
minting = (async () => { // Standard path: mint from the OAuth app's client credentials.
const cached = readCachedToken(); if (DOMAIN && hasOAuth) {
if (cached && (await tokenWorks(cached))) { if (!minting) {
memToken = cached; minting = (async () => {
return cached; 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(); const token = await minting;
if (minted) { if (token) return token;
writeCachedToken(minted); }
memToken = minted;
console.log("[shopify] storefront token minted");
return minted;
}
return null;
})().finally(() => {
minting = null;
});
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 }; export type NutritionRow = { label: string; value: string; unit?: string };
@@ -187,6 +236,12 @@ function formatNutrient(raw: string): string {
export type ShopProduct = { export type ShopProduct = {
handle: string; handle: string;
title: 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) */ /** tag shown on the card (weight, or first useful tag) */
weight?: string; weight?: string;
/** Shopify product_type (collection-page filter chips) */ /** Shopify product_type (collection-page filter chips) */
@@ -202,6 +257,7 @@ export type ShopProduct = {
descriptionHtml: string; descriptionHtml: string;
/** short plain-text description */ /** short plain-text description */
blurb: string; blurb: string;
/** the Variant's price — commerce truth, not the Editorial catalog's */
price: number; price: number;
compareAtPrice?: number; compareAtPrice?: number;
currencyCode: string; currencyCode: string;
@@ -218,43 +274,122 @@ export type ShopCollection = {
products: ShopProduct[]; products: ShopProduct[];
}; };
/* ── cache (TTL + stale fallback) ──────────────────────────── */ /* ── cache: fresh in memory, last-good on disk ─────────────── */
type Entry = { at: number; data: unknown }; type Entry = { at: number; data: unknown };
const CACHE = new Map<string, Entry>(); const CACHE = new Map<string, Entry>();
const TTL_MS = Number(process.env.SHOPIFY_CACHE_TTL_MS ?? 60_000); 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<string, Entry> | null = null;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function lastGoodStore(): Map<string, Entry> {
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<T>(key: string): T | undefined { function getCached<T>(key: string): T | undefined {
const hit = CACHE.get(key); const hit = CACHE.get(key);
if (!hit) return undefined; if (!hit) return undefined;
if (Date.now() - hit.at > TTL_MS) return undefined; // expired if (Date.now() - hit.at > TTL_MS) return undefined; // expired
return hit.data as T; return hit.data as T;
} }
/** Any-age real data: in-process first, then the on-disk last good. */
function getStale<T>(key: string): T | undefined { function getStale<T>(key: string): T | undefined {
const hit = CACHE.get(key); const hit = CACHE.get(key) ?? lastGoodStore().get(key);
return hit ? (hit.data as T) : undefined; // any age — error fallback return hit ? (hit.data as T) : undefined;
} }
function setCached(key: string, data: unknown) { 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<T>( async function gql<T>(
query: string, query: string,
variables?: Record<string, unknown>, variables?: Record<string, unknown>,
): Promise<T | null> { ): Promise<T | null> {
let token = await ensureToken();
if (!token) return null;
for (let attempt = 0; attempt < 2; attempt++) { for (let attempt = 0; attempt < 2; attempt++) {
try { try {
let token = await ensureToken();
if (!token) return null;
const ip = buyerIp();
const res = await fetch(ENDPOINT, { const res = await fetch(ENDPOINT, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": token, "X-Shopify-Storefront-Access-Token": token,
...(ip ? { "Shopify-Storefront-Buyer-IP": ip } : {}),
}, },
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
signal: AbortSignal.timeout(8_000), signal: AbortSignal.timeout(8_000),
}); });
noteServedVersion(res.headers.get("x-shopify-api-version"));
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
// token went stale/revoked — drop it and re-mint once // token went stale/revoked — drop it and re-mint once
memToken = null; memToken = null;
@@ -293,17 +428,6 @@ const PRODUCT_FIELDS = /* GraphQL */ `
tags tags
productType productType
availableForSale availableForSale
priceRange {
minVariantPrice {
amount
currencyCode
}
}
compareAtPriceRange {
minVariantPrice {
amount
}
}
images(first: 6) { images(first: 6) {
nodes { nodes {
url url
@@ -312,9 +436,17 @@ const PRODUCT_FIELDS = /* GraphQL */ `
} }
variants(first: 1) { variants(first: 1) {
nodes { nodes {
id
availableForSale availableForSale
weight weight
weightUnit weightUnit
price {
amount
currencyCode
}
compareAtPrice {
amount
}
} }
} }
metafields( metafields(
@@ -347,14 +479,15 @@ type GqlProduct = {
tags: string[]; tags: string[];
productType: string | null; productType: string | null;
availableForSale: boolean; availableForSale: boolean;
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
compareAtPriceRange?: { minVariantPrice: { amount: string } };
images: { nodes: { url: string; altText: string | null }[] }; images: { nodes: { url: string; altText: string | null }[] };
variants: { variants: {
nodes: { nodes: {
id: string;
availableForSale: boolean; availableForSale: boolean;
weight: number | null; weight: number | null;
weightUnit: string | null; weightUnit: string | null;
price: { amount: string; currencyCode: string };
compareAtPrice: { amount: string } | null;
}[]; }[];
}; };
metafields: { namespace: string; key: string; value: string }[] | null; metafields: { namespace: string; key: string; value: string }[] | null;
@@ -369,9 +502,17 @@ const stripTags = (html: string) =>
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .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 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 // metafields arrive as null list entries when a key is absent
const mf = new Map<string, string>(); const mf = new Map<string, string>();
@@ -423,28 +564,29 @@ function shapeProduct(p: GqlProduct): ShopProduct {
} }
const unit = const unit =
variant?.weightUnit === "KILOGRAMS" variant.weightUnit === "KILOGRAMS"
? "kg" ? "kg"
: variant?.weightUnit === "GRAMS" : variant.weightUnit === "GRAMS"
? "g" ? "g"
: null; : null;
return { return {
handle: p.handle, handle: p.handle,
title: p.title, title: p.title,
variantId: variant.id,
descriptionHtml: p.descriptionHtml, descriptionHtml: p.descriptionHtml,
blurb: stripTags(p.descriptionHtml).slice(0, 180), blurb: stripTags(p.descriptionHtml).slice(0, 180),
price: Number(p.priceRange.minVariantPrice.amount), price: Number(variant.price.amount),
compareAtPrice: p.compareAtPriceRange compareAtPrice: variant.compareAtPrice
? Number(p.compareAtPriceRange.minVariantPrice.amount) || undefined ? Number(variant.compareAtPrice.amount) || undefined
: undefined, : undefined,
currencyCode: p.priceRange.minVariantPrice.currencyCode, currencyCode: variant.price.currencyCode,
productType: p.productType || undefined, productType: p.productType || undefined,
tagline: pick("custom.tagline"), tagline: pick("custom.tagline"),
nutrition: nutrition.length ? nutrition : undefined, nutrition: nutrition.length ? nutrition : undefined,
ingredients: pick("custom.ingredients", "facts.ingredients"), ingredients: pick("custom.ingredients", "facts.ingredients"),
storage: pick("facts.storage"), storage: pick("facts.storage"),
images: p.images.nodes.map((i) => i.url), images: p.images.nodes.map((i) => i.url),
available: p.availableForSale && (variant?.availableForSale ?? true), available: p.availableForSale && variant.availableForSale,
weight: weight:
weight && unit weight && unit
? `${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 ────────────────────────────────────────────── */ /* ── public API ────────────────────────────────────────────── */
const COLLECTION_QUERY = /* GraphQL */ ` const COLLECTION_QUERY = /* GraphQL */ `
@@ -501,7 +647,7 @@ export async function getShopifyCollection(
title: data.collection.title, title: data.collection.title,
description: data.collection.description ?? "", description: data.collection.description ?? "",
image: data.collection.image?.url, image: data.collection.image?.url,
products: data.collection.products.nodes.map(shapeProduct), products: shapeProducts(data.collection.products.nodes),
}; };
setCached(key, shaped); setCached(key, shaped);
return shaped; return shaped;
@@ -532,6 +678,8 @@ export async function getShopifyProduct(
if (!data?.product) return getStale<ShopProduct>(key) ?? null; if (!data?.product) return getStale<ShopProduct>(key) ?? null;
const shaped = shapeProduct(data.product); const shaped = shapeProduct(data.product);
if (!shaped) return getStale<ShopProduct>(key) ?? null;
setCached(key, shaped); setCached(key, shaped);
return shaped; return shaped;
} }
@@ -603,7 +751,6 @@ export async function getPredictiveSearch(
const key = `search:${query.toLowerCase()}`; const key = `search:${query.toLowerCase()}`;
const cached = getCached<PredictiveSearchData>(key); const cached = getCached<PredictiveSearchData>(key);
if (cached) return cached; if (cached) return cached;
// 2025-01: predictiveSearch sections are plain lists, not connections
const data = await gql<{ const data = await gql<{
predictiveSearch: { predictiveSearch: {
products: GqlProduct[]; products: GqlProduct[];
@@ -638,7 +785,7 @@ export async function getPredictiveSearch(
if (!data?.predictiveSearch) if (!data?.predictiveSearch)
return getStale<PredictiveSearchData>(key) ?? null; return getStale<PredictiveSearchData>(key) ?? null;
const shaped: PredictiveSearchData = { const shaped: PredictiveSearchData = {
products: (data.predictiveSearch.products ?? []).map(shapeProduct), products: shapeProducts(data.predictiveSearch.products ?? []),
collections: (data.predictiveSearch.collections ?? []).map((c) => ({ collections: (data.predictiveSearch.collections ?? []).map((c) => ({
handle: c.handle, handle: c.handle,
title: c.title, title: c.title,