The editorial spine
2,192 ways into 10,752 shows.
That is up from 864 ways into a curated 939 on 25 August 2026. Every work a production points at was promoted into shows that day (docs/model.md), and the generators here query shows directly rather than a fixed list, so the spine grew with the table. And up again since, from a second, unrelated fix: tools/build-db.py used to build cast_credits from only the 1,536 in-scope productions of the curated 939; a later commit rebuilt it from every production whose workId is in the full, promoted shows (comment at tools/build-db.py above the prods = [p for p in allprods if ...] line), so a performer's credit count run against cast_credits now sees roughly eleven times the productions it used to. Verified both times by running web/collections_spine.py's all_collections() directly against the rebuilt database (first 2026-08-27, then again same day after the cast_credits fix), not assumed from a row count.
flows.md says the busiest arrival cannot be a search box, because a search box serves people who already know what they want and the highest-value browsing arrival does not. The spine is the answer to that. It lives in web/collections_spine.py, with two extension modules, web/collections_people.py and web/collections_more.py, loaded lazily from it.
Two kinds, one shape
Handmade collections are written by a person. EXCLAMATION! exists because somebody noticed that forty-four shows have an exclamation mark in the title and almost all of them are pre-1940, which says something about how theatre named itself and then stopped. No generator finds that.
Generated collections come off an axis: every composer with four or more shows, every performer with six, every decade, every house. There are several hundred, and they are why you can follow Cole Porter to Ethel Merman to the Alvin Theatre without touching the search box.
Both produce the same record, so nothing downstream can tell them apart:
slug title standfirst kind sql params group
kind decides how a row renders and where it links: show, production, person or venue. params is always bound, never interpolated.
The groups
| Group | n | Group | n |
|---|---|---|---|
| On stage | 1,519 | Shape | 13 |
| Houses | 105 | Rights | 13 |
| Composer | 104 | Titles | 12 |
| Lyricist | 77 | People | 10 |
| Book | 62 | Produced by | 9 |
| Songs | 44 | Recordings | 8 |
| Directed by | 36 | Week | 7 |
| Choreographed by | 36 | Runs | 7 |
| Themes | 30 | Company | 7 |
| Eras | 25 | Careers | 6 |
| Crossings | 22 | Sources | 5 |
| Buildings | 14 | Form | 4 |
| Partnerships | 14 | Places | 3 |
On stage did almost all of today's growth (361→1,519), and it is the cast_credits fix, not the shows promotion. starring-* in collections_spine.py is one collection per performer credited in 6+ shows, read straight off cast_credits joined to productions, a table that only ever covered the curated 939's 1,536 in-scope productions until tools/build-db.py was changed to build it from all 13,459. Composer, Lyricist and Book are the shows promotion's own growth (37→104, 29→77, 16→62): _people_axis() reads composer/lyricist/book-writer credits straight off shows, and promoted_and_credited_shows() backfills those three fields from works.json onto the newly promoted titles, so names that fell short of four credited shows in the curated 939 clear it now. Houses and Songs are exactly what they were: those generators run off venues and song lists, neither of which either fix touched.
1,641 collections return productions, 523 return shows, 16 return people and 12 return venues.
"Absences" (twelve collections) is gone, and git log -p says exactly when and why it looks like nobody meant to. web/collections_more.py's absences() (no-synopsis, no-song-list, songs-but-no-recording, no-picture-at-all, only-a-sleeve, no-cast-recorded, no-composer-named, no-licence-found, no-house-recorded, no-closing-date, venues-without-a-capacity, shows-never-in-a-broadway-house; twelve collections, matching this table's old count exactly) was deleted whole, function and call site both, in commit 7a3dba2 ("The personal layer, the API, and the Year/Decade pages all ship at once"). None of the twelve slugs survive anywhere in the codebase today under any name; this isn't a rename.
That same commit is also the one that first added this file: docs/collections.md was new in 7a3dba2, 113 lines, and it already said "Absences | 12" the moment it was committed. So the doc was never accurate; it was written against a version of collections_more.py that still had absences(), and the deletion landed in the same giant commit ("Accumulated work never committed", dozens of unrelated efforts squashed together) without the two being checked against each other. The commit message accounts for accounts, the API, Year/Decade pages, Tony data, and "the 'doors' metaphor retired from both prose and code (decisions.md §19-22)". Nothing about collections, and nothing explains why absences() specifically was cut. No decision, comment, or finding names a reason. Whoever can speak to that commit should confirm whether the loss was intentional (an editorial call to retire the "what's missing" group, perhaps over decisions.md §17's discomfort with the catalogue narrating itself) or a casualty of the squash. Until then this table just doesn't carry it.
Three rules, and they have all cut something
A collection must be true. "Set in London" was cut: forty shows mention London and exactly two are set there; the rest transferred. "Berlin" was cut because half the mentions are Irving Berlin.
A title must not assert what the data cannot support. "Friday flops" became "Friday finales" once it turned out that a one-performance run is as often a gala as a disaster. The catalogue records the run, not the intention.
The standfirst is writing. "Shows where composer = X" is a filter, not a sentence. Say something, and let the number render from the data.
The weekly rota
Seven collections carry a day, and the front page leads with whichever one it is. Monday musicals, Tuesday troublemakers, Wednesday elsewhere, Thursday throwbacks, Friday finales, Saturday spectaculars, Sunday second acts. Defined in web/collections_spine.py as DAYS and DAY_LABEL.
Why counting them is a deployment concern
web/collections_spine.py:all_collections() counts every collection the first time anything asks for one. Cold, that is 864 COUNT(*) queries and about 69 seconds on an M-series laptop; on a shared vCPU it is minutes, and it lands on whichever request arrives first after a restart.
So it runs at import in deploy/wsgi.py, before gunicorn forks. With --preload, the master imports once and workers fork from it, so the built list is shared copy-on-write and two workers cost one worker's worth.
The container ships warm caches, all three stamped with the database's size rather than its mtime: a deploy does not preserve mtime, so an mtime-stamped cache is invalid the moment it lands. If a stamp is stale (someone swapped the database under a running container), the site pays for the rebuild at boot, loudly, rather than silently on a reader's first page.
web/collection-counts.json and web/collection-counts-more.json hold the row count per collection. web/collection-spine.json holds the built definitions: slug, title, standfirst, kind, SQL, parameters, group and count, and it is the one that matters on a serverless host. The counts cache saved the answers and not the questions, and the questions were the expensive part: assembling the 864 definitions runs ten aggregate queries over the whole catalogue, which measured 5.2 seconds cold. That was paid on every page rather than only the collection pages, because collection_slugs() is a context processor.
Rebuild them by deleting them and asking for the spine once:
rm web/collection-spine.json web/collection-counts*.json .venv/bin/python3 -c "import sys; sys.path.insert(0, 'web'); \ import collections_spine as s; s.all_collections()"
tools/check-deploy.py refuses a deploy whose caches are stamped for a different database from the one shipping beside them.