[object Object]

← back to Shopify Room Mockup

TK-12090 Fix 1: ledger the media id upload_media() already returns

3a3e31e8ed7e117fac4b82a5e97f6ab04d72bafa · 2026-09-24 07:59:28 -0700 · Steve Abrams

productCreateMedia's response already carries the MediaImage gid
(media{id status}), but the success branch only ever printed the
product title -- the id reached no ledger, so a documented undo
(productDeleteMedia for "the ids the mutation returns") was
unexecutable, because those ids existed nowhere after the run.

Adds --ledger (JSONL, default out/room-mockup-ledger.jsonl) and
--ticket. Every successful upload writes {ts, ticket, product_gid,
media_id, undo_cmd} BEFORE printing the success line, so a crash
between write and print can't lose the id. A response missing a
usable id, or a ledger-write failure, is now a loud refusal to
report success (stderr + counted failed) instead of a silent green
line with nothing recorded.

Adds --delete-media/--product as a small undo-utility mode so the
ledger's undo_cmd is a directly runnable invocation of this same
script, not a hand-assembled GraphQL call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY

Files touched

Diff

commit 3a3e31e8ed7e117fac4b82a5e97f6ab04d72bafa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 07:59:28 2026 -0700

    TK-12090 Fix 1: ledger the media id upload_media() already returns
    
    productCreateMedia's response already carries the MediaImage gid
    (media{id status}), but the success branch only ever printed the
    product title -- the id reached no ledger, so a documented undo
    (productDeleteMedia for "the ids the mutation returns") was
    unexecutable, because those ids existed nowhere after the run.
    
    Adds --ledger (JSONL, default out/room-mockup-ledger.jsonl) and
    --ticket. Every successful upload writes {ts, ticket, product_gid,
    media_id, undo_cmd} BEFORE printing the success line, so a crash
    between write and print can't lose the id. A response missing a
    usable id, or a ledger-write failure, is now a loud refusal to
    report success (stderr + counted failed) instead of a silent green
    line with nothing recorded.
    
    Adds --delete-media/--product as a small undo-utility mode so the
    ledger's undo_cmd is a directly runnable invocation of this same
    script, not a hand-assembled GraphQL call.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01KXUyzc9vybUz39rhnNJdwY
---
 shopify-room-mockup.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 76 insertions(+), 2 deletions(-)

