chore(datastore): npm packages and versions management reads
Why
Step 11 and Step 12 of the Phase 3 plan serve the npm package, version, and file endpoints, and no npm store can answer them today:
- npm packages have no list method at all.
NpmVersionsByPackagecarries the packument generator's fixed(created_at, id)order, its own after-cursor pair, andpackage_json. The management list needs a selectable sort,hasMore, and none of the jsonb.NpmPackageByIDis keyed by(namespace_id, id)alone, so it would return a package the repository in the URL does not contain. Detail reads on the management surface have to verify the parent chain.
This step adds the four reads those two handler steps consume. Nothing is wired into a route: Steps 11 and 12 own that.
What is worth a second look
The version list is a single-table scan on purpose. It scopes on (namespace_id, npm_package_id) and does not join npm_packages, so it does not check the parent's repository or soft-delete state. The spec's resolution flow already requires the handler to read the package detail first, to tell 404 from an empty 200, and FindNpmPackageInRepository is that read. Joining again per row would buy a check the request already performed. ListNpmVersionsByPackage's doc states the obligation so a handler author cannot skip it.
The version reads do not project package_json. No management response renders it and it is up to 20000 bytes per row, so a 100-row page would otherwise pull 2 MB of jsonb the handler discards. npmVersionPageForFileCascade is the existing precedent for a partial projection into model.NpmVersions.
The version detail read is two primary-key seeks, not one statement. Relating npm_versions to npm_packages in one query leaves namespace_id as the only constant Postgres can push into the package side, because the package id lives in the version row the query has not read yet. On a 5000-package fixture that planned a merge join estimating 5002 package rows for a one-row answer, and the cost grows with the namespace. Writing the parent check as a correlated EXISTS did not help, because the planner unnests it into the same join. The two seeks cost 3 buffer hits each and do not grow. The Database Review Evidence below has both plans.
Both version sorts and the package sort ride existing indexes, so this step adds no migration and does not depend on Step 2. index_npm_versions_on_ns_id_pkg_id_created_at_id was already in place with the matching soft-delete predicate.
MR size. 2951 lines, of which 773 are production code and the rest is the test matrix (four keyset walks per sort and direction, soft-delete exclusion, chain scoping, and per-sort EXPLAIN). Splitting packages from versions would put the shared publish-committer fixture in one MR and half its callers in the other.
Spec coverage
| Spec / plan requirement | Covered by | Test |
|---|---|---|
Artifact lists: packages sort name asc default, keyset, hasMore |
ListNpmPackages |
TestNpmPackageStore_ListNpmPackages, ..._KeysetNoGapsOrDuplicates |
Artifact lists: versions default created_at desc, sort=version lexicographic |
ListNpmVersionsByPackage |
TestNpmVersionStore_ListNpmVersionsByPackage, ..._KeysetNoGapsOrDuplicates, ..._CreatedAtTieBreak |
AC 19: npm package rows carry name, scope, versions_count, tags_count |
npmPackageColumns |
TestNpmPackageStore_ListNpmPackages (projection subtest) |
AC 20: version detail carries the publish attribution, null when absent |
FindNpmVersionByID |
TestNpmVersionStore_FindNpmVersionByID, ..._ListNpmVersionsByPackage (projection subtest) |
| Artifact read routes: a break anywhere in the chain returns not-found | FindNpmPackageInRepository, FindNpmVersionByID |
TestNpmPackageStore_FindNpmPackageInRepository, TestNpmVersionStore_FindNpmVersionByID |
Security Considerations: tenant isolation by namespace_id |
every predicate | ..._ChainScoping, ..._Scoping, both pruning tests |
| Soft-delete exclusion at every level of the chain | all four reads | ..._ExcludesSoftDeleted, soft-deleted-parent subtest |
Plan: EXPLAIN shows index backing per sort |
statement builders | ..._DeepPageIsIndexBacked (5 sort and direction pairs) |
Test plan
go test ./internal/datastore/...
ARTIFACT_REGISTRY_DATABASE_TEST_DSN="postgres://...:5432/artifact_registry_test?sslmode=disable" \
go test -tags=integration -count=1 -run 'TestNpmPackageStore_|TestNpmVersionStore_' ./internal/datastore/The test:integration job runs the integration suite on the PostgreSQL 16, 17, and 18 matrix. The new tests add roughly 8 seconds.
No e2e scenario changes: the catalogs cover protocol-client journeys, and artifact browsing lands with the monolith slices that consume these endpoints.
Related to #312 (closed)
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against PostgreSQL 16.14 with
synthesized seed data, torn down after the run. The project's canonical
version is 17 (GL_PG_CURR_VERSION), and test:integration asserts these
same plan shapes on 16, 17, and 18. Numbers reflect moderate cardinality and
do not capture production-scale effects. See
Database review evidence
for seed sizing and methodology. Expand each row for the seed shape, rendered
SQL, bound args, and raw plan.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
datastore.ListNpmPackages.name-asc |
Index Scan | npm_packages_p24_namespace_id_npm_repository_id_name_idx |
21 / 21 | 3.64 | 0.027ms | 3 / 0 | 1 |
datastore.ListNpmPackages.name-desc |
Index Scan Backward | npm_packages_p24_namespace_id_npm_repository_id_name_idx |
21 / 21 | 3.64 | 0.020ms | 3 / 0 | 1 |
datastore.FindNpmPackageInRepository |
Index Scan | npm_packages_p24_pkey |
1 / 1 | 8.31 | 0.029ms | 3 / 0 | 1 |
datastore.ListNpmVersionsByPackage.created_at-desc |
Index Scan Backward | npm_versions_p24_namespace_id_npm_package_id_created_at_id_idx |
21 / 21 | 3.71 | 0.039ms | 3 / 0 | 1 |
datastore.ListNpmVersionsByPackage.version-asc |
Index Scan | npm_versions_p24_namespace_id_npm_package_id_version_idx |
21 / 21 | 3.61 | 0.027ms | 7 / 0 | 1 |
datastore.FindNpmVersionByID.version-leg |
Index Scan | npm_versions_p24_pkey |
1 / 1 | 8.30 | 0.021ms | 3 / 0 | 1 |
datastore.FindNpmVersionByID.parent-leg |
Index Scan | npm_packages_p24_pkey |
1 / 1 | 8.31 | 0.016ms | 3 / 0 | 1 |
datastore.ListNpmPackages.name-asc
Summary: Forward scan of the partial unique name index with every predicate in the Index Cond, so the soft-delete filter and the repository scoping are index-resident rather than a post-scan filter. Plan rows equal actual rows at the fetch limit, and 3 buffer hits means the page came from the index and its heap pages alone. The 5000 sibling-repository packages in the same partition cost nothing.
Seed shape: npm_packages=10002, npm_versions=10000, npm_repositories=2, repositories=2
Rendered SQL:
SELECT npm_packages.namespace_id AS "npm_packages.namespace_id",
npm_packages.id AS "npm_packages.id",
npm_packages.npm_repository_id AS "npm_packages.npm_repository_id",
npm_packages.last_downloaded_at AS "npm_packages.last_downloaded_at",
npm_packages.soft_deleted_at AS "npm_packages.soft_deleted_at",
npm_packages.versions_count AS "npm_packages.versions_count",
npm_packages.tags_count AS "npm_packages.tags_count",
npm_packages.name AS "npm_packages.name",
npm_packages.scope AS "npm_packages.scope"
FROM public.npm_packages
WHERE (((npm_packages.namespace_id = $1::uuid) AND (npm_packages.npm_repository_id = $2::uuid)) AND (npm_packages.soft_deleted_at IS NULL)) AND (npm_packages.name > $3::text)
ORDER BY npm_packages.name ASC
LIMIT $4;Bound args: [c9436e39-6349-40ad-985d-708af9e30c72 019faadb-11e1-7482-931e-c89a0e931654 dbreview-2500 21]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..3.64 rows=21 width=118) (actual time=0.010..0.013 rows=21 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_packages_p24_namespace_id_npm_repository_id_name_idx on npm_packages_p24 npm_packages (cost=0.29..399.85 rows=2500 width=118) (actual time=0.009..0.011 rows=21 loops=1)
Index Cond: ((namespace_id = 'c9436e39-6349-40ad-985d-708af9e30c72'::uuid) AND (npm_repository_id = '019faadb-11e1-7482-931e-c89a0e931654'::uuid) AND (name > 'dbreview-2500'::text))
Buffers: shared hit=3
Planning:
Buffers: shared hit=287
Planning Time: 0.844 ms
Execution Time: 0.027 msTimings: planning 0.844ms, execution 0.027ms, total 0.871ms.
datastore.ListNpmPackages.name-desc
Summary: The same index read backward, which is why one ascending btree serves both directions with no post-scan Sort. Identical cost and buffers to the ascending case.
Seed shape: npm_packages=10002, npm_versions=10000, npm_repositories=2, repositories=2
Rendered SQL:
SELECT npm_packages.namespace_id AS "npm_packages.namespace_id",
npm_packages.id AS "npm_packages.id",
npm_packages.npm_repository_id AS "npm_packages.npm_repository_id",
npm_packages.last_downloaded_at AS "npm_packages.last_downloaded_at",
npm_packages.soft_deleted_at AS "npm_packages.soft_deleted_at",
npm_packages.versions_count AS "npm_packages.versions_count",
npm_packages.tags_count AS "npm_packages.tags_count",
npm_packages.name AS "npm_packages.name",
npm_packages.scope AS "npm_packages.scope"
FROM public.npm_packages
WHERE (((npm_packages.namespace_id = $1::uuid) AND (npm_packages.npm_repository_id = $2::uuid)) AND (npm_packages.soft_deleted_at IS NULL)) AND (npm_packages.name < $3::text)
ORDER BY npm_packages.name DESC
LIMIT $4;Bound args: [c9436e39-6349-40ad-985d-708af9e30c72 019faadb-11e1-7482-931e-c89a0e931654 dbreview-2500 21]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..3.64 rows=21 width=118) (actual time=0.009..0.011 rows=21 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using npm_packages_p24_namespace_id_npm_repository_id_name_idx on npm_packages_p24 npm_packages (cost=0.29..399.87 rows=2501 width=118) (actual time=0.009..0.010 rows=21 loops=1)
Index Cond: ((namespace_id = 'c9436e39-6349-40ad-985d-708af9e30c72'::uuid) AND (npm_repository_id = '019faadb-11e1-7482-931e-c89a0e931654'::uuid) AND (name < 'dbreview-2500'::text))
Buffers: shared hit=3
Planning:
Buffers: shared hit=5
Planning Time: 0.108 ms
Execution Time: 0.020 msTimings: planning 0.108ms, execution 0.020ms, total 0.128ms.
datastore.FindNpmPackageInRepository
Summary: Primary-key seek on (id, namespace_id) with the repository and soft-delete predicates as a filter on the single row it returns. The filter here fans out nothing: the Index Cond already pins one row, so the cost does not move with the number of packages in the namespace.
Seed shape: npm_packages=10002, npm_versions=10000, npm_repositories=2, repositories=2
Rendered SQL:
SELECT npm_packages.namespace_id AS "npm_packages.namespace_id",
npm_packages.id AS "npm_packages.id",
npm_packages.npm_repository_id AS "npm_packages.npm_repository_id",
npm_packages.last_downloaded_at AS "npm_packages.last_downloaded_at",
npm_packages.soft_deleted_at AS "npm_packages.soft_deleted_at",
npm_packages.versions_count AS "npm_packages.versions_count",
npm_packages.tags_count AS "npm_packages.tags_count",
npm_packages.name AS "npm_packages.name",
npm_packages.scope AS "npm_packages.scope"
FROM public.npm_packages
WHERE (((npm_packages.namespace_id = $1::uuid) AND (npm_packages.id = $2::uuid)) AND (npm_packages.npm_repository_id = $3::uuid)) AND (npm_packages.soft_deleted_at IS NULL)
LIMIT $4;Bound args: [c9436e39-6349-40ad-985d-708af9e30c72 dfaccdad-b11f-4f98-9f2f-a583dec0ce1d 019faadb-11e1-7482-931e-c89a0e931654 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..8.31 rows=1 width=118) (actual time=0.016..0.017 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_packages_p24_pkey on npm_packages_p24 npm_packages (cost=0.29..8.31 rows=1 width=118) (actual time=0.016..0.016 rows=1 loops=1)
Index Cond: ((id = 'dfaccdad-b11f-4f98-9f2f-a583dec0ce1d'::uuid) AND (namespace_id = 'c9436e39-6349-40ad-985d-708af9e30c72'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (npm_repository_id = '019faadb-11e1-7482-931e-c89a0e931654'::uuid))
Buffers: shared hit=3
Planning Time: 0.091 ms
Execution Time: 0.029 msTimings: planning 0.091ms, execution 0.029ms, total 0.120ms.
datastore.ListNpmVersionsByPackage.created_at-desc
Summary: Backward scan of the created_at keyset index with the row-value bound folded into the Index Cond, which is the whole point of writing it as ROW(created_at, id) rather than the expanded boolean form. A mid-dataset cursor over 5000 versions still reads 3 buffers.
Seed shape: npm_packages=10002, npm_versions=10000, npm_repositories=2, repositories=2
Rendered SQL:
SELECT npm_versions.namespace_id AS "npm_versions.namespace_id",
npm_versions.id AS "npm_versions.id",
npm_versions.npm_package_id AS "npm_versions.npm_package_id",
npm_versions.last_downloaded_at AS "npm_versions.last_downloaded_at",
npm_versions.soft_deleted_at AS "npm_versions.soft_deleted_at",
npm_versions.created_at AS "npm_versions.created_at",
npm_versions.version AS "npm_versions.version",
npm_versions.gitlab_user_id AS "npm_versions.gitlab_user_id",
npm_versions.gitlab_project_id AS "npm_versions.gitlab_project_id",
npm_versions.gitlab_git_commit_sha AS "npm_versions.gitlab_git_commit_sha"
FROM public.npm_versions
WHERE (((npm_versions.namespace_id = $1::uuid) AND (npm_versions.npm_package_id = $2::uuid)) AND (npm_versions.soft_deleted_at IS NULL)) AND ((npm_versions.created_at, npm_versions.id) < ($3::timestamp with time zone, $4::uuid))
ORDER BY npm_versions.created_at DESC, npm_versions.id DESC
LIMIT $5;Bound args: [c9436e39-6349-40ad-985d-708af9e30c72 019faadb-11ea-7cd2-9bc6-cef792b88d1b 2026-01-02 17:40:00 +0000 UTC 019faadb-15de-76ed-9df7-f19af0d727c5 21]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..3.71 rows=21 width=176) (actual time=0.010..0.013 rows=21 loops=1)
Buffers: shared hit=3
-> Index Scan Backward using npm_versions_p24_namespace_id_npm_package_id_created_at_id_idx on npm_versions_p24 npm_versions (cost=0.29..407.85 rows=2500 width=176) (actual time=0.009..0.012 rows=21 loops=1)
Index Cond: ((namespace_id = 'c9436e39-6349-40ad-985d-708af9e30c72'::uuid) AND (npm_package_id = '019faadb-11ea-7cd2-9bc6-cef792b88d1b'::uuid) AND (ROW(created_at, id) < ROW('2026-01-02 17:40:00+00'::timestamp with time zone, '019faadb-15de-76ed-9df7-f19af0d727c5'::uuid)))
Buffers: shared hit=3
Planning:
Buffers: shared hit=321
Planning Time: 0.955 ms
Execution Time: 0.039 msTimings: planning 0.955ms, execution 0.039ms, total 0.994ms.
datastore.ListNpmVersionsByPackage.version-asc
Summary: Forward scan of the partial unique version index. The single-column bound on version is a complete keyset because version is unique per package among active rows, so no id tiebreaker appears and no Sort node is needed.
Seed shape: npm_packages=10002, npm_versions=10000, npm_repositories=2, repositories=2
Rendered SQL:
SELECT npm_versions.namespace_id AS "npm_versions.namespace_id",
npm_versions.id AS "npm_versions.id",
npm_versions.npm_package_id AS "npm_versions.npm_package_id",
npm_versions.last_downloaded_at AS "npm_versions.last_downloaded_at",
npm_versions.soft_deleted_at AS "npm_versions.soft_deleted_at",
npm_versions.created_at AS "npm_versions.created_at",
npm_versions.version AS "npm_versions.version",
npm_versions.gitlab_user_id AS "npm_versions.gitlab_user_id",
npm_versions.gitlab_project_id AS "npm_versions.gitlab_project_id",
npm_versions.gitlab_git_commit_sha AS "npm_versions.gitlab_git_commit_sha"
FROM public.npm_versions
WHERE (((npm_versions.namespace_id = $1::uuid) AND (npm_versions.npm_package_id = $2::uuid)) AND (npm_versions.soft_deleted_at IS NULL)) AND (npm_versions.version > $3::text)
ORDER BY npm_versions.version ASC
LIMIT $4;Bound args: [c9436e39-6349-40ad-985d-708af9e30c72 019faadb-11ea-7cd2-9bc6-cef792b88d1b 1.0.2500 21]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..3.61 rows=21 width=176) (actual time=0.010..0.018 rows=21 loops=1)
Buffers: shared hit=7
-> Index Scan using npm_versions_p24_namespace_id_npm_package_id_version_idx on npm_versions_p24 npm_versions (cost=0.29..524.37 rows=3315 width=176) (actual time=0.010..0.016 rows=21 loops=1)
Index Cond: ((namespace_id = 'c9436e39-6349-40ad-985d-708af9e30c72'::uuid) AND (npm_package_id = '019faadb-11ea-7cd2-9bc6-cef792b88d1b'::uuid) AND (version > '1.0.2500'::text))
Buffers: shared hit=7
Planning:
Buffers: shared hit=1
Planning Time: 0.098 ms
Execution Time: 0.027 msTimings: planning 0.098ms, execution 0.027ms, total 0.125ms.
datastore.FindNpmVersionByID.version-leg
Summary: Primary-key seek returning the one version row. This leg replaced a single statement that related npm_versions to npm_packages: that shape planned a merge join estimating 5002 package rows and 69 buffer hits, because the package id it needs lives in the version row it has not read yet, so namespace_id was the only constant it could push into the package side.
Seed shape: npm_packages=10002, npm_versions=10000, npm_repositories=2, repositories=2
Rendered SQL:
SELECT npm_versions.namespace_id AS "npm_versions.namespace_id",
npm_versions.id AS "npm_versions.id",
npm_versions.npm_package_id AS "npm_versions.npm_package_id",
npm_versions.last_downloaded_at AS "npm_versions.last_downloaded_at",
npm_versions.soft_deleted_at AS "npm_versions.soft_deleted_at",
npm_versions.created_at AS "npm_versions.created_at",
npm_versions.version AS "npm_versions.version",
npm_versions.gitlab_user_id AS "npm_versions.gitlab_user_id",
npm_versions.gitlab_project_id AS "npm_versions.gitlab_project_id",
npm_versions.gitlab_git_commit_sha AS "npm_versions.gitlab_git_commit_sha"
FROM public.npm_versions
WHERE ((npm_versions.namespace_id = $1::uuid) AND (npm_versions.id = $2::uuid)) AND (npm_versions.soft_deleted_at IS NULL)
LIMIT $3;Bound args: [c9436e39-6349-40ad-985d-708af9e30c72 854d4961-1c1f-496d-9d7d-037db9a73f4a 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..8.30 rows=1 width=176) (actual time=0.011..0.012 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_versions_p24_pkey on npm_versions_p24 npm_versions (cost=0.29..8.30 rows=1 width=176) (actual time=0.011..0.011 rows=1 loops=1)
Index Cond: ((id = '854d4961-1c1f-496d-9d7d-037db9a73f4a'::uuid) AND (namespace_id = 'c9436e39-6349-40ad-985d-708af9e30c72'::uuid))
Filter: (soft_deleted_at IS NULL)
Buffers: shared hit=3
Planning:
Buffers: shared hit=1
Planning Time: 0.064 ms
Execution Time: 0.021 msTimings: planning 0.064ms, execution 0.021ms, total 0.085ms.
datastore.FindNpmVersionByID.parent-leg
Summary: Primary-key seek verifying the parent package is active and belongs to the repository in the URL. Together with the version leg this is 6 buffer hits, and neither leg's cost grows with how many packages or versions the namespace holds.
Seed shape: npm_packages=10002, npm_versions=10000, npm_repositories=2, repositories=2
Rendered SQL:
SELECT npm_packages.id AS "npm_packages.id"
FROM public.npm_packages
WHERE (((npm_packages.namespace_id = $1::uuid) AND (npm_packages.id = $2::uuid)) AND (npm_packages.npm_repository_id = $3::uuid)) AND (npm_packages.soft_deleted_at IS NULL)
LIMIT $4;Bound args: [c9436e39-6349-40ad-985d-708af9e30c72 019faadb-11ea-7cd2-9bc6-cef792b88d1b 019faadb-11e1-7482-931e-c89a0e931654 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.29..8.31 rows=1 width=16) (actual time=0.007..0.007 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_packages_p24_pkey on npm_packages_p24 npm_packages (cost=0.29..8.31 rows=1 width=16) (actual time=0.007..0.007 rows=1 loops=1)
Index Cond: ((id = '019faadb-11ea-7cd2-9bc6-cef792b88d1b'::uuid) AND (namespace_id = 'c9436e39-6349-40ad-985d-708af9e30c72'::uuid))
Filter: ((soft_deleted_at IS NULL) AND (npm_repository_id = '019faadb-11e1-7482-931e-c89a0e931654'::uuid))
Buffers: shared hit=3
Planning Time: 0.049 ms
Execution Time: 0.016 msTimings: planning 0.049ms, execution 0.016ms, total 0.065ms.
Note
No migrations in this MR, so migration mode did not run. No flags: every
statement is a single-partition index read whose plan rows match its actual
rows, and no plan carries a post-scan Sort or a Rows Removed by Filter
that fans out. The one anomaly the measurement found (the version detail read
planning a namespace-wide merge join) is fixed in 67a283e5, and the plans above
are from the fixed shape.
Context for LLM agents
What this is
Step 8 of the merged plan docs/plans/2026-07-22-s17-phase3-format-artifact-reads.md (lines 394-414). Datastore only. Steps 11 and 12 own the handlers, DTOs, and the handler.go / wire_management.go wiring, and nothing here touches them.
Design decisions and rejected alternatives
Version list does not join npm_packages. Rejected: joining to enforce npm_repository_id and the parent's soft-delete state. The join is affordable (a nested loop keeps the outer index order), but the spec's resolution flow already mandates a parent-detail read per request, so the join re-runs a check the handler just performed. Maven's ListMavenVersionsByPackage (Step 6) is specified the same way, and the Step 12 handler dispatches to both, so the two must agree.
Excluded package_json from the version projection. Rejected: returning full rows. The plan's word is "full rows", meaning the columns the resource needs rather than every column, contrasted against protocol listers that return names only. Precedent for a partial projection into model.NpmVersions is npmVersionPageForFileCascade.
Per-store page-size ceilings (maxNpmPackagesPageSize, reusing maxNpmVersionsPageSize) rather than relying on the handler's maxPageSize clamp of 100. They are backstops for a caller wiring the store directly. maxNpmVersionsPageSize already established the pattern in npm_versions.go.
NpmPackageCursor carries only Name, no id. name is unique per (namespace_id, npm_repository_id) among active rows, so a single-column bound is a complete keyset and a row-value tuple over (name, id) would reintroduce a column the index does not carry, forcing a post-index filter. Same reasoning as repositoryKeysetBound's name special case.
pg.WRAP not pg.ROW for the (created_at, id) bound. listByImageStmt documents that pg.WRAP emits the bare-parenthesized tuple the planner folds into a multicolumn index range, while pg.ROW emits the ROW keyword. The ..._DeepPageIsIndexBacked cases assert the fold empirically.
Own cleanup sweep (cleanupNpmManagementRows) instead of reusing cleanupNpmRowsForNamespace. The publish committer mints a blob_storage_attachments row that no seedAttachment per-row cleanup covers, and the existing sweep deletes attachments before npm_files, so the committer's attachment would survive and fail the namespace teardown. The helper's own doc explains why the existing ordering is fine for its own callers.
Non-goals
NpmPackageByNamestill hand-lists the same nine columnsnpmPackageColumnsnow returns. The plan says protocol methods stay untouched. Refactoring a protocol read to share the helper would widen the protocol-path review surface for no behavior change.errNpmPackage*NonPositive*sentinel names predate the bigserial-to-UUID migration and read oddly next to their "must not be the zero UUID" messages. Renaming them touches every existing npm guard test and belongs in its own change.- No handler, DTO, route, or OpenAPI change. Steps 1, 11, and 12 own those.
- No
EXPLAIN (ANALYZE, BUFFERS)output in this description. The plan's acceptance is that the assertions live in the suite, and they do: five list cases pin the index by name and assert no post-scanSortand noRows Removed by Filter, and two detail cases assert single-partition pruning.
Review history
Two full /review-branch passes ran before this MR opened. Pass 1 raised two warnings, both fixed: the detail reads built their statements inline so nothing could EXPLAIN them (fixed by extracting the two builders plus pruning tests), and nothing distinguished a transient database failure from not-found (fixed by two cancelled-context subtests). Pass 2 raised one: a doc comment claimed whole-package unpublish leaves an active version under a soft-deleted package "for a while", which NpmPackageUnpublishDeleter contradicts because it cascades in one transaction. The predicate is still correct and the comment now says why.
The EXPLAIN measurement afterwards found what both review passes missed: the version detail read planned a namespace-wide merge join. Neither pass caught it because the plan is only visible from a real plan at scale, and the pruning test that pass 1 added asserted partition count rather than plan shape, so it passed the bad shape. Fixed in 67a283e5, with a plan-shape assertion that would not.
The automated AppSec review raised one medium design finding, declined with rationale in its thread: ListNpmVersionsByPackage delegates the parent-chain check to its caller, which the merged plan settles as the handler's job for every by-parent list in Phase 3. Its proposed proof-of-resolution token is also forgeable, since an empty composite literal needs no field names. Its fallback was taken: the obligation now sits on the params struct a handler author fills in.