← back to Qty Bulk 2026 04 23

set_qty_york_brewster_dwqw.js

256 lines

#!/usr/bin/env node
/**
 * Bulk set min=2, step=2 on York + Brewster-tagged + DWJS + DWQW products.
 *
 * - Product metafield:  global.v_prods_quantity_order_min  = "2"
 * - Product metafield:  global.v_prods_quantity_order_units = "2"
 * - Variant metafield:  custom.v_prod_quantity_order_min   = "2" (non-Sample only)
 * - Variant metafield:  custom.v_prods_quantity_order_units = "2" (non-Sample only)
 * - Sample variants stay at 1/1 — we set custom.v_prod_quantity_order_min=1 and
 *   custom.v_prods_quantity_order_units=1 explicitly to normalize (in case any
 *   stray value exists).
 *
 * Uses GraphQL metafieldsSet (up to 25 per call). Paginates bulk-operation
 * output for product/variant discovery.
 */

const https = require('https');
const fs = require('fs');
const path = require('path');

const STORE  = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN  = (process.env.SHOPIFY_ADMIN_TOKEN || '');
const API    = '/admin/api/2024-10/graphql.json';
const FILTER = '(vendor:York* OR tag:Brewster OR sku:DWJS* OR sku:DWQW*)';
const OUT    = __dirname;
const LOG    = path.join(OUT, 'run.log');
const FAILS  = path.join(OUT, 'failures.ndjson');
const STATE  = path.join(OUT, 'state.json');

function ts() { return new Date().toISOString().replace('T',' ').slice(0,19); }
function log(msg) { const line = `[${ts()}] ${msg}\n`; process.stdout.write(line); fs.appendFileSync(LOG, line); }

function gql(body, retry = 0) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({
      hostname: STORE, path: API, method: 'POST',
      headers: {
        'X-Shopify-Access-Token': TOKEN,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(data),
      },
    }, res => {
      let c = '';
      res.on('data', d => c += d);
      res.on('end', async () => {
        try {
          const j = JSON.parse(c);
          // throttle back-off
          const tri = j?.extensions?.cost?.throttleStatus;
          if (j.errors && /throttle/i.test(JSON.stringify(j.errors)) && retry < 6) {
            await new Promise(r => setTimeout(r, 2000 * (retry + 1)));
            return resolve(gql(body, retry + 1));
          }
          if (tri && tri.currentlyAvailable < 200) {
            await new Promise(r => setTimeout(r, 1000));
          }
          resolve(j);
        } catch (e) {
          if (retry < 4) {
            setTimeout(() => resolve(gql(body, retry + 1)), 2000 * (retry + 1));
          } else {
            resolve({ error: c.slice(0, 500) });
          }
        }
      });
    });
    req.on('error', err => {
      if (retry < 4) setTimeout(() => resolve(gql(body, retry + 1)), 2000 * (retry + 1));
      else reject(err);
    });
    req.setTimeout(60000, () => { req.destroy(); if (retry < 4) resolve(gql(body, retry + 1)); else reject(new Error('timeout')); });
    req.write(data); req.end();
  });
}

async function startBulk() {
  const q = `
    {
      products(query: "${FILTER}") {
        edges {
          node {
            id
            vendor
            variants {
              edges {
                node {
                  id
                  sku
                  title
                }
              }
            }
          }
        }
      }
    }`;
  const r = await gql({
    query: `mutation {
      bulkOperationRunQuery(
        query: """${q}"""
      ) {
        bulkOperation { id status }
        userErrors { field message }
      }
    }`
  });
  return r;
}

async function pollBulk() {
  for (;;) {
    const r = await gql({ query: `{ currentBulkOperation { id status errorCode objectCount url partialDataUrl } }` });
    const op = r?.data?.currentBulkOperation;
    if (!op) { log('bulk op missing — retrying'); await new Promise(r => setTimeout(r, 3000)); continue; }
    log(`bulk ${op.status} objects=${op.objectCount} err=${op.errorCode || '-'}`);
    if (op.status === 'COMPLETED') return op;
    if (['FAILED','CANCELED','EXPIRED'].includes(op.status)) throw new Error(`bulk ${op.status}`);
    await new Promise(r => setTimeout(r, 5000));
  }
}

async function downloadJsonl(url) {
  const file = path.join(OUT, 'bulk.jsonl');
  await new Promise((resolve, reject) => {
    const out = fs.createWriteStream(file);
    https.get(url, res => { res.pipe(out); out.on('finish', () => out.close(resolve)); })
         .on('error', reject);
  });
  return file;
}

