[object Object]

← back to Designerwallcoverings

fix(dwpw-grs-migrate): image verify accepts ATTACHED media, not just featuredImage (eventual-consistency, TK-11306)

d46e031614bc554b73a6519c3fc51d5ee18d1074 · 2026-09-08 13:42:48 -0700 · Steve Abrams

Freshly-created GRS products failed step-2 image verify with NEEDS_IMAGE and
were held DRAFT even though their source URL was a validated HTTP 200 and the
media was attached. Root cause: Shopify ingests attached images asynchronously
(PENDING->PROCESSING->READY); product.featuredImage/images read back empty until
READY, so the old check (v_image = chk.has_image = bool(featuredImage)) saw "no
image" for the ~seconds after productCreateMedia and skipped publish.

Fix:
- Read product media in find_grs / find_grs_by_id (media(first:20){...MediaImage});
  has_image now = featuredImage OR an image media node is attached.
- New verify_image(pid): bounded read-your-writes poll by product id (~20s) for
  status READY; if still PROCESSING/UPLOADED after the window, ACCEPT as present
  (source URL was pre-validated 200). Genuine NEEDS_IMAGE only when NO media is
  attached at all, or every attached image FAILED to ingest (dead-URL rows).
- APPLY step-2 uses verify_image instead of featuredImage; DRY-RUN plan mirrors
  the same media-attached semantics (existing_img_ok) so it predicts publish
  truthfully.

Dry-run over the 12 previously-failing rows: all show image present / would-
publish; offline decision-table test confirms FAILED + NONE still correctly
read NEEDS_IMAGE. Also keeps --grs comma-list support.

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

Files touched

Diff

commit d46e031614bc554b73a6519c3fc51d5ee18d1074
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 13:42:48 2026 -0700

    fix(dwpw-grs-migrate): image verify accepts ATTACHED media, not just featuredImage (eventual-consistency, TK-11306)
    
    Freshly-created GRS products failed step-2 image verify with NEEDS_IMAGE and
    were held DRAFT even though their source URL was a validated HTTP 200 and the
    media was attached. Root cause: Shopify ingests attached images asynchronously
    (PENDING->PROCESSING->READY); product.featuredImage/images read back empty until
    READY, so the old check (v_image = chk.has_image = bool(featuredImage)) saw "no
    image" for the ~seconds after productCreateMedia and skipped publish.
    
    Fix:
    - Read product media in find_grs / find_grs_by_id (media(first:20){...MediaImage});
      has_image now = featuredImage OR an image media node is attached.
    - New verify_image(pid): bounded read-your-writes poll by product id (~20s) for
      status READY; if still PROCESSING/UPLOADED after the window, ACCEPT as present
      (source URL was pre-validated 200). Genuine NEEDS_IMAGE only when NO media is
      attached at all, or every attached image FAILED to ingest (dead-URL rows).
    - APPLY step-2 uses verify_image instead of featuredImage; DRY-RUN plan mirrors
      the same media-attached semantics (existing_img_ok) so it predicts publish
      truthfully.
    
    Dry-run over the 12 previously-failing rows: all show image present / would-
    publish; offline decision-table test confirms FAILED + NONE still correctly
    read NEEDS_IMAGE. Also keeps --grs comma-list support.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01C6KGNY395sd4PKXbEzXbgV
---
 scripts/dwpw-grs-migrate.py | 120 +++++++++++++++++++++++++++++++++++---------
 1 file changed, 97 insertions(+), 23 deletions(-)

diff --git a/scripts/dwpw-grs-migrate.py b/scripts/dwpw-grs-migrate.py
index 505765f..33f3bd9 100644
--- a/scripts/dwpw-grs-migrate.py
+++ b/scripts/dwpw-grs-migrate.py
@@ -160,37 +160,94 @@ def metafields_for(row):
     return mfs
 
 # ---------------------------------------------------------------- reads
-def find_grs(grs):
-    """Return {id, handle, status, variants:[{id,sku,title,price,position,tracked}], has_image} or None."""
-    q = '''query($qy:String!){ products(first:5, query:$qy){ edges{ node{
+# Shared product-node projection. Includes media(...) so image presence reflects
+# "media ATTACHED" (even while Shopify ingests it async) rather than only the
+# featuredImage, which reads back empty until the media status reaches READY.
+_PRODUCT_FIELDS = '''
       id handle status featuredImage{ url }
