-- SPDX-FileCopyrightText: 2026 Alexandra de Wit
--
-- SPDX-License-Identifier: MIT

{- | The short-TTL, size-bounded metadata cache shared by the serve paths.

Resolving a package re-fetches its upstream packument, parses it, and evaluates
the rules. To avoid repeating the fetch and parse, the result (a coherent pair of
the parsed __packument metadata__, 'PackageInfo', and the __raw document__ it was
decoded from, 'CacheEntry') is held here in a short-TTL, size-bounded, STM-backed
cache (the @cache@ library backs the TTL store). Both serve paths share it: a
packument request and the tarball-gating fetch that follows reuse one fetch and
parse, and concurrent resolutions of a popular package __collapse to one upstream
call__ (single-flight).

== Per-source key

A packument is fetched from two distinct upstreams, a private origin and a public
origin, whose documents differ for the same package, so one entry cannot represent
both. The key is @(source, package)@: the source is the upstream's base URL, which
distinguishes any cached origin without naming a credential, so distinct upstreams
never cross-contaminate and the key never blurs the trust split.

== Credential-free; sharing is the caller's policy

The key carries __no credential dimension__ and the value is a canonical document,
so the cache stores nothing derived from a caller's credential. Whether a given
origin is handed to it, and so shared across clients, is the serve path's decision.

Under the default @passthrough@ access strategy only the anonymous public origin is
cached. The trusted private upstream is the per-client authority: it re-authorises
each request with that client's own forwarded credential, so the serve path fetches
it per request and never hands it here. Were a private entry cached under
@passthrough@, the credential-free key would let one client's entry serve another
client's private document within the TTL, bypassing the upstream's authorisation.
The public origin is anonymous, so one shared entry serves every client without
crossing a trust boundary. Other strategies make a shared private entry safe by
authorising each serve before it is returned (see
@docs\/architecture\/access-model.md@ → "Caching"); that gate lives on the serve
path, never in this store.

== Coherent pair

An entry holds the parsed 'PackageInfo' __and__ the raw document ('CachedDoc') it was
decoded from, so a hit returns a typed view and the exact bytes that produced it. The
packument serve path needs both: it decides over the typed view but rebuilds the served
body from the raw document, and the two must describe the same fetch. The store holds the
raw document opaquely -- it never reads it, only hands it back to the injected adapter
capabilities that assemble and serialise the served body.

What is cached is the __metadata, not the verdict__. The rules are re-evaluated on
the cached metadata each request, so time-sensitive rules
('Ecluse.Core.Rules.Types.AllowIfOlderThan') and the separately-synced advisory
tier stay correct; only each upstream's fetch and parse is memoised. The TTL is
short, and brief staleness is benign: a brand-new publish need not appear instantly
(see @docs\/architecture\/web-layer.md@ → "Metadata cache").

Two properties the @cache@ library does not provide on its own are layered onto
every store by the shared machinery ("Ecluse.Core.Server.Cache.Store"):

* __Resident-byte budget with recency-aware eviction.__ @cache@ expires by TTL but
  bounds neither entry count nor memory. Each entry is wrapped with an estimate of
  its resident footprint (a heavy packument, parsed plus raw, costs many times its
  wire size) and a last-access stamp bumped on every hit. An insert first purges
  expired entries, then evicts the __least-recently-used__ entries until the incoming
  entry fits within both its store's resident-byte budget and entry count
  ('StoreBudget'). Recency keeps a re-accessed hot head resident under pressure
  while shedding the one-shot tail; the byte budget bounds memory more faithfully
  than a count alone. An entry whose estimated footprint alone exceeds its store's
  byte budget is __served without being retained__ (nothing resident is evicted to
  make impossible room), so one pathological document can never flush a store.

* __Single-flight.__ @cache@'s own @fetchWithCache@ is lookup-then-fetch in plain
  'IO', so two concurrent misses would both fetch. 'resolveMetadata' instead
  installs an in-flight marker atomically, so the first miss fetches while concurrent
  misses wait on its result. The leader inserts the result into the store __before__
  removing its in-flight marker, so a caller arriving the instant the fetch returns
  still finds either the store entry or the marker (never a gap) and never re-leads a
  redundant fetch.

== Two coherent stores: the full packument and one version

This handle owns __two__ stores of the same shape (the TTL + size-bound + single-flight
machinery, 'SingleFlight', is shared between them):

  * the __full-packument__ store ('resolveMetadata' \/ 'cachedMetadata'), keyed by
    @(source, package)@, holding the 'CacheEntry' described above; and

  * a __single-version__ store ('resolveVersion' \/ 'cachedVersion'), keyed by
    @(source, package, version)@, holding just one version's
    'Ecluse.Core.Package.PackageDetails' (or its determined absence, a cached
    'Nothing'): the cold tarball gate's selectively-parsed result.

They are __isolated on writes__: a single-version resolution caches under its own
key and __never writes back__ to the full-packument store, so a cold tarball gate
cannot materialise a whole packument into the shared full cache. The serve path's
single-version read consults the warm full-packument store __read-only__ first (a
packument @GET@ followed by its tarball gate still collapses to one upstream call),
and only falls back to leading its own selective fetch into the version store when
the full entry is cold. Each store enforces its __own named sub-budget__
('StoreBudget'): the three sub-budgets are carved from one cache aggregate at the
composition root and sum to it, so the aggregate holds by arithmetic while each
class's eviction stays isolated (a version-store flood can never evict the full
store's hot head). Each reports its own residency gauge: the full-packument store
under @ecluse.metadata_cache.resident_bytes@ and the single-version store under
@ecluse.metadata_cache.version.resident_bytes@. The hit\/miss counter and the
entry-count occupancy gauge stay about the full-packument store.

A third store memoises the __assembled representation__ ('resolveAssembled'): the
encoded merged document, keyed by its derived validator
('Ecluse.Core.Server.Pipeline.Packument.packumentETag'). The key is a fingerprint of
every input the document is a function of (the origin bodies, private included by
content digest; the survivor sets; the mount base), which makes the store
__content-addressed__: an entry can never be served stale, because changed inputs
produce a different key and simply miss. The resident-byte budget is the real bound
here, not the TTL, which only trims dead entries early. Cross-client safety follows
from the same property: a lookup key includes the digest of the private document
__this request's own authorised fetch returned__, so a client can only hit an entry
whose bytes its own inputs would deterministically re-produce. The transform is
shared, never the authorisation and never another client's view (the private-origin
caching prohibition is about credential-blind keying, which a content key is not).
Residency gauge: @ecluse.metadata_cache.assembled.resident_bytes@.
-}
module Ecluse.Core.Server.Cache (
    -- * Configuration
    CacheConfig (..),
    StoreBudget (..),

    -- * The cache handle
    MetadataCache,
    newMetadataCache,

    -- * Cache entries
    Source (..),
    CacheEntry (..),
    weighCacheEntry,

    -- * Resolution
    resolveMetadata,
    resolveMetadataWith,
    cachedMetadata,

    -- * Single-version resolution
    resolveVersion,
    resolveVersionWith,
    cachedVersion,

    -- * Assembled-representation resolution
    resolveAssembled,
) where

