← back to Dw Five Field Step0
Phase 2: Fix REST API variant creation with option1='Sample'
ca94eb01e5230c2f0d5b0220dd034b1f5f4ffe0c · 2026-06-21 19:54:42 -0700 · Steve Abrams
Files touched
M scripts/phase2-add-sample-bulk.jsA scripts/test-50-variants.js
Diff
commit ca94eb01e5230c2f0d5b0220dd034b1f5f4ffe0c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jun 21 19:54:42 2026 -0700
Phase 2: Fix REST API variant creation with option1='Sample'
---
scripts/phase2-add-sample-bulk.js | 283 ++++++++++++++++++++++++++------------
scripts/test-50-variants.js | 111 +++++++++++++++
2 files changed, 306 insertions(+), 88 deletions(-)
diff --git a/scripts/phase2-add-sample-bulk.js b/scripts/phase2-add-sample-bulk.js
index 16b63ab..ee24ce3 100755
--- a/scripts/phase2-add-sample-bulk.js
+++ b/scripts/phase2-add-sample-bulk.js
@@ -19,6 +19,11 @@
* Batch strategy: 100 variants per GraphQL batch, ≥90s between batches
* (1,000 variants/day cap is safe until store hits 50K+ variants)
*
+ * Modes:
+ * --test Run on 5 SKUs only (dry-run without Shopify writes)
+ * --execute Full production run on all 13,669 SKUs
+ * --status Check progress from state.json
+ *
* Cost: $0 (Shopify GraphQL quota included)
*/
const fs = require('fs');
@@ -49,31 +54,107 @@ delete process.env.PGPASSWORD;
delete process.env.DATABASE_URL;
const pool = new Pool({ host: '/tmp', database: 'dw_unified' });
-async function gql(query, variables = {}) {
- for (let attempt = 0; attempt < 8; attempt++) {
- const r = await fetch(GRAPHQL_URL, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Shopify-Access-Token': TOKEN
- },
- body: JSON.stringify({ query, variables })
+const MODE = process.argv[2];
+const STATE_FILE = path.join(__dirname, '../.phase2-state.json');
+
+function loadState() {
+ if (fs.existsSync(STATE_FILE)) {
+ return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
+ }
+ return {
+ mode: MODE,
+ startTime: Date.now(),
+ vendorIndex: 0,
+ skuIndex: 0,
+ results: [],
+ totalCreated: 0,
+ totalErrors: 0
+ };
+}
+
+function saveState(state) {
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
+}
+
+function showStatus() {
+ if (!fs.existsSync(STATE_FILE)) {
+ console.log('No state file. Run with --test or --execute to start.');
+ process.exit(0);
+ }
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
+ const elapsed = ((Date.now() - state.startTime) / 1000 / 60).toFixed(1);
+ console.log(`\n[Phase 2 Status]`);
+ console.log(` Mode: ${state.mode}`);
+ console.log(` Elapsed: ${elapsed} min`);
+ console.log(` Vendor: ${VENDORS[state.vendorIndex] || 'Complete'} (${state.vendorIndex}/${VENDORS.length})`);
+ console.log(` Total created: ${state.totalCreated}`);
+ console.log(` Total errors: ${state.totalErrors}`);
+ console.log(` Completed vendors: ${state.results.filter(r => r.skus > 0).length}`);
+ if (state.results.length > 0) {
+ console.log(`\n Progress:`);
+ state.results.forEach(r => {
+ const pct = r.skus > 0 ? ((r.created / r.skus) * 100).toFixed(0) : 'N/A';
+ console.log(` ${r.vendor.padEnd(25)} ${r.created}/${r.skus} (${pct}%)`);
});
+ }
+ process.exit(0);
+}
+
+async function createVariantRest(productId, sku, title, price) {
+ /**
+ * Create a variant using the REST API (more reliable than GraphQL for variant creation)
+ * Note: We set option1 to "Sample" to avoid conflicts with existing variants.
+ */
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const r = await fetch(
+ `https://${DOMAIN}/admin/api/${VER}/products/${productId}/variants.json`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Shopify-Access-Token': TOKEN
+ },
+ body: JSON.stringify({
+ variant: {
+ title,
+ sku,
+ price,
+ option1: 'Sample' // Unique option value for sample variants
+ }
+ })
+ }
+ );
const j = await r.json();
- if (j.errors && JSON.stringify(j.errors).includes('Throttled')) {
+
+ if (r.status === 429) {
const waitMs = 3000 * (attempt + 1);
- console.log(` [THROTTLED] Retry ${attempt + 1}/8 in ${waitMs}ms...`);
+ console.log(` [THROTTLED] Retry ${attempt + 1}/5 in ${waitMs}ms...`);
await new Promise(s => setTimeout(s, waitMs));
continue;
}
- if (j.errors) throw new Error(JSON.stringify(j.errors));
- return j.data;
+
+ if (r.status === 422) {
+ const errorMsg = JSON.stringify(j.errors);
+
+ // Variant SKU already exists or already defined - both are OK
+ if (errorMsg.includes('has already been taken') || errorMsg.includes("already exists")) {
+ return { success: true, skipped: true, reason: errorMsg };
+ }
+
+ throw new Error(errorMsg);
+ }
+
+ if (!r.ok || j.errors) {
+ throw new Error(j.errors ? JSON.stringify(j.errors) : `HTTP ${r.status}`);
+ }
+
+ return { success: true, variant: j.variant };
}
- throw new Error('Throttled exhausted');
+ throw new Error('Rate limited (5 attempts exhausted)');
}
-async function fetchSkusForVendor(vendor) {
- const q = `
+async function fetchSkusForVendor(vendor, limit = null) {
+ let q = `
SELECT DISTINCT
af.shopify_id,
af.dw_sku,
@@ -86,81 +167,75 @@ async function fetchSkusForVendor(vendor) {
AND af.priceable = true
ORDER BY af.dw_sku
`;
+ if (limit) q += ` LIMIT ${limit}`;
const res = await pool.query(q, [vendor]);
return res.rows;
}
-async function createSampleVariantsBatch(skus) {
+async function createSampleVariantsBatch(skus, testMode = false) {
/**
- * Creates sample variants for a batch of SKUs using productVariantsBulkCreate.
- * Group by product_id and create all variants for that product in one call.
+ * Creates sample variants for a batch of SKUs using the REST API.
+ * One API call per SKU (REST API is more reliable than GraphQL for variant creation).
+ * In test mode, simulates the API calls without writing to Shopify.
*/
- const mutation = `
- mutation bulkCreate($pid: ID!, $variants: [ProductVariantsBulkInput!]!) {
- productVariantsBulkCreate(productId: $pid, variants: $variants, strategy: REMOVE_STANDALONE_VARIANT) {
- productVariants { id sku title price }
- userErrors { field message code }
- }
- }
- `;
-
- const byProduct = {};
- for (const sku of skus) {
- const productId = sku.shopify_id; // should be gid://shopify/Product/...
- if (!byProduct[productId]) {
- byProduct[productId] = [];
- }
- byProduct[productId].push(sku);
+ if (testMode) {
+ // Simulate: log what we would create
+ console.log(` [TEST] Would create ${skus.length} sample variants`);
+ return {
+ created: skus.length,
+ errors: 0,
+ results: skus.map(s => ({
+ sku: s.dw_sku,
+ status: 'created',
+ title: 'Sample',
+ price: SAMPLE_PRICE
+ }))
+ };
}
- let created = 0, errors = 0;
+ let created = 0, errors = 0, skipped = 0;
const results = [];
- for (const [productId, skuList] of Object.entries(byProduct)) {
+ for (const sku of skus) {
try {
- const variantInputs = skuList.map(sku => ({
- title: 'Sample',
- sku: `${sku.dw_sku}-Sample`,
- price: SAMPLE_PRICE
- }));
+ // Extract numeric product ID from gid://shopify/Product/ID
+ const productId = sku.shopify_id.match(/\d+$/)[0];
- const result = await gql(mutation, {
- pid: productId,
- variants: variantInputs
- });
+ const result = await createVariantRest(
+ productId,
+ `${sku.dw_sku}-Sample`,
+ 'Sample',
+ SAMPLE_PRICE
+ );
- if (result.productVariantsBulkCreate.userErrors.length > 0) {
- for (const err of result.productVariantsBulkCreate.userErrors) {
- console.log(` ⚠️ Error: ${err.message}`);
- errors++;
- }
- results.push(...skuList.map((s, i) => ({
- sku: s.dw_sku,
- status: i < result.productVariantsBulkCreate.userErrors.length ? 'error' : 'created'
- })));
+ if (result.skipped) {
+ skipped++;
+ results.push({
+ sku: sku.dw_sku,
+ status: 'skipped',
+ reason: 'Variant already exists'
+ });
} else {
- created += result.productVariantsBulkCreate.productVariants.length;
- results.push(...skuList.map((s, i) => ({
- sku: s.dw_sku,
+ created++;
+ results.push({
+ sku: sku.dw_sku,
status: 'created',
- variantId: result.productVariantsBulkCreate.productVariants[i]?.id
- })));
+ variantId: result.variant?.id
+ });
}
} catch (e) {
- for (const sku of skuList) {
- console.log(` ❌ ${sku.dw_sku}: ${e.message.slice(0, 80)}`);
- errors++;
- results.push({ sku: sku.dw_sku, status: 'exception', error: e.message });
- }
+ console.log(` ❌ ${sku.dw_sku}: ${e.message.slice(0, 80)}`);
+ errors++;
+ results.push({ sku: sku.dw_sku, status: 'exception', error: e.message });
}
}
- return { created, errors, results };
+ return { created, errors, skipped, results };
}
-async function processVendor(vendor) {
+async function processVendor(vendor, testMode = false, testLimit = null) {
console.log(`\n[${vendor}] Fetching add-sample SKUs...`);
- const skus = await fetchSkusForVendor(vendor);
+ const skus = await fetchSkusForVendor(vendor, testMode ? testLimit : null);
console.log(` Found ${skus.length} SKUs`);
if (skus.length === 0) {
@@ -176,11 +251,12 @@ async function processVendor(vendor) {
console.log(` [Batch ${batchCount}] Processing ${batch.length} SKUs (${i + 1}–${Math.min(i + BATCH_SIZE, skus.length)} of ${skus.length})...`);
try {
- const result = await createSampleVariantsBatch(batch);
+ const result = await createSampleVariantsBatch(batch, testMode);
totalCreated += result.created;
totalErrors += result.errors;
allResults.push(...result.results);
- console.log(` → ${result.created} created, ${result.errors} errors`);
+ const skippedMsg = result.skipped ? `, ${result.skipped} skipped` : '';
+ console.log(` → ${result.created} created, ${result.errors} errors${skippedMsg}`);
} catch (e) {
console.error(` → BATCH FAILED: ${e.message.slice(0, 100)}`);
totalErrors += batch.length;
@@ -204,17 +280,36 @@ async function processVendor(vendor) {
};
}
+// Check for --status flag first
+if (MODE === '--status') {
+ showStatus();
+}
+
(async () => {
+ if (!MODE || !['-test', '--test', '--execute'].includes(MODE)) {
+ console.log('Usage: node phase2-add-sample-bulk.js [--test|--execute|--status]');
+ console.log(' --test Run on 5 SKUs only (dry-run)');
+ console.log(' --execute Full production run on all 13,669 SKUs');
+ console.log(' --status Check progress from state.json');
+ process.exit(1);
+ }
+
+ const testMode = ['-test', '--test'].includes(MODE);
+ const executeMode = MODE === '--execute';
+
console.log(`\n[PHASE 2] Kravet Family Add-Sample Bulk Activation`);
- console.log(`${VENDORS.join(', ')}`);
- console.log(`Expected: 13,669 SKUs total, 1,000 variants/day cap (safe)\n`);
+ console.log(`Mode: ${testMode ? 'TEST (5 SKUs, dry-run)' : 'PRODUCTION (all vendors)'}`);
+ console.log(`Vendors: ${VENDORS.join(', ')}`);
+ if (!testMode) {
+ console.log(`Expected: 13,669 SKUs total, 1,000 variants/day cap (safe)\n`);
+ }
const startTime = Date.now();
const allResults = [];
for (const vendor of VENDORS) {
try {
- const result = await processVendor(vendor);
+ const result = await processVendor(vendor, testMode, testMode ? 5 : null);
allResults.push(result);
} catch (e) {
console.error(`[ERROR] ${vendor}:`, e.message);
@@ -235,7 +330,7 @@ async function processVendor(vendor) {
const totalErrors = allResults.reduce((a, r) => a + r.errors, 0);
const totalBatches = allResults.reduce((a, r) => a + r.batches, 0);
- console.log(`\n[PHASE 2 COMPLETE] Elapsed: ${elapsed} min`);
+ console.log(`\n[PHASE 2 ${testMode ? 'TEST' : 'COMPLETE'}] Elapsed: ${elapsed} min`);
console.log(`─────────────────────────────`);
for (const r of allResults) {
const pct = r.skus > 0 ? ((r.created / r.skus) * 100).toFixed(0) : 'N/A';
@@ -246,28 +341,40 @@ async function processVendor(vendor) {
console.log(` Batches: ${totalBatches}, Elapsed: ${elapsed} min`);
// Save results to file
- const resultsPath = path.join(__dirname, `../results/phase2-add-sample-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
+ const modeTag = testMode ? 'test' : 'prod';
+ const resultsPath = path.join(__dirname, `../results/phase2-add-sample-${modeTag}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
const resultsDir = path.dirname(resultsPath);
if (!fs.existsSync(resultsDir)) fs.mkdirSync(resultsDir, { recursive: true });
fs.writeFileSync(resultsPath, JSON.stringify(allResults, null, 2));
console.log(`\nResults saved: ${resultsPath}`);
+ // Update state file
+ const state = loadState();
+ state.results = allResults;
+ state.totalCreated = totalCreated;
+ state.totalErrors = totalErrors;
+ state.completedAt = Date.now();
+ saveState(state);
+
// Log to CNCP wins
- const successPct = totalSkus > 0 ? ((totalCreated / totalSkus) * 100).toFixed(1) : 'N/A';
- const winPayload = {
- project: 'dw-five-field-step0',
- title: `Phase 2 Kravet Family Add-Sample Bulk — ${totalCreated}/${totalSkus} created`,
- summary: `Completed add-sample variant bulk creation across 9 vendors. ${totalCreated} of ${totalSkus} SKUs successfully created (${successPct}%). ${totalErrors} errors across ${totalBatches} batches. Elapsed: ${elapsed} min.`,
- valueToday: `${totalCreated * 4.25 / 100}K+ in sample revenue unlocked`
- };
+ if (!testMode) {
+ const successPct = totalSkus > 0 ? ((totalCreated / totalSkus) * 100).toFixed(1) : 'N/A';
+ const winPayload = {
+ project: 'dw-five-field-step0',
+ title: `Phase 2 Kravet Family Add-Sample Bulk — ${totalCreated}/${totalSkus} created`,
+ summary: `Completed add-sample variant bulk creation across 9 vendors. ${totalCreated} of ${totalSkus} SKUs successfully created (${successPct}%). ${totalErrors} errors across ${totalBatches} batches. Elapsed: ${elapsed} min.`,
+ valueToday: `${(totalCreated * 4.25 / 100).toFixed(2)}K in sample revenue unlocked`
+ };
- await fetch('http://127.0.0.1:3333/api/wins', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(winPayload)
- }).catch(e => console.log(` [CNCP] Win logged (or offline): ${e.message}`));
+ await fetch('http://127.0.0.1:3333/api/wins', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(winPayload)
+ }).catch(e => console.log(` [CNCP] Win logged (or offline): ${e.message}`));
+ }
- process.exit(totalErrors > totalSkus * 0.05 ? 1 : 0); // Fail if error rate > 5%
+ const exitCode = testMode ? 0 : (totalErrors > totalSkus * 0.05 ? 1 : 0); // Fail if error rate > 5% in prod
+ process.exit(exitCode);
})().finally(async () => {
await pool.end();
});
diff --git a/scripts/test-50-variants.js b/scripts/test-50-variants.js
new file mode 100644
index 0000000..ff68f14
--- /dev/null
+++ b/scripts/test-50-variants.js
@@ -0,0 +1,111 @@
+const { Pool } = require('pg');
+const fs = require('fs');
+const path = require('path');
+
+const env = fs.readFileSync(path.join(process.env.HOME, '/Projects/secrets-manager/.env'), 'utf8');
+const TOKEN = (env.match(/SHOPIFY_ADMIN_TOKEN=(\S+)/) || [])[1];
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const VER = '2024-10';
+
+delete process.env.PGPASSWORD;
+delete process.env.DATABASE_URL;
+const pool = new Pool({ host: '/tmp', database: 'dw_unified' });
+
+const SAMPLE_PRICE = '4.25';
+
+async function createVariantRest(productId, sku, title, price) {
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const r = await fetch(
+ `https://${DOMAIN}/admin/api/${VER}/products/${productId}/variants.json`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Shopify-Access-Token': TOKEN
+ },
+ body: JSON.stringify({
+ variant: {
+ title, sku, price,
+ option1: 'Sample' // Unique option value for sample variants
+ }
+ })
+ }
+ );
+ const j = await r.json();
+
+ if (r.status === 429) {
+ const waitMs = 3000 * (attempt + 1);
+ console.log(` [THROTTLED] Retry ${attempt + 1}/5 in ${waitMs}ms...`);
+ await new Promise(s => setTimeout(s, waitMs));
+ continue;
+ }
+
+ if (r.status === 422) {
+ const errorMsg = JSON.stringify(j.errors);
+ if (errorMsg.includes('has already been taken') || errorMsg.includes("already exists")) {
+ return { success: true, skipped: true };
+ }
+ throw new Error(errorMsg);
+ }
+
+ if (!r.ok || j.errors) {
+ throw new Error(j.errors ? JSON.stringify(j.errors) : `HTTP ${r.status}`);
+ }
+
+ return { success: true, variant: j.variant };
+ }
+ throw new Error('Rate limited (5 attempts exhausted)');
+}
+
+(async () => {
+ console.log('[TEST] Creating 50 sample variants...\n');
+
+ const res = await pool.query(`
+ SELECT DISTINCT
+ af.shopify_id, af.dw_sku
+ FROM active_five_field_gaps af
+ WHERE af.vendor = 'Clarke And Clarke'
+ AND af.fix_type = 'add-sample'
+ AND af.priceable = true
+ ORDER BY af.dw_sku
+ LIMIT 50
+ `);
+
+ let created = 0, errors = 0, skipped = 0;
+ const startTime = Date.now();
+
+ for (let i = 0; i < res.rows.length; i++) {
+ const sku = res.rows[i];
+ const productId = sku.shopify_id.match(/\d+$/)[0];
+
+ try {
+ const result = await createVariantRest(
+ productId,
+ `${sku.dw_sku}-Sample-50TEST-V3`,
+ 'Sample',
+ SAMPLE_PRICE
+ );
+
+ if (result.skipped) {
+ skipped++;
+ if ((i + 1) % 10 === 0) console.log(` ${i + 1}/50: ${sku.dw_sku} (skipped)`);
+ } else {
+ created++;
+ if ((i + 1) % 10 === 0) console.log(` ${i + 1}/50: ${sku.dw_sku} (created)`);
+ }
+ } catch (e) {
+ errors++;
+ console.log(` ❌ ${i + 1}/50: ${sku.dw_sku}: ${e.message.slice(0, 60)}`);
+ }
+
+ if ((i + 1) % 10 === 0) {
+ await new Promise(s => setTimeout(s, 1000));
+ }
+ }
+
+ const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(2);
+ console.log(`\n[RESULT] ${created} created, ${skipped} skipped, ${errors} errors in ${elapsed} min`);
+ console.log(` Estimated rate: ~${(res.rows.length / elapsed).toFixed(0)} variants/min\n`);
+
+ process.exit(errors > 0 ? 1 : 0);
+})().finally(() => pool.end());
← d906de5 JD combined cost dry-run: Pointe-xref + JD own-line, 1,934/2
·
back to Dw Five Field Step0
·
Add Phase 2 verification script for live sample variants 6f4bf56 →