-      variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } } } } } }'''
+      media(first:20){ edges{ node{ mediaContentType status ... on MediaImage { id } } } }
+      variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } }'''
+
+def _media_info(n):
+    """Return (has_image_media, [statuses]) for a product node's IMAGE media."""
+    has_media = False
+    statuses = []
+    for e in ((n.get("media") or {}).get("edges") or []):
+        node = e["node"]
+        if node.get("mediaContentType") == "IMAGE":
+            has_media = True
+            if node.get("status"):
+                statuses.append(node["status"])
+    return has_media, statuses
+
+def _parse_node(n):
+    vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
+           "price": x["node"]["price"], "position": x["node"]["position"],
+           "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
+    featured = bool(n.get("featuredImage"))
+    has_media, statuses = _media_info(n)
+    return {"id": n["id"], "handle": n["handle"], "status": n["status"], "variants": vs,
+            "featured": featured, "has_media": has_media, "media_statuses": statuses,
+            # image is "present" when a media node is attached OR the featuredImage
+            # is already populated — NOT featuredImage-only (which lags ingest).
+            "has_image": featured or has_media}
+
+def find_grs(grs):
+    """Return the shared product dict (see _parse_node) or None, via the SKU search index."""
+    q = '''query($qy:String!){ products(first:5, query:$qy){ edges{ node{''' + _PRODUCT_FIELDS + ''' } } } }'''
     d = gql(q, {"qy": f"sku:{grs}"})
     for e in d.get("data", {}).get("products", {}).get("edges", []):
         n = e["node"]
-        vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
-               "price": x["node"]["price"], "position": x["node"]["position"],
-               "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
-        if any(v["sku"] == grs or v["sku"] == grs + "-Sample" for v in vs):
-            return {"id": n["id"], "handle": n["handle"], "status": n["status"],
-                    "variants": vs, "has_image": bool(n["featuredImage"])}
+        p = _parse_node(n)
+        if any(v["sku"] == grs or v["sku"] == grs + "-Sample" for v in p["variants"]):
+            return p
     return None
 
 def find_grs_by_id(pid):
     """Re-read a product by its GID (read-your-writes consistent, unlike the SKU
     search index which lags a fresh productCreate). Same shape as find_grs()."""
-    q = '''query($id:ID!){ product(id:$id){
-      id handle status featuredImage{ url }
-      variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } } } }'''
+    q = '''query($id:ID!){ product(id:$id){''' + _PRODUCT_FIELDS + ''' } }'''
     d = gql(q, {"id": pid})
     n = (d.get("data") or {}).get("product")
     if not n:
         return None
