← back to All Designerwallcoverings

scripts/shopify-images-sync.mjs

163 lines

#!/usr/bin/env node
// Shopify product-images sync  →  dw_unified.product_images
// ─────────────────────────────────────────────────────────────────────────────
// The dw_unified.shopify_products mirror keeps only the FEATURED image_url; the full
// per-product galleries live in Shopify. Steve: "need all images for unified except
// archived or disco in all. / substitutes / new." This pulls every non-archived
// product's COMPLETE image set via Shopify's Bulk Operations API (one server-side
// export → a single JSONL, minutes not hours) and loads it into product_images, keyed
// by the numeric Shopify product_id so the all-dw feed can join galleries in at serve time.
//
// COST: Shopify Admin API = free (rate-limited). SAFE: writes ONLY to product_images.
// Full-refresh: rebuilds the table each run (TRUNCATE + reload) so removed images drop out.
//
// Usage: node scripts/shopify-images-sync.mjs

import pg from 'pg';
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';

const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
const SHOPIFY_LIB = [
  process.env.SHOPIFY_LIB,
  '/Users/macstudio3/Projects/designerwallcoverings/scripts/lib/shopify.mjs',
  new URL('./lib/shopify.mjs', import.meta.url).pathname,
].find((p) => p && fs.existsSync(p));
if (!SHOPIFY_LIB) { console.error('Shopify client lib not found (set SHOPIFY_LIB)'); process.exit(1); }
const { gql, ENDPOINT, TOKEN } = await import(pathToFileURL(SHOPIFY_LIB).href);

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const pidOf = (gid) => (String(gid || '').match(/(\d+)\s*$/) || [])[1] || null;

const DDL = `
CREATE TABLE IF NOT EXISTS product_images (
  id         bigserial PRIMARY KEY,
  product_id text NOT NULL,
  handle     text,
  status     text,
  position   int,
  image_url  text NOT NULL,
  alt        text,
  width      int,
  height     int,
  synced_at  timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS product_images_pid_idx ON product_images (product_id);
`;

// One bulk query exports every non-archived product with its full image list. Shopify runs
// it server-side and returns a JSONL where each product node and each nested image node is a
// line; image lines carry __parentId = the product's gid.
const BULK_QUERY = `
{
  products(query: "status:active OR status:draft") {
    edges { node {
      id handle status
      images { edges { node { url altText width height } } }
    } }
  }
}`;

async function startBulk() {
  const m = `mutation {
    bulkOperationRunQuery(query: ${JSON.stringify(BULK_QUERY)}) {
      bulkOperation { id status }
      userErrors { field message }
    }
  }`;
  const d = await gql(m);
  const ue = d?.bulkOperationRunQuery?.userErrors;
  if (ue?.length) throw new Error('bulk start userErrors: ' + JSON.stringify(ue));
  const op = d?.bulkOperationRunQuery?.bulkOperation;
  if (!op) throw new Error('bulk start failed: ' + JSON.stringify(d));
  return op.id;
}

async function pollBulk() {
  for (let i = 0; i < 600; i++) {            // up to ~50 min (5s poll)
    const d = await gql(`{ currentBulkOperation { id status errorCode objectCount url } }`);
    const op = d?.currentBulkOperation;
    if (!op) throw new Error('no currentBulkOperation');
    if (op.status === 'COMPLETED') return op;
    if (['FAILED', 'CANCELED', 'EXPIRED'].includes(op.status)) throw new Error('bulk ' + op.status + ' ' + (op.errorCode || ''));
    if (i % 6 === 0) console.log(`  bulk ${op.status}… objects=${op.objectCount || 0}`);
    await sleep(5000);
  }
  throw new Error('bulk poll timed out');
}

async function downloadJsonl(url, dest) {
  const r = await fetch(url);
  if (!r.ok) throw new Error('download failed ' + r.status);
  const buf = Buffer.from(await r.arrayBuffer());
  fs.writeFileSync(dest, buf);
  return buf.length;
}

async function main() {
  const t0 = Date.now();
  const pool = new pg.Pool({ connectionString: DSN, max: 3 });
  await pool.query(DDL);

  console.log('starting Shopify bulk export of product images…');
  await startBulk();
  const op = await pollBulk();
  console.log(`bulk COMPLETED: ${op.objectCount} objects`);
  if (!op.url) { console.log('no url (0 products?) — nothing to load'); await pool.end(); return; }

  const jsonl = '/tmp/shopify-images-bulk.jsonl';
  const bytes = await downloadJsonl(op.url, jsonl);
  console.log(`downloaded ${(bytes / 1e6).toFixed(1)} MB → ${jsonl}`);

  // Parse: product lines have id+handle+status; image lines have url+__parentId (the product gid).
  // Assign position per product in the order images appear.
  const prodMeta = new Map();               // gid → { handle, status }
  const posByProd = new Map();              // gid → running position counter
  const lines = fs.readFileSync(jsonl, 'utf8').split('\n');
  let images = [], loaded = 0;

  await pool.query('TRUNCATE product_images');
  const flush = async () => {
    if (!images.length) return;
    // multi-row insert
    const vals = [], params = [];
    images.forEach((im, i) => {
      const b = i * 8;
      vals.push(`($${b + 1},$${b + 2},$${b + 3},$${b + 4},$${b + 5},$${b + 6},$${b + 7},$${b + 8})`);
      params.push(im.product_id, im.handle, im.status, im.position, im.image_url, im.alt, im.width, im.height);
    });
    await pool.query(
      `INSERT INTO product_images (product_id, handle, status, position, image_url, alt, width, height) VALUES ${vals.join(',')}`,
      params);
    loaded += images.length; images = [];
  };

  for (const line of lines) {
    if (!line.trim()) continue;
    let o; try { o = JSON.parse(line); } catch { continue; }
    if (o.id && o.id.includes('/Product/')) {         // product line
      prodMeta.set(o.id, { handle: o.handle || null, status: o.status || null });
      continue;
    }
    if (o.url && o.__parentId) {                       // image line
      const meta = prodMeta.get(o.__parentId) || {};
      const pid = pidOf(o.__parentId);
      const pos = (posByProd.get(o.__parentId) || 0) + 1;
      posByProd.set(o.__parentId, pos);
      images.push({ product_id: pid, handle: meta.handle, status: meta.status,
        position: pos, image_url: o.url, alt: o.altText || null, width: o.width || null, height: o.height || null });
      if (images.length >= 500) await flush();
    }
  }
  await flush();

  const { rows: [c] } = await pool.query(
    `SELECT count(*) imgs, count(distinct product_id) prods, round(avg(n),2) avg_per FROM
      (SELECT product_id, count(*) n FROM product_images GROUP BY product_id) t`);
  console.log(`\nimages sync DONE in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
  console.log(`  ${(+c.imgs).toLocaleString()} images across ${(+c.prods).toLocaleString()} products (avg ${c.avg_per}/product)`);
  await pool.end();
}

main().catch((e) => { console.error('images sync FAILED:', e.message); process.exit(1); });