← back to Tk 10965 Zero Price Analysis
paginate zero-price canary variants
f46558ffddd97872f5a08df5833cb1693d0debd3 · 2026-08-30 17:41:20 -0700 · Steve Abrams
Files touched
M tk10964-canary-guard/check.mjsM tk10964-canary-guard/test.mjs
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 →