-    vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
-           "price": x["node"]["price"], "position": x["node"]["position"],
-           "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
-    return {"id": n["id"], "handle": n["handle"], "status": n["status"],
-            "variants": vs, "has_image": bool(n["featuredImage"])}
+    return _parse_node(n)
+
+def verify_image(pid, source_url, tries=8, delay=2.5):
+    """Bounded read-your-writes poll of the product's IMAGE media (by product id).
+
+    Shopify ingests attached media asynchronously (PENDING->PROCESSING->READY), so
+    the product's featuredImage/images read back empty for several seconds after
+    productCreateMedia — which previously made a freshly-created product fail the
+    step-2 image check and get held as DRAFT even though its media WAS attached and
+    the source URL was a validated HTTP 200.
+
+    Returns (ok, state, statuses):
+      ok=True  -> media is present: READY, or still PROCESSING/UPLOADED after the
+                  bounded window (accepted because the source URL was pre-validated
+                  200 and the media node exists — it will become READY).
+      ok=False -> NO media attached at all, or every attached image FAILED to
+                  ingest = genuine NEEDS_IMAGE (e.g. the known dead-URL rows).
+    Bounded to ~tries*delay seconds (~20s)."""
+    last = None
+    for i in range(tries):
+        last = find_grs_by_id(pid)
+        final = (i == tries - 1)
+        if last:
+            sts = set(last["media_statuses"])
+            if last["featured"] or "READY" in sts:
+                return True, "READY", last["media_statuses"]
+            if last["has_media"]:
+                if sts and sts <= {"FAILED"}:
+                    return False, "FAILED", last["media_statuses"]  # ingest failed -> no image
+                if final:
+                    # attached but still processing -> ACCEPT (URL was pre-validated 200)
+                    return True, "PROCESSING", last["media_statuses"]
+            elif final:
+                return False, "NONE", last["media_statuses"]  # nothing attached
+        if not final:
+            time.sleep(delay)
+    return False, "NONE", (last["media_statuses"] if last else [])
 
 def verify_read(pid, grs, tries=6, delay=2):
     """Authoritative post-write re-read. Prefer by-ID (immediately consistent);
@@ -420,6 +477,16 @@ def process(row, apply):
     img_status = http_status(row["image"])
     image_ok = (img_status == 200)
     plan["image_status"] = img_status
+    # For an already-existing product, an image that is ATTACHED but still PROCESSING
+    # counts as present (same rule the APPLY-path verify_image uses); only "no media
+    # attached / all FAILED" is a real no-image.
+    existing_img_ok = False
+    if existing:
+        _sts = set(existing.get("media_statuses") or [])
+        existing_img_ok = existing["featured"] or (existing["has_media"] and _sts != {"FAILED"})
+        plan["existing_image"] = {"featured": existing["featured"],
+                                  "has_media": existing["has_media"],
+                                  "statuses": existing["media_statuses"]}
     try:
         price = float(row["dw_price"]); cost = float(row["cost_yd"])
         price_ok = abs(price - 3 * cost) <= 0.02
@@ -446,7 +513,7 @@ def process(row, apply):
     # publish gate: image + width metafield required to go ACTIVE.
     # width metafield is ALWAYS set by create/update. image gate = image_ok OR
     # (update path where product already had an image).
-    will_have_image = image_ok or (existing and existing["has_image"])
+    will_have_image = image_ok or existing_img_ok
     can_publish = (skip is None) and bool(will_have_image)
     plan["would_publish"] = can_publish
     if skip:
@@ -479,14 +546,20 @@ def process(row, apply):
     # productCreate and previously returned None here -> false VERIFY_DRAFT_FAILED).
     chk = verify_read(pid, grs)
     v_variants = bool(chk and len(chk["variants"]) >= 2)
-    v_image = bool(chk and chk["has_image"])
     v_price_ok = price_ok
-    v_img_http = (http_status(row["image"]) == 200)
     if not (v_variants and v_price_ok):
         res["result"] = "VERIFY_DRAFT_FAILED"; res["detail"] = {"variants": v_variants, "price_ok": v_price_ok}
         return res  # leave DWPW untouched
+    # Image gate: treat the image as present when MEDIA IS ATTACHED to the product
+    # (a media node exists) AND the source URL was pre-validated 200 — even if the
+    # media is still PROCESSING (Shopify ingests async; featuredImage lags READY).
+    # Poll the product's media by id for a bounded window; a still-processing image
+    # is accepted. Genuinely NEEDS_IMAGE only when NO media is attached / all FAILED.
+    v_image, img_state, img_statuses = verify_image(pid, row["image"])
+    res["image_state"] = img_state; res["image_statuses"] = img_statuses
     if not v_image:
-        # no image -> cannot go ACTIVE; leave DRAFT + Needs-Image, do NOT archive DWPW
+        # no image attached (or ingest FAILED) -> cannot go ACTIVE; leave DRAFT +
+        # Needs-Image, do NOT archive DWPW.
         res["result"] = "LEFT_DRAFT_NEEDS_IMAGE"; return res
     # STEP 3 publish
     publish_active(pid, grs, prev_status)
@@ -521,7 +594,8 @@ def main():
 
     rows = json.load(open(args.batch))
     if args.grs:
-        rows = [r for r in rows if r["grs"] == args.grs]
+        _grs_set = set(s.strip() for s in args.grs.split(",") if s.strip())
+        rows = [r for r in rows if r["grs"] in _grs_set]
     if args.limit:
         rows = rows[:args.limit]
 

← 9e97f6a auto-data-snapshot: 2026-09-08T13:39:29 (1 data files) — scr  ·  back to Designerwallcoverings  ·  fix(dwpw-grs-migrate): --grs comma-list, drop blank metafiel 35ba74f →