diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2022, Gil Mizrahi
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * 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.
+
+    * Neither the name of Gil Mizrahi nor the names of other
+      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
+OWNER 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,9 @@
+sqlite-easy
+===========
+
+This library provides a primitive yet easy to use api for SQLite3 using the `direct-sqlite` library,
+resource pooling using `resource-pool`, and migrations using the `migrant` library.
+
+It might be useful for small toy projects and save a tiny bit of boilerplate.
+
+[See the docs for more information](https://gilmi.gitlab.io/sqlite-easy/haddocks/sqlite-easy-0.1.0.0/Database-Sqlite-Easy.html)!
diff --git a/sqlite-easy.cabal b/sqlite-easy.cabal
new file mode 100644
--- /dev/null
+++ b/sqlite-easy.cabal
@@ -0,0 +1,63 @@
+cabal-version: 2.4
+
+name:           sqlite-easy
+version:        0.1.0.0
+synopsis:       A primitive yet easy to use sqlite library.
+description:    A primitive yet easy to use sqlite library built using sqlite-direct, resource-pool and migrant.
+author:         Gil Mizrahi
+category:       Database
+stability:      Experimental
+maintainer:     gilmi@posteo.net
+copyright:      2022 Gil Mizrahi
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+--    cbits/sqlite3.c
+--    cbits/sqlite3.h
+
+library
+  exposed-modules:
+      Database.Sqlite.Easy
+      Database.Sqlite.Easy.Internal
+      Database.Sqlite.Easy.Migrant
+  hs-source-dirs:
+      src
+  build-depends:
+      base >=4.12 && <5
+    , bytestring
+    , text
+    , direct-sqlite
+    , resource-pool
+    , migrant-core
+  default-language: Haskell2010
+  ghc-options: -Wall
+
+  -- c-sources: cbits/sqlite3.c
+  -- include-dirs: cbits
+  -- includes: sqlite3.h
+  -- install-includes: sqlite3.h
+  -- cc-options: -fPIC -std=c99
+
+  -- if !os(windows)
+  --   extra-libraries: pthread
+
+test-suite sqlite-easy-test
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test
+  main-is: Spec.hs
+  other-modules:
+    SqliteEasySpec
+  build-depends:
+      base
+    , hspec
+    , hspec-discover
+    , sqlite-easy
+    , text
+    , direct-sqlite
+  default-language: Haskell2010
+  ghc-options:
+    -threaded -rtsopts -with-rtsopts=-N
+  build-tool-depends:
+    hspec-discover:hspec-discover
diff --git a/src/Database/Sqlite/Easy.hs b/src/Database/Sqlite/Easy.hs
new file mode 100644
--- /dev/null
+++ b/src/Database/Sqlite/Easy.hs
@@ -0,0 +1,264 @@
+-- | Easy to use interface for SQLite3 using the @direct-sqlite@ library.
+--
+-- This can be useful for your toy, hobby projects.
+module Database.Sqlite.Easy
+  ( -- * Connect to the database
+    -- $connecting
+    withDb
+  , ConnectionString(..)
+  , Database
+    -- ** Pooling connections
+    -- $pooling
+  , Pool
+  , createSqlitePool
+  , withResource
+  , destroyAllResources
+    -- * Running statements and queries
+    -- $running
+  , run
+  , runWith
+  , -- ** Database types
+    SQL
+  , SQLData(..)
+  , SQLError(..)
+  , ColumnType(..)
+    -- * Running transactions
+    -- $transactions
+  , asTransaction
+  , cancelTransaction
+  , -- * Migrations
+    -- $migrations
+    module Migrant
+  , -- * Fun types to export
+    Int64, Text, ByteString
+    -- * Suggestions
+    -- $suggestions
+  )
+  where
+
+import Data.Pool (Pool, withResource, destroyAllResources)
+import Database.SQLite3 (SQLData(..), Database, SQLError(..), ColumnType(..))
+import Database.Sqlite.Easy.Internal
+import Database.Sqlite.Easy.Migrant as Migrant
+import Database.Migrant as Migrant
+import Data.Int (Int64)
+import Data.Text (Text)
+import Data.ByteString (ByteString)
+
+{- $connecting
+
+The easiest way to run some statements on the database is using the
+'withDb' function. 'withDb' uses continuation-passing style and expects
+a connection string (such as a file with flags or @:memory:@,
+see sqlite3 docs: <https://www.sqlite.org/c3ref/open.html>), and
+a function that will take the database handle and do things with it.
+It will open the connection to the database and call the function,
+passing the database handle to it.
+
+=== Example
+
+> do
+>   results <- withDb ":memory:" (run "select 1 + 1")
+>   case results of
+>     [[SQLInteger n]] -> print n
+>     _ -> error ("Got unexpected results: " <> show results)
+
+Note: use 'Data.String.fromString' to convert a 'String' to a 'ConnectionString'
+      or to 'SQL' if you prefer not to use @OverloadedStrings@.
+-}
+
+{- $pooling
+
+An alternative to 'withDb' is to create a resource 'Pool'.
+A resource pool is an abstraction for automatically managing connections
+to a resource (such as a database).
+
+We can use the 'createSqlitePool' function to create a @Pool 'Database'@
+and pass that around until you are ready to use the database.
+
+We can use 'withResource' to essentially make 'withDb' from a pool.
+
+=== Example
+
+> do
+>   pool <- createSqlitePool ":memory:"
+>   results <- withResource pool (run "select 1 + 1")
+>   case results of
+>     [[SQLInteger n]] -> print n
+>     _ -> error ("Got unexpected results: " <> show results)
+
+Note: a resource pool disconnects automatically after some time,
+so if you are using @:memory:@ as your database, you will lose your
+data when the connection closes!
+
+-}
+
+{- $running
+
+To execute a statement or query, use the 'run' or 'runWith' functions.
+
+'run' is used to execute a statement or query and fetch the results.
+
+'runWith' is similar to 'run', but lets us place parameters instead of
+places where we write @?@ in the query.
+If you want to pass user data, use 'runWith' to avoid SQL injection!
+
+The list of lists of SQLData returned from these functions are
+rows of columns of sqlite values. Sqlite only has a few possible
+values: integers, floating-point numbers, text, bytes and null.
+The 'SQLData' type encodes these options.
+
+=== Example
+
+> do
+>   results <- withDb ":memory:" $ \db -> do
+>     [] <- run "CREATE TABLE characters(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)" db
+>     [] <- run "INSERT INTO characters(name) VALUES ('Scanlan'),('Nott'),('Fresh Cut Grass')" db
+>     runWith "SELECT * FROM characters WHERE id = ?" [SQLInteger 2] db
+>
+>   for_ results $ \case
+>     [SQLInteger id', SQLText name] -> putStrLn (show id' <> ", " <> show name)
+>     row -> hPutStrLn stderr ("Unexpected row: " <> show row)
+-}
+
+{- $transactions
+
+If you'd like to run multiple statements and queries atomically, use 'asTransaction'.
+Please note that sqlite transactions do not nest.
+
+=== Example
+
+> withDb ":memory:" $ \db -> do
+>   asTransaction db $ do
+>     [] <- run "CREATE TABLE t1(id INTEGER, name TEXT)" db
+>     [] <- run "CREATE TABLE t2(id INTEGER, name TEXT)" db
+>     [] <- run "CREATE TABLE t3id INTEGER, name TEXT)" db -- whoops
+>     [] <- run "CREATE TABLE t4(id INTEGER, name TEXT)" db
+>     pure ()
+>   run "select * from t1" db -- throws an exception (table not found) because the transaction was rolled back
+
+You can also decide to cancel a transaction yourself by supplying the result value
+with 'cancelTransaction'.
+
+-}
+
+{- $migrations
+
+Database migrations are a way to setup a database with the relevant information
+(such as table structure) needed for the application to start, and update it
+from a possible older version to a newer version (or even go the other direction).
+
+Migrations are a list of statements we run in order to upgrade or downgrade a database.
+We use the @migrant@ library to semi-automate this process - we write the upgrade
+and downgrade steps, and it runs them. For more information, consult the @migrant@
+documentation: <https://github.com/tdammers/migrant>.
+
+To create a migration we need to write the following things:
+
+1) A list of migration names
+
+> migrations :: [MigrationName]
+> migrations =
+>   [ "user-table"
+>   , "article-table"
+>   ]
+
+2) Migration up steps - a mapping from migration name to what to do.
+
+> migrateUp :: MigrationName -> Database -> IO ()
+> migrateUp = \case
+>   "user-table" ->
+>     void . run "CREATE TABLE user(id INTEGER, name TEXT)"
+>   "article-table" ->
+>     void . run "CREATE TABLE article(id integer, title TEXT, content TEXT, author_id integer)"
+>   unknown ->
+>     error ("Unexpected migration: " <> show unknown)
+
+3) Migration down steps
+
+> migrateDown :: MigrationName -> Database -> IO ()
+> migrateDown = \case
+>   "user-table" ->
+>     void . run "DROP TABLE user"
+>   "article-table" ->
+>     void . run "DROP TABLE article"
+>   unknown ->
+>     error ("Unexpected migration: " <> show unknown)
+
+After doing that, we can run a migration with the 'migrate' function:
+
+> runMigrations :: Database -> IO ()
+> runMigrations = migrate migrations migrateUp migrateDown
+
+-}
+
+{- $suggestions
+
+A suggestion for the architecture of your database interactions: use the handler pattern!
+
+=== 1. Create a type for your API
+
+Think about what actions you want to perform on your database:
+
+For example:
+
+> data DB
+>   = DB
+>     { getPost :: Id -> IO (Id, Post)
+>     , getPosts :: IO [(Id, Post)]
+>     , insertPost :: Post -> IO Id
+>     , deletePostById :: Id -> IO ()
+>     }
+
+Note how we don't mention the database connection here!
+
+=== 2. Create a smart constructor
+
+This function should:
+
+1. Create a resource pool with the database
+2. Run the database migrations
+3. Return a @DB@ such that each function in the API is a closure over the pool
+
+For example:
+
+> mkDB :: ConnectionString -> IO DB
+> mkDB connectionString = do
+>   pool <- DB.createSqlitePool connstr
+>   DB.withResource pool runMigrations
+>   pure $ DB
+>     { getPost = getPostFromDb pool
+>     , getPosts = getPostsFromDb pool
+>     , insertPost = insertPostToDb pool
+>     , deletePostById = deletePostByIdFromDb pool
+>     }
+
+Where:
+
+> getPostFromDb :: Pool Database -> Id -> IO (Id, Post)
+
+> insertPostToDb :: Pool Database -> Post -> IO Id
+
+and so on.
+
+=== 3. Use the handler from your application code
+
+When you start the application, run @mkDB@ and get a handler, and pass it around
+(or use @ReaderT@). When you need to run a database command, call a field from @DB@:
+
+> -- A page for a specific post
+> [ Twain.get "/post/:id" $ do
+>   postId <- Twain.param "id"
+>   post <- liftIO $ getPost db postId -- (*)
+>   Twain.send (displayPost post)
+> ]
+
+Or, with @OverloadedRecordDot@,
+
+>   post <- liftIO $ db.getPost postId
+
+== Complete Example
+
+Visit this link for a more complete example:
+<https://github.com/soupi/learn-twain-bulletin-app/blob/sqlite/src/DB.hs>
+-}
diff --git a/src/Database/Sqlite/Easy/Internal.hs b/src/Database/Sqlite/Easy/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Database/Sqlite/Easy/Internal.hs
@@ -0,0 +1,108 @@
+{-# language DerivingVia #-}
+{-# language OverloadedStrings #-}
+{-# language ScopedTypeVariables #-}
+
+module Database.Sqlite.Easy.Internal where
+
+import Database.SQLite3
+import Data.String (IsString)
+import Data.Text (Text)
+import Control.Exception
+import Data.Typeable
+import Data.Pool
+
+-- * Connection
+
+-- | A SQLite3 connection string
+newtype ConnectionString
+  = ConnectionString
+    { unConnectionString :: Text
+    }
+  deriving IsString via Text
+  deriving Show
+
+-- | Create a pool of a sqlite3 db with a specific connection string.
+createSqlitePool :: ConnectionString -> IO (Pool Database)
+createSqlitePool (ConnectionString connStr) =
+  newPool PoolConfig
+    { createResource = open connStr
+    , freeResource = close
+    , poolCacheTTL = 180
+    , poolMaxResources = 50
+    }
+
+-- | Open a database, run some stuff, close the database.
+withDb :: ConnectionString -> (Database -> IO a) -> IO a
+withDb (ConnectionString connStr) = bracket (open connStr) close
+
+-- * Execution
+
+-- | A SQL statement
+newtype SQL
+  = SQL
+    { unSQL :: Text
+    }
+  deriving (Semigroup, IsString) via Text
+  deriving Show
+
+-- | Run a SQL statement on a database and fetch the results.
+run :: SQL -> Database -> IO [[SQLData]]
+run (SQL stmt) db = prepare db stmt >>= fetchAll
+
+-- | Run a SQL statement with certain parameters on a database and fetch the results.
+runWith :: SQL -> [SQLData] -> Database -> IO [[SQLData]]
+runWith (SQL stmt) params db = do
+  preparedStmt <- prepare db stmt
+  bind preparedStmt params
+  fetchAll preparedStmt
+
+-- | Run a statement and fetch all of the data.
+fetchAll :: Statement -> IO [[SQLData]]
+fetchAll stmt = do
+  res <- step stmt
+  case res of
+    Row -> do
+      row <- columns stmt
+      rows <- fetchAll stmt
+      pure (row : rows)
+    Done -> do
+      finalize stmt
+      pure []
+
+-- * Transaction
+
+-- | Run operations as a transaction.
+--   If the action throws an error, the transaction is rolled back.
+--
+--   __Note__: Transactions do not nest.
+--
+--   For more information, visit: <https://www.sqlite.org/lang_transaction.html>
+asTransaction :: Typeable a => Database -> IO a -> IO a
+asTransaction db action = do
+  [] <- run "BEGIN" db
+  catches
+    (action <* run "COMMIT" db)
+    [ Handler $ \(CancelTransaction a) -> run "ROLLBACK" db *> pure a
+    , Handler $ \(ex :: SomeException) -> run "ROLLBACK" db *> throwIO ex
+    ]
+
+asTransaction' :: Database -> IO a -> IO a
+asTransaction' db action = do
+  [] <- run "BEGIN" db
+  catches
+    (action <* run "COMMIT" db)
+    [ Handler $ \(ex :: SomeException) -> run "ROLLBACK" db *> throwIO ex
+    ]
+
+-- | Cancel a transaction by supplying the return value.
+--   To be used inside transactions.
+cancelTransaction :: Typeable a => a -> IO a
+cancelTransaction = throwIO . CancelTransaction
+
+data CancelTransaction a
+  = CancelTransaction a
+
+instance Show (CancelTransaction a) where
+  show CancelTransaction{} = "CancelTransaction"
+
+instance (Typeable a) => Exception (CancelTransaction a)
diff --git a/src/Database/Sqlite/Easy/Migrant.hs b/src/Database/Sqlite/Easy/Migrant.hs
new file mode 100644
--- /dev/null
+++ b/src/Database/Sqlite/Easy/Migrant.hs
@@ -0,0 +1,39 @@
+{-#LANGUAGE OverloadedStrings #-}
+module Database.Sqlite.Easy.Migrant
+  ( module Database.Migrant.Driver.Class
+  )
+  where
+
+import Database.Migrant.Driver.Class
+import Database.Migrant.MigrationName
+import qualified Database.Sqlite.Easy.Internal as Sqlite
+import qualified Database.SQLite3 as Sqlite
+
+instance Driver Sqlite.Database where
+  withTransaction action conn = Sqlite.asTransaction' conn (action conn)
+
+  initMigrations conn = do
+    [] <- Sqlite.run
+      "CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY, name TEXT)"
+      conn
+    pure ()
+
+  markUp name conn = do
+    [] <- Sqlite.runWith
+      "INSERT INTO _migrations (name) VALUES (?)"
+      [Sqlite.SQLText $ unpackMigrationName name]
+      conn
+    pure ()
+
+  markDown name conn = do
+    [] <- Sqlite.runWith
+      "DELETE FROM _migrations WHERE name = ?"
+      [Sqlite.SQLText $ unpackMigrationName name]
+      conn
+    pure ()
+
+  getMigrations conn = do
+    result <- Sqlite.run
+      "SELECT name FROM _migrations ORDER BY id"
+       conn
+    return [ MigrationName name | [Sqlite.SQLText name] <- result ]
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 hspec-discover #-}
diff --git a/test/SqliteEasySpec.hs b/test/SqliteEasySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/SqliteEasySpec.hs
@@ -0,0 +1,70 @@
+{-# language OverloadedStrings #-}
+{-# language ScopedTypeVariables #-}
+
+module SqliteEasySpec (spec) where
+
+import Test.Hspec
+import Database.Sqlite.Easy
+import Control.Exception (try)
+
+spec :: Spec
+spec = do
+  describe "sanity" $ do
+    it "create insert select" $ do
+      result <- withDb ":memory:" $ \db -> do
+        [] <- run "create table t(x int not null)" db
+        [] <- run "insert into t values (1),(2),(3)" db
+        run "select * from t" db
+      shouldBe
+        result
+        [[SQLInteger 1], [SQLInteger 2], [SQLInteger 3]]
+
+    it "select with params" $ do
+      result <- withDb ":memory:" $ \db -> do
+        [] <- run "create table t(x int not null)" db
+        [] <- run "insert into t values (1),(2),(3)" db
+        runWith "select * from t where x = ?" [SQLInteger 1] db
+      shouldBe
+        result
+        [[SQLInteger 1]]
+
+  describe "transactions" $ do
+    it "transaction is rolled back in case of error" $ do
+      result <- withDb ":memory:" $ \db -> do
+        [] <- run "create table t(x int not null)" db
+        Left SQLError{} <- try $ asTransaction db $ do
+          [] <- run "insert into t values (1)" db
+          [] <- run "insert into t values (2" db
+          pure ()
+        run "select * from t" db
+      shouldBe result []
+
+    it "transaction is commited" $ do
+      result <- withDb ":memory:" $ \db -> do
+        [] <- run "create table t(x int not null)" db
+        (Right () :: Either SQLError ()) <- try $ asTransaction db $ do
+          [] <- run "insert into t values (1)" db
+          [] <- run "insert into t values (2)" db
+          pure ()
+        run "select * from t" db
+      shouldBe result [[SQLInteger 1], [SQLInteger 2]]
+
+    it "transaction is cancelled" $ do
+      result <- withDb ":memory:" $ \db -> do
+        [] <- run "create table t(x int not null)" db
+        (Right transactionResult :: Either SQLError [[SQLData]]) <- try $ asTransaction db $ do
+          [] <- run "insert into t values (1)" db
+          cancelTransaction []
+        result <- run "select * from t" db
+        pure (transactionResult, result)
+      uncurry shouldBe result
+
+  describe "pool" $ do
+    it "create, use, and destroy" $ do
+      pool <- createSqlitePool ":memory:"
+      result <- withResource pool $ \db -> do
+        [] <- run "create table t(x int not null)" db
+        [] <- run "insert into t values (1)" db
+        run "select * from t" db
+      destroyAllResources pool
+      shouldBe result [[SQLInteger 1]]
