feat: Library endpoints and search filters for MD catalog editing#1154
feat: Library endpoints and search filters for MD catalog editing#1154JacksonMeade wants to merge 10 commits into
Conversation
jakebromberg
left a comment
There was a problem hiding this comment.
Found 15 issues, ranked most-severe first. The new PATCH /library/:id is the riskiest surface — it uses PATCH-route semantics but the handler unconditionally rewrites every column from the body, so several optional fields can be silently wiped on a partial edit.
Blocking
1. genres filter ORs multiple genre names test will fail in CI — tests/integration/library-query.spec.js:95
CI loads only dev_env/seed_db.sql (LOAD_CLONE_FIXTURE=false); the seed has 11 library rows across genre_ids 11/6/15 only — no Jazz. /library/query?genre=Jazz returns total=0, so expect(jazzOnly.body.results.length).toBeGreaterThan(0) fails before the combined-filter assertions run. Either seed a Jazz row or pick two genres that the seed already covers (e.g. Rock + Hiphop).
2. parseEnumQueryList crashes with TypeError on repeated query keys — apps/backend/services/library-search.service.ts:448
Express 5's default 'simple' parser (Node querystring.parse) yields a string[] for repeated keys. ?genres=Rock&genres=Jazz gives req.query.genres = ['Rock','Jazz']; inside parseEnumQueryList the array passes the truthy if (!value) guard, then value.split(',') throws value.split is not a function → 500. Same path for ?genre=, ?format=, ?formats=, ?rotation_bins=. Normalize each rest-arg via Array.isArray(x) ? x : [x] or coerce upstream.
3. searchArtistsInGenre q.trim() crashes on repeated q — apps/backend/controllers/library.controller.ts:284
/library/artists/search?genre_id=11&q=Bu&q=lt → req.query.q is ['Bu','lt'] → (req.query.q ?? '').trim() throws TypeError → 500. The integer-checked params coerce arrays to NaN and 400 cleanly; q is the only explosive parameter.
4. Cascade in-memory filter omits the missing constraint — apps/backend/services/library-search.service.ts:178
SQL primary path applies missing via EXISTS. When primary returns 0 rows and passesCascadeGate(q) is true, runCascade filters on on_streaming/genres/formats/rotation_bins but not missing. TaggedLibraryViewEntry has no date_lost/date_found to inspect, so cascade results from CTA/LML leak through to the librarian-facing 'missing' view as if they were missing. The new rotation_bins AND missing both apply test only asserts rotation_bin, masking this. Easiest fix: skip the cascade when params.missing === true.
High — silent data loss / corruption on PATCH
5. updateAlbumInDB SETs every column unconditionally — apps/backend/services/library.service.ts:1272
The handler defaults omitted optional fields (disc_quantity → 1, alternate_artist_name → null) and updateAlbumInDB writes them unconditionally. A partial PATCH that fixes only a typo in album_title will overwrite a 2-disc album to disc_quantity=1 and wipe a curator-set alternate_artist_name (a load-bearing V/A filing-display surface — see project_librarian_va_invariant). Either only SET fields the body explicitly provided, or use PUT semantics and require every optional field.
6. Empty label: '' wipes existing label_id — apps/backend/controllers/library.controller.ts:527
Missing-params guard only rejects === undefined, so label: '' slides past. if (label_id === undefined && body.label) is false (empty string is falsy), so createLabel is skipped, label_id stays undefined, and updateAlbumInDB writes label_id: undefined which drizzle maps to NULL. The FK to a long-stable label is destroyed without any signal.
7. updateAlbum doesn't regenerate code_number when artist_id changes — apps/backend/services/library.service.ts:1272
No UNIQUE(artist_id, code_number) exists. addAlbum calls generateAlbumCodeNumber(artist_id) to assign the per-artist sequence; updateAlbum carries the old number over. Re-attributing an album to an artist that already has that code_number produces two rows with the same (code_letters, code_artist_number, code_number) — librarian V/A invariant violation, silently filed.
8. Empty album_title: '' persists silently — apps/backend/controllers/library.controller.ts:498
Same === undefined-only guard. ''.trim() === '' and the NOT NULL is satisfied by empty string. addAlbum has the same hole; updateAlbum exposes it on long-stable rows. Either reject empty trimmed strings at the controller, or CHECK (album_title <> '') at the schema.
Medium
9. missing=false is a silent no-op — apps/backend/controllers/library.controller.ts:645 vs services L342
Controller parses to missing: false and forwards; buildFilterClause only branches on missing === true. A UI toggle that round-trips both states will silently disable filtering when set to false. Either reject missing=false as 400 or implement the inverse NOT-EXISTS arm.
10. updateAlbum creates an orphan label row on the 404 path — apps/backend/controllers/library.controller.ts:524
labelsService.createLabel(body.label) runs before updateAlbumInDB. If the album doesn't exist (or another row is concurrently deleted), the label persists. Enumerating album IDs against this endpoint bulk-pollutes the labels table. Move the album existence/lookup before the label upsert.
11. createLabel un-trimmed vs library.label trimmed — labels duplication — apps/backend/controllers/library.controller.ts:527
createLabel(' Drag City ') inserts a padded labels.label_name; library.label then writes 'Drag City'. A later submission with the trimmed form misses the ON CONFLICT and creates a second labels row. addAlbum is internally consistent (both writes un-trimmed); only updateAlbum desyncs. Trim before createLabel.
12. updateAlbum doesn't refresh streaming / artwork / canonical_entity — apps/backend/controllers/library.controller.ts:493
addAlbum (L93–135) fires checkStreamingAvailability, lookupMetadata, and the canonical-entity resolver. updateAlbum doesn't. When a librarian fixes an album's artist or title, library.on_streaming / library.artwork_url / library_identity / canonical_entity_id stay bound to the OLD identity. metadata_attempt_at-driven drain jobs don't repair this because the row's attempt-at is already set. Either invalidate the enrichment columns on metadata-affecting fields or fire the same allSettled enrichment.
13. disc_quantity lacks type/range validation — apps/backend/controllers/library.controller.ts:534
Math.max(1, 'abc') = NaN, Math.max(1, 1.5) = 1.5, Math.max(1, 40000) = 40000 — all reach PG smallint and fail with invalid input syntax / value out of range → 500. Number.isInteger + range check at the controller, mirroring genre_id.
14. searchArtistsInGenre ILIKE prefix doesn't escape % / _ — apps/backend/services/library.service.ts:1149
q='%a' (2 chars passes the length check) builds pattern '%a%' and returns any artist whose name contains 'a'. q='__' → '__%' matches any 2+ char artist. The autocomplete shorts its prefix-search contract; future planner shortcuts on trigram will be bypassed. Same shape exists in searchLabels (pre-existing). Escape %, _, \ in the bound prefix.
15. rotation_bins filter inherits row duplication from library_artist_view — apps/backend/services/library-search.service.ts:341
The rotation table comment (shared/database/src/schema.ts:534) explicitly permits "multiple active rows per (album_id, rotation_bin)" (re-bins, re-promotes). The view LEFT JOINs rotation without distinctOn, so an album with two unkilled rotation rows appears twice. Pre-existing in the view, newly surfaced by the inArray(rotation_bin) filter and the COUNT(*) total. distinctOn(library_artist_view.id) on the projection or a subquery that picks one rotation row per album.
Also worth a quick look
- Client-supplied
body.label_idisn't validated against the labels table — a stale/guessed integer bypassescreateLabeland surfaces as PG 23503 → 500. body.label_id: null(vsundefined) plus a non-emptybody.labelskipscreateLabelAND writes label_id=NULL with non-emptylibrary.labeltext — desync.searchArtistsInGenrereturns{ artists: [] }for any positivegenre_idregardless of whether the genre actually exists — silent empty results for stale dropdown IDs.recordActivity's new SELECT-then-INSERT adds a DB round-trip to every authenticated request (including allproxy_route.use(trackActivity)endpoints). The previous FK violation also surfaced anonymous/orphaned user IDs to Sentry; the silent no-op masks that signal. A catch-on-23503 would preserve both the latency floor and the visibility.- No integration tests cover
PATCH /library/:idat all — every error branch and the response shape ship unverified. api.yamldoesn't declaregenres/formats/missing/rotation_bins,PATCH /library/{id}, orGET /library/artists/search— codegen drift across dj-site / iOS / Android.
- PATCH /library/:id now has true partial semantics: only fields present
in the body are validated and written, so partial edits can't reset
disc_quantity, wipe alternate_artist_name, or NULL label_id (issues 5-8)
- 404 resolves before the label upsert (no orphan labels rows), label is
trimmed before createLabel, client label_id is validated against the
labels table, and label_id:null explicitly clears the linkage (10, 11)
- artist re-attribution keeps code_number unless it collides under the
new artist; identity-affecting edits invalidate LML enrichment columns
and re-fire the addAlbum enrichment pipeline (7, 12)
- repeated query keys (Express simple parser arrays) no longer 500:
parseEnumQueryList flattens string[], q is rejected with 400 (2, 3)
- missing=false gets the inverse NOT-EXISTS arm; unspecified still
returns both missing and non-missing rows; cascade is skipped whenever
the missing filter is present since cascade rows carry no
date_lost/date_found (4, 9)
- /library/query dedupes albums with multiple active rotation rows via
DISTINCT ON + COUNT(DISTINCT id) (15)
- searchArtistsInGenre escapes ILIKE metacharacters and 404s on unknown
genre_id; empty album_title rejected on add and update (8, 13, 14)
- recordActivity drops the SELECT pre-check; FK violations surface via
trackActivity's existing Sentry catch
- seed a Jazz library row so the genres-OR test passes against
seed_db.sql in CI (1)
- declare /library/query filters, PATCH /library/{id},
/library/{id}/missing|found, and /library/artists/search in app.yaml
- new PATCH /library/:id integration spec + regression cases in
library-query.spec.js
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks for the thorough review — all 15 issues plus the quick-look items are addressed in d05b81f. Point-by-point: Blocking1. Jazz test seed — Fixed. Seeded a Jazz row (Miles Davis / Kind of Blue, genre_id 7) in 2. 3. Repeated 4. Cascade leaks into the PATCH /library/:id (5–8, 10, 11, 13)We went with true partial PATCH semantics: 5. Unconditional column rewrite — Fixed. A title-typo PATCH no longer resets 6. 7. 8. Empty 10. Orphan label on the 404 path — Fixed. The album row is resolved first; all validation and the label upsert happen only after the 404 check. Test asserts no labels row is created when PATCHing a nonexistent id. 11. Un-trimmed 13. 12. Stale enrichment on identity change — We did both halves: when Medium9. 14. ILIKE prefix escaping — Fixed in 15. Rotation-driven row duplication — Fixed. Quick-look items
|
Conflict resolution: union of drizzle/database imports in library-search.service.ts (inArray + library from this branch, artist_search_alias from main). Also migrated the new enrichAlbumAfterIdentityChange helper from the removed lookupMetadata to main's lmlLookupCoordinator.lookup, mirroring addAlbum's options (warm_cache, requireSearchType: 'direct') and null-result handling. Co-authored-by: Cursor <cursoragent@cursor.com>
recordActivity no longer SELECTs auth_user before inserting; update the unit suite to assert upsert runs unconditionally instead of expecting a skip for unknown user IDs. Co-authored-by: Cursor <cursoragent@cursor.com>
CI format:check flagged library-search.service.ts, library.service.ts, and library-query.spec.js after the PR #1154 review changes. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Backend support for Music Director catalog editing and richer catalog search on the Card Catalog.
PATCH /library/:idalbum update with validation (artist in genre, label resolution, etc.).catalog:read/catalog:writepermission checks; integration tests cover update, artist search, and query filter behavior.Pairs with the dj-site
feat/md-interfacePR for end-to-end MD catalog maintenance.Test plan
PATCH /library/:idupdates album metadata for authorized callers; rejects invalid artist/genre pairings/library/queryhonorsgenres,formats,rotation_bins, andmissingfilterslibrary.spec.js,library-query.spec.js)Made with Cursor