← back to Designer Wallcoverings
TK-11046 WS-A: sweep DRAFT/ARCHIVED for phantom deletes (root fix)
6f26210b0881e4fcfb5937f68386d8c666d2f3c3 · 2026-09-03 12:31:32 -0700 · Steve
markPhantomDeleted only 404-verified status='ACTIVE' rows, so a product
deleted while DRAFT or ARCHIVED froze at its last non-ACTIVE status forever
instead of becoming DELETED_FROM_SHOPIFY. Add markPhantomDeletedNonActive on a
SEPARATE budget (own candidate cap 300/run, own confirmed cap 250/run, own
broken-crawl guard) so the non-active backlog drains over several nightly runs
and can never trip the ACTIVE 500-candidate circuit-breaker. ACTIVE sweep is
byte-for-byte unchanged. Concurrency-safe UPDATE (AND status=old) per Cody.
DUPLICATE_IMPORT opt-in only (provenance unaudited).
Cody /contrarian: 4x SHIP IT / 1x FIX FIRST -> folded in the status guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbqKZLbd8emwVkNrxwtG2X
Files touched
M shopify/scripts/sync-shopify-products.js
Diff
commit 6f26210b0881e4fcfb5937f68386d8c666d2f3c3
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 3 12:31:32 2026 -0700
TK-11046 WS-A: sweep DRAFT/ARCHIVED for phantom deletes (root fix)
markPhantomDeleted only 404-verified status='ACTIVE' rows, so a product
deleted while DRAFT or ARCHIVED froze at its last non-ACTIVE status forever
instead of becoming DELETED_FROM_SHOPIFY. Add markPhantomDeletedNonActive on a
SEPARATE budget (own candidate cap 300/run, own confirmed cap 250/run, own
broken-crawl guard) so the non-active backlog drains over several nightly runs
and can never trip the ACTIVE 500-candidate circuit-breaker. ACTIVE sweep is
byte-for-byte unchanged. Concurrency-safe UPDATE (AND status=old) per Cody.
DUPLICATE_IMPORT opt-in only (provenance unaudited).
Cody /contrarian: 4x SHIP IT / 1x FIX FIRST -> folded in the status guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbqKZLbd8emwVkNrxwtG2X
---
shopify/scripts/sync-shopify-products.js | 221 +++++++++++++++++++++++++++++++
1 file changed, 221 insertions(+)
diff --git a/shopify/scripts/sync-shopify-products.js b/shopify/scripts/sync-shopify-products.js
index a5864b2f..e5f2af0e 100644
--- a/shopify/scripts/sync-shopify-products.js
+++ b/shopify/scripts/sync-shopify-products.js
@@ -106,6 +106,36 @@ const MAX_CANDIDATES_TO_CHECK = 500;
// the number of rows we're about to irreversibly mark as deleted.
const MAX_CONFIRMED_DELETIONS_PER_RUN = 50;
+// ─────────────────────────────────────────────────────────────────────────────
+// NON-ACTIVE deletion sweep (DRAFT / ARCHIVED) — TK-11046.
+// ROOT CAUSE fixed: markPhantomDeleted only ever considered status='ACTIVE' rows
+// as deletion candidates, so a product deleted while DRAFT or ARCHIVED froze at its
+// last-known non-ACTIVE status forever instead of becoming DELETED_FROM_SHOPIFY.
+// This sweep 404-verifies non-active candidates too, but on a SEPARATE budget so the
+// (larger) non-active backlog can never trip the ACTIVE circuit-breaker above, and a
+// broken/partial crawl can never mass-corrupt the mirror.
+//
+// Why this is safe even against a "crawl returned only active" bug:
+// 1. BROKEN-CRAWL GUARD — a HEALTHY full crawl re-touches ~all non-active rows
+// (empirically ~99.6% ARCHIVED / ~96.8% DRAFT). If >NONACTIVE_ABSENT_RATIO_ABORT
+// of non-active rows are ABSENT from this crawl's seen-set, the crawl did not
+// paginate them → skip the sweep entirely rather than 404-storm the whole backlog.
+// 2. PER-RUN CANDIDATE CAP — verify at most N (oldest-first) per run, so even a real
+// backlog drains over several nightly runs, never one giant burst.
+// 3. LIVE 404-VERIFY — every candidate is GET-checked live; a product that still
+// exists (even ARCHIVED, which is NOT deleted) returns 200 and is left untouched.
+// 4. PER-RUN CONFIRMED CAP — a final backstop on marks/run against a token-scope
+// failure that could make many live products 404.
+// 5. BACKUP-BEFORE-WRITE — old_status recorded to a JSON before any UPDATE.
+const NONACTIVE_STATUSES = ['DRAFT', 'ARCHIVED']; // DUPLICATE_IMPORT is opt-in (below)
+const NONACTIVE_STALE = { DRAFT: '2 days', ARCHIVED: '3 days', DUPLICATE_IMPORT: '3 days' };
+const NONACTIVE_MAX_CANDIDATES_PER_RUN = 300; // 404-verify at most N non-active rows/run
+const NONACTIVE_MAX_CONFIRMED_DELETIONS_PER_RUN = 250; // backstop cap on marks/run
+const NONACTIVE_ABSENT_RATIO_ABORT = 0.5; // >50% non-active absent ⇒ crawl broken ⇒ skip
+// DUPLICATE_IMPORT is NOT swept by default: those rows are a dedup artifact the crawl
+// never touches (provenance unaudited), so blanket-marking them is unsafe. Opt in with
+// SWEEP_DUPLICATE_IMPORT=1 only after their provenance is understood (see TK-11046 WS-B audit).
+
async function markPhantomDeleted(db, shopifyClient, options = {}) {
const { seenIds = new Set(), fullCrawlComplete = false } = options;
@@ -263,6 +293,188 @@ async function markPhantomDeleted(db, shopifyClient, options = {}) {
console.log(` 💾 Backup : ${backupFile}`);
}
+/**
+ * markPhantomDeletedNonActive — the DRAFT/ARCHIVED half of deletion detection (TK-11046).
+ *
+ * markPhantomDeleted (above) only ever considered status='ACTIVE' rows, so a product
+ * deleted while DRAFT or ARCHIVED never became DELETED_FROM_SHOPIFY. This function does
+ * the identical live-404-verify for the non-active statuses, on a SEPARATE budget so it
+ * can never trip the ACTIVE circuit-breaker and never mass-corrupt the mirror.
+ *
+ * Purely additive: it does NOT touch the ACTIVE sweep or its safety constants.
+ *
+ * @param {Pool} db
+ * @param {object} shopifyClient { store, token, apiVersion }
+ * @param {object} options { seenIds:Set<string>, fullCrawlComplete:boolean }
+ */
+async function markPhantomDeletedNonActive(db, shopifyClient, options = {}) {
+ const { seenIds = new Set(), fullCrawlComplete = false } = options;
+
+ if (!fullCrawlComplete) {
+ console.log('\n⏭️ Skipping non-active deletion detection — full crawl did not complete cleanly.');
+ return;
+ }
+
+ const sweepStatuses = [...NONACTIVE_STATUSES];
+ if (process.env.SWEEP_DUPLICATE_IMPORT === '1') sweepStatuses.push('DUPLICATE_IMPORT');
+
+ console.log('\n' + '='.repeat(60));
+ console.log('🔍 NON-ACTIVE DELETION DETECTION (DRAFT/ARCHIVED backlog drain)');
+ console.log('='.repeat(60));
+ console.log(` Statuses swept: ${sweepStatuses.join(', ')}`);
+
+ const seenArr = [...seenIds];
+
+ // BROKEN-CRAWL GUARD — a healthy full crawl re-touches ~all non-active rows. If most
+ // non-active rows are ABSENT from this crawl's seen-set, the crawl did not paginate
+ // them (a status filter regressed, an early break, a rate-limit drop) — skip rather
+ // than 404-storm the entire non-active backlog. DUPLICATE_IMPORT is excluded from the
+ // ratio because it is never crawl-touched by design (always absent, would skew it).
+ const ratioRes = await db.query(
+ `SELECT count(*)::int AS total,
+ count(*) FILTER (WHERE shopify_id != ALL($1::text[]))::int AS absent
+ FROM shopify_products
+ WHERE status = ANY($2::text[])`,
+ [seenArr, NONACTIVE_STATUSES]
+ );
+ const { total, absent } = ratioRes.rows[0];
+ if (total > 0 && absent / total > NONACTIVE_ABSENT_RATIO_ABORT) {
+ console.warn(
+ `\n ⚠️ BROKEN-CRAWL GUARD: ${absent}/${total} non-active rows absent from this crawl ` +
+ `(> ${Math.round(NONACTIVE_ABSENT_RATIO_ABORT * 100)}%). A healthy full crawl re-touches ~all ` +
+ `non-active rows, so this suggests the crawl did not fetch them. Skipping non-active sweep.`
+ );
+ return;
+ }
+ console.log(` Non-active rows absent from crawl: ${absent}/${total} (guard threshold ${Math.round(NONACTIVE_ABSENT_RATIO_ABORT * 100)}%)`);
+
+ // Candidate query: absent from the seen-set AND stale (per-status floor) AND has a
+ // gid. Oldest synced_at first so the oldest backlog drains first; capped per run.
+ const staleClauses = sweepStatuses
+ .map(s => `(status = '${s}' AND synced_at < now() - interval '${NONACTIVE_STALE[s]}')`)
+ .join(' OR ');
+ const candidateResult = await db.query(
+ `SELECT shopify_id, handle, sku, title, status
+ FROM shopify_products
+ WHERE (${staleClauses})
+ AND coalesce(shopify_id, '') <> ''
+ AND shopify_id != ALL($1::text[])
+ ORDER BY synced_at ASC
+ LIMIT ${NONACTIVE_MAX_CANDIDATES_PER_RUN}`,
+ [seenArr]
+ );
+ const candidates = candidateResult.rows;
+ console.log(` Candidates this run (cap ${NONACTIVE_MAX_CANDIDATES_PER_RUN}): ${candidates.length}`);
+
+ if (candidates.length === 0) {
+ console.log(' ✅ No non-active candidates — backlog drained.');
+ return;
+ }
+
+ // Live-verify every candidate via Shopify REST (same 250ms spacing + 10s timeout).
+ const { store, token, apiVersion } = shopifyClient;
+ const confirmedDeleted = [];
+ let confirmedLive = 0;
+ let skippedErrors = 0;
+
+ console.log(`\n Verifying ${candidates.length} non-active candidates via REST...`);
+
+ for (const row of candidates) {
+ const numericId = row.shopify_id.split('/').pop();
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 10_000);
+ let resp;
+ try {
+ resp = await fetch(
+ `https://${store}/admin/api/${apiVersion}/products/${numericId}.json`,
+ { headers: { 'X-Shopify-Access-Token': token }, signal: controller.signal }
+ );
+ } finally {
+ clearTimeout(timer);
+ }
+
+ if (resp.status === 404) {
+ confirmedDeleted.push(row);
+ console.log(` 🗑️ 404 [${row.status}]: ${row.sku || row.handle || numericId} — ${(row.title || '').slice(0, 45)}`);
+ } else if (resp.status === 200) {
+ confirmedLive++;
+ } else {
+ console.warn(` ⚠️ HTTP ${resp.status} for ${row.sku || numericId} — skipping (not marking)`);
+ skippedErrors++;
+ }
+
+ await new Promise(r => setTimeout(r, 250));
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ console.warn(` ⚠️ Timeout (10s) for ${row.sku || numericId} — skipping`);
+ } else {
+ console.error(` ❌ Fetch error for ${row.sku || numericId}:`, err.message);
+ }
+ skippedErrors++;
+ }
+ }
+
+ // PER-RUN CONFIRMED CAP — backstop against a token-scope failure that makes many live
+ // products 404. Abort without writing if we somehow confirmed more than the cap.
+ if (confirmedDeleted.length > NONACTIVE_MAX_CONFIRMED_DELETIONS_PER_RUN) {
+ console.warn(
+ `\n ⚠️ WARN: ${confirmedDeleted.length} confirmed non-active deletions exceeds per-run cap ` +
+ `${NONACTIVE_MAX_CONFIRMED_DELETIONS_PER_RUN}. Aborting without marking (possible token-scope issue).`
+ );
+ return;
+ }
+
+ if (confirmedDeleted.length === 0) {
+ console.log(`\n ✅ All ${confirmedLive} non-active candidates are still live — none marked.`);
+ if (skippedErrors > 0) console.warn(` ⚠️ ${skippedErrors} skipped due to errors — re-run to retry.`);
+ return;
+ }
+
+ // BACKUP-BEFORE-WRITE — records old_status so the marking is one-command reversible.
+ const backupDir = path.join(__dirname, '..', '..', 'data');
+ if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ const backupFile = path.join(backupDir, `phantom-deletion-nonactive-backup-${ts}.json`);
+ fs.writeFileSync(backupFile, JSON.stringify({
+ savedAt: new Date().toISOString(),
+ crawlSeenCount: seenIds.size,
+ totalCandidates: candidates.length,
+ confirmedLive,
+ skippedErrors,
+ confirmedDeleted: confirmedDeleted.map(r => ({
+ shopify_id: r.shopify_id, sku: r.sku, handle: r.handle,
+ title: r.title, old_status: r.status
+ }))
+ }, null, 2));
+ console.log(`\n 💾 Backup → ${backupFile}`);
+
+ // Write — mark only confirmed-404 rows deleted. The `AND status = $2` guard (Cody
+ // TK-11046) makes this concurrency-safe: if a --quick sync flipped this row's status
+ // between the 404-verify and now, the UPDATE is a no-op rather than a clobber.
+ let markedDeleted = 0;
+ for (const row of confirmedDeleted) {
+ const res = await db.query(
+ `UPDATE shopify_products
+ SET status = 'DELETED_FROM_SHOPIFY', synced_at = NOW()
+ WHERE shopify_id = $1 AND status = $2`,
+ [row.shopify_id, row.status]
+ );
+ if (res.rowCount > 0) {
+ console.log(` ✅ Marked DELETED_FROM_SHOPIFY [was ${row.status}]: ${row.sku || row.handle}`);
+ markedDeleted++;
+ } else {
+ console.log(` ⏭️ Skipped (status changed since verify): ${row.sku || row.handle}`);
+ }
+ }
+
+ console.log('\n Non-active deletion summary:');
+ console.log(` 🗑️ Marked DELETED_FROM_SHOPIFY : ${markedDeleted}`);
+ console.log(` ✅ Confirmed still live (skipped) : ${confirmedLive}`);
+ console.log(` ⚠️ Skipped on error/timeout : ${skippedErrors}`);
+ console.log(` 💾 Backup : ${backupFile}`);
+}
+
async function syncProducts(quickMode = false) {
console.log(`\n🔄 Shopify Products Sync - ${new Date().toISOString()}`);
console.log(` Mode: ${quickMode ? 'Quick (new only)' : 'Full sync'}`);
@@ -515,6 +727,15 @@ async function syncProducts(quickMode = false) {
{ store: SHOPIFY_STORE, token: SHOPIFY_TOKEN, apiVersion: API_VERSION },
{ seenIds: seenShopifyIds, fullCrawlComplete }
);
+ // TK-11046: also sweep DRAFT/ARCHIVED (previously frozen at their last non-ACTIVE
+ // status forever). Separate budget — see markPhantomDeletedNonActive. This runs
+ // AFTER the ACTIVE sweep; its own broken-crawl guard independently protects it if
+ // the crawl was partial (the ACTIVE circuit-breaker returns early on a broken crawl).
+ await markPhantomDeletedNonActive(
+ pool,
+ { store: SHOPIFY_STORE, token: SHOPIFY_TOKEN, apiVersion: API_VERSION },
+ { seenIds: seenShopifyIds, fullCrawlComplete }
+ );
} catch (err) {
// Deletion detection failing must not prevent the pool from closing or the
// sync from reporting success — the main sync already completed cleanly.
← c6e825b1 git-bloat Layer 1: gitignore broad room-render/snapshot patt
·
back to Designer Wallcoverings
·
auto-data-snapshot: 2026-09-03T12:32:21 (1 data files) — sho a6231040 →