import Data.ByteString qualified as BS
import Data.Text.Short qualified as TS
import Data.Time (NominalDiffTime)

import Ecluse.Core.Package (
    PackageDetails,
    PackageInfo,
    PackageName,
    pkgCanonical,
    pkgEcosystem,
    pkgNamespace,
    renderScope,
 )
import Ecluse.Core.Registry.CachedDocument (CachedDoc, weighCachedDoc)
import Ecluse.Core.Registry.Metadata (ContentDigest, MetadataError)
import Ecluse.Core.Server.Cache.Store (
    CacheOccupancy (..),
    SingleFlight,
    lookupStore,
    lookupStoreTouching,
    newSingleFlight,
    resolveSingleFlight,
 )
import Ecluse.Core.Server.MemoryModel (expandWireBytes)
import Ecluse.Core.Telemetry.Record (
    MetricsPort,
    mpAssembledCacheResidentBytes,
    mpCacheEntries,
    mpCacheRequest,
    mpCacheResidentBytes,
    mpVersionCacheResidentBytes,
 )
import Ecluse.Core.Version (Version, renderVersion)

{- | One store's bounds: the entry count and the resident-byte budget it keeps its
held entries under before it evicts. Each entry is weighted by an estimate of its
resident footprint, and an insert past the byte budget evicts the
least-recently-used entries until the budget holds -- bounding memory more
faithfully than the entry count alone.
-}
data StoreBudget = StoreBudget
    { StoreBudget -> Int
sbMaxEntries :: Int
    -- ^ The maximum number of distinct entries held; an insert past this evicts.
    , StoreBudget -> Int
sbMaxBytes :: Int
    -- ^ The resident-byte budget the held entries are kept under.
    }
    deriving stock (StoreBudget -> StoreBudget -> Bool
(StoreBudget -> StoreBudget -> Bool)
-> (StoreBudget -> StoreBudget -> Bool) -> Eq StoreBudget
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: StoreBudget -> StoreBudget -> Bool
== :: StoreBudget -> StoreBudget -> Bool
$c/= :: StoreBudget -> StoreBudget -> Bool
/= :: StoreBudget -> StoreBudget -> Bool
Eq, Int -> StoreBudget -> ShowS
[StoreBudget] -> ShowS
StoreBudget -> String
(Int -> StoreBudget -> ShowS)
-> (StoreBudget -> String)
-> ([StoreBudget] -> ShowS)
-> Show StoreBudget
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> StoreBudget -> ShowS
showsPrec :: Int -> StoreBudget -> ShowS
$cshow :: StoreBudget -> String
show :: StoreBudget -> String
$cshowList :: [StoreBudget] -> ShowS
showList :: [StoreBudget] -> ShowS
Show)

{- | The metadata cache's tunables, sourced from configuration: how long a parsed
packument stays fresh, and each store's own 'StoreBudget'. The three sub-budgets
are carved from one cache aggregate at the composition root
(@Ecluse.Composition.MemoryBudget.budgetCacheConfig@) and __sum to it__, so the
cache's total resident bytes are bounded by the aggregate while each class's
eviction pressure stays its own.
-}
data CacheConfig = CacheConfig
    { CacheConfig -> NominalDiffTime
cacheTtl :: NominalDiffTime
    {- ^ How long a cached 'CacheEntry' is served before it is re-fetched. Short
    by design: brief staleness is benign, and conditional-GET revalidates.
    -}
    , CacheConfig -> StoreBudget
cacheFullBudget :: StoreBudget
    -- ^ The full-packument store's bounds, keyed by @(source, package)@.
    , CacheConfig -> StoreBudget
cacheVersionBudget :: StoreBudget
    -- ^ The single-version store's bounds (small, flat-weighted entries).
    , CacheConfig -> StoreBudget
cacheAssembledBudget :: StoreBudget
    -- ^ The assembled-representation store's bounds (exact strict-bytes weights).
    }
    deriving stock (CacheConfig -> CacheConfig -> Bool
(CacheConfig -> CacheConfig -> Bool)
-> (CacheConfig -> CacheConfig -> Bool) -> Eq CacheConfig
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: CacheConfig -> CacheConfig -> Bool
== :: CacheConfig -> CacheConfig -> Bool
$c/= :: CacheConfig -> CacheConfig -> Bool
/= :: CacheConfig -> CacheConfig -> Bool
Eq, Int -> CacheConfig -> ShowS
[CacheConfig] -> ShowS
CacheConfig -> String
(Int -> CacheConfig -> ShowS)
-> (CacheConfig -> String)
-> ([CacheConfig] -> ShowS)
-> Show CacheConfig
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> CacheConfig -> ShowS
showsPrec :: Int -> CacheConfig -> ShowS
$cshow :: CacheConfig -> String
show :: CacheConfig -> String
$cshowList :: [CacheConfig] -> ShowS
showList :: [CacheConfig] -> ShowS
Show)

