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
MavenFileStoreon this branch, so the code is unreachable until then. - Sibling Maven read stores.
MavenPackageStoreandMavenVersionStoreare Step 6, on another branch. - A
sizeorcreated_atsort on files. The API definesfile_nameonly, andmaven_fileshas nocreated_atcolumn.
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.