feat(npm): wire npm handler + datastore npm-repository finder (Step 8b)
What
S11 npm-hosted Step 8b: wires the npm format handler into the application
mux at the composition root and adds the datastore finder its slug/repo
resolver needs. This is the production caller for npm.NewHandler that Step 8a
(!459 (merged)) deliberately left unwired, so npm routes become reachable for the first
time.
Step 8a (!459 (merged)) has merged; this MR is rebased onto main, including the
local→hosted repository-kind rename.
cmd/artifact-registry/wire_npm.go-wireNPM(srv, client)builds the npm resolver from the production stores and mounts the handler. The composition-seam adapters translatedatastore.ErrNotFoundinto the npm tier'sErrNamespaceNotFound/ErrRepositoryNotFound. Mirrorswire_oci.go.cmd/artifact-registry/wire_database.go-wireServicesmounts the npm tier alongside OCI once the database client is built.internal/datastore/npm_repositories.go-NpmRepositoryStore.FindHostedByNameInNamespaceresolves an npm-format,kind=hosted, non-soft-deleted repository by name to itsrepositories.id, served by the partial(namespace_id, name) WHERE soft_deleted_at IS NULLindex. The name encodes thehostedkind per review: virtual and remote npm repositories land in S15.- Tests - unit coverage for the wiring and the adapter error-translation seam, plus a six-case finder integration suite (success, missing, wrong-format, wrong-kind, soft-deleted, cross-namespace).
The handler mounts at the root pattern /, not a slug-first prefix: a
/{slug}/npm/... pattern conflicts with OCI's /v2/ on the Go 1.22 ServeMux,
so / is the only mount that coexists. OCI's more-specific /v2/ and
/v2/auth/token still win, ops routes sit on a separate probe listener, and
non-npm paths fall through to the npm handler's own 404.
Acceptance
- AC 33 - an npm-format hosted repository resolves by
(namespace_id, name)to its id; missing / wrong-format / wrong-kind / soft-deleted / cross-namespace all surface asrepository_not_found. - AC 63 / AC 64 (wiring tier) - a concrete npm route reaches the mounted handler and returns the interim 501 rather than the bare-mux 404, proving the route table (8a) is mounted (8b).
Reviewer note: diff size
~830 reviewable LOC, above the 500 ceiling but test-dominated: only ~261 lines
are production Go (wire_npm.go 153 + finder 105 + the wireServices call);
the remaining ~570 are unit and integration tests, including the review-response
coverage (wireNPM nil-dependency panics + unmatched-path fallthrough). One test change touches
wire_oci_test.go: the new npm adapter tests reused the same case-name strings
as the OCI adapter tests, tripping goconst package-wide, so the shared strings
were lifted into consts used by both (commit a26d95b). Step 8 was split into
8a (route table + depguard) and 8b (this: wiring + finder) precisely for size.
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 and do not capture production-scale effects. See
Database review evidence
for seed sizing and methodology. Expand the 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.FindHostedByNameInNamespace |
Limit → Index Scan | repositories_p00_namespace_id_name_idx1 |
1 / 1 | 8.31 | 0.260ms | 3 / 0 | 1 |
datastore.FindHostedByNameInNamespace
Summary: Plan matches the method's intent. The namespace_id literal prunes to a single partition (repositories_p00), and an Index Scan over the partial (namespace_id, name) WHERE soft_deleted_at IS NULL index (index_repositories_on_namespace_id_and_name_active) resolves the row; format/kind apply as a cheap recheck Filter on the single matched row (the index does not carry them, but (namespace_id, name) is unique so at most one row is fetched). Actual rows match the estimate (1 / 1), all buffers are cache hits (3 / 0), execution ~0.26ms at 5000 seeded rows in the partition. No anomalies.
Seed shape: namespaces=1, repositories=5000 (all in one namespace → one partition)
Rendered SQL:
SELECT repositories.id AS "repositories.id"
FROM public.repositories
WHERE ((((repositories.namespace_id = $1::uuid) AND (repositories.name = $2::text)) AND (repositories.format = $3)) AND (repositories.kind = $4)) AND (repositories.soft_deleted_at IS NULL)
LIMIT $5;Bound args: [a6dc0420-6ae8-457c-bf8b-524c00b97163, review-prep-repo-002500, 2, 0, 1]
Plan (EXPLAIN (ANALYZE, BUFFERS) output):
Limit (cost=0.28..8.31 rows=1 width=8) (actual time=0.249..0.249 rows=1 loops=1)
Buffers: shared hit=3
-> Index Scan using repositories_p00_namespace_id_name_idx1 on repositories_p00 repositories (cost=0.28..8.31 rows=1 width=8) (actual time=0.249..0.249 rows=1 loops=1)
Index Cond: ((namespace_id = 'a6dc0420-6ae8-457c-bf8b-524c00b97163'::uuid) AND (name = 'review-prep-repo-002500'::text))
Filter: ((format = '2'::smallint) AND (kind = '0'::smallint))
Buffers: shared hit=3
Planning:
Buffers: shared hit=516
Planning Time: 1.034 ms
Execution Time: 0.260 msTimings: planning 1.034ms, execution 0.260ms, total 1.294ms.
Plan: docs/plans/2026-05-11-npm-hosted.md (Step 8)
Related to #22 (closed)