[object Object]

← back to Designer Wallcoverings

Add fix-double-encoded-metafields.js: dry-run-safe repair for {type,value} blob leak in PDP specs

bcebc43274c1624c691a4720712367bfe2052636 · 2026-06-24 15:52:21 -0700 · Steve Abrams

Files touched

Diff

commit bcebc43274c1624c691a4720712367bfe2052636
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 15:52:21 2026 -0700

    Add fix-double-encoded-metafields.js: dry-run-safe repair for {type,value} blob leak in PDP specs
---
 shopify/scripts/fix-double-encoded-metafields.js | 111 +++++++++++++++++++++++
 1 file changed, 111 insertions(+)

diff --git a/shopify/scripts/fix-double-encoded-metafields.js b/shopify/scripts/fix-double-encoded-metafields.js
new file mode 100644
index 00000000..36b5a448
--- /dev/null
+++ b/shopify/scripts/fix-double-encoded-metafields.js
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+/**
+ * fix-double-encoded-metafields.js
+ *
+ * Repairs metafields whose VALUE was double-encoded as a whole Shopify metafield
+ * object, e.g.  {"type":"single_line_text_field","value":"Nonwoven"}  stored as the
+ * string value of custom.material / dwc.contents (and any sibling the same bad import
+ * touched). The corruption leaks raw JSON onto the live PDP "Material"/"Contents" spec.
+ *
+ * The correct value is the inner .value of the blob already stored, so the repair is
+ * deterministic, needs no external lookup, and is reversible.
+ *
+ * SAFE BY DEFAULT — dry-run unless you pass --apply.
+ *   node fix-double-encoded-metafields.js                 # dry-run, scans sku:DWQW*
+ *   node fix-double-encoded-metafields.js --query 'sku:DWQW*'
+ *   node fix-double-encoded-metafields.js --apply         # actually writes (GATED)
+ *
+ * Cost: Shopify Admin API = $0 (free, rate-limited). Local node = $0 (local).
+ */
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-07';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_ADMIN_API_TOKEN;
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const qIdx = args.indexOf('--query');
+const QUERY = qIdx >= 0 ? args[qIdx + 1] : 'sku:DWQW*';
+
+if (!TOKEN) { console.error('Missing SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+function isBlob(v) {
+  return typeof v === 'string' && v.trim().startsWith('{') && v.includes('"value"');
+}
+function unwrap(v) {
+  try { const o = JSON.parse(v.trim()); if (o && typeof o.value === 'string') return o.value; } catch (_) {}
+  return null;
+}
+
+async function gql(query, variables) {
+  const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const j = await res.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors));
+  return j;
+}
+
+const PRODUCTS_Q = `query($q:String!,$cursor:String){
+  products(first:50, query:$q, after:$cursor){
+    pageInfo{ hasNextPage endCursor }
+    edges{ node{ id handle metafields(first:60){ edges{ node{ namespace key type value } } } } }
+  }
+}`;
+
+const SET_M = `mutation($mf:[MetafieldsSetInput!]!){
+  metafieldsSet(metafields:$mf){ metafields{ id } userErrors{ field message } }
+}`;
+
+async function run() {
+  let cursor = null, scanned = 0, productsWithFix = 0, fieldsFixed = 0;
+  const examples = [];
+  const pending = []; // {ownerId, namespace, key, type, value}
+
+  console.log(`${APPLY ? 'APPLY' : 'DRY-RUN'} | query="${QUERY}" | store=${STORE}\n`);
+
+  do {
+    const r = await gql(PRODUCTS_Q, { q: QUERY, cursor });
+    const conn = r.data.products;
+    for (const { node } of conn.edges) {
+      scanned++;
+      let hit = false;
+      for (const { node: mf } of node.metafields.edges) {
+        if (isBlob(mf.value)) {
+          const clean = unwrap(mf.value);
+          if (clean == null) continue; // not the {type,value} shape
+          hit = true; fieldsFixed++;
+          pending.push({ ownerId: node.id, namespace: mf.namespace, key: mf.key, type: mf.type, value: clean });
+          if (examples.length < 8) examples.push(`${node.handle}  ${mf.namespace}.${mf.key}: ${mf.value.slice(0,55)} -> "${clean}"`);
+        }
+      }
+      if (hit) productsWithFix++;
+    }
+    cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
+    process.stdout.write(`\r  scanned ${scanned} products, ${fieldsFixed} corrupt fields found...`);
+  } while (cursor);
+
+  console.log(`\n\nSummary: ${productsWithFix} products affected, ${fieldsFixed} metafields to repair.`);
+  console.log('Examples:'); examples.forEach(e => console.log('  ' + e));
+
+  if (!APPLY) {
+    console.log('\nDRY-RUN — nothing written. Re-run with --apply to repair (GATED: customer-facing bulk write).');
+    return;
+  }
+
+  console.log('\nApplying repairs (batches of 25)...');
+  let written = 0;
+  for (let i = 0; i < pending.length; i += 25) {
+    const batch = pending.slice(i, i + 25);
+    const r = await gql(SET_M, { mf: batch });
+    const errs = r.data.metafieldsSet.userErrors;
+    if (errs && errs.length) console.error('  userErrors:', JSON.stringify(errs));
+    written += r.data.metafieldsSet.metafields.length;
+    process.stdout.write(`\r  written ${written}/${pending.length}...`);
+    await new Promise(res => setTimeout(res, 300)); // gentle throttle
+  }
+  console.log(`\nDone. ${written} metafields repaired.`);
+}
+
+run().catch(e => { console.error('\nFATAL', e); process.exit(1); });

← b25933f1 Fix double-encoded metafield leak: unwrap {type,value} blobs  ·  back to Designer Wallcoverings  ·  auto-save: 2026-06-24T15:58:04 (4 files) — shopify/scripts/c 3bf61e37 →