feat(managementapi): serve the npm remote file reads (S17 Phase 6 plan: 18/42)

Stacked on feat(managementapi): serve the npm remote packa... (!1750 - merged) • Hayley Swimelar • 19.4, which has merged.

Why

The npm file routes answer 404 on a kind=remote repository. feat(managementapi): serve the npm remote packa... (!1750 - merged) • Hayley Swimelar • 19.4 filled the npm package and version arms and left the file arms on the pending 404 the artifact-read kind-dispatch scaffold installed in chore(managementapi): artifact-read kind-dispat... (!1582 - merged) • Hayley Swimelar • 19.4. A remote repository's cached tarballs stay unreachable through the management API even though the rows exist.

This fills them from npm_remote_files, implementing Step 18 of docs/plans/2026-08-13-s17-phase6-virtual-remote-repositories.md against the npm file slices of acceptance criteria #95, #96, #97, and #100 in docs/specs/S17-rest-management-api.md.

What

The cache envelope lands here, format-neutral. ArtifactCache carries upstream_checked_at and a nullable upstream_etag for any freshness-tracked remote row, so the Maven file and container tag steps can reuse the component rather than declaring their own. Its description states that rule without naming a declarer, since MavenFile and ContainerTag already declare the same key in their own branches.

NpmFile.created_at widens to nullable, which docs/dev/api-style.md counts a breaking change (changing the type of a response field). Acceptance criterion #97 requires it: a remote response serializes null for npm file created_at, and npm_remote_files stores no created_at to serialize. No client can observe the widening either. A null is reachable only on kind=remote, and the npm files list and file detail answered 404 there until this MR, which the deleted TestNpmRemoteFileRoutes_StayOnTheRepositoryLevel404 pinned. The management API cannot create a remote repository to reach them: create.go rejects a non-hosted kind with 422 and then hardcodes hosted, and the child-row inserts are format-keyed, so the create path cannot write the npm_remote_repositories binding. driver.sh seed-npm-remote seeds one by direct SQL, which is how the Test plan's rig verification reached these routes.

Each file route peels its remote arm off ahead of the shared kind gate, the shape feat(managementapi): serve the npm remote packa... (!1750 - merged) • Hayley Swimelar • 19.4 established for the version routes. hostedVersionFileRepository's remote arm still gates six callers this step does not serve: Maven's files list and file detail, plus the four version and file deletes. listMavenVersions and showMavenVersion read the kind themselves and reach their remote arm before that dispatch runs, which landed in feat(managementapi): serve the maven remote pac... (!1749 - merged) • Hayley Swimelar • 19.4. Filling that arm inside the switch would change all six at once. Maven's file routes keep their pending 404, and the deletes keep theirs permanently, because eviction owns a remote repository's deletes.

The detail read is one three-way join, not the two-statement chain the version detail uses. Every join carries the namespace equality, so all three npm remote tables prune to a single hash partition. blob_storage_blobs cannot plan-time prune, because sha256 is its own partition key.

Departures from the plan

The step's file list names three files. This touches eleven non-test. The eight beyond npm_remote_files.go, files.go, and v1.yaml are the seam and its wiring (handler.go, versions.go, wire_management.go), the resource mapper (version_file_resources.go), query_names.go, artifact.go, which is where the ArtifactCache envelope is declared, npm_remote_bulk_evict_scope.go, whose limit-error variable this renames, and the run recipe.

The step's Acceptance line cites #95-#97 and does not name #100. The branch satisfies #100 too: an evicted-but-unreaped row is absent from both reads. The detail read filters all three chain levels in one statement. The list filters the file's own marker, and the handler's findNpmRemoteVersion resolve is what rejects an evicted parent.

NpmPackage.versions_count and tags_count are untouched here. The plan assigned all of v1.yaml to this step, but feat(managementapi): serve the npm remote packa... (!1750 - merged) • Hayley Swimelar • 19.4 widened those two already, as its own documented departure.

Spec coverage

