[object Object]

← back to Designerwallcoverings

Restore orphan-publish-guard (silent ~1wk death): recover script from git, add observability heartbeat, fix launchd psql PATH crash — detect-only, --enforce stays gated

f55e542fafe3e95ae5bd0f7648868505931e860b · 2026-08-06 12:14:31 -0700 · Steve Abrams

Files touched

Diff

commit f55e542fafe3e95ae5bd0f7648868505931e860b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:14:31 2026 -0700

    Restore orphan-publish-guard (silent ~1wk death): recover script from git, add observability heartbeat, fix launchd psql PATH crash — detect-only, --enforce stays gated
---
 orphan-publish-guard.mjs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 87 insertions(+)

diff --git a/orphan-publish-guard.mjs b/orphan-publish-guard.mjs
new file mode 100644
index 0000000..a346a57
--- /dev/null
+++ b/orphan-publish-guard.mjs
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+// ORPHAN PUBLISH GUARD — prevents the "slug-SKU sample-only orphan" class from staying live.
+// Born 2026-07-16 after venice-beach-los-angeles (+577 siblings) were found ACTIVE on the
+// Online Store with mfr_sku=upper(handle), no sellable variant, no price.
+//
+// There is no single publish chokepoint (products reach ACTIVE+online_store via the drain,
+// vendor importers, and manual Shopify-admin edits), so this guard runs as a sweep over the
+// dw_unified mirror (importers write PG-first, so it catches them at the source) and flags — or
+// with --enforce, auto-DRAFTs — any product matching the orphan fingerprint:
+//     status=ACTIVE  AND  online_store_published  AND  mfr_sku = upper(handle)
+//     AND  has_product_variant IS NOT TRUE           (only a sample/placeholder variant)
+//
+//   node orphan-publish-guard.mjs            # REPORT only (default, no writes)
+//   node orphan-publish-guard.mjs --enforce  # auto-DRAFT offenders (reversible, ledgered)
+//
+// Reuses the reversible-ledger + Shopify productUpdate(status:DRAFT) mechanism from
+// unpublish-broken-orphans.mjs. Idempotent; safe to run on a schedule.
+import fs from 'node:fs';
+import { execFileSync } from 'node:child_process';
+
+// launchd runs with a minimal PATH that omits homebrew, so `psql` was ENOENT under the scheduled job
+// (pid:0 crash) even though it resolved in an interactive shell. Prepend the homebrew bins so the two
+// execFileSync('psql', ...) calls resolve identically under launchd and in a terminal. (fix 2026-08-06)
+process.env.PATH = `/opt/homebrew/bin:/opt/homebrew/opt/postgresql@14/bin:/usr/local/bin:${process.env.PATH || ''}`;
+
+const ENFORCE = process.argv.includes('--enforce');
+const DIR = '/Users/macstudio3/Projects/designerwallcoverings/data/orphan-cleanup-20260716';
+const LEDGER = `${DIR}/guard-enforced-ledger.jsonl`;
+// Observability heartbeat (added 2026-08-06): written on every COMPLETED run so the meta-watchdog sees
+// the guard alive+working via file-mtime + ok. A crash never reaches these writes, so a STALE heartbeat
+// surfaces a silent re-death — the exact week-long MODULE_NOT_FOUND gap that killed this guard unnoticed.
+const HB_F = `${DIR}/latest.json`;
+const writeHB = (offenders, enforced) => { try { fs.writeFileSync(HB_F, JSON.stringify({ ts: new Date().toISOString(), ok: true, mode: ENFORCE ? 'enforce' : 'detect', offenders, enforced }, null, 2)); } catch {} };
+const ENV = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1]?.trim();
+const ENDPOINT = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// Detect off the mirror (fast; importers are PG-first so offenders appear here first).
+// Fingerprint = Steve's rule (2026-07-16): the disqualifier is a MISSING/junk real SKU, NOT
+// sample-only. A product is an orphan only if its mfr_sku is the slug AND it has no real dw_sku
+// either (blank, or the slug, or a bare word with no digit). Sample-only WITH a real SKU is fine.
+const SQL = `SELECT shopify_id||E'\\t'||handle||E'\\t'||coalesce(supplier_name,'')
+FROM shopify_products
+WHERE status='ACTIVE' AND online_store_published
+  AND mfr_sku = upper(handle)
+  AND ( dw_sku IS NULL OR dw_sku='' OR NOT (dw_sku ~ '[0-9]' AND upper(dw_sku) <> upper(handle)) )
+ORDER BY handle`;
+const raw = execFileSync('psql', ['host=/tmp dbname=dw_unified', '-At', '-c', SQL]).toString().trim();
+const rows = raw ? raw.split('\n').map(l => { const [gid, handle, supplier] = l.split('\t'); return { gid, handle, supplier }; }) : [];
+
+console.log(`[orphan-publish-guard] offenders live: ${rows.length}${ENFORCE ? '  (ENFORCE — will DRAFT)' : '  (report only)'}`);
+if (!rows.length) { console.log('  clean — no slug-SKU sample-only orphans are live.'); writeHB(0, 0); process.exit(0); }
+for (const r of rows.slice(0, 20)) console.log(`  • ${r.handle}  [${r.supplier}]`);
+if (rows.length > 20) console.log(`  … +${rows.length - 20} more`);
+
+if (!ENFORCE) {
+  console.log('\nRe-run with --enforce to auto-DRAFT these (reversible; ledgered).');
+  writeHB(rows.length, 0);
+  process.exit(rows.length ? 2 : 0);   // nonzero exit → a scheduler/canary can alert
+}
+
+async function gql(q, v) {
+  for (let i = 0; i < 6; i++) {
+    let res; try { res = await fetch(ENDPOINT, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); }
+    catch { await sleep(1500 * (i + 1)); continue; }
+    if (res.status === 429 || res.status >= 500) { await sleep(2000 * (i + 1)); continue; }
+    let j; try { j = await res.json(); } catch { await sleep(2000 * (i + 1)); continue; }
+    if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
+    return j;
+  } throw new Error('exhausted');
+}
+const MUT = `mutation($id:ID!){ productUpdate(input:{id:$id, status:DRAFT}){ product{ id status } userErrors{ message } } }`;
+const ledger = fs.createWriteStream(LEDGER, { flags: 'a' });
+let ok = 0, skip = 0;
+for (const r of rows) {
+  const rr = await gql(MUT, { id: r.gid });
+  const pu = rr.data && rr.data.productUpdate;
+  const e = (pu && pu.userErrors) || [];
+  if (!pu || e.length) { skip++; ledger.write(JSON.stringify({ handle: r.handle, gid: r.gid, reason: JSON.stringify(e.length ? e : rr.errors) }) + '\n'); continue; }
+  ledger.write(JSON.stringify({ ts: new Date().toISOString(), handle: r.handle, supplier: r.supplier, gid: r.gid, action: 'DRAFT' }) + '\n');
+  execFileSync('psql', ['host=/tmp dbname=dw_unified', '-c', `UPDATE shopify_products SET status='DRAFT', online_store_published=false, synced_at=now() WHERE shopify_id='${r.gid}'`]);
+  ok++; await sleep(150);
+}
+ledger.end();
+writeHB(rows.length, ok);
+console.log(`[orphan-publish-guard] ENFORCED: drafted=${ok} skipped=${skip}`);

← 87ae1ea auto-save: 2026-08-06T08:19:59 (3 files) — scripts/price-she  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-08-06T12:25:51 (2 data files) — dat 77b3068 →