packages feed

relay-pagination-conformance-0.1.0.0: src/Relay/Pagination/Conformance/Walk.hs

-- | Page walker: fetch every page of a paginated endpoint in one direction,
-- following cursors, defended against paginators that never terminate.
--
-- The walker's only handle on the system under test is a 'FetchPage'
-- callback, so it works over a hasql session, an HTTP client, or a pure
-- fake. Two independent defenses bound the walk (see 'WalkFailure'): a set
-- of every cursor already followed catches genuine cycles, and a page cap
-- catches non-cycling divergence (a paginator that mints a fresh bogus
-- cursor every page). Both failure modes exist in the wild and neither
-- check subsumes the other.
module Relay.Pagination.Conformance.Walk
  ( FetchPage,
    WalkConfig (..),
    defaultWalkConfig,
    WalkFailure (..),
    WalkedPage (..),
    Walk (..),
    walkForward,
    walkBackward,
    walkForwardWith,
    walkBackwardWith,
  )
where

import Data.ByteString (ByteString)
import Data.Set qualified as Set
import Relay.Pagination

-- | How the conformance walker fetches a page from the system under test.
type FetchPage row = PageRequest -> IO (Connection row)

-- | Walker limits. 'maxWalkPages' bounds the number of pages fetched in one
-- walk; a conforming endpoint with more pages than this cannot be checked
-- without raising the cap.
data WalkConfig = WalkConfig
  { maxWalkPages :: !Int
  }
  deriving stock (Eq, Show)

-- | 10 000 pages.
defaultWalkConfig :: WalkConfig
defaultWalkConfig = WalkConfig {maxWalkPages = 10000}

-- | Why a walk aborted instead of reaching the end of the result set.
data WalkFailure
  = -- | The cursor the walker was about to follow (raw wire bytes) had
    -- already been followed earlier in this walk: the paginator re-issued
    -- an old cursor and the walk would cycle. The 'Int' is the index of the
    -- page the walker was about to fetch.
    WalkCursorLoop !Int !ByteString
  | -- | The walk did not terminate within 'maxWalkPages' pages (the 'Int'
    -- is that cap): either the result set is genuinely bigger than the cap,
    -- or the paginator diverges by minting fresh cursors forever.
    WalkPageLimitExceeded !Int
  | -- | The page at this index claims a continuation ('hasNextPage' forward,
    -- 'hasPreviousPage' backward) but provides no cursor to continue from
    -- ('endCursor' / 'startCursor' is 'Nothing').
    WalkMissingCursor !Int
  deriving stock (Eq, Show)

-- | One fetched page plus the exact request that produced it — the evidence
-- the invariant checker needs.
data WalkedPage row = WalkedPage
  { pageIndex :: !Int,
    requestSent :: !PageRequest,
    page :: !(Connection row)
  }
  deriving stock (Show)

-- | A completed walk.
data Walk row = Walk
  { -- | All edges in canonical order, regardless of walk direction.
    walkEdges :: ![Edge row],
    -- | Every page in visit order ('pageIndex' ascending).
    walkPages :: ![WalkedPage row]
  }
  deriving stock (Show)

-- | Walk forward from the beginning: first request has no cursor, then
-- follow 'endCursor' while 'hasNextPage' is true.
walkForward :: FetchPage row -> Int -> IO (Either WalkFailure (Walk row))
walkForward = walkForwardWith defaultWalkConfig

-- | Walk backward from the end: first request has no cursor, then follow
-- 'startCursor' while 'hasPreviousPage' is true.
walkBackward :: FetchPage row -> Int -> IO (Either WalkFailure (Walk row))
walkBackward = walkBackwardWith defaultWalkConfig

-- | 'walkForward' with explicit limits.
walkForwardWith :: WalkConfig -> FetchPage row -> Int -> IO (Either WalkFailure (Walk row))
walkForwardWith = walkWith Forward

-- | 'walkBackward' with explicit limits.
walkBackwardWith :: WalkConfig -> FetchPage row -> Int -> IO (Either WalkFailure (Walk row))
walkBackwardWith = walkWith Backward

walkWith :: Direction -> WalkConfig -> FetchPage row -> Int -> IO (Either WalkFailure (Walk row))
walkWith dir config fetchPage size = go 0 Set.empty Nothing []
  where
    go i followed currentCursor visited
      | i >= maxWalkPages config = pure (Left (WalkPageLimitExceeded (maxWalkPages config)))
      | otherwise = do
          let request = PageRequest {pageSize = size, direction = dir, cursor = currentCursor}
          fetched <- fetchPage request
          let walked = WalkedPage {pageIndex = i, requestSent = request, page = fetched}
              visited' = walked : visited
              info = pageInfo fetched
              (continues, nextCursor) = case dir of
                Forward -> (hasNextPage info, endCursor info)
                Backward -> (hasPreviousPage info, startCursor info)
          if not continues
            then pure (Right (assemble visited'))
            else case nextCursor of
              Nothing -> pure (Left (WalkMissingCursor i))
              Just next@(Cursor bytes)
                | Set.member bytes followed -> pure (Left (WalkCursorLoop (i + 1) bytes))
                | otherwise -> go (i + 1) (Set.insert bytes followed) (Just next) visited'

    -- Each page is internally canonical in both directions. A backward walk
    -- visits pages last-to-first, so concatenating pages in *reverse visit
    -- order* restores the full canonical sequence; the prepend-accumulated
    -- `visited` list is already in that order. A forward walk needs visit
    -- order, i.e. `reverse visited`.
    assemble visited =
      let visitOrder = reverse visited
          canonicalPages = case dir of
            Forward -> visitOrder
            Backward -> visited
       in Walk
            { walkEdges = concatMap (\w -> edges (page w)) canonicalPages,
              walkPages = visitOrder
            }