feat(managementapi): evict on the npm remote deletes (S17 Phase 6 plan: 29/42)
Why
The npm package, version, and file DELETE routes serve hosted repositories only. On a kind=remote repository they answer the existence-hiding 404, so nothing can evict a cached upstream artifact through the management API.
This is Step 29 of the S17 Phase 6 plan. The three routes now dispatch on the resolved repository's kind and run the eviction composers chore(datastore): compose the id-keyed npm remo... (!1573 - merged) • Hayley Swimelar • 19.3 added. An eviction answers 202, the target disappears from reads, no usage-data event is emitted, and no outbound upstream request is sent. Hosted behavior is unchanged.
Spec: docs/specs/S17-rest-management-api.md, AC #101 through #104. This is the npm twin of feat(managementapi): serve the maven remote pac... (!1726 - merged) • Hayley Swimelar • 19.4 (Maven, Step 28), and it mirrors that MR's seams.
What
The chain resolver is the security boundary, and the plan did not list it. Step 29's Files section names only the managementapi handlers. NpmRemoteEvictor's marks scope on namespace_id and the row id alone, so a request-supplied id would otherwise reach any row in the namespace, including one under a sibling remote repository. The new internal/datastore/npm_remote_eviction_resolve.go holds an addressed id to the repository the URL named before a composer sees it. Without it these routes would be a cross-repository delete primitive.
The dist-tag route keeps its remote 404, and it gained a test rather than an arm. Dist-tags are a hosted-only write, so there is nothing to evict. TestNpmDistTagDeleteHandler_RemoteRepositoryStays404 pins that deliberately, so a later wholesale arm-fill cannot widen the write surface without tripping a test.
The virtual kind's 404 moves level on two routes. On a kind=virtual repository the version and file deletes now answer the repository-level 404 where a malformed artifact id previously took the artifact-level one. The repository is the miss, so this is the correct direction. It is unreachable today because repository create rejects virtual and remote.
The file arm answers 202 on the composer's miss where the hosted arm answers 404. A hard delete that matched nothing means a concurrent eviction already removed the row, and eviction promises only that the coordinate stops being readable. An INFO line is the only thing separating that race from a resolve defect, because the status is identical either way.
Three npm divergences from the Maven twin. npm_remote_files.npm_remote_version_id is NOT NULL, so the fail-closed backstop compares against the zero UUID rather than a nil pointer. npm_remote_metadata_files is a table these routes never address, and the eviction leaves it byte for byte. The remote arms move no npm_packages.tags_count and emit no event, where the hosted arms do both.
The container step's test file loses one clause. main carries the dupl directive now, and its rationale names the Maven package matrix as the pair. That naming is merge-order dependent once the npm matrix lands beside it, so this step drops the clause and keeps the reason that holds either way: one line in internal/managementapi/container_remote_image_delete_test.go, Step 30's file.
Main's Maven eviction suites take dupl directives. They pair with this branch's npm ones only once both sit in one package, and dupl reports both sides, so each side needs its own: ten sites the untagged lint job sees and two more under the integration tag. The two wire guard chains go the other way and requireArtifactDeps loses the token, because the npm guards this branch adds push the chains apart. Every token measured with the directive neutralized.
The rebase drops three declarations Step 17 landed under this branch's names. findNpmRemoteRepositoryIDStmt, resolveNpmRemoteRepositoryID, and two npm remote log-field keys existed on both sides in different files, so they auto-merged into a redeclaration rather than a conflict. Main's copies stay, because its resolve logs the repository_id this branch's omitted.
Spec coverage
Spec: docs/specs/S17-rest-management-api.md. AC numbers use the spec's auto-numbered list. AC #104's reap half is S20-A's. This step owes the pre-reap half only.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-101 | DELETE on a remote package, version, and file returns 202, the target disappears from reads, no outbound upstream request, and a later pull re-caches it |
TestNpmRemotePackageDelete_EvictsAndAnswers202, TestNpmRemoteVersionDelete_EvictsAndAnswers202, TestNpmRemoteFileDelete_EvictsAndAnswers202, TestNpmRemoteEvictionIntegration_PackageEvictWalk, TestNpmRemoteEvictionIntegration_VersionEvictWalk, TestNpmRemoteEvictionIntegration_FileEvictWalk (the three walks carry the counting httptest upstream; the package and version walks also carry the re-cache, and the file arm's re-cache meets no tombstone, so it is pinned at the store) |
| AC-102 | Evictions emit no artifact_registry_artifact_deleted event, and the hosted deletes' emission is unchanged |
TestNpmRemote{Package,Version,File}Delete_KindDispatch (hosted row asserts one event, remote row zero), TestNpmRemoteVersionDelete_MovesNoCounterAndKeepsTheCachedDocuments, requireNpmRemoteEvictRedHerringsAbsent on every 404 and 500 row, TestNpmRemoteEvictionIntegration_*Walk |
| AC-103 | Evicting a remote npm version leaves the cached packument untouched until its own freshness window expires, and a pull of the evicted version's tarball through it re-caches the tarball | TestNpmRemoteEvictionIntegration_VersionEvictWalk (all four packument columns compared before and after), TestNpmRemoteEvictionIntegration_FileEvictWalk (the re-fill), TestNpmRemoteVersionDelete_MovesNoCounterAndKeepsTheCachedDocuments, TestResolveNpmRemoteVersionInRepositoryStmt_SQL (the resolve never reads the table), and e2e.npm.remote.evict-file for the tarball clause |
| AC-104 | Evicting a remote package removes, once the reap has run, its versions, files, and npm_remote_metadata_files rows with their attachments, and a package emptied of versions by eviction remains listed |
This step owes the pre-reap half only (the reap is S20-A's): TestNpmRemoteEvictionIntegration_PackageEvictWalk (mark on the named row, subtree live, packument attachment intact), TestNpmRemoteVersionDelete_ParentPackageStaysReachable, TestNpmRemoteEvictionIntegration_VersionEvictWalk (emptied package still resolves) |
Error cases
| Condition | Tests |
|---|---|
Artifact delete: target missing, or outside the URL's parent chain → 404 not_found |
TestNpmRemote{Package,Version,File}Delete_NotFoundMatrix, TestNpmRemotePackageDelete_RejectsNonCanonicalIDs, TestNpmRemoteEvictionResolver_Find*InRepository (integration), TestNpmRemoteEvictionIntegration_AuthorizationScoping |
Artifact routes: repository kind is virtual → 404 not_found |
TestNpmRemote{Package,Version,File}Delete_KindDispatch (virtual row), TestNpmRemoteEvictionIntegration_VirtualKindAnswers404 |
Artifact routes: repository format differs from the :format segment → 404 |
TestNpmRemote{Package,Version,File}Delete_NotFoundMatrix (Maven-repository row) |
npm dist-tag routes on a remote repository → 404 not_found (permanent, no eviction arm) |
TestNpmDistTagDeleteHandler_RemoteRepositoryStays404 |
Repeat eviction of an already-evicted target → 404 |
TestNpmRemote{Package,Version,File}Delete_SecondDeleteIs404, the integration walks' second delete |
A concurrent eviction removed the file first (existed=false, nil error) → idempotent 202 with an INFO record |
TestNpmRemoteFileDelete_ComposerMissIsIdempotentAccept, TestNpmRemoteFileDelete_ResponsesMatchOpenAPIContract |
All: unexpected server failure → 500 internal_server_error |
TestNpmRemote{Package,Version,File}Delete_FailureMatrix, TestNpmRemoteFileDelete_ContractViolationBackstops, TestNpmRemote{Package,Version,File}Delete_EvictorFailureLogs* |
A kind=remote parent with no npm_remote_repositories child row → logged 500, never a 404 |
TestNpmRemote{Package,Version,File}Delete_FailureMatrix (missing-child row), TestNpmRemoteEvictionResolver_FindNpmRemoteRepositoryID |
A stored kind outside the enum |
TestNpmRemote{Package,Version,File}Delete_KindDispatch (unmapped-kind row: rejected by resolveArtifactRepositoryForFormat with the logged 500 before any arm runs) |
| Client cancellation mid-eviction | TestNpmRemote{Package,Version,File}Delete_CanceledEvict_WritesNothing, TestNpmRemoteEvictionResolver_CancelledContext |
| Every response class validates against the OpenAPI contract | TestNpmRemote{Package,Version,File}Delete_ResponsesMatchOpenAPIContract |
Security considerations
| Concern | Tests |
|---|---|
Existence hiding on writes: an artifact outside the URL's parent chain returns 404 on single delete |
TestNpmRemote{Package,Version,File}Delete_NotFoundMatrix (sibling-repository and cross-namespace rows, each asserting the foreign row survives), TestNpmRemoteEvictionIntegration_AuthorizationScoping |
Tenant isolation: every query is scoped by the resolved namespace_id, and artifact queries verify the full parent chain |
TestFindNpmRemote{Repository,File}*Stmt_SQL and TestResolveNpmRemote{Package,Version}InRepositoryStmt_SQL (the emitted scoping and soft-delete legs), TestNpmRemoteEvictionResolver_Find*InRepository (integration: marked row, live child under a marked parent, sibling repository, foreign namespace) |
| Echoed input: no failure response repeats a submitted identifier | TestNpmRemote{Package,Version,File}Delete_NotFoundMatrix (one static message per level), TestNpmRemote{Package,Version,File}Delete_FailureMatrix (the 500 body must not leak the cause) |
| A cancellation must not read as an absent row on a delete route | TestNpmRemoteEvictionResolver_CancelledContext (ErrorIs(context.Canceled) and NotErrorIs(ErrNotFound) on all four reads) |
| A wiring bug must not read as an absent row | TestNpmRemoteEvictionResolver_ZeroValue, TestNpmRemoteEvictionResolver_Find*_Guards, TestNewNpmRemoteEvictionResolver_NilClient |
| Upstream credentials absent from the eviction path | TestFindNpmRemoteRepositoryIDStmt_SQL (the child probe projects the id alone, never tmp_plaintext_auth_token) |
Write authorization (delete_artifact) |
S09-owned. Runs the stub in this phase, not tested in this MR. |
| Authentication (S08) | S08-owned. Runs the stub in this phase, not tested in this MR. |
| SSRF, upstream URL validation | S13-owned. The eviction path sends no outbound request, which is asserted by the counting upstream in the integration walks. |
Hosted-suite census
Test and subtest counts are unchanged on every hosted npm and shared delete suite. Four table rows were dropped, each because the route now dispatches a remote npm repository to its eviction arm rather than answering the repository 404, and each row's guarantee moves to the new kind-dispatch tables. This is the same relocation the Maven step performs on its own suites.
npm_version_delete_test.go: "remote repository is not a hosted delete target"npm_file_delete_test.go: "remote repository is not a hosted delete target"package_delete_test.go: "repository of kind remote" and "remote-kind repository"
Database Review Evidence
No migration is added or modified, so migration mode does not apply. Query mode covers the four new query-producing methods in internal/datastore/npm_remote_eviction_resolve.go, all of which dispatch through instrumentQuery. internal/datastore/npm_remote_eviction.go also changed, but only in comments: every chain it builds is byte for byte the one already on main, so the evictor's marks and file delete carry no new statement to plan.
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml) holding the migrated schema, with the
container torn down at the end of the run. Numbers reflect moderate
cardinality and do not capture production-scale effects. See
Database review evidence
for seed sizing, methodology, and the anomalies the skill flags. Expand
each row's details for the seed shape, rendered SQL, bound args, and raw plan.
One seed serves all four statements: 5 namespaces, 5,040 repositories, 5,040 remote repositories, 30,080 packages, 50,160 versions, and 20,000 files over 20,000 blobs and attachments. The addressed namespace's hash partition holds 5,000 remote repositories, 30,000 packages, 50,000 versions, and 20,000 files, so no plan is choosing among near-empty relations. 4,999 of those remote repositories are live siblings of the one addressed, and every level carries marked rows (5,000 packages, 2,508 versions, 2,000 files), so the npm_remote_repository_id and soft_deleted_at IS NULL legs have to discriminate. The seed is committed and VACUUM (ANALYZE)d before measurement, each EXPLAIN (ANALYZE, BUFFERS) ran in a rolled-back transaction, and every plan is reported warm.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.NpmRemoteEvictionResolver.FindNpmRemoteRepositoryID |
Limit over Index Scan | unique_npm_remote_repositories_namespace_id_and_repository_id |
1 / 1 | 8.30 | 0.024ms | 3 / 0 | 1/64 |
datastore.NpmRemoteEvictionResolver.FindNpmRemotePackageInRepository |
Limit over Index Scan | pk_npm_remote_packages |
1 / 1 | 8.31 | 0.015ms | 3 / 0 | 1/64 |
datastore.NpmRemoteEvictionResolver.FindNpmRemoteVersionInRepository |
Limit over Nested Loop of two Index Scans | pk_npm_remote_versions, pk_npm_remote_packages |
1 / 1 | 16.75 | 0.013ms | 7 / 0 | 1/64, 1/64 |
datastore.NpmRemoteEvictionResolver.FindNpmRemoteFileInRepository |
Limit over Nested Loop of three Index Scans | pk_npm_remote_files, pk_npm_remote_versions, pk_npm_remote_packages |
1 / 1 | 17.12 | 0.019ms | 10 / 0 | 1/64, 1/64, 1/64 |
Pruning reaches the joined side, which the statements do not bind directly. The version and file statements bind a namespace_id literal on the anchor table only, and the joined tables take their partition key from the equijoin column. The planner closes that gap: from npm_remote_packages.namespace_id = npm_remote_versions.namespace_id and npm_remote_versions.namespace_id = <literal> its equivalence class derives the literal for the joined side. Every scan node in all four plans names one partition, all _p20, and no Append appears, so the other 63 are excluded at plan time rather than skipped at run time. enable_partitionwise_join is off. The MR's own TestNpmRemoteEvictionResolver_*_PrunesToOnePartition tests pin this at fixture volume, and these numbers measure it at 5,000 to 50,000 rows per partition.
The chain legs were probed, not assumed. Rebinding the third argument to a live sibling remote repository in the same namespace returns 0 rows on both the package and the file resolve, and a marked package returns 0 rows through its own repository. That is the property the resolver exists for, because NpmRemoteEvictor scopes on namespace_id and the row id alone.
Production always gets a custom plan. labkit's client defaults to pgx.QueryExecModeSimpleProtocol and this service does not override it, so every execution is planned with the values inlined, which is the regime above. Forcing plan_cache_mode = force_generic_plan still reaches one partition per table, by run-time pruning (Subplans Removed: 63) at 21 ms of planning against 0.35 ms.
A second run at the skill's canonical shape (one row per ancestor plus a live sibling parent, 5,000 rows in the read target, seeded inside the measured transaction) reached the same verdict on both joined resolves: one partition per table, 0 rows for the sibling bind. Its joined side comes back as a sequential scan over a single-row partition, a cost choice at that volume rather than weaker pruning, and it returns to an index scan at the volume the table above measures.
datastore.NpmRemoteEvictionResolver.FindNpmRemoteRepositoryID
Summary: Plan matches the method's intent. The probe is satisfied straight from the child table's unique (namespace_id, repository_id) index, with the namespace_id literal pruning to 1 of 64 partitions at plan time. Estimate and actual agree at 1 row, 3 buffer hits, no read. No anomalies.
Seed shape: npm_remote_repositories=5,000 in the addressed partition (5,040 total), repositories=5,040, namespaces=5
Rendered SQL:
SELECT npm_remote_repositories.id AS "npm_remote_repositories.id"
FROM public.npm_remote_repositories
WHERE (npm_remote_repositories.namespace_id = $1::uuid) AND (npm_remote_repositories.repository_id = $2::uuid)
LIMIT $3;Bound args: [2a0c13d2-da7a-4839-9702-3f0b27671953, c9ffa994-61ba-4c87-afd7-cc6f42aa8966, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.30 rows=1 width=16) (actual time=0.024..0.024 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_remote_repositories_p20_namespace_id_repository_id_idx on npm_remote_repositories_p20 npm_remote_repositories (cost=0.28..8.30 rows=1 width=16) (actual time=0.024..0.024 rows=1 loops=1)
Index Cond: ((namespace_id = '2a0c13d2-da7a-4839-9702-3f0b27671953'::uuid) AND (repository_id = 'c9ffa994-61ba-4c87-afd7-cc6f42aa8966'::uuid))
Buffers: shared hit=3
Planning Time: 0.101 ms
Execution Time: 0.037 msTimings: planning 0.101ms, execution 0.037ms, total 0.138ms.
datastore.NpmRemoteEvictionResolver.FindNpmRemotePackageInRepository
Summary: Plan matches the method's intent. The primary key on (id, namespace_id) addresses the row and prunes to 1 of 64 partitions, and the repository and liveness legs apply as a filter on that one row. index_npm_remote_packages_on_ns_id_repo_id exists but would be the wrong pick, since it returns the repository's whole package set. No anomalies.
Seed shape: npm_remote_packages=30,000 in the addressed partition (30,080 total) across 5,000 remote repositories, 5,000 of them marked
Rendered SQL:
SELECT npm_remote_packages.namespace_id AS "npm_remote_packages.namespace_id",
npm_remote_packages.id AS "npm_remote_packages.id",
npm_remote_packages.npm_remote_repository_id AS "npm_remote_packages.npm_remote_repository_id",
npm_remote_packages.name AS "npm_remote_packages.name",
npm_remote_packages.scope AS "npm_remote_packages.scope",
npm_remote_packages.last_downloaded_at AS "npm_remote_packages.last_downloaded_at",
npm_remote_packages.soft_deleted_at AS "npm_remote_packages.soft_deleted_at"
FROM public.npm_remote_packages
WHERE (((npm_remote_packages.namespace_id = $1::uuid) AND (npm_remote_packages.id = $2::uuid)) AND (npm_remote_packages.npm_remote_repository_id = $3::uuid)) AND (npm_remote_packages.soft_deleted_at IS NULL)
LIMIT $4;Bound args: [2a0c13d2-da7a-4839-9702-3f0b27671953, ee26d32a-4e0c-4b50-bd49-06a00d02fb43, 9ce2f598-320b-41ce-b767-8b95d106c485, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..8.31 rows=1 width=110) (actual time=0.015..0.016 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_remote_packages_p20_pkey on npm_remote_packages_p20 npm_remote_packages (cost=0.29..8.31 rows=1 width=110) (actual time=0.015..0.015 rows=1 loops=1)
Index Cond: ((id = 'ee26d32a-4e0c-4b50-bd49-06a00d02fb43'::uuid) AND (namespace_id = '2a0c13d2-da7a-4839-9702-3f0b27671953'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (npm_remote_repository_id = '9ce2f598-320b-41ce-b767-8b95d106c485'::uuid))
Buffers: shared hit=3
Planning Time: 0.145 ms
Execution Time: 0.035 msTimings: planning 0.145ms, execution 0.035ms, total 0.180ms.
datastore.NpmRemoteEvictionResolver.FindNpmRemoteVersionInRepository
Summary: Plan matches the method's intent. The leaf primary key seeks the version, one nested-loop hop takes the package by its own primary key, and both scans name one partition. The parent's liveness and npm_remote_repository_id legs apply on that single package row. No anomalies.
Seed shape: npm_remote_versions=50,000 in the addressed partition (50,160 total) under 25,000 packages, 2,508 versions marked
Rendered SQL:
SELECT npm_remote_versions.namespace_id AS "npm_remote_versions.namespace_id",
npm_remote_versions.id AS "npm_remote_versions.id",
npm_remote_versions.npm_remote_package_id AS "npm_remote_versions.npm_remote_package_id",
npm_remote_versions.version AS "npm_remote_versions.version",
npm_remote_versions.size_bytes AS "npm_remote_versions.size_bytes",
npm_remote_versions.created_at AS "npm_remote_versions.created_at",
npm_remote_versions.last_downloaded_at AS "npm_remote_versions.last_downloaded_at",
npm_remote_versions.soft_deleted_at AS "npm_remote_versions.soft_deleted_at"
FROM public.npm_remote_versions
INNER JOIN public.npm_remote_packages ON ((npm_remote_packages.namespace_id = npm_remote_versions.namespace_id) AND (npm_remote_packages.id = npm_remote_versions.npm_remote_package_id))
WHERE ((((npm_remote_versions.namespace_id = $1::uuid) AND (npm_remote_versions.id = $2::uuid)) AND (npm_remote_versions.soft_deleted_at IS NULL)) AND (npm_remote_packages.soft_deleted_at IS NULL)) AND (npm_remote_packages.npm_remote_repository_id = $3::uuid)
LIMIT $4;Bound args: [2a0c13d2-da7a-4839-9702-3f0b27671953, 0001ee34-d0f2-4c7f-b80a-2430cb861eba, 9ce2f598-320b-41ce-b767-8b95d106c485, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.70..16.75 rows=1 width=86) (actual time=0.013..0.013 rows=1 loops=1)
Buffers: shared hit=7
-> Nested Loop (cost=0.70..16.75 rows=1 width=86) (actual time=0.013..0.013 rows=1 loops=1)
Buffers: shared hit=7
-> Index Scan using npm_remote_versions_p20_pkey on npm_remote_versions_p20 npm_remote_versions (cost=0.41..8.43 rows=1 width=86) (actual time=0.007..0.007 rows=1 loops=1)
Index Cond: ((id = '0001ee34-d0f2-4c7f-b80a-2430cb861eba'::uuid) AND (namespace_id = '2a0c13d2-da7a-4839-9702-3f0b27671953'::uuid))
Filter: (soft_deleted_at IS NULL)
Buffers: shared hit=4
-> Index Scan using npm_remote_packages_p20_pkey on npm_remote_packages_p20 npm_remote_packages (cost=0.29..8.31 rows=1 width=32) (actual time=0.005..0.005 rows=1 loops=1)
Index Cond: ((id = npm_remote_versions.npm_remote_package_id) AND (namespace_id = '2a0c13d2-da7a-4839-9702-3f0b27671953'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (npm_remote_repository_id = '9ce2f598-320b-41ce-b767-8b95d106c485'::uuid))
Buffers: shared hit=3
Planning Time: 0.185 ms
Execution Time: 0.026 msTimings: planning 0.185ms, execution 0.026ms, total 0.211ms.
datastore.NpmRemoteEvictionResolver.FindNpmRemoteFileInRepository
Summary: Plan matches the method's intent. Three primary-key index scans in two nested loops walk file to version to package, each naming one partition, with the three soft_deleted_at IS NULL legs and the repository leg applied as filters on single rows. Widest of the four chains at 10 buffer hits and 0.044 ms, no read. No anomalies.
Seed shape: npm_remote_files=20,000 in the addressed partition over 20,000 blobs and attachments, 2,000 files marked
Rendered SQL:
SELECT npm_remote_files.namespace_id AS "npm_remote_files.namespace_id",
npm_remote_files.id AS "npm_remote_files.id",
npm_remote_files.npm_remote_version_id AS "npm_remote_files.npm_remote_version_id",
npm_remote_files.file_name AS "npm_remote_files.file_name",
npm_remote_files.upstream_checked_at AS "npm_remote_files.upstream_checked_at",
npm_remote_files.soft_deleted_at AS "npm_remote_files.soft_deleted_at"
FROM public.npm_remote_files
INNER JOIN public.npm_remote_versions ON ((npm_remote_versions.namespace_id = npm_remote_files.namespace_id) AND (npm_remote_versions.id = npm_remote_files.npm_remote_version_id))
INNER JOIN public.npm_remote_packages ON ((npm_remote_packages.namespace_id = npm_remote_versions.namespace_id) AND (npm_remote_packages.id = npm_remote_versions.npm_remote_package_id))
WHERE (((((npm_remote_files.namespace_id = $1::uuid) AND (npm_remote_files.id = $2::uuid)) AND (npm_remote_files.soft_deleted_at IS NULL)) AND (npm_remote_versions.soft_deleted_at IS NULL)) AND (npm_remote_packages.soft_deleted_at IS NULL)) AND (npm_remote_packages.npm_remote_repository_id = $3::uuid)
LIMIT $4;Bound args: [2a0c13d2-da7a-4839-9702-3f0b27671953, 3d9435b4-7dbc-45ca-8937-05758ab641ec, 9ce2f598-320b-41ce-b767-8b95d106c485, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.99..17.12 rows=1 width=83) (actual time=0.019..0.020 rows=1 loops=1)
Buffers: shared hit=10
-> Nested Loop (cost=0.99..17.12 rows=1 width=83) (actual time=0.019..0.020 rows=1 loops=1)
Buffers: shared hit=10
-> Nested Loop (cost=0.70..16.75 rows=1 width=115) (actual time=0.012..0.013 rows=1 loops=1)
Buffers: shared hit=7
-> Index Scan using npm_remote_files_p20_pkey on npm_remote_files_p20 npm_remote_files (cost=0.29..8.31 rows=1 width=83) (actual time=0.007..0.007 rows=1 loops=1)
Index Cond: ((id = '3d9435b4-7dbc-45ca-8937-05758ab641ec'::uuid) AND (namespace_id = '2a0c13d2-da7a-4839-9702-3f0b27671953'::uuid))
Filter: (soft_deleted_at IS NULL)
Buffers: shared hit=3
-> Index Scan using npm_remote_versions_p20_pkey on npm_remote_versions_p20 npm_remote_versions (cost=0.41..8.43 rows=1 width=48) (actual time=0.005..0.005 rows=1 loops=1)
Index Cond: ((id = npm_remote_files.npm_remote_version_id) AND (namespace_id = '2a0c13d2-da7a-4839-9702-3f0b27671953'::uuid))
Filter: (soft_deleted_at IS NULL)
Buffers: shared hit=4
-> Index Scan using npm_remote_packages_p20_pkey on npm_remote_packages_p20 npm_remote_packages (cost=0.29..0.36 rows=1 width=32) (actual time=0.006..0.006 rows=1 loops=1)
Index Cond: ((id = npm_remote_versions.npm_remote_package_id) AND (namespace_id = '2a0c13d2-da7a-4839-9702-3f0b27671953'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (npm_remote_repository_id = '9ce2f598-320b-41ce-b767-8b95d106c485'::uuid))
Buffers: shared hit=3
Planning Time: 0.353 ms
Execution Time: 0.044 msTimings: planning 0.353ms, execution 0.044ms, total 0.397ms.
Test plan
Every gate below was run on the final tree and is green.
go build ./...,go vet ./..., andgo vet -tags=integration ./...golangci-lint2.12.2 on the touched packages, plain and with--build-tags=integration --max-same-issues=0 --max-issues-per-linter=0: 0 issues. CI lint cannot see the integration-tagged files.- Whole-tree unit suite,
go test ./... -count=1: 0 failures. - Integration suites against PostgreSQL 17.10:
./internal/managementapi/(57s),./internal/datastore/(540s),./cmd/artifact-registry/(124s), and the migrations suite (415s).
Two e2e scenarios are added. main served the remote packument read, and then the remote tarball read, while this branch stood still, so remote eviction's end-to-end claim is exercisable at both levels: e2e.npm.remote.evict-package and e2e.npm.remote.evict-file in docs/testing/e2e/npm.md drive the DELETE and assert that the read which follows re-fetches from the upstream. Both ride the ## Remote repositories group's seeded npm_remote_repositories binding, because repository create still rejects kind=remote. Version eviction gets no row: it leaves the packument unchanged by design, so no end-to-end read observes it. The integration walks assert all three at the datastore and handler level. Sibling Step 28 (feat(managementapi): serve the maven remote pac... (!1726 - merged) • Hayley Swimelar • 19.4) has no served Maven remote read and still declines a scenario.
Conformance suites are unaffected. This MR changes management API routes under /api/v1/, not the npm wire protocol.
Diff size
5,277 added lines across 43 files, past the 500 docs/dev/development-model.md asks a justification for.
| Group | Added |
|---|---|
| Production Go | 719 |
| Tests | 4334 |
Wiring (cmd/, tests included) |
176 |
Prose (api/, docs/) |
48 |
Tests carry the line count: 4,471 added test lines against 758 of production Go, a 5.9:1 ratio.
Splitting by route does not help. The three arms share one chain resolver and one Deps block, and the kind widening is one behavior expressed across three routes. A per-route split lands the resolver with whichever arm goes first and leaves the other two reviewing a security boundary they cannot exercise. Splitting the resolver out on its own lands a store with no caller. The Maven twin feat(managementapi): serve the maven remote pac... (!1726 - merged) • Hayley Swimelar • 19.4 is the same shape at 5,872 added lines (measured at its head 119aec92).
Context for LLM agents
Rationale
- No
cmd/artifact-registry/npm_remote_evictor.go.*datastore.NpmRemoteEvictorsatisfies all three write seams directly, because its methods are pool-bound (e.client.DB()per call, noqrm.DBargument). Maven needed that file only because its marks take an explicitqrm.DB. A pass-through adapter written purely to mirror Maven would be dead indirection. - One
default:arm per route, foldingRepositoryKindVirtualand an unmapped kind into a singlewriteRepositoryNotFound. Kept for Maven parity.check_repositories_kindbounds the column to 0, 1, and 2, so the two cases cannot be reached apart. - A cancelled context returns silently on the eviction paths, so the metrics middleware counts a 200. That is the shared
logAndWriteInternalErrorbehavior, already present on the Maven marks and the npm hosted deletes, so this MR does not introduce it. A follow-up spanning both formats owns the fix. The file arm's half is already closed bywriteNpmRemoteFileEvictFailure. - The resolve runs before the composer on every arm, and its miss is the artifact 404 while the child-row probe's miss is a logged 500. A
kind=remoteparent carrying nonpm_remote_repositoriesrow is a broken create invariant, not a miss to hide.
Non-goals
- The reap of marked subtrees. S20-A's lifecycle purger owns it, per plan Step 26. This step asserts the pre-reap half of AC #104 only.
- An eviction arm on the npm dist-tag route. Dist-tags are a hosted-only write, so the remote 404 is permanent rather than pending.
- Externally visible eviction state. Beyond the 202, an eviction is invisible: once the mark commits, a repeat delete answers 404 like any other miss.
- Bulk eviction. Plan Step 32 owns the npm remote bulk pass.
Related to #314