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 smokeEXPLAIN 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_namewithcreated_at: nulland acacheobject, one carrying a nullupstream_etagand one a token. - Detail serves both. The soft-deleted file is absent from the list and
404on detail. - An absent version is
404where a live empty one is200 []. order=desc&limit=1serves the right single row, andsort=created_atis400.
Context for LLM agents
Rationale
- Declare the
cacheobject onNpmFilealone. 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, whichTestNpmRemotePackageInRepositoryProbeStmt_SinglePartitionPrunepins 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_atnon-nullable and emit the row'supstream_checked_atin 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
cacheobject. Step 20 owns it, and it reusesArtifactCache. - 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/npmremotewritesnpm_remote_fileson the outbound fetch path. This MR only reads it. - The plan's
## Statusrow. 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