diff --git a/shopify-room-mockup.py b/shopify-room-mockup.py
index 67133f2..c306db3 100644
--- a/shopify-room-mockup.py
+++ b/shopify-room-mockup.py
@@ -57,6 +57,7 @@ Examples:
   python3 shopify-room-mockup.py --collection 480239649 --dry-run
 """
 import argparse, base64, json, os, re, shutil, subprocess, sys, tempfile, urllib.request
+from datetime import datetime, timezone
 
 API_VERSION = '2024-10'
 
@@ -181,6 +182,24 @@ def render_room(image_url, room_engines_dir, tile_px):
         return buf if buf[:8] == bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) else None
 
 
+def append_ledger(ledger_path, row):
+    """Append one JSONL row and fsync before returning, so the row is durable on
+    disk the instant this call returns — a crash immediately after (e.g. before
+    the caller prints its success line) cannot lose the media id. Raises on
+    failure; callers MUST treat a ledger-write failure as loud, not swallow it —
+    a media object that exists live with no recorded undo is unrecoverable."""
+    os.makedirs(os.path.dirname(ledger_path) or '.', exist_ok=True)
+    with open(ledger_path, 'a') as f:
+        f.write(json.dumps(row) + '\n')
+        f.flush()
+        os.fsync(f.fileno())
+
+
+def undo_cmd_for(store, token_env, media_id, product_gid):
+    return (f'python3 shopify-room-mockup.py --store {store} --token-env {token_env} '
+            f'--delete-media {media_id} --product {product_gid}')
+
+
 def upload_media(sh, product_gid, png_bytes, alt):
     fn = 'room-mockup.png'
     su = sh.gql('mutation($i:[StagedUploadInput!]!){stagedUploadsCreate(input:$i){stagedTargets{url resourceUrl parameters{name value}} userErrors{field message}}}',
@@ -209,7 +228,7 @@ def upload_media(sh, product_gid, png_bytes, alt):
 
 def main():
     ap = argparse.ArgumentParser(description='Add photoreal room mockups to Shopify products.')
-    g = ap.add_mutually_exclusive_group(required=True)
+    g = ap.add_mutually_exclusive_group(required=False)
     g.add_argument('--ids'); g.add_argument('--query'); g.add_argument('--collection')
     ap.add_argument('--store', default='designer-laboratory-sandbox')
     ap.add_argument('--room-type', default='living_room')
@@ -224,8 +243,32 @@ def main():
     ap.add_argument('--limit', type=int, default=0)
     ap.add_argument('--dry-run', action='store_true')
     ap.add_argument('--token-env', default='SHOPIFY_ADMIN_TOKEN')
+    ap.add_argument('--ticket', default='TK-12096', help='ticket id stamped into ledger rows')
+    ap.add_argument('--ledger', default=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'out', 'room-mockup-ledger.jsonl'),
+                     help='JSONL path recording {media_id, undo_cmd, ...} for every uploaded media (TK-12090)')
+    ap.add_argument('--delete-media', default=None,
+                     help='UNDO utility mode: productDeleteMedia on this media id/gid (requires --product). '
+                          'This is exactly the invocation the ledger writes as undo_cmd.')
+    ap.add_argument('--product', default=None, help='product id/gid, used with --delete-media')
     args = ap.parse_args()
 
+    if args.delete_media:
+        if not args.product:
+            ap.error('--delete-media requires --product <product id or gid>')
+        sh = Shopify(store_host(args.store), load_token(args.token_env))
+        media_gid = args.delete_media if str(args.delete_media).startswith('gid://') else f'gid://shopify/MediaImage/{args.delete_media}'
+        pgid = gid(args.product)
+        d = sh.gql('mutation($ids:[ID!]!,$pid:ID!){productDeleteMedia(mediaIds:$ids,productId:$pid){deletedMediaIds mediaUserErrors{field message}}}',
+                   {'ids': [media_gid], 'pid': pgid})['productDeleteMedia']
+        errs = d.get('mediaUserErrors') or []
+        if errs:
+            sys.exit(f'productDeleteMedia errors: {errs}')
+        print(f'  ↩  deleted media {media_gid} from {pgid}: {d.get("deletedMediaIds")}')
+        return
+
+    if not (args.ids or args.query or args.collection):
+        ap.error('one of --ids, --query, --collection is required (or --delete-media + --product for undo mode)')
+
     if args.box:
         print('  (note) --box is deprecated/no-op — the :3075 SSH room service is retired; '
               'rendering now happens locally via room-engines/compose_real.py', file=sys.stderr)
@@ -252,7 +295,38 @@ def main():
         alt = f"{p['title']} — {args.alt_suffix}"
         ok, info = upload_media(sh, p['id'], buf, alt)
         if ok:
-            print(f'  ✓  added room: {tag}'); done += 1
+            media_id = None
+            if isinstance(info, list) and info and isinstance(info[0], dict):
+                media_id = info[0].get('id')
+            if not media_id:
+                # The mutation reported success but returned no media id we can
+                # act on — that means a live media object may exist with NO
+                # recoverable undo. Loud failure, not a silent success line.
+                print(f'  ✗  uploaded but NO media id in response — cannot ledger, refusing to report success: {tag} — {info}',
+                      file=sys.stderr)
+                failed += 1
+                continue
+            row = {
+                'ts': datetime.now(timezone.utc).isoformat(),
+                'ticket': args.ticket,
+                'store': args.store,
+                'product_gid': p['id'],
+                'product_title': p['title'],
+                'media_id': media_id,
+                'alt': alt,
+                'undo_cmd': undo_cmd_for(args.store, args.token_env, media_id, p['id']),
+            }
+            try:
+                # Write BEFORE printing success — a crash between write and
+                # print can never lose the id (TK-12090 liveness-artifact rule).
+                append_ledger(args.ledger, row)
+            except Exception as e:
+                print(f'  ✗  LEDGER WRITE FAILED for {tag} (media {media_id} now LIVE with NO recorded undo!): {e}',
+                      file=sys.stderr)
+                failed += 1
+                continue
+            print(f'  ✓  added room: {tag}  (media {media_id} ledgered -> {args.ledger})')
+            done += 1
         else:
             print(f'  ✗  upload failed: {tag} — {info}'); failed += 1
     print(f'\n[done] added={done} skipped={skipped} failed={failed}'

← 37d416c Repoint room render off the dead Kamatera :3075 service to r  ·  back to Shopify Room Mockup  ·  TK-12096: double-run guard — refuse push when ledger has a l e6427c0 →