feat(npm): package, version, tag datastore read queries (S11 Step 4a)
📦 What
Step 4a of the npm local plan — the relational read-side datastore queries the packument path consumes: NpmPackageByName, NpmVersionsByPackage, NpmTagsByPackage.
Originally the single Step 4 MR; split into two cohesive slices by storage shape. This MR is the three relational stores; the two content-addressed stores (npm_files, npm_metadata_files) follow in Step 4b (!476 (merged)), stacked on this branch and sharing the fixture layer introduced here.
| Store file | Read method |
|---|---|
npm_packages.go |
NpmPackageByName |
npm_versions.go |
NpmVersionsByPackage |
npm_tags.go |
NpmTagsByPackage |
All queries follow docs/dev/database-query-patterns.md: jet via the pg-aliased package with columns listed explicitly (no AllColumns), the simple query protocol (PgBouncer-safe), client.DB() resolved per query, and qrm.ErrNoRows wrapped as datastore.ErrNotFound on the single-row read. Every filter leads with the namespace_id partition key (ADR 022), is index-backed (the Step-2 unique indexes for the point and tag reads; a dedicated keyset index added in this MR for the version-list order — see Database Review Evidence below), and excludes soft-deleted rows via soft_deleted_at IS NULL. The two list reads (NpmVersionsByPackage, NpmTagsByPackage) are keyset-paginated (a *ByPackageParams cursor + Limit); the single-row NpmPackageByName is not.
✅ Acceptance coverage
Step 4 carries no numbered spec ACs (those land in the consuming handler steps); its acceptance is the plan's per-step bullet. Evidence in npm_read_integration_test.go:
| Behaviour | Test |
|---|---|
| Each query returns expected rows for the fixture state | TestNpmPackageStore_NpmPackageByName, TestNpmVersionStore_NpmVersionsByPackage, TestNpmTagStore_NpmTagsByPackage |
Missing row → ErrNotFound (single-row) / empty slice (list) |
single-row miss + list empty-slice subtests |
| Soft-deleted rows excluded | soft-delete subtests on packages / versions |
Scope-prefixed @scope/foo looked up by full name (scope stored with the @ sigil) |
scoped-name subtest on NpmPackageByName |
| Namespace isolation (+ repo / package scoping) | cross-namespace subtest per store; cross-repo on packages; cross-package on tags |
🧪 Testing
internal/datastore/npm_read_integration_test.go(integration, testcontainers PostgreSQL).internal/datastore/npm_read_test.go(unit): argument-guard rejections + constructor nil-client panics.- All integration + unit tests pass locally; full pre-commit suite (gofmt, goimports, golangci-lint, go-test, gitlint) green.
MR size: ~1,120 LOC hand-written, majority test code, now including the 20260615120000 keyset-index migration (plus its generated structure.sql partition DDL). One cohesive slice (the three relational read stores over near-identical jet queries); the content-addressed pair is split out to !476 (merged).
♻️ Review feedback
- Per-store argument-guard sentinels (
errNpmPackageNilContext/errNpmPackageZeroNamespace, and theerrNpmVersion*/errNpmTag*equivalents) replace the sharedguards.gosentinels on all three read methods, so a caller doingerrors.Isacross stores can attribute a guard failure to the store that raised it — matching thecontainer_blobs/container_imagespattern. Per the AppSec review; covered bynpm_read_test.go. NpmVersionsByPackageordering gains anidtiebreaker (ORDER_BY(created_at ASC, id ASC)):created_atisDEFAULT NOW()and non-unique, so theidmakes the order total. The contract is asserted against an ordered slice (the subtest seeds an earliest row plus two sharing onecreated_at, so a reversed or droppedORDER BYfails; tags assert thename ASCslice) —798562d.- Keyset pagination on the two list reads:
NpmVersionsByPackage/NpmTagsByPackagenow take a*ByPackageParams(keyset cursor +Limit), key on(created_at, id)andnamerespectively, reject a non-positiveLimit, and expose no unpaginated variant — mirroringcontainer_manifest.ListReferrersPage. The prior all-rows reads would invite an unbounded materialization once the packument consumers (Step 11/25) land. Covered by the*_Keysetmulti-page walks and the limit guard —342512a. - Database review (
/db-review-prep) surfaced that theNpmVersionsByPackagekeyset was not index-backed: the expandedcreated_at > x OR (created_at = x AND id > y)cursor degraded to a post-scanFilter(Rows Removed by Filter: 2501at a mid-range page — O(offset) per page, ~O(n²) over the packument generator's full-history paging). Fixed with a partial indexindex_npm_versions_on_ns_id_pkg_id_created_at_idon(namespace_id, npm_package_id, created_at, id)(migration20260615120000) and switching the cursor to a row-value boundROW(created_at, id) > ROW(…)— both required for PostgreSQL to push the bound into the index range. The keyset is now a true Index-Cond seek (0 rows discarded at any page depth, 5 buffers vs 83);npm_tagswas already optimal. Full EXPLAIN evidence below —a7bc494. - Guard test cases inlined as anonymous
testsslices in each test function (dropped the sharednpmGuardCasenamed struct), per the datastore test convention. - Declined: narrow per-store reader interfaces, keeping the package-wide concrete-
*Storeconvention (consumers define their own interfaces at the call site).
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml), with synthesized seed data rolled
back per query and the container torn down at the end of the run.
Numbers reflect moderate cardinality (5000 rows in one partition) and
do not capture production-scale effects. See
Database review evidence
for seed sizing, methodology, and the anomalies the skill flags.
Expand each row's details 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.NpmPackageByName |
Index Scan | npm_packages…ns_id_npm_repository_id_name_idx |
1 / 1 | 8.30 | 0.013ms | 3 / 0 | 1 |
datastore.NpmTagsByPackage.FirstPage |
Index Scan | npm_tags…ns_id_npm_package_id_name_idx |
50 / 50 | 4.38 | 0.020ms | 3 / 0 | 1 |
datastore.NpmTagsByPackage.KeysetPage |
Index Scan | npm_tags…ns_id_npm_package_id_name_idx |
50 / 50 | 5.52 | 0.019ms | 4 / 0 | 1 |
datastore.NpmVersionsByPackage.FirstPage |
Index Scan | npm_versions…ns_id_pkg_id_created_at_id_idx |
50 / 50 | 4.37 | 0.022ms | 3 / 0 | 1 |
datastore.NpmVersionsByPackage.KeysetPage |
Index Scan | npm_versions…ns_id_pkg_id_created_at_id_idx |
50 / 50 | 5.69 | 0.040ms | 5 / 0 | 1 |
Query notes:
datastore.NpmVersionsByPackage(both branches): the initial run found the keyset was not index-backed — no index covered(namespace_id, npm_package_id, created_at, id), so the planner drove offindex_npm_versions_on_ns_id_created_atand applied bothnpm_package_idand the cursor as a post-scanFilter(Rows Removed by Filter: 2501at the mid-range page, 83 buffers — O(offset) per page). This MR fixes it with (a) a partial indexindex_npm_versions_on_ns_id_pkg_id_created_at_idon(namespace_id, npm_package_id, created_at, id) WHERE soft_deleted_at IS NULL(migration20260615120000) and (b) expressing the cursor as a row-value boundROW(created_at, id) > ROW($, $)inNpmVersionsByPackage. Both are required: the index alone leaves the expanded boolean form as a Filter; the row-value form is what Postgres pushes into the index range. With both, the keyset is a true Index-Cond seek — 0 rows discarded, 5 buffers, regardless of page depth. MirrorsContainerTagStore.ListByImageand its index (issue #109 (closed)). No remaining anomalies.datastore.NpmTagsByPackage(both branches): textbook keyset already — the unique(namespace_id, npm_package_id, name)index serves the equality predicates and thename > cursorkeyset as Index Conds, so each page seeks straight to the cursor with 0 rows discarded. No anomalies.datastore.NpmPackageByName: unique-index point lookup pruned to one partition. No anomalies.
datastore.NpmPackageByName
Summary: Plan matches intent — an Index Scan over the unique
(namespace_id, npm_repository_id, name) index, with the namespace_id
literal pruning to one of 64 partitions. Estimate matches reality (1 / 1)
and execution is 0.013ms over 5000 seeded packages. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=5000
Rendered SQL:
SELECT npm_packages.namespace_id, npm_packages.id, npm_packages.npm_repository_id,
npm_packages.last_downloaded_at, npm_packages.soft_deleted_at,
npm_packages.versions_count, npm_packages.tags_count, npm_packages.name,
npm_packages.scope
FROM public.npm_packages
WHERE (((npm_packages.namespace_id = $1::uuid) AND (npm_packages.npm_repository_id = $2)) AND (npm_packages.name = $3::text)) AND (npm_packages.soft_deleted_at IS NULL)
LIMIT $4;Bound args: [<namespace uuid>, 1, 'review-prep-pkg-002501', 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.30 rows=1 width=111) (actual time=0.006..0.006 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_packages_p44_namespace_id_npm_repository_id_name_idx on npm_packages_p44 npm_packages (cost=0.28..8.30 rows=1 width=111) (actual time=0.006..0.006 rows=1 loops=1)
Index Cond: ((namespace_id = '...'::uuid) AND (npm_repository_id = '1'::bigint) AND (name = 'review-prep-pkg-002501'::text))
Buffers: shared hit=3
Planning:
Buffers: shared hit=276
Planning Time: 0.452 ms
Execution Time: 0.013 msTimings: planning 0.452ms, execution 0.013ms, total 0.465ms.
datastore.NpmTagsByPackage.FirstPage
Summary: Plan matches intent — Index Scan over the unique
(namespace_id, npm_package_id, name) index, both equality predicates as
Index Conds and name ASC provided by the index (no Sort), pruned to one
partition. 50 / 50 rows, 0.020ms. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=1, npm_tags=5000
Rendered SQL:
SELECT npm_tags.namespace_id, npm_tags.id, npm_tags.npm_package_id,
npm_tags.npm_version_id, npm_tags.name
FROM public.npm_tags
WHERE (npm_tags.namespace_id = $1::uuid) AND (npm_tags.npm_package_id = $2)
ORDER BY npm_tags.name ASC
LIMIT $3;Bound args: [<namespace uuid>, <package id>, 50]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..4.38 rows=50 width=63) (actual time=0.006..0.010 rows=50 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_tags_p04_namespace_id_npm_package_id_name_idx on npm_tags_p04 npm_tags (cost=0.28..409.78 rows=5000 width=63) (actual time=0.006..0.008 rows=50 loops=1)
Index Cond: ((namespace_id = '...'::uuid) AND (npm_package_id = '...'::bigint))
Buffers: shared hit=3
Planning:
Buffers: shared hit=320
Planning Time: 0.523 ms
Execution Time: 0.020 msTimings: planning 0.523ms, execution 0.020ms, total 0.543ms.
datastore.NpmTagsByPackage.KeysetPage
Summary: Textbook keyset — the unique (namespace_id, npm_package_id, name) index serves both equality predicates and the name > cursor
keyset as Index Conds, so the scan seeks straight to the cursor with 0
rows discarded. 50 / 50 rows, 0.019ms, one partition. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=1, npm_tags=5000
Rendered SQL:
SELECT npm_tags.namespace_id, npm_tags.id, npm_tags.npm_package_id,
npm_tags.npm_version_id, npm_tags.name
FROM public.npm_tags
WHERE ((npm_tags.namespace_id = $1::uuid) AND (npm_tags.npm_package_id = $2)) AND (npm_tags.name > $3::text)
ORDER BY npm_tags.name ASC
LIMIT $4;Bound args: [<namespace uuid>, <package id>, 'review-prep-tag-002501', 50]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..5.52 rows=50 width=63) (actual time=0.006..0.012 rows=50 loops=1)
Buffers: shared hit=4
-> Index Scan using npm_tags_p58_namespace_id_npm_package_id_name_idx on npm_tags_p58 npm_tags (cost=0.28..262.28 rows=2500 width=63) (actual time=0.006..0.010 rows=50 loops=1)
Index Cond: ((namespace_id = '...'::uuid) AND (npm_package_id = '...'::bigint) AND (name > 'review-prep-tag-002501'::text))
Buffers: shared hit=4
Planning:
Buffers: shared hit=64
Planning Time: 0.177 ms
Execution Time: 0.019 msTimings: planning 0.177ms, execution 0.019ms, total 0.196ms.
datastore.NpmVersionsByPackage.FirstPage
Summary: Index Scan over the partial index (namespace_id, npm_package_id, created_at, id) WHERE soft_deleted_at IS NULL added by
this MR — both equality predicates are Index Conds, (created_at, id)
order comes from the index (no Sort), and the partial predicate absorbs
the soft_deleted_at IS NULL filter (no residual Filter). 50 / 50 rows,
0.022ms. Before the index the planner drove off (namespace_id, created_at) with npm_package_id as a Filter plus an Incremental Sort.
No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=5000
Rendered SQL:
SELECT npm_versions.namespace_id, npm_versions.id, npm_versions.npm_package_id,
npm_versions.last_downloaded_at, npm_versions.soft_deleted_at,
npm_versions.created_at, npm_versions.version, npm_versions.package_json,
npm_versions.gitlab_user_id, npm_versions.gitlab_project_id,
npm_versions.gitlab_git_commit_sha
FROM public.npm_versions
WHERE ((npm_versions.namespace_id = $1::uuid) AND (npm_versions.npm_package_id = $2)) AND (npm_versions.soft_deleted_at IS NULL)
ORDER BY npm_versions.created_at ASC, npm_versions.id ASC
LIMIT $3;Bound args: [<namespace uuid>, <package id>, 50]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..4.37 rows=50 width=180) (actual time=0.009..0.013 rows=50 loops=1)
Buffers: shared hit=3
-> Index Scan using npm_versions_p44_namespace_id_npm_package_id_created_at_id_idx on npm_versions_p44 npm_versions (cost=0.28..408.91 rows=5000 width=180) (actual time=0.008..0.010 rows=50 loops=1)
Index Cond: ((namespace_id = '...'::uuid) AND (npm_package_id = '...'::bigint))
Buffers: shared hit=3
Planning:
Buffers: shared hit=109
Planning Time: 0.341 ms
Execution Time: 0.022 msTimings: planning 0.341ms, execution 0.022ms, total 0.363ms.
datastore.NpmVersionsByPackage.KeysetPage
Summary: True keyset seek — with this MR's partial index plus the
row-value bound ROW(created_at, id) > ROW($, $), the cursor is an Index
Cond, so the scan seeks straight to the page boundary: 0 rows discarded
at any depth, 5 buffers, 0.040ms. Before the fix (expanded boolean form,
no covering index) the same mid-range page discarded 2501 rows as a
post-scan Filter (83 buffers) — O(offset). Fixed by migration
20260615120000 and the row-value predicate. No anomalies.
Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=5000
Rendered SQL:
SELECT npm_versions.namespace_id, npm_versions.id, npm_versions.npm_package_id,
npm_versions.last_downloaded_at, npm_versions.soft_deleted_at,
npm_versions.created_at, npm_versions.version, npm_versions.package_json,
npm_versions.gitlab_user_id, npm_versions.gitlab_project_id,
npm_versions.gitlab_git_commit_sha
FROM public.npm_versions
WHERE (((npm_versions.namespace_id = $1::uuid) AND (npm_versions.npm_package_id = $2)) AND (npm_versions.soft_deleted_at IS NULL)) AND (ROW(npm_versions.created_at, npm_versions.id) > ROW($3::timestamp with time zone, $4))
ORDER BY npm_versions.created_at ASC, npm_versions.id ASC
LIMIT $5;Bound args: [<namespace uuid>, <package id>, <mid-range created_at>, <mid-range id>, 50]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..5.69 rows=50 width=180) (actual time=0.005..0.014 rows=50 loops=1)
Buffers: shared hit=5
-> Index Scan using npm_versions_p44_namespace_id_npm_package_id_created_at_id_idx on npm_versions_p44 npm_versions (cost=0.28..270.57 rows=2499 width=180) (actual time=0.005..0.011 rows=50 loops=1)
Index Cond: ((namespace_id = '...'::uuid) AND (npm_package_id = '...'::bigint) AND (ROW(created_at, id) > ROW('...'::timestamptz, '...'::bigint)))
Buffers: shared hit=5
Planning:
Buffers: shared hit=10
Planning Time: 0.120 ms
Execution Time: 0.040 msTimings: planning 0.120ms, execution 0.040ms, total 0.160ms.
Related to #122 (closed)