Repair the 2026-07-30 review findings #1
Reference in New Issue
Block a user
Delete Branch "repair/review-2026-07-30"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
One commit per finding from
REVIEW-2026-07-30.md, in the review's "repair now" order.canonical_name, stop writing generatednormalized_namedecayBeliefsCTE so belief decay actually runsNULLS NOT DISTINCTdeleted_permanentaudit row destroying itselfcurrentIndexso prev and repeat-all workPooland real transactionsPlus a
package-lock.jsonrefresh and the review doc itself.Verification
git rebase master --exectypechecked backend + workers at every commitNotes for review
The worker's Pool / integrity / hard-delete trio is type-entangled. So each commit compiles standalone, the Pool commit carries only the constructor type change for
integrity.service.tsandcleanup.service.ts; their real fixes land in the next two commits. Consequence: at the Pool commitcleanup.service.tsstill issuesBEGIN/COMMITagainst a Pool — wrong, noted in that commit message, replaced wholesale by the hard-delete commit. No intermediate code exists that isn't in the final tree.MUZICK_ALLOW_HARD_DELETEis off everywhere — commented out indocker-compose.yml, no enabling default in code, worker's/musicbind is the only writable one. Deletion stays dry-run until turned on deliberately. The 37 stuckWARNEDrows are untouched, as is the live stack.The dislike-lifecycle decision went against the review's recommendation at the owner's direction: real
unlink()(no trash dir) after a 7-day grace period post-HIDDEN, rather than making HIDDEN terminal.Three separate insert paths made a fresh Postgres volume unusable. The live database only works because its volume predates the constraints. - scanner.service.resolveOrCreateArtist inserted only (name), but schema.sql declares canonical_name NOT NULL with no default. Every artist insert failed, and processFile swallows per-file errors, so a scan reported success with 0 tracks and a permanently empty library. - enrichment.service inserted explicitly into artists.normalized_name, which is GENERATED ALWAYS AS (normalize_artist(name)) STORED: "cannot insert a non-DEFAULT value into column" (428C9). All enrichment artist creation failed on a fresh volume. - db.service.createArtist omitted canonical_name, same failure. canonical_name holds the raw tag name, not normalize_artist() output, which truncates on `/` and a standalone `x` ("AC/DC" -> "AC"). That is the convention createLocalArtist already used. The truncation bug in artists.name is pre-existing and deliberately left untouched here. Verified on a scratch postgres:16-alpine with the real schema: the old statement reproduces the NOT NULL violation, the new path yields artists/albums/tracks/track_artists rows. REVIEW-2026-07-30.md finding 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>updateTrack/updateArtist/updateAlbum built their SET clause from Object.keys(data) where data is `request.body as any`, interpolating request-supplied keys straight into SQL as quoted identifiers: fields.map((f, i) => `"${f}" = $${i + 2}`) A crafted body key closes the quoted identifier and injects into the SET list. Mitigated in practice only by the LAN/VPN-only proxy — which supplies the auth token automatically, so any device on the LAN could reach it from a browser. Adds per-table UPDATABLE_COLUMNS plus an allowedFields() helper, applied in all three methods. The allowlist lives in the service layer rather than the routes so it covers every caller. Unknown keys are dropped rather than rejected: the three routes do no error mapping, so a throw surfaces as a bare 500, and the pre-existing "No fields to update" error still fires for a payload rejected in its entirety. Also closes plain mass-assignment. Excluded: path/hash/mtime (scanner-owned; path is the only link to the read-only bind), state/quarantined_at/deleted_at (dislike lifecycle and integrity sweep), play_count/skip_count/dislike_count/last_played_at (learning signal — forgeable counters poison the engine), and identity/generated columns. The only callers are the three HTTP PUTs; the frontend's update* service exports are dead code, so nothing relied on writing an excluded column. REVIEW-2026-07-30.md finding 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>throttle() read lastRequestAt, awaited delay(), then wrote it back — a TOCTOU race with no mutex or queue, under concurrency: 10. Ten jobs read the same timestamp, slept the same duration and fired in the same tick, giving up to ~10 req/s against MusicBrainz's 1 req/s policy and risking an IP block. Replaced the lastRequestAt map with a per-host { lastRequestAt, tail } limiter; each call links onto that host's promise chain, so the read-sleep-write critical section is serialized and N concurrent callers space out by minIntervalMs. Chain rejections are swallowed so one failure cannot poison the queue. Per-host rather than global, so other integrations are not starved by MusicBrainz. Measured: 5 concurrent same-host calls at 200ms -> 802ms (previously all in one tick); 3 distinct hosts at 1000ms -> 0ms, confirming no cross-host starvation. musicbrainz.client caught HttpError and returned null at all 7 catch sites, making a rate-limited MusicBrainz indistinguishable from "no data for your library" while every job reported success. A shared logMbFailure() now logs 429 (and 503 whose body mentions a rate limit) at error, stating results are INCOMPLETE. The error model is otherwise unchanged. REVIEW-2026-07-30.md finding 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The sweep stats every track path and marks unreadable files missing, with no check that /music is mounted. An unmounted or misbehaving bind would fail every stat and mark the entire library missing in one pass; the ratio is only recoverable by a full rescan. Three guards, cheapest first: - liveness: probe a sample of existing track paths before doing anything; abort if none are readable - ratio: abort mid-sweep if the missing fraction crosses a threshold, leaving already-marked rows alone rather than rolling back a partial pass - progress: keyset pagination over id with the cursor persisted in integrity_sweep_state, so a sweep aborted or restarted mid-run resumes instead of re-walking from the top and re-marking The repair-corrupted-metadata script shares the same failure mode and gets the same abort path. REVIEW-2026-07-30.md secondary finding: integrity sweep has no mount check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>The dislike lifecycle promised WARNED -> HIDDEN -> deleted, but nothing ever removed a file: cleanup.service logged its intent behind MUZICK_ALLOW_HARD_DELETE and returned, and the two backend delete paths (hardDeleteTrack, permanentlyDeleteTrack) disagreed about what deletion meant. The review recommended dropping hard deletion and making HIDDEN terminal; the owner chose to make deletion real instead. - cleanup.service performs a true unlink() — no trash directory — for tracks that have been HIDDEN for a 7-day grace period, then settles the row. This is the single unlink() call site in the system. - permanentlyDeleteTrack is the one delete path; hardDeleteTrack is gone. - a deleted_permanent audit row records what was removed, and migration 20260730_hard_delete_audit_trail backs it. MUZICK_ALLOW_HARD_DELETE remains OFF: the docker-compose entry is commented out, there is no enabling default in code, and the worker's /music bind is the only writable one. Deletion stays dry-run until the owner opts in deliberately. REVIEW-2026-07-30.md open decision: dislike lifecycle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>@@ -506,1 +552,4 @@},{id: '20260730_claims_dedup_nulls_not_distinct',sql: `might be a good idea to extract this in separate file.
Done, two extraction commits pushed.
db.service.ts2085 -> 1398 — three pure moves intobackend/src/db/(which already ownsschema.sql):migrations.ts(513),types.ts(168),updatable-columns.ts(42).db.service.tsre-exports../db/types.js, so existingimport { Track, ListenerBelief } from '../services/db.service.js'in the routes/generators/session-director is untouched. Verified rather than asserted: the migration id list and the whole 502-line SQL body diff byte-identical against the parent commit.index.ts515 -> 313 — thereprocess_artistscase was 208 lines (40% of the file, and most of what this PR changed there); it moves toreprocess-artists.service.ts. That also collapsed a real duplication: the artist merge and the normalized_name dedup pass ran the same five statements in the same order on different id pairs, so thewithTransactionchange had to be written twice. Both now call onemergeArtistInto(client, keepId, loserId)taking aQueryable, so the caller owns the transaction and the ON DELETE CASCADE hazard is documented once. Statement sequence diffs identical against the parent.Gate re-run over all 15 commits: backend + workers typecheck at every commit, frontend typecheck clean, 33/33 tests.
Deliberately not split here:
enrichment.service.ts(1316),musicbrainz.client.ts(548),Jobs.tsx(514).enrichment.service.tshas clean seams (identity resolution / images /enrichTrack/ album dedup) but it is one class, so splitting it means converting methods to free functions over aQueryable— a real behavioural refactor, not a move, and it would make this PR harder to review, not easier. Better as its own PR.db.service.ts was 2085 lines, of which ~700 were not behaviour at all: the migration registry, the row-shape interfaces, and the column allowlist. That makes the file painful to review — the reviewer's note on the MIGRATIONS array. Three pure moves into backend/src/db/, which already owns schema.sql: - migrations.ts (513) — the registry, plus a named Migration type - types.ts (168) — the row shapes - updatable-columns.ts (42) — UPDATABLE_COLUMNS + allowedFields db.service.ts drops to 1378 lines and re-exports ../db/types.js, so existing `import { Track, ListenerBelief } from '../services/db.service.js'` in the routes, generators and session-director keeps working untouched. No behaviour change, and verified as such rather than asserted: the migration id list and the entire 502-line SQL body diff byte-identical against the previous commit, backend typecheck is clean and 33/33 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>