← 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
M tk11847-colorway-override.mjs
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 →