[object Object]

← back to Tk 10965 Zero Price Analysis

paginate zero-price canary variants

f46558ffddd97872f5a08df5833cb1693d0debd3 · 2026-08-30 17:41:20 -0700 · Steve Abrams

Files touched

Diff

commit f46558ffddd97872f5a08df5833cb1693d0debd3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Aug 30 17:41:20 2026 -0700

    paginate zero-price canary variants
---
 tk10964-canary-guard/check.mjs | 24 ++++++++++++++++++++++--
 tk10964-canary-guard/test.mjs  | 19 +++++++++++++++++--
 2 files changed, 39 insertions(+), 4 deletions(-)

diff --git a/tk10964-canary-guard/check.mjs b/tk10964-canary-guard/check.mjs
index 8a93b81..beff799 100644
--- a/tk10964-canary-guard/check.mjs
+++ b/tk10964-canary-guard/check.mjs
@@ -62,6 +62,26 @@ export function exitCodeFor(result) {
   return 3;
 }
 
+// Shopify can return products with more than 20 variants. The search query keeps
+// its small first page for efficiency, then only products that advertise another
+// variant page incur follow-up reads. This prevents a zero-price variant at #21+
+// from becoming a silent false negative without issuing one query per product.
+export async function hydrateVariantPages(products, gql) {
+  for (const product of products) {
+    let pageInfo = product.variants?.pageInfo;
+    let after = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
+    while (after) {
+      const data = await gql(`query($id:ID!,$after:String!){product(id:$id){variants(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{title price availableForSale inventoryPolicy inventoryQuantity inventoryItem{tracked}}}}}`, { id: product.id, after });
+      const page = data.product?.variants;
+      if (!page) throw new Error(`missing variant page for ${product.id}`);
+      product.variants.nodes.push(...page.nodes);
+      pageInfo = page.pageInfo;
+      after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
+    }
+  }
+  return products;
+}
+
 async function liveProducts() {
   const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
   const val = key => (env.match(new RegExp(`^${key}=(.*)$`, 'm')) || [])[1]?.trim();
@@ -81,12 +101,12 @@ async function liveProducts() {
   for (const q of SEARCHES) {
     let after = null;
     do {
-      const data = await gql(`query($q:String!,$after:String){products(first:100,query:$q,after:$after){pageInfo{hasNextPage endCursor} nodes{id title vendor tags variants(first:20){nodes{title price availableForSale inventoryPolicy inventoryQuantity inventoryItem{tracked}}}}}}`, { q, after });
+      const data = await gql(`query($q:String!,$after:String){products(first:100,query:$q,after:$after){pageInfo{hasNextPage endCursor} nodes{id title vendor tags variants(first:20){pageInfo{hasNextPage endCursor} nodes{title price availableForSale inventoryPolicy inventoryQuantity inventoryItem{tracked}}}}}}`, { q, after });
       products.push(...data.products.nodes);
       after = data.products.pageInfo.hasNextPage ? data.products.pageInfo.endCursor : null;
     } while (after);
   }
-  return products;
+  return hydrateVariantPages(products, gql);
 }
 
 async function main() {
diff --git a/tk10964-canary-guard/test.mjs b/tk10964-canary-guard/test.mjs
index 6d316ae..44e403f 100644
--- a/tk10964-canary-guard/test.mjs
+++ b/tk10964-canary-guard/test.mjs
@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 import assert from 'node:assert/strict';
 import fs from 'node:fs';
-import { SEARCHES, exitCodeFor, inScope, summarize } from './check.mjs';
+import { SEARCHES, exitCodeFor, hydrateVariantPages, inScope, summarize } from './check.mjs';
 
 const products = JSON.parse(fs.readFileSync(new URL('./fixtures/mixed.json', import.meta.url)));
 const result = summarize(products, '2026-08-30T00:00:00.000Z');
@@ -17,4 +17,19 @@ assert.equal(result.verdict, 'WARN');
 assert.equal(exitCodeFor({ verdict: 'PASS' }), 0);
 assert.equal(exitCodeFor({ verdict: 'WARN' }), 2);
 assert.equal(exitCodeFor({ verdict: 'FAIL' }), 3);
-console.log('PASS: broad searches, Fentucci blind spot, dedupe, defect predicate, and controls');
+
+const firstTwenty = Array.from({ length: 20 }, (_, index) => ({ title: `Roll ${index + 1}`, price: '100', availableForSale: true }));
+const variant21Product = {
+  id: 'gid://shopify/Product/21', title: 'Variant 21 boundary', vendor: 'Fentucci Naturals', tags: [],
+  variants: { nodes: firstTwenty, pageInfo: { hasNextPage: true, endCursor: 'cursor-20' } }
+};
+let pageCalls = 0;
+await hydrateVariantPages([variant21Product], async (_query, variables) => {
+  pageCalls++;
+  assert.deepEqual(variables, { id: variant21Product.id, after: 'cursor-20' });
+  return { product: { variants: { pageInfo: { hasNextPage: false, endCursor: 'cursor-21' }, nodes: [{ title: 'Roll 21', price: '0', availableForSale: true, inventoryPolicy: 'DENY', inventoryQuantity: 2026, inventoryItem: { tracked: true } }] } } };
+});
+const variant21Result = summarize([variant21Product]);
+assert.equal(pageCalls, 1, 'only products with another variant page should incur a follow-up read');
+assert.equal(variant21Result.zero_price_orderable, 1, 'bad variant #21 must not be a false negative');
+console.log('PASS: broad searches, Fentucci blind spot, dedupe, variant pagination, defect predicate, and controls');

← 6696093 snapshot concurrent zero-price remediation evidence  ·  back to Tk 10965 Zero Price Analysis  ·  feat: Fix B prevention guard (inventory-stamp invariant) + t 0bf5296 →