{- | Which upstream a cached packument was fetched from: the dimension that
partitions the cache by source so distinct upstreams never share an entry.

The discriminator is the upstream's __base URL__: an upstream is addressed at a
distinct URL, and the URL names a location, never a credential, so keying on it
keeps the trust split intact (the cached origin is fetched with its own token, supplied
through its fetch action; the source carries none). Under the default @passthrough@
strategy only the anonymous public origin is cached, so in practice the cache holds one
source per package; the dimension keeps the key honest about /which/ upstream an entry
is, never blurring the split.
-}
newtype Source = Source Text
    deriving stock (Source -> Source -> Bool
(Source -> Source -> Bool)
-> (Source -> Source -> Bool) -> Eq Source
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: Source -> Source -> Bool
== :: Source -> Source -> Bool
$c/= :: Source -> Source -> Bool
/= :: Source -> Source -> Bool
Eq, Eq Source
Eq Source =>
(Source -> Source -> Ordering)
-> (Source -> Source -> Bool)
-> (Source -> Source -> Bool)
-> (Source -> Source -> Bool)
-> (Source -> Source -> Bool)
-> (Source -> Source -> Source)
-> (Source -> Source -> Source)
-> Ord Source
Source -> Source -> Bool
Source -> Source -> Ordering
Source -> Source -> Source
forall a.
Eq a =>
(a -> a -> Ordering)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> a)
-> (a -> a -> a)
-> Ord a
$ccompare :: Source -> Source -> Ordering
compare :: Source -> Source -> Ordering
$c< :: Source -> Source -> Bool
< :: Source -> Source -> Bool
$c<= :: Source -> Source -> Bool
<= :: Source -> Source -> Bool
$c> :: Source -> Source -> Bool
> :: Source -> Source -> Bool
$c>= :: Source -> Source -> Bool
>= :: Source -> Source -> Bool
$cmax :: Source -> Source -> Source
max :: Source -> Source -> Source
$cmin :: Source -> Source -> Source
min :: Source -> Source -> Source
Ord, Int -> Source -> ShowS
[Source] -> ShowS
Source -> String
(Int -> Source -> ShowS)
-> (Source -> String) -> ([Source] -> ShowS) -> Show Source
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> Source -> ShowS
showsPrec :: Int -> Source -> ShowS
$cshow :: Source -> String
show :: Source -> String
$cshowList :: [Source] -> ShowS
showList :: [Source] -> ShowS
Show)

{- | A coherent cache entry: the parsed 'PackageInfo' paired with the raw document
('CachedDoc') it was decoded from. A hit returns both, so a caller gets a typed view to
decide over and the exact bytes that produced it: the packument serve path rebuilds the
served body from the raw document and must keep its typed decision coherent with those
bytes. The store holds the raw document opaquely -- it never reads it, only weighs it
('weighCachedDoc') and hands it back to the injected adapter capabilities.
-}
data CacheEntry = CacheEntry
    { CacheEntry -> PackageInfo
entryInfo :: PackageInfo
    -- ^ The typed packument view the rules and merge reason over.
    , CacheEntry -> CachedDoc
entryRaw :: CachedDoc
    -- ^ The raw upstream document the served body is built from.
    , CacheEntry -> ContentDigest
entryDigest :: ContentDigest
    {- ^ Digest of the wire bytes both views were decoded from, computed once at the
    leader's fetch -- the public origin's contribution to the serve path's derived
    ETag, amortised across every hit on this entry.
    -}
    }
    deriving stock (CacheEntry -> CacheEntry -> Bool
(CacheEntry -> CacheEntry -> Bool)
-> (CacheEntry -> CacheEntry -> Bool) -> Eq CacheEntry
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: CacheEntry -> CacheEntry -> Bool
== :: CacheEntry -> CacheEntry -> Bool
$c/= :: CacheEntry -> CacheEntry -> Bool
/= :: CacheEntry -> CacheEntry -> Bool
Eq, Int -> CacheEntry -> ShowS
[CacheEntry] -> ShowS
CacheEntry -> String
(Int -> CacheEntry -> ShowS)
-> (CacheEntry -> String)
-> ([CacheEntry] -> ShowS)
-> Show CacheEntry
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> CacheEntry -> ShowS
showsPrec :: Int -> CacheEntry -> ShowS
$cshow :: CacheEntry -> String
show :: CacheEntry -> String
$cshowList :: [CacheEntry] -> ShowS
showList :: [CacheEntry] -> ShowS
Show)

{- | Estimate a 'CacheEntry'\'s resident footprint in bytes as a fixed multiple of its raw
document's compact-encoded byte length ('weighCachedDoc'). The resident cost (the parsed
'PackageInfo' plus the raw document) is a near-constant multiple of the document's size, so
scaling the document's encoded length estimates the footprint without measuring the parsed
structure. The encode is an @O(document)@ pass run only on a leader's insert (the cold path
after a fetch), never on a hit. The multiplier is set at the high end of the observed
resident-to-encoded ratio so the estimate is an upper bound: a memory budget must not
systematically under-count.
-}
weighCacheEntry :: CacheEntry -> Int
weighCacheEntry :: CacheEntry -> Int
weighCacheEntry CacheEntry
e = Int64 -> Int
weighEncodedBytes (CachedDoc -> Int64
weighCachedDoc (CacheEntry -> CachedDoc
entryRaw CacheEntry
e))

{- | Estimate a single-version entry's resident footprint in bytes. A present version's
'PackageDetails' is a single bounded manifest, so it is weighted at a flat per-version
figure; a cached determined absence (a negative entry) carries only a small fixed overhead.
The single-version store holds no raw document, so its weight is a fixed estimate rather than
an encoded-size multiple.
-}
weighVersion :: Maybe PackageDetails -> Int
weighVersion :: Maybe PackageDetails -> Int
weighVersion = \case
    Just PackageDetails
_ -> Int
versionEntryBytes
    Maybe PackageDetails
Nothing -> Int
negativeEntryBytes

