S05/Step 2: Add lastDownloadedAt to Artifact Registry element types
Step 1, !252683 (merged), merged as b1c42ff1. This MR now targets master and is out of Draft.
What this does
Step 2 of 3. It adds a nullable lastDownloadedAt field to the three Artifact Registry artifact element types (ArtifactRegistryMavenPackage, ArtifactRegistryNpmPackage, ArtifactRegistryImage) and a matching reader on each value object, coerced through the shared ArtifactRegistry::TimeCoercion module. The field lands on the shared element types rather than remote-only ones, because the service serializes it for every repository kind and a hosted repository's table simply does not render it.
The versionsCount nullability change
versionsCount on the npm package type changes from null: false to null: true. This is required for the step to work, not unrelated cleanup.
On a remote repository an npm package is a cache entry and the cache stores no version counter, so the service sends null rather than zero. The value object passes that null through. Against a non-null field this is a non-null propagation error rather than a field resolving null. Because the connection's nodes list has nullable items, the nearest nullable ancestor is the element, so that row reads null and the response carries a GraphQL error, instead of the field simply reading null. The intended behaviour, a remote npm package reporting an unknown count rather than a false zero, was therefore unreachable while the field stayed non-null.
By the styleguide's own definition this is a breaking change: doc/development/api_graphql_styleguide.md lines 272 to 273 list changing a field from null: false to null: true as one. It is permitted anyway, because two independent exemptions each cover it on their own. The field is marked experiment: { milestone: '19.3' }, and doc/development/graphql_guide/reviewing.md:30 says an experiment can take breaking changes immediately with no deprecation period. It also sits behind artifact_registry_ui, which is disabled by default (ee/config/feature_flags/wip/artifact_registry_ui.yml), and doc/api/graphql/_index.md lines 290 to 291 says fields behind a disabled-by-default flag do not follow the deprecation and removal process. Its experiment milestone deliberately stays at 19.3, because doc/development/api_graphql_styleguide.md:1033 defines that property as the milestone that introduced the experimental item, not the milestone it last changed in. Beyond the exemptions, a non-null field that can never resolve non-null is broken as declared: the service declares NpmPackage.versions_count as type: [integer, 'null'] and required, so null: false was a contract violation, and this change is a correction rather than a loosening.
No frontend change is needed, and not because of coalescing on display. The remote artifact table has no Versions column at all: ee/app/assets/javascripts/packages_and_registries/artifact_registry/constants.js lines 369 to 374 define the remote field lists as name plus Last downloaded for all four formats, with no versionsCount entry, and the comment above them at lines 360 to 361 records why. The ?? 0 in artifacts_table.vue lines 54 to 56 is therefore unreachable for this case, because the only null-producing case is a remote npm package and that column never renders for a remote repository. There is no display path for the null at all.
Local typedefs deletion and merge order
Three local lastDownloadedAt declarations are deleted from ee/app/assets/javascripts/packages_and_registries/artifact_registry/graphql/typedefs.graphql. Once the server schema owns the field, redeclaring it locally duplicates a field the schema already carries. Enforcement is lint rather than runtime: eslint.config.mjs lines 767 to 774 lint **/*.graphql against the schema the generate-apollo-graphql-schema job builds, and config/apollo.config.js:7 feeds typedefs.graphql into that build.
Merge order requirement: this MR must merge before whatever change stops these two documents being resolved locally, because a schema-bound document selecting a field the schema does not carry fails schema verification. The two documents are ee/app/assets/javascripts/packages_and_registries/artifact_registry/graphql/queries/get_repository_packages.query.graphql and ee/app/assets/javascripts/packages_and_registries/artifact_registry/graphql/queries/get_repository_images.query.graphql. Both already select lastDownloadedAt while their connections are still resolved by local mock resolvers. The local mock data seeding the field stays, because both connections are still resolved locally today.
Also, dead test scaffolding is removed. A constant in ee/spec/frontend/packages_and_registries/artifact_registry/graphql/mock_resolvers_spec.js subtracted these three fields from an assertion. With the declarations gone the subtraction matches nothing, so leaving it would be silent dead code describing a case that no longer exists.
Two files the plan lists that are deliberately absent from this diff
ee/spec/graphql/types/artifact_registry/repository_details_type_spec.rb and ee/spec/requests/api/graphql/organizations/artifact_registry_repository_spec.rb are unchanged, because all of their criteria already have coverage:
- The 20-row page cap is asserted as
'caps a page at 20 rows rather than the schema default', once insidedescribe 'the packages connection'and again insidedescribe 'the images connection', each with its ownlet(:field). - The remote detail read is covered by the
resolved_remote_settingslet, which carries the upstream URL, the artifact cache window, the health status and the health-checked timestamp, plus a per-formatwheretable that asserts the whole settings hash witheqacross all four formats, so it catches missing and extra keys alike. - Settings resolving null is asserted for a hosted repository and for a virtual one.
- The flag-off case asserts the repository read is not requested.
The credential criteria
The plan asks for an explicit key-absence assertion on the response body. The spec instead has value-absence, and that is better rather than worse.
- Durable guarantee, a property of graphql-ruby: the schema cannot emit a field the type does not declare, so no credential key can appear in a response. Enforced by the
selecting a credential field under settingsloop, which fails at schema-validation time the moment someone declares a credential field onRemoteSettingsType. - Current-code observation, a property of
remote_settings_type.rbtoday: no declared field is derived from a credential value.has_credentialsis a bare pass-through of the service's own key, with no resolver method. Guarded by the value-absence assertion in'reports hasCredentials and puts no credential value in the response body', which fails if any declared field ever echoes a credential value into the body.
Value-absence on the raw body is path-agnostic where key-absence is not: a leak under a renamed key, for example "upstreamSecret": "ar-upstream-secret", evades a key-absence assertion entirely and is caught by the value-absence one.
The two assertions in that example are coupled: the hasCredentials assertion is the precondition that makes the value-absence check non-vacuous, because a value-absence check would pass trivially against an error response or a null settings field. Anyone simplifying that example by dropping the first line would silently make the credential check a no-op.
Regenerated artifacts
Two artifacts regenerate, not the four the plan states: doc/api/graphql/reference/_index.md and public/-/graphql/introspection_result.json.
app/assets/javascripts/graphql_shared/possible_types.jsonis unchanged because the extraction script emits only types that havepossibleTypes, meaning unions and interfaces, and no union or interface membership moves here.public/-/graphql/introspection_result_no_deprecated.jsonis unchanged because all four Artifact Registry types are already entirely absent from that dump. Their ancestor fields are marked experiment, which orphans the whole subtree. This is not because the new fields are individually stripped, and the distinction matters: adding a non-experiment field to these types would still not change that file until the ancestor fields lose their annotation.
Note for design
Only npm records downloads in the service today, so a Maven package or a container image reads lastDownloadedAt as null even after a genuine pull (service contract, api/openapi/v1.yaml, LastDownloadedAt schema). The remote artifact table's Last downloaded column is therefore blank for three of the four formats. Neither the spec nor the plan records this. No code change follows from it; it is raised so the column is a decision rather than an oversight.
Testing
990 examples, 0 failures across the Artifact Registry rspec surface. 1458 jest tests passing across the artifact registry frontend directory. Rubocop clean on all 14 changed Ruby files. All five GraphQL schema check tasks exit 0, including check_docs and check_introspection_sync, which confirm the two regenerated artifacts are genuinely in sync rather than merely regenerated.
Feature flag and changelog
Ships dark behind artifact_registry_ui, which is disabled. No changelog entry, because the change sits entirely behind a disabled flag.
Multiversion compatibility: Danger's warning does not apply here. The rule is a path match with no content analysis (tooling/danger/multiversion.rb:32), so touching typedefs.graphql and ee/app/graphql/ in one diff is enough to fire it. Nothing sends lastDownloadedAt to the server: the only two documents that select it do so inside a @client subtree answered by local resolvers, and ee/spec/frontend/packages_and_registries/artifact_registry/graphql/queries_spec.js:102 pins that by asserting the outgoing request carries only name and format. The reverse direction is safe too, because Apollo's typeDefs option is stored and never read (@apollo/client 3.5.10, core/ApolloClient.js:40), so an older bundle declaring the field locally cannot collide with a newer schema. @gl_introduced becomes relevant only when a later MR drops the @client binding, and then only if that MR lands in 19.4.
Technical Writer review: Not requested. Danger raises it because the diff touches doc/api/graphql/reference/_index.md. Generated files are not exempt from that rule, whose allowlist is only doc/development/, doc/solutions/ and doc-locale/, so the message fires by design and is non-blocking. The file is machine-generated with a do-not-edit banner and is verified by graphql-verify, and the prose a writer would review is the field descriptions in the Ruby type files, which are covered by ordinary code review.
References
Tracked by #626611 (closed), which covers all three steps and has a merge requests table with one row per step. Parent MR: !252683 (merged)