← back to Dw Yolo Loop

scripts/lib/shopify.mjs

91 lines

/**
 * Shared Shopify Admin client seam for DW scripts.
 *
 * One place that owns the store endpoint, API version, token load, retry/backoff,
 * and Shopify cost-throttle sleep — so individual scripts stop re-inlining their own
 * `X-Shopify-Access-Token` + fetch-retry boilerplate. The `gql()` THROTTLED backoff +
 * cost-throttle pattern is lifted verbatim from scripts/price-sheets/add-roll-variant.mjs.
 *
 * Centralizing the write path here is also what makes the "writes go through
 * shopify_api_queue" rule enforceable: there's now a single function a guard can wrap.
 *
 * Exports:
 *   SHOP, VER, TOKEN, ENDPOINT     — connection constants
 *   gql(query, vars)               — GraphQL Admin call w/ THROTTLED backoff + throttle sleep
 *   rest(path, { method, body })   — REST Admin call (path begins after /admin/api/<VER>)
 *   restAll(path)                  — REST GET that follows Link rel="next" pagination
 *   getLocation()                  — primary location gid (read_locations scope required)
 */
import fs from 'node:fs';

export const SHOP = 'designer-laboratory-sandbox.myshopify.com';
export const VER = '2024-10';
const _env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
// Prefer the full-scope token (write_inventory + all others); fall back to the narrow custom-app token.
// TK-11055: onboarders that call inventoryItemUpdate need write_inventory scope, which the narrow
// SHOPIFY_ADMIN_TOKEN (…7d19, 4 scopes) lacks. SHOPIFY_FULL_ACCESS_TOKEN has 139 scopes.
export const TOKEN = (
  _env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m) || _env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || []
)[1]?.trim();
if (!TOKEN) { console.error('no SHOPIFY_FULL_ACCESS_TOKEN or SHOPIFY_ADMIN_TOKEN in ~/Projects/secrets-manager/.env'); process.exit(1); }

export const ENDPOINT = `https://${SHOP}/admin/api/${VER}`;
const GQL_URL = `${ENDPOINT}/graphql.json`;
const sleep = ms => new Promise(r => setTimeout(r, ms));

export async function gql(query, vars) {
  for (let a = 0; a < 8; a++) {
    let j;
    try {
      const r = await fetch(GQL_URL, {
        method: 'POST',
        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, variables: vars }),
      });
      j = await r.json();
    } catch (e) { await sleep(1500 * (a + 1)); continue; }
    if (j.errors) {
      if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
      return { __err: j.errors };
    }
    const t = j.extensions?.cost?.throttleStatus;
    if (t && t.currentlyAvailable < 400) await sleep(1200);
    return j.data;
  }
  throw new Error('gql retries exhausted');
}

export async function rest(path, { method = 'GET', body } = {}, tries = 5) {
  for (let i = 0; i < tries; i++) {
    const r = await fetch(`${ENDPOINT}${path}`, {
      method,
      headers: { 'X-Shopify-Access-Token': TOKEN, ...(body ? { 'Content-Type': 'application/json' } : {}) },
      ...(body ? { body: JSON.stringify(body) } : {}),
    });
    if (r.status === 429 || r.status >= 500) { await sleep(1500 * (i + 1)); continue; }
    await sleep(90);
    return r;
  }
  throw new Error('rest fail ' + path);
}

export async function restAll(path, key) {
  const collection = key || path.replace(/^\/([a-z_]+)\.json.*/, '$1');
  const out = [];
  let url = path;
  while (url) {
    const r = await rest(url);
    const link = r.headers.get('Link') || '';
    out.push(...(((await r.json())[collection]) || []));
    const m = link.split(',').find(s => s.includes('rel="next"'));
    url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
  }
  return out;
}

export async function getLocation() {
  const loc = await gql(`{locations(first:5){nodes{id}}}`);
  if (loc?.__err) throw new Error('cannot read locations: ' + JSON.stringify(loc.__err).slice(0, 200));
  return loc.locations.nodes[0].id;
}