diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2024, haskell-works
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,1 @@
+# rds-data
diff --git a/app/App/AWS/Env.hs b/app/App/AWS/Env.hs
new file mode 100644
--- /dev/null
+++ b/app/App/AWS/Env.hs
@@ -0,0 +1,89 @@
+{-# LANGUAGE BlockArguments    #-}
+{-# LANGUAGE DataKinds         #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications  #-}
+
+module App.AWS.Env
+  ( awsLogger
+  , mkEnv
+  , newAwsLogger
+  , setEnvEndpoint
+  ) where
+
+import           Control.Concurrent         (myThreadId)
+import           Control.Monad              (forM_, when)
+import           Data.ByteString            (ByteString)
+import           Data.ByteString.Builder    (toLazyByteString)
+import           Data.Function              ((&))
+import           Data.Generics.Product.Any  (the)
+import           Data.RdsData.Internal.Show (tshow)
+import           Lens.Micro                 ((%~), (.~))
+import           Network.HTTP.Client        (HttpException (..),
+                                             HttpExceptionContent (..))
+
+import qualified Amazonka                   as AWS
+import qualified App.Console                as CIO
+import qualified Data.ByteString            as BS
+import qualified Data.ByteString.Lazy       as L
+import qualified Data.ByteString.Lazy       as LBS
+import qualified Data.ByteString.Lazy.Char8 as LC8
+import qualified Data.Text.Encoding         as T
+import qualified System.IO                  as IO
+
+setEnvEndpoint :: Maybe (ByteString, Int, Bool) -> IO AWS.Env -> IO AWS.Env
+setEnvEndpoint mHostEndpoint getEnv = do
+  env <- getEnv
+  case mHostEndpoint of
+    Just (host, port, ssl) ->
+      pure $ env
+        & the @"overrides" .~ \svc -> do
+            svc & the @"endpoint" %~ \mkEndpoint region -> do
+              mkEndpoint region
+                & the @"host" .~ host
+                & the @"port" .~ port
+                & the @"secure" .~ ssl
+    Nothing -> pure env
+
+mkEnv :: AWS.Region -> (AWS.LogLevel -> LBS.ByteString -> IO ()) -> IO AWS.Env
+mkEnv region lg = do
+  lgr <- newAwsLogger lg
+  discoveredEnv <- AWS.newEnv AWS.discover
+
+  pure discoveredEnv
+    { AWS.logger = lgr
+    , AWS.region = region
+    , AWS.retryCheck = retryPolicy 5
+    }
+
+newAwsLogger :: Monad m => (AWS.LogLevel -> LBS.ByteString -> IO ()) -> m AWS.Logger
+newAwsLogger lg = return $ \y b ->
+  let lazyMsg = toLazyByteString b
+  in case L.toStrict lazyMsg of
+      msg | BS.isInfixOf "404 Not Found" msg    -> lg AWS.Debug lazyMsg
+      msg | BS.isInfixOf "304 Not Modified" msg -> lg AWS.Debug lazyMsg
+      _                                         -> lg y lazyMsg
+
+retryPolicy :: Int -> Int -> AWS.HttpException -> Bool
+retryPolicy maxNum attempt ex = (attempt <= maxNum) && shouldRetry ex
+
+shouldRetry :: AWS.HttpException -> Bool
+shouldRetry ex = case ex of
+  HttpExceptionRequest _ ctx -> case ctx of
+    ResponseTimeout          -> True
+    ConnectionTimeout        -> True
+    ConnectionFailure _      -> True
+    InvalidChunkHeaders      -> True
+    ConnectionClosed         -> True
+    InternalException _      -> True
+    NoResponseDataReceived   -> True
+    ResponseBodyTooShort _ _ -> True
+    _                        -> False
+  _ -> False
+
+awsLogger :: Maybe AWS.LogLevel -> AWS.LogLevel -> LC8.ByteString -> IO ()
+awsLogger maybeConfigLogLevel msgLogLevel message =
+  forM_ maybeConfigLogLevel $ \configLogLevel ->
+    when (msgLogLevel <= configLogLevel) do
+      threadId <- myThreadId
+      CIO.hPutStrLn IO.stderr $ "[" <> tshow msgLogLevel <> "] [tid: " <> tshow threadId <> "]"  <> text
+  where text = T.decodeUtf8 $ LBS.toStrict message
diff --git a/app/App/Cli/Options.hs b/app/App/Cli/Options.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Options.hs
@@ -0,0 +1,100 @@
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE OverloadedStrings     #-}
+
+module App.Cli.Options where
+
+import           App.Options
+import           Control.Applicative
+import           Data.ByteString     (ByteString)
+
+import qualified Amazonka.Data       as AWS
+import qualified App.Cli.Types       as CLI
+import           Data.RdsData.Aws
+import qualified Data.Text           as T
+import qualified Options.Applicative as OA
+
+opts :: OA.ParserInfo CLI.Cmd
+opts = OA.info (pCmds <**> OA.helper) $ mconcat
+  [ OA.fullDesc
+  , OA.header $ mconcat
+    [ "rds-data"
+    ]
+  ]
+
+pCmds :: OA.Parser CLI.Cmd
+pCmds =
+  asum
+    [ subParser "up"
+        $ OA.info (CLI.CmdOfUpCmd <$> pUpCmd)
+        $ OA.progDesc "Up command."
+    , subParser "down"
+        $ OA.info (CLI.CmdOfDownCmd <$> pDownCmd)
+        $ OA.progDesc "Down command."
+    , subParser "local-stack"
+        $ OA.info (CLI.CmdOfLocalStackCmd <$> pLocalStackCmd)
+        $ OA.progDesc "Launch a local-stack RDS cluster."
+    ]
+
+pStatementContext :: OA.Parser StatementContext
+pStatementContext =
+  StatementContext
+    <$> do  OA.strOption $ mconcat
+              [ OA.long "resource-arn"
+              , OA.help "Resource ARN"
+              , OA.metavar "ARN"
+              ]
+    <*> do  OA.strOption $ mconcat
+              [ OA.long "secret-arn"
+              , OA.help "Secret ARN"
+              , OA.metavar "ARN"
+              ]
+    <*> do  optional
+              ( OA.strOption $ mconcat
+                [ OA.long "database"
+                , OA.help "Database"
+                , OA.metavar "DATABASE"
+                ]
+              )
+pUpCmd :: OA.Parser CLI.UpCmd
+pUpCmd =
+  CLI.UpCmd
+    <$> pStatementContext
+    <*> do  OA.strOption $ mconcat
+              [ OA.long "migration-file"
+              , OA.help "Migration File"
+              , OA.metavar "FILE"
+              ]
+
+pDownCmd :: OA.Parser CLI.DownCmd
+pDownCmd =
+  CLI.DownCmd
+    <$> pStatementContext
+    <*> do  OA.strOption $ mconcat
+              [ OA.long "migration-file"
+              , OA.help "Migration File"
+              , OA.metavar "FILE"
+              ]
+
+pLocalStackCmd :: OA.Parser CLI.LocalStackCmd
+pLocalStackCmd =
+  pure CLI.LocalStackCmd
+
+parseEndpoint :: OA.Parser (ByteString, Int, Bool)
+parseEndpoint =
+  (,,)
+    <$> do  OA.option (OA.eitherReader (AWS.fromText . T.pack)) $ mconcat
+              [ OA.long "host-name-override"
+              , OA.help "Override the host name (default: s3.amazonaws.com)"
+              , OA.metavar "HOST_NAME"
+              ]
+    <*> do  OA.option OA.auto $ mconcat
+              [ OA.long "host-port-override"
+              , OA.help "Override the host port"
+              , OA.metavar "HOST_PORT"
+              ]
+    <*> do  OA.option OA.auto $ mconcat
+              [ OA.long "host-ssl-override"
+              , OA.help "Override the host SSL"
+              , OA.metavar "HOST_SSL"
+              ]
diff --git a/app/App/Cli/Run.hs b/app/App/Cli/Run.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Run.hs
@@ -0,0 +1,26 @@
+{-# LANGUAGE BlockArguments        #-}
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase            #-}
+
+module App.Cli.Run
+  ( runCmd
+  ) where
+
+import           App.Cli.Run.BatchExecuteStatement
+import           App.Cli.Run.Down
+import           App.Cli.Run.Example
+import           App.Cli.Run.ExecuteStatement
+import           App.Cli.Run.LocalStack
+import           App.Cli.Run.Up
+
+import qualified App.Cli.Types                     as CLI
+
+runCmd :: CLI.Cmd -> IO ()
+runCmd = \case
+  CLI.CmdOfBatchExecuteStatementCmd cmd -> runBatchExecuteStatementCmd cmd
+  CLI.CmdOfDownCmd cmd                  -> runDownCmd cmd
+  CLI.CmdOfExampleCmd cmd               -> runExampleCmd cmd
+  CLI.CmdOfExecuteStatementCmd cmd      -> runExecuteStatementCmd cmd
+  CLI.CmdOfLocalStackCmd cmd            -> runLocalStackCmd cmd
+  CLI.CmdOfUpCmd cmd                    -> runUpCmd cmd
diff --git a/app/App/Cli/Run/BatchExecuteStatement.hs b/app/App/Cli/Run/BatchExecuteStatement.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Run/BatchExecuteStatement.hs
@@ -0,0 +1,12 @@
+{-# LANGUAGE BlockArguments        #-}
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+
+module App.Cli.Run.BatchExecuteStatement
+  ( runBatchExecuteStatementCmd
+  ) where
+
+import qualified App.Cli.Types as CLI
+
+runBatchExecuteStatementCmd :: CLI.BatchExecuteStatementCmd -> IO ()
+runBatchExecuteStatementCmd _ = pure ()
diff --git a/app/App/Cli/Run/Down.hs b/app/App/Cli/Run/Down.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Run/Down.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE FlexibleContexts      #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE TypeApplications      #-}
+{-# LANGUAGE TypeOperators         #-}
+
+{- HLINT ignore "Redundant id" -}
+{- HLINT ignore "Use let" -}
+
+module App.Cli.Run.Down
+  ( runDownCmd
+  ) where
+
+import           Data.Generics.Product.Any
+import           Lens.Micro
+
+import qualified Amazonka                        as AWS
+import qualified App.Cli.Types                   as CLI
+import qualified App.Console                     as T
+import           Data.RdsData.Aws
+import           Data.RdsData.Internal.Show
+import           Data.RdsData.Polysemy.Core
+import           Data.RdsData.Polysemy.Error
+import           Data.RdsData.Polysemy.Migration
+import           HaskellWorks.Polysemy
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Polysemy.File
+import           HaskellWorks.Polysemy.Log
+import           HaskellWorks.Prelude
+import           Polysemy.Log
+import           Polysemy.Time.Interpreter.Ghc
+
+newtype AppError
+  = AppError Text
+  deriving (Eq, Show)
+
+runApp :: ()
+  => CLI.DownCmd
+  -> Sem
+      [ Reader StatementContext
+      , Reader AWS.Env
+      , Error AppError
+      , DataLog AwsLogEntry
+      , Log
+      , GhcTime
+      , DataLog (LogEntry LogMessage)
+      , Resource
+      , Embed IO
+      , Final IO
+      ] ()
+  -> IO ()
+runApp cmd f = f
+  & runReader (cmd ^. the @"statementContext")
+  & runReaderAwsEnvDiscover
+  & trap @AppError reportFatal
+  & interpretDataLogAwsLogEntryToLog
+  & interpretLogDataLog
+  & interpretTimeGhc
+  & setLogLevel (Just Info)
+  & interpretDataLogToJsonStdout (logEntryToJson logMessageToJson)
+  & runResource
+  & embedToFinal @IO
+  & runFinal @IO
+
+reportFatal :: ()
+  => Member (Embed IO) r
+  => AppError
+  -> Sem r ()
+reportFatal (AppError msg) =
+  T.putStrLn msg
+
+runDownCmd :: CLI.DownCmd -> IO ()
+runDownCmd cmd = runApp cmd do
+  initialiseDb
+    & trap @AWS.Error (throw . AppError . tshow)
+    & trap @RdsDataError (throw . AppError . tshow)
+
+  migrateDown (cmd ^. the @"migrationFp")
+    & trap @AWS.Error (throw . AppError . tshow)
+    & trap @IOException (throw . AppError . tshow)
+    & trap @JsonDecodeError (throw . AppError . tshow)
+    & trap @RdsDataError (throw . AppError . tshow)
+    & trap @YamlDecodeError (throw . AppError . tshow)
diff --git a/app/App/Cli/Run/Example.hs b/app/App/Cli/Run/Example.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Run/Example.hs
@@ -0,0 +1,325 @@
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DeriveGeneric         #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE TypeApplications      #-}
+
+{- HLINT ignore "Use let" -}
+
+module App.Cli.Run.Example
+  ( runExampleCmd
+  ) where
+
+import           Amazonka.RDSData
+import           App.AWS.Env
+import           App.Config
+import           Control.Monad.IO.Class
+import           Data.ByteString            (ByteString)
+import           Data.Generics.Product.Any
+import           Data.Int
+import           Data.Maybe
+import           Data.Monoid
+import           Data.RdsData.Internal.Show
+import           Data.RdsData.Types
+import           Data.Text                  (Text)
+import           Data.Time
+import           Data.UUID                  (UUID)
+import           Data.Word
+import           GHC.Generics
+import           Lens.Micro
+import           Text.Printf
+
+import qualified Amazonka                   as AWS
+import qualified App.Cli.Types              as CLI
+import qualified App.Console                as T
+import qualified Data.Aeson                 as J
+import qualified Data.RdsData.Decode.Row    as DEC
+import qualified Data.RdsData.Encode.Params as ENC
+import qualified Data.Text                  as T
+import qualified Data.Text.Encoding         as T
+import qualified Data.Text.Lazy.Encoding    as LT
+import qualified Data.Text.Lazy.IO          as LT
+import qualified System.IO                  as IO
+import qualified System.IO.Unsafe           as IO
+
+data ExampleRow = ExampleRow
+  { theBigInt            :: Int64
+  , theBigSerial         :: Int32
+  , theBoolean           :: Bool
+  , theByteA             :: ByteString
+  , theCharacter         :: Text
+  , theCharacters        :: Text
+  , theVaryingCharacter  :: Text
+  , theVaryingCharacters :: Text
+  , theDate              :: Day
+  , theDouble            :: Double
+  , theInteger           :: Integer
+  , theJson              :: J.Value
+  , theJsonB             :: J.Value
+  , theNumeric           :: Double
+  , theNumerics          :: Double
+  , theReal              :: Double
+  , theSmallInt          :: Int16
+  , theSmallSerial       :: Word32
+  , theSerial            :: Word64
+  , theText              :: Text
+  , theTime              :: TimeOfDay
+  , theTimes             :: TimeOfDay
+  , theTimestamp         :: UTCTime
+  , theTimestamps        :: UTCTime
+  , theUuid              :: UUID
+  } deriving (Eq, Show, Generic)
+
+runExampleCmd :: CLI.ExampleCmd -> IO ()
+runExampleCmd cmd = do
+  let theAwsLogLevel   = cmd ^. the @"mAwsLogLevel"
+  let theMHostEndpoint = cmd ^. the @"mHostEndpoint"
+  let theRegion        = cmd ^. the @"region"
+  let theResourceArn   = cmd ^. the @"statementContext" . the @"resourceArn" . the @1
+  let theSecretArn     = cmd ^. the @"statementContext" . the @"secretArn" . the @1
+
+  envAws <-
+    liftIO (IO.unsafeInterleaveIO (mkEnv theRegion (awsLogger theAwsLogLevel)))
+      <&> applyMHostEndpoint theMHostEndpoint
+
+  AWS.runResourceT $ do
+    sql <- pure $ T.pack $ unlines
+      [ "CREATE TABLE all_types ("
+      , "  the_bigint                bigint                not null,"
+      , "  the_bigserial             bigserial             not null,"
+      , "  the_boolean               boolean               not null,"
+      , "  the_bytea                 bytea                 not null,"
+      , "  the_character             character             not null,"
+      , "  the_characters            character(2)          not null,"
+      , "  the_varying_character     character varying     not null,"
+      , "  the_varying_characters    character varying(2)  not null,"
+      , "  the_date                  date                  not null,"
+      , "  the_double                double precision      not null,"
+      , "  the_integer               integer               not null,"
+      , "  the_json                  json                  not null,"
+      , "  the_jsonb                 jsonb                 not null,"
+      , "  the_numeric               numeric               not null,"
+      , "  the_numerics              numeric(4, 2)         not null,"
+      , "  the_real                  real                  not null,"
+      , "  the_smallint              smallint              not null,"
+      , "  the_smallserial           smallserial           not null,"
+      , "  the_serial                serial                not null,"
+      , "  the_text                  text                  not null,"
+      , "  the_time                  time                  not null,"
+      , "  the_times                 time(2)               not null,"
+      , "  the_timestamp             timestamp             not null,"
+      , "  the_timestamps            timestamp(2)          not null,"
+      , "  the_uuid                  uuid                  not null"
+      , ")"
+      ]
+
+    req <- pure $ newExecuteStatement theResourceArn theSecretArn sql
+
+    res <- AWS.send envAws req
+
+    liftIO . LT.putStrLn $ LT.decodeUtf8 $ J.encode res
+
+  AWS.runResourceT $ do
+    sql <- pure $ T.pack $ unlines
+      [ "INSERT INTO all_types ("
+      , "  the_bigint,"
+      , "  the_bigserial,"
+      , "  the_boolean,"
+      , "  the_bytea,"
+      , "  the_character,"
+      , "  the_characters,"
+      , "  the_varying_character,"
+      , "  the_varying_characters,"
+      , "  the_date,"
+      , "  the_double,"
+      , "  the_integer,"
+      , "  the_json,"
+      , "  the_jsonb,"
+      , "  the_numeric,"
+      , "  the_numerics,"
+      , "  the_real,"
+      , "  the_smallint,"
+      , "  the_smallserial,"
+      , "  the_serial,"
+      , "  the_text,"
+      , "  the_time,"
+      , "  the_times,"
+      , "  the_timestamp,"
+      , "  the_timestamps,"
+      , "  the_uuid"
+      , ") VALUES ("
+      , "  :p00," -- the_bigint
+      , "  :p01," -- the_bigserial
+      , "  :p02," -- the_boolean
+      , "  :p03," -- the_bytea
+      , "  :p04," -- the_character
+      , "  :p05," -- the_characters
+      , "  :p06," -- the_varying_character
+      , "  :p07," -- the_varying_characters
+      , "  :p08," -- the_date
+      , "  :p09," -- the_double
+      , "  :p10," -- the_integer
+      , "  :p11," -- the_json
+      , "  :p12," -- the_jsonb
+      , "  :p13," -- the_numeric
+      , "  :p14," -- the_numerics
+      , "  :p15," -- the_real
+      , "  :p16," -- the_smallint
+      , "  :p17," -- the_smallserial
+      , "  :p18," -- the_serial
+      , "  :p19," -- the_text
+      , "  :p20," -- the_time
+      , "  :p21," -- the_times
+      , "  :p22," -- the_timestamp
+      , "  :p23," -- the_timestamps
+      , "  :p24"  -- the_uuid
+      , ")"
+      ]
+
+    exampleRow <- pure $ ExampleRow
+      { theBigInt             = 1234567890
+      , theBigSerial          = 1
+      , theBoolean            = True
+      , theByteA              = T.encodeUtf8 "EjQ="
+      , theCharacter          = "A"
+      , theCharacters         = "AB"
+      , theVaryingCharacter   = "C"
+      , theVaryingCharacters  = "CD"
+      , theDate               = YearMonthDay 2024 02 04
+      , theDouble             = 3.14159265359
+      , theInteger            = 42
+      , theJson               = "{\"key\":\"value\"}"
+      , theJsonB              = "{\"key\":\"value\"}"
+      , theNumeric            = 1234.56
+      , theNumerics           = 12.34
+      , theReal               = 3.14
+      , theSmallInt           = 12345
+      , theSmallSerial        = 1
+      , theSerial             = 1
+      , theText               = "Some text"
+      , theTime               = TimeOfDay 12 34 56
+      , theTimes              = TimeOfDay 12 34 56.780
+      , theTimestamp          = read "2024-02-04 12:34:56"
+      , theTimestamps         = read "2024-02-04 12:34:56"
+      , theUuid               = read "550e8400-e29b-41d4-a716-446655440000"
+      }
+
+    row <- pure $ flip appEndo [] $ mconcat $ fmap Endo
+      [ ENC.encodeParams ENC.int64       (exampleRow ^. the @"theBigInt"            )
+      , ENC.encodeParams ENC.int32       (exampleRow ^. the @"theBigSerial"         )
+      , ENC.encodeParams ENC.bool        (exampleRow ^. the @"theBoolean"           )
+      , ENC.encodeParams ENC.bytestring  (exampleRow ^. the @"theByteA"             )
+      , ENC.encodeParams ENC.text        (exampleRow ^. the @"theCharacter"         )
+      , ENC.encodeParams ENC.text        (exampleRow ^. the @"theCharacters"        )
+      , ENC.encodeParams ENC.text        (exampleRow ^. the @"theVaryingCharacter"  )
+      , ENC.encodeParams ENC.text        (exampleRow ^. the @"theVaryingCharacters" )
+      , ENC.encodeParams ENC.day         (exampleRow ^. the @"theDate"              )
+      , ENC.encodeParams ENC.double      (exampleRow ^. the @"theDouble"            )
+      , ENC.encodeParams ENC.integer     (exampleRow ^. the @"theInteger"           )
+      , ENC.encodeParams ENC.json        (exampleRow ^. the @"theJson"              )
+      , ENC.encodeParams ENC.json        (exampleRow ^. the @"theJsonB"             )
+      , ENC.encodeParams ENC.double      (exampleRow ^. the @"theNumeric"           )
+      , ENC.encodeParams ENC.double      (exampleRow ^. the @"theNumerics"          )
+      , ENC.encodeParams ENC.double      (exampleRow ^. the @"theReal"              )
+      , ENC.encodeParams ENC.int16       (exampleRow ^. the @"theSmallInt"          )
+      , ENC.encodeParams ENC.word32      (exampleRow ^. the @"theSmallSerial"       )
+      , ENC.encodeParams ENC.word64      (exampleRow ^. the @"theSerial"            )
+      , ENC.encodeParams ENC.text        (exampleRow ^. the @"theText"              )
+      , ENC.encodeParams ENC.timeOfDay   (exampleRow ^. the @"theTime"              )
+      , ENC.encodeParams ENC.timeOfDay   (exampleRow ^. the @"theTimes"             )
+      , ENC.encodeParams ENC.utcTime     (exampleRow ^. the @"theTimestamp"         )
+      , ENC.encodeParams ENC.utcTime     (exampleRow ^. the @"theTimestamps"        )
+      , ENC.encodeParams ENC.uuid        (exampleRow ^. the @"theUuid"              )
+      ]
+
+    liftIO $ LT.putStrLn $ LT.decodeUtf8 $ J.encode row
+
+    req <- pure $ newBatchExecuteStatement theResourceArn theSecretArn sql
+      & the @"parameterSets" ?~
+        [ zip row [0..] <&>
+          ( \(v, i) ->
+              toSqlParameter v
+                & the @"name" ?~ T.pack ("p" <> printf "%02d" (id @Int i))
+          )
+        ]
+
+    liftIO $ T.putStrLn $ "===> " <> tshow req
+
+    res <- AWS.send envAws req
+
+    liftIO . LT.putStrLn $ LT.decodeUtf8 $ J.encode res
+
+  AWS.runResourceT $ do
+    sql <- pure $ T.pack $ unlines
+      [ "SELECT"
+      , "  the_bigint,"
+      , "  the_bigserial,"
+      , "  the_boolean,"
+      , "  the_bytea,"
+      , "  the_character,"
+      , "  the_characters,"
+      , "  the_varying_character,"
+      , "  the_varying_characters,"
+      , "  the_date,"
+      , "  the_double,"
+      , "  the_integer,"
+      , "  the_json,"
+      , "  the_jsonb,"
+      , "  the_numeric,"
+      , "  the_numerics,"
+      , "  the_real,"
+      , "  the_smallint,"
+      , "  the_smallserial,"
+      , "  the_serial,"
+      , "  the_text,"
+      , "  the_time,"
+      , "  the_times,"
+      , "  the_timestamp,"
+      , "  the_timestamps,"
+      , "  the_uuid"
+      , "FROM all_types"
+      ]
+
+    liftIO $ T.putStrLn sql
+
+    req <- pure $ newExecuteStatement theResourceArn theSecretArn sql
+
+    res <- AWS.send envAws req
+
+    liftIO . LT.putStrLn $ LT.decodeUtf8 $ J.encode res
+
+    decodeExampleRow <- pure $ id @(DEC.DecodeRow ExampleRow) $
+      ExampleRow
+        <$> DEC.int64
+        <*> DEC.int32
+        <*> DEC.bool
+        <*> DEC.bytestring
+        <*> DEC.text
+        <*> DEC.text
+        <*> DEC.text
+        <*> DEC.text
+        <*> DEC.day
+        <*> DEC.double
+        <*> DEC.integer
+        <*> DEC.json
+        <*> DEC.json
+        <*> DEC.double
+        <*> DEC.double
+        <*> DEC.double
+        <*> DEC.int16
+        <*> DEC.word32
+        <*> DEC.word64
+        <*> DEC.text
+        <*> DEC.timeOfDay
+        <*> DEC.timeOfDay
+        <*> DEC.utcTime
+        <*> DEC.utcTime
+        <*> DEC.uuid
+
+    records <- pure $ id @[[Value]] $ fromMaybe [] $ mapM (mapM fromField) =<< res ^. the @"records"
+
+    row <- pure $ DEC.decodeRows decodeExampleRow records
+
+    liftIO $ IO.print row
+
+  pure ()
diff --git a/app/App/Cli/Run/ExecuteStatement.hs b/app/App/Cli/Run/ExecuteStatement.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Run/ExecuteStatement.hs
@@ -0,0 +1,43 @@
+{-# LANGUAGE BlockArguments        #-}
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE TypeApplications      #-}
+
+module App.Cli.Run.ExecuteStatement
+  ( runExecuteStatementCmd
+  ) where
+
+import           Amazonka.RDSData
+import           App.AWS.Env
+import           App.Config
+import           Control.Monad.IO.Class
+import           Data.Generics.Product.Any
+import           Lens.Micro
+
+import qualified Amazonka                  as AWS
+import qualified App.Cli.Types             as CLI
+import qualified Data.Aeson                as J
+import qualified Data.ByteString.Lazy      as LBS
+import qualified Data.Text.Encoding        as T
+import qualified Data.Text.IO              as T
+import qualified System.IO.Unsafe          as IO
+
+runExecuteStatementCmd :: CLI.ExecuteStatementCmd -> IO ()
+runExecuteStatementCmd cmd = do
+  let theAwsLogLevel   = cmd ^. the @"mAwsLogLevel"
+  let theMHostEndpoint = cmd ^. the @"mHostEndpoint"
+  let theRegion        = cmd ^. the @"region"
+  let theResourceArn   = cmd ^. the @"statementContext" . the @"resourceArn" . the @1
+  let theSecretArn     = cmd ^. the @"statementContext" . the @"secretArn" . the @1
+  let theSql           = cmd ^. the @"sql"
+
+  envAws <-
+    liftIO (IO.unsafeInterleaveIO (mkEnv theRegion (awsLogger theAwsLogLevel)))
+      <&> applyMHostEndpoint theMHostEndpoint
+
+  let req = newExecuteStatement theResourceArn theSecretArn theSql
+
+  AWS.runResourceT $ do
+    res <- AWS.send envAws req
+
+    liftIO . T.putStrLn $ T.decodeUtf8 $ LBS.toStrict $ J.encode res
diff --git a/app/App/Cli/Run/LocalStack.hs b/app/App/Cli/Run/LocalStack.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Run/LocalStack.hs
@@ -0,0 +1,138 @@
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE FlexibleContexts      #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE ScopedTypeVariables   #-}
+{-# LANGUAGE TypeApplications      #-}
+
+{- HLINT ignore "Redundant id" -}
+{- HLINT ignore "Use let" -}
+
+module App.Cli.Run.LocalStack
+  ( runLocalStackCmd,
+    runApp,
+    reportFatal,
+  ) where
+
+import           Lens.Micro
+
+import qualified App.Cli.Types                                     as CLI
+import qualified App.Console                                       as T
+import qualified Control.Concurrent                                as IO
+import qualified Control.Concurrent.STM                            as IO
+import qualified Control.Exception                                 as IO
+import qualified Control.Monad.Trans.Resource                      as IO
+import qualified Control.Monad.Trans.Resource.Internal             as IO
+import           Data.Acquire                                      (ReleaseType (ReleaseNormal))
+import           Data.Generics.Product.Any
+import           Data.RdsData.Aws
+import           Data.RdsData.Default
+import           Data.RdsData.Internal.Show
+import           Data.RdsData.Polysemy.Test.Cluster
+import           Data.RdsData.Polysemy.Test.Env
+import           GHC.IORef                                         (IORef)
+import           HaskellWorks.Polysemy
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Polysemy.Amazonka.LocalStack
+import qualified HaskellWorks.Polysemy.Control.Concurrent.STM      as STM
+import qualified HaskellWorks.Polysemy.Control.Concurrent.STM.TVar as STM
+import           HaskellWorks.Polysemy.Hedgehog
+import           HaskellWorks.Prelude
+import           HaskellWorks.TestContainers.LocalStack
+import qualified Hedgehog                                          as H
+import           Polysemy.Log
+import           Polysemy.Time.Interpreter.Ghc
+import qualified System.Exit                                       as IO
+import qualified TestContainers.Monad                              as TC
+import qualified TestContainers.Tasty                              as TC
+
+newtype AppError
+  = AppError Text
+  deriving (Eq, Show)
+
+runApp :: ()
+  => Sem
+      [ Error AppError
+      , DataLog AwsLogEntry
+      , Log
+      , GhcTime
+      , DataLog (LogEntry LogMessage)
+      , Resource
+      , Embed IO
+      , Final IO
+      ] ()
+  -> IO ()
+runApp f = f
+  & trap @AppError reportFatal
+  & interpretDataLogAwsLogEntryToLog
+  & interpretLogDataLog
+  & interpretTimeGhc
+  & setLogLevel (Just Info)
+  & interpretDataLogStdout
+  & runResource
+  & embedToFinal @IO
+  & runFinal @IO
+
+reportFatal :: ()
+  => Member (Embed IO) r
+  => AppError
+  -> Sem r ()
+reportFatal (AppError msg) =
+  T.putStrLn msg
+
+
+startContainers :: ()
+  => TC.Config
+  -> IO (TC.Container, IO.InternalState)
+startContainers tcConfig =
+  TC.runTestContainer tcConfig do
+    result <- setupContainers' projectDefaultLocalStack
+    releaseMap <- IO.liftResourceT IO.getInternalState
+
+    -- N.B. runResourceT runs the finalizers on every resource. We don't want it to! We want to run
+    -- finalization in the release function that is called by Tasty! stateAlloc increments a references
+    -- count to accomodate for exactly these kind of cases.
+    liftIO $ IO.stateAlloc releaseMap
+    pure (result, releaseMap)
+
+stopContainers :: ()
+  => (a, IORef IO.ReleaseMap)
+  -> IO ()
+stopContainers (_, internalState) = do
+  T.putStrLn "Stopping containers"
+  IO.stateCleanup ReleaseNormal internalState
+
+runLocalStackCmd :: CLI.LocalStackCmd -> IO ()
+runLocalStackCmd _ = do
+  tvRunning <- IO.newTVarIO False
+
+  tcConfig <- TC.determineConfig
+
+  void $ IO.bracket (startContainers tcConfig) stopContainers \(container, _) -> do
+    void $ H.check $ propertyOnce do
+      STM.atomically $ STM.writeTVar tvRunning True
+
+      void $ runLocalTestEnv (pure container) do
+        rdsClusterDetails <- createRdsDbCluster "rds_data_migration" (pure container)
+
+        runReaderStatementContextFromClusterDetails rdsClusterDetails do
+          lsEp <- getLocalStackEndpoint container
+          jotShow_ lsEp -- Localstack endpoint
+          let port = lsEp ^. the @"port"
+          let exampleCmd = "awslocal --endpoint-url=http://localhost:" <> tshow port <> " s3 ls"
+          -- Example awslocal command:
+          jot_ exampleCmd
+          jotShowM_ $ ask @StatementContext
+          pure ()
+
+      failure -- Not a failure
+
+    running <- IO.readTVarIO tvRunning
+
+    if running
+      then do
+        T.putStrLn "Localstack RDS cluster is running.  Type CTRL-C to exit."
+        void . forever $ IO.threadDelay 10000000
+      else do
+        T.putStrLn "Failed to start Localstack RDS cluster."
+        IO.exitFailure
diff --git a/app/App/Cli/Run/Up.hs b/app/App/Cli/Run/Up.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Run/Up.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE FlexibleContexts      #-}
+{-# LANGUAGE TypeApplications      #-}
+
+{- HLINT ignore "Redundant id" -}
+{- HLINT ignore "Use let" -}
+
+module App.Cli.Run.Up
+  ( runUpCmd
+  ) where
+
+import           Data.Generics.Product.Any
+import           Lens.Micro
+
+import qualified Amazonka                        as AWS
+import qualified App.Cli.Types                   as CLI
+import qualified App.Console                     as T
+import           Data.RdsData.Aws
+import           Data.RdsData.Polysemy.Core
+import           Data.RdsData.Polysemy.Error
+import           Data.RdsData.Polysemy.Migration
+import           HaskellWorks.Polysemy
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Polysemy.File
+import           HaskellWorks.Polysemy.Log
+import           HaskellWorks.Prelude
+import           Polysemy.Log
+import           Polysemy.Time.Interpreter.Ghc
+
+newtype AppError
+  = AppError Text
+  deriving (Eq, Show)
+
+runApp :: ()
+  => CLI.UpCmd
+  -> Sem
+      [ Reader StatementContext
+      , Reader AWS.Env
+      , Error AppError
+      , DataLog AwsLogEntry
+      , Log
+      , GhcTime
+      , DataLog (LogEntry LogMessage)
+      , Resource
+      , Embed IO
+      , Final IO
+      ] ()
+  -> IO ()
+runApp cmd f = f
+  & runReader (cmd ^. the @"statementContext")
+  & runReaderAwsEnvDiscover
+  & trap @AppError reportFatal
+  & interpretDataLogAwsLogEntryToLog
+  & interpretLogDataLog
+  & interpretTimeGhc
+  & setLogLevel (Just Info)
+  & interpretDataLogToJsonStdout (logEntryToJson logMessageToJson)
+  & runResource
+  & embedToFinal @IO
+  & runFinal @IO
+
+reportFatal :: ()
+  => Member (Embed IO) r
+  => AppError
+  -> Sem r ()
+reportFatal (AppError msg) =
+  T.putStrLn msg
+
+runUpCmd :: CLI.UpCmd -> IO ()
+runUpCmd cmd = runApp cmd do
+  initialiseDb
+    & trap @AWS.Error (throw . AppError . tshow)
+    & trap @RdsDataError (throw . AppError . tshow)
+
+  migrateUp (cmd ^. the @"migrationFp")
+    & trap @AWS.Error (throw . AppError . tshow)
+    & trap @IOException (throw . AppError . tshow)
+    & trap @JsonDecodeError (throw . AppError . tshow)
+    & trap @RdsDataError (throw . AppError . tshow)
+    & trap @YamlDecodeError (throw . AppError . tshow)
diff --git a/app/App/Cli/Types.hs b/app/App/Cli/Types.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Cli/Types.hs
@@ -0,0 +1,68 @@
+{-# LANGUAGE BlockArguments        #-}
+{-# LANGUAGE DataKinds             #-}
+{-# LANGUAGE DeriveGeneric         #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+
+module App.Cli.Types
+  ( Cmd(..)
+  , BatchExecuteStatementCmd(..)
+  , DownCmd(..)
+  , ExampleCmd(..)
+  , ExecuteStatementCmd(..)
+  , LocalStackCmd(..)
+  , UpCmd(..)
+  ) where
+
+import           Data.ByteString    (ByteString)
+import           Data.RdsData.Types ()
+import           Data.Text
+import           GHC.Generics
+
+import qualified Amazonka           as AWS
+import qualified Amazonka.RDSData   as AWS
+import           Data.RdsData.Aws
+
+data Cmd =
+    CmdOfBatchExecuteStatementCmd BatchExecuteStatementCmd
+  | CmdOfDownCmd DownCmd
+  | CmdOfExampleCmd ExampleCmd
+  | CmdOfExecuteStatementCmd ExecuteStatementCmd
+  | CmdOfLocalStackCmd LocalStackCmd
+  | CmdOfUpCmd UpCmd
+
+data ExecuteStatementCmd = ExecuteStatementCmd
+  { mAwsLogLevel     :: Maybe AWS.LogLevel
+  , region           :: AWS.Region
+  , mHostEndpoint    :: Maybe (ByteString, Int, Bool)
+  , statementContext :: StatementContext
+  , sql              :: Text
+  } deriving Generic
+
+data BatchExecuteStatementCmd = BatchExecuteStatementCmd
+  { mAwsLogLevel     :: Maybe AWS.LogLevel
+  , region           :: AWS.Region
+  , mHostEndpoint    :: Maybe (ByteString, Int, Bool)
+  , parameterSets    :: Maybe [[AWS.SqlParameter]]
+  , statementContext :: StatementContext
+  , sql              :: Text
+  } deriving Generic
+
+data ExampleCmd = ExampleCmd
+  { mAwsLogLevel     :: Maybe AWS.LogLevel
+  , region           :: AWS.Region
+  , mHostEndpoint    :: Maybe (ByteString, Int, Bool)
+  , statementContext :: StatementContext
+  } deriving Generic
+
+data UpCmd = UpCmd
+  { statementContext :: StatementContext
+  , migrationFp      :: FilePath
+  } deriving Generic
+
+data DownCmd = DownCmd
+  { statementContext :: StatementContext
+  , migrationFp      :: FilePath
+  } deriving Generic
+
+data LocalStackCmd = LocalStackCmd
+  deriving Generic
diff --git a/app/App/Config.hs b/app/App/Config.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Config.hs
@@ -0,0 +1,28 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE TypeApplications #-}
+
+module App.Config
+  ( applyMHostEndpoint
+  ) where
+
+import Lens.Micro
+import Data.ByteString (ByteString)
+import Data.Generics.Product.Any
+
+import qualified Amazonka as AWS
+
+applyMHostEndpoint :: Maybe (ByteString, Int, Bool) -> AWS.Env -> AWS.Env
+applyMHostEndpoint = \case
+  Just (host, port, ssl) ->
+    \env ->
+      env
+        & the @"overrides" .~ \svc ->
+            svc & the @"endpoint" %~ \mkEndpoint region ->
+              mkEndpoint region
+                & the @"host"   .~ host
+                & the @"port"   .~ port
+                & the @"secure" .~ ssl
+  Nothing -> id
diff --git a/app/App/Console.hs b/app/App/Console.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Console.hs
@@ -0,0 +1,35 @@
+module App.Console
+  ( putStrLn,
+    print,
+    hPutStrLn,
+    hPrint,
+  ) where
+
+import           Control.Exception       (bracket_)
+import           Control.Monad.IO.Class  (MonadIO, liftIO)
+import           Data.Text               (Text)
+import           Prelude                 (IO, Show (..), ($), (.))
+
+import qualified Control.Concurrent.QSem as IO
+import qualified Data.Text.IO            as T
+import qualified System.IO               as IO
+import qualified System.IO.Unsafe        as IO
+
+sem :: IO.QSem
+sem = IO.unsafePerformIO $ IO.newQSem 1
+{-# NOINLINE sem #-}
+
+consoleBracket :: IO a -> IO a
+consoleBracket = bracket_ (IO.waitQSem sem) (IO.signalQSem sem)
+
+putStrLn :: MonadIO m => Text -> m ()
+putStrLn = liftIO . consoleBracket . T.putStrLn
+
+print :: (MonadIO m, Show a) => a -> m ()
+print = liftIO . consoleBracket . IO.print
+
+hPutStrLn :: MonadIO m => IO.Handle -> Text -> m ()
+hPutStrLn h = liftIO . consoleBracket . T.hPutStrLn h
+
+hPrint :: (MonadIO m, Show a) => IO.Handle -> a -> m ()
+hPrint h = liftIO . consoleBracket . IO.hPrint h
diff --git a/app/App/Options.hs b/app/App/Options.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Options.hs
@@ -0,0 +1,22 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+
+module App.Options where
+
+import qualified Amazonka.Data              as AWS
+import qualified Data.Text                  as T
+import qualified Options.Applicative        as OA
+
+text :: AWS.FromText a => OA.ReadM a
+text = OA.eitherReader (AWS.fromText . T.pack)
+
+subParser :: String -> OA.ParserInfo a -> OA.Parser a
+subParser availableCommand pInfo =
+  OA.hsubparser $ OA.command availableCommand pInfo <> OA.metavar availableCommand
+
+pref :: OA.ParserPrefs
+pref =
+  OA.prefs $ mconcat
+    [ OA.showHelpOnEmpty
+    ]
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,16 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+
+module Main where
+
+import App.Options
+
+import qualified App.Cli.Run          as CLI
+import qualified App.Cli.Options      as CLI
+import qualified Options.Applicative  as OA
+
+main :: IO ()
+main = do
+  cmd <- OA.customExecParser pref CLI.opts
+
+  CLI.runCmd cmd
diff --git a/integration/Spec.hs b/integration/Spec.hs
new file mode 100644
--- /dev/null
+++ b/integration/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --hide-successes #-}
diff --git a/integration/Test/Data/RdsData/Migration/ConnectionSpec.hs b/integration/Test/Data/RdsData/Migration/ConnectionSpec.hs
new file mode 100644
--- /dev/null
+++ b/integration/Test/Data/RdsData/Migration/ConnectionSpec.hs
@@ -0,0 +1,114 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+
+{- HLINT ignore "Move brackets to avoid $" -}
+{- HLINT ignore "Redundant id" -}
+{- HLINT ignore "Redundant pure" -}
+{- HLINT ignore "Use camelCase" -}
+{- HLINT ignore "Use let" -}
+
+module Test.Data.RdsData.Migration.ConnectionSpec
+  ( tasty_rds_integration_test,
+  ) where
+
+import qualified Amazonka.Types                         as AWS
+import           Data.Function
+import           Data.Generics.Product.Any
+import qualified Data.List                              as L
+import           Data.RdsData.Default
+import           Data.RdsData.Polysemy.Core
+import           Data.RdsData.Polysemy.Error
+import           Data.RdsData.Polysemy.Migration
+import           Data.RdsData.Polysemy.Test.Cluster
+import           Data.RdsData.Polysemy.Test.Env
+import           Data.RdsData.Polysemy.Test.Workspace
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Polysemy.File
+import           HaskellWorks.Polysemy.Hedgehog
+import           HaskellWorks.Polysemy.Prelude
+import           HaskellWorks.TestContainers.LocalStack
+import           Lens.Micro
+import qualified System.Info                            as OS
+import qualified Test.Tasty                             as Tasty
+import qualified Test.Tasty.Hedgehog                    as H
+import qualified TestContainers.Tasty                   as TC
+
+isWindows :: Bool
+isWindows = OS.os == "mingw32"
+
+isMacos :: Bool
+isMacos = OS.os == "darwin"
+
+-- cabal test rds-data-test --test-options "--pattern \"/RDS integration test/\""
+tasty_rds_integration_test :: Tasty.TestTree
+tasty_rds_integration_test =
+  if isWindows || isMacos
+    then Tasty.testGroup "LocalStackSpec skipped on Windows and MacOS" []
+    else
+      TC.withContainers (setupContainers' projectDefaultLocalStack) $ \getContainer ->
+        H.testProperty "RDS integration test" $ propertyOnce $ localWorkspace "rds-data" $ runLocalTestEnv getContainer $ do
+          rdsClusterDetails <- createRdsDbCluster "rds_data_migration" getContainer
+
+          dbClusterArn <- rdsClusterDetails ^. the @"createDbClusterResponse" . the @"dbCluster" . _Just . the @"dbClusterArn"
+            & nothingFail
+
+          runReaderStatementContextFromClusterDetails rdsClusterDetails $ do
+            waitUntilRdsDbClusterAvailable dbClusterArn
+              & trapFail @AWS.Error
+              & jotShowDataLog @AwsLogEntry
+
+            initialiseDb
+              & trapFail @RdsDataError
+              & trapFail @AWS.Error
+              & jotShowDataLog @AwsLogEntry
+
+            migrateUp "db/migration.yaml"
+              & trapFail @AWS.Error
+              & trapFail @IOException
+              & trapFail @JsonDecodeError
+              & trapFail @RdsDataError
+              & trapFail @YamlDecodeError
+              & jotShowDataLog
+
+            upResult <-
+              ( executeStatement $ mconcat
+                  [ "SELECT table_name"
+                  , "  FROM information_schema.tables"
+                  , "  WHERE table_schema = 'public'"
+                  , "    AND table_type = 'BASE TABLE';"
+                  ]
+              )
+              & trapFail @AWS.Error
+              & trapFail @RdsDataError
+              & jotShowDataLog
+
+            let upTables = upResult ^.. the @"records" . each . each . each . the @"stringValue" . _Just
+
+            L.sort upTables === ["examples", "migration", "projects", "users"]
+
+            migrateDown "db/migration.yaml"
+              & trapFail @AWS.Error
+              & trapFail @IOException
+              & trapFail @JsonDecodeError
+              & trapFail @RdsDataError
+              & trapFail @YamlDecodeError
+              & jotShowDataLog
+
+            downResult <-
+              ( executeStatement $ mconcat
+                  [ "SELECT table_name"
+                  , "  FROM information_schema.tables"
+                  , "  WHERE table_schema = 'public'"
+                  , "    AND table_type = 'BASE TABLE'"
+                  ]
+              )
+              & trapFail @AWS.Error
+              & trapFail @RdsDataError
+              & jotShowDataLog
+
+            let downTables = downResult ^.. the @"records" . each . each . each . the @"stringValue" . _Just
+
+            L.sort downTables === ["migration"]
diff --git a/integration/Test/Data/RdsData/TestTypes.hs b/integration/Test/Data/RdsData/TestTypes.hs
new file mode 100644
--- /dev/null
+++ b/integration/Test/Data/RdsData/TestTypes.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DataKinds      #-}
+{-# LANGUAGE DeriveGeneric  #-}
+
+{- HLINT ignore "Monad law, left identity" -}
+{- HLINT ignore "Use let" -}
+
+module Test.Data.RdsData.TestTypes
+  ( Record(..)
+  ) where
+
+import           Data.ByteString             (ByteString)
+import           Data.RdsData.Internal.Aeson ()
+import           Data.Text                   (Text)
+import           Data.Time                   (Day, TimeOfDay (..), UTCTime)
+import           Data.ULID                   (ULID)
+import           Data.UUID                   (UUID)
+import           GHC.Generics
+
+import qualified Data.Aeson                  as J
+
+data Record = Record
+  { bigint            :: Int
+  , bigserial         :: Int
+  , boolean           :: Bool
+  , bytea             :: ByteString
+  , character         :: Text
+  , characters        :: Text
+  , varyingCharacter  :: Text
+  , varyingCharacters :: Text
+  , date              :: Day
+  , double            :: Double
+  , integer           :: Integer
+  , json              :: J.Value
+  , jsonb             :: J.Value
+  , numeric           :: Text
+  , numerics          :: Text
+  , real              :: Double
+  , smallint          :: Int
+  , smallserial       :: Int
+  , serial            :: Int
+  , text              :: Text
+  , time              :: TimeOfDay
+  , times             :: TimeOfDay
+  , timestamp         :: UTCTime
+  , timestamps        :: UTCTime
+  , ulid              :: ULID
+  , uuid              :: UUID
+  }
+  deriving (Show, Eq, Generic)
diff --git a/polysemy/Data/RdsData/Polysemy.hs b/polysemy/Data/RdsData/Polysemy.hs
new file mode 100644
--- /dev/null
+++ b/polysemy/Data/RdsData/Polysemy.hs
@@ -0,0 +1,2 @@
+module Data.RdsData.Polysemy
+  () where
diff --git a/polysemy/Data/RdsData/Polysemy/Core.hs b/polysemy/Data/RdsData/Polysemy/Core.hs
new file mode 100644
--- /dev/null
+++ b/polysemy/Data/RdsData/Polysemy/Core.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+
+module Data.RdsData.Polysemy.Core
+  ( executeStatement,
+    executeStatement_,
+    initialiseDb,
+    newExecuteStatement,
+    newBatchExecuteStatement,
+  ) where
+
+import           Amazonka.Env                           (Env)
+import qualified Amazonka.RDSData.BatchExecuteStatement as AWS
+import qualified Amazonka.RDSData.ExecuteStatement      as AWS
+import qualified Amazonka.Types                         as AWS
+import           Data.Generics.Product.Any
+import           Data.RdsData.Aws
+import           Data.RdsData.Polysemy.Error
+import           HaskellWorks.Polysemy
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Prelude
+import           Lens.Micro
+
+newExecuteStatement :: ()
+  => Member (Reader StatementContext) r
+  => Text
+  -> Sem r AWS.ExecuteStatement
+newExecuteStatement sql = do
+  context <- ask @StatementContext
+
+  let AwsResourceArn theResourceArn = context ^. the @"resourceArn"
+  let AwsSecretArn theSecretArn = context ^. the @"secretArn"
+
+  pure $ AWS.newExecuteStatement theResourceArn theSecretArn sql
+    & the @"database" .~ (context ^? the @"database" . _Just . the @1)
+
+newBatchExecuteStatement :: ()
+  => Member (Reader StatementContext) r
+  => Text
+  -> Sem r AWS.BatchExecuteStatement
+newBatchExecuteStatement sql = do
+  context <- ask @StatementContext
+
+  let AwsResourceArn theResourceArn = context ^. the @"resourceArn"
+  let AwsSecretArn theSecretArn = context ^. the @"secretArn"
+
+  pure $ AWS.newBatchExecuteStatement theResourceArn theSecretArn sql
+    & the @"database" .~ (context ^? the @"database" . _Just . the @1)
+
+executeStatement :: ()
+  => HasCallStack
+  => Member (DataLog AwsLogEntry) r
+  => Member (Embed m) r
+  => Member (Error AWS.Error) r
+  => Member (Error RdsDataError) r
+  => Member (Reader StatementContext) r
+  => Member (Reader Env) r
+  => Member Log r
+  => Member Resource r
+  => MonadIO m
+  => Text
+  -> Sem r AWS.ExecuteStatementResponse
+executeStatement sql = withFrozenCallStack do
+  res <- newExecuteStatement sql >>= sendAws
+
+  case res ^. the @"httpStatus" of
+    200 -> do
+      info $ "Successfully executed statement.  Results: " <> tshow res
+      pure res
+    _   -> throw $ RdsDataError $ "Failed to initialise database: " <> tshow res
+
+executeStatement_ :: ()
+  => HasCallStack
+  => Member (DataLog AwsLogEntry) r
+  => Member (Embed m) r
+  => Member (Error AWS.Error) r
+  => Member (Error RdsDataError) r
+  => Member (Reader StatementContext) r
+  => Member (Reader Env) r
+  => Member Log r
+  => Member Resource r
+  => MonadIO m
+  => Text
+  -> Sem r ()
+executeStatement_ f = withFrozenCallStack do
+  void $ executeStatement f
+
+initialiseDb :: ()
+  => HasCallStack
+  => Member (DataLog AwsLogEntry) r
+  => Member (Embed m) r
+  => Member (Error AWS.Error) r
+  => Member (Error RdsDataError) r
+  => Member (Reader StatementContext) r
+  => Member (Reader Env) r
+  => Member Log r
+  => Member Resource r
+  => MonadIO m
+  => Sem r ()
+initialiseDb = withFrozenCallStack do
+  executeStatement_ $ mconcat
+    [ "CREATE TABLE IF NOT EXISTS migration ("
+    , "  ulid CHAR(26)    NOT NULL PRIMARY KEY,"
+    , "  created_at       TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+    , "  deployed_by      TEXT NOT NULL,"
+    , "  CONSTRAINT valid_ulid_constraint"
+    , "    CHECK (ulid::text ~ '^[0-9A-HJKMNP-TV-Z]{26}$')"
+    , ")"
+    ]
+
+  executeStatement_
+    "CREATE INDEX IF NOT EXISTS idx_migration_created_at ON migration (created_at)"
+
+  executeStatement_
+    "CREATE INDEX IF NOT EXISTS idx_migration_deployed_by ON migration (deployed_by)"
diff --git a/polysemy/Data/RdsData/Polysemy/Error.hs b/polysemy/Data/RdsData/Polysemy/Error.hs
new file mode 100644
--- /dev/null
+++ b/polysemy/Data/RdsData/Polysemy/Error.hs
@@ -0,0 +1,19 @@
+{-# LANGUAGE GADTs              #-}
+{-# LANGUAGE StandaloneDeriving #-}
+
+module Data.RdsData.Polysemy.Error
+  ( RdsDataError(..)
+  ) where
+
+import           Data.Text (Text)
+
+data RdsDataError where
+  EnvironmentVariableMissing
+    :: String
+    -> RdsDataError
+
+  RdsDataError
+    :: Text
+    -> RdsDataError
+
+deriving instance Show RdsDataError
diff --git a/polysemy/Data/RdsData/Polysemy/Migration.hs b/polysemy/Data/RdsData/Polysemy/Migration.hs
new file mode 100644
--- /dev/null
+++ b/polysemy/Data/RdsData/Polysemy/Migration.hs
@@ -0,0 +1,195 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE LambdaCase          #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+
+{- HLINT ignore "Use let" -}
+
+module Data.RdsData.Polysemy.Migration
+  ( migrateDown,
+    migrateUp,
+  ) where
+
+import qualified Amazonka.Env                   as AWS
+import qualified Amazonka.Types                 as AWS
+import qualified Data.Aeson                     as J
+import qualified Data.ByteString.Lazy           as LBS
+import           Data.Generics.Product.Any
+import qualified Data.List                      as L
+import           Data.RdsData.Aws
+import           Data.RdsData.Migration.Types   hiding (id)
+import           Data.RdsData.Polysemy.Core
+import           Data.RdsData.Polysemy.Error
+import qualified Data.Text                      as T
+import qualified Data.Text.Encoding             as T
+import           HaskellWorks.Polysemy
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Polysemy.File
+import           HaskellWorks.Polysemy.Prelude
+import           Lens.Micro
+
+migrateDown :: ()
+  => Member (DataLog AwsLogEntry) r
+  => Member (Embed IO) r
+  => Member (Error AWS.Error) r
+  => Member (Error IOException) r
+  => Member (Error JsonDecodeError) r
+  => Member (Error RdsDataError) r
+  => Member (Error YamlDecodeError) r
+  => Member (Reader AWS.Env) r
+  => Member (Reader StatementContext) r
+  => Member Log r
+  => Member Resource r
+  => FilePath
+  -> Sem r ()
+migrateDown migrationFp = do
+  value :: Migration <- readYamlFile migrationFp
+
+  let theSteps = value ^.. the @"plan" . to reverse . each . the @"steps" . _Just . to reverse . each
+
+  forM_ theSteps $ \case
+      StepOfDown downStep -> do
+        info $ "Executing statement: " <> tshow downStep
+
+        let statement = downStep ^. the @"down" . the @1
+
+        response <- executeStatement statement
+
+        info $ "Results: " <> T.decodeUtf8 (LBS.toStrict (J.encode (response ^. the @"records")))
+      StepOfUp _ -> pure ()
+      StepOfCreateTable createTableStatement -> do
+        statement <- pure $ mconcat
+          [ "DROP TABLE " <> createTableStatement ^. the @"createTable" . the @"name"
+          ]
+
+        info $ "Executing statement: " <> statement
+
+        response <- executeStatement statement
+
+        info $ "Results: " <> T.decodeUtf8 (LBS.toStrict (J.encode (response ^. the @"records")))
+      StepOfCreateIndex createIndexStatement -> do
+        statement <- pure $ mconcat
+          [ "DROP INDEX " <> createIndexStatement ^. the @"createIndex" . the @"name"
+          ]
+
+        info $ "Executing statement: " <> statement
+
+        response <- executeStatement statement
+
+        info $ "Results: " <> T.decodeUtf8 (LBS.toStrict (J.encode (response ^. the @"records")))
+
+migrateUp :: ()
+  => Member (DataLog AwsLogEntry) r
+  => Member (Embed IO) r
+  => Member (Error AWS.Error) r
+  => Member (Error IOException) r
+  => Member (Error JsonDecodeError) r
+  => Member (Error RdsDataError) r
+  => Member (Error YamlDecodeError) r
+  => Member (Reader AWS.Env) r
+  => Member (Reader StatementContext) r
+  => Member Log r
+  => Member Resource r
+  => FilePath
+  -> Sem r ()
+migrateUp migrationFp = do
+  value :: Migration <- readYamlFile migrationFp
+
+  let theSteps = value ^.. the @"plan" . each . the @"steps"  . _Just . each
+
+  forM_ theSteps $ \case
+      StepOfUp upStep -> do
+        info $ "Executing statement: " <> tshow upStep
+
+        let statement = upStep ^. the @"up" . the @1
+
+        response <- executeStatement statement
+
+        info $ "Results: " <> T.decodeUtf8 (LBS.toStrict (J.encode (response ^. the @"records")))
+      StepOfDown _ -> pure ()
+      StepOfCreateTable createTableStatement -> do
+        columnClauses <- pure $
+          createTableStatement ^.. the @"createTable" . the @"columns" . each . to columnToText
+
+        primaryKeyClause <- pure $
+          createTableStatement ^.. the @"createTable" . the @"primaryKey" . _Just . to primaryKeyToText
+
+        constraintClauses <- pure $
+          createTableStatement ^.. the @"createTable" . the @"constraints" . _Just . each . to constraintToText
+
+        statement <- pure $ mconcat
+          [ "CREATE TABLE " <> createTableStatement ^. the @"createTable" . the @"name" <> " ("
+          , mconcat $ L.intersperse ", " (columnClauses <> primaryKeyClause <> constraintClauses)
+          , ");\n"
+          ]
+
+        info $ "Executing create table statement: " <> statement
+
+        response <- executeStatement statement
+
+        info $ "Results: " <> T.decodeUtf8 (LBS.toStrict (J.encode (response ^. the @"records")))
+      StepOfCreateIndex createIndexStatement -> do
+        columnClauses <- pure $
+          createIndexStatement ^.. the @"createIndex" . the @"columns" . each
+
+        statement <- pure $ mconcat
+          [ "CREATE INDEX " <> createIndexStatement ^. the @"createIndex" . the @"name"
+          , " ON " <> createIndexStatement ^. the @"createIndex" . the @"table" <> " ("
+          , mconcat $ L.intersperse ", " columnClauses
+          , ");\n"
+          ]
+
+        info $ "Executing  create index statement: " <> statement
+
+        response <- executeStatement statement
+
+        info $ "Results: " <> T.decodeUtf8 (LBS.toStrict (J.encode (response ^. the @"records")))
+
+columnToText :: Column -> Text
+columnToText c =
+  T.intercalate " " $ concat
+    [ [c ^. the @"name"]
+    , [c ^. the @"type_"]
+    , [ "NOT NULL"
+      | c ^. the @"required"
+      ]
+    , [ "PRIMARY KEY"
+      | c ^. the @"primaryKey"
+      ]
+    , [ "UNIQUE"
+      | c ^. the @"unique"
+      ]
+    , [ "AUTO_INCREMENT"
+      | c ^. the @"autoIncrement"
+      ]
+    , [ [ "REFERENCES"
+        , fk ^. the @"table"
+        , "("
+        , fk ^. the @"column"
+        , ")"
+        ] & T.intercalate " "
+      | Just fk <- [c ^. the @"references"]
+      ]
+    ]
+
+primaryKeyToText :: [Text] -> Text
+primaryKeyToText cs =
+  T.intercalate " "
+    [ "PRIMARY KEY"
+    , "("
+    , T.intercalate ", " cs
+    , ")"
+    ]
+
+constraintToText :: Constraint -> Text
+constraintToText c =
+  T.intercalate " "
+    [ "CONSTRAINT"
+    , c ^. the @"name"
+    , "CHECK"
+    , "("
+    , c ^. the @"check"
+    , ")"
+    ]
diff --git a/rds-data-polysemy.cabal b/rds-data-polysemy.cabal
new file mode 100644
--- /dev/null
+++ b/rds-data-polysemy.cabal
@@ -0,0 +1,327 @@
+cabal-version: 3.6
+
+name:                   rds-data-polysemy
+version:                0.1.0.0
+synopsis:               Codecs for use with AWS rds-data
+description:            Codecs for use with AWS rds-data.
+category:               Data
+stability:              Experimental
+homepage:               http://github.com/haskell-works/rds-data#readme
+bug-reports:            https://github.com/haskell-works/rds-data/issues
+author:                 John Ky
+maintainer:             newhoggy@gmail.com
+copyright:              2024 John Ky
+license:                BSD-3-Clause
+license-file:           LICENSE
+tested-with:            GHC == 9.6.6, GHC == 9.4.8
+build-type:             Simple
+extra-source-files:     README.md
+
+source-repository head
+  type:                 git
+  location:             https://github.com/haskell-works/rds-data
+
+common base                                     { build-depends: base                                     >= 4.11       && < 5      }
+
+common aeson                                    { build-depends: aeson                                    >= 2.2        && < 2.3    }
+common aeson-pretty                             { build-depends: aeson-pretty                             >= 0.8        && < 1.0    }
+common amazonka                                 { build-depends: amazonka                                 >= 2.0        && < 3      }
+common amazonka-core                            { build-depends: amazonka-core                            >= 2.0        && < 3      }
+common amazonka-rds                             { build-depends: amazonka-rds                             >= 2.0        && < 3      }
+common amazonka-rds-data                        { build-depends: amazonka-rds-data                        >= 2.0        && < 3      }
+common amazonka-secretsmanager                  { build-depends: amazonka-secretsmanager                  >= 2.0        && < 3      }
+common base64-bytestring                        { build-depends: base64-bytestring                        >= 1.2.1      && < 2      }
+common bytestring                               { build-depends: bytestring                               >= 0.11       && < 0.13   }
+common contravariant                            { build-depends: contravariant                            >= 1.5.5      && < 2      }
+common doctest                                  { build-depends: doctest                                  >= 0.22.2     && < 0.23   }
+common doctest-discover                         { build-depends: doctest-discover                         >= 0.2        && < 0.3    }
+common generic-lens                             { build-depends: generic-lens                             >= 2.2.2.0    && < 3      }
+common hedgehog                                 { build-depends: hedgehog                                 >= 1.4        && < 2      }
+common hedgehog-extras                          { build-depends: hedgehog-extras                          >= 0.6.0.2    && < 0.7    }
+common http-client                              { build-depends: http-client                              >= 0.5.14     && < 0.8    }
+common hw-polysemy-amazonka                     { build-depends: hw-polysemy:amazonka                     >= 0.3.1.1    && < 0.4    }
+common hw-polysemy-core                         { build-depends: hw-polysemy:core                         >= 0.3.1.1    && < 0.4    }
+common hw-polysemy-hedgehog                     { build-depends: hw-polysemy:hedgehog                     >= 0.3.1.1    && < 0.4    }
+common hw-polysemy-testcontainers-localstack    { build-depends: hw-polysemy:testcontainers-localstack    >= 0.3.1.1    && < 0.4    }
+common hw-prelude                               { build-depends: hw-prelude                               >= 0.0.1.0    && < 0.1    }
+common microlens                                { build-depends: microlens                                >= 0.4.13     && < 0.5    }
+common mtl                                      { build-depends: mtl                                      >= 2          && < 3      }
+common optparse-applicative                     { build-depends: optparse-applicative                     >= 0.18.1.0   && < 0.19   }
+common polysemy                                 { build-depends: polysemy                                 >= 1.9.2      && < 2      }
+common polysemy-log                             { build-depends: polysemy-log                             >= 0.10.0.0   && < 1      }
+common polysemy-plugin                          { build-depends: polysemy-plugin                          >= 0.4.5.2    && < 1      }
+common polysemy-time                            { build-depends: polysemy-time                            >= 0.7        && < 1      }
+common resourcet                                { build-depends: resourcet                                >= 1.3        && < 2      }
+common stm                                      { build-depends: stm                                      >= 2.5        && < 3      }
+common tasty                                    { build-depends: tasty                                    >= 1.4        && < 2      }
+common tasty-discover                           { build-depends: tasty-discover                           >= 5          && < 6      }
+common tasty-hedgehog                           { build-depends: tasty-hedgehog                           >= 1.4        && < 2      }
+common testcontainers                           { build-depends: testcontainers                           >= 0.5.0.0    && < 1      }
+common text                                     { build-depends: text                                     >= 2.1        && < 3      }
+common time                                     { build-depends: time                                     >= 1.12.2     && < 2      }
+common transformers                             { build-depends: transformers                             >= 0.5        && < 0.7    }
+common ulid                                     { build-depends: ulid                                     >= 0.3.2.0    && < 0.4    }
+common uuid                                     { build-depends: uuid                                     >= 1.3.15     && < 1.4    }
+
+common rds-data-codecs                          { build-depends: rds-data:codecs                          }
+common rds-data-polysemy                        { build-depends: rds-data:polysemy                        }
+common rds-data-testlib                         { build-depends: rds-data:testlib                         }
+
+common project-config
+  default-language:     Haskell2010
+  default-extensions:   BlockArguments
+                        DataKinds
+                        DeriveGeneric
+                        DuplicateRecordFields
+                        FlexibleContexts
+                        FlexibleInstances
+                        ImportQualifiedPost
+                        LambdaCase
+                        NoFieldSelectors
+                        OverloadedRecordDot
+                        OverloadedStrings
+                        RankNTypes
+                        ScopedTypeVariables
+                        TypeApplications
+                        TypeOperators
+                        TypeSynonymInstances
+
+  ghc-options:          -Wall
+                        -Wcompat
+                        -Wincomplete-record-updates
+                        -Wincomplete-uni-patterns
+                        -Wno-unticked-promoted-constructors
+                        -Wpartial-fields
+                        -Wredundant-constraints
+                        -Wunused-imports
+
+library codecs
+  import:               base, project-config
+                      , aeson
+                      , amazonka-core
+                      , amazonka-rds
+                      , amazonka-rds-data
+                      , amazonka-secretsmanager
+                      , base64-bytestring
+                      , bytestring
+                      , contravariant
+                      , generic-lens
+                      , hw-prelude
+                      , microlens
+                      , mtl
+                      , text
+                      , time
+                      , transformers
+                      , ulid
+                      , uuid
+  visibility:           public
+  exposed-modules:      Data.RdsData
+                        Data.RdsData.Aws
+                        Data.RdsData.Decode.Array
+                        Data.RdsData.Decode.Row
+                        Data.RdsData.Decode.ToRows
+                        Data.RdsData.Decode.Value
+                        Data.RdsData.Default
+                        Data.RdsData.Encode.Array
+                        Data.RdsData.Encode.Param
+                        Data.RdsData.Encode.Params
+                        Data.RdsData.Encode.Row
+                        Data.RdsData.Encode.ToSqlParameters
+                        Data.RdsData.Encode.Value
+                        Data.RdsData.Internal.Aeson
+                        Data.RdsData.Internal.Convert
+                        Data.RdsData.Internal.Maybe
+                        Data.RdsData.Internal.Show
+                        Data.RdsData.Migration
+                        Data.RdsData.Migration.Types
+                        Data.RdsData.Orphans
+                        Data.RdsData.Types
+                        Data.RdsData.Types.Array
+                        Data.RdsData.Types.EncodedParams
+                        Data.RdsData.Types.Param
+                        Data.RdsData.Types.Value
+  other-modules:        Paths_rds_data_polysemy
+  autogen-modules:      Paths_rds_data_polysemy
+  hs-source-dirs:       src
+
+library polysemy
+  import:               base, project-config
+                      , aeson
+                      , amazonka
+                      , amazonka-core
+                      , amazonka-rds-data
+                      , bytestring
+                      , contravariant
+                      , generic-lens
+                      , hw-polysemy-amazonka
+                      , hw-polysemy-core
+                      , hw-prelude
+                      , microlens
+                      , mtl
+                      , polysemy
+                      , polysemy-log
+                      , polysemy-plugin
+                      , rds-data-codecs
+                      , text
+                      , time
+                      , transformers
+                      , ulid
+                      , uuid
+  visibility:           public
+  exposed-modules:      Data.RdsData.Polysemy
+                        Data.RdsData.Polysemy.Core
+                        Data.RdsData.Polysemy.Error
+                        Data.RdsData.Polysemy.Migration
+  ghc-options:          -fplugin=Polysemy.Plugin
+  hs-source-dirs:       polysemy
+
+library testlib
+  import:               base, project-config
+                      , aeson
+                      , amazonka
+                      , amazonka-core
+                      , amazonka-rds
+                      , amazonka-rds-data
+                      , amazonka-secretsmanager
+                      , base64-bytestring
+                      , bytestring
+                      , contravariant
+                      , generic-lens
+                      , hw-polysemy-amazonka
+                      , hw-polysemy-core
+                      , hw-polysemy-hedgehog
+                      , hw-polysemy-testcontainers-localstack
+                      , hw-prelude
+                      , microlens
+                      , mtl
+                      , polysemy
+                      , polysemy-log
+                      , polysemy-plugin
+                      , rds-data-codecs
+                      , rds-data-polysemy
+                      , text
+                      , time
+                      , transformers
+                      , ulid
+                      , uuid
+  visibility:           public
+  exposed-modules:      Data.RdsData.Polysemy.Test.Cluster
+                        Data.RdsData.Polysemy.Test.Env
+                        Data.RdsData.Polysemy.Test.Workspace
+  ghc-options:          -fplugin=Polysemy.Plugin
+  hs-source-dirs:       testlib
+
+executable rds-data
+  import:               base, project-config
+                      , aeson
+                      , amazonka
+                      , amazonka-rds-data
+                      , bytestring
+                      , generic-lens
+                      , hedgehog
+                      , http-client
+                      , hw-polysemy-amazonka
+                      , hw-polysemy-core
+                      , hw-polysemy-hedgehog
+                      , hw-polysemy-testcontainers-localstack
+                      , hw-prelude
+                      , microlens
+                      , optparse-applicative
+                      , polysemy
+                      , polysemy-log
+                      , polysemy-plugin
+                      , polysemy-time
+                      , resourcet
+                      , rds-data-codecs
+                      , rds-data-polysemy
+                      , rds-data-testlib
+                      , stm
+                      , testcontainers
+                      , text
+                      , time
+                      , ulid
+                      , uuid
+  main-is:              Main.hs
+  ghc-options:          -fplugin=Polysemy.Plugin
+  other-modules:        App.AWS.Env
+                        App.Cli.Options
+                        App.Cli.Run
+                        App.Cli.Run.BatchExecuteStatement
+                        App.Cli.Run.Down
+                        App.Cli.Run.Example
+                        App.Cli.Run.ExecuteStatement
+                        App.Cli.Run.LocalStack
+                        App.Cli.Run.Up
+                        App.Cli.Types
+                        App.Config
+                        App.Console
+                        App.Options
+  hs-source-dirs:       app
+  ghc-options:          -threaded "-with-rtsopts=-I0 -A16m -N2 --disable-delayed-os-memory-return"
+
+test-suite rds-data-test
+  import:               base, project-config
+                      , aeson
+                      , aeson-pretty
+                      , amazonka-rds-data
+                      , bytestring
+                      , generic-lens
+                      , hedgehog
+                      , hedgehog-extras
+                      , microlens
+                      , rds-data-codecs
+                      , tasty
+                      , tasty-hedgehog
+                      , text
+                      , time
+                      , ulid
+                      , uuid
+  type:                 exitcode-stdio-1.0
+  main-is:              Spec.hs
+  other-modules:        Data.RdsData.TestTypes
+                        Data.RdsDataSpec
+  hs-source-dirs:       test
+  ghc-options:          -threaded -rtsopts -with-rtsopts=-N
+  build-tool-depends:   tasty-discover:tasty-discover
+
+test-suite rds-data-integration
+  import:               base, project-config
+                      , aeson
+                      , aeson-pretty
+                      , amazonka
+                      , amazonka-core
+                      , amazonka-rds
+                      , amazonka-rds-data
+                      , amazonka-secretsmanager
+                      , base64-bytestring
+                      , bytestring
+                      , generic-lens
+                      , hedgehog
+                      , hedgehog-extras
+                      , hw-polysemy-amazonka
+                      , hw-polysemy-core
+                      , hw-polysemy-hedgehog
+                      , hw-polysemy-testcontainers-localstack
+                      , microlens
+                      , polysemy
+                      , polysemy-log
+                      , polysemy-plugin
+                      , rds-data-codecs
+                      , rds-data-polysemy
+                      , rds-data-testlib
+                      , tasty
+                      , tasty-discover
+                      , tasty-hedgehog
+                      , testcontainers
+                      , text
+                      , time
+                      , ulid
+                      , uuid
+  type:                 exitcode-stdio-1.0
+  main-is:              Spec.hs
+  other-modules:        Test.Data.RdsData.Migration.ConnectionSpec
+                        Test.Data.RdsData.TestTypes
+  ghc-options:          -fplugin=Polysemy.Plugin
+  hs-source-dirs:       integration
+  ghc-options:          -threaded -rtsopts -with-rtsopts=-N
+  build-tool-depends:   tasty-discover:tasty-discover
diff --git a/src/Data/RdsData.hs b/src/Data/RdsData.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData.hs
@@ -0,0 +1,3 @@
+module Data.RdsData where
+
+import Data.RdsData.Types ()
diff --git a/src/Data/RdsData/Aws.hs b/src/Data/RdsData/Aws.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Aws.hs
@@ -0,0 +1,33 @@
+{-# LANGUAGE DeriveGeneric              #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+module Data.RdsData.Aws
+  ( AwsResourceArn(AwsResourceArn),
+    AwsSecretArn(AwsSecretArn),
+    Database(..),
+    StatementContext(StatementContext),
+    newStatementContext,
+  ) where
+
+import           Data.String  (IsString)
+import           Data.Text    (Text)
+import           GHC.Generics
+
+newtype AwsResourceArn = AwsResourceArn Text
+  deriving (Eq, Generic, IsString, Show)
+
+newtype AwsSecretArn = AwsSecretArn Text
+  deriving (Eq, Generic, IsString, Show)
+
+newtype Database = Database { unDatabase :: Text }
+  deriving (Eq, Generic, IsString, Show)
+
+data StatementContext = StatementContext
+  { resourceArn :: AwsResourceArn
+  , secretArn   :: AwsSecretArn
+  , database    :: Maybe Database
+  } deriving (Eq, Generic, Show)
+
+newStatementContext :: AwsResourceArn -> AwsSecretArn -> StatementContext
+newStatementContext theResourceArn theSecretArn =
+  StatementContext theResourceArn theSecretArn Nothing
diff --git a/src/Data/RdsData/Decode/Array.hs b/src/Data/RdsData/Decode/Array.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Decode/Array.hs
@@ -0,0 +1,216 @@
+{-# LANGUAGE BlockArguments    #-}
+{-# LANGUAGE DataKinds         #-}
+{-# LANGUAGE DeriveFunctor     #-}
+{-# LANGUAGE LambdaCase        #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- HLINT ignore "Use <&>" -}
+
+module Data.RdsData.Decode.Array
+  ( DecodeArray(..)
+
+  , decodeArrayFailedMessage
+
+  , arrays
+  , bools
+  , doubles
+  , integers
+  , texts
+
+  , days
+  , int16s
+  , int32s
+  , int64s
+  , int8s
+  , ints
+  , jsons
+  , lazyTexts
+  , strings
+  , timesOfDay
+  , ulids
+  , utcTimes
+  , uuids
+  , word16s
+  , word32s
+  , word64s
+  , word8s
+  , words
+  ) where
+
+import           Control.Applicative
+import           Data.Int
+import           Data.RdsData.Internal.Aeson
+import           Data.RdsData.Types.Array
+import           Data.Text                     (Text)
+import           Data.Time
+import           Data.ULID                     (ULID)
+import           Data.UUID                     (UUID)
+import           Data.Word
+import           Prelude                       hiding (maybe, null, words)
+
+import qualified Data.Aeson                    as J
+import qualified Data.RdsData.Internal.Convert as CONV
+import qualified Data.Text                     as T
+import qualified Data.Text.Encoding            as T
+import qualified Data.Text.Lazy                as LT
+import qualified Data.UUID                     as UUID
+import qualified Prelude                       as P
+
+newtype DecodeArray a = DecodeArray
+  { decodeArray :: Array -> Either Text a
+  } deriving Functor
+
+instance Applicative DecodeArray where
+  pure a = DecodeArray \_ -> Right a
+  DecodeArray f <*> DecodeArray a = DecodeArray \v -> f v <*> a v
+
+instance Alternative DecodeArray where
+  empty = DecodeArray \_ -> Left "empty"
+  DecodeArray a <|> DecodeArray b = DecodeArray \v ->
+    either (const (b v)) Right (a v)
+
+instance Monad DecodeArray where
+  DecodeArray a >>= f = DecodeArray \v -> do
+    a' <- a v
+    (.decodeArray) (f a') v
+
+--------------------------------------------------------------------------------
+
+decodeArrayFailedMessage :: Text -> Text -> Maybe Text -> Array -> Text
+decodeArrayFailedMessage item type_ reason value =
+  mconcat
+    [ "Failed to decode " <> item <> " of type " <> type_ <> " from Array of " <> toJsonText value
+    , P.maybe "" (" because " <>) reason
+    ]
+
+--------------------------------------------------------------------------------
+
+integers :: DecodeArray [Integer]
+integers =
+  DecodeArray \v ->
+    case v of
+      ArrayOfIntegers es -> Right es
+      _ -> Left $ decodeArrayFailedMessage "integers" "ArrayOfIntegers" Nothing v
+
+texts :: DecodeArray [Text]
+texts =
+  DecodeArray \v ->
+    case v of
+      ArrayOfTexts es -> Right es
+      _ -> Left $ decodeArrayFailedMessage "texts" "ArrayOfStrings" Nothing v
+
+bools :: DecodeArray [Bool]
+bools =
+  DecodeArray \v ->
+    case v of
+      ArrayOfBools es -> Right es
+      _ -> Left $ decodeArrayFailedMessage "bools" "ArrayOfBooleans" Nothing v
+
+doubles :: DecodeArray [Double]
+doubles =
+  DecodeArray \v ->
+    case v of
+      ArrayOfDoubles es -> Right es
+      _ -> Left $ decodeArrayFailedMessage "doubles" "ArrayOfDoubles" Nothing v
+
+arrays :: DecodeArray [Array]
+arrays =
+  DecodeArray \v ->
+    case v of
+      ArrayOfArrays es -> Right es
+      _ -> Left $ decodeArrayFailedMessage "arrays" "ArrayOfArrays" Nothing v
+
+--------------------------------------------------------------------------------
+
+ints :: DecodeArray [Int]
+ints =
+  fmap fromIntegral <$> integers
+
+int8s :: DecodeArray [Int8]
+int8s =
+  fmap fromIntegral <$> integers
+
+int16s :: DecodeArray [Int16]
+int16s =
+  fmap fromIntegral <$> integers
+
+int32s :: DecodeArray [Int32]
+int32s =
+  fmap fromIntegral <$> integers
+
+int64s :: DecodeArray [Int64]
+int64s =
+  fmap fromIntegral <$> integers
+
+words :: DecodeArray [Word]
+words =
+  fmap fromIntegral <$> integers
+
+word8s :: DecodeArray [Word8]
+word8s =
+  fmap fromIntegral <$> integers
+
+word16s :: DecodeArray [Word16]
+word16s =
+  fmap fromIntegral <$> integers
+
+word32s :: DecodeArray [Word32]
+word32s =
+  fmap fromIntegral <$> integers
+
+word64s :: DecodeArray [Word64]
+word64s =
+  fmap fromIntegral <$> integers
+
+lazyTexts :: DecodeArray [LT.Text]
+lazyTexts =
+  fmap LT.fromStrict <$> texts
+
+strings :: DecodeArray [String]
+strings =
+  fmap T.unpack <$> texts
+
+jsons  :: DecodeArray [J.Value]
+jsons = do
+  ts <- texts
+  case traverse (J.eitherDecodeStrict' . T.encodeUtf8) ts of
+    Right js -> pure js
+    Left e   -> DecodeArray \_ -> Left $ "Failed to decode JSON: " <> T.pack e
+
+timesOfDay :: DecodeArray [TimeOfDay]
+timesOfDay = do
+  ts <- texts
+  case traverse (parseTimeM True defaultTimeLocale "%H:%M:%S". T.unpack) ts of
+    Just tod -> pure tod
+    Nothing  -> DecodeArray \_ -> Left "Failed to decode TimeOfDay"
+
+utcTimes :: DecodeArray [UTCTime]
+utcTimes = do
+  ts <- texts
+  case traverse (parseTimeM True defaultTimeLocale "%Y-%m-%d %H:%M:%S" . T.unpack) ts of
+    Just utct -> pure utct
+    Nothing   -> DecodeArray \_ -> Left "Failed to decode UTCTime"
+
+days :: DecodeArray [Day]
+days = do
+  ts <- texts
+  case traverse (parseTimeM True defaultTimeLocale "%Y-%m-%d" . T.unpack) ts of
+    Just d  -> pure d
+    Nothing -> DecodeArray \_ -> Left "Failed to decode Day"
+
+-- | Decode an array of ULIDs
+-- ULIDs are encoded as strings in the database and have have better database performance
+-- than UUIDs stored as strings in the database.
+ulids :: DecodeArray [ULID]
+ulids = do
+  ts <- texts
+  case traverse CONV.textToUlid ts of
+    Right u  -> pure u
+    Left msg -> DecodeArray \_ -> Left $ "Failed to decode UUID: " <> msg
+
+uuids :: DecodeArray [UUID]
+uuids = do
+  ts <- texts
+  case traverse (UUID.fromString . T.unpack) ts of
+    Just u  -> pure u
+    Nothing -> DecodeArray \_ -> Left "Failed to decode UUID"
diff --git a/src/Data/RdsData/Decode/Row.hs b/src/Data/RdsData/Decode/Row.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Decode/Row.hs
@@ -0,0 +1,234 @@
+{-# LANGUAGE BlockArguments             #-}
+{-# LANGUAGE FlexibleContexts           #-}
+{-# LANGUAGE GeneralisedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings          #-}
+
+module Data.RdsData.Decode.Row
+  ( DecodeRow(..)
+  , ToRows(..)
+  , integer
+  , int
+  , int8
+  , int16
+  , int32
+  , int64
+  , bool
+  , double
+  , string
+  , text
+  , lazyText
+  , word
+  , word8
+  , word16
+  , word32
+  , word64
+  , bytestring
+  , lazyBytestring
+  , base64Text
+  , lazyBase64Text
+  , timeOfDay
+  , day
+  , ulid
+  , utcTime
+  , uuid
+  , ignore
+  , json
+  , maybe
+  , column
+  , decodeRow
+  , decodeRows
+  ) where
+
+import           Control.Monad
+import           Control.Monad.Except
+import           Control.Monad.State
+import           Data.ByteString               (ByteString)
+import           Data.Functor.Identity         (Identity)
+import           Data.Int
+import           Data.RdsData.Decode.Value     (DecodeValue)
+import           Data.RdsData.Internal.Show
+import           Data.RdsData.Types.Value
+import           Data.Text
+import           Data.Time
+import           Data.ULID                     (ULID)
+import           Data.UUID                     (UUID)
+import           Data.Word
+import           Prelude                       hiding (maybe)
+
+import qualified Data.Aeson                    as J
+import qualified Data.ByteString.Lazy          as LBS
+import           Data.RdsData.Decode.ToRows
+import qualified Data.RdsData.Decode.Value     as DV
+import qualified Data.RdsData.Internal.Convert as CONV
+import qualified Data.Text                     as T
+import qualified Data.Text.Lazy                as LT
+import qualified Data.UUID                     as UUID
+
+newtype DecodeRow a = DecodeRow
+  { run :: ExceptT Text (StateT [Value] Identity) a
+  }
+  deriving (Applicative, Functor, Monad, MonadState [Value], MonadError Text)
+
+instance MonadFail DecodeRow where
+  fail = DecodeRow . throwError . pack
+
+maybe :: DecodeRow a -> DecodeRow (Maybe a)
+maybe r = do
+  cs <- get
+  case cs of
+    ValueOfNull : vs -> do
+      put vs
+      pure Nothing
+    _ -> Just <$> r
+
+decodeRowValue :: ()
+  => MonadError Text m
+  => DecodeValue a
+  -> Value
+  -> m a
+decodeRowValue decoder v =
+  case decoder.decodeValue v of
+    Right a -> pure a
+    Left e  -> throwError $ "Failed to decode Value: " <> e
+
+column :: ()
+  => DecodeValue a
+  -> DecodeRow a
+column decoder = do
+  cs <- get
+  case cs of
+    v : vs -> do
+      s <- decodeRowValue decoder v
+      put vs
+      pure s
+    [] -> do
+      throwError "Expected RdsText, but got no more values in row."
+
+integer :: DecodeRow Integer
+integer =
+  column DV.integer
+
+int :: DecodeRow Int
+int =
+  column DV.int
+
+int8 :: DecodeRow Int8
+int8 =
+  column DV.int8
+
+int16 :: DecodeRow Int16
+int16 =
+  column DV.int16
+
+int32 :: DecodeRow Int32
+int32 =
+  column DV.int32
+
+int64 :: DecodeRow Int64
+int64 =
+  column DV.int64
+
+word :: DecodeRow Word
+word =
+  column DV.word
+
+word8 :: DecodeRow Word8
+word8 =
+  column DV.word8
+
+word16 :: DecodeRow Word16
+word16 =
+  column DV.word16
+
+word32 :: DecodeRow Word32
+word32 =
+  column DV.word32
+
+word64 :: DecodeRow Word64
+word64 =
+  column DV.word64
+
+text :: DecodeRow Text
+text =
+  column DV.text
+
+lazyText :: DecodeRow LT.Text
+lazyText =
+  column DV.lazyText
+
+bool :: DecodeRow Bool
+bool =
+  column DV.bool
+
+double :: DecodeRow Double
+double =
+  column DV.double
+
+bytestring :: DecodeRow ByteString
+bytestring =
+  column DV.bytestring
+
+lazyBytestring :: DecodeRow LBS.ByteString
+lazyBytestring =
+  column DV.lazyBytestring
+
+base64Text :: DecodeRow ByteString
+base64Text =
+  column DV.base64Text
+
+lazyBase64Text :: DecodeRow LBS.ByteString
+lazyBase64Text =
+  column DV.lazyBase64Text
+
+string :: DecodeRow String
+string =
+  column DV.string
+
+json :: DecodeRow J.Value
+json =
+  column DV.json
+
+timeOfDay :: DecodeRow TimeOfDay
+timeOfDay = do
+  t <- text
+  case parseTimeM True defaultTimeLocale "%H:%M:%S%Q" (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> throwError $ "Failed to parse TimeOfDay: " <> tshow t
+
+ulid :: DecodeRow ULID
+ulid = do
+  t <- text
+  case CONV.textToUlid t of
+    Right a  -> pure a
+    Left msg -> throwError $ "Failed to parse ULID: " <> msg
+
+utcTime :: DecodeRow UTCTime
+utcTime = do
+  t <- text
+  case parseTimeM True defaultTimeLocale "%Y-%m-%d %H:%M:%S" (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> throwError $ "Failed to parse UTCTime: " <> tshow t
+
+uuid :: DecodeRow UUID
+uuid = do
+  t <- text
+  case UUID.fromString (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> throwError $ "Failed to parse UUID: " <> tshow t
+
+day :: DecodeRow Day
+day = do
+  t <- text
+  case parseTimeM True defaultTimeLocale "%Y-%m-%d" (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> throwError $ "Failed to parse Day: " <> tshow t
+
+ignore :: DecodeRow ()
+ignore =
+  void $ column DV.rdsValue
+
+decodeRow :: DecodeRow a -> [Value] -> Either Text a
+decodeRow r = evalState (runExceptT r.run)
+
+decodeRows :: ToRows res => DecodeRow a -> res -> Either Text [a]
+decodeRows r = traverse (decodeRow r) . toRows
diff --git a/src/Data/RdsData/Decode/ToRows.hs b/src/Data/RdsData/Decode/ToRows.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Decode/ToRows.hs
@@ -0,0 +1,19 @@
+module Data.RdsData.Decode.ToRows
+  ( ToRows(..)
+  ) where
+
+import qualified Amazonka.RDSData          as AWS
+import           Data.Generics.Product.Any
+import           Data.Maybe
+import           Data.RdsData.Types
+import           Lens.Micro                ((^.))
+
+class ToRows a where
+    toRows :: a -> [[Value]]
+
+instance ToRows [[Value]] where
+    toRows = id
+
+instance ToRows AWS.ExecuteStatementResponse where
+    toRows res =
+        fromMaybe [] $ mapM (mapM fromField) =<< res ^. the @"records"
diff --git a/src/Data/RdsData/Decode/Value.hs b/src/Data/RdsData/Decode/Value.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Decode/Value.hs
@@ -0,0 +1,314 @@
+{-# LANGUAGE BlockArguments    #-}
+{-# LANGUAGE DataKinds         #-}
+{-# LANGUAGE DeriveFunctor     #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- HLINT ignore "Use <&>" -}
+
+module Data.RdsData.Decode.Value
+  ( DecodeValue(..)
+
+  , rdsValue
+  , decodeValueFailedMessage
+  , decodeValueFailed
+  , maybe
+  , array
+  , base64
+  , bool
+  , double
+  , text
+  , integer
+  , null
+  , int
+  , int8
+  , int16
+  , int32
+  , int64
+  , word
+  , word8
+  , word16
+  , word32
+  , word64
+  , bytestring
+  , lazyText
+  , lazyBytestring
+  , base64Text
+  , lazyBase64Text
+  , string
+  , json
+  , timeOfDay
+  , utcTime
+  , uuid
+  , day
+
+  , fail
+
+  ) where
+
+import           Amazonka.Data.Base64
+import           Control.Applicative
+import           Data.ByteString               (ByteString)
+import           Data.Int
+import           Data.RdsData.Decode.Array     (DecodeArray (..))
+import           Data.RdsData.Internal.Aeson
+import           Data.RdsData.Internal.Show
+import           Data.RdsData.Types.Value
+import           Data.Text                     (Text)
+import           Data.Time
+import           Data.UUID                     (UUID)
+import           Data.Word
+import           Prelude                       hiding (fail, maybe, null)
+
+import qualified Data.Aeson                    as J
+import qualified Data.ByteString.Base64        as B64
+import qualified Data.ByteString.Base64.Lazy   as LB64
+import qualified Data.ByteString.Lazy          as LBS
+import qualified Data.RdsData.Internal.Convert as CONV
+import qualified Data.Text                     as T
+import qualified Data.Text.Encoding            as T
+import qualified Data.Text.Lazy                as LT
+import qualified Data.Text.Lazy.Encoding       as LT
+import qualified Data.UUID                     as UUID
+import qualified Prelude                       as P
+
+newtype DecodeValue a = DecodeValue
+  { decodeValue :: Value -> Either Text a
+  } deriving Functor
+
+instance Applicative DecodeValue where
+  pure a = DecodeValue \_ -> Right a
+  DecodeValue f <*> DecodeValue a = DecodeValue \v -> f v <*> a v
+
+instance Alternative DecodeValue where
+  empty = DecodeValue \_ -> Left "empty"
+  DecodeValue a <|> DecodeValue b = DecodeValue \v ->
+    either (const (b v)) Right (a v)
+
+instance Monad DecodeValue where
+  DecodeValue a >>= f = DecodeValue \v -> do
+    a' <- a v
+    (.decodeValue) (f a') v
+
+fail :: Text -> DecodeValue a
+fail =
+    DecodeValue . const . Left
+
+--------------------------------------------------------------------------------
+
+rdsValue :: DecodeValue Value
+rdsValue =
+  DecodeValue Right
+
+decodeValueFailedMessage :: Text -> Text -> Maybe Text -> Value -> Text
+decodeValueFailedMessage item type_ reason value =
+  mconcat
+    [ "Failed to decode " <> item <> " of type " <> type_ <> " from Value of " <> toJsonText value
+    , P.maybe "" (" because " <>) reason
+    ]
+
+decodeValueFailed :: Text -> Text -> Maybe Text -> DecodeValue a
+decodeValueFailed value type_ reason =
+  DecodeValue $ Left . decodeValueFailedMessage value type_ reason
+
+--------------------------------------------------------------------------------
+
+maybe :: DecodeValue a -> DecodeValue (Maybe a)
+maybe (DecodeValue f) =
+  DecodeValue \v ->
+    case v of
+      ValueOfNull -> Right Nothing
+      _           -> Just <$> f v
+
+--------------------------------------------------------------------------------
+
+array :: DecodeArray a -> DecodeValue a
+array decoder =
+  DecodeValue \v ->
+    case v of
+      ValueOfArray a -> decoder.decodeArray a
+      _ -> Left $ decodeValueFailedMessage "array" "Array" Nothing v
+
+base64 :: DecodeValue Base64
+base64 =
+  DecodeValue \v ->
+    case v of
+      ValueOfBase64 b64 -> Right b64
+      _ -> Left $ decodeValueFailedMessage "base64" "Base64" Nothing v
+
+bool :: DecodeValue Bool
+bool =
+  DecodeValue \v ->
+    case v of
+      ValueOfBool b -> Right b
+      _             -> Left $ decodeValueFailedMessage "bool" "Bool" Nothing v
+
+double :: DecodeValue Double
+double =
+  DecodeValue \v ->
+    case v of
+      ValueOfDouble n -> Right n
+      ValueOfText t ->
+        case CONV.textToDouble t of
+          Just n -> Right n
+          Nothing -> Left $ decodeValueFailedMessage "double" "Double" (Just "failed to parse text as double") v
+      _ -> Left $ decodeValueFailedMessage "double" "Double" Nothing v
+
+text :: DecodeValue Text
+text =
+  DecodeValue \v ->
+    case v of
+      ValueOfText s -> Right s
+      _             -> Left $ decodeValueFailedMessage "text" "Text" Nothing v
+
+integer :: DecodeValue Integer
+integer =
+  DecodeValue \v ->
+    case v of
+      ValueOfInteger n -> Right n
+      _ -> Left $ decodeValueFailedMessage "integer" "Integer" Nothing v
+
+null :: DecodeValue ()
+null =
+  DecodeValue \v ->
+    case v of
+      ValueOfNull -> Right ()
+      _           -> Left $ decodeValueFailedMessage "null" "()" Nothing v
+
+--------------------------------------------------------------------------------
+
+int :: DecodeValue Int
+int =
+  asum
+    [ fromIntegral <$> int64
+    , decodeValueFailed "int" "Int" Nothing
+    ]
+
+int8 :: DecodeValue Int8
+int8 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "int8" "Int8" Nothing
+    ]
+
+int16 :: DecodeValue Int16
+int16 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "int16" "Int16" Nothing
+    ]
+
+int32 :: DecodeValue Int32
+int32 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "int32" "Int32" Nothing
+    ]
+
+int64 :: DecodeValue Int64
+int64 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "int64" "Int64" Nothing
+    ]
+
+word :: DecodeValue Word
+word =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "word" "Word" Nothing
+    ]
+
+word8 :: DecodeValue Word8
+word8 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "word8" "Word8" Nothing
+    ]
+
+word16 :: DecodeValue Word16
+word16 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "word16" "Word16" Nothing
+    ]
+
+word32 :: DecodeValue Word32
+word32 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "word32" "Word32" Nothing
+    ]
+
+word64 :: DecodeValue Word64
+word64 =
+  asum
+    [ fromIntegral <$> integer
+    , decodeValueFailed "word64" "Word64" Nothing
+    ]
+
+bytestring :: DecodeValue ByteString
+bytestring =
+  unBase64 <$> base64
+
+lazyText :: DecodeValue LT.Text
+lazyText =
+  LT.fromStrict <$> text
+
+base64Text :: DecodeValue ByteString
+base64Text = do
+  t <- text
+  let b64 = T.encodeUtf8 t
+  case B64.decode b64 of
+    Right a -> pure a
+    Left e  -> decodeValueFailed "base64-text" "Text" (Just (T.pack e))
+
+lazyBase64Text :: DecodeValue LBS.ByteString
+lazyBase64Text = do
+  t <- lazyText
+  let b64 = LT.encodeUtf8 t
+  case LB64.decode b64 of
+    Right a -> pure a
+    Left e  -> decodeValueFailed "base64-text" "Text" (Just (T.pack e))
+
+lazyBytestring :: DecodeValue LBS.ByteString
+lazyBytestring =
+  LBS.fromStrict <$> bytestring
+
+string :: DecodeValue String
+string =
+  T.unpack <$> text
+
+json :: DecodeValue J.Value
+json = do
+  t <- text
+  case J.eitherDecode (LBS.fromStrict (T.encodeUtf8 t)) of
+    Right v -> pure v
+    Left e  -> decodeValueFailed "json" "Value" (Just (T.pack e))
+
+timeOfDay :: DecodeValue TimeOfDay
+timeOfDay = do
+  t <- text
+  case parseTimeM True defaultTimeLocale "%H:%M:%S%Q" (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> decodeValueFailed "timeOfDay" "TimeOfDay" (Just (tshow t))
+
+utcTime :: DecodeValue UTCTime
+utcTime = do
+  t <- text
+  case parseTimeM True defaultTimeLocale "%Y-%m-%d %H:%M:%S" (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> decodeValueFailed "utcTime" "UTCTime" Nothing
+
+uuid :: DecodeValue UUID
+uuid = do
+  t <- text
+  case UUID.fromString (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> decodeValueFailed "uuid" "UUID" Nothing
+
+day :: DecodeValue Day
+day = do
+  t <- text
+  case parseTimeM True defaultTimeLocale "%Y-%m-%d" (T.unpack t) of
+    Just a  -> pure a
+    Nothing -> decodeValueFailed "day" "Day" Nothing
diff --git a/src/Data/RdsData/Default.hs b/src/Data/RdsData/Default.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Default.hs
@@ -0,0 +1,10 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.RdsData.Default
+  ( projectDefaultLocalStack
+  ) where
+
+import           Data.Text
+
+projectDefaultLocalStack :: Text
+projectDefaultLocalStack = "localstack/localstack-pro:latest"
diff --git a/src/Data/RdsData/Encode/Array.hs b/src/Data/RdsData/Encode/Array.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Encode/Array.hs
@@ -0,0 +1,153 @@
+{-# LANGUAGE BlockArguments #-}
+
+module Data.RdsData.Encode.Array
+  ( EncodeArray(..)
+
+  , rdsArray
+  , arrays
+
+  , bools
+  , doubles
+  , integers
+  , texts
+
+  , days
+  , int16s
+  , int32s
+  , int64s
+  , int8s
+  , ints
+  , jsons
+  , lazyTexts
+  , timesOfDay
+  , ulids
+  , uuids
+  , utcTimes
+  , word16s
+  , word32s
+  , word64s
+  , word8s
+  , words
+  ) where
+
+import Data.Functor.Contravariant
+import Data.Int
+import Data.RdsData.Internal.Convert
+import Data.RdsData.Types.Array (Array(..))
+import Data.Text (Text)
+import Data.Time
+import Data.ULID
+import Data.UUID
+import Data.Word
+import Prelude hiding (words)
+
+import qualified Data.Aeson           as J
+import qualified Data.Text.Lazy       as LT
+
+newtype EncodeArray a = EncodeArray
+  { encodeArray :: a -> Array
+  }
+
+instance Contravariant EncodeArray where
+  contramap f (EncodeArray g) =
+    EncodeArray (g . f)
+
+--------------------------------------------------------------------------------
+
+rdsArray :: EncodeArray Array
+rdsArray =
+  EncodeArray id
+
+--------------------------------------------------------------------------------
+
+arrays :: EncodeArray Array -> EncodeArray [Array]
+arrays =
+  contramap ArrayOfArrays
+
+--------------------------------------------------------------------------------
+
+bools :: EncodeArray [Bool]
+bools =
+  ArrayOfBools >$< rdsArray
+
+integers :: EncodeArray [Integer]
+integers =
+  ArrayOfIntegers >$< rdsArray
+
+texts :: EncodeArray [Text]
+texts =
+  ArrayOfTexts >$< rdsArray
+
+doubles :: EncodeArray [Double]
+doubles =
+  ArrayOfDoubles >$< rdsArray
+
+--------------------------------------------------------------------------------
+
+ints :: EncodeArray [Int]
+ints =
+  fmap fromIntegral >$< integers
+
+int8s :: EncodeArray [Int8]
+int8s =
+  fmap fromIntegral >$< integers
+
+int16s :: EncodeArray [Int16]
+int16s =
+  fmap fromIntegral >$< integers
+
+int32s :: EncodeArray [Int32]
+int32s =
+  fmap fromIntegral >$< integers
+
+int64s :: EncodeArray [Int64]
+int64s =
+  fmap fromIntegral >$< integers
+
+words :: EncodeArray [Word]
+words =
+  fmap fromIntegral >$< integers
+
+word8s :: EncodeArray [Word8]
+word8s =
+  fmap fromIntegral >$< integers
+
+word16s :: EncodeArray [Word16]
+word16s =
+  fmap fromIntegral >$< integers
+
+word32s :: EncodeArray [Word32]
+word32s =
+  fmap fromIntegral >$< integers
+
+word64s :: EncodeArray [Word64]
+word64s =
+  fmap fromIntegral >$< integers
+
+lazyTexts :: EncodeArray [LT.Text]
+lazyTexts =
+  fmap LT.toStrict >$< texts
+
+timesOfDay :: EncodeArray [TimeOfDay]
+timesOfDay =
+  fmap timeOfDayToText >$< texts
+
+days :: EncodeArray [Day]
+days =
+  fmap dayToText >$< texts
+
+jsons :: EncodeArray [J.Value]
+jsons =
+  fmap jsonToText >$< texts
+
+ulids :: EncodeArray [ULID]
+ulids =
+  fmap ulidToText >$< texts
+
+uuids :: EncodeArray [UUID]
+uuids =
+  fmap uuidToText >$< texts
+
+utcTimes :: EncodeArray [UTCTime]
+utcTimes =
+  fmap utcTimeToText >$< texts
diff --git a/src/Data/RdsData/Encode/Param.hs b/src/Data/RdsData/Encode/Param.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Encode/Param.hs
@@ -0,0 +1,217 @@
+{-# LANGUAGE BlockArguments   #-}
+{-# LANGUAGE DataKinds        #-}
+{-# LANGUAGE TypeApplications #-}
+
+{- HLINT ignore "Redundant flip" -}
+
+module Data.RdsData.Encode.Param
+  ( EncodeParam(..)
+
+  , rdsParam
+
+  , named
+  , typed
+
+  , maybe
+
+  , array
+  , bool
+  , bytestring
+  , double
+  , integer
+  , null
+  , text
+
+  , base64
+  , day
+  , int
+  , int16
+  , int32
+  , int64
+  , int8
+  , json
+  , lazyBytestring
+  , base64Text
+  , lazyBase64Text
+  , lazyText
+  , timeOfDay
+  , ulid
+  , utcTime
+  , uuid
+  , word
+  , word16
+  , word32
+  , word64
+  , word8
+  ) where
+
+import           Data.ByteString               (ByteString)
+import           Data.Functor.Contravariant
+import           Data.Generics.Product.Any
+import           Data.Int
+import           Data.RdsData.Encode.Array
+import           Data.RdsData.Types.Param
+import           Data.RdsData.Types.Value
+import           Data.Text                     (Text)
+import           Data.Time
+import           Data.ULID                     (ULID)
+import           Data.UUID                     (UUID)
+import           Data.Word
+import           Lens.Micro
+import           Prelude                       hiding (maybe, null)
+
+import qualified Amazonka.Data.Base64          as AWS
+import qualified Amazonka.RDSData              as AWS
+import qualified Data.Aeson                    as J
+import qualified Data.ByteString.Base64        as B64
+import qualified Data.ByteString.Base64.Lazy   as LB64
+import qualified Data.ByteString.Lazy          as LBS
+import qualified Data.RdsData.Internal.Convert as CONV
+import qualified Data.Text.Encoding            as T
+import qualified Data.Text.Lazy                as LT
+import qualified Data.Text.Lazy.Encoding       as LT
+import qualified Prelude                       as P
+
+newtype EncodeParam a = EncodeParam
+  { encodeParam :: a -> Param
+  }
+
+instance Contravariant EncodeParam where
+  contramap f (EncodeParam g) =
+    EncodeParam (g . f)
+
+--------------------------------------------------------------------------------
+
+named :: Text -> EncodeParam a -> EncodeParam a
+named n (EncodeParam f) =
+  EncodeParam \a -> f a & the @"name" .~ Just n
+
+typed :: AWS.TypeHint -> EncodeParam a -> EncodeParam a
+typed t (EncodeParam f) =
+  EncodeParam \a -> f a & the @"hint" .~ Just t
+
+rdsParam :: EncodeParam Param
+rdsParam =
+  EncodeParam id
+
+--------------------------------------------------------------------------------
+
+maybe :: EncodeParam a -> EncodeParam (Maybe a)
+maybe =
+  EncodeParam . P.maybe (Param Nothing Nothing ValueOfNull) . (.encodeParam)
+
+--------------------------------------------------------------------------------
+
+array :: EncodeArray a -> EncodeParam a
+array enc =
+  Param Nothing Nothing . ValueOfArray . enc.encodeArray >$< rdsParam
+
+base64 :: EncodeParam AWS.Base64
+base64 =
+  Param Nothing Nothing . ValueOfBase64 >$< rdsParam
+
+bool :: EncodeParam Bool
+bool =
+  Param Nothing Nothing . ValueOfBool >$< rdsParam
+
+double :: EncodeParam Double
+double =
+  Param Nothing Nothing . ValueOfDouble >$< rdsParam
+
+null :: EncodeParam ()
+null =
+  Param Nothing Nothing . const ValueOfNull >$< rdsParam
+
+integer :: EncodeParam Integer
+integer =
+  Param Nothing Nothing . ValueOfInteger >$< rdsParam
+
+text :: EncodeParam Text
+text =
+  Param Nothing Nothing . ValueOfText >$< rdsParam
+
+--------------------------------------------------------------------------------
+
+int :: EncodeParam Int
+int =
+  fromIntegral >$< integer
+
+int8 :: EncodeParam Int8
+int8 =
+  fromIntegral >$< integer
+
+int16 :: EncodeParam Int16
+int16 =
+  fromIntegral >$< integer
+
+int32 :: EncodeParam Int32
+int32 =
+  fromIntegral >$< integer
+
+int64 :: EncodeParam Int64
+int64 =
+  fromIntegral >$< integer
+
+word :: EncodeParam Word
+word =
+  fromIntegral >$< integer
+
+word8 :: EncodeParam Word8
+word8 =
+  fromIntegral >$< integer
+
+word16 :: EncodeParam Word16
+word16 =
+  fromIntegral >$< integer
+
+word32 :: EncodeParam Word32
+word32 =
+  fromIntegral >$< integer
+
+word64 :: EncodeParam Word64
+word64 =
+  fromIntegral >$< integer
+
+lazyText :: EncodeParam LT.Text
+lazyText =
+  LT.toStrict >$< text
+
+bytestring :: EncodeParam ByteString
+bytestring =
+  AWS.Base64 >$< base64
+
+lazyBytestring :: EncodeParam LBS.ByteString
+lazyBytestring =
+  LBS.toStrict >$< bytestring
+
+base64Text :: EncodeParam ByteString
+base64Text =
+  (T.decodeUtf8 . B64.encode) >$< text
+
+lazyBase64Text :: EncodeParam LBS.ByteString
+lazyBase64Text =
+  (LT.decodeUtf8 . LB64.encode) >$< lazyText
+
+timeOfDay :: EncodeParam TimeOfDay
+timeOfDay =
+  CONV.timeOfDayToText >$< text & typed AWS.TypeHint_TIME
+
+day :: EncodeParam Day
+day =
+  CONV.dayToText >$< text & typed AWS.TypeHint_DATE
+
+json :: EncodeParam J.Value
+json =
+  CONV.jsonToText >$< text & typed AWS.TypeHint_JSON
+
+ulid :: EncodeParam ULID
+ulid =
+  CONV.ulidToText >$< text
+
+utcTime :: EncodeParam UTCTime
+utcTime =
+  CONV.utcTimeToText >$< text & typed AWS.TypeHint_TIMESTAMP
+
+uuid :: EncodeParam UUID
+uuid =
+  CONV.uuidToText >$< text & typed AWS.TypeHint_UUID
diff --git a/src/Data/RdsData/Encode/Params.hs b/src/Data/RdsData/Encode/Params.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Encode/Params.hs
@@ -0,0 +1,240 @@
+{-# LANGUAGE BlockArguments #-}
+
+module Data.RdsData.Encode.Params
+  ( EncodeParams(..)
+  , EncodedParams(..)
+  , encodeParams
+  , encode
+
+  , rdsValue
+
+  , column
+
+  , maybe
+  , named
+
+  , array
+  , bool
+  , bytestring
+  , double
+  , integer
+  , null
+  , text
+
+  , base64
+  , day
+  , int
+  , int8
+  , int16
+  , int32
+  , int64
+  , json
+  , lazyBytestring
+  , base64Text
+  , lazyBase64Text
+  , lazyText
+  , timeOfDay
+  , ulid
+  , utcTime
+  , uuid
+  , word
+  , word8
+  , word16
+  , word32
+  , word64
+  ) where
+
+import           Data.ByteString                      (ByteString)
+import           Data.Functor.Contravariant
+import           Data.Functor.Contravariant.Divisible
+import           Data.Int
+import           Data.RdsData.Encode.Array            (EncodeArray (..))
+import           Data.RdsData.Encode.Param            (EncodeParam (..))
+import           Data.RdsData.Types.EncodedParams
+import           Data.RdsData.Types.Param
+import           Data.Text                            (Text)
+import           Data.Time
+import           Data.ULID                            (ULID)
+import           Data.UUID                            (UUID)
+import           Data.Void
+import           Data.Word
+import           Prelude                              hiding (maybe, null)
+
+import qualified Amazonka.Data.Base64                 as AWS
+import qualified Data.Aeson                           as J
+import qualified Data.ByteString.Lazy                 as LBS
+import qualified Data.RdsData.Encode.Param            as EP
+import qualified Data.Text.Lazy                       as LT
+import qualified Prelude                              as P
+
+newtype EncodeParams a = EncodeParams
+  { run :: a -> [Param] -> [Param]
+  }
+
+encodeParams :: EncodeParams a -> a -> [Param] -> [Param]
+encodeParams =
+  (.run)
+
+encode :: EncodeParams a -> a -> EncodedParams
+encode (EncodeParams f) a =
+  EncodedParams (f a)
+
+instance Contravariant EncodeParams where
+  contramap f (EncodeParams g) =
+    EncodeParams (g . f)
+
+instance Divisible EncodeParams where
+  divide f (EncodeParams g) (EncodeParams h) =
+    EncodeParams \a ->
+      case f a of
+        (b, c) -> g b . h c
+  conquer =
+    EncodeParams $ const id
+
+instance Decidable EncodeParams where
+  choose f (EncodeParams g) (EncodeParams h) =
+    EncodeParams \a ->
+      case f a of
+        Left b  -> g b
+        Right c -> h c
+  lose f =
+    EncodeParams $ absurd . f
+
+--------------------------------------------------------------------------------
+
+rdsValue :: EncodeParams Param
+rdsValue =
+  EncodeParams (:)
+
+--------------------------------------------------------------------------------
+
+column :: EncodeParam a -> EncodeParams a
+column (EncodeParam f) =
+  EncodeParams \a -> (f a:)
+
+named :: Text -> EncodeParam a -> EncodeParams a
+named n ep =
+  EncodeParams \a ->
+    ((.encodeParam) (EP.named n ep) a:)
+
+--------------------------------------------------------------------------------
+
+maybe :: EncodeParams a -> EncodeParams (Maybe a)
+maybe =
+  choose (P.maybe (Left ()) Right) null
+
+--------------------------------------------------------------------------------
+
+array :: EncodeArray a -> EncodeParams a
+array =
+  column . EP.array
+
+base64 :: EncodeParams AWS.Base64
+base64 =
+  column EP.base64
+
+bool :: EncodeParams Bool
+bool =
+  column EP.bool
+
+double :: EncodeParams Double
+double =
+  column EP.double
+
+null :: EncodeParams ()
+null =
+  column EP.null
+
+integer :: EncodeParams Integer
+integer =
+  column EP.integer
+
+text :: EncodeParams Text
+text =
+  column EP.text
+
+--------------------------------------------------------------------------------
+
+int :: EncodeParams Int
+int =
+  column EP.int
+
+int8 :: EncodeParams Int8
+int8 =
+  column EP.int8
+
+int16 :: EncodeParams Int16
+int16 =
+  column EP.int16
+
+int32 :: EncodeParams Int32
+int32 =
+  column EP.int32
+
+int64 :: EncodeParams Int64
+int64 =
+  column EP.int64
+
+word :: EncodeParams Word
+word =
+  column EP.word
+
+word8 :: EncodeParams Word8
+word8 =
+  column EP.word8
+
+word16 :: EncodeParams Word16
+word16 =
+  column EP.word16
+
+word32 :: EncodeParams Word32
+word32 =
+  column EP.word32
+
+word64 :: EncodeParams Word64
+word64 =
+  column EP.word64
+
+lazyText :: EncodeParams LT.Text
+lazyText =
+  column EP.lazyText
+
+bytestring :: EncodeParams ByteString
+bytestring =
+  column EP.bytestring
+
+lazyBytestring :: EncodeParams LBS.ByteString
+lazyBytestring =
+  column EP.lazyBytestring
+
+base64Text :: EncodeParams ByteString
+base64Text =
+  column EP.base64Text
+
+lazyBase64Text :: EncodeParams LBS.ByteString
+lazyBase64Text =
+  column EP.lazyBase64Text
+
+timeOfDay :: EncodeParams TimeOfDay
+timeOfDay =
+  column EP.timeOfDay
+
+day :: EncodeParams Day
+day =
+  column EP.day
+
+json :: EncodeParams J.Value
+json =
+  column EP.json
+
+ulid :: EncodeParams ULID
+ulid =
+  column EP.ulid
+
+utcTime :: EncodeParams UTCTime
+utcTime =
+  column EP.utcTime
+
+uuid :: EncodeParams UUID
+uuid =
+  column EP.uuid
diff --git a/src/Data/RdsData/Encode/Row.hs b/src/Data/RdsData/Encode/Row.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Encode/Row.hs
@@ -0,0 +1,217 @@
+{-# LANGUAGE BlockArguments #-}
+
+module Data.RdsData.Encode.Row
+  ( EncodeRow(..)
+
+  , encodeRow
+  , rdsValue
+
+  , column
+
+  , maybe
+
+  , array
+  , bool
+  , bytestring
+  , double
+  , integer
+  , null
+  , text
+
+  , base64
+  , day
+  , int
+  , int8
+  , int16
+  , int32
+  , int64
+  , json
+  , lazyBytestring
+  , lazyText
+  , timeOfDay
+  , ulid
+  , utcTime
+  , uuid
+  , word
+  , word8
+  , word16
+  , word32
+  , word64
+  ) where
+
+import           Data.ByteString                      (ByteString)
+import           Data.Functor.Contravariant
+import           Data.Functor.Contravariant.Divisible
+import           Data.Int
+import           Data.RdsData.Encode.Array            (EncodeArray (..))
+import           Data.RdsData.Encode.Value            (EncodeValue (..))
+import           Data.RdsData.Types.Value
+import           Data.Text                            (Text)
+import           Data.Time
+import           Data.ULID                            (ULID)
+import           Data.UUID                            (UUID)
+import           Data.Void
+import           Data.Word
+import           Prelude                              hiding (maybe, null)
+
+import qualified Amazonka.Data.Base64                 as AWS
+import qualified Data.Aeson                           as J
+import qualified Data.ByteString.Lazy                 as LBS
+import qualified Data.RdsData.Encode.Value            as EV
+import qualified Data.Text.Lazy                       as LT
+import qualified Prelude                              as P
+
+newtype EncodeRow a = EncodeRow
+  { encodeRow :: a -> [Value] -> [Value]
+  }
+
+instance Contravariant EncodeRow where
+  contramap f (EncodeRow g) =
+    EncodeRow (g . f)
+
+instance Divisible EncodeRow where
+  divide f (EncodeRow g) (EncodeRow h) =
+    EncodeRow \a ->
+      case f a of
+        (b, c) -> g b . h c
+  conquer =
+    EncodeRow $ const id
+
+instance Decidable EncodeRow where
+  choose f (EncodeRow g) (EncodeRow h) =
+    EncodeRow \a ->
+      case f a of
+        Left b  -> g b
+        Right c -> h c
+  lose f =
+    EncodeRow $ absurd . f
+
+encodeRow :: EncodeRow a -> a -> [Value] -> [Value]
+encodeRow =
+  (.encodeRow)
+
+--------------------------------------------------------------------------------
+
+rdsValue :: EncodeRow Value
+rdsValue =
+  EncodeRow (:)
+
+--------------------------------------------------------------------------------
+
+column :: EncodeValue a -> EncodeRow a
+column (EncodeValue f) =
+  EncodeRow \a -> (f a:)
+
+--------------------------------------------------------------------------------
+
+maybe :: EncodeRow a -> EncodeRow (Maybe a)
+maybe =
+  choose (P.maybe (Left ()) Right) null
+
+--------------------------------------------------------------------------------
+
+array :: EncodeArray a -> EncodeRow a
+array =
+  column . EV.array
+
+base64 :: EncodeRow AWS.Base64
+base64 =
+  column EV.base64
+
+bool :: EncodeRow Bool
+bool =
+  column EV.bool
+
+double :: EncodeRow Double
+double =
+  column EV.double
+
+null :: EncodeRow ()
+null =
+  column EV.null
+
+integer :: EncodeRow Integer
+integer =
+  column EV.integer
+
+text :: EncodeRow Text
+text =
+  column EV.text
+
+--------------------------------------------------------------------------------
+
+int :: EncodeRow Int
+int =
+  column EV.int
+
+int8 :: EncodeRow Int8
+int8 =
+  column EV.int8
+
+int16 :: EncodeRow Int16
+int16 =
+  column EV.int16
+
+int32 :: EncodeRow Int32
+int32 =
+  column EV.int32
+
+int64 :: EncodeRow Int64
+int64 =
+  column EV.int64
+
+word :: EncodeRow Word
+word =
+  column EV.word
+
+word8 :: EncodeRow Word8
+word8 =
+  column EV.word8
+
+word16 :: EncodeRow Word16
+word16 =
+  column EV.word16
+
+word32 :: EncodeRow Word32
+word32 =
+  column EV.word32
+
+word64 :: EncodeRow Word64
+word64 =
+  column EV.word64
+
+lazyText :: EncodeRow LT.Text
+lazyText =
+  column EV.lazyText
+
+bytestring :: EncodeRow ByteString
+bytestring =
+  column EV.bytestring
+
+lazyBytestring :: EncodeRow LBS.ByteString
+lazyBytestring =
+  column EV.lazyBytestring
+
+timeOfDay :: EncodeRow TimeOfDay
+timeOfDay =
+  column EV.timeOfDay
+
+day :: EncodeRow Day
+day =
+  column EV.day
+
+json :: EncodeRow J.Value
+json =
+  column EV.json
+
+ulid :: EncodeRow ULID
+ulid =
+  column EV.ulid
+
+utcTime :: EncodeRow UTCTime
+utcTime =
+  column EV.utcTime
+
+uuid :: EncodeRow UUID
+uuid =
+  column EV.uuid
diff --git a/src/Data/RdsData/Encode/ToSqlParameters.hs b/src/Data/RdsData/Encode/ToSqlParameters.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Encode/ToSqlParameters.hs
@@ -0,0 +1,18 @@
+module Data.RdsData.Encode.ToSqlParameters
+  ( ToSqlParameters(..)
+  ) where
+
+import           Data.RdsData.Encode.Params
+import           Data.RdsData.Types.Param
+
+import qualified Amazonka.RDSData           as AWS
+
+class ToSqlParameters a where
+  toSqlParameters :: a -> [AWS.SqlParameter]
+
+instance ToSqlParameters [AWS.SqlParameter] where
+  toSqlParameters = id
+
+instance ToSqlParameters EncodedParams where
+  toSqlParameters encodedParams =
+    fmap toSqlParameter (encodedParams.run [])
diff --git a/src/Data/RdsData/Encode/Value.hs b/src/Data/RdsData/Encode/Value.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Encode/Value.hs
@@ -0,0 +1,185 @@
+{-# LANGUAGE BlockArguments #-}
+
+module Data.RdsData.Encode.Value
+  ( EncodeValue(..)
+
+  , rdsValue
+
+  , maybe
+
+  , array
+  , bool
+  , bytestring
+  , double
+  , integer
+  , null
+  , text
+
+  , base64
+  , day
+  , int
+  , int8
+  , int16
+  , int32
+  , int64
+  , json
+  , lazyBytestring
+  , lazyText
+  , timeOfDay
+  , utcTime
+  , ulid
+  , uuid
+  , word
+  , word8
+  , word16
+  , word32
+  , word64
+  ) where
+
+import           Data.ByteString               (ByteString)
+import           Data.Functor.Contravariant
+import           Data.Int
+import           Data.RdsData.Encode.Array
+import           Data.RdsData.Types.Value
+import           Data.Text                     (Text)
+import           Data.Time
+import           Data.ULID                     (ULID)
+import           Data.UUID                     (UUID)
+import           Data.Word
+import           Prelude                       hiding (maybe, null)
+
+import qualified Amazonka.Bytes                as AWS
+import qualified Amazonka.Data.Base64          as AWS
+import qualified Data.Aeson                    as J
+import qualified Data.ByteString.Lazy          as LBS
+import qualified Data.RdsData.Internal.Convert as CONV
+import qualified Data.Text.Lazy                as LT
+import qualified Prelude                       as P
+
+newtype EncodeValue a = EncodeValue
+  { encodeValue :: a -> Value
+  }
+
+instance Contravariant EncodeValue where
+  contramap f (EncodeValue g) =
+    EncodeValue (g . f)
+
+--------------------------------------------------------------------------------
+
+rdsValue :: EncodeValue Value
+rdsValue =
+  EncodeValue id
+
+--------------------------------------------------------------------------------
+
+maybe :: EncodeValue a -> EncodeValue (Maybe a)
+maybe =
+  EncodeValue . P.maybe ValueOfNull . (.encodeValue)
+
+--------------------------------------------------------------------------------
+
+array :: EncodeArray a -> EncodeValue a
+array enc =
+  ValueOfArray . enc.encodeArray >$< rdsValue
+
+base64 :: EncodeValue AWS.Base64
+base64 =
+  ValueOfBase64 >$< rdsValue
+
+bool :: EncodeValue Bool
+bool =
+  ValueOfBool >$< rdsValue
+
+double :: EncodeValue Double
+double =
+  ValueOfDouble >$< rdsValue
+
+null :: EncodeValue ()
+null =
+  const ValueOfNull >$< rdsValue
+
+integer :: EncodeValue Integer
+integer =
+  ValueOfInteger >$< rdsValue
+
+text :: EncodeValue Text
+text =
+  ValueOfText >$< rdsValue
+
+--------------------------------------------------------------------------------
+
+int :: EncodeValue Int
+int =
+  fromIntegral >$< integer
+
+int8 :: EncodeValue Int8
+int8 =
+  fromIntegral >$< integer
+
+int16 :: EncodeValue Int16
+int16 =
+  fromIntegral >$< integer
+
+int32 :: EncodeValue Int32
+int32 =
+  fromIntegral >$< integer
+
+int64 :: EncodeValue Int64
+int64 =
+  fromIntegral >$< integer
+
+word :: EncodeValue Word
+word =
+  fromIntegral >$< integer
+
+word8 :: EncodeValue Word8
+word8 =
+  fromIntegral >$< integer
+
+word16 :: EncodeValue Word16
+word16 =
+  fromIntegral >$< integer
+
+word32 :: EncodeValue Word32
+word32 =
+  fromIntegral >$< integer
+
+word64 :: EncodeValue Word64
+word64 =
+  fromIntegral >$< integer
+
+lazyText :: EncodeValue LT.Text
+lazyText =
+  LT.toStrict >$< text
+
+bytestring :: EncodeValue ByteString
+bytestring =
+  (AWS.Base64 . AWS.encodeBase64) >$< base64
+
+lazyBytestring :: EncodeValue LBS.ByteString
+lazyBytestring =
+  LBS.toStrict >$< bytestring
+
+timeOfDay :: EncodeValue TimeOfDay
+timeOfDay =
+  CONV.timeOfDayToText >$< text
+
+day :: EncodeValue Day
+day =
+  CONV.dayToText >$< text
+
+json :: EncodeValue J.Value
+json =
+  CONV.jsonToText >$< text
+
+utcTime :: EncodeValue UTCTime
+utcTime =
+  CONV.utcTimeToText >$< text
+
+ulid :: EncodeValue ULID
+ulid =
+  CONV.ulidToText >$< text
+
+uuid :: EncodeValue UUID
+uuid =
+  CONV.uuidToText >$< text
diff --git a/src/Data/RdsData/Internal/Aeson.hs b/src/Data/RdsData/Internal/Aeson.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Internal/Aeson.hs
@@ -0,0 +1,48 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE FlexibleInstances #-}
+
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Data.RdsData.Internal.Aeson where
+
+import Data.Aeson
+import Data.Text (Text)
+
+import qualified Amazonka.RDSData     as AWS
+import qualified Data.Aeson           as J
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Text.Encoding   as T
+
+infixr 8 .=!
+infixr 8 .=?
+
+-- | A key-value pair difference list for encoding a JSON object.
+(.=!) :: (KeyValue e kv, ToJSON v) => Key -> v -> [kv] -> [kv]
+(.=!) n v = (n .= v:)
+
+-- | A key-value pair difference list for encoding a JSON object where Nothing encodes absence of the key-value pair.
+(.=?) :: (KeyValue e kv, ToJSON v) => Key -> Maybe v -> [kv] -> [kv]
+(.=?) n mv = case mv of
+  Just v -> (n .= v:)
+  Nothing -> id
+
+toJsonText :: ToJSON a => a -> Text
+toJsonText field = T.decodeUtf8 (LBS.toStrict (J.encode field))
+
+instance ToJSON AWS.ColumnMetadata where
+
+instance ToJSON AWS.UpdateResult where
+
+-- Customized options for toJSON
+myOptions :: Options
+myOptions = defaultOptions { omitNothingFields = True }
+
+instance ToJSON AWS.ExecuteStatementResponse where
+  toJSON = genericToJSON myOptions
+
+instance FromJSON AWS.ExecuteStatementResponse where
+
+instance ToJSON AWS.BatchExecuteStatementResponse where
+  toJSON = genericToJSON myOptions
+
+instance FromJSON AWS.BatchExecuteStatementResponse where
diff --git a/src/Data/RdsData/Internal/Convert.hs b/src/Data/RdsData/Internal/Convert.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Internal/Convert.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Data.RdsData.Internal.Convert
+  ( dayToText
+  , jsonToText
+  , timeOfDayToText
+  , ulidToText
+  , uuidToText
+  , utcTimeToText
+  , textToDouble
+  , textToUlid
+  ) where
+
+import Data.Bifunctor
+import Data.ULID (ULID)
+import Data.UUID (UUID)
+import Data.Text (Text)
+import Data.Time
+    ( formatTime, defaultTimeLocale, Day, UTCTime, TimeOfDay )
+import Prelude hiding (maybe, null)
+import Text.Read
+
+import qualified Data.Aeson           as J
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.Text            as T
+import qualified Data.Text.Encoding   as T
+import qualified Data.ULID            as ULID
+import qualified Data.ULID.Base32     as ULID
+import qualified Data.UUID            as UUID
+
+timeOfDayToText :: TimeOfDay -> Text
+timeOfDayToText = 
+  T.pack . formatTime defaultTimeLocale "%H:%M:%S%Q"
+
+dayToText :: Day -> Text
+dayToText = 
+  T.pack . formatTime defaultTimeLocale "%Y-%m-%d"
+
+jsonToText :: J.Value -> Text
+jsonToText =
+  T.decodeUtf8 . LBS.toStrict . J.encode
+
+utcTimeToText :: UTCTime -> Text
+utcTimeToText = 
+  T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S"
+
+ulidToText :: ULID -> Text
+ulidToText = 
+  ULID.encode 26 . ULID.ulidToInteger
+
+textToUlid :: Text -> Either Text ULID
+textToUlid t =
+  bimap (const ("Unable to decode ULID: " <> t)) id (readEither @ULID (T.unpack t))
+
+uuidToText :: UUID -> Text
+uuidToText = 
+  T.pack . UUID.toString
+
+textToDouble :: Text -> Maybe Double
+textToDouble text =
+  case reads (T.unpack text) of
+    [(x, "")] -> Just x
+    _         -> Nothing
diff --git a/src/Data/RdsData/Internal/Maybe.hs b/src/Data/RdsData/Internal/Maybe.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Internal/Maybe.hs
@@ -0,0 +1,7 @@
+module Data.RdsData.Internal.Maybe
+  ( toMaybe
+  ) where
+
+toMaybe :: a -> Bool -> Maybe a
+toMaybe x True = Just x
+toMaybe _ _ = Nothing
diff --git a/src/Data/RdsData/Internal/Show.hs b/src/Data/RdsData/Internal/Show.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Internal/Show.hs
@@ -0,0 +1,5 @@
+module Data.RdsData.Internal.Show
+  ( tshow
+  ) where
+
+import           HaskellWorks.Prelude
diff --git a/src/Data/RdsData/Migration.hs b/src/Data/RdsData/Migration.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Migration.hs
@@ -0,0 +1,3 @@
+module Data.RdsData.Migration
+  (
+  ) where
diff --git a/src/Data/RdsData/Migration/Types.hs b/src/Data/RdsData/Migration/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Migration/Types.hs
@@ -0,0 +1,260 @@
+{-# LANGUAGE DataKinds                  #-}
+{-# LANGUAGE DeriveGeneric              #-}
+{-# LANGUAGE DerivingStrategies         #-}
+{-# LANGUAGE DuplicateRecordFields      #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase                 #-}
+{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE TypeApplications           #-}
+
+module Data.RdsData.Migration.Types
+  ( MigrationRow(..),
+    RdsClusterDetails(..),
+    Migration(..),
+    Delta(..),
+    Step(..),
+    Statement(..),
+    CreateTableStep(..),
+    TableSchema(..),
+    Column(..),
+    Constraint(..),
+    ForeignKey(..),
+  ) where
+
+import           Amazonka.Data             (FromJSON, ToJSON, (.!=), (.:),
+                                            (.:?), (.=))
+import qualified Amazonka.RDS              as AWS
+import qualified Amazonka.SecretsManager   as AWS
+import           Control.Applicative
+import qualified Data.Aeson                as J
+import qualified Data.Aeson.Key            as J
+import qualified Data.Aeson.Types          as J
+import           Data.Bool
+import           Data.Char                 (isAsciiUpper, toLower)
+import           Data.Functor
+import           Data.Generics.Product.Any
+import           Data.Maybe
+import           Data.RdsData.Orphans      ()
+import           Data.Text                 (Text)
+import           Data.Time
+import           Data.ULID                 (ULID)
+import           GHC.Generics
+import           Lens.Micro
+
+-- Helper to transform field names to snake_case
+snakeCaseOptions :: J.Options
+snakeCaseOptions = J.defaultOptions { J.fieldLabelModifier = camelToSnake }
+
+-- Helper function to convert camelCase to snake_case
+camelToSnake :: String -> String
+camelToSnake [] = []
+camelToSnake (x:xs) = toLower x : go xs
+  where
+    go [] = []
+    go "_" = []
+    go (y:ys)
+      | isAsciiUpper y = '_' : toLower y : go ys
+      | otherwise      = y : go ys
+
+data MigrationRow = MigrationRow
+  { uuid       :: ULID
+  , createdBy  :: UTCTime
+  , deployedBy :: Text
+  } deriving (Eq, Show, Generic)
+
+data RdsClusterDetails = RdsClusterDetails
+  { createDbClusterResponse :: AWS.CreateDBClusterResponse
+  , createSecretResponse    :: AWS.CreateSecretResponse
+  } deriving (Eq, Generic, Show)
+
+data Migration = Migration
+  { description :: Text
+  , plan        :: [Delta]
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON Migration where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON Migration where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+data Delta = Delta
+  { id          :: ULID
+  , description :: Text
+  , steps       :: Maybe [Step]
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON Delta where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON Delta where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+data Step =
+    StepOfUp UpStep
+  | StepOfDown DownStep
+  | StepOfCreateTable CreateTableStep
+  | StepOfCreateIndex CreateIndexStep
+  deriving (Eq, Show)
+
+instance ToJSON Step where
+  toJSON = \case
+    StepOfUp step -> J.toJSON step
+    StepOfDown step -> J.toJSON step
+    StepOfCreateTable t -> J.toJSON t
+    StepOfCreateIndex i -> J.toJSON i
+
+instance FromJSON Step where
+  parseJSON v = do
+    f <- fieldIn v
+      [ "up"
+      , "down"
+      , "create_table"
+      , "create_index"
+      ]
+
+    case f of
+      "up"           -> StepOfUp          <$> J.parseJSON v
+      "down"         -> StepOfDown        <$> J.parseJSON v
+      "create_table" -> StepOfCreateTable <$> J.parseJSON v
+      "create_index" -> StepOfCreateIndex <$> J.parseJSON v
+      _              -> fail "Invalid Step"
+
+fieldIn :: J.Value -> [Text] -> J.Parser Text
+fieldIn v fs =
+  asum $ fmap (field v) fs
+
+field :: J.Value -> Text -> J.Parser Text
+field v f =
+  flip (J.withObject "Step") v $ \o ->
+    (J..:) @J.Value o (J.fromText f) $> f
+
+newtype UpStep = UpStep
+  { up   :: Statement
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON UpStep where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON UpStep where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+newtype DownStep = DownStep
+  { down :: Statement
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON DownStep where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON DownStep where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+newtype Statement = Statement Text
+  deriving (Eq, Generic, Show)
+  deriving newtype (ToJSON, FromJSON)
+
+newtype CreateTableStep = CreateTableStep
+  { createTable :: TableSchema
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON CreateTableStep where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON CreateTableStep where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+newtype CreateIndexStep = CreateIndexStep
+  { createIndex :: IndexSchema
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON CreateIndexStep where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON CreateIndexStep where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+data TableSchema = TableSchema
+  { name        :: Text
+  , columns     :: [Column]
+  , primaryKey  :: Maybe [Text]
+  , constraints :: Maybe [Constraint]
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON TableSchema where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON TableSchema where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+data Column = Column
+  { name          :: Text
+  , type_         :: Text
+  , required      :: Bool
+  , primaryKey    :: Bool
+  , unique        :: Bool
+  , autoIncrement :: Bool
+  , references    :: Maybe ForeignKey
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON Column where
+  toJSON v =
+    J.object $ catMaybes
+      [ "name"            .=? do v ^. the @"name"           & Just
+      , "type"            .=? do v ^. the @"type_"          & Just
+      , "required"        .=? do v ^. the @"required"       & bool Nothing (Just True)
+      , "primary_key"     .=? do v ^. the @"primaryKey"     & bool Nothing (Just True)
+      , "unique"          .=? do v ^. the @"unique"         & bool Nothing (Just True)
+      , "auto_increment"  .=? do v ^. the @"autoIncrement"  & bool Nothing (Just True)
+      , "references"      .=? do v ^. the @"references"     & Just
+      ]
+
+(.=?) :: (J.KeyValue e kv, ToJSON v) => J.Key -> Maybe v -> Maybe kv
+(.=?) k mv =
+  case mv of
+    Just v  -> Just $ k .= v
+    Nothing -> Nothing
+
+instance FromJSON Column where
+  parseJSON = J.withObject "Column" $ \v ->
+    Column
+      <$> v .:  "name"
+      <*> v .:  "type"
+      <*> v .:? "required"        .!= False
+      <*> v .:? "primary_key"     .!= False
+      <*> v .:? "unique"          .!= False
+      <*> v .:? "auto_increment"  .!= False
+      <*> v .:? "references"
+
+data IndexSchema = IndexSchema
+  { name    :: Text
+  , table   :: Text
+  , columns :: [Text]
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON IndexSchema where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON IndexSchema where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+data ForeignKey = ForeignKey
+  { table  :: Text
+  , column :: Text
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON ForeignKey where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON ForeignKey where
+  parseJSON = J.genericParseJSON snakeCaseOptions
+
+data Constraint = Constraint
+  { name  :: Text
+  , check :: Text
+  } deriving (Eq, Generic, Show)
+
+instance ToJSON Constraint where
+  toJSON = J.genericToJSON snakeCaseOptions
+
+instance FromJSON Constraint where
+  parseJSON = J.genericParseJSON snakeCaseOptions
diff --git a/src/Data/RdsData/Orphans.hs b/src/Data/RdsData/Orphans.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Orphans.hs
@@ -0,0 +1,23 @@
+{-# LANGUAGE DerivingStrategies    #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+{- HLINT ignore "Use let" -}
+
+module Data.RdsData.Orphans
+  (
+  ) where
+
+import           Amazonka.Data                 (FromJSON, ToJSON)
+import qualified Data.Aeson                    as J
+import           Data.RdsData.Internal.Convert
+import qualified Data.Text                     as T
+import           Data.ULID                     (ULID)
+
+instance FromJSON ULID where
+  parseJSON = J.withText "ULID" $ \txt ->
+    either (fail . T.unpack) return $ textToUlid txt
+
+instance ToJSON ULID where
+  toJSON = J.toJSON . show
diff --git a/src/Data/RdsData/Types.hs b/src/Data/RdsData/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Types.hs
@@ -0,0 +1,15 @@
+module Data.RdsData.Types
+  ( Array(..)
+  , Param(..)
+  , Value(..)
+  , fromArrayValue
+  , toArrayValue
+  , fromField
+  , toField
+  , toSqlParameter
+  ) where
+
+import Data.RdsData.Internal.Aeson ()
+import Data.RdsData.Types.Array
+import Data.RdsData.Types.Param
+import Data.RdsData.Types.Value
diff --git a/src/Data/RdsData/Types/Array.hs b/src/Data/RdsData/Types/Array.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Types/Array.hs
@@ -0,0 +1,49 @@
+{-# LANGUAGE DataKinds        #-}
+{-# LANGUAGE DeriveGeneric    #-}
+{-# LANGUAGE LambdaCase       #-}
+{-# LANGUAGE TypeApplications #-}
+
+{- HLINT ignore "Use <&>" -}
+
+module Data.RdsData.Types.Array
+  ( Array(..)
+  , fromArrayValue
+  , toArrayValue
+  ) where
+
+import           Control.Applicative
+import           Data.Generics.Product.Any
+import           Data.Text
+import           GHC.Generics
+import           Lens.Micro
+
+import qualified Amazonka.RDSData          as AWS
+import qualified Data.Aeson                as J
+
+data Array =
+    ArrayOfArrays   [Array]
+  | ArrayOfBools    [Bool]
+  | ArrayOfDoubles  [Double]
+  | ArrayOfIntegers [Integer]
+  | ArrayOfTexts    [Text]
+  deriving (Eq, Generic, Show)
+
+instance J.ToJSON Array where
+
+fromArrayValue :: AWS.ArrayValue -> Maybe Array
+fromArrayValue v =
+  asum
+    [ v ^. the @"booleanValues" >>= pure . ArrayOfBools
+    , v ^. the @"doubleValues"  >>= pure . ArrayOfDoubles
+    , v ^. the @"longValues"    >>= pure . ArrayOfIntegers
+    , v ^. the @"stringValues"  >>= pure . ArrayOfTexts
+    , v ^. the @"arrayValues"   >>= fmap ArrayOfArrays . traverse fromArrayValue
+    ]
+
+toArrayValue :: Array -> AWS.ArrayValue
+toArrayValue = \case
+  ArrayOfArrays   vs -> AWS.newArrayValue & the @"arrayValues"    .~ Just (toArrayValue <$> vs)
+  ArrayOfBools    vs -> AWS.newArrayValue & the @"booleanValues"  .~ Just vs
+  ArrayOfDoubles  vs -> AWS.newArrayValue & the @"doubleValues"   .~ Just vs
+  ArrayOfIntegers vs -> AWS.newArrayValue & the @"longValues"     .~ Just vs
+  ArrayOfTexts    vs -> AWS.newArrayValue & the @"stringValues"   .~ Just vs
diff --git a/src/Data/RdsData/Types/EncodedParams.hs b/src/Data/RdsData/Types/EncodedParams.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Types/EncodedParams.hs
@@ -0,0 +1,17 @@
+module Data.RdsData.Types.EncodedParams
+  ( EncodedParams(..)
+  ) where
+
+import           Data.RdsData.Types.Param
+
+newtype EncodedParams = EncodedParams
+  { run :: [Param] -> [Param]
+  }
+
+instance Semigroup EncodedParams where
+  EncodedParams f <> EncodedParams g =
+    EncodedParams (f . g)
+
+instance Monoid EncodedParams where
+  mempty =
+    EncodedParams id
diff --git a/src/Data/RdsData/Types/Param.hs b/src/Data/RdsData/Types/Param.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Types/Param.hs
@@ -0,0 +1,31 @@
+{-# LANGUAGE DataKinds        #-}
+{-# LANGUAGE DeriveGeneric    #-}
+{-# LANGUAGE TypeApplications #-}
+
+{- HLINT ignore "Use <&>" -}
+
+module Data.RdsData.Types.Param
+  ( Param(..)
+  , toSqlParameter
+  ) where
+
+import           Data.Generics.Product.Any
+import           Data.RdsData.Types.Value
+import           Data.Text
+import           GHC.Generics
+import           Lens.Micro
+
+import qualified Amazonka.RDSData          as AWS
+import qualified Data.Aeson                as J
+
+data Param = Param
+  { name  :: Maybe Text
+  , hint  :: Maybe AWS.TypeHint
+  , value :: Value
+  } deriving (Eq, Generic, Show)
+
+instance J.ToJSON Param where
+
+toSqlParameter :: Param -> AWS.SqlParameter
+toSqlParameter (Param n h v) =
+  AWS.newSqlParameter & the @"name" .~ n & the @"typeHint" .~ h & the @"value" ?~ toField v
diff --git a/src/Data/RdsData/Types/Value.hs b/src/Data/RdsData/Types/Value.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/RdsData/Types/Value.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE DataKinds        #-}
+{-# LANGUAGE DeriveGeneric    #-}
+{-# LANGUAGE LambdaCase       #-}
+{-# LANGUAGE TypeApplications #-}
+
+{- HLINT ignore "Use <&>" -}
+
+module Data.RdsData.Types.Value
+  ( Value(..)
+  , fromField
+  , toField
+  ) where
+
+import           Amazonka.Data.Base64
+import           Control.Applicative
+import           Data.Generics.Product.Any
+import           Data.RdsData.Internal.Maybe
+import           Data.RdsData.Types.Array
+import           Data.Text
+import           GHC.Generics
+import           Lens.Micro
+
+import qualified Amazonka.RDSData            as AWS
+import qualified Data.Aeson                  as J
+
+data Value =
+    ValueOfArray    Array
+  | ValueOfBase64   Base64
+  | ValueOfBool     Bool
+  | ValueOfDouble   Double
+  | ValueOfInteger  Integer
+  | ValueOfText     Text
+  | ValueOfNull
+  deriving (Eq, Generic, Show)
+
+instance J.ToJSON Value where
+
+fromField :: AWS.Field -> Maybe Value
+fromField field =
+  asum
+    [ field ^. the @"arrayValue"    >>= fromArrayValue >>= pure . ValueOfArray
+    , field ^. the @"blobValue"     >>= pure . ValueOfBase64
+    , field ^. the @"booleanValue"  >>= pure . ValueOfBool
+    , field ^. the @"doubleValue"   >>= pure . ValueOfDouble
+    , field ^. the @"longValue"     >>= pure . ValueOfInteger
+    , field ^. the @"stringValue"   >>= pure . ValueOfText
+    , field ^. the @"isNull"        >>= toMaybe ValueOfNull
+    ]
+
+toField :: Value -> AWS.Field
+toField = \case
+  ValueOfArray    v -> AWS.newField & the @"arrayValue" .~ Just (toArrayValue v)
+  ValueOfBase64   v -> AWS.newField & the @"blobValue" .~ Just v
+  ValueOfBool     v -> AWS.newField & the @"booleanValue" .~ Just v
+  ValueOfDouble   v -> AWS.newField & the @"doubleValue" .~ Just v
+  ValueOfInteger  v -> AWS.newField & the @"longValue" .~ Just v
+  ValueOfText     v -> AWS.newField & the @"stringValue" .~ Just v
+  ValueOfNull       -> AWS.newField & the @"isNull" .~ Just True
diff --git a/test/Data/RdsData/TestTypes.hs b/test/Data/RdsData/TestTypes.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/RdsData/TestTypes.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE BlockArguments #-}
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveGeneric #-}
+
+{- HLINT ignore "Monad law, left identity" -}
+{- HLINT ignore "Use let" -}
+
+module Data.RdsData.TestTypes
+  ( Record(..)
+  ) where
+
+import Data.ByteString (ByteString)
+import Data.RdsData.Internal.Aeson ()
+import Data.Text (Text)
+import Data.Time (Day, TimeOfDay(..), UTCTime)
+import Data.ULID (ULID)
+import Data.UUID (UUID)
+import GHC.Generics
+
+import qualified Data.Aeson                   as J
+
+data Record = Record
+  { bigint              :: Int
+  , bigserial           :: Int
+  , boolean             :: Bool
+  , bytea               :: ByteString
+  , character           :: Text
+  , characters          :: Text
+  , varyingCharacter    :: Text
+  , varyingCharacters   :: Text
+  , date                :: Day
+  , double              :: Double
+  , integer             :: Integer
+  , json                :: J.Value
+  , jsonb               :: J.Value
+  , numeric             :: Text
+  , numerics            :: Text
+  , real                :: Double
+  , smallint            :: Int
+  , smallserial         :: Int
+  , serial              :: Int
+  , text                :: Text
+  , time                :: TimeOfDay
+  , times               :: TimeOfDay
+  , timestamp           :: UTCTime
+  , timestamps          :: UTCTime
+  , ulid                :: ULID
+  , uuid                :: UUID
+  }
+  deriving (Show, Eq, Generic)
diff --git a/test/Data/RdsDataSpec.hs b/test/Data/RdsDataSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/RdsDataSpec.hs
@@ -0,0 +1,183 @@
+{-# LANGUAGE BlockArguments    #-}
+{-# LANGUAGE DataKinds         #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications  #-}
+
+{- HLINT ignore "Monad law, left identity" -}
+{- HLINT ignore "Use let" -}
+
+module Data.RdsDataSpec where
+
+import           Data.Generics.Product.Any
+import           Data.Maybe
+import           Data.RdsData.Internal.Aeson ()
+import           Data.RdsData.Types.Value
+import           Data.Time                   (TimeOfDay (..))
+import           Hedgehog
+import           Lens.Micro
+
+import qualified Amazonka.RDSData            as AWS
+import qualified Data.Aeson                  as J
+import qualified Data.Aeson.Encode.Pretty    as J
+import qualified Data.ByteString.Lazy        as LBS
+import qualified Data.RdsData.Decode.Row     as DEC
+import qualified Data.RdsData.Encode.Row     as ENC
+import qualified Data.RdsData.TestTypes      as TT
+import qualified Data.Text                   as T
+import qualified Data.Text.Encoding          as T
+import qualified Hedgehog                    as H
+import qualified Hedgehog.Extras             as H
+import qualified Hedgehog.Extras.Test.Golden as H
+
+hprop_stub :: Property
+hprop_stub = H.propertyOnce $ do
+  H.evalIO $ pure ()
+  response <- H.evalIO (J.eitherDecodeFileStrict @AWS.ExecuteStatementResponse "test/files/row.json")
+    >>= either (const H.failure) pure
+
+  records <- pure (response ^. the @"records")
+    >>= maybe H.failure pure
+
+  values <- pure $ mapMaybe fromField <$> records
+
+  rows <- pure
+    do
+      DEC.decodeRows
+        do
+          TT.Record
+            <$> DEC.int
+            <*> DEC.int
+            <*> DEC.bool
+            <*> DEC.bytestring
+            <*> DEC.text
+            <*> DEC.text
+            <*> DEC.text
+            <*> DEC.text
+            <*> DEC.day
+            <*> DEC.double
+            <*> DEC.integer
+            <*> DEC.json
+            <*> DEC.json
+            <*> DEC.text
+            <*> DEC.text
+            <*> DEC.double
+            <*> DEC.int
+            <*> DEC.int
+            <*> DEC.int
+            <*> DEC.text
+            <*> DEC.timeOfDay
+            <*> DEC.timeOfDay
+            <*> DEC.utcTime
+            <*> DEC.utcTime
+            <*> DEC.ulid
+            <*> DEC.uuid
+        values
+    >>= either (\s -> H.noteShow_ s >> H.failure) pure
+
+  rows ===
+    [ TT.Record
+      { TT.bigint             = 1234567890
+      , TT.bigserial          = 1
+      , TT.boolean            = True
+      , TT.bytea              = "\DC24"
+      , TT.character          = "A"
+      , TT.characters         = "AB"
+      , TT.varyingCharacter   = "C"
+      , TT.varyingCharacters  = "CD"
+      , TT.date               = read "2024-02-04"
+      , TT.double             = 3.14159265359
+      , TT.integer            = 42
+      , TT.json               = fromJust $ J.decode "{\"key\": \"value\"}"
+      , TT.jsonb              = fromJust $ J.decode "{\"key\": \"value\"}"
+      , TT.numeric            = "1234.56"
+      , TT.numerics           = "12.34"
+      , TT.real               = 3.14
+      , TT.smallint           = 12345
+      , TT.smallserial        = 1
+      , TT.serial             = 1
+      , TT.text               = "Some text"
+      , TT.time               = TimeOfDay 12 34 56
+      , TT.times              = TimeOfDay 12 34 56.78
+      , TT.timestamp          = read "2024-02-04 12:34:56 UTC"
+      , TT.timestamps         = read "2024-02-04 12:34:56 UTC"
+      , TT.ulid               = read "0123456789ABCDEFGHJKMNPQRS"
+      , TT.uuid               = read "550e8400-e29b-41d4-a716-446655440000"
+      }
+    ]
+
+
+hprop_stub2 :: Property
+hprop_stub2 = H.propertyOnce $ do
+  record <- pure
+    TT.Record
+      { TT.bigint             = 1234567890
+      , TT.bigserial          = 1
+      , TT.boolean            = True
+      , TT.bytea              = "EjQ="
+      , TT.character          = "A"
+      , TT.characters         = "AB"
+      , TT.varyingCharacter   = "C"
+      , TT.varyingCharacters  = "CD"
+      , TT.date               = read "2024-02-04"
+      , TT.double             = 3.14159265359
+      , TT.integer            = 42
+      , TT.json               = fromJust $ J.decode "{\"key\": \"value\"}"
+      , TT.jsonb              = fromJust $ J.decode "{\"key\": \"value\"}"
+      , TT.numeric            = "1234.56"
+      , TT.numerics           = "12.34"
+      , TT.real               = 3.14
+      , TT.smallint           = 12345
+      , TT.smallserial        = 1
+      , TT.serial             = 1
+      , TT.text               = "Some text"
+      , TT.time               = TimeOfDay 12 34 56
+      , TT.times              = TimeOfDay 12 34 56.78
+      , TT.timestamp          = read "2024-02-04 12:34:56 UTC"
+      , TT.timestamps         = read "2024-02-04 12:34:56 UTC"
+      , TT.ulid               = read "0123456789ABCDEFGHJKMNPQRS"
+      , TT.uuid               = read "550e8400-e29b-41d4-a716-446655440000"
+      }
+
+  row <- pure $
+    mconcat
+      [ ENC.encodeRow ENC.int         (record ^. the @"bigint"           ) []
+      , ENC.encodeRow ENC.int         (record ^. the @"bigserial"        ) []
+      , ENC.encodeRow ENC.bool        (record ^. the @"boolean"          ) []
+      , ENC.encodeRow ENC.bytestring  (record ^. the @"bytea"            ) []
+      , ENC.encodeRow ENC.text        (record ^. the @"character"        ) []
+      , ENC.encodeRow ENC.text        (record ^. the @"characters"       ) []
+      , ENC.encodeRow ENC.text        (record ^. the @"varyingCharacter" ) []
+      , ENC.encodeRow ENC.text        (record ^. the @"varyingCharacters") []
+      , ENC.encodeRow ENC.day         (record ^. the @"date"             ) []
+      , ENC.encodeRow ENC.double      (record ^. the @"double"           ) []
+      , ENC.encodeRow ENC.integer     (record ^. the @"integer"          ) []
+      , ENC.encodeRow ENC.json        (record ^. the @"json"             ) []
+      , ENC.encodeRow ENC.json        (record ^. the @"jsonb"            ) []
+      , ENC.encodeRow ENC.text        (record ^. the @"numeric"          ) []
+      , ENC.encodeRow ENC.text        (record ^. the @"numerics"         ) []
+      , ENC.encodeRow ENC.double      (record ^. the @"real"             ) []
+      , ENC.encodeRow ENC.int         (record ^. the @"smallint"         ) []
+      , ENC.encodeRow ENC.int         (record ^. the @"smallserial"      ) []
+      , ENC.encodeRow ENC.int         (record ^. the @"serial"           ) []
+      , ENC.encodeRow ENC.text        (record ^. the @"text"             ) []
+      , ENC.encodeRow ENC.timeOfDay   (record ^. the @"time"             ) []
+      , ENC.encodeRow ENC.timeOfDay   (record ^. the @"times"            ) []
+      , ENC.encodeRow ENC.utcTime     (record ^. the @"timestamp"        ) []
+      , ENC.encodeRow ENC.utcTime     (record ^. the @"timestamps"       ) []
+      , ENC.encodeRow ENC.uuid        (record ^. the @"uuid"             ) []
+      ]
+
+  json <- pure $ T.unpack $ T.decodeUtf8 $ LBS.toStrict $ J.encodePretty row
+
+  H.diffVsGoldenFile json "test/files/golden/row.json"
+
+  response <- pure $
+    AWS.newExecuteStatementResponse 0
+      & the @"records" .~ Just [toField <$> row]
+
+  responseJson <- pure $ T.unpack $ T.decodeUtf8 $ LBS.toStrict $ J.encodePretty response
+
+  H.diffVsGoldenFile responseJson "test/files/golden/rds-row.json"
+
+  True === True
+
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --hide-successes #-}
diff --git a/testlib/Data/RdsData/Polysemy/Test/Cluster.hs b/testlib/Data/RdsData/Polysemy/Test/Cluster.hs
new file mode 100644
--- /dev/null
+++ b/testlib/Data/RdsData/Polysemy/Test/Cluster.hs
@@ -0,0 +1,136 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE NumericUnderscores  #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+
+{- HLINT ignore "Redundant id" -}
+{- HLINT ignore "Redundant pure" -}
+{- HLINT ignore "Use let" -}
+
+module Data.RdsData.Polysemy.Test.Cluster
+  ( RdsClusterDetails(..),
+    createRdsDbCluster,
+    waitUntilRdsDbClusterAvailable,
+  ) where
+
+import qualified Amazonka                                  as AWS
+import qualified Amazonka.RDS                              as AWS
+import qualified Amazonka.SecretsManager                   as AWS
+import           Data.Aeson                                ((.=))
+import qualified Data.Aeson                                as J
+import qualified Data.ByteString.Base64                    as B64
+import qualified Data.ByteString.Lazy                      as LBS
+import           Data.Function
+import           Data.Generics.Product.Any
+import           Data.RdsData.Migration.Types              (RdsClusterDetails (RdsClusterDetails))
+import qualified Data.Text.Encoding                        as T
+import qualified Data.UUID                                 as UUID
+import qualified Data.UUID.V4                              as UUID
+import           HaskellWorks.Control.Monad
+import           HaskellWorks.Polysemy
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Polysemy.Amazonka.LocalStack (getLocalStackEndpoint,
+                                                            inspectContainer)
+import           HaskellWorks.Polysemy.Control.Concurrent
+import           HaskellWorks.Polysemy.Hedgehog
+import           HaskellWorks.Polysemy.Prelude
+import           HaskellWorks.TestContainers.LocalStack
+import           Lens.Micro
+
+createRdsDbCluster :: ()
+  => HasCallStack
+  => Member (Embed IO) r
+  => Member (Reader AWS.Env) r
+  => Member Hedgehog r
+  => Member Resource r
+  => Text
+  -> IO Container
+  -> Sem r RdsClusterDetails
+createRdsDbCluster databaseName getContainer = withFrozenCallStack do
+  container <- embed getContainer
+  jotShowM_ $ getLocalStackEndpoint container
+  jotYamlM_ $ inspectContainer container
+  masterUsername <- pure "masterUsername"
+  masterPassword <- pure "masterPassword"
+
+  let dbClusterId = "my-cluster"
+
+  createDbClusterRequest <-
+    pure $
+      AWS.newCreateDBCluster dbClusterId "aurora-postgresql"
+        & the @"masterUsername" .~ Just masterUsername
+        & the @"masterUserPassword" .~ Just masterPassword
+        & the @"enableHttpEndpoint" .~ Just True
+        & the @"databaseName" .~ Just databaseName
+
+  createDbClusterResponse <-
+    sendAws createDbClusterRequest
+      & jotShowDataLog @AwsLogEntry
+      & trapFail
+
+  let secretName = "my-aurora-cluster"
+
+  secretString <-
+    jotYaml $
+      J.object
+        [ "engine" .= id @Text "aurora-postgresql"
+        , "username" .= id @Text masterUsername
+        , "password" .= id @Text masterPassword
+        , "host" .= id @Text "localhost"
+        , "dbname" .= id @Text databaseName
+        , "port" .= id @Text "4510"
+        ]
+
+  uuid <- embed UUID.nextRandom
+
+  let clientRequestToken = T.encodeUtf8 $ UUID.toText uuid
+
+  let secretStringText = T.decodeUtf8 $ LBS.toStrict $ J.encode secretString
+
+  createSecretReq <-
+    pure $
+      AWS.newCreateSecret secretName
+        & the @"secretString" ?~ AWS.Sensitive secretStringText
+        & the @"clientRequestToken" ?~ T.decodeUtf8 (B64.encode clientRequestToken)
+
+  createSecetResp <-
+    sendAws createSecretReq
+      & jotShowDataLog @AwsLogEntry
+      & trapFail
+
+  createDbInstanceReq <-
+    pure $
+      AWS.newCreateDBInstance dbClusterId "my-db-instance" "db.t3.medium"
+        & the @"engine" .~ "aurora-postgresql"
+
+  _dbInstanceIdResp <-
+    jotShowM $
+      sendAws createDbInstanceReq
+        & jotShowDataLog @AwsLogEntry
+        & trapFail
+
+  pure (RdsClusterDetails createDbClusterResponse createSecetResp)
+
+waitUntilRdsDbClusterAvailable :: ()
+  => HasCallStack
+  => Member (Embed IO) r
+  => Member (Error AWS.Error) r
+  => Member (Reader AWS.Env) r
+  => Member (DataLog AwsLogEntry) r
+  => Member Resource r
+  => Text
+  -> Sem r ()
+waitUntilRdsDbClusterAvailable dbClusterArn =
+  withFrozenCallStack do
+    repeatNWhileM_ 120 $ \_ -> do
+      result <- sendAws $
+        AWS.newDescribeDBClusters
+          & the @"dbClusterIdentifier" .~ Just dbClusterArn
+
+      let mStatus = result ^? the @"dbClusters" . _Just . each . the @"status" . _Just
+
+      if mStatus == Just "available"
+        then threadDelay 1_000_000 >> pure False
+        else threadDelay 1_000_000 >> pure True
diff --git a/testlib/Data/RdsData/Polysemy/Test/Env.hs b/testlib/Data/RdsData/Polysemy/Test/Env.hs
new file mode 100644
--- /dev/null
+++ b/testlib/Data/RdsData/Polysemy/Test/Env.hs
@@ -0,0 +1,103 @@
+{-# LANGUAGE DataKinds           #-}
+{-# LANGUAGE FlexibleContexts    #-}
+{-# LANGUAGE FlexibleInstances   #-}
+{-# LANGUAGE GADTs               #-}
+{-# LANGUAGE PolyKinds           #-}
+{-# LANGUAGE RankNTypes          #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications    #-}
+{-# LANGUAGE TypeOperators       #-}
+
+{- HLINT ignore "Redundant pure" -}
+{- HLINT ignore "Use let" -}
+
+module Data.RdsData.Polysemy.Test.Env
+  ( AwsResourceArn(..),
+    AwsSecretArn(..),
+    runLocalTestEnv,
+    runTestEnv,
+    runReaderFromEnvOrFail,
+    runReaderStatementContextFromClusterDetails,
+  ) where
+
+import qualified Amazonka                                  as AWS
+import           Data.Generics.Product.Any
+import           Data.RdsData.Aws
+import           Data.RdsData.Migration.Types
+import           Data.RdsData.Polysemy.Error
+import qualified Data.Text                                 as Text
+import           HaskellWorks.Polysemy.Amazonka
+import           HaskellWorks.Polysemy.Amazonka.LocalStack
+import           HaskellWorks.Polysemy.Error
+import           HaskellWorks.Polysemy.Hedgehog
+import           HaskellWorks.Polysemy.System.Environment
+import           HaskellWorks.Prelude
+import           HaskellWorks.TestContainers.LocalStack    (Container)
+import           Lens.Micro
+import           Polysemy
+import           Polysemy.Error
+import           Polysemy.Reader
+
+runTestEnv :: ()
+  => HasCallStack
+  => Member (Embed IO) r
+  => Member Hedgehog r
+  => Sem
+        ( Reader AWS.Env
+        : Reader AwsResourceArn
+        : Reader AwsSecretArn
+        : r)
+      a
+  -> Sem r a
+runTestEnv f =
+  withFrozenCallStack $ f
+    & runReaderAwsEnvDiscover
+    & runReaderFromEnvOrFail (AwsResourceArn . Text.pack) "AURORA_RESOURCE_ARN"
+    & runReaderFromEnvOrFail (AwsSecretArn . Text.pack) "AURORA_SECRET_ARN"
+
+runLocalTestEnv :: ()
+  => HasCallStack
+  => Member (Embed IO) r
+  => IO Container
+  -> Sem
+        ( Reader AWS.Env
+        : r)
+      a
+  -> Sem r a
+runLocalTestEnv getContainer f =
+  withFrozenCallStack $ f
+    & runReaderLocalAwsEnvDiscover getContainer
+
+runReaderFromEnvOrFail :: forall i r a. ()
+  => Member (Embed IO) r
+  => Member Hedgehog r
+  => (String -> i)
+  -> String
+  -> Sem (Reader i ': r) a
+  -> Sem r a
+runReaderFromEnvOrFail f envVar action = do
+  env <- lookupEnv envVar
+    & onNothingM (throw (EnvironmentVariableMissing envVar) & trapFail)
+
+  runReader (f env) action
+
+runReaderStatementContextFromClusterDetails :: ()
+  => Member Hedgehog r
+  => RdsClusterDetails
+  -> Sem (Reader StatementContext : r) a
+  -> Sem r a
+runReaderStatementContextFromClusterDetails details f = do
+  resourceArn <- (details ^. the @"createDbClusterResponse" . the @"dbCluster" . _Just . the @"dbClusterArn")
+    & nothingFail
+
+  secretArn <- (details ^. the @"createSecretResponse" ^. the @"arn")
+    & nothingFail
+
+  mDatabase <- pure $ (details ^? the @"createDbClusterResponse" . the @"dbCluster" . _Just . the @"databaseName" . _Just)
+    <&> Database
+
+  statementContext <- pure $ newStatementContext (AwsResourceArn resourceArn) (AwsSecretArn secretArn)
+    & the @"database" .~ mDatabase
+
+
+  f & runReader statementContext
diff --git a/testlib/Data/RdsData/Polysemy/Test/Workspace.hs b/testlib/Data/RdsData/Polysemy/Test/Workspace.hs
new file mode 100644
--- /dev/null
+++ b/testlib/Data/RdsData/Polysemy/Test/Workspace.hs
@@ -0,0 +1,38 @@
+{-# LANGUAGE DataKinds         #-}
+{-# LANGUAGE FlexibleContexts  #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeOperators     #-}
+
+module Data.RdsData.Polysemy.Test.Workspace
+  ( localWorkspace,
+  ) where
+
+import qualified Data.Text                      as T
+
+import           HaskellWorks.Polysemy
+import           HaskellWorks.Polysemy.Hedgehog
+import           HaskellWorks.Polysemy.Prelude
+import           Polysemy                       ()
+
+localWorkspace :: ()
+  => HasCallStack
+  => Member (Embed IO) r
+  => Member Hedgehog r
+  => Member Log r
+  => Text
+  -> Sem
+        ( Reader Workspace
+        : Reader ProjectRoot
+        : Reader PackagePath
+        : Resource
+        : r)
+        ()
+  -> Sem r ()
+localWorkspace prefix f =
+  withFrozenCallStack $ do
+    cabalProjectDir <- findCabalProjectDir "."
+
+    f & moduleWorkspace (T.unpack prefix)
+      & runReader (ProjectRoot cabalProjectDir)
+      & runReader (PackagePath "rds-data")
+      & runResource
