← back to Dw Yolo Loop
April mass-archive restore staging plan (report-only, tiered, batched)
65f918bc9f4f3f59980d93ba3bcb7bfc38111794 · 2026-06-16 01:10:31 -0700 · Steve Abrams
29,697 restorable (image+not-Nicolette+not-disco) from the 2026-04-17/18 wrongful
sweep → 24 daily 1k batches. Tiered: T1 clean 21,039 / T2 MAP Kravet-family 2,451
(batched) / T3 hold PJ+gated 6,207 (review). Stage-time re-validation gate blocks
restoring $4.25-sample-only products (price col = sample, not roll). NO writes —
Steve fires the publish.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A scripts/april-restore-plan/build-april-restore-plan.mjs
Diff
commit 65f918bc9f4f3f59980d93ba3bcb7bfc38111794
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 16 01:10:31 2026 -0700
April mass-archive restore staging plan (report-only, tiered, batched)
29,697 restorable (image+not-Nicolette+not-disco) from the 2026-04-17/18 wrongful
sweep → 24 daily 1k batches. Tiered: T1 clean 21,039 / T2 MAP Kravet-family 2,451
(batched) / T3 hold PJ+gated 6,207 (review). Stage-time re-validation gate blocks
restoring $4.25-sample-only products (price col = sample, not roll). NO writes —
Steve fires the publish.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.../build-april-restore-plan.mjs | 115 +++++++++++++++++++++
1 file changed, 115 insertions(+)
diff --git a/scripts/april-restore-plan/build-april-restore-plan.mjs b/scripts/april-restore-plan/build-april-restore-plan.mjs
new file mode 100644
index 0000000..448d4ac
--- /dev/null
+++ b/scripts/april-restore-plan/build-april-restore-plan.mjs
@@ -0,0 +1,115 @@
+// build-april-restore-plan — REPORT-ONLY staging plan for the April 2026 wrongful
+// mass-archive. Produces a tiered, batched JSON Steve green-lights; writes NOTHING
+// to Shopify. The standing veto forbids autonomous restore — this only PLANS it.
+//
+// Cohort: products archived 2026-04-17/18 (the wrongful sweep) that are restorable:
+// image present + NOT Nicolette Mayer + NOT discontinued-tagged.
+// (price is NOT a gate: the product-level price column reflects the $4.25 sample
+// variant, not the real roll price — real price must be re-validated at stage time
+// against the live variant, same root cause as the GMC $4.25 issue.)
+//
+// Tiers:
+// T1 CLEAN — house/standard lines, restore freely. (batched)
+// T2 MAP — Kravet-family; restore BUT must price at MAP. (batched, flagged)
+// T3 HOLD — Phillip Jeffries (display-excluded) + gated/uncosted (Scalamandre/
+// Koroseal/Newmor/Desima/Cowtan/Innovations/Wolf Gordon). NOT batched —
+// listed for explicit per-vendor review.
+//
+// Batches: 1,000/day (Shopify ~1k/day variant cap), T1 first then T2. Each batch
+// carries a stage-time re-validation gate spec.
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+
+const PSQL = [ '/opt/homebrew/opt/postgresql@14/bin/psql', '/usr/local/opt/postgresql@14/bin/psql', 'psql' ]
+ .find(p => { try { execFileSync(p, ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } }) || 'psql';
+const DB = process.env.DW_UNIFIED_URL || 'postgresql:///dw_unified?host=/tmp';
+const OUT_JSON = `${process.env.HOME}/.claude/yolo-queue/april-restore-plan-2026-06-16.json`;
+const OUT_MD = `${process.env.HOME}/.claude/yolo-queue/april-restore-plan-2026-06-16.md`;
+const BATCH = 1000;
+
+const HOLD = /phillip jeffries|jeffries|scalamandre|koroseal|newmor|desima|cowtan|innovations|wolf *gordon/i;
+const KF = /kravet|lee jofa|groundworks|brunschwig|cole *& *son|gp *& *j *baker|colefax|clarke *& *clarke|mulberry|threads|baker lifestyle|andrew martin|aerin|barclay butera|thom filicia/i;
+
+function q(sql) {
+ const out = execFileSync(PSQL, [DB, '-At', '-F', '|', '-c', sql], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
+ return out.trim() ? out.trim().split('\n').map(r => r.split('|')) : [];
+}
+
+const rows = q(`
+ select shopify_id, vendor, mfr_sku, handle from shopify_products
+ where status='ARCHIVED' and updated_at_shopify::date in ('2026-04-17','2026-04-18')
+ and vendor !~* 'nicolette' and (tags is null or tags !~* 'discontinu')
+ and image_url is not null and image_url<>''
+ order by vendor, mfr_sku`).map(r => ({ shopify_id: r[0], vendor: r[1], sku: r[2], handle: r[3] }));
+
+const tier = (v) => HOLD.test(v) ? 'T3_HOLD' : (KF.test(v) && !/scalamandre/i.test(v)) ? 'T2_MAP' : 'T1_CLEAN';
+for (const r of rows) r.tier = tier(r.vendor);
+
+const t1 = rows.filter(r => r.tier === 'T1_CLEAN');
+const t2 = rows.filter(r => r.tier === 'T2_MAP');
+const t3 = rows.filter(r => r.tier === 'T3_HOLD');
+
+// batch T1 then T2 into 1k/day
+const batchable = [...t1, ...t2];
+const batches = [];
+for (let i = 0; i < batchable.length; i += BATCH) {
+ const slice = batchable.slice(i, i + BATCH);
+ batches.push({
+ day: batches.length + 1,
+ count: slice.length,
+ tiers: { T1_CLEAN: slice.filter(r => r.tier === 'T1_CLEAN').length, T2_MAP: slice.filter(r => r.tier === 'T2_MAP').length },
+ ids: slice.map(r => r.shopify_id),
+ });
+}
+
+const byVendor = (arr) => Object.entries(arr.reduce((m, r) => (m[r.vendor] = (m[r.vendor]||0)+1, m), {})).sort((a,b)=>b[1]-a[1]);
+
+const plan = {
+ generated_at: new Date().toISOString(),
+ source: 'dw_unified shopify_products mirror (Mac2), READ-ONLY',
+ cohort: 'status=ARCHIVED, updated_at_shopify 2026-04-17/18, image present, not Nicolette, not discontinued-tagged',
+ totals: { restorable: rows.length, t1_clean: t1.length, t2_map: t2.length, t3_hold: t3.length, batchable: batchable.length, batches: batches.length },
+ stage_time_revalidation_gate: [
+ 'product still ARCHIVED (not already restored/changed)',
+ 'image_url still present',
+ 'live MIN non-sample variant price > $5 (skip if it only has the $4.25 sample → would re-trigger GMC $4.25)',
+ 'vendor not in HOLD list (re-check)',
+ 'for T2_MAP: price floored at MAP (wholesale×1.5) before publish',
+ ],
+ apply_method: 'shopify_api_queue: {id, status:ACTIVE} per product, ≤1000/day, ≥90s between bursts (gated — Steve fires)',
+ batches,
+ t3_hold_for_review: { count: t3.length, by_vendor: byVendor(t3) },
+ t1_by_vendor_top: byVendor(t1).slice(0, 25),
+ t2_by_vendor: byVendor(t2),
+};
+
+fs.writeFileSync(OUT_JSON, JSON.stringify(plan, null, 2));
+
+let md = `# April mass-archive — RESTORE STAGING PLAN (report-only)\n\n`;
+md += `**Generated:** ${plan.generated_at} · source: dw_unified mirror (READ-ONLY) · **$0**\n`;
+md += `**Standing veto respected:** this is a PLAN only — no Shopify writes. Steve fires the publish.\n\n`;
+md += `## Cohort\n${plan.cohort}\n\n`;
+md += `## Totals\n`;
+md += `- **Restorable: ${plan.totals.restorable.toLocaleString()}**\n`;
+md += ` - T1 CLEAN (restore freely): **${t1.length.toLocaleString()}**\n`;
+md += ` - T2 MAP (Kravet-family — price at MAP): **${t2.length.toLocaleString()}**\n`;
+md += ` - T3 HOLD (PJ display-excluded + gated/uncosted — review): **${t3.length.toLocaleString()}**\n`;
+md += `- **Auto-batchable (T1+T2): ${batchable.length.toLocaleString()}** → **${batches.length} daily batches** of ≤${BATCH}\n\n`;
+md += `> Note: the product-level price column = the $4.25 sample variant for most rows, so price is re-validated at STAGE TIME against the live variant (not used as a pre-filter). This prevents restoring a product that only has the $4.25 sample (which would re-feed the GMC $4.25 problem).\n\n`;
+md += `## Stage-time re-validation gate (re-checked per product at publish)\n`;
+for (const g of plan.stage_time_revalidation_gate) md += `- ${g}\n`;
+md += `\n## Apply method (GATED)\n${plan.apply_method}\n\n`;
+md += `## T1 CLEAN — top vendors\n| Vendor | Count |\n|---|---:|\n`;
+for (const [v, c] of plan.t1_by_vendor_top) md += `| ${v} | ${c} |\n`;
+md += `\n## T2 MAP — Kravet-family (restore at MAP)\n| Vendor | Count |\n|---|---:|\n`;
+for (const [v, c] of plan.t2_by_vendor) md += `| ${v} | ${c} |\n`;
+md += `\n## T3 HOLD-for-review (NOT batched)\n| Vendor | Count |\n|---|---:|\n`;
+for (const [v, c] of plan.t3_hold_for_review.by_vendor) md += `| ${v} | ${c} |\n`;
+md += `\n## Batch schedule (first 10 days)\n| Day | Count | T1 | T2 |\n|---:|---:|---:|---:|\n`;
+for (const b of batches.slice(0, 10)) md += `| ${b.day} | ${b.count} | ${b.tiers.T1_CLEAN} | ${b.tiers.T2_MAP} |\n`;
+if (batches.length > 10) md += `| … | | | (${batches.length} batches total) |\n`;
+md += `\n_Full per-batch ID lists in the JSON: ${OUT_JSON.replace(process.env.HOME, '~')}_\n`;
+fs.writeFileSync(OUT_MD, md);
+
+console.log(`[april-restore-plan] restorable=${rows.length} (T1=${t1.length} T2=${t2.length} T3-hold=${t3.length}) → ${batches.length} batches`);
+console.log(`JSON: ${OUT_JSON}\nMD: ${OUT_MD}`);
← e122d4f CF DNS drift snapshotter: read-only zone export + diff vs la
·
back to Dw Yolo Loop
·
Scraper contract test: DB-side field-depth canary for top-re 7d9aad2 →