function parseJsonl(file) {
  const products = new Map();
  const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
  for (const line of lines) {
    const o = JSON.parse(line);
    if (o.id && o.id.includes('/Product/')) {
      products.set(o.id, { id: o.id, vendor: o.vendor, variants: [] });
    } else if (o.id && o.id.includes('/ProductVariant/') && o.__parentId) {
      const p = products.get(o.__parentId);
      if (p) p.variants.push({ id: o.id, sku: o.sku || '', title: o.title || '' });
    }
  }
  return [...products.values()];
}

function isSample(v) {
  const s = (v.sku || '').toLowerCase();
  const t = (v.title || '').toLowerCase();
  return s.includes('sample') || t.includes('sample');
}

function buildMetafields(product) {
  const out = [];
  // product-level
  out.push({
    ownerId: product.id, namespace: 'global',
    key: 'v_prods_quantity_order_min', type: 'single_line_text_field', value: '2',
  });
  out.push({
    ownerId: product.id, namespace: 'global',
    key: 'v_prods_quantity_order_units', type: 'single_line_text_field', value: '2',
  });
  for (const v of product.variants) {
    const sample = isSample(v);
    const minVal = sample ? '1' : '2';
    const stepVal = sample ? '1' : '2';
    out.push({
      ownerId: v.id, namespace: 'custom',
      key: 'v_prod_quantity_order_min', type: 'single_line_text_field', value: minVal,
    });
    out.push({
      ownerId: v.id, namespace: 'custom',
      key: 'v_prods_quantity_order_units', type: 'single_line_text_field', value: stepVal,
    });
  }
  return out;
}

async function setMetafieldsBatch(mfs) {
  const m = `mutation SetMF($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields { id key namespace }
      userErrors { field message code }
    }
  }`;
  const r = await gql({ query: m, variables: { metafields: mfs } });
  return r;
}

async function run() {
  log(`START — filter: ${FILTER}`);
  // resume support
  let processedIds = new Set();
  if (fs.existsSync(STATE)) {
    try { processedIds = new Set(JSON.parse(fs.readFileSync(STATE,'utf8'))); log(`resuming: ${processedIds.size} already done`); } catch {}
  }

  // always start a fresh bulk export so we get our filter, not some stale op
  log('starting bulk export');
  for (let attempt = 0; attempt < 6; attempt++) {
    const s = await startBulk();
    const errs = s?.data?.bulkOperationRunQuery?.userErrors || [];
    if (!errs.length) { log('bulk started'); break; }
    log('bulk start errors: ' + JSON.stringify(errs));
    // if one is already running, wait for it to drain
    await new Promise(r => setTimeout(r, 8000));
  }
  const op = await pollBulk();
  if (!op.url) { log('bulk completed but no url (likely zero objects). exiting'); return; }
  log(`downloading jsonl: ${op.url.slice(0,80)}...`);
  const jsonl = await downloadJsonl(op.url);
  const products = parseJsonl(jsonl);
  log(`parsed ${products.length} products from bulk export`);

  // batch metafields, 25 per call
  let ok = 0, fail = 0, batchIdx = 0;
  let buffer = [];
  let bufferOwners = new Set();
  const flush = async () => {
    if (!buffer.length) return;
    batchIdx++;
    const r = await setMetafieldsBatch(buffer);
    const errs = r?.data?.metafieldsSet?.userErrors || [];
    if (errs.length) {
      fail += errs.length;
      fs.appendFileSync(FAILS, JSON.stringify({ batch: batchIdx, errs, payload: buffer.slice(0,3) }) + '\n');
      log(`batch ${batchIdx}: ${buffer.length} mfs, ${errs.length} errors — first: ${JSON.stringify(errs[0])}`);
    }
    ok += (r?.data?.metafieldsSet?.metafields?.length || 0);
    if (batchIdx % 20 === 0) {
      log(`batch ${batchIdx} ok=${ok} fail=${fail}`);
      // save state
      fs.writeFileSync(STATE, JSON.stringify([...processedIds]));
    }
    buffer = [];
    bufferOwners = new Set();
  };

  for (const p of products) {
    if (processedIds.has(p.id)) continue;
    const mfs = buildMetafields(p);
    // flush if next product would overflow batch
    if (buffer.length + mfs.length > 25) await flush();
    buffer.push(...mfs);
    processedIds.add(p.id);
  }
  await flush();
  fs.writeFileSync(STATE, JSON.stringify([...processedIds]));

  log(`DONE — products=${products.length} mfOk=${ok} mfFail=${fail} batches=${batchIdx}`);
}

run().catch(e => { log('FATAL ' + (e.stack || e.message || e)); process.exit(1); });