[object Object]

← back to Gmc Titlefix

TK-11847 Fix Option B: incremental result persistence, transport-error handling, and --from-artifact rollback path

d9f83a81621ec2e7bb6afb026dec023f7fd05a4e · 2026-09-24 12:56:33 -0700 · Steve

- Move result persistence inside the loop (after each write) so a transport failure mid-loop leaves an audit trail
- Wrap fetch in try/catch to detect and exit loudly on transport errors (network/DNS/TLS failures)
- Parse the real resource 'name' from successful insert responses and store it for rollback use
- Require --from-artifact <path> on rollback to read the prior APPLY artifact
- If --from-artifact is missing on rollback, fail with exit code 1 and print usage
- Rollback now deletes by the real resource names (avoiding 404s on legacy-feed products)
- Fall back to guessed names if realName is absent (backward compat with old APPLY artifacts)

All three findings from the code review are now addressed:
(1a) Incremental persistence prevents loss of the audit trail on transport failure
(1b) Rollback now uses the actual created names from the APPLY artifact
(2) --from-artifact enforces correct rollback behavior and prevents silent 404s

Dry-run behavior is unchanged (zero writes before line 92).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sb592So8iWsxtsmVsiTMfq

Files touched

Diff

commit d9f83a81621ec2e7bb6afb026dec023f7fd05a4e
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 24 12:56:33 2026 -0700

    TK-11847 Fix Option B: incremental result persistence, transport-error handling, and --from-artifact rollback path
    
    - Move result persistence inside the loop (after each write) so a transport failure mid-loop leaves an audit trail
    - Wrap fetch in try/catch to detect and exit loudly on transport errors (network/DNS/TLS failures)
    - Parse the real resource 'name' from successful insert responses and store it for rollback use
    - Require --from-artifact <path> on rollback to read the prior APPLY artifact
    - If --from-artifact is missing on rollback, fail with exit code 1 and print usage
    - Rollback now deletes by the real resource names (avoiding 404s on legacy-feed products)
    - Fall back to guessed names if realName is absent (backward compat with old APPLY artifacts)
    
    All three findings from the code review are now addressed:
    (1a) Incremental persistence prevents loss of the audit trail on transport failure
    (1b) Rollback now uses the actual created names from the APPLY artifact
    (2) --from-artifact enforces correct rollback behavior and prevents silent 404s
    
    Dry-run behavior is unchanged (zero writes before line 92).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Sb592So8iWsxtsmVsiTMfq
---
 tk11847-colorway-override.mjs | 85 +++++++++++++++++++++++++++++++++++--------
 1 file changed, 69 insertions(+), 16 deletions(-)

diff --git a/tk11847-colorway-override.mjs b/tk11847-colorway-override.mjs
index 1d10a70..f0f71c0 100644
--- a/tk11847-colorway-override.mjs
+++ b/tk11847-colorway-override.mjs
@@ -19,14 +19,15 @@
  *   apply requires BOTH --apply AND --yes-i-am-steve.
  * REVERSIBLE — rollback = productInputs.delete of each inserted input from the supplemental DS
  *   (the primary Shopify-channel input is untouched; a supplemental input only adds/overrides the
- *   attributes it carries). Rollback: node tk11847-colorway-override.mjs --rollback --yes-i-am-steve
+ *   attributes it carries). Rollback reads the APPLY artifact to get the real resource names.
+ *   Rollback: node tk11847-colorway-override.mjs --rollback --yes-i-am-steve --from-artifact <path>
  * GUARDS — never sends title/price/link/availability; resolves the DS by displayName + verified
  *   primary link (fail-closed); refuses any offer whose live processed title contains "Sample";
  *   refuses Vista Wine (44736900464691) unconditionally; re-reads live state before any write.
  *
  * DRY-RUN:  node tk11847-colorway-override.mjs
  * APPLY:    node tk11847-colorway-override.mjs --apply --yes-i-am-steve
- * ROLLBACK: node tk11847-colorway-override.mjs --rollback --yes-i-am-steve
+ * ROLLBACK: node tk11847-colorway-override.mjs --rollback --yes-i-am-steve --from-artifact data/tk11847-colorway-override-APPLY-2026-09-24T...json
  */
 import fs from 'fs';
 import { createRequire } from 'module';
