Add read-only endpoints for the two legacy /wxycdb cross-reference views - #2387
Add read-only endpoints for the two legacy /wxycdb cross-reference views#2387jakebromberg wants to merge 1 commit into
Conversation
…ews (BS#2386) `GET /library/crossreferences/artists` and `GET /library/crossreferences/releases` serve `artist_crossreference` and `artist_library_crossreference` — the data behind `/wxycdb`'s `xrefsToLibraryCodes.jsp` and `xrefsToLibraryReleases.jsp`, the last two `mainmenu.jsp` links with no successor in this service. dj-site#1163 needs both screens; dj-site#1211 recorded the read path as an open question, and the answer is that the tables survive in the schema and are populated. Read only, by decision rather than omission. WXYC/wiki#89's D5 freezes the artist-code set at the tubafrenzy cutover and drops the release set outright, so a create/update/delete sibling would unfreeze what that decision froze. `jobs/library-etl` keeps its own ETL write path; that is not an API surface. `catalog: ['write']` on both READS. `mainmenu.jsp:32-38` wraps these two links in `<c:if test="${user.hasAdminAccess()}">`, unlike Missing Releases and the rotation links beside them; `catalog:write` selects musicDirector + stationManager, the same pair that flag names, without minting a statement key. `/library/bmi-performance-list`, `/library/artists/search`, and `/library/artists/peek-code` set the precedent for an admin-gated read at that tier. Paginated in the `{ results, total, page, totalPages }` shape `GET /library/query` uses, but with its own bounds (default 200, max 500) rather than the shared 50/100. The collections are frozen at 119 and 35 rows, so a 100-row ceiling would make the artist set permanently un-fetchable in one request for a cap that never binds; the cap still exists because the freeze is a decision and not something the query can enforce while library-etl is still upserting. Both queries project call-number PARTS rather than a composed string, matching every other catalog projection here. `target_code_artist_number` is a correlated subquery, not a join: `artist_genre_key` is unique on `(artist_id, genre_id)`, so joining would fan one cross-reference row out into several for a multi-genre artist. It collapses to the lowest `genre_id` so the code this view shows matches the artist's own card. The release listing LEFT joins `genre_artist_crossreference` where `getReleasesForArtist` INNER joins it — there the join is a scoping predicate, here it supplies one display column, and a frozen legacy row must not silently vanish from the only listing that reproduces it. Divergence from the JSPs: both render a "Time Last Modified" column and neither table has a timestamp. The field is omitted; no migration invents one.
Verification outputAll run in this worktree at
|
Closes #2386.
Adds the two read-only endpoints
/wxycdb's admin menu needs a successor for, so WXYC/dj-site#1163 (the classic librarian epic) can build the screens. WXYC/dj-site#1211 previously recorded the read path for the legacy cross-references as an open question — this answers it: both tables survive inshared/database/src/schema.ts, both are populated on prod, and only the API surface was missing.Routes and shapes
GET /library/crossreferences/artistsSuccessor to
libraryAdmin/xrefsToLibraryCodes.jsp, overartist_crossreference.{ "results": [ { "source_artist_id": 4102, "source_artist_name": "Barry Black", "target_artist_id": 991, "target_artist_name": "Eric Bachmann", "target_code_letters": "BA", "target_code_artist_number": 42, // nullable "comment": "Barry Black is filed w/ Eric Bachmann" // nullable } ], "total": 78, "page": 0, "totalPages": 1 }Only the TARGET carries a call number, matching the JSP: its "Cross-Referencing Artist" column renders a bare presentation name, its "Cross-Referenced Library Code" column renders code + name.
GET /library/crossreferences/releasesSuccessor to
libraryAdmin/xrefsToLibraryReleases.jsp, overartist_library_crossreference.{ "results": [ { "artist_id": 4102, // the cross-REFERENCING artist "artist_name": "Barry Black", "library_id": 20114, "album_title": "To The Races", "album_artist_name": "Eric Bachmann", // the RELEASE's own artist; nullable "alternate_artist_name": null, "format_name": "CD", "genre_id": 11, "code_letters": "BA", "code_artist_number": 42, // nullable "code_number": 7, "code_volume_letters": null, "comment": "see also Barry Black" // nullable } ], "total": 22, "page": 0, "totalPages": 1 }Two artists per row, aliased apart, because they are usually different — that difference is the association the row records. Collapsing them would lose the point of the table.
Both endpoints take
?page=/?limit=and answer{ results, total, page, totalPages }, the envelopeGET /library/queryuses. An empty collection is a 200 withtotal: 0, not a 404 — that is the state each JSP renders as "There are no ... Cross-References". Swagger docs added toapps/backend/app.yaml.Design decisions
1. Route shape and pagination
Registered on
library_routeas two literal two-segment paths, grouped under/crossreferences/so the pair reads as one feature and the second segment names the cross-reference TARGET — exactly how the JSP titles distinguish them ("Cross-References to Library Codes" vs "... to Library Releases"). Neither collides with the templatedGET /:id/compilation-tracks(different second segment), but they are registered ahead of every templated route on the router anyway, per that file's standing ordering rule, and a route test pins that the two literals reach distinct handlers.Paginated, with their own bounds (default 200, max 500) rather than the shared
DEFAULT_LIMIT/MAX_LIMITof 50/100. Real row counts, from WXYC/wiki#89's 2026-08-11 prod measurement:artist_crossreferenceartist_library_crossreferenceartist_crossreferencerises toward 119 asscripts/audit/bs_2117_crossref_backfill.sqllands its 110 resolvable pairs (9 of the 119 dangle — an endpoint whoseLIBRARY_CODEwas deleted from tubafrenzy — and are unrepairable). A 100-row ceiling would make the artist collection permanently un-fetchable in one request, for the sake of a cap that never binds; a default above the whole frozen set lets the librarian's one-screen table be one request. The cap still exists rather than serving the tables whole, becausejobs/library-etlis still upserting into both on a 30-minute cron: the ceiling is a decision, not something the query can rely on.I could not verify these counts against prod directly — the EC2/RDS read path was unavailable in this environment. The numbers above are WXYC/wiki#89's own measurement, cross-checked against
scripts/audit/bs_2117_crossref_backfill.sql's enumeration of the 119 source rows. Worth a re-measure before dj-site tunes its page size.2. Authorization:
catalog: ['write']on a readmainmenu.jsp:32-38wraps both of these links inside<c:if test="${user.hasAdminAccess()}">, unlike Missing Releases and the two rotation links immediately below, which sit outside it.hasAdminAccess()is tubafrenzy'sY/Nlibrarian flag (libs/entities/src/main/java/org/wxyc/components/User.java:76-80).catalog: ['write']is the grant that selectsmusicDirector+stationManagerinshared/authentication/src/auth.roles.ts— the same pair that flag names — and this repo already uses it for admin-gated reads:GET /library/bmi-performance-list("exactly the librarian/MD submission audience with no new permission minted"),GET /library/artists/search,GET /library/artists/peek-code.The two alternatives were both worse:
catalog: ['read']hands everydjandmembera screen the legacy system gated. This is not the/artists/by-codesituation, where widening was safe because the endpoint returned strictly less thanGET /library/queryalready does atcatalog:read— nothing atcatalog:readexposes cross-references today.catalog: ['admin'], or acrossreferenceskey) would have to be decided for all four roles underauth.roles.ts's totality rule, to express a tier that already exists and already selects the right two roles.The gate is pinned by
tests/unit/routes/library-crossreferences-permissions.route.test.ts, verified by mutation: relaxing either route tocatalog: ['read']turns two tests red.3. Read-only, permanently
WXYC/wiki#89 decision D5 freezes the artist-code cross-references at the tubafrenzy cutover ("post-cutover artists don't gain aliases; unfreezing is a future ticket") and drops the release cross-references outright. There is therefore no POST/PATCH/DELETE sibling in this PR, and adding one would unfreeze a set that decision deliberately froze.
jobs/library-etlretains its own upsert path into both tables; that is an ETL, not an API surface, and is untouched here.Divergences accepted
No
Time Last Modified. Both JSPs render that column and neither table has a timestamp column —artist_crossreferenceis(source_artist_id, target_artist_id, comment)andartist_library_crossreferenceis(artist_id, library_id, comment), full stop. The field is omitted from both responses. No migration invents one; the tables are frozen by D5 and adding a column to carry a value that was never captured would fabricate data, not recover it. Documented inapp.yamlon both paths.No
fullLibraryCode/entireLibraryCode/entireArtistTitleStringcomposed strings. The JSPs render tubafrenzy bean accessors that concatenate the call number and the artist/title pair. This service projects call-number PARTS everywhere (ArtistCardRow,ArtistReleaseRow,LIBRARY_VIEW_PROJECTION), so these do too, and the client composes. Same information, existing convention.code_artist_numberis genre-scoped and can be null.artist_genre_keyis unique on(artist_id, genre_id), not onartist_id, so a legacy artist filed under several genres owns severalartist_genre_codes. On the artist listing this is a correlated subquery, not a join — joining would fan one cross-reference row out into several — and it collapses to the lowestgenre_id, matchinggetArtistCardById, so the code this view shows for an artist is the code that artist's own card shows. It is null for an artist with nogenre_artist_crossreferencerow.The release listing LEFT joins
genre_artist_crossreferencewheregetReleasesForArtistINNER joins the same pair. There the join is a scoping predicate over one artist's shelf; here it supplies one display column, and this endpoint's job is to show a frozen legacy set in full. An artist missing its genre crossreference row would otherwise silently drop its cross-reference from the only listing that reproduces it. The key is still the(artist_id, genre_id)pair, so the join cannot fan out.No
wxyc-shared/api.yamlentry. CLAUDE.md's schema-first rule says a new public endpoint's shape goes into the cross-repo SSOT "first, before or alongside the private TS type." This PR ships the Swagger-UI docs inapps/backend/app.yamlbut does not carry awxyc-sharedcompanion PR — that was outside the scope handed to this change. It should land before dj-site codegens against these routes; flagging it explicitly rather than letting it drift.Ordering is alphabetical-by-referencing-artist, not by insertion. Neither table has a primary key or an id — the only unique constraint is the FK pair (
artist_crossref_source_target,library_id_artist_id) — so there is no insertion order to preserve. Both listings sort by the cross-referencing artist's name and then carry both FK columns to reach a total order; without that, two artists sharing a name would order arbitrarily and rows could repeat or vanish across page boundaries.Verification
npm run typecheck,npm run lint(0 errors; the new files add 0 warnings to the tracked backlog),npm run test:unit(535 suites / 9504 tests passing), andnpx prettier --checkon every touched file. Full output in the PR thread.