← back to Dw Material Filter
write-material.mjs
82 lines
#!/usr/bin/env node
// Write the derived custom.material_category metafield to active products via
// GraphQL metafieldsSet (25/batch, throttle-aware). NON-DESTRUCTIVE: a brand-new
// namespace/key — never touches the PDP custom.material composition field.
// node write-material.mjs --define # create the metafield definition (once)
// node write-material.mjs --ids <file> # pilot: only shopify_ids listed in <file>
// node write-material.mjs --all # write the whole plan
// add --dry to preview counts without writing
import https from 'node:https';
import { readFileSync } from 'node:fs';
const env = readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [, ''])[1].trim();
if (!TOKEN) { console.error('SHOPIFY_ADMIN_TOKEN not found in .env'); process.exit(1); }
const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
const args = process.argv.slice(2);
const DRY = args.includes('--dry');
function gql(query, variables) {
const body = JSON.stringify({ query, variables });
return new Promise((res, rej) => {
const r = https.request({ hostname: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { res(JSON.parse(d)); } catch (e) { rej(new Error(d.slice(0, 200))); } }); });
r.on('error', rej); r.write(body); r.end();
});
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function define() {
const q = `mutation($d:MetafieldDefinitionInput!){ metafieldDefinitionCreate(definition:$d){ createdDefinition{ id } userErrors{ code message } } }`;
const d = { name: 'Material Category', namespace: 'custom', key: 'material_category',
type: 'single_line_text_field', ownerType: 'PRODUCT',
description: 'Normalized material for filtering (Grasscloth, Silk, Vinyl, …). Separate from the PDP custom.material composition field.' };
const r = await gql(q, { d });
const err = r.data?.metafieldDefinitionCreate?.userErrors || [];
if (err.length && !err.some(e => e.code === 'TAKEN')) console.log('define userErrors:', err);
else console.log('metafield definition custom.material_category ready', err.length ? '(already existed)' : '(created)');
}
async function writeBatch(items) { // items: [{shopify_id, material}]
const metafields = items.map(it => ({
ownerId: String(it.shopify_id).startsWith('gid://') ? it.shopify_id : `gid://shopify/Product/${it.shopify_id}`,
namespace: 'custom', key: 'material_category', type: 'single_line_text_field', value: it.material }));
const q = `mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{ field message } } }`;
for (let attempt = 0; attempt < 5; attempt++) {
const r = await gql(q, { m: metafields });
if (r.errors && JSON.stringify(r.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; }
const ue = r.data?.metafieldsSet?.userErrors || [];
return ue;
}
return [{ message: 'throttled-giveup' }];
}
// ---- load plan
const plan = readFileSync('data/material-plan.jsonl', 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
let work = plan;
const idsFlag = args.indexOf('--ids');
if (idsFlag >= 0) {
const wanted = new Set(readFileSync(args[idsFlag + 1], 'utf8').trim().split(/\s+/));
const numId = s => String(s).replace(/^.*\//, ''); // numeric tail of a GID
work = plan.filter(p => wanted.has(numId(p.shopify_id)));
console.log(`pilot: ${work.length} of ${plan.length} plan rows match the id list`);
}
if (!args.includes('--all') && idsFlag < 0 && !args.includes('--define')) {
console.log('specify --define, --ids <file>, or --all'); process.exit(0);
}
if (args.includes('--define')) { await define(); if (!args.includes('--all') && idsFlag < 0) process.exit(0); }
if (DRY) { console.log(`DRY: would write ${work.length} products`); process.exit(0); }
let done = 0, errs = 0;
for (let i = 0; i < work.length; i += 25) {
const batch = work.slice(i, i + 25);
const ue = await writeBatch(batch);
if (ue.length) { errs += ue.length; if (errs <= 10) console.log('userErrors:', ue.slice(0, 2)); }
done += batch.length;
if (done % 1000 < 25) console.log(` ${done}/${work.length} written (${errs} errors)`);
await sleep(120); // gentle pacing
}
console.log(`DONE: ${done} products written, ${errs} errors`);