feat(npm): datastore remote read + remote-repository finder (S15 Step 5)

📦 What this adds

S15 Step 5 lands the read side of the npm-remote datastore layer — the store methods a kind=2 (caching-proxy) npm repository reads through on every packument, dist-tags, and tarball request. It is the piece the kind-dispatch resolver (Step 8) will call to turn an incoming npm request into a cache lookup, and the piece the S13 Lookup/Fetch operations read their freshness verdict from.

It is read-only: cache-fill writes are Step 6, the credential/cache-validity service layer is Step 7, and the resolver that wires these into the request path is Step 8. Everything here mirrors the already-merged hosted npm read stores rather than inventing new patterns.

:jigsaw: The pieces

  • npm_remote_repositories.go — the remote-repository finder. FindRemoteByNameInNamespace resolves a kind=2 npm repository by name and returns the two-id pair the proxy handlers key on (npm_remote_repositories.id for the child lookups + repositories.id for visibility) plus the S13 behavioral fields (upstream URL, interim bearer token, both cache-validity windows, health columns). It gates the parent repositories.soft_deleted_at on the JOIN, so a hosted, virtual, soft-deleted, or cross-namespace row all resolve as ErrNotFound.
  • npm_remote_packages.go / npm_remote_versions.go — the resolution helpers. Resolve a package (by full @scope/name) or a version to its binding id so the metadata/tarball lookups can key on it. Each gates its own soft_deleted_at; a not-yet-cached row is a cache Miss (ErrNotFound), never a distinct error.
  • npm_remote_metadata_files.go / npm_remote_files.go — the cache reads with a freshness verdict. Return the cached row plus a Fresh boolean computed in SQL against the database clock (the same clock the cache-fill writer stamps upstream_checked_at with). A stale row is still returned — carrying its ETag — so the handler can revalidate with If-None-Match rather than refetch blindly; only a missing row is the Miss. Metadata is never pinned (its window is > 0 by DB CHECK); a tarball is pinned fresh forever when cache_validity_hours = 0 (an immutable upstream such as npmjs.org).

The interim plaintext token is returned unredacted at this layer — it exists only to compose the upstream request; redaction for management-API reads is Step 7, and no method here logs it.

Review follow-ups folded in

This branch went through a full pre-MR review; two test-adequacy gaps the hosted siblings already cover are closed in the test commit on top:

  • 🧪 DB-free guard coverage. New npm_remote_read_test.go unit tests the five nil-client constructor panics and every per-method argument guard (nil ctx, zero UUID, empty name/version, out-of-range kind, non-positive metadata window, negative cache validity) with bare store literals — so they run in the default unit job, not just under a database.
  • 🛡️ "A transient DB failure is not a cache miss." Each of the five reads now has a pre-cancelled-context subtest asserting a generic DB error stays wrapped and never collapses to ErrNotFound. Misread as a Miss, a transient failure would drive a storm of spurious upstream fetches and rebuilds; this pins that only qrm.ErrNoRows maps to the Miss signal.
  • 🧹 Marked the intentionally-parallel hosted/remote package guard tables //nolint:dupl, and switched the cleanup //nolint comments to American English (canceled).

🧪 Testing

Case Test
All five store constructors panic on a nil client TestNewNpmRemoteStores_NilClient
Every argument guard rejects with its per-store sentinel Test*_ArgumentGuards (5 tables)
Finder resolves a kind=2 repo → id pair + remote fields; nil token when unset TestNpmRemoteRepositoryStore_FindRemoteByNameInNamespace
Finder rejects hosted, virtual, soft-deleted, missing, and other-namespace rows same
Package resolves (incl. scoped @scope/name); soft-deleted / uncached → ErrNotFound TestNpmRemotePackageStore_NpmRemotePackageByName
Version resolves; soft-deleted / uncached → ErrNotFound TestNpmRemoteVersionStore_NpmRemoteVersionByPackageAndVersion
Metadata fresh vs. stale verdict; variants cached per kind; miss → ErrNotFound TestNpmRemoteMetadataFileStore_NpmRemoteMetadataFileByPackageAndKind
Tarball fresh vs. stale; pinned forever at cache_validity_hours = 0; miss → ErrNotFound TestNpmRemoteFileStore_NpmRemoteFileByVersionAndName
A transient DB failure is not misreported as ErrNotFound (all five reads) .../a transient DB failure is not misreported as ErrNotFound

Verified clean on this branch: go build, go vet, go test -race (unit and integration on PostgreSQL), goimports, and golangci-lint (v2.12.0).

  • Plan: docs/plans/2026-07-15-npm-remote.md — Step 5
  • Spec: docs/specs/S15-npm-remote.md — name resolution, packument/tarball proxy, credentials

Related to #342 (closed)

Merge request reports

Loading
Loading