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