Acceptance criterion Tests
#95 cached npm files serve from the cache tables with Phase 3's sorts, pagination, and error rules Route: TestNpmRemoteFileList_ServesTheCachedFiles, _BothOrders, _KeysetWalkFollowsTheLinkHeader, _BackwardWalkRetraces, _ParentResolveSeparatesAbsentFromEmpty, _ClampsAnOversizedLimit, TestNpmRemoteFileDetail_ServesTheCachedRow, _IdOutsideTheChainIs404. Store: TestNpmRemoteFileStore_ListNpmRemoteFilesByVersion_{KeysetWalkBothDirections,HasMoreSignal,ChainScoping,UnmatchedCursor,RowFidelity,LimitMaxIntSaturates}, TestNpmRemoteFileStore_FindNpmRemoteFileByID, TestListNpmRemoteFilesByVersionStmt, TestFindNpmRemoteFileByIDStmt, TestNpmRemoteFileBlobJoin, TestNextNpmRemoteFileCursor, TestNpmRemoteFileCursorAcrossStores. Composition: TestVersionFileHandlersIntegration_NpmRemoteWalksAndDetails, _NpmRemoteChainScoping
#96 remote file and container tag responses carry cache with upstream_checked_at and a nullable upstream_etag, and images, packages, versions, manifests, and every hosted response omit the key TestNpmRemoteFileResource_NullsCreatedAtAndCarriesTheCache, _NullEtagKeepsTheEnvelope, TestNpmFile_CacheKeyPresenceFollowsThePointer (the omitempty a field assertion cannot see), and the negative TestHostedFileRoutes_OmitTheCacheKey across hosted npm and Maven, list and detail
#97 npm file created_at serializes null on a remote repository TestNpmRemoteFileResource_NullsCreatedAtAndCarriesTheCache, the converse guard TestNpmFileResource_KeepsTheHostedCreatedAtPresent (npm_files.created_at is NOT NULL, so the hosted arm must keep emitting a value), and TestNpmRemoteFileHandlers_ResponsesMatchOpenAPIContract
#100 an evicted-but-unreaped row is absent from every list and detail read TestNpmRemoteFileStore_SoftDeletedFileIsInvisibleToReads (interior and probe positions, an all-marked version, and a re-cached pair sharing a file_name), TestNpmRemoteFileStore_MarkedParentHidesItsLiveFiles (marked version and marked package), TestNpmRemoteFileList_EvictedFilesAreAbsent, and the marker filter at all three chain levels in TestFindNpmRemoteFileByIDStmt
Error case Tests
Absent, evicted, or out-of-chain ids return 404, and an existing version with no files returns an empty 200 TestNpmRemoteFileDetail_IdOutsideTheChainIs404 (a real file id under a sibling remote repository, the case only the binding predicate rejects), TestNpmRemoteFileList_ParentResolveSeparatesAbsentFromEmpty, store-side TestNpmRemoteFileStore_FindNpmRemoteFileByID
Invalid sort, order, limit, or cursor returns 400, while an over-maximum limit clamps TestNpmRemoteFileList_RejectsInvalidQueryBeforeAnyRead (including the sort=created_at the versions list accepts and files does not), TestNpmRemoteFileStore_ListNpmRemoteFilesByVersion_ArgumentGuards, _FindNpmRemoteFileByID_ArgumentGuards, and TestNpmRemoteFileList_ClampsAnOversizedLimit for the one value that is not a 400
An unexpected store failure returns 500 and logs the scoping ids TestNpmRemoteFileRoutes_StoreFailureIsLogged500, _MissingBindingIsLogged500, _StoreFailureLogsTheScopingIDs, store-side TestNpmRemoteFileStore_ListNpmRemoteFilesByVersion_QueryFailure and _FindNpmRemoteFileByID_QueryFailure. A cancelled request writes no envelope (_CancellationWritesNothing)
Maven file routes and both file deletes hold their 404 on a remote repository TestVersionFileRoutes_NonHostedRepositoryIs404 with a fully live chain seeded beneath it
The keyset stays index-backed and the chain prunes TestNpmRemoteFileStore_ListNpmRemoteFilesByVersion_DeepPageIsIndexBacked, TestFindNpmRemoteFileByIDStmt_SinglePartitionPrune

