← 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 →