@@ -91,22 +92,74 @@ const art = `data/tk11847-colorway-override-${ROLLBACK ? 'ROLLBACK' : APPLY ? 'A
 const record = { ticket: 'TK-11847', mode: ROLLBACK ? 'ROLLBACK' : APPLY ? 'APPLY' : 'DRY-RUN', at: new Date().toISOString(), dataSource: DS, hold: [...HOLD], rows, results: [] };
 if (!APPLY && !ROLLBACK) { fs.writeFileSync(art, JSON.stringify(record, null, 1)); console.log('\nartifact:', art, '\nDRY-RUN only — ZERO writes. Apply path (Steve): --apply --yes-i-am-steve'); process.exit(0); }
 
-for (const r of rows) {
-  if (!ROLLBACK && !r.stillLR) { record.results.push({ offerId: r.offerId, skipped: 'self-cleared' }); continue; }
-  const name = `accounts/${MERCHANT}/productInputs/online~en~US~${r.offerId}`;
-  let resp;
-  if (ROLLBACK) {
-    resp = await fetch(`https://merchantapi.googleapis.com/products/v1/${name}?dataSource=${encodeURIComponent(DS)}`, { method: 'DELETE', headers: H });
-  } else {
-    const url = `https://merchantapi.googleapis.com/products/v1/accounts/${MERCHANT}/productInputs:insert?dataSource=${encodeURIComponent(DS)}`;
-    const body = { offerId: r.offerId, contentLanguage: 'en', feedLabel: 'US', productAttributes: r.productAttributes };
-    resp = await fetch(url, { method: 'POST', headers: H, body: JSON.stringify(body) });
+// Rollback: load the prior APPLY artifact to read the real resource names
+let appliedResults = null;
+if (ROLLBACK) {
+  const fromArtifact = process.argv.includes('--from-artifact')
+    ? process.argv[process.argv.indexOf('--from-artifact') + 1]
+    : null;
+  if (!fromArtifact) {
+    console.error('❌ ROLLBACK requires --from-artifact <path>');
+    console.error('   Example: node tk11847-colorway-override.mjs --rollback --yes-i-am-steve --from-artifact data/tk11847-colorway-override-APPLY-2026-09-24T12-34-56.json');
+    process.exit(1);
   }
-  const txt = await resp.text();
-  record.results.push({ offerId: r.offerId, http: resp.status, ok: resp.ok, body: txt.slice(0, 300) });
-  console.log(ROLLBACK ? 'DELETE' : 'INSERT', r.offerId, 'HTTP', resp.status, resp.ok ? '' : txt.slice(0, 160));
-  await new Promise(z => setTimeout(z, 400));
+  try {
+    const artifact = JSON.parse(fs.readFileSync(fromArtifact, 'utf8'));
+    if (artifact.mode !== 'APPLY') {
+      throw new Error(`artifact mode is '${artifact.mode}', expected 'APPLY'`);
+    }
+    appliedResults = new Map((artifact.results || []).map(r => [r.offerId, r]));
+    console.log(`loaded ${appliedResults.size} prior results from ${fromArtifact} (mode: ${artifact.mode})`);
+  } catch (e) {
+    console.error(`❌ Failed to read --from-artifact: ${e.message}`);
+    process.exit(1);
+  }
+}
+
+let lastResultArtifact = null;  // side effect: write after each mutation
+try {
+  for (const r of rows) {
+    if (!ROLLBACK && !r.stillLR) { record.results.push({ offerId: r.offerId, skipped: 'self-cleared' }); continue; }
+    const result = appliedResults?.get(r.offerId);
+    const deleteTarget = result?.realName || `accounts/${MERCHANT}/productInputs/online~en~US~${r.offerId}`;
+    let resp, realName;
+    if (ROLLBACK) {
+      resp = await fetch(`https://merchantapi.googleapis.com/products/v1/${deleteTarget}?dataSource=${encodeURIComponent(DS)}`, { method: 'DELETE', headers: H });
+    } else {
+      // APPLY: insert via supplemental, then parse the real resource name from the response
+      const url = `https://merchantapi.googleapis.com/products/v1/accounts/${MERCHANT}/productInputs:insert?dataSource=${encodeURIComponent(DS)}`;
+      const body = { offerId: r.offerId, contentLanguage: 'en', feedLabel: 'US', productAttributes: r.productAttributes };
+      resp = await fetch(url, { method: 'POST', headers: H, body: JSON.stringify(body) });
+      if (resp.ok) {
+        try {
+          const respBody = await resp.json();
+          realName = respBody.name;  // captured for rollback use
+        } catch (e) {
+          console.warn(`  (response parse failed for ${r.offerId}, using guess)`, e.message);
+        }
+      }
+    }
+    const txt = await resp.text();
+    const recordResult = { offerId: r.offerId, http: resp.status, ok: resp.ok, body: txt.slice(0, 300) };
+    if (realName) recordResult.realName = realName;  // store the name Google returned
+    record.results.push(recordResult);
+    console.log(ROLLBACK ? 'DELETE' : 'INSERT', r.offerId, 'HTTP', resp.status, resp.ok ? '' : txt.slice(0, 160));
+    // CRITICAL: flush after every write so a transport failure mid-loop leaves an audit trail
+    fs.writeFileSync(art, JSON.stringify(record, null, 1));
+    lastResultArtifact = art;
+    await new Promise(z => setTimeout(z, 400));
+  }
+} catch (e) {
+  if (e instanceof TypeError && e.message.includes('fetch')) {
+    // transport failure: network, DNS, TLS, or connection reset
+    console.error(`\n⚠️  TRANSPORT FAILURE at offer index ${record.results.length}:`, e.message);
+    console.error(`Partial results written to: ${lastResultArtifact || art}`);
+    console.error('Review the artifact, fix the issue, and retry with --rollback to clean up what was applied.\n');
+    process.exit(2);  // distinct exit code: not a user gate, a system error
+  }
+  throw e;  // re-throw for other errors (Google API errors, JSON parse, etc.)
 }
+// If loop completed without transport error, final flush is redundant but harmless
 fs.writeFileSync(art, JSON.stringify(record, null, 1));
 console.log('\nartifact:', art);
 if (!ROLLBACK) {

← b01c159 auto-data-snapshot: 2026-09-24T12:49:23 (1 data files) — tk1  ·  back to Gmc Titlefix  ·  TK-11847 fix double Response-body read in APPLY path 7b93cd7 →