[object Object]

← back to Designerwallcoverings

TK-12227: palm-grove promote/retire pair executor + scoped rollback + snapshot

2b2bd120e89fadd865887abde8ae13a29e7093d4 · 2026-09-25 09:33:07 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 2b2bd120e89fadd865887abde8ae13a29e7093d4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 25 09:33:07 2026 -0700

    TK-12227: palm-grove promote/retire pair executor + scoped rollback + snapshot
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---
 scripts/palm-grove-promote/probe.mjs        |   4 +
 scripts/palm-grove-promote/promote-pair.mjs | 233 ++++++++++++++++++++++++++++
 scripts/palm-grove-promote/rollback.mjs     |  41 +++++
 scripts/palm-grove-promote/snapshot.mjs     |  59 +++++++
 4 files changed, 337 insertions(+)

diff --git a/scripts/palm-grove-promote/probe.mjs b/scripts/palm-grove-promote/probe.mjs
new file mode 100644
index 0000000..79f47e6
--- /dev/null
+++ b/scripts/palm-grove-promote/probe.mjs
@@ -0,0 +1,4 @@
+import { gql } from '../lib/shopify.mjs';
+const ids=[7822360084531,7822360182835,7822360215603,7822360248371,7822360281139,7822360313907];
+for (const id of ids){ const d=await gql(`query($id:ID!){product(id:$id){handle descriptionHtml seo{title description} options{id name}}}`,{id:`gid://shopify/Product/${id}`}); console.log(d.product.handle, 'descLen', d.product.descriptionHtml.length, JSON.stringify(d.product.descriptionHtml.slice(0,160)), JSON.stringify(d.product.options)); }
+const p=await gql(`{publications(first:30){nodes{id name}}}`); console.log(JSON.stringify(p.publications.nodes.filter(n=>/Online Store|Google/.test(n.name))));
diff --git a/scripts/palm-grove-promote/promote-pair.mjs b/scripts/palm-grove-promote/promote-pair.mjs
new file mode 100644
index 0000000..6bf16b6
--- /dev/null
+++ b/scripts/palm-grove-promote/promote-pair.mjs
@@ -0,0 +1,233 @@
+// TK-12227 — promote ONE Palm Grove (Malibu) draft to ACTIVE and retire its live twin.
+// Steve-approved live write (2026-09-25, "keep the drafts, retire the live twins").
+//
+// Usage: node promote-pair.mjs <MFR_SKU>            # dry-run: pre-checks + planned ops only
+//        node promote-pair.mjs <MFR_SKU> --apply    # live
+//
+// Order (stop the pair on ANY failed pre-check / step; nothing is written before the
+// restore-map + ledger row exist):
+//   pre-checks -> restore-map + ledger -> draft: image, option rename, roll variant,
+//   reorder, sample weight, validate price, drop Needs-* tags, publish Online Store,
+//   validate 5-field+image+width+mfr+vendor, ACTIVE -> twin: ARCHIVE -> 301 redirect.
+import fs from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { gql, rest } from '../lib/shopify.mjs';
+import { PAIRS, snap } from './snapshot.mjs';
+
+const TICKET = 'TK-12227';
+const ONLINE_STORE = 'gid://shopify/Publication/22208643184';
+const RDIR = process.env.HOME + '/.claude/yolo-queue/executed-reversible';
+const LOGEXEC = RDIR + '/log-exec.mjs';
+const HERE = new URL('.', import.meta.url).pathname;
+
+const mfr = process.argv[2];
+const APPLY = process.argv.includes('--apply');
+const pair = PAIRS.find(p => p.mfr === mfr);
+if (!pair) { console.error('unknown mfr', mfr); process.exit(2); }
+
+const gidP = id => `gid://shopify/Product/${id}`;
+const mf = (p, k) => p.metafields.nodes.find(m => `${m.namespace}.${m.key}` === k)?.value;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const die = (msg, code = 3) => { console.error(`STOP ${mfr}: ${msg}`); process.exit(code); };
+
+// ---------- live pre-checks ----------
+const draft = await snap(pair.draft);
+const twin = await snap(pair.twin);
+if (!draft) die('draft not found');
+if (!twin) die('twin not found');
+const fails = [];
+if (draft.status !== 'DRAFT') fails.push(`draft status ${draft.status} != DRAFT`);
+if (draft.variants.nodes.length !== 1 || !/sample/i.test(draft.variants.nodes[0].title)) fails.push('draft is not sample-only');
+if (twin.status !== 'ACTIVE') fails.push(`twin status ${twin.status} != ACTIVE`);
+const twinRoll = twin.variants.nodes.find(v => v.title === 'Sold Per Single Roll');
+const twinSample = twin.variants.nodes.find(v => /sample/i.test(v.title));
+if (!twinRoll) fails.push('twin has no "Sold Per Single Roll" variant');
+if (!twinSample) fails.push('twin has no Sample variant');
+if (!Array.isArray(twin.openOrders) || twin.openOrders.length) fails.push(`twin open orders: ${JSON.stringify(twin.openOrders)}`);
+if (!Array.isArray(draft.openOrders) || draft.openOrders.length) fails.push(`draft open orders: ${JSON.stringify(draft.openOrders)}`);
+const priceDw = mf(draft, 'custom.price_dw');
+if (!priceDw || !(Number(priceDw) > 0)) fails.push(`missing/zero custom.price_dw (${priceDw})`);
+// cross-check vs wallquest_catalog (never invent a price)
+let catPrice = null;
+try {
+  catPrice = execFileSync('psql', ['host=/tmp dbname=dw_unified', '-At', '-v', 'ON_ERROR_STOP=1', '-c',
+    `select price_dw from wallquest_catalog where mfr_sku='${mfr}' and shopify_product_id='${pair.draft}'`], { encoding: 'utf8' }).trim();
+} catch (e) { fails.push('wallquest_catalog price lookup failed'); }
+if (catPrice && Number(catPrice).toFixed(2) !== Number(priceDw).toFixed(2)) fails.push(`price_dw ${priceDw} != wallquest_catalog ${catPrice}`);
+if (mf(draft, 'custom.manufacturer_sku') !== mfr) fails.push(`draft manufacturer_sku ${mf(draft, 'custom.manufacturer_sku')} != ${mfr}`);
+if (!mf(draft, 'custom.width')) fails.push('draft has no custom.width');
+if (!draft.vendor) fails.push('draft has no vendor');
+if (!twin.featuredImage?.url || !twin.featuredImage.url.includes(`/${mfr}_`)) fails.push(`twin featured image not ${mfr}_*.jpg (${twin.featuredImage?.url})`);
+if (Array.isArray(twin.redirects) && twin.redirects.length) fails.push(`twin handle already has redirect(s) ${JSON.stringify(twin.redirects)}`);
+if (!Array.isArray(twin.redirects)) fails.push('could not read existing redirects');
+if (draft.options.length !== 1) fails.push('draft has != 1 option');
+const sampleSku = draft.variants.nodes[0]?.sku || '';
+const rollSku = sampleSku.replace(/-Sample$/i, '');
+if (!/^DWQW-\d+$/.test(rollSku)) fails.push(`cannot derive roll sku from ${sampleSku}`);
+if (/wallquest/i.test(draft.title + draft.vendor)) fails.push('WallQuest in draft title/vendor');
+console.log(`[${mfr}] draft ${draft.handle} ${draft.status} | twin ${twin.handle} ${twin.status} | price_dw ${priceDw} cat ${catPrice} | roll sku ${rollSku}`);
+if (fails.length) die('pre-check failed: ' + fails.join('; '));
+console.log(`[${mfr}] pre-checks PASS`);
+
+const plan = {
+  image: { originalSource: twin.featuredImage.url, alt: draft.title },
+  optionRename: { id: draft.options[0].id, from: draft.options[0].name, to: twinRoll.selectedOptions[0].name },
+  rollVariant: {
+    title: twinRoll.title, sku: rollSku, price: Number(priceDw).toFixed(2), taxable: twinRoll.taxable,
+    inventoryPolicy: twinRoll.inventoryPolicy, tracked: twinRoll.inventoryItem.tracked,
+    requiresShipping: twinRoll.inventoryItem.requiresShipping, weight: twinRoll.inventoryItem.measurement.weight,
+    qty: twinRoll.inventoryQuantity, metafields: twinRoll.metafields.nodes,
+  },
+  sampleWeight: { id: draft.variants.nodes[0].id, from: draft.variants.nodes[0].inventoryItem.measurement.weight, to: twinSample.inventoryItem.measurement.weight },
+  tagsRemove: draft.tags.filter(t => ['Needs-Price', 'Needs-Bolt-Variant'].includes(t)),
+  publish: ONLINE_STORE,
+  redirect: { path: `/products/${twin.handle}`, target: `/products/${draft.handle}` },
+};
+console.log(JSON.stringify(plan, null, 1));
+if (!APPLY) { console.log(`[${mfr}] DRY-RUN — no writes`); process.exit(0); }
+
+// ---------- restore-map + ledger BEFORE any write ----------
+const ts = new Date().toISOString().replace(/[:.]/g, '').replace('Z', 'Z');
+const mapPath = `${RDIR}/palm-grove-${TICKET}-${mfr}-${ts}.json`;
+const rmap = { ticket: TICKET, mfr, createdAt: new Date().toISOString(), pre: { draft, twin }, plan, done: {} };
+const save = () => fs.writeFileSync(mapPath, JSON.stringify(rmap, null, 2));
+save();
+const undo = `cd ${HERE} && node rollback.mjs ${mapPath} --apply`;
+execFileSync('node', [LOGEXEC, '--agent', 'vp-dw-commerce', '--ticket', TICKET,
+  '--action', `Palm Grove ${mfr}: promote draft ${pair.draft} (${draft.handle}) to ACTIVE w/ image + roll $${plan.rollVariant.price}; archive twin ${pair.twin} (${twin.handle}); 301 ${plan.redirect.path} -> ${plan.redirect.target}. Steve-approved live write.`,
+  '--blast', '2', '--undo', undo,
+  '--verify', `curl -s https://www.designerwallcoverings.com/products/${draft.handle}.js ; curl -sI https://www.designerwallcoverings.com${plan.redirect.path}`], { stdio: 'inherit' });
+console.log(`[${mfr}] restore-map ${mapPath}`);
+
+const must = (d, path, label) => {
+  if (d?.__err) die(`${label}: ${JSON.stringify(d.__err).slice(0, 400)}`, 4);
+  const node = path.split('.').reduce((o, k) => o?.[k], d);
+  const errs = node?.userErrors || node?.mediaUserErrors || [];
+  if (errs.length) die(`${label}: ${JSON.stringify(errs).slice(0, 400)}`, 4);
+  return node;
+};
+
+// 1. image (copy of twin's featured file)
+{
+  const r = must(await gql(`mutation($pid:ID!,$m:[CreateMediaInput!]!){ productCreateMedia(productId:$pid, media:$m){ media{ id status } mediaUserErrors{ field message code } } }`,
+    { pid: gidP(pair.draft), m: [{ originalSource: plan.image.originalSource, mediaContentType: 'IMAGE', alt: plan.image.alt }] }), 'productCreateMedia', 'productCreateMedia');
+  const mid = r.media[0].id; rmap.done.mediaIds = [mid]; save();
+  let status = r.media[0].status;
+  for (let i = 0; i < 40 && !['READY', 'FAILED'].includes(status); i++) {
+    await sleep(3000);
+    const q = await gql(`query($id:ID!){ node(id:$id){ ... on MediaImage{ status mediaErrors{ code message } image{ url } } } }`, { id: mid });
+    status = q?.node?.status;
+    if (status === 'FAILED') console.error(JSON.stringify(q.node.mediaErrors));
+  }
+  if (status !== 'READY') die(`image copy did not become READY (status=${status}) — run rollback: ${undo}`, 5);
+  console.log(`[${mfr}] image READY ${mid}`);
+}
+
+// 2. option rename Title -> Size
+if (plan.optionRename.from !== plan.optionRename.to) {
+  must(await gql(`mutation($pid:ID!,$o:OptionUpdateInput!){ productOptionUpdate(productId:$pid, option:$o){ product{ id } userErrors{ field message code } } }`,
+    { pid: gidP(pair.draft), o: { id: plan.optionRename.id, name: plan.optionRename.to } }), 'productOptionUpdate', 'productOptionUpdate');
+  rmap.done.optionRenamed = true; save();
+  console.log(`[${mfr}] option ${plan.optionRename.from} -> ${plan.optionRename.to}`);
+}
+
+// 3. roll variant (clone of twin roll shape). $0 guard: price must be > 0 before any inventory.
+{
+  const rv = plan.rollVariant;
+  if (!(Number(rv.price) > 0)) die('roll price not > 0 — refusing (no $0 variant, no inventory)');
+  const loc = (await gql(`{locations(first:5){nodes{id}}}`)).locations.nodes[0].id;
+  const input = {
+    optionValues: [{ optionName: plan.optionRename.to, name: rv.title }],
+    price: rv.price, taxable: rv.taxable, inventoryPolicy: rv.inventoryPolicy,
+    inventoryItem: { sku: rv.sku, tracked: rv.tracked, requiresShipping: rv.requiresShipping, measurement: { weight: { unit: rv.weight.unit, value: rv.weight.value } } },
+    metafields: rv.metafields.map(m => ({ namespace: m.namespace, key: m.key, type: m.type, value: m.value })),
+  };
+  if (rv.tracked && rv.qty > 0) input.inventoryQuantities = [{ availableQuantity: rv.qty, locationId: loc }];
+  const r = must(await gql(`mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){ productVariantsBulkCreate(productId:$pid, variants:$v, strategy:PRESERVE_STANDALONE_VARIANT){ productVariants{ id title price sku } userErrors{ field message code } } }`,
+    { pid: gidP(pair.draft), v: [input] }), 'productVariantsBulkCreate', 'productVariantsBulkCreate');
+  rmap.done.rollVariantId = r.productVariants[0].id; save();
+  console.log(`[${mfr}] roll variant ${JSON.stringify(r.productVariants[0])}`);
+}
+
+// 4. reorder: roll position 1, sample position 2 (landing page w/o ?variant= renders position 1)
+must(await gql(`mutation($pid:ID!,$p:[ProductVariantPositionInput!]!){ productVariantsBulkReorder(productId:$pid, positions:$p){ product{ id } userErrors{ field message code } } }`,
+  { pid: gidP(pair.draft), p: [{ id: rmap.done.rollVariantId, position: 1 }, { id: plan.sampleWeight.id, position: 2 }] }), 'productVariantsBulkReorder', 'productVariantsBulkReorder');
+rmap.done.reordered = true; save();
+
+// 5. sample weight 0 -> twin sample weight (weight go-live rule)
+if (Number(plan.sampleWeight.from?.value || 0) !== Number(plan.sampleWeight.to.value)) {
+  must(await gql(`mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid, variants:$v){ productVariants{ id } userErrors{ field message code } } }`,
+    { pid: gidP(pair.draft), v: [{ id: plan.sampleWeight.id, inventoryItem: { measurement: { weight: { unit: plan.sampleWeight.to.unit, value: plan.sampleWeight.to.value } } } }] }), 'productVariantsBulkUpdate', 'sample weight');
+  rmap.done.sampleWeightSet = true; save();
+}
+
+// 6. verify roll price is real before dropping Needs-* tags
+{
+  const mid = await snap(pair.draft);
+  const roll = mid.variants.nodes.find(v => v.id === rmap.done.rollVariantId);
+  const samp = mid.variants.nodes.find(v => v.id === plan.sampleWeight.id);
+  if (!roll || roll.price !== plan.rollVariant.price || roll.position !== 1) die(`roll variant check failed ${JSON.stringify(roll)} — rollback: ${undo}`, 6);
+  if (!samp || samp.price !== '4.25' || samp.position !== 2) die(`sample variant check failed ${JSON.stringify(samp)} — rollback: ${undo}`, 6);
+  if (plan.tagsRemove.length) {
+    must(await gql(`mutation($id:ID!,$t:[String!]!){ tagsRemove(id:$id, tags:$t){ node{ id } userErrors{ field message } } }`,
+      { id: gidP(pair.draft), t: plan.tagsRemove }), 'tagsRemove', 'tagsRemove');
+    rmap.done.tagsRemoved = plan.tagsRemove; save();
+  }
+}
+
+// 7. publish to Online Store ONLY (twin's customer-facing channel; no Google & YouTube / external)
+must(await gql(`mutation($id:ID!,$i:[PublicationInput!]!){ publishablePublish(id:$id, input:$i){ userErrors{ field message } } }`,
+  { id: gidP(pair.draft), i: [{ publicationId: ONLINE_STORE }] }), 'publishablePublish', 'publishablePublish');
+rmap.done.publishedOnlineStore = true; save();
+
+// 8. validate go-live gates, then ACTIVE
+{
+  const d = await snap(pair.draft);
+  const f = [];
+  const vs = d.variants.nodes;
+  if (!vs.some(v => /sample/i.test(v.title))) f.push('no sample variant');
+  if (!vs.some(v => !/sample/i.test(v.title))) f.push('no sellable variant');
+  if (vs.some(v => !(Number(v.price) > 0))) f.push('a $0/null price');
+  if (vs.some(v => !(Number(v.inventoryItem.measurement?.weight?.value) > 0))) f.push('a zero-weight variant');
+  if (d.tags.length < 2) f.push('<2 tags');
+  if (!d.featuredImage?.url) f.push('no featured image');
+  if (!mf(d, 'custom.width')) f.push('no width');
+  if (mf(d, 'custom.manufacturer_sku') !== mfr) f.push('mfr sku');
+  if (!d.vendor) f.push('no vendor');
+  if (d.tags.some(t => /^Needs-/.test(t))) f.push('still has Needs-* tag');
+  const pubs = d.resourcePublicationsV2.nodes.filter(x => x.isPublished).map(x => x.publication.name);
+  if (pubs.join() !== 'Online Store') f.push(`publications ${pubs}`);
+  const desc = await gql(`query($id:ID!){ product(id:$id){ descriptionHtml } }`, { id: gidP(pair.draft) });
+  if (!(desc?.product?.descriptionHtml?.length > 20)) f.push('no description');
+  if (f.length) die(`go-live validation failed: ${f.join('; ')} — draft left DRAFT; rollback: ${undo}`, 7);
+  must(await gql(`mutation($i:ProductInput!){ productUpdate(input:$i){ product{ id status } userErrors{ field message } } }`,
+    { i: { id: gidP(pair.draft), status: 'ACTIVE' } }), 'productUpdate', 'draft ACTIVE');
+  rmap.done.draftActivated = true; save();
+  console.log(`[${mfr}] draft ACTIVE`);
+}
+
+// 9. confirm draft public 200 BEFORE retiring the twin
+{
+  let code = 0;
+  for (let i = 0; i < 12 && code !== 200; i++) {
+    await sleep(5000);
+    code = (await fetch(`https://www.designerwallcoverings.com/products/${draft.handle}.js`, { redirect: 'manual' })).status;
+  }
+  if (code !== 200) die(`draft public URL ${code} — twin NOT archived; draft is ACTIVE; investigate or rollback: ${undo}`, 8);
+}
+
+// 10. archive twin
+must(await gql(`mutation($i:ProductInput!){ productUpdate(input:$i){ product{ id status } userErrors{ field message } } }`,
+  { i: { id: gidP(pair.twin), status: 'ARCHIVED' } }), 'productUpdate', 'twin ARCHIVED');
+rmap.done.twinArchived = true; save();
+console.log(`[${mfr}] twin ARCHIVED`);
+
+// 11. 301 twin handle -> draft handle
+{
+  const r = must(await gql(`mutation($r:UrlRedirectInput!){ urlRedirectCreate(urlRedirect:$r){ urlRedirect{ id path target } userErrors{ field message code } } }`,
+    { r: plan.redirect }), 'urlRedirectCreate', 'urlRedirectCreate');
+  rmap.done.redirectId = r.urlRedirect.id; save();
+  console.log(`[${mfr}] redirect ${JSON.stringify(r.urlRedirect)}`);
+}
+rmap.completedAt = new Date().toISOString(); save();
+console.log(`[${mfr}] DONE restore-map=${mapPath}`);
diff --git a/scripts/palm-grove-promote/rollback.mjs b/scripts/palm-grove-promote/rollback.mjs
new file mode 100644
index 0000000..dd0f84a
--- /dev/null
+++ b/scripts/palm-grove-promote/rollback.mjs
@@ -0,0 +1,41 @@
+// TK-12227 — undo ONE Palm Grove pair from its restore-map (scoped: only ids this run recorded).
+// Usage: node rollback.mjs <restore-map.json>          # dry-run (prints ops)
+//        node rollback.mjs <restore-map.json> --apply  # live
+// Reverses in reverse order: delete redirect -> twin status back -> draft status back ->
+// unpublish Online Store -> re-add Needs-* tags -> sample weight back -> delete roll variant ->
+// option name back -> delete created media. Then local dw_unified mirror rows back to pre values.
+import fs from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { gql } from '../lib/shopify.mjs';
+
+const [file] = process.argv.slice(2).filter(a => !a.startsWith('--'));
+const APPLY = process.argv.includes('--apply');
+const m = JSON.parse(fs.readFileSync(file, 'utf8'));
+const { pre, plan, done } = m;
+const D = pre.draft.id, T = pre.twin.id;
+const ops = [];
+if (done.redirectId) ops.push(['urlRedirectDelete', `mutation($id:ID!){ urlRedirectDelete(id:$id){ deletedUrlRedirectId userErrors{ message } } }`, { id: done.redirectId }]);
+if (done.twinArchived) ops.push(['twin status', `mutation($i:ProductInput!){ productUpdate(input:$i){ userErrors{ message } } }`, { i: { id: T, status: pre.twin.status } }]);
+if (done.draftActivated) ops.push(['draft status', `mutation($i:ProductInput!){ productUpdate(input:$i){ userErrors{ message } } }`, { i: { id: D, status: pre.draft.status } }]);
+if (done.publishedOnlineStore) ops.push(['unpublish', `mutation($id:ID!,$i:[PublicationInput!]!){ publishableUnpublish(id:$id, input:$i){ userErrors{ message } } }`, { id: D, i: [{ publicationId: plan.publish }] }]);
+if (done.tagsRemoved?.length) ops.push(['tagsAdd', `mutation($id:ID!,$t:[String!]!){ tagsAdd(id:$id, tags:$t){ userErrors{ message } } }`, { id: D, t: done.tagsRemoved }]);
+if (done.sampleWeightSet) ops.push(['sample weight', `mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid, variants:$v){ userErrors{ message } } }`,
+  { pid: D, v: [{ id: plan.sampleWeight.id, inventoryItem: { measurement: { weight: { unit: plan.sampleWeight.from?.unit || 'POUNDS', value: Number(plan.sampleWeight.from?.value || 0) } } } }] }]);
+if (done.rollVariantId) ops.push(['delete roll', `mutation($pid:ID!,$v:[ID!]!){ productVariantsBulkDelete(productId:$pid, variantsIds:$v){ userErrors{ message } } }`, { pid: D, v: [done.rollVariantId] }]);
+if (done.optionRenamed) ops.push(['option name', `mutation($pid:ID!,$o:OptionUpdateInput!){ productOptionUpdate(productId:$pid, option:$o){ userErrors{ message } } }`, { pid: D, o: { id: plan.optionRename.id, name: plan.optionRename.from } }]);
+if (done.mediaIds?.length) ops.push(['delete media', `mutation($pid:ID!,$ids:[ID!]!){ productDeleteMedia(productId:$pid, mediaIds:$ids){ userErrors{ message } } }`, { pid: D, ids: done.mediaIds }]);
+
+for (const [label, q, v] of ops) {
+  console.log(APPLY ? 'APPLY' : 'DRY  ', label, JSON.stringify(v));
+  if (!APPLY) continue;
+  const r = await gql(q, v);
+  const s = JSON.stringify(r);
+  if (r?.__err || /"message"/.test(s)) console.error('  !!', s.slice(0, 300)); else console.log('  ok');
+}
+if (APPLY && m.mirror?.pre) {
+  for (const row of m.mirror.pre) {
+    execFileSync('psql', ['host=/tmp dbname=dw_unified', '-v', 'ON_ERROR_STOP=1', '-c',
+      `update shopify_products set status='${row.status}' where id=${Number(row.id)}`], { stdio: 'inherit' });
+  }
+}
+console.log(APPLY ? 'rollback applied' : 'dry-run only (add --apply)');
diff --git a/scripts/palm-grove-promote/snapshot.mjs b/scripts/palm-grove-promote/snapshot.mjs
new file mode 100644
index 0000000..0729f68
--- /dev/null
+++ b/scripts/palm-grove-promote/snapshot.mjs
@@ -0,0 +1,59 @@
+// TK-12227 — READ-ONLY full snapshot of the 12 Palm Grove products (6 drafts + 6 twins).
+// Usage: node snapshot.mjs [outfile]
+import fs from 'node:fs';
+import { gql, rest } from '../lib/shopify.mjs';
+
+export const PAIRS = [
+  { mfr: 'LN40201', draft: 7822360084531, twin: 7297256423475 },
+  { mfr: 'LN40202', draft: 7822360182835, twin: 7297256587315 },
+  { mfr: 'LN40204', draft: 7822360215603, twin: 7297256751155 },
+  { mfr: 'LN40208', draft: 7822360248371, twin: 7297256914995 },
+  { mfr: 'LN40212', draft: 7822360281139, twin: 7297257078835 },
+  { mfr: 'LN40218', draft: 7822360313907, twin: 7297257209907 },
+];
+
+const Q = `query($id:ID!){ product(id:$id){
+  id handle title vendor status tags productType templateSuffix onlineStoreUrl
+  options{ id name position values }
+  featuredImage{ id url altText }
+  media(first:20){ nodes{ id mediaContentType alt status ... on MediaImage{ image{ url width height } } } }
+  metafields(first:80){ nodes{ namespace key type value } }
+  resourcePublicationsV2(first:30, onlyPublished:false){ nodes{ isPublished publication{ id name } } }
+  variants(first:20){ nodes{ id title sku price compareAtPrice position taxable inventoryPolicy inventoryQuantity barcode
+     selectedOptions{ name value }
+     inventoryItem{ id tracked requiresShipping measurement{ weight{ unit value } } }
+     metafields(first:20){ nodes{ namespace key type value } } } }
+}}`;
+
+export async function snap(id) {
+  const d = await gql(Q, { id: `gid://shopify/Product/${id}` });
+  if (d?.__err) throw new Error('gql ' + JSON.stringify(d.__err).slice(0, 300));
+  const p = d.product;
+  if (!p) return null;
+  // open orders touching this product: search by each variant SKU, then keep open + unfulfilled lines for this product
+  const openOrders = [];
+  for (const v of p.variants.nodes) {
+    if (!v.sku) continue;
+    const oq = await gql(`query($q:String!){ orders(first:50, query:$q){ nodes{ name closed cancelledAt displayFulfillmentStatus
+        lineItems(first:50){ nodes{ product{ id } sku unfulfilledQuantity } } } } }`, { q: `sku:${JSON.stringify(v.sku)}` });
+    if (oq?.__err) { openOrders.push({ error: JSON.stringify(oq.__err).slice(0, 200) }); continue; }
+    for (const o of oq.orders.nodes) {
+      if (o.closed || o.cancelledAt) continue;
+      if (o.lineItems.nodes.some(li => li.product?.id === p.id && li.unfulfilledQuantity > 0)) openOrders.push({ name: o.name, sku: v.sku, status: o.displayFulfillmentStatus });
+    }
+  }
+  // redirects already pointing from this handle
+  const rr = await rest(`/redirects.json?path=${encodeURIComponent('/products/' + p.handle)}`);
+  const redirects = rr.ok ? (await rr.json()).redirects : { error: rr.status };
+  return { ...p, openOrders, redirects };
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const out = {};
+  for (const pr of PAIRS) {
+    out[pr.mfr] = { draft: await snap(pr.draft), twin: await snap(pr.twin) };
+  }
+  const f = process.argv[2] || `snapshot-${new Date().toISOString().replace(/[:.]/g, '')}.json`;
+  fs.writeFileSync(f, JSON.stringify(out, null, 2));
+  console.log('wrote', f);
+}

← ce228bc auto-data-snapshot: 2026-09-25T08:13:10 (5 data files) — scr  ·  back to Designerwallcoverings  ·  TK-12227: restrict to Online Store (unpublish pre-listed cha dd22e2c →