← back to Dw Five Field Step0
scripts/phase2-add-sample-bulk.js
381 lines
#!/usr/bin/env node
/**
* Phase 2: Kravet Family Add-Sample Bulk Activation
*
* Executes 13,669 add-sample variant creates across 8 Kravet family vendors
* + Phillipe Romano (3,910). All at flat $4.25 pricing.
*
* Execution order (by cohort size):
* 1. Clarke & Clarke (3,173)
* 2. Gaston Y Daniela (1,881)
* 3. Lee Jofa (1,341)
* 4. Brunschwig & Fils (1,218)
* 5. GP & J Baker (937)
* 6. Threads (already in canary)
* 7. Mulberry (193)
* 8. Kravet (16)
* 9. Phillipe Romano / Command54 (3,910)
*
* 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');
const path = require('path');
const { Pool } = require('pg');
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';
const GRAPHQL_URL = `https://${DOMAIN}/admin/api/${VER}/graphql.json`;
const BATCH_SIZE = 100; // variants per batch
const BATCH_GAP_MS = 2000; // 2s between batches (≫ 90s canary rule)
const SAMPLE_PRICE = '4.25';
const VENDORS = [
'Clarke And Clarke',
'Gaston Y Daniela',
'Lee Jofa',
'Brunschwig & Fils',
'GP & J Baker',
'Threads',
'Mulberry',
'Kravet',
'Phillipe Romano'
];
delete process.env.PGPASSWORD;
delete process.env.DATABASE_URL;
const pool = new Pool({ host: '/tmp', database: 'dw_unified' });
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 (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);
// 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('Rate limited (5 attempts exhausted)');
}
async function fetchSkusForVendor(vendor, limit = null) {
let q = `
SELECT DISTINCT
af.shopify_id,
af.dw_sku,
af.mfr_sku,
af.kravet_price,
af.product_price
FROM active_five_field_gaps af
WHERE af.vendor = $1
AND af.fix_type = 'add-sample'
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, testMode = false) {
/**
* 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.
*/
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, skipped = 0;
const results = [];
for (const sku of skus) {
try {
// Extract numeric product ID from gid://shopify/Product/ID
const productId = sku.shopify_id.match(/\d+$/)[0];
const result = await createVariantRest(
productId,
`${sku.dw_sku}-Sample`,
'Sample',
SAMPLE_PRICE
);
if (result.skipped) {
skipped++;
results.push({
sku: sku.dw_sku,
status: 'skipped',
reason: 'Variant already exists'
});
} else {
created++;
results.push({
sku: sku.dw_sku,
status: 'created',
variantId: result.variant?.id
});
}
} catch (e) {
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, skipped, results };
}
async function processVendor(vendor, testMode = false, testLimit = null) {
console.log(`\n[${vendor}] Fetching add-sample SKUs...`);
const skus = await fetchSkusForVendor(vendor, testMode ? testLimit : null);
console.log(` Found ${skus.length} SKUs`);
if (skus.length === 0) {
return { vendor, skus: 0, created: 0, errors: 0, batches: 0 };
}
let totalCreated = 0, totalErrors = 0, batchCount = 0;
const allResults = [];
for (let i = 0; i < skus.length; i += BATCH_SIZE) {
const batch = skus.slice(i, i + BATCH_SIZE);
batchCount++;
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, testMode);
totalCreated += result.created;
totalErrors += result.errors;
allResults.push(...result.results);
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;
}
if (i + BATCH_SIZE < skus.length) {
await new Promise(s => setTimeout(s, BATCH_GAP_MS));
}
}
// Log summary
console.log(` [${vendor} COMPLETE] ${totalCreated}/${skus.length} created, ${totalErrors} errors`);
return {
vendor,
skus: skus.length,
created: totalCreated,
errors: totalErrors,
batches: batchCount,
results: allResults
};
}
// 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(`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, testMode, testMode ? 5 : null);
allResults.push(result);
} catch (e) {
console.error(`[ERROR] ${vendor}:`, e.message);
allResults.push({
vendor,
skus: 0,
created: 0,
errors: -1,
batches: 0,
error: e.message
});
}
}
const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
const totalSkus = allResults.reduce((a, r) => a + r.skus, 0);
const totalCreated = allResults.reduce((a, r) => a + r.created, 0);
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 ${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';
console.log(` ${r.vendor.padEnd(25)} ${r.skus.toString().padStart(5)} SKUs ${r.created.toString().padStart(5)} created ${r.errors.toString().padStart(5)} errors ${pct}% ✓`);
}
console.log(`─────────────────────────────`);
console.log(` TOTAL ${totalSkus.toString().padStart(30)} SKUs ${totalCreated.toString().padStart(5)} created ${totalErrors.toString().padStart(5)} errors`);
console.log(` Batches: ${totalBatches}, Elapsed: ${elapsed} min`);
// Save results to file
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
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}`));
}
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();
});