-- Scale a raw document's encoded byte length to an estimated resident footprint,
-- through the one shared wire-to-resident model ("Ecluse.Core.Server.MemoryModel"),
-- so this weigher and the composition root's memory plan can never drift on the
-- expansion factor.
weighEncodedBytes :: Int64 -> Int
weighEncodedBytes :: Int64 -> Int
weighEncodedBytes = Int -> Int
expandWireBytes (Int -> Int) -> (Int64 -> Int) -> Int64 -> Int
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Int64 -> Int
forall a b. (Integral a, Num b) => a -> b
fromIntegral

-- The flat resident estimate for a present single-version entry (one bounded manifest) and
-- for a cached determined absence (a small negative entry).
versionEntryBytes :: Int
versionEntryBytes :: Int
versionEntryBytes = Int
16 Int -> Int -> Int
forall a. Num a => a -> a -> a
* Int
1024

negativeEntryBytes :: Int
negativeEntryBytes :: Int
negativeEntryBytes = Int
1024

{- | An assembled entry's resident footprint __is__ its strict bytes (plus a small
constant for the key and spine): unlike a parsed 'CacheEntry' there is no expanded
structure to estimate, so the budget counts what is genuinely held.
-}
weighAssembled :: ByteString -> Int
weighAssembled :: ByteString -> Int
weighAssembled ByteString
bytes = ByteString -> Int
BS.length ByteString
bytes Int -> Int -> Int
forall a. Num a => a -> a -> a
+ Int
assembledEntryOverheadBytes

assembledEntryOverheadBytes :: Int
assembledEntryOverheadBytes :: Int
assembledEntryOverheadBytes = Int
256

{- | The key a 'CacheEntry' is cached under: the upstream 'Source' paired with the
package's identity, rendered to a stable 'Text'. The package identity is distinct
from a display name so two encodings of the same scoped package share one entry, and
the source dimension keeps distinct upstreams apart; equality and ordering match
@(Source, PackageName)@ identity (the @cache@ library needs a 'Hashable' key, which
the opaque 'PackageName' does not expose, so the identity is projected to this key
here rather than via an orphan instance).
-}
newtype CacheKey = CacheKey Text
    deriving stock (CacheKey -> CacheKey -> Bool
(CacheKey -> CacheKey -> Bool)
-> (CacheKey -> CacheKey -> Bool) -> Eq CacheKey
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: CacheKey -> CacheKey -> Bool
== :: CacheKey -> CacheKey -> Bool
$c/= :: CacheKey -> CacheKey -> Bool
/= :: CacheKey -> CacheKey -> Bool
Eq, Eq CacheKey
Eq CacheKey =>
(CacheKey -> CacheKey -> Ordering)
-> (CacheKey -> CacheKey -> Bool)
-> (CacheKey -> CacheKey -> Bool)
-> (CacheKey -> CacheKey -> Bool)
-> (CacheKey -> CacheKey -> Bool)
-> (CacheKey -> CacheKey -> CacheKey)
-> (CacheKey -> CacheKey -> CacheKey)
-> Ord CacheKey
CacheKey -> CacheKey -> Bool
CacheKey -> CacheKey -> Ordering
CacheKey -> CacheKey -> CacheKey
forall a.
Eq a =>
(a -> a -> Ordering)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> a)
-> (a -> a -> a)
-> Ord a
$ccompare :: CacheKey -> CacheKey -> Ordering
compare :: CacheKey -> CacheKey -> Ordering
$c< :: CacheKey -> CacheKey -> Bool
< :: CacheKey -> CacheKey -> Bool
$c<= :: CacheKey -> CacheKey -> Bool
<= :: CacheKey -> CacheKey -> Bool
$c> :: CacheKey -> CacheKey -> Bool
> :: CacheKey -> CacheKey -> Bool
$c>= :: CacheKey -> CacheKey -> Bool
>= :: CacheKey -> CacheKey -> Bool
$cmax :: CacheKey -> CacheKey -> CacheKey
max :: CacheKey -> CacheKey -> CacheKey
$cmin :: CacheKey -> CacheKey -> CacheKey
min :: CacheKey -> CacheKey -> CacheKey
Ord, Int -> CacheKey -> ShowS
[CacheKey] -> ShowS
CacheKey -> String
(Int -> CacheKey -> ShowS)
-> (CacheKey -> String) -> ([CacheKey] -> ShowS) -> Show CacheKey
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> CacheKey -> ShowS
showsPrec :: Int -> CacheKey -> ShowS
$cshow :: CacheKey -> String
show :: CacheKey -> String
$cshowList :: [CacheKey] -> ShowS
showList :: [CacheKey] -> ShowS
Show)
    deriving newtype (Eq CacheKey
Eq CacheKey =>
(Int -> CacheKey -> Int) -> (CacheKey -> Int) -> Hashable CacheKey
Int -> CacheKey -> Int
CacheKey -> Int
forall a. Eq a => (Int -> a -> Int) -> (a -> Int) -> Hashable a
$chashWithSalt :: Int -> CacheKey -> Int
hashWithSalt :: Int -> CacheKey -> Int
$chash :: CacheKey -> Int
hash :: CacheKey -> Int
Hashable)

{- The @(source, package)@ identity rendered to a stable 'Text': the source's base URL
joined with the package's identity (not its display form). The shared prefix of both
cache keys -- the full-packument key is exactly this, the single-version key appends the
version -- so the two stores partition on the same source\/package identity. -}
keyText :: Source -> PackageName -> Text
keyText :: Source -> PackageName -> Text
keyText (Source Text
source) PackageName
name =
    Text
source
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"\x1f"
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Ecosystem -> Text
forall b a. (Show a, IsString b) => a -> b
show (PackageName -> Ecosystem
pkgEcosystem PackageName
name)
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"\x1f"
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text -> (Scope -> Text) -> Maybe Scope -> Text
forall b a. b -> (a -> b) -> Maybe a -> b
maybe Text
"" Scope -> Text
renderScope (PackageName -> Maybe Scope
pkgNamespace PackageName
name)
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"\x1f"
        Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> ShortText -> Text
