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.FindRemoteByNameInNamespaceresolves akind=2npm repository by name and returns the two-id pair the proxy handlers key on (npm_remote_repositories.idfor the child lookups +repositories.idfor visibility) plus the S13 behavioral fields (upstream URL, interim bearer token, both cache-validity windows, health columns). It gates the parentrepositories.soft_deleted_aton the JOIN, so a hosted, virtual, soft-deleted, or cross-namespace row all resolve asErrNotFound.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 ownsoft_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 aFreshboolean computed in SQL against the database clock (the same clock the cache-fill writer stampsupstream_checked_atwith). A stale row is still returned — carrying its ETag — so the handler can revalidate withIf-None-Matchrather than refetch blindly; only a missing row is the Miss. Metadata is never pinned (its window is> 0by DB CHECK); a tarball is pinned fresh forever whencache_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. Newnpm_remote_read_test.gounit 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 toErrNotFound. Misread as a Miss, a transient failure would drive a storm of spurious upstream fetches and rebuilds; this pins that onlyqrm.ErrNoRowsmaps to the Miss signal.🧹 Marked the intentionally-parallel hosted/remote package guard tables//nolint:dupl, and switched the cleanup//nolintcomments 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).
🔗 References
- 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)