← 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
A scripts/palm-grove-promote/probe.mjsA scripts/palm-grove-promote/promote-pair.mjsA scripts/palm-grove-promote/rollback.mjsA scripts/palm-grove-promote/snapshot.mjs
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 →