TS.toText (PackageName -> ShortText
pkgCanonical PackageName
name)

{- | Project a 'Source' and a 'PackageName' to their full-packument cache key (the
source's base URL joined with the package's identity, not its display form).
-}
cacheKey :: Source -> PackageName -> CacheKey
cacheKey :: Source -> PackageName -> CacheKey
cacheKey Source
source PackageName
name = Text -> CacheKey
CacheKey (Source -> PackageName -> Text
keyText Source
source PackageName
name)

{- | The key a single-version entry is cached under: the @(source, package)@ identity
'cacheKey' uses, with the rendered 'Version' appended -- so distinct versions of one
package hold distinct entries, and the version store partitions on the same source as the
full store.
-}
newtype VersionKey = VersionKey Text
    deriving stock (VersionKey -> VersionKey -> Bool
(VersionKey -> VersionKey -> Bool)
-> (VersionKey -> VersionKey -> Bool) -> Eq VersionKey
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: VersionKey -> VersionKey -> Bool
== :: VersionKey -> VersionKey -> Bool
$c/= :: VersionKey -> VersionKey -> Bool
/= :: VersionKey -> VersionKey -> Bool
Eq, Eq VersionKey
Eq VersionKey =>
(VersionKey -> VersionKey -> Ordering)
-> (VersionKey -> VersionKey -> Bool)
-> (VersionKey -> VersionKey -> Bool)
-> (VersionKey -> VersionKey -> Bool)
-> (VersionKey -> VersionKey -> Bool)
-> (VersionKey -> VersionKey -> VersionKey)
-> (VersionKey -> VersionKey -> VersionKey)
-> Ord VersionKey
VersionKey -> VersionKey -> Bool
VersionKey -> VersionKey -> Ordering
VersionKey -> VersionKey -> VersionKey
forall a.
Eq a =>
(a -> a -> Ordering)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> Bool)
-> (a -> a -> a)
-> (a -> a -> a)
-> Ord a
$ccompare :: VersionKey -> VersionKey -> Ordering
compare :: VersionKey -> VersionKey -> Ordering
$c< :: VersionKey -> VersionKey -> Bool
< :: VersionKey -> VersionKey -> Bool
$c<= :: VersionKey -> VersionKey -> Bool
<= :: VersionKey -> VersionKey -> Bool
$c> :: VersionKey -> VersionKey -> Bool
> :: VersionKey -> VersionKey -> Bool
$c>= :: VersionKey -> VersionKey -> Bool
>= :: VersionKey -> VersionKey -> Bool
$cmax :: VersionKey -> VersionKey -> VersionKey
max :: VersionKey -> VersionKey -> VersionKey
$cmin :: VersionKey -> VersionKey -> VersionKey
min :: VersionKey -> VersionKey -> VersionKey
Ord, Int -> VersionKey -> ShowS
[VersionKey] -> ShowS
VersionKey -> String
(Int -> VersionKey -> ShowS)
-> (VersionKey -> String)
-> ([VersionKey] -> ShowS)
-> Show VersionKey
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> VersionKey -> ShowS
showsPrec :: Int -> VersionKey -> ShowS
$cshow :: VersionKey -> String
show :: VersionKey -> String
$cshowList :: [VersionKey] -> ShowS
showList :: [VersionKey] -> ShowS
Show)
    deriving newtype (Eq VersionKey
Eq VersionKey =>
(Int -> VersionKey -> Int)
-> (VersionKey -> Int) -> Hashable VersionKey
Int -> VersionKey -> Int
VersionKey -> Int
forall a. Eq a => (Int -> a -> Int) -> (a -> Int) -> Hashable a
$chashWithSalt :: Int -> VersionKey -> Int
hashWithSalt :: Int -> VersionKey -> Int
$chash :: VersionKey -> Int
hash :: VersionKey -> Int
Hashable)

versionKey :: Source -> PackageName -> Version -> VersionKey
versionKey :: Source -> PackageName -> Version -> VersionKey
versionKey Source
source PackageName
name Version
version = Text -> VersionKey
VersionKey (Source -> PackageName -> Text
keyText Source
source PackageName
name Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Text
"\x1f" Text -> Text -> Text
forall a. Semigroup a => a -> a -> a
<> Version -> Text
renderVersion Version
version)

{- | The metadata-cache handle: the three single-flight stores (the full-packument
cache, the single-version cache, and the assembled-representation store). Opaque:
built with 'newMetadataCache' and reached only through the accessors. Lives in the
composition root (one per process), so every request shares the same caches and their
connection-collapsing.
-}
data MetadataCache = MetadataCache
    { MetadataCache -> SingleFlight MetadataError CacheKey CacheEntry
mcFull :: SingleFlight MetadataError CacheKey CacheEntry
    -- ^ The full-packument store, keyed by @(source, package)@.
    , MetadataCache
-> SingleFlight MetadataError VersionKey (Maybe PackageDetails)
mcVersion :: SingleFlight MetadataError VersionKey (Maybe PackageDetails)
    {- ^ The single-version store, keyed by @(source, package, version)@, holding one
    version's 'PackageDetails' (or its determined absence), written only by the
    single-version path, never the full path.
    -}
    , MetadataCache -> SingleFlight Void Text ByteString
mcAssembled :: SingleFlight Void Text ByteString
    {- ^ The assembled-representation store: the encoded served document, keyed by its
    derived validator's rendered form (a content address over every serve input; see
    the module header), written and read only by the packument serve tail. The
    'Void' error slot states in the type that the assembled render has no domain
    failure: a bottom during the render is an invariant break, not an outcome.
    -}
    }

{- | Build a metadata cache from its configuration: the full-packument store, the
single-version store, and the assembled-representation store, each over the same
TTL but sized from its __own__ sub-budget (the three used to share one bound,
which tripled the intended cache footprint in the worst case).
-}
newMetadataCache :: CacheConfig -> IO MetadataCache
newMetadataCache :: CacheConfig -> IO MetadataCache
newMetadataCache CacheConfig
cfg =
    SingleFlight MetadataError CacheKey CacheEntry
