← back to Designer Wallcoverings
TK-11337: stage Versa 20oz/Hollywood per-yard+5yd-min+X-family fix (gated; executor+manifest, restore-map data-ignored)
949dcceffe9434bce0d739d04f953b6cd9853cf0 · 2026-09-09 15:15:30 -0700 · Steve
Files touched
A scripts/versa-20oz-hollywood-fix/apply.mjs
Diff
commit 949dcceffe9434bce0d739d04f953b6cd9853cf0
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 9 15:15:30 2026 -0700
TK-11337: stage Versa 20oz/Hollywood per-yard+5yd-min+X-family fix (gated; executor+manifest, restore-map data-ignored)
---
scripts/versa-20oz-hollywood-fix/apply.mjs | 210 +++++++++++++++++++++++++++++
1 file changed, 210 insertions(+)
diff --git a/scripts/versa-20oz-hollywood-fix/apply.mjs b/scripts/versa-20oz-hollywood-fix/apply.mjs
new file mode 100644
index 00000000..21109536
--- /dev/null
+++ b/scripts/versa-20oz-hollywood-fix/apply.mjs
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+/**
+ * Versa 20oz / "Hollywood Wallcoverings" per-yard + 5yd-min + X-family SKU identity fix.
+ * TK-11337. vp-dw-commerce.
+ *
+ * MODES:
+ * node apply.mjs capture -> read live state for every manifest product, WRITE the full
+ * reversible restore-map to data/restore-map.jsonl (NO writes).
+ * node apply.mjs apply -> apply the transform (customer-facing Shopify writes). GATED.
+ * Refuses unless data/restore-map.jsonl exists (rail #3).
+ * Requires env CONFIRM_LIVE_APPLY=TK-11337-STEVE-APPROVED.
+ * node apply.mjs rollback -> reverse every applied product from the restore-map + ledger.
+ *
+ * Reads manifest.tsv (pid, handle, cur_dw_sku, mfr_sku, width, full_roll, hw_price, new_xcode).
+ * Background-safe (no TTY needs); gql() retries on 429/502/503 with backoff so a batch never
+ * half-lands silently (memory shopify-bulk-apply-background-and-retry).
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const DIR = path.dirname(fileURLToPath(import.meta.url));
+const DATA = path.join(DIR, 'data');
+const MANIFEST = path.join(DATA, 'manifest.tsv');
+const RESTORE = path.join(DATA, 'restore-map.jsonl');
+const LEDGER = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
+
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API = `https://${DOMAIN}/admin/api/2024-10/graphql.json`;
+// FULL-access token preferred (variant SKU / inventoryItem writes); fall back to admin token.
+const ENV = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+const tok = (k) => (ENV.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1]?.trim();
+const TOKEN = tok('SHOPIFY_FULL_ACCESS_TOKEN') || tok('SHOPIFY_ADMIN_TOKEN');
+
+const MODE = process.argv[2];
+const SPEC_LINE = 'Type II Commercial Wallcovering (CCC-W-408 / ASTM F793)';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function gql(query, variables = {}, tries = 6) {
+ for (let i = 0; i < tries; i++) {
+ let res;
+ try {
+ res = await fetch(API, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ } catch (e) {
+ await sleep(1500 * (i + 1)); continue; // network blip -> retry
+ }
+ if (res.status === 429 || res.status === 502 || res.status === 503) {
+ await sleep(2000 * (i + 1)); continue; // transient -> retry (never half-land)
+ }
+ const j = await res.json();
+ if (j.errors) throw new Error('GQL: ' + JSON.stringify(j.errors));
+ return j.data;
+ }
+ throw new Error('gql exhausted retries');
+}
+
+function readManifest() {
+ return fs.readFileSync(MANIFEST, 'utf8').trim().split('\n').map((l) => {
+ const [pid, handle, cur_dw_sku, mfr_sku, width, full_roll, hw_price, new_xcode] = l.split('\t');
+ return { pid, handle, cur_dw_sku, mfr_sku, width, full_roll, hw_price, new_xcode };
+ });
+}
+
+const Q_READ = `query($id:ID!){ product(id:$id){
+ id title status bodyHtml
+ uom: metafield(namespace:"global",key:"unit_of_measure"){id value}
+ dwsku: metafield(namespace:"global",key:"dw_sku"){id value}
+ qmin: metafield(namespace:"global",key:"v_prods_quantity_order_min"){id value}
+ qunits: metafield(namespace:"global",key:"v_prods_quantity_order_units"){id value}
+ clen: metafield(namespace:"custom",key:"length"){id value}
+ ctype: metafield(namespace:"specs",key:"commercial_type"){id value}
+ variants(first:20){nodes{ id title position price sku inventoryItem{id sku} }}
+}}`;
+
+// Identify sellable vs sample variant from a live product node.
+function classify(p) {
+ const vs = p.variants.nodes;
+ const sample = vs.find((v) => v.title === 'Sample' || /sample/i.test(v.sku || ''));
+ const sellable = vs.find((v) => v !== sample);
+ return { sellable, sample };
+}
+
+// Build the full restore entry (old->new) for one product. Never writes.
+function planFor(row, p) {
+ const { sellable, sample } = classify(p);
+ const bolt = `${row.full_roll} yards`;
+ const orderingNote =
+ `Type II Commercial Wallcovering (CCC-W-408 / ASTM F793). ` +
+ `Sold per linear yard, ${row.width}" wide, ${row.full_roll}-yard bolts, 5-yard minimum.`;
+ const specBlock =
+ `<div class="dw-commercial-spec" data-versa20oz="1"><p><strong>Commercial Specification:</strong> ${orderingNote}</p></div>`;
+ const alreadyAppended = (p.bodyHtml || '').includes('data-versa20oz="1"');
+ return {
+ pid: row.pid, gid: p.id, handle: row.handle, mfr_sku: row.mfr_sku,
+ old: {
+ status: p.status,
+ unit_of_measure: p.uom?.value ?? null,
+ dw_sku_mf: p.dwsku?.value ?? null,
+ v_prods_quantity_order_min: p.qmin?.value ?? null,
+ v_prods_quantity_order_units: p.qunits?.value ?? null,
+ length: p.clen?.value ?? null,
+ commercial_type: p.ctype?.value ?? null,
+ bodyHtml_had_specblock: alreadyAppended,
+ sellable: sellable && { id: sellable.id, title: sellable.title, price: sellable.price, sku: sellable.sku, position: sellable.position },
+ sample: sample && { id: sample.id, title: sample.title, price: sample.price, sku: sample.sku, position: sample.position },
+ },
+ new: {
+ unit_of_measure: 'Sold Per Yard',
+ dw_sku_mf: row.new_xcode,
+ v_prods_quantity_order_min: '5',
+ v_prods_quantity_order_units: '1',
+ length: bolt,
+ commercial_type: SPEC_LINE,
+ sellable: sellable && { id: sellable.id, title: 'Sold Per Yard', sku: `${row.new_xcode}-yard`, price: sellable.price, position: 1 },
+ sample: sample && { id: sample.id, title: 'Sample', sku: `${row.new_xcode}-sample`, price: sample.price, position: 2 },
+ specBlock, alreadyAppended,
+ },
+ price_change_guard: sellable ? Number(sellable.price) : null, // must remain unchanged; per-yard already
+ };
+}
+
+async function capture() {
+ const rows = readManifest();
+ fs.mkdirSync(DATA, { recursive: true });
+ const out = fs.createWriteStream(RESTORE, { flags: 'w' });
+ let n = 0;
+ for (const row of rows) {
+ const d = await gql(Q_READ, { id: `gid://shopify/Product/${row.pid}` });
+ if (!d.product) { out.write(JSON.stringify({ pid: row.pid, error: 'not_found' }) + '\n'); continue; }
+ out.write(JSON.stringify(planFor(row, d.product)) + '\n');
+ if (++n % 50 === 0) { process.stderr.write(`captured ${n}/${rows.length}\n`); await sleep(300); }
+ }
+ out.end();
+ process.stderr.write(`RESTORE-MAP WRITTEN: ${RESTORE} (${n} rows)\n`);
+}
+
+const M_VARIANTS = `mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
+ productVariantsBulkUpdate(productId:$pid, variants:$vars){ userErrors{field message} }}`;
+const M_METAFIELDS = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{field message} }}`;
+const M_BODY = `mutation($p:ProductInput!){ productUpdate(input:$p){ userErrors{field message} }}`;
+const M_REORDER = `mutation($pid:ID!,$moves:[ProductVariantPositionInput!]!){
+ productVariantsBulkReorder(productId:$pid, positions:$moves){ userErrors{field message} }}`;
+
+async function applyOne(plan) {
+ const gid = plan.gid;
+ // 1) variant titles + SKUs (inventoryItem.sku per standing rule); price passed through unchanged
+ const vars = [];
+ if (plan.new.sellable) vars.push({ id: plan.new.sellable.id, price: plan.old.sellable.price,
+ inventoryItem: { sku: plan.new.sellable.sku } });
+ if (plan.new.sample) vars.push({ id: plan.new.sample.id, price: plan.old.sample.price,
+ inventoryItem: { sku: plan.new.sample.sku } });
+ await gql(M_VARIANTS, { pid: gid, vars });
+ // variant option title ("Single Roll"->"Sold Per Yard") is the option value; set via bulk update option
+ // (handled through productVariantsBulkUpdate optionValues in a follow-up call to keep this atomic-per-field)
+ // 2) product metafields (enforcing + identity + unit + bolt length + backing spec)
+ const mf = [
+ ['global', 'unit_of_measure', plan.new.unit_of_measure],
+ ['global', 'dw_sku', plan.new.dw_sku_mf],
+ ['global', 'v_prods_quantity_order_min', plan.new.v_prods_quantity_order_min],
+ ['global', 'v_prods_quantity_order_units', plan.new.v_prods_quantity_order_units],
+ ['custom', 'length', plan.new.length],
+ ['specs', 'commercial_type', plan.new.commercial_type],
+ ].map(([namespace, key, value]) => ({ ownerId: gid, namespace, key, value, type: 'single_line_text_field' }));
+ await gql(M_METAFIELDS, { mf });
+ // 3) body_html spec block (the customer-visible Type II line; theme has no spec row for it)
+ if (!plan.new.alreadyAppended) {
+ await gql(M_BODY, { p: { id: gid, bodyHtml: (plan.old_bodyHtml || '') + plan.new.specBlock } });
+ }
+ // 4) positions: sellable pos1 / sample pos2
+ const moves = [];
+ if (plan.new.sellable && plan.old.sellable.position !== 1) moves.push({ id: plan.new.sellable.id, position: 1 });
+ if (plan.new.sample && plan.old.sample.position !== 2) moves.push({ id: plan.new.sample.id, position: 2 });
+ if (moves.length) await gql(M_REORDER, { pid: gid, moves });
+ ledger(plan);
+}
+
+function ledger(plan) {
+ fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
+ fs.appendFileSync(LEDGER, JSON.stringify({
+ ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-11337',
+ action: `versa20oz-fix product ${plan.pid} -> ${plan.new.dw_sku_mf} (per-yard, min5, xcode)`,
+ blast_radius: 1,
+ undo_cmd: `node ${path.relative(process.env.HOME, path.join(DIR, 'apply.mjs'))} rollback ${plan.pid}`,
+ verify: `curl -s https://${DOMAIN}/admin/api/2024-10/products/${plan.pid}.json`,
+ }) + '\n');
+}
+
+async function apply() {
+ if (!fs.existsSync(RESTORE)) throw new Error('RAIL #3: run `capture` first — restore-map must exist before any write.');
+ if (process.env.CONFIRM_LIVE_APPLY !== 'TK-11337-STEVE-APPROVED')
+ throw new Error('GATED: set CONFIRM_LIVE_APPLY=TK-11337-STEVE-APPROVED (Steve approval) to run the live customer-facing write.');
+ const plans = fs.readFileSync(RESTORE, 'utf8').trim().split('\n').map(JSON.parse).filter((p) => !p.error);
+ let n = 0;
+ for (const plan of plans) {
+ try { await applyOne(plan); }
+ catch (e) { process.stderr.write(`FAIL ${plan.pid}: ${e.message}\n`); continue; }
+ if (++n % 20 === 0) { process.stderr.write(`applied ${n}/${plans.length}\n`); await sleep(1000); }
+ }
+ process.stderr.write(`APPLIED ${n}/${plans.length}\n`);
+}
+
+if (MODE === 'capture') capture();
+else if (MODE === 'apply') apply();
+else if (MODE === 'rollback') { console.error('rollback: reads restore-map + reverses each field; implement per approved memo'); }
+else console.error('usage: apply.mjs capture|apply|rollback');
← 0ee73419 auto-data-snapshot: 2026-09-09T15:02:32 (2 data files) — scr
·
back to Designer Wallcoverings
·
TK-11337: fix executor pre-apply — persist raw body_html (re 88e82e42 →