Skip to content

Add read-only endpoints for the two legacy /wxycdb cross-reference views - #2387

Open
jakebromberg wants to merge 1 commit into
mainfrom
feature/legacy-crossreference-reads
Open

Add read-only endpoints for the two legacy /wxycdb cross-reference views#2387
jakebromberg wants to merge 1 commit into
mainfrom
feature/legacy-crossreference-reads

Conversation

@jakebromberg

Copy link
Copy Markdown
Member

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 in shared/database/src/schema.ts, both are populated on prod, and only the API surface was missing.

Routes and shapes

GET /library/crossreferences/artists

Successor to libraryAdmin/xrefsToLibraryCodes.jsp, over artist_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/releases

Successor to libraryAdmin/xrefsToLibraryReleases.jsp, over artist_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 envelope GET /library/query uses. An empty collection is a 200 with total: 0, not a 404 — that is the state each JSP renders as "There are no ... Cross-References". Swagger docs added to apps/backend/app.yaml.

Design decisions

1. Route shape and pagination

Registered on library_route as 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 templated GET /: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_LIMIT of 50/100. Real row counts, from WXYC/wiki#89's 2026-08-11 prod measurement:

Collection tubafrenzy Backend PG Ceiling
artist_crossreference 119 78 119 (D5 freeze)
artist_library_crossreference 35 22 35 (D5 drop)

artist_crossreference rises toward 119 as scripts/audit/bs_2117_crossref_backfill.sql lands its 110 resolvable pairs (9 of the 119 dangle — an endpoint whose LIBRARY_CODE was 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, because jobs/library-etl is 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 read

mainmenu.jsp:32-38 wraps 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's Y/N librarian flag (libs/entities/src/main/java/org/wxyc/components/User.java:76-80).

catalog: ['write'] is the grant that selects musicDirector + stationManager in shared/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 every dj and member a screen the legacy system gated. This is not the /artists/by-code situation, where widening was safe because the endpoint returned strictly less than GET /library/query already does at catalog:read — nothing at catalog:read exposes cross-references today.
  • A new statement key (catalog: ['admin'], or a crossreferences key) would have to be decided for all four roles under auth.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 to catalog: ['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-etl retains its own upsert path into both tables; that is an ETL, not an API surface, and is untouched here.

Divergences accepted

  1. No Time Last Modified. Both JSPs render that column and neither table has a timestamp columnartist_crossreference is (source_artist_id, target_artist_id, comment) and artist_library_crossreference is (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 in app.yaml on both paths.

  2. No fullLibraryCode / entireLibraryCode / entireArtistTitleString composed 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.

  3. code_artist_number is genre-scoped and can be null. artist_genre_key is unique on (artist_id, genre_id), not on artist_id, so a legacy artist filed under several genres owns several artist_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 lowest genre_id, matching getArtistCardById, 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 no genre_artist_crossreference row.

  4. The release listing LEFT joins genre_artist_crossreference where getReleasesForArtist INNER 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.

  5. No wxyc-shared/api.yaml entry. 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 in apps/backend/app.yaml but does not carry a wxyc-shared companion 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.

  6. 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), and npx prettier --check on every touched file. Full output in the PR thread.

…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.
@jakebromberg

Copy link
Copy Markdown
Member Author

Verification output

All run in this worktree at 7b221a2c, foreground, against a clean npm ci.

npm run typecheck — exit 0

> @wxyc/database@1.0.0 typecheck
> tsc --noEmit

> @wxyc/authentication@1.0.0 typecheck
> tsc --noEmit

> @wxyc/legacy-mirror@1.0.0 typecheck
> tsc --noEmit

> @wxyc/lml-client@1.0.0 typecheck
> tsc --noEmit

> @wxyc/metadata@1.0.0 typecheck
> tsc --noEmit

> @wxyc/observability@1.0.0 typecheck
> tsc --noEmit

> @wxyc/auth-service@1.0.0 typecheck
> tsc --noEmit

> @wxyc/backend@1.0.0 typecheck
> tsc --noEmit

> @wxyc/enrichment-worker@1.0.0 typecheck
> tsc --noEmit

npm run lint — exit 0

✖ 1076 problems (0 errors, 1076 warnings)
  0 errors and 35 warnings potentially fixable with the `--fix` option.

Same run on origin/main for the baseline:

✖ 1076 problems (0 errors, 1076 warnings)

Identical — this branch adds zero warnings to the tracked backlog. Both new test files report zero lint findings of their own, and every warning in the three modified source files falls outside the added hunks (library.controller.ts +909–1003, library.service.ts +2570–2784).

npm run test:unit — exit 0

Test Suites: 535 passed, 535 total
Tests:       9504 passed, 9504 total
Snapshots:   0 total
Time:        31.131 s

The two new files alone:

Test Suites: 2 passed, 2 total
Tests:       31 passed, 31 total

npx prettier --check on every touched file

Checking formatting...
All matched files use Prettier code style!

Other pre-push gates

npm run check:auth-tables-doc (hard-fail): PASS: 12 auth_* table(s) under shared/database/src match the CLAUDE.md sentinel-fenced list. check:doc-budget / check:doc-rules are warn-only and report the same pre-existing findings as main — nothing here touches CLAUDE.md or docs/.

Mutation check on the authorization gate

Relaxing GET /library/crossreferences/artists from catalog: ['write'] to catalog: ['read'] turns the permission suite red, confirming the gate is actually asserted rather than incidentally green:

    expect(received).toBe(expected) // Object.is equality

    Expected: 403
    Received: 200

      167 |       expect(res.status).toBe(403);

Test Suites: 1 failed, 1 total
Tests:       2 failed, 11 passed, 13 total

Reverted before commit.

What was not verified

No integration or E2E run — this change adds no migration and touches no shared fixture, and the unit route test exercises the real requirePermissions implementation (not the tests/mocks/authentication.mock.ts stub) via jest.requireActual. The generated SQL for both queries was rendered with .toSQL() and read by hand; the derived-table count(*) wrapper over a projection carrying two same-named columns was confirmed legal against a real PostgreSQL 18 instance. Neither query was executed against populated data — the prod read path was unavailable in this environment, which is also why the row counts in the description are WXYC/wiki#89's measurement rather than a fresh one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No read path for the two legacy /wxycdb cross-reference views

1 participant