-> SingleFlight MetadataError VersionKey (Maybe PackageDetails)
-> SingleFlight Void Text ByteString
-> MetadataCache
MetadataCache
        (SingleFlight MetadataError CacheKey CacheEntry
 -> SingleFlight MetadataError VersionKey (Maybe PackageDetails)
 -> SingleFlight Void Text ByteString
 -> MetadataCache)
-> IO (SingleFlight MetadataError CacheKey CacheEntry)
-> IO
     (SingleFlight MetadataError VersionKey (Maybe PackageDetails)
      -> SingleFlight Void Text ByteString -> MetadataCache)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> StoreBudget
-> (CacheEntry -> Int)
-> IO (SingleFlight MetadataError CacheKey CacheEntry)
forall v e k. StoreBudget -> (v -> Int) -> IO (SingleFlight e k v)
newStore (CacheConfig -> StoreBudget
cacheFullBudget CacheConfig
cfg) CacheEntry -> Int
weighCacheEntry
        IO
  (SingleFlight MetadataError VersionKey (Maybe PackageDetails)
   -> SingleFlight Void Text ByteString -> MetadataCache)
-> IO
     (SingleFlight MetadataError VersionKey (Maybe PackageDetails))
-> IO (SingleFlight Void Text ByteString -> MetadataCache)
forall a b. IO (a -> b) -> IO a -> IO b
forall (f :: * -> *) a b. Applicative f => f (a -> b) -> f a -> f b
<*> StoreBudget
-> (Maybe PackageDetails -> Int)
-> IO
     (SingleFlight MetadataError VersionKey (Maybe PackageDetails))
forall v e k. StoreBudget -> (v -> Int) -> IO (SingleFlight e k v)
newStore (CacheConfig -> StoreBudget
cacheVersionBudget CacheConfig
cfg) Maybe PackageDetails -> Int
weighVersion
        IO (SingleFlight Void Text ByteString -> MetadataCache)
-> IO (SingleFlight Void Text ByteString) -> IO MetadataCache
forall a b. IO (a -> b) -> IO a -> IO b
forall (f :: * -> *) a b. Applicative f => f (a -> b) -> f a -> f b
<*> StoreBudget
-> (ByteString -> Int) -> IO (SingleFlight Void Text ByteString)
forall v e k. StoreBudget -> (v -> Int) -> IO (SingleFlight e k v)
newStore (CacheConfig -> StoreBudget
cacheAssembledBudget CacheConfig
cfg) ByteString -> Int
weighAssembled
  where
    newStore :: StoreBudget -> (v -> Int) -> IO (SingleFlight e k v)
    newStore :: forall v e k. StoreBudget -> (v -> Int) -> IO (SingleFlight e k v)
newStore StoreBudget
budget = NominalDiffTime
-> Int -> Int -> (v -> Int) -> IO (SingleFlight e k v)
forall v e k.
NominalDiffTime
-> Int -> Int -> (v -> Int) -> IO (SingleFlight e k v)
newSingleFlight (CacheConfig -> NominalDiffTime
cacheTtl CacheConfig
cfg) (StoreBudget -> Int
sbMaxEntries StoreBudget
budget) (StoreBudget -> Int
sbMaxBytes StoreBudget
budget)

{- | Resolve a package's metadata from one upstream 'Source', reusing the cache and
collapsing concurrent misses.

On a fresh, unexpired hit the cached 'CacheEntry' is returned and the fetch action
is never run. On a miss the action runs exactly once even under concurrent callers:
the first installs an in-flight marker and fetches, the others wait on its result.
A successful fetch is cached (subject to the TTL and size bound); a failed fetch
caches __nothing__ (so a transient upstream error does not poison the cache) and its
typed 'Left' is handed to every waiter, so a coalesced follower sees exactly the
fault the leader saw.

A claimed in-flight slot is __always eventually filled and de-registered__, even if
the leader is hit by an async exception (a request timeout, a killed handler thread)
between claiming the slot and completing: the claim commits under a 'mask' and the
leader's run is handed straight to 'Ecluse.Core.InFlight.guardInFlight', which frees the
slot on every exit and, on an escape before the marker is filled, hands that error
to every waiting follower rather than leaving them parked forever. This closes the
single-flight orphan window (without it, a cancelled leader would wedge that
@(source, package)@ key until restart). A follower receiving an orphaned marker
re-evaluates the resolve when the leader was cancelled (async), re-entering
interruptibly and counting its miss only once, and re-raises when the leader escaped
synchronously: the fetch's contract is total, so a synchronous escape is an invariant
break for the outer boundary, never laundered into the typed channel. A follower's own
wait on the marker stays interruptible.

The 'Source' partitions the cache: distinct upstreams of the same package resolve
under distinct keys and never cross-contaminate. The fetch action supplies the origin's
own credential, so reading through one source never blurs another's trust posture.
Under the default @passthrough@ strategy only the anonymous public origin is resolved
here: the trusted private origin is the per-client authority and is fetched per request,
never cached, so a shared entry can never serve one client another's private document.

The result is always re-decided by the caller's rules on each request -- only the
fetch+parse is memoised, never the verdict.

Each resolution records the @ecluse.metadata_cache.requests@ hit\/miss counter (a
coalescing follower counts as a miss, like the leader it waits on), and a leader's
insert refreshes the @ecluse.metadata_cache.entries@ occupancy gauge and the
@ecluse.metadata_cache.resident_bytes@ residency gauge.
-}
resolveMetadata :: MetricsPort -> MetadataCache -> Source -> PackageName -> IO (Either MetadataError CacheEntry) -> IO (Either MetadataError CacheEntry)
resolveMetadata :: MetricsPort
-> MetadataCache
-> Source
-> PackageName
-> IO (Either MetadataError CacheEntry)
-> IO (Either MetadataError CacheEntry)
resolveMetadata = IO ()
-> MetricsPort
-> MetadataCache
-> Source
-> PackageName
-> IO (Either MetadataError CacheEntry)
-> IO (Either MetadataError CacheEntry)
resolveMetadataWith (() -> IO ()
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure ())

