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 localhosted 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 translate datastore.ErrNotFound into the npm tier's ErrNamespaceNotFound / ErrRepositoryNotFound. Mirrors wire_oci.go.
  • cmd/artifact-registry/wire_database.go - wireServices mounts the npm tier alongside OCI once the database client is built.
  • internal/datastore/npm_repositories.go - NpmRepositoryStore.FindHostedByNameInNamespace resolves an npm-format, kind=hosted, non-soft-deleted repository by name to its repositories.id, served by the partial (namespace_id, name) WHERE soft_deleted_at IS NULL index. The name encodes the hosted kind 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 as repository_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 ms

Timings: planning 1.034ms, execution 0.260ms, total 1.294ms.

Plan: docs/plans/2026-05-11-npm-hosted.md (Step 8)

Related to #22 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading