Adding an album costs a full production build today, and it does not need to. The photos are already fetched at runtime; only the manifest is baked into the deployment. Journalism already publishes with no build at all. Five galleries do not, and the reason is one line.
What happens now
- Photos go to
src/images/Portfolios/, push to main.
- Vercel skips that build. From the logs: "The deployment was canceled because the Ignored Build Step command returned exit code 0." Correct, since nothing under
sites/mcc-cal-vite/ changed.
seo-auto-update.yml fires on src/images/Portfolios/**, regenerates the seven manifests and commits them into sites/mcc-cal-vite/.
- That commit is inside the root directory, so it triggers a full production build.
So it works. It just costs a build and a second commit every time, and if step 3 ever fails the photos sit in the repo with no manifest and nothing says so.
What already exists
scripts/cloudflare/add-shoot.js accepts journalism | concert | portrait | events | nature, converts to WebP q82, reads IPTC captions and keywords, uploads to R2 and writes portfolio_images. Resumable.
- R2 is live.
images.mcc-cal.com returns 200 for a journalism WebP at 213 KB.
portfolio_images holds 422 images across 18 collections.
- RLS already allows anonymous read of every portfolio type (
public_read, qual = true). No policy change needed.
VITE_R2_PUBLIC_URL, VITE_SUPABASE_URL and the anon key have been set in production for 72 days.
- The runtime merge has a 5 second timeout and falls back to the static manifest, so an outage degrades to today's behaviour rather than an empty gallery.
The one line
src/components/portfolio/useManifest.ts:144
if (type.toLowerCase() === 'journalism') {
add-shoot.js will happily write rows for the other four types. The site never reads them back. The same line means the photojournalism alias misses the merge too, since that string is not journalism.
Three ways a naive change breaks quietly
Row cap truncation. The events gallery has 1,635 images. The current query is .select('*') with no pagination, and PostgREST's default max rows is 1,000. That returns 1,000 rows with no error and silently drops the rest. The setting is PostgREST config, not a database role setting, so it cannot be read from SQL and has to be checked under Settings, API, Max rows. Paginate with .range() regardless, or better, do not fetch every image row just to draw a grid: one row per collection with a cover and a count is a far smaller query, and the full list can load when a collection is opened.
Shape divergence. It is not two shapes, it is five.
| Gallery |
Key |
Name field |
Image entry |
| journalism |
events |
eventName |
object: filename, path, description, caption, tags |
| events |
events |
eventName |
object: path only |
| concert |
bands |
bandName |
plain string |
| portrait |
collections |
collectionName |
plain string, plus nested albums and looseImages |
| nature |
collections |
collectionName |
plain string |
Supabase always produces objects carrying a url. Concert, portrait and nature treat images as bare filenames today, so each has to accept both forms.
URL construction. imageUrl builds a jsDelivr path per gallery from folderPath plus filename. The Supabase source sets folderPath to the collection display name, not a real path, so any adapter that ignores a pre-built url will build src/images/Portfolios/Nature/Steel Strike 2026/foo.webp and 404. Journalism already solved this at JournalismPortfolio.tsx:50 with img.url ?? imageUrl.journalism(...). The other four adapters need the same.
And the rollups
featured-manifest.json and portfolio-manifest.json are aggregates built from the others at build time. An album that exists only in Supabase will not appear in either, so the featured and universal views would still silently omit new work after the per-gallery fix. Either compute those at runtime too or accept it as a documented gap.
Sequencing
Smallest surface first, so the generalisation is proven before it touches anything big.
- Nature. 7 collections, 61 images, simplest shape, nowhere near the row cap.
- Portraits and concert. Same string-image pattern. Portraits also has the nested
albums and looseImages to handle.
- Events last. It is the one that trips the row cap and needs pagination.
- Rollups as a separate decision.
Alongside: narrow .select('*') to the columns actually used, and add a guard that fails if any gallery's row count comes back at the cap, since that is the failure with no symptom.
Verification
Per the usual rule, prove the failure fires before trusting the fix. Seed a collection past the row cap and confirm truncation is detected rather than silently rendered. Point a Supabase collection at a bad storage_path and confirm the fallback shows the static album rather than a broken grid. Confirm a collection present in both sources renders once, not twice.
Payoff beyond the build saving
The Supabase rows point at optimised WebP in R2. The static path points at the original JPEGs on jsDelivr, including the 25.5 MB file that 403s in #261. Finishing this routes the galleries at optimised images, advances the jsDelivr exit in #266, and lets Lightroom captions become real alt_text, which a manifest generated from filenames cannot do.
Adding an album costs a full production build today, and it does not need to. The photos are already fetched at runtime; only the manifest is baked into the deployment. Journalism already publishes with no build at all. Five galleries do not, and the reason is one line.
What happens now
src/images/Portfolios/, push to main.sites/mcc-cal-vite/changed.seo-auto-update.ymlfires onsrc/images/Portfolios/**, regenerates the seven manifests and commits them intosites/mcc-cal-vite/.So it works. It just costs a build and a second commit every time, and if step 3 ever fails the photos sit in the repo with no manifest and nothing says so.
What already exists
scripts/cloudflare/add-shoot.jsacceptsjournalism | concert | portrait | events | nature, converts to WebP q82, reads IPTC captions and keywords, uploads to R2 and writesportfolio_images. Resumable.images.mcc-cal.comreturns 200 for a journalism WebP at 213 KB.portfolio_imagesholds 422 images across 18 collections.public_read,qual = true). No policy change needed.VITE_R2_PUBLIC_URL,VITE_SUPABASE_URLand the anon key have been set in production for 72 days.The one line
src/components/portfolio/useManifest.ts:144add-shoot.jswill happily write rows for the other four types. The site never reads them back. The same line means thephotojournalismalias misses the merge too, since that string is notjournalism.Three ways a naive change breaks quietly
Row cap truncation. The events gallery has 1,635 images. The current query is
.select('*')with no pagination, and PostgREST's default max rows is 1,000. That returns 1,000 rows with no error and silently drops the rest. The setting is PostgREST config, not a database role setting, so it cannot be read from SQL and has to be checked under Settings, API, Max rows. Paginate with.range()regardless, or better, do not fetch every image row just to draw a grid: one row per collection with a cover and a count is a far smaller query, and the full list can load when a collection is opened.Shape divergence. It is not two shapes, it is five.
eventseventNameeventseventNamebandsbandNamecollectionscollectionNamealbumsandlooseImagescollectionscollectionNameSupabase always produces objects carrying a
url. Concert, portrait and nature treat images as bare filenames today, so each has to accept both forms.URL construction.
imageUrlbuilds a jsDelivr path per gallery fromfolderPathplus filename. The Supabase source setsfolderPathto the collection display name, not a real path, so any adapter that ignores a pre-builturlwill buildsrc/images/Portfolios/Nature/Steel Strike 2026/foo.webpand 404. Journalism already solved this atJournalismPortfolio.tsx:50withimg.url ?? imageUrl.journalism(...). The other four adapters need the same.And the rollups
featured-manifest.jsonandportfolio-manifest.jsonare aggregates built from the others at build time. An album that exists only in Supabase will not appear in either, so the featured and universal views would still silently omit new work after the per-gallery fix. Either compute those at runtime too or accept it as a documented gap.Sequencing
Smallest surface first, so the generalisation is proven before it touches anything big.
albumsandlooseImagesto handle.Alongside: narrow
.select('*')to the columns actually used, and add a guard that fails if any gallery's row count comes back at the cap, since that is the failure with no symptom.Verification
Per the usual rule, prove the failure fires before trusting the fix. Seed a collection past the row cap and confirm truncation is detected rather than silently rendered. Point a Supabase collection at a bad
storage_pathand confirm the fallback shows the static album rather than a broken grid. Confirm a collection present in both sources renders once, not twice.Payoff beyond the build saving
The Supabase rows point at optimised WebP in R2. The static path points at the original JPEGs on jsDelivr, including the 25.5 MB file that 403s in #261. Finishing this routes the galleries at optimised images, advances the jsDelivr exit in #266, and lets Lightroom captions become real
alt_text, which a manifest generated from filenames cannot do.