{- | As 'resolveMetadata', but with a hook run on the leading thread at the
single-flight claim → fetch-runner handoff: the window between the STM transaction
committing the in-flight claim and the leader's exception guard taking ownership of
the marker. It exists only so a test can deterministically park a leader in that
window and cancel it there, exercising the orphan-window guarantee; production always
passes @pure ()@ via 'resolveMetadata'.
-}
resolveMetadataWith :: IO () -> MetricsPort -> MetadataCache -> Source -> PackageName -> IO (Either MetadataError CacheEntry) -> IO (Either MetadataError CacheEntry)
resolveMetadataWith :: IO ()
-> MetricsPort
-> MetadataCache
-> Source
-> PackageName
-> IO (Either MetadataError CacheEntry)
-> IO (Either MetadataError CacheEntry)
resolveMetadataWith IO ()
afterClaim MetricsPort
metrics MetadataCache
cache Source
source PackageName
name =
    IO ()
-> (CacheResult -> IO ())
-> (CacheOccupancy -> IO ())
-> SingleFlight MetadataError CacheKey CacheEntry
-> CacheKey
-> IO (Either MetadataError CacheEntry)
-> IO (Either MetadataError CacheEntry)
forall k e v.
(Hashable k, Ord k) =>
IO ()
-> (CacheResult -> IO ())
-> (CacheOccupancy -> IO ())
-> SingleFlight e k v
-> k
-> IO (Either e v)
-> IO (Either e v)
resolveSingleFlight
        IO ()
afterClaim
        (MetricsPort -> CacheResult -> IO ()
mpCacheRequest MetricsPort
metrics)
        ( \CacheOccupancy
occ -> do
            MetricsPort -> Int -> IO ()
mpCacheEntries MetricsPort
metrics (CacheOccupancy -> Int
occEntries CacheOccupancy
occ)
            MetricsPort -> Int -> IO ()
mpCacheResidentBytes MetricsPort
metrics (CacheOccupancy -> Int
occBytes CacheOccupancy
occ)
        )
        (MetadataCache -> SingleFlight MetadataError CacheKey CacheEntry
mcFull MetadataCache
cache)
        (Source -> PackageName -> CacheKey
cacheKey Source
source PackageName
name)

{- | Resolve __one version's__ 'PackageDetails' (or its determined absence) from the
single-version cache, leading a selective fetch on a miss and collapsing concurrent misses
exactly as 'resolveMetadata' does for the full packument. The cached value is the
@'Maybe' 'PackageDetails'@ the fetch yields, so a version determined __absent__ over sound
metadata is cached as 'Nothing' (a negative entry) and re-served without a re-fetch within
the TTL.

This writes to the single-version store only, never the full-packument store, so a cold
tarball gate's selective parse cannot materialise a whole packument into the shared full
cache. Unlike 'resolveMetadata', the single-version store records no hit\/miss counter; a
leader's insert does refresh the single-version residency gauge
(@ecluse.metadata_cache.version.resident_bytes@), so the byte budget that bounds both
stores is observable on each.
-}
resolveVersion :: MetricsPort -> MetadataCache -> Source -> PackageName -> Version -> IO (Either MetadataError (Maybe PackageDetails)) -> IO (Either MetadataError (Maybe PackageDetails))
resolveVersion :: MetricsPort
-> MetadataCache
-> Source
-> PackageName
-> Version
-> IO (Either MetadataError (Maybe PackageDetails))
-> IO (Either MetadataError (Maybe PackageDetails))
resolveVersion = IO ()
-> MetricsPort
-> MetadataCache
-> Source
-> PackageName
-> Version
-> IO (Either MetadataError (Maybe PackageDetails))
-> IO (Either MetadataError (Maybe PackageDetails))
resolveVersionWith (() -> IO ()
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure ())

{- | As 'resolveVersion', with the single-flight claim → fetch-runner handoff hook
'resolveMetadataWith' exposes, for the same orphan-window test (production passes @pure ()@
via 'resolveVersion').
-}
resolveVersionWith :: IO () -> MetricsPort -> MetadataCache -> Source -> PackageName -> Version -> IO (Either MetadataError (Maybe PackageDetails)) -> IO (Either MetadataError (Maybe PackageDetails))
resolveVersionWith :: IO ()
-> MetricsPort
-> MetadataCache
-> Source
-> PackageName
-> Version
-> IO (Either MetadataError (Maybe PackageDetails))
-> IO (Either MetadataError (Maybe PackageDetails))
resolveVersionWith IO ()
afterClaim MetricsPort
metrics MetadataCache
cache Source
source PackageName
name Version
version =
    IO ()
-> (CacheResult -> IO ())
-> (CacheOccupancy -> IO ())
-> SingleFlight MetadataError VersionKey (Maybe PackageDetails)
-> VersionKey
-> IO (Either MetadataError (Maybe PackageDetails))
-> IO (Either MetadataError (Maybe PackageDetails))
forall k e v.
(Hashable k, Ord k) =>
IO ()
-> (CacheResult -> IO ())
-> (CacheOccupancy -> IO ())
-> SingleFlight e k v
-> k
-> IO (Either e v)
-> IO (Either e v)
resolveSingleFlight
        IO ()
afterClaim
        (IO () -> CacheResult -> IO ()
forall a b. a -> b -> a
const IO ()
forall (f :: * -> *). Applicative f => f ()
pass)
        (MetricsPort -> Int -> IO ()
mpVersionCacheResidentBytes MetricsPort
metrics (Int -> IO ())
-> (CacheOccupancy -> Int) -> CacheOccupancy -> IO ()
forall b c a. (b -> c) -> (a -> b) -> a -> c
. CacheOccupancy -> Int
occBytes)
        (MetadataCache
-> SingleFlight MetadataError VersionKey (Maybe PackageDetails)
mcVersion MetadataCache
cache)
        (Source -> PackageName -> Version -> VersionKey
versionKey Source
source PackageName
name Version
version)

