← 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); });