Problem
A hosted project's display row outlives its files. Deleting every file of a project leaves the root Simple index advertising a project whose detail page answers 404, which breaks the PEP 691 contract instead of just showing stale text.
Reproduced against e64cc8308 through the served HTTP surface: upload one wheel to a hosted index, DELETE /root/pypi/peryxpkg/, then read both views. Detail answers 404 while the root index answers 200 with {"meta":{"api-version":"1.4"},"projects":[{"name":"peryxpkg"}]}.
The cause is that remove_upload_row maintains the per-index counts but never touches the project row, and list_projects returns every project holding a PROJECTS row minus retired tombstones, without consulting whether any live upload remains.
Corrections to the original report
The first version of this issue named delete_upload as the entry point. That is wrong and sends a reader down a dead end, so this corrects it.
delete_upload is test-only. Nothing in production calls it. mutate_uploads is test-only as well, which makes UploadMutation::Delete and the remove_upload_row call behind it unreachable outside tests.
HTTP DELETE soft-deletes. The real path is remove_files_with_webhook to mutate_uploads_and_overrides, whose closure returns a replacement record with trashed set. It removes no row. So the reproduction above is the trash case: the record survives, marked trashed, the detail page stops serving it, and the root listing keeps advertising the project.
No production path removes a hosted upload row from the store. Retention plans and exports decisions and nothing applies them; the ExportHeader doc in peryx-driver/src/retention.rs refers to "a later apply" that does not exist yet. This reads as a boundary nobody has built yet, not a second defect, and it decides what this issue can promise.
Required change
Maintain a persisted live-upload count or visibility marker per hosted project, written in the same transaction as every mutation that can change it: upload, trash, restore, promotion, and the store's delete paths. A second transaction would replace the inconsistency this closes with a new window.
list_projects includes a hosted project when the marker shows at least one untrashed upload and excludes it at zero. The display row stays while trashed records remain, so a restore returns the original spelling. When the final upload record goes, both the display and marker rows go with it, and a later upload writes fresh display spelling.
Acceptance checks
- Publish one file and trash it. Root and detail views omit the project; restore returns the original display spelling.
- Delete one file from a multi-file project. Root and detail views retain the project.
- Purge the trashed file. Root and detail views still omit the project, and the display and visibility rows no longer exist.
- Upload the project after final purge with different display spelling. Root and detail views use the new spelling.
The first two are reachable today, and a fix answers to them. The last two have no production trigger, because nothing purges. A PR can drive them through the store API so the invariant holds once an apply phase arrives, and should claim exactly that instead of an end-to-end purge it cannot exercise.
Architecture and test boundary
Keep PyPI protocol rules, normalization, metadata semantics, handlers, tooling, and behavioral tests in peryx-ecosystem-pypi. Shared crates may expose ecosystem-neutral data and narrow capability traits; peryx-ecosystem-pypi implements those traits.
Do not add a shared-crate dependency on peryx-ecosystem-pypi or a PyPI branch in generic orchestration. Use peryx-plugin-registry only where the binary composes concrete plugins. The ecosystem crate must build and test independently. System tests may verify composition without taking ownership of PyPI behavior.
Problem
A hosted project's display row outlives its files. Deleting every file of a project leaves the root Simple index advertising a project whose detail page answers
404, which breaks the PEP 691 contract instead of just showing stale text.Reproduced against
e64cc8308through the served HTTP surface: upload one wheel to a hosted index,DELETE /root/pypi/peryxpkg/, then read both views. Detail answers404while the root index answers200with{"meta":{"api-version":"1.4"},"projects":[{"name":"peryxpkg"}]}.The cause is that
remove_upload_rowmaintains the per-index counts but never touches the project row, andlist_projectsreturns every project holding aPROJECTSrow minus retired tombstones, without consulting whether any live upload remains.Corrections to the original report
The first version of this issue named
delete_uploadas the entry point. That is wrong and sends a reader down a dead end, so this corrects it.delete_uploadis test-only. Nothing in production calls it.mutate_uploadsis test-only as well, which makesUploadMutation::Deleteand theremove_upload_rowcall behind it unreachable outside tests.HTTP
DELETEsoft-deletes. The real path isremove_files_with_webhooktomutate_uploads_and_overrides, whose closure returns a replacement record withtrashedset. It removes no row. So the reproduction above is the trash case: the record survives, marked trashed, the detail page stops serving it, and the root listing keeps advertising the project.No production path removes a hosted upload row from the store. Retention plans and exports decisions and nothing applies them; the
ExportHeaderdoc inperyx-driver/src/retention.rsrefers to "a later apply" that does not exist yet. This reads as a boundary nobody has built yet, not a second defect, and it decides what this issue can promise.Required change
Maintain a persisted live-upload count or visibility marker per hosted project, written in the same transaction as every mutation that can change it: upload, trash, restore, promotion, and the store's delete paths. A second transaction would replace the inconsistency this closes with a new window.
list_projectsincludes a hosted project when the marker shows at least one untrashed upload and excludes it at zero. The display row stays while trashed records remain, so a restore returns the original spelling. When the final upload record goes, both the display and marker rows go with it, and a later upload writes fresh display spelling.Acceptance checks
The first two are reachable today, and a fix answers to them. The last two have no production trigger, because nothing purges. A PR can drive them through the store API so the invariant holds once an apply phase arrives, and should claim exactly that instead of an end-to-end purge it cannot exercise.
Architecture and test boundary
Keep PyPI protocol rules, normalization, metadata semantics, handlers, tooling, and behavioral tests in
peryx-ecosystem-pypi. Shared crates may expose ecosystem-neutral data and narrow capability traits;peryx-ecosystem-pypiimplements those traits.Do not add a shared-crate dependency on
peryx-ecosystem-pypior a PyPI branch in generic orchestration. Useperyx-plugin-registryonly where the binary composes concrete plugins. The ecosystem crate must build and test independently. System tests may verify composition without taking ownership of PyPI behavior.