← 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;
}