← back to Dw Color Image Audit

scripts/live_color_hex_export.js

87 lines

#!/usr/bin/env node
/**
 * live_color_hex_export.js — READ-ONLY Shopify Bulk Operation export of every product's
 * live custom.color_hex (+ custom.color_details) metafield. This is the LIVE source of
 * truth for the color-fix re-scope (the dw_unified mirror does NOT carry custom.color_hex,
 * and the product_colors TABLE is independently/wrongly populated — see the 2026-06-21 memo).
 *
 * Output: full/live_color_hex.jsonl  (one {id,status,color_hex,color_details} per line)
 * No writes to Shopify. No writes to any DB. Safe / ungated.
 */
const https = require('https');
const fs = require('fs');
const os = require('os');

const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
const OUT = __dirname + '/../full/live_color_hex.jsonl';
const sleep = ms => new Promise(r => setTimeout(r, ms));

function gql(query) {
  return new Promise((resolve, reject) => {
    const b = JSON.stringify({ query });
    const r = https.request({ hostname: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } },
      x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { resolve(JSON.parse(d)); } catch { reject(new Error(d.slice(0, 300))); } }); });
    r.on('error', reject); r.write(b); r.end();
  });
}
function download(url, dest) {
  return new Promise((resolve, reject) => {
    const f = fs.createWriteStream(dest);
    https.get(url, res => { res.pipe(f); f.on('finish', () => f.close(() => resolve())); }).on('error', e => { fs.unlink(dest, () => {}); reject(e); });
  });
}

const BULK_QUERY = `
mutation {
  bulkOperationRunQuery(
    query: """
    {
      products {
        edges {
          node {
            id
            status
            color_hex: metafield(namespace: "custom", key: "color_hex") { value }
            color_details: metafield(namespace: "custom", key: "color_details") { value }
          }
        }
      }
    }
    """
  ) {
    bulkOperation { id status }
    userErrors { field message }
  }
}`;

async function main() {
  if (!TOKEN) { console.error('FATAL no token'); process.exit(1); }
  // guard: don't collide with a running bulk op
  const cur = await gql(`{currentBulkOperation(type:QUERY){id status}}`);
  const cs = cur.data && cur.data.currentBulkOperation && cur.data.currentBulkOperation.status;
  if (cs === 'RUNNING' || cs === 'CREATED') { console.error('ABORT: a QUERY bulk op is already running:', cs); process.exit(2); }

  const start = await gql(BULK_QUERY);
  const ue = start.data && start.data.bulkOperationRunQuery && start.data.bulkOperationRunQuery.userErrors;
  if (start.errors || (ue && ue.length)) { console.error('FATAL start:', JSON.stringify(start.errors || ue)); process.exit(1); }
  console.log('bulk op started:', JSON.stringify(start.data.bulkOperationRunQuery.bulkOperation));

  let url = null;
  for (let i = 0; i < 240; i++) { // up to ~40 min
    await sleep(10000);
    const r = await gql(`{currentBulkOperation(type:QUERY){id status objectCount url errorCode}}`);
    const op = r.data.currentBulkOperation;
    process.stdout.write(`\r  [${i}] status=${op.status} objects=${op.objectCount || 0}        `);
    if (op.status === 'COMPLETED') { url = op.url; console.log('\nCOMPLETED objects=' + op.objectCount); break; }
    if (['FAILED', 'CANCELED', 'EXPIRED'].includes(op.status)) { console.error('\nFATAL bulk op', op.status, op.errorCode); process.exit(1); }
  }
  if (!url) { console.error('\nFATAL: timed out waiting for bulk op'); process.exit(1); }
  await download(url, OUT);
  const lines = fs.readFileSync(OUT, 'utf8').trim().split('\n').filter(Boolean).length;
  console.log(`downloaded ${lines} lines -> ${OUT}`);
}
main().catch(e => { console.error('FATAL', e.message); process.exit(1); });