← 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
A shopify/scripts/fix-double-encoded-metafields.js
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 →