Diff size

3846 reviewable LOC, past the 500 docs/dev/development-model.md asks be split or justified. By group: test Go 3212, source Go 495, run recipe 101, contract 38. Non-test is 634 of that, split datastore 261, managementapi 227, docs and tooling 101, contract 38, cmd wiring 7.

Splitting does not help. The store half and the handler half are one route pair: a store-only MR ships two unreachable symbols, and a handler-only MR serves a body that fails its own schema, since the cache component and the nullable created_at are what make the cached response valid. Four fifths of the diff is test mass that mirrors the merged npm remote package and version suites subtest for subtest and adds the EXPLAIN pins.

e2e scenarios

No scenario is added or affected. npm_remote_files holds cached tarballs, and docs/testing/e2e/npm.md records the tarball proxy route as unmounted and owned by S15, so no journey in that catalog populates a row these routes read. Its ## Remote repositories preamble already states that the test environment seeds a remote repository out of band, because the management API cannot create one.

Test plan

go test -count=1 ./internal/managementapi/... ./internal/datastore/... ./cmd/...
ARTIFACT_REGISTRY_DATABASE_TEST_DSN=<dsn> go test -tags=integration -count=1 \
  ./internal/managementapi/... ./internal/datastore/...
golangci-lint run --build-tags=integration --max-same-issues=0 \
  --max-issues-per-linter=0 ./internal/datastore/... ./internal/managementapi/...
.claude/skills/run-artifact-registry/driver.sh smoke

EXPLAIN on the two new statements: the list rides unique_npm_remote_files_ns_id_ver_id_file_name in both directions at a 2500-deep cursor, with no post-scan Sort and no Rows Removed by Filter. The detail prunes each of the three npm remote tables to one partition.

Verified on the local rig against a cold-booted binary, with a remote npm repository, package, version, and three cached files seeded through seed-npm-remote and the recipe's SQL:

  • The list serves both live files ordered by file_name with created_at: null and a cache object, one carrying a null upstream_etag and one a token.
  • Detail serves both. The soft-deleted file is absent from the list and 404 on detail.
  • An absent version is 404 where a live empty one is 200 [].
  • order=desc&limit=1 serves the right single row, and sort=created_at is 400.
Context for LLM agents

Rationale

  • Declare the cache object on NpmFile alone. Rejected because the Maven file and container tag steps carry the same two fields from the same spec paragraph, so three private copies would diverge on the first edit.
  • Resolve the file detail through the version finder, mirroring FindNpmRemoteVersionByID's two-statement chain. Rejected because it costs three round trips where the three-way join costs one. Both shapes prune: each statement of the chain carries its own namespace literal, which TestNpmRemotePackageInRepositoryProbeStmt_SinglePartitionPrune pins for the intermediate read.
  • Fill the remote arm inside hostedVersionFileRepository. Rejected because its remote arm still gates six callers this step does not serve.
  • Keep created_at non-nullable and emit the row's upstream_checked_at in its place. Rejected because the two are different facts: one is when the upstream published, which the cache does not store, and the other is when this service last checked.

Non-goals

  • Maven remote file reads and MavenFile.cache. Step 16 owns them, which is why Maven's file routes keep the pending 404 here.
  • The container tag cache object. Step 20 owns it, and it reuses ArtifactCache.
  • Eviction and the remote delete arms. Steps 26 and 29 own them, which is why both file deletes keep the gate's 404.
  • Filling the cache. internal/format/npm/npmremote writes npm_remote_files on the outbound fetch path. This MR only reads it.
  • The plan's ## Status row. Every branch of this fan-out would edit the same table, so one batch commit fills the rows after the MRs open.

Related to #314

Edited by Hayley Swimelar

Merge request reports

Loading
Loading