feat(api): version statistics contract (S17 Phase 8 plan: 2/13)

Why

Phase 8's statistics surface has four scopes. feat(api): aggregate statistics contract (S17 P... (!1964 - merged) • Hayley Swimelar • 19.4 declared the three aggregate ones, and this declares the fourth. Nothing is built against a scope the document does not carry: each handler step drains its arm out of the contract sweep's pending set, and the sweep reads its arms out of api/openapi/v1.yaml.

Plan: docs/plans/2026-08-12-s17-phase8-statistics.md, ### Step 2: Contract: artifact statistics. Spec: docs/specs/S17-rest-management-api.md, Statistics (Phase 8), AC #111 (closed) and AC #118 (closed).

What (the non-obvious parts)

One schema, not a branch per format. RepositoryStatistics needs a oneOf over three closed branches because a container repository's figures differ from an npm repository's. A version's do not, so VersionStatistics is one open schema serving both {format} arms. It stays open for the reason those branches close: closure is what makes a oneOf decidable, this schema sits in none, so closing it would only re-price every added figure as breaking (docs/dev/api-style.md#closed-response-branches).

files_count and size_bytes disagree on the same version by design. The count drops a tombstoned file from the mark onward. The footprint keeps its bytes until the reap and garbage collection remove them. Both sit in one response, so a caller reading a version mid-delete sees a count that has moved beside a byte figure that has not.

No downloads_count, and the schema says why. Every other statistics scope has one. No column counts downloads per artifact, so the field could only carry a zero no caller can tell from a version nobody downloaded. Omitted rather than stubbed, and a test pins the absence.

Hosted-only, while the artifact routes on the same prefix are not. Phase 6 widened those to remote repositories and left statistics alone. A remote repository's version is readable at /{format}/versions/{version_id} and answers 404 at .../statistics.

AC #118 (closed) is enforced as a closed set. ADR-009 declares image, container tag, package, and npm dist-tag statistics paths this phase does not serve. Four literal absence checks would pass vacuously the moment a spelling drifted, so TestContract_StatisticsPaths_AreExactlyTheServedScopes collects every path that ends in /statistics or carries the statistics tag, and matches that set against the seven served scopes.

docs/dev/bruno.md is outside the plan's Step 2 file list. !1964 (merged) wrote three claims into it that the seventh Bruno request here falsifies: "the namespace, format, and repository statistics reads", "the six statistics reads", and a variables row calling versionId the id "one delete addresses". All three are corrected, and nothing else in that file is touched.

A spec correction this MR does not make

docs/specs/S17-rest-management-api.md:1260 explains the four withdrawn scopes' unreachability as "Each carries a segment past the artifact patterns' last wildcard, so it matches no route". That causality is wrong, and this MR's own path is the counterexample: versionPattern + "/statistics" is exactly that shape, and imageTagsPattern, imageManifestsPattern, and packageVersionsPattern are all a literal segment past their last wildcard and all registered (internal/managementapi/handler.go:570, :572, :601). The four are unreachable because nothing registers them, and nothing will, for the product reason the same spec gives two paragraphs earlier: no design surface calls one, and their figures are the per-row figures. The assertion message here states that reason rather than repeating the routing claim. The spec sentence needs its own correction MR.

Test plan

  • go test ./internal/managementapi/ -count=1 green, and green under -race.
  • npx @redocly/cli@2.34.0 lint --config api/openapi/redocly.yaml validates both documents, at the version lint:openapi pins.
  • golangci-lint reports 0 issues plain and under --build-tags=integration.
  • The sweep skips the two new format arms while they answer the mux's 404, and fails if one starts serving without its entry dropped, or if an entry names an arm the document does not declare.
  • Every assertion was checked against a mutated document rather than trusted. Closing the schema with additionalProperties: false fails the family-shape test, and so does an additionalProperties schema, which a Has-only predicate cannot see. Respelling size_bytes as [integer, 'null'] without a minimum fails the floor walk, which the Type.Is form silently skipped. A statistics path spelled outside the /statistics suffix fails the closed-set test. Inlining last_downloaded_at fails the $ref pin, and a lossy inline drops the RFC 3339 check with it. Swapping packageFormat for repositoryFormat fails the enum pin and the sweep's docker and oci arms.

No e2e catalog scenario is added or affected: docs/testing/ mentions statistics nowhere, and no handler is registered here.

Related to #316

Merge request reports

Loading
Loading