chore(datastore): maven file management reads (S17 Phase 3 Step 7)

Why

S17 Phase 3 is the read-only artifact surface behind the frontend's repository- and artifact-detail views. Step 7 of the merged plan is the Maven half of the file reads: the file list under a version, and file detail by id. Nothing serves them yet, because Step 12 wires the handlers.

Maven had no store under internal/datastore/ at all. Its SQL lives in internal/format/maven/store.go in protocol shapes the management reads cannot use, so this adds a read-only MavenFileStore on the generated Jet models.

Plan: docs/plans/2026-07-22-s17-phase3-format-artifact-reads.md, Step 7. Work item S17 Phase 3: format artifact reads (#312 - closed) • Hayley Swimelar.

What

Two things the diff does not show.

A nullable column is the exclusion boundary. maven_files.maven_version_id is NULL on the package-level maven-metadata.xml row and its checksum siblings, which the API does not expose. Both reads filter maven_version_id IS NOT NULL, which also matches unique_maven_files_ns_id_version_id_file_name's predicate exactly, so the keyset stays one index-ordered scan. Dropping that filter leaves every integration assertion green, because the version equality and the version join already exclude NULL rows, so the emitted-SQL unit assertions are its only gate.

The list does not re-walk the parent chain, deliberately. It scopes on (namespace_id, maven_version_id), while detail verifies file to version to package to repository in one query. The handler resolves version detail first regardless, to tell a missing parent's 404 from an existing parent's empty 200. The cost is that an unverified version id reaches any version in the namespace, including another repository's, so the method carries an explicit caller contract. Step 12 should cover a sibling-repository version id on the files list.

Reviewable LOC is 1706, past the 500 guideline: 465 lines are the store, the rest its test suite, which ships with it. Read maven_files.go first.

This overlaps Relocate Maven datastore SQL from internal/form... (#372 - closed) • David Fernandez • 19.3, which relocates the Maven protocol SQL into per-entity stores. This step does not attempt that, so Maven SQL lives in two homes until #372 (closed) lands.

Test plan

Both suites run in CI: internal/datastore is already wired into test:integration.

Behavior Test
size equals the stored blob size, on list and detail ListMavenFilesByVersion/orders by file_name..., FindMavenFileByID/returns the row with its checksums...
Maven checksums serialize from their own columns, md5 nullable FindMavenFileByID/returns the row..., /serves a nil md5 as nil...
Package-level files never list and are not addressable ListMavenFilesByVersion/excludes package-level files..., FindMavenFileByID/returns ErrNotFound for a package-level file, TestListMavenFilesByVersionStmt_SQL, TestFindMavenFileByIDStmt_SQL
Soft-deleted files, and everything under a soft-deleted version or package, are invisible ListMavenFilesByVersion/excludes..., FindMavenFileByID/returns ErrNotFound across the soft-delete and chain break cases
A file id from another repository or namespace returns not-found FindMavenFileByID/...file in another repository..., /live file read through another namespace, TestMavenFileStore_ListMavenFilesByVersion_ScopesToVersionAndNamespace
An existing version with no files returns an empty page ListMavenFilesByVersion/returns an empty page for a version with no files
file_name order in both directions, keyset walks with no gaps or duplicates, hasMore /orders by file_name in both directions, /keyset walks the whole set..., /hasMore reports a further page..., TestMavenFileStore_ListMavenFilesByVersion_FullFinalPage
A database failure is not misreported as not-found FindMavenFileByID/a transient DB failure is not misreported as ErrNotFound, TestMavenFileStore_ListMavenFilesByVersion_QueryFailure
Invalid sort, order, limit, or cursor is rejected before any query TestMavenFileStore_ListMavenFilesByVersion_Guards, TestMavenFileStore_FindMavenFileByID_Guards
Both reads stay index-backed and partition-pruned TestMavenFileStore_ListMavenFilesByVersion_IsIndexBacked, TestMavenFileStore_FindMavenFileByID_PrunesEachChainHop

Security considerations

# Concern Tests
S-1 Cross-repository and cross-namespace reads Detail verifies the whole chain: FindMavenFileByID/returns ErrNotFound across the soft-delete and chain break cases. The list scopes to (namespace_id, maven_version_id) by design and defers chain verification to the version and file handlers in feat(managementapi): version and file reads (S1... (!1134 - merged) • Hayley Swimelar • 19.3. The store-side half of that split is pinned by TestMavenFileStore_ListMavenFilesByVersion_ScopesToVersionAndNamespace and TestMavenFileStore_ListMavenFilesByVersion_ScopingStopsAtVersion, so it cannot drift silently.

Query plans

PostgreSQL 16, 2000 files under one version each addressing its own blob, one 20-row page at a mid-set cursor. The list rides the partial unique index with the keyset bound folded into the index condition and no sort node:

Limit
  ->  Nested Loop
        ->  Index Scan using maven_files_p24_namespace_id_maven_version_id_file_name_idx on maven_files_p24
              Index Cond: ((namespace_id = '...') AND (maven_version_id = '...') AND (file_name > 'widget-1.0.0-01000.jar'))
        ->  Append  (64 blob partitions, 63 never executed)
              ->  Index Scan using blob_storage_blobs_pNN_namespace_id_sha256_idx
                    Index Cond: ((namespace_id = '...') AND (sha256 = maven_files.blob_sha256))

A descending page is the same index read backward. Detail prunes maven_files, maven_versions, and maven_packages to one partition each.

The blob join costs 1.8-2.0 ms planning against 0.7-1.0 ms execution, because blob_storage_blobs is hash-partitioned on sha256 and the join binds no sha256 literal, so the planner builds an Append over all 64 partitions and prunes per outer row at execution. LabKit's pool runs pgx in simple-protocol mode, so nothing amortizes that across requests. The spec prescribes this single-row join and reserves batched per-page fetches for a later patch, so it stays. The number is recorded here for the spec author.

Conformance and e2e catalogs: not applicable. This is management-API surface, not protocol behavior, and the plan's Testing Strategy already records e2e impact as none.

Context for LLM agents

Design rationale

Why MavenFile embeds model.MavenFiles plus a tagged Size rather than embedding model.BlobStorageBlobs. Embedding both would make ID and NamespaceID ambiguous selectors on MavenFile. go-jet's qrm keys each destination field on <struct type name>.<field name>, so Size needs alias:"blob_storage_blobs.size" to reach the joined column. An untagged field scans as zero and never errors, which is how it first shipped. The integration test's size assertion caught it.

Why the list takes no MavenRepositoryID. The plan declares the list as a file_name keyset plus the blob join, with parent resolution in the handler, and adds two joins per page if the store re-walks the chain. Both review passes confirmed this is not drift. The residual gap is that nothing at the datastore layer enforces the chain for the list, so the method documents the contract and this description hands the cross-repository test case to Step 12.

Why Limit has no store-level ceiling. RepositoryStore.List, the merged sibling this mirrors, has none either, and the clamp is parseLimitParam at maxPageSize = 100. Adding a ceiling here alone would leave two contracts in one package. If a store-level cap is wanted, it belongs on both.

Why MavenFileSortColumn carries one value. The management API sorts files by file_name only, so the enum selects no branch. It exists so the handler's parseEnumParam has a typed target, matching RepositorySortColumn, and so an unmapped handler enum fails loud instead of paging by an unrequested column.

Why integration tests seed through SQL, not the Maven write path. internal/format/maven imports internal/datastore, so an in-package test importing it is an import cycle (verified: import cycle not allowed in test). The helpers mirror npm_read_integration_test.go, which seeds the sibling npm chain the same way.

Non-goals

  • Relocating the Maven protocol SQL. Tracked in #372 (closed). Attempting it here would mix a behavior-preserving refactor into a new-code MR.
  • Handlers, DTOs, and routes. Step 12 owns them. Nothing constructs MavenFileStore on this branch, so the code is unreachable until then.
  • Sibling Maven read stores. MavenPackageStore and MavenVersionStore are Step 6, on another branch.
  • A size or created_at sort on files. The API defines file_name only, and maven_files has no created_at column.

Label note

The plan types this step chore, and the type label was set to match the work-stream instruction for this batch. type::maintenance is the defensible alternative under docs/dev/labels.md, whose own example is a maintenance prep MR under a feature issue. Flip it if you prefer that reading.

Edited by Hayley Swimelar

Merge request reports

Loading
Loading