{- | Resolve the __assembled representation__ for one derived validator, leading the
render (assemble + encode) on a miss and collapsing concurrent identical renders,
exactly as 'resolveMetadata' does for a fetch.

The key is the rendered derived 'Ecluse.Core.Server.Conditional.ETag' -- a content
address over every input the served document is a function of -- so a hit is
byte-for-byte the document this request's own inputs would deterministically produce:
the store can never serve stale bytes (changed inputs miss by construction) and never
crosses a client boundary (a different private view is a different key; see the
module header). Under the TTL-zero configuration the store degrades to pure
single-flight coalescing, the same behaviour as the sibling stores.

Like the single-version store it records no hit\/miss counter; a leader's insert
refreshes the @ecluse.metadata_cache.assembled.resident_bytes@ residency gauge, so
the byte budget's third occupant is observable alongside the other two.

The store's error slot is 'Void' -- the render has no domain failure -- so the
resolve is folded back to a plain 'IO' 'ByteString' here ('absurd' discharges the
impossible 'Left'), keeping the serve tail's call shape unchanged.
-}
resolveAssembled :: MetricsPort -> MetadataCache -> Text -> IO ByteString -> IO ByteString
resolveAssembled :: MetricsPort
-> MetadataCache -> Text -> IO ByteString -> IO ByteString
resolveAssembled MetricsPort
metrics MetadataCache
cache Text
key IO ByteString
render =
    (Void -> ByteString)
-> (ByteString -> ByteString)
-> Either Void ByteString
-> ByteString
forall a c b. (a -> c) -> (b -> c) -> Either a b -> c
either Void -> ByteString
forall a. Void -> a
absurd ByteString -> ByteString
forall a. a -> a
id
        (Either Void ByteString -> ByteString)
-> IO (Either Void ByteString) -> IO ByteString
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> IO ()
-> (CacheResult -> IO ())
-> (CacheOccupancy -> IO ())
-> SingleFlight Void Text ByteString
-> Text
-> IO (Either Void ByteString)
-> IO (Either Void ByteString)
forall k e v.
(Hashable k, Ord k) =>
IO ()
-> (CacheResult -> IO ())
-> (CacheOccupancy -> IO ())
-> SingleFlight e k v
-> k
-> IO (Either e v)
-> IO (Either e v)
resolveSingleFlight
            (() -> IO ()
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure ())
            (IO () -> CacheResult -> IO ()
forall a b. a -> b -> a
const IO ()
forall (f :: * -> *). Applicative f => f ()
pass)
            (MetricsPort -> Int -> IO ()
mpAssembledCacheResidentBytes MetricsPort
metrics (Int -> IO ())
-> (CacheOccupancy -> Int) -> CacheOccupancy -> IO ()
forall b c a. (b -> c) -> (a -> b) -> a -> c
. CacheOccupancy -> Int
occBytes)
            (MetadataCache -> SingleFlight Void Text ByteString
mcAssembled MetadataCache
cache)
            Text
key
            (ByteString -> Either Void ByteString
forall a b. b -> Either a b
Right (ByteString -> Either Void ByteString)
-> IO ByteString -> IO (Either Void ByteString)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> IO ByteString
render)

{- | Look up a package's cached full-packument entry for one 'Source' without fetching on a
miss and __without bumping recency__: the cache's read-only view. Two readers share it: the
inspection and test probes, and the hybrid serve path's step-2 full-packument consult (the
tarball gate selecting one version from a warm entry). That consult stays read-only on
purpose: the full store's recency is driven by the packument @GET@'s own 'resolveMetadata'
hit, so the gate's select need not bump it, unlike the single-version store whose only
steady-state read is 'cachedVersion'. A 'Nothing' is a miss or an expired entry; this never
triggers a fetch and never collapses (use 'resolveMetadata' for the serve path).
-}
cachedMetadata :: MetadataCache -> Source -> PackageName -> IO (Maybe CacheEntry)
cachedMetadata :: MetadataCache -> Source -> PackageName -> IO (Maybe CacheEntry)
cachedMetadata MetadataCache
cache Source
source PackageName
name = SingleFlight MetadataError CacheKey CacheEntry
-> CacheKey -> IO (Maybe CacheEntry)
forall k e v. Hashable k => SingleFlight e k v -> k -> IO (Maybe v)
lookupStore (MetadataCache -> SingleFlight MetadataError CacheKey CacheEntry
mcFull MetadataCache
cache) (Source -> PackageName -> CacheKey
cacheKey Source
source PackageName
name)

{- | Look up a single-version cached entry for one @(source, package, version)@ without
fetching on a miss, __bumping the entry's recency on a hit__: the hybrid serve path's step-1
version consult before it leads a selective fetch. This read is the version store's only
steady-state access (a hit here short-circuits before 'resolveVersion' and its recency-bumping
hit), so without the bump a warm version entry would never refresh its recency and would age
out of the least-recently-used eviction in insert order. The outer 'Maybe' is the cache
hit\/miss (an expired or absent entry is 'Nothing'); the inner @'Maybe' 'PackageDetails'@ is
the cached result (a version determined absent is a cached @'Just' 'Nothing'@). Never fetches
and never collapses (use 'resolveVersion' to lead a selective fetch).
-}
cachedVersion :: MetadataCache -> Source -> PackageName -> Version -> IO (Maybe (Maybe PackageDetails))
cachedVersion :: MetadataCache
-> Source
-> PackageName
-> Version
-> IO (Maybe (Maybe PackageDetails))
cachedVersion MetadataCache
cache Source
source PackageName
name Version
version = SingleFlight MetadataError VersionKey (Maybe PackageDetails)
-> VersionKey -> IO (Maybe (Maybe PackageDetails))
forall k e v. Hashable k => SingleFlight e k v -> k -> IO (Maybe v)
lookupStoreTouching (MetadataCache
-> SingleFlight MetadataError VersionKey (Maybe PackageDetails)
mcVersion MetadataCache
cache) (Source -> PackageName -> Version -> VersionKey
versionKey Source
source PackageName
name Version
version)