ADR-0005 — CMS image pipeline¶
Status: Accepted Date: 2026-05-10 Deciders: Cathal Dempsey Related: ADR-0004 (adapter pattern, the consumer), ADR-0003 (assembler image handling)
Context¶
The per-site assembled-build path (ADR-0003) handles images: the
crawler downloads from static.wixstatic.com/media/<basename> to
builds/<domain>/public/assets/images/, the assembler bundles them
into the per-site Astro build, the per-site Cloudflare Pages deploy
serves them. This works.
The multi-tenant CMS worker (apps/cms/, ADR-0004) does not.
D1 stores Portable Text blocks with imageUrl paths like
/assets/images/c00d38_…png — these point at a public/ directory
that doesn't exist in the worker bundle. Every CMS-rendered image
404s in production.
Verified during the 2026-05-10 session: Astro's /_image optimizer
works fine against R2 source URLs (200 webp output for a test image
at replatform-emdash-media/test/garvanbay-hero.jpg). The
infrastructure is in place — bucket exists, MEDIA binding wired,
public dev URL enabled. The gap is the migration step that takes
images from local builds and pushes them to R2 with rewritten paths
in D1.
Constraints¶
- No egress fee. R2's pricing makes path-keyed multi-tenant storage near-free at portfolio scale (~$0.20/month for ~12 GB).
/_imageis the existing optimization layer. Already integrated viaastro:assets, already verified against R2. Don't introduce a separate Cloudflare Images direct-API path unless/_imageitself becomes a problem.- Per-site Astro builds keep bundling local images. This ADR is CMS-only. Don't touch ADR-0003's flow.
- R2 dev URL is throttled for production-style traffic. Map a
custom domain (
cdn.replatform.fcr.mediaor similar) before launch. Not blocking the migration step itself.
Options under consideration¶
Option A — Single bucket, path-keyed per site (recommended)¶
replatform-emdash-media/<domain>/<key> where <key> follows the
shape locked in §2 below. Reuse the existing bucket, MEDIA binding
already wired, public access already enabled. Path-prefix isolation
matches the "one CMS worker, many sites" architecture.
- Migration: ~3.75 days plumbing (transformer step + backfill + spec update + resolver helper + schema field + verification).
- Steady-state: $0.18/mo storage + ~$45/mo
/_imagetransforms at full traffic.
Option B — One bucket per site¶
replatform-<domain>/<basename>. Operationally heavier: 1800
buckets, per-bucket binding management, per-bucket dev URL.
- No real benefit at our scale. Path isolation in option A gives the same logical separation without the operational cost.
- Bucket-per-tenant is what you'd reach for if R2 had per-bucket billing isolation requirements; it doesn't.
Option C — Hot-link from static.wixstatic.com¶
Continue serving Wix's CDN directly. No migration step, no R2 cost.
- IP/ownership concern from
docs/pipeline/known-issues.md: client owns the images, but the URL points at Wix's CDN. Ongoing reliability dependency on a third party we're trying to migrate off. - Doesn't solve the actual problem — D1 paths are local
/assets/images/..., not wixstatic URLs. Would require a different rewrite step (D1 paths → wixstatic URLs), which is path-shaped work for less benefit.
Recommendation¶
Option A. ~3.75 days of plumbing, costs are rounding error, infrastructure is already in place. The other options lose on operational complexity (B) or don't solve the actual problem (C).
Implementation specifics (locked 2026-05-10)¶
Six sub-decisions captured during pre-promotion review. Folded into this ADR so the doc carries the full design, not just the option-pick. The path through these followed two key-shape revisions; see "Decision history" below for the supersession trail.
1. Storage layout¶
Per Option A: single bucket replatform-emdash-media, path-prefixed
per site. MEDIA binding already wired; public access already enabled
at pub-4939c2fb5cd540d0862e9c02ac6644ba.r2.dev.
2. Key shape — <domain>/<stem>-<8hex>.<ext>¶
<stem> is the normalised basename stem (see §3). <8hex> is the
first 8 hex characters of the SHA-256 of the file contents.
8 hex = 32 bits = 4.3B namespace per <domain>+<stem> pair.
Even at N=100 re-uploads of the same basename within one site,
P(collision) ≈ 1.2e-6 — statistically zero. Collision space is
per-stem, not global.
This shape was chosen over a pure content-addressed alternative
(<domain>/<sha256>.<ext>) for human-grep-friendly bucket
inspection. The opaqueness cost of pure SHA keys was not worth
saving 8 chars when the stem is also recoverable via the
originalName field (§6).
3. Filename normalisation¶
Pipeline:
1. Strip extension (preserve for re-attachment in step 7).
2. Reverse Wix's _<2hex> filesystem-safe substitution (Wix
replaces % with _ when persisting URL-encoded names): match
/_([0-9a-fA-F]{2})/g, replace with %$1.
3. URL-decode (decodeURIComponent); on malformed sequence, fall
back to the pre-decode stem.
4. Transliterate via the transliteration
library's slugify(str, { lowercase: true, separator: '-' }).
This lowercases, NFD-decomposes, ASCII-folds Latin diacritics
(á→a, é→e, í→i, ñ→n, ç→c, ó→o, etc. — including the broader
Latin Extended ranges that cover Irish, Spanish, French, German,
Portuguese), replaces non-[a-z0-9] runs with -, collapses
- runs, trims leading/trailing -.
5. Empty-stem fallback → image.
6. Append -<8hex> (the SHA-256 prefix from §2).
7. Re-attach lowercased extension.
Before/after:
| Original | Normalised key (under wfpainters/) |
|---|---|
Fair_20Pricing.png |
fair-pricing-a1b2c3d4.png |
IMG-20251107-WA0012.jpg |
img-20251107-wa0012-9d8c7b6a.jpg |
COMMERCIAL_20CLEANING.jpg |
commercial-cleaning-2c1b0a9f.jpg |
Naíonra Cnoc na Sí.jpg |
naionra-cnoc-na-si-4e5d6c7b.jpg |
Café Photo.jpg |
cafe-photo-8a9b0c1d.jpg |
Naïve résumé Ñoño.png |
naive-resume-nono-1f2e3d4c.png |
8b4be4_aca71b0c670a40c8bf05565ebc232510_mv2.png |
8b4be4-aca71b0c670a40c8bf05565ebc232510-mv2-5d4c3b2a.png |
___.jpg |
image-0a1b2c3d.jpg |
Test_20cm.jpg (false-positive on _20 decode) |
test-cm-e1f2a3b4.jpg |
Irish-language and other non-ASCII Latin filenames round-trip
cleanly via transliteration — no accent stripping that erases
characters. Verify the transliteration lib's behaviour with a
quick install + sanity check before locking the lib choice in
implementation; the table above asserts documented behaviour for
the relevant Unicode blocks.
Known false positive: step 2 decodes literal _<2hex> substrings
that weren't actually URL-encoded characters. Test_20cm.jpg
("test, 20 cm") becomes test cm then test-cm. The hash still
differentiates content; the original name is preserved in metadata
and the Portable Text sibling field (§6), so forensic recovery is
intact.
4. Replacement semantics — immutable keys + deferred GC with explicit triggers¶
When an SME re-uploads an image:
- New bytes → new SHA-256 → new key under
<domain>/. - D1's logical reference is rewritten to point at the new key.
- Old object is orphaned in R2.
- Cache headers on R2 objects can be aggressive
(
Cache-Control: public, max-age=31536000, immutable) because keys never change content.
The alternatives — mutable keys with cache purge, or D1↔R2
transactional cleanup — fight three caches (/_image, R2 dev URL,
browser) and add complexity for a problem ($0.18/mo orphan storage)
that doesn't merit it. Mutable-key in particular is the
silent-cache-staleness pattern we just spent two days eliminating
at the primitive layer.
GC implementation deferred with explicit triggers, so "when justified" doesn't become "never":
- Trigger A — storage threshold: total R2 bucket size exceeds 10× expected portfolio size (>120 GB, given 12 GB expected). At that point >90% of objects are orphans.
- Trigger B — clean-slate event: bulk migration completes and we want a known-clean state before SMEs start editing through emdash.
- Whichever fires first.
GC implementation when triggered: walk all logical refs in D1 →
set of in-use keys; list R2 objects under each <domain>/ prefix
→ set of present keys; delete the difference. Cloudflare Cron
Trigger on the worker, weekly cadence.
5. Path rewrite location — logical references, resolved at render¶
transformer.js does NOT emit fully-qualified R2 URLs into D1 or
seed.json. It emits a logical reference of shape r2:<domain>/<key>
(string scalar). The Image primitive (or a small worker-side
helper) resolves the reference to a public URL at render time.
// packages/components-v3/src/lib/resolve-image-ref.ts
export function resolveImageRef(ref: string | undefined): string | undefined {
if (!ref) return undefined;
if (ref.startsWith('r2:')) {
const base = import.meta.env.PUBLIC_MEDIA_BASE;
return `${base}/${ref.slice(3)}`;
}
return ref; // external URLs and local-dev paths pass through
}
PUBLIC_MEDIA_BASE comes from env. Astro's /_image then optimises
the resolved public URL as today.
Why:
- Seeds stay portable. Test environments point at a different
bucket with one config change, no D1 migration.
- Custom-domain swap (pub-…r2.dev → cdn.replatform.fcr.media)
is a single env change.
- Provider migration (R2 → Cloudflare Images, R2 → S3) is a single
resolver swap.
6. Provenance — originalName in BOTH metadata and sibling field¶
When uploading to R2, attach the original filename as object
metadata: x-amz-meta-original-name: <original-basename>.
ALSO: extend the Portable Text image-node schema with an optional
originalName: string sibling to src and alt:
{
"_type": "image",
"src": "r2:wfpainters/fair-pricing-a1b2c3d4.png",
"alt": "Fair pricing graphic",
"originalName": "Fair_20Pricing.png"
}
Both, not either. Reasoning:
- R2 metadata is forensic-only. Discoverable when you go looking; invisible when you don't. A future session asked to recover a name has to know to inspect R2 headers and have permissions to fetch.
- The Portable Text sibling is in the data path. Travels with every export, every D1 dump, every seed. Survives R2 migrations. Queryable. Costs ~12 bytes per image and one optional schema field.
- We're migrating off a vendor. Provenance trail belongs in our data, not in a sidecar nobody reads. If an SME ever says "that's not what I uploaded" or an audit ever asks "where did this come from", the breadcrumb is in our database, not in a third-party object store's metadata.
- The "emdash media library probably handles original names" argument is doing work the word "probably" shouldn't be doing. We don't know yet. Cost of being wrong: re-crawl Wix to recover names. Cost of belt-and-braces: 12 bytes per image.
The transformer emits both on every upload. Adapters and
primitives ignore originalName at render time — it's data-path
provenance, not display.
§6 amendment (2026-05-12, Slice 3) — block-level scalar fields¶
The §6 example above shows a Portable Text image node (_type:
"image"). The FCR block schema (apps/cms/src/plugins/marketing-blocks/)
doesn't carry standalone image nodes — image refs live as fields
on blocks instead. Three shapes appear in the live data:
| Field shape | Where | Migration |
|---|---|---|
block.imageUrl: string (scalar) |
fcr.hero, fcr.about, fcr.cta-strip, fcr.service-grid items, fcr.team-grid items, fcr.blog-posts items |
Restructured to block.imageUrl: { src, originalName?, alt? }. The object-shaped value carries the §6 sibling-fields for free. |
block.logoUrl: string (scalar) |
fcr.header |
Same restructure as imageUrl. |
items[].src: string already inside an object with alt |
fcr.gallery items, fcr.logo-strip items, fcr.footer partnerLogos |
Kept scalar; originalName? added as a sibling next to the existing alt field. |
The first two cases match §6's spirit directly — the block field
becomes the image node's analog. The third case already has the
"sibling to src" structure, so adding originalName is purely
additive.
Editor schema constraint. emdash's editor schema can only declare
scalar field types in repeaters (no nested object groups). The data
shape (Portable Text in D1 / seed.json) is decoupled from the
editor input shape — schemas continue to declare imageUrl: text_input
even though the rendered data is an object. Editor authoring of
structured image fields is a separate workstream (per "Per-edit
upload UX" in §"Open decisions for build-time").
Adapter contract. Adapters mediate between the data shape and the
canonical block prop signature via a small helper at
packages/components-v3/src/lib/get-image-src.ts:
export function getImageSrc(field: ImageField): string | undefined
export function getImageAlt(field: ImageField, fallback?: string): string
ImageField accepts either the legacy scalar string or the new
object shape. Both paths funnel through resolveImageRef (§5) so
r2: refs resolve to public URLs at render time. Adapters that use
canonical primitives delegating to the Image primitive don't need
the helper — the primitive resolves internally — but adapters that
emit raw <img> (Hero, About, CtaStrip, BlogPosts, Header, Footer)
do.
videoUrl is migrated identically to imageUrl even though
videos themselves are deferred per §"Open decisions for build-time"
— the field is rare in current seed data (typically empty), and
treating it consistently with imageUrl avoids a separate
migration when videos do land.
slideshowUrls stays a multiline scalar. The editor schema's
multiline text_input for slideshow lines doesn't compose well
with object shapes (it's a string-list input, not a repeater). Each
line is rewritten to a r2: ref scalar — provenance stays in R2's
object metadata only. This is a known §6 divergence for one field;
restructuring slideshow inputs to a repeater of {src,
originalName?} is editor-side work, not migration work.
Pipeline shape (Option A)¶
[1] CRAWL [2] BUILD [3] CMS SEED
───────── ────────── ──────────────
dom-pipeline assembler-fulldev transformer
↓ ↓ ↓
downloads bundles into per image:
to local per-site Astro hash bytes (SHA-256)
public/assets/ build (Pages normalise stem (§3)
images/ deploy — no upload to MEDIA
change) <domain>/<stem>-<8hex>
with original-name
metadata
write logical ref
r2:<domain>/<key>
+ originalName sibling
into D1
↓
per-edit uploads via
emdash admin go through
the same path (separate
workstream)
Four new pieces of code:
1. R2 upload step in the seed transformer — walks imageUrl
refs, computes SHA-256, normalises basename, uploads to
MEDIA:<domain>/<stem>-<8hex>.<ext> with original-name
metadata, rewrites the field to r2:<domain>/<key> and adds
the originalName sibling. Idempotent (same content + same
normalised stem → same key).
2. Backfill script for already-seeded D1 rows — reads each
ec_pages row, walks Portable Text content, applies the same
upload+rewrite pipeline, writes back.
3. Seed JSON spec update so a fresh npm run seed produces
logical refs (not local paths).
4. Image-ref resolver helper in
packages/components-v3/src/lib/resolve-image-ref.ts. Image
primitive consumes it on the render path. Reads
PUBLIC_MEDIA_BASE from env.
Cost (Option A)¶
| Dimension | Number |
|---|---|
| Sites | 1800 |
| Images/site (waterfordcountypainters sample) | 159 |
| Total file count | ~290k |
| Total storage | ~12 GB |
| One-time R2 PUT | ~$1.50 |
| Steady-state storage | ~$0.18/month |
/_image transforms (50/site/mo @ 1800 sites) |
~$45/month |
R2 has no egress fee, so the cost ceiling is transform volume — and transforms scale with traffic, not with migration. At low CMS-edit traffic (the realistic scenario for replatformed FCR sites), the real cost is closer to $0.20/month total.
Effort (Option A)¶
| Task | Estimate |
|---|---|
| Upload step in transformer (incl. SHA, normaliser, metadata) | 1 day |
| Backfill script | 0.5 day |
| Seed JSON spec update | 0.25 day |
| Resolver helper + Image primitive integration | 0.25 day |
Schema field add (originalName on image nodes) |
0.25 day |
| Parallelised mass run + verification | 1 day |
| Buffer (R2 rate-limit, video paths, edge cases) | 0.5 day |
| Total | ~3.75 days |
The 290k uploads are the wall-clock-heaviest item. R2 PUT is fast (~50ms each). Single-threaded would take ~4 hours; parallelised 20–50 wide with 429-retry it lands in ~30 minutes.
Open decisions for build-time, not blockers¶
- Custom domain or
pub-…r2.dev.r2.devis fine for the migration step itself. Map a custom domain before public traffic. ~30 minutes DNS + R2 config. UpdatePUBLIC_MEDIA_BASEenv (§5). - Video files.
video.wixstatic.com/.../file.mp4— 10–50 MB each. Defer until a CMS site needs them; same R2 pattern would apply. - Per-edit upload UX. When an operator uploads a new image
through emdash admin, where does it go? Same MEDIA bucket, same
key scheme (§2-3), same metadata + sibling-field discipline (§6),
but the upload pathway through
/_emdash/api/*is a separate workstream. - ~~Dedup across sites.~~ Closed by §2 + per-domain prefix: same content uploaded for two different sites occupies two keys by design. Per-tenant isolation > 5-cents-per-month dedup gain.
Promotion record¶
Promoted from DRAFT to Accepted on 2026-05-10. Sign-off criteria at promotion:
- (a) Option A and the §1–§6 specifics confirmed by Cathal.
- (b) Start time: implementation begins next session (sequencing
picked up from
apps/cms/README.mdopen-work list). - (c) Backfill run by Cathal (solo dev).
File renamed 0005-image-pipeline-DRAFT.md → 0005-image-pipeline.md.
Cross-refs updated in apps/cms/README.md and
docs/pipeline/known-issues.md. Session handover
(docs/pipeline/session-2026-05-10-handover.md) preserves the
DRAFT-era references as historical record.
Slice tracker¶
Implementation is sliced. Slice 4 (SDK swap, new addition) executed ahead of Slice 3 (transformer integration) after Slice 2 surfaced that wrangler-shellout perf was throughput-blocking at portfolio scale: the SDK swap moved from "polish" to a precondition for transformer integration. Slice numbers stay stable identifiers — Slice 3 has always been transformer integration; Slice 4 is the new SDK-swap slice.
| Slice | Scope | Status |
|---|---|---|
| 1 | Logical-ref rendering — r2:<key> resolved at render via PUBLIC_MEDIA_BASE (§5). Resolver helper at packages/components-v3/src/lib/resolve-image-ref.ts. |
Done 2026-05-10 (commit 9c99bd2). |
| 2 | R2 upload helper, wrangler-shellout baseline. SHA-256 + transliteration normaliser per §3, idempotent re-runs via r2 object get exit-code probe. Helper at apps/cms/scripts/upload-image.mjs. Validated against 158 wfpainters images. |
Done 2026-05-10. |
| 3 | Transformer integration — walk seed.json imageUrl refs through the SDK upload helper; rewrite to r2:<key> + add originalName sibling per §6; backfill the 11 already-seeded garvanbay pages. |
Done 2026-05-12. Transformer at apps/cms/scripts/transform-seed-images.mjs (40 images uploaded, idempotent on re-run). D1 backfill at apps/cms/scripts/backfill-d1-image-refs.mjs (33 fields rewritten across 11 rows). Adapter helper at packages/components-v3/src/lib/get-image-src.ts. §6 amendment landed (block-level scalar fields). Smoketest verified on /, /smoketest, /adapter-test, /contact — zero broken /assets/images/ refs, only deliberate-fixture loud-fail placeholders remain. |
| 4 | SDK auth swap — @aws-sdk/client-s3 against R2's S3-compatible endpoint with R2 API tokens. Closed the §6 metadata gap (sets x-amz-meta-original-name on every upload; backfilled the 158 wrangler-era wfpainters objects via CopyObject + MetadataDirective: REPLACE). Helper signature uploadImage(localPath, domain) → { logicalRef, key, alreadyExisted } unchanged across the swap. |
Done 2026-05-11 (mostly complete; throughput sub-issue deferred — see below). |
Slice 4 throughput sub-issue — deferred. The 30-min/290k target was missed: measured throughput is ~50–70 PUT/sec regardless of concurrency (sweep across 50/100/200/500/1000), projecting to ~70–110 min wall. Curve shape (flat with noise) points at a remote-imposed ceiling — likely per-token or per-account rate-limit on Cloudflare's S3 API. EC2 pre-stage confirmed reachable but the EC2 sweep wasn't run; multi-token sharding wasn't designed.
Deferral rationale: 60 PUT/sec is sufficient for the 50-site gate
(8000 images / 60 PUT/sec = ~2 min). Throughput architecture
matters at wholesale-fleet scale; pre-empting it before end-to-end
is proven would be optimisation without data. See
known-issues.md "R2 upload — throughput ceiling" for the curve
data, hypothesis status, EC2 pre-stage state, and the explicit
deferral-trigger contract.
Slice 3 deriver hardening (2026-07-05). The transformer's basename
deriver originally matched only a single clean segment under
/assets/images/, silently skipping Wix transform-suffixed paths and
/assets/logos/*.png (they stayed /assets/… refs that 404). Reworked to
resolveAssetSource → { basename, subdir, isWixMedia } (commit 77bdb33);
a --dry on the wfpainters seed dropped underivable refs ~178 → 1. The
residual (/assets/videos/*.mp4) is a distinct shape on a different host
(video.wixstatic.com) — tracked in known-issues.md. No §-contract
change: §3 normalisation is untouched; this only widens which source paths
reach it.
Decision history¶
The key shape went through two supersessions during pre-promotion review on 2026-05-10:
- DRAFT V1 —
<domain>/<basename>. Initial scoping. Path- keyed by raw basename. Pre-promotion review surfaced collision risk in the human-named long tail (operator re-uploads ofFair_20Pricing.png-style filenames silently overwrite). The silent-overwrite pattern matched the exact failure mode we'd spent two days eliminating at the primitive layer. Superseded. - DRAFT V2 —
<domain>/<sha256>.<ext>(briefly recommended during follow-up). Pure content-addressed keys solve collision and idempotency in one move, but bucket inspection becomes opaque (wfpainters/9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c.jpgis not human-readable). Superseded. - DRAFT V3, final —
<domain>/<stem>-<8hex>.<ext>. Hybrid: human-grep-friendly stem + per-stem 4.3B-collision namespace via 8-hex-of-SHA256. Combined withoriginalNamepreservation in both R2 metadata and Portable Text sibling field (§6), bucket inspection and forensic recovery both work. Locked pending sign-off.
Followup — operator-editable image fields (2026-07-04, "Option A")¶
The §6 migration stored block image fields as { src, originalName }
objects. That made them uneditable in the emdash admin: the editor
schema declared imageUrl as a scalar text_input, which can't bind an
object value, so the field rendered blank — the operator couldn't see
or change the image (surfaced on the Payroll fcr.about block).
Decision: for operator-editable image fields, use emdash's native
media_picker element and store the value as a scalar asset-URL
string. Confirmed from @emdash-cms/admin BlockKitMediaPickerField:
the picker's stored value is a plain URL string, interchangeable with
text_input. So the object shape was the whole problem.
- Schema:
fcr.aboutimageUrltext_input→media_picker. - Data:
migrate-about-imageurl-to-string.mjsflattens{ src: "r2:<key>", originalName }→ the resolved public URL string (PUBLIC_MEDIA_BASE/<key>). Idempotent. - Provenance: retained in R2 object metadata
(
x-amz-meta-original-name, Slice 4 backfill); dropped from the D1 field. This is a conscious amendment to §6's "provenance in the data too" for operator-editable fields — the picker forces a scalar string, and R2 metadata is the durable store. (Belt-and-braces reduces to belt for these fields.) - Render:
getImageSrcalready handles the scalar string; the About adapter now also pickscontent-2(side-by-side) vscontent-1(text-only) — see ADR-0004 followup.
Scoped to fcr.about as the proof. Hero / CTA-strip / Header
(scalar imageUrl/logoUrl) and repeater-item images (service-grid,
team-grid — Block-Kit repeaters are scalar-only, so media_picker may
not apply inside them) remain object+text_input; extend later (see
known-issues).
Two image models now coexist and need reconciling: (1) these
structured fcr.* image fields (R2 r2:-scheme, ADR-0005), and (2) the
native emdash image block an operator inserts via the toolbar (emdash
media storage, ULID keys, no x-amz-meta-original-name). The per-edit
upload path (§"Open decisions") is where this reconciles.
Commits: 8ffcf5f (Option A code + migration), ffdf270 (content-2
routing), 63beb5f (Base @container). D1 applied via
reseed-d1-from-json.mjs using a D1-scoped token.
Extension to all top-level image fields (2026-07-04 pt3)¶
The fcr.about proof was extended to the remaining top-level image
fields: fcr.hero.imageUrl, fcr.cta-strip.imageUrl,
fcr.header.logoUrl → media_picker + scalar URL string. The
about-only migration was generalized to
migrate-block-image-fields-to-string.mjs (a {type: field} map;
idempotent). 28 seed fields flattened; adapters unchanged (all already
read via shape-agnostic getImageSrc). Commit 8a51bc5.
Still object+text_input: repeater-item images (Block-Kit repeater
sub-fields are scalar-only — media_picker can't drop in). See
known-issues; that's the remaining piece before a 2nd site.
Reseed-globals bug found and fixed along the way (commit 1d31300):
reseed-d1-from-json.mjs upserted globals by id, but emdash's globals
row uses a ULID that never matches the seed's id: "site" — so every
prior globals reseed silently no-op'd the live row and created a junk
draft. Header logoUrl is a global, so its migration didn't land until
this was fixed to UPDATE-by-slug. See known-patterns "Reseeding a global
must UPDATE by slug". (Pages were never affected — DELETE-by-slug is
id-independent.)
Followup — the migration walker must cover INVISIBLE image fields (2026-07-13)¶
FIELD_MIGRATIONS (the {_type → image field} table in
transform-seed-images.mjs) was enumerated from the fields that render.
fcr.business.imageUrl renders nothing — it is the og:image + LocalBusiness
JSON-LD image — so it was never in the table, and stayed a raw
/assets/logos/… path that is dead on an R2-served worker. It survived every
image migration and a placeholder sweep that drove visible loud-fails to zero,
because no page-facing instrument can see it. Only the ADR-0010 §E asset
reconciliation could (referenced-but-missing: 1), and that is reporting-only.
Two fixes, both pipeline-level (so the next site inherits them):
- FIELD_MIGRATIONS['fcr.business'] = [{ path: ['imageUrl'], shape: 'object' }].
- apps/cms/src/layouts/Base.astro resolves the field through getImageSrc
(the §6-amendment ImageField contract) for both og:image and the JSON-LD
image. It was the one CMS image consumer resolving nothing — feeding the
raw field straight into new URL(...), which an r2: ref or an object shape
cannot survive.
Zero uploads. The logo was already in R2 under the exact key
fcr.header.logoUrl uses — the field hashed straight onto the existing object.
The asset was never missing; only the reference was. referenced-but-missing
1 → 0; garvanbay unaffected (its value is already a resolved URL string, which
getImageSrc passes through — verified byte-identical after redeploy).
Contract note: enumerate image fields from the schema, not from what a page renders. See known-patterns "An image field that never renders on-page is still a shipped image field".
References¶
- ADR-0003 (per-site image flow — already works, not changing)
- ADR-0004 (the adapter layer, which surfaces the broken path)
docs/pipeline/known-issues.md— "CMS image migration pipeline"lib/backfill-assets.js— the per-site safety-net downloaderapps/cms/wrangler.jsonc— MEDIA binding already wiredtransliterationnpm package — https://www.npmjs.com/package/transliteration