on cab55e2 help sign in
28 files changed+1959−14
since splitsnapshots
unifiedsplit
default.nix+46−1 dep/.gitignore+1−1 dep/beam/default.nix+7 dep/beam/github.json+7 dep/gargoyle/default.nix+7 dep/gargoyle/github.json+7 dep/postgresql-simple/default.nix+7 dep/postgresql-simple/github.json+7 lib/backend/obelisk-backend.cabal+37−2 lib/backend/src/Data/MonoidMap.hs+48 lib/backend/src/Obelisk/Api.hs+187 lib/backend/src/Obelisk/Api/Pipeline.hs+408 lib/backend/src/Obelisk/Db.hs+51 lib/backend/src/Obelisk/Postgres/LogicalDecoding/Plugins/TestDecoding.hs+221 lib/backend/src/Obelisk/Postgres/QQ.hs+69 lib/backend/src/Obelisk/Postgres/Replication.hs+178 lib/request/obelisk-request.cabal+18 lib/request/src/Obelisk/Request.hs+25 lib/request/src/Obelisk/Request/HList.hs+59 lib/request/src/Obelisk/Request/TH.hs+142 skeleton/.gitignore+1 skeleton/backend/backend.cabal+4 skeleton/backend/src/Backend.hs+264−1 skeleton/backend/src/Backend/Schema.hs+40 skeleton/common/src/Common/Api.hs+48−2 skeleton/common/src/Common/Route.hs+2 skeleton/common/src/Common/Schema.hs+60 skeleton/frontend/src/Frontend.hs+8−7
default.nix+46−1
@@ -16,6 +16,12 @@ let
1616 openssh
1717 ];
1818
19+ beam-src = hackGet ./dep/beam;
20+
21+ gargoyle-src = hackGet ./dep/gargoyle;
22+
23+ postgresql-simple-src = hackGet ./dep/postgresql-simple;
24+
1925 getReflexPlatform = getReflexPlatform' __useLegacyCompilers;
2026 getReflexPlatform' = __useLegacyCompilers: sys: import ./dep/reflex-platform {
2127 inherit iosSdkVersion __useLegacyCompilers;
@@ -28,7 +34,7 @@ let
2834 })
2935 ];
3036
31− haskellOverlays = [
37+ haskellOverlays = with pkgs.haskell.lib; [
3238
3339 # Fix misc upstream packages
3440 (self: super: let
@@ -48,6 +54,44 @@ let
4854 rev = "6a71119bfa5db2b9990a2491c941469ff8ef5d13";
4955 sha256 = "0z8smyainnlzcglv3dlx6x1n9j6d2jv48aa8f2421iayfkxg3js5";
5056 } + /template) {};
57+
58+ algebraic-graphs = pkgs.haskell.lib.doJailbreak
59+ (self.callCabal2nix "algebraic-graphs" (pkgs.fetchFromGitHub {
60+ owner = "snowleopard";
61+ repo = "alga";
62+ rev = "480a73137e9b38ad3f1bc2c628847953d2fb3e25";
63+ sha256 = "0dpwi5ffs88brl3lz51bwb004c6zm8ds8pkw1vzsg2a6aaiyhlzl";
64+ }) {});
65+
66+ # Need ShowTag, EqTag, and OrdTag instances
67+ dependent-sum-template = self.callCabal2nix "dependent-sum-template" (pkgs.fetchFromGitHub {
68+ owner = "mokus0";
69+ repo = "dependent-sum-template";
70+ rev = "dcb92a98d35d79f712b9a398c8c356acc979ecff";
71+ sha256 = "1v337d810d88jzfriw07pr16d34nibm6q2zkw787lc3sfn07glp5";
72+ }) {};
73+
74+ beam-core = dontCheck (self.callCabal2nix "beam-core" (beam-src + /beam-core) {});
75+ beam-postgres = dontCheck (self.callCabal2nix "beam-postgres" (beam-src + /beam-postgres) {});
76+ beam-migrate = self.callCabal2nix "beam-migrate" (beam-src + /beam-migrate) {};
77+
78+ postgresql-simple = dontCheck (self.callCabal2nix "postgresql-simple" postgresql-simple-src {});
79+
80+ websockets = self.callCabal2nix "websockets" (pkgs.fetchFromGitHub {
81+ owner = "obsidiansystems";
82+ repo = "websockets";
83+ rev = "b750edf10fc6f532f9fb36588904491af87606c7";
84+ sha256 = "1wd0gkx8a4x6n5cdkra96zvpv8l49i0z90amvrng70ybnzbxpfi5";
85+ }) {};
86+
87+ gargoyle = doJailbreak (self.callCabal2nix "gargoyle" (gargoyle-src + /gargoyle) {});
88+ gargoyle-postgresql-nix = pkgs.haskell.lib.addBuildTool
89+ (self.callCabal2nix "gargoyle-postgresql-nix" (gargoyle-src + /gargoyle-postgresql-nix) {})
90+ pkgs.postgresql100; # `staticWhich` requires `psql` on PATH during build time
91+ gargoyle-postgresql = addBuildDepend (doJailbreak (self.callCabal2nix "gargoyle-postgresql" (gargoyle-src + /gargoyle-postgresql) {})) pkgs.postgresql100;
92+ postgresql-libpq = enableCabalFlag (overrideCabal super.postgresql-libpq (drv: {
93+ pkgconfigDepends = (drv.pkgconfigDepends or []) ++ [pkgs.postgresql100]; #TODO: Obelisk should probably provide this version of postgresql
94+ })) "use-pkg-config";
5195 })
5296
5397 # Add obelisk packages
@@ -63,6 +107,7 @@ let
63107 obelisk-cliapp = self.callCabal2nix "obelisk-cliapp" (cleanSource ./lib/cliapp) {};
64108 obelisk-command = self.callCabal2nix "obelisk-command" (cleanSource ./lib/command) {};
65109 obelisk-frontend = self.callCabal2nix "obelisk-frontend" (cleanSource ./lib/frontend) {};
110+ obelisk-request = self.callCabal2nix "obelisk-request" (cleanSource ./lib/request) {};
66111 obelisk-run = self.callCabal2nix "obelisk-run" (cleanSource ./lib/run) {};
67112 obelisk-route = self.callCabal2nix "obelisk-route" (cleanSource ./lib/route) {};
68113 obelisk-selftest = self.callCabal2nix "obelisk-selftest" (cleanSource ./lib/selftest) {};
dep/.gitignore+1−1
@@ -1,4 +1,4 @@
1−*
1+*/*
22!.gitignore
33!*/*.json
44!*/default.nix
dep/beam/default.nixnew file+7
@@ -0,0 +1,7 @@
1+# DO NOT HAND-EDIT THIS FILE
2+import ((import <nixpkgs> {}).fetchFromGitHub (
3+ let json = builtins.fromJSON (builtins.readFile ./github.json);
4+ in { inherit (json) owner repo rev sha256;
5+ private = json.private or false;
6+ }
7+))
dep/beam/github.jsonnew file+7
@@ -0,0 +1,7 @@
1+{
2+ "owner": "obsidiansystems",
3+ "repo": "beam",
4+ "branch": "obelisk",
5+ "rev": "d060ed41016aa62f4a571e69430f64ef660db894",
6+ "sha256": "0ag19lc2izay5qg3j9mv44izsccglh89191gmh9vfx2kqv20h945"
7+}
dep/gargoyle/default.nixnew file+7
@@ -0,0 +1,7 @@
1+# DO NOT HAND-EDIT THIS FILE
2+import ((import <nixpkgs> {}).fetchFromGitHub (
3+ let json = builtins.fromJSON (builtins.readFile ./github.json);
4+ in { inherit (json) owner repo rev sha256;
5+ private = json.private or false;
6+ }
7+))
dep/gargoyle/github.jsonnew file+7
@@ -0,0 +1,7 @@
1+{
2+ "owner": "obsidiansystems",
3+ "repo": "gargoyle",
4+ "branch": "master",
5+ "rev": "b641902ff1e798e230e5a101fd22ebfbae3c6a08",
6+ "sha256": "0pxa2zn1vy4n9cpal1cy1lcpmzgjvrx0ppmjqjs7ykv7z8647f1g"
7+}
dep/postgresql-simple/default.nixnew file+7
@@ -0,0 +1,7 @@
1+# DO NOT HAND-EDIT THIS FILE
2+import ((import <nixpkgs> {}).fetchFromGitHub (
3+ let json = builtins.fromJSON (builtins.readFile ./github.json);
4+ in { inherit (json) owner repo rev sha256;
5+ private = json.private or false;
6+ }
7+))
dep/postgresql-simple/github.jsonnew file+7
@@ -0,0 +1,7 @@
1+{
2+ "owner": "obsidiansystems",
3+ "repo": "postgresql-simple",
4+ "branch": "synthesizable-fields",
5+ "rev": "dc162dc21f586ba4c6e5c44f92b76aac653a85db",
6+ "sha256": "1mpx6w94z9b3vv1981v7csj2y4p97irv3p7cvv1lyz9c595kkisp"
7+}
lib/backend/obelisk-backend.cabal+37−2
@@ -6,22 +6,57 @@ build-type: Simple
66library
77 hs-source-dirs: src
88 build-depends: base,
9+ aeson,
10+ async,
11+ attoparsec,
12+ binary,
913 bytestring,
1014 categories,
15+ clientsession,
16+ constraints,
17+ containers,
1118 data-default,
1219 dependent-sum,
1320 dependent-sum-template,
21+ directory,
22+ --TODO: Do we really want enclosed-exceptions?
23+ enclosed-exceptions,
24+ filepath,
25+ gargoyle,
26+ gargoyle-postgresql-nix,
1427 lens,
28+ monoidal-containers,
1529 mtl,
1630 obelisk-asset-serve-snap,
1731 obelisk-executable-config-inject,
1832 obelisk-frontend,
33+ obelisk-request,
1934 obelisk-route,
2035 obelisk-snap-extras,
36+ postgresql-libpq,
37+ postgresql-simple,
38+ reflex,
2139 reflex-dom,
40+ resource-pool,
2241 snap,
42+ snap-core,
2343 snap-server,
44+ split,
45+ stm,
46+ template-haskell,
2447 text,
25− universe
26− exposed-modules: Obelisk.Backend
48+ these,
49+ time,
50+ universe,
51+ unordered-containers,
52+ websockets,
53+ websockets-snap
54+ exposed-modules:
55+ Data.MonoidMap
56+ Obelisk.Api
57+ Obelisk.Api.Pipeline
58+ Obelisk.Backend
59+ Obelisk.Db
60+ Obelisk.Postgres.LogicalDecoding.Plugins.TestDecoding
61+ Obelisk.Postgres.Replication
2762 ghc-options: -Wall
lib/backend/src/Data/MonoidMap.hsnew file+48
@@ -0,0 +1,48 @@
1+{-# LANGUAGE DeriveFoldable #-}
2+{-# LANGUAGE FlexibleContexts #-}
3+{-# LANGUAGE TypeFamilies #-}
4+module Data.MonoidMap where
5+
6+import Data.Align
7+import Data.Map.Monoidal (MonoidalMap)
8+import Data.Map.Monoidal as Map
9+import Data.Semigroup (Semigroup, (<>))
10+import Reflex (Query, QueryResult, crop, FunctorMaybe (..))
11+
12+newtype MonoidMap k v = MonoidMap { unMonoidMap :: MonoidalMap k v }
13+ deriving (Show, Eq, Ord, Foldable)
14+
15+monoidMap :: (Eq v, Monoid v) => MonoidalMap k v -> MonoidMap k v
16+monoidMap = MonoidMap . Map.filter (/= mempty)
17+
18+instance (Eq (QueryResult q), Ord k, Query q) => Query (MonoidMap k q) where
19+ type QueryResult (MonoidMap k q) = MonoidMap k (QueryResult q)
20+ crop (MonoidMap q) (MonoidMap qr) =
21+ -- This assumes that the query result of a null query should be null
22+ monoidMap $ Map.intersectionWith crop q qr
23+
24+instance (Monoid a, Eq a, Ord k) => Semigroup (MonoidMap k a) where
25+ MonoidMap a <> MonoidMap b =
26+ let combine _ a' b' =
27+ let c = a' `mappend` b'
28+ in if c == mempty
29+ then Nothing
30+ else Just c
31+ in MonoidMap $ Map.mergeWithKey combine id id a b
32+
33+instance (Ord k, Monoid a, Eq a) => Monoid (MonoidMap k a) where
34+ mempty = MonoidMap Map.empty
35+ mappend = (<>)
36+
37+instance (Ord k) => Functor (MonoidMap k) where
38+ fmap f (MonoidMap x) = MonoidMap (fmap f x)
39+
40+instance FunctorMaybe (MonoidMap k) where
41+ fmapMaybe f (MonoidMap m) = MonoidMap $ Map.mapMaybe f m
42+
43+instance Ord k => Align (MonoidMap k) where
44+ nil = MonoidMap $ MonoidalMap nil
45+ align (MonoidMap (MonoidalMap x)) (MonoidMap (MonoidalMap y)) = MonoidMap $ MonoidalMap $ align x y
46+ alignWith f (MonoidMap (MonoidalMap x)) (MonoidMap (MonoidalMap y)) = MonoidMap $ MonoidalMap $ alignWith f x y
47+
48+instance Ord k => Unalign (MonoidMap k)
lib/backend/src/Obelisk/Api.hsnew file+187
@@ -0,0 +1,187 @@
1+{-# LANGUAGE GADTs #-}
2+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
3+{-# LANGUAGE LambdaCase #-}
4+{-# LANGUAGE OverloadedStrings #-}
5+{-# LANGUAGE PatternSynonyms #-}
6+{-# LANGUAGE QuasiQuotes #-}
7+{-# LANGUAGE RankNTypes #-}
8+{-# LANGUAGE RecursiveDo #-}
9+{-# LANGUAGE ScopedTypeVariables #-}
10+module Obelisk.Api where
11+
12+import Prelude hiding ((.))
13+
14+import Obelisk.Api.Pipeline
15+import Obelisk.Db
16+import Obelisk.Postgres.Replication
17+import Obelisk.Postgres.LogicalDecoding.Plugins.TestDecoding
18+import Obelisk.Request (Request)
19+
20+import Control.Category
21+import Control.Concurrent.Async
22+import Control.Concurrent.STM (TChan, atomically, readTChan, writeTChan, newTChanIO)
23+import Control.Monad (forever)
24+import Control.Monad.Reader
25+import Control.Monad.Trans (lift)
26+import Data.Aeson (FromJSON, ToJSON)
27+import Data.Attoparsec.ByteString
28+import Data.ByteString (ByteString)
29+import Data.Default
30+import Data.Map.Monoidal (MonoidalMap)
31+import Data.MonoidMap (MonoidMap (..))
32+import Data.Pool (withResource)
33+import qualified Database.PostgreSQL.Simple as PG
34+import qualified Database.PostgreSQL.Simple.Transaction as PG
35+import Reflex (FunctorMaybe (..))
36+import Reflex.Patch (Group)
37+import Reflex.Query.Class (QueryResult, SelectedCount (..))
38+import Snap.Core (Snap)
39+
40+{-
41+-- | A way for a pipeline to retrieve data
42+newtype QueryHandler q m = QueryHandler
43+ { runQueryHandler :: q -> m (QueryResult q) }
44+
45+-- | A way to push data into a pipeline
46+newtype Recipient q m = Recipient
47+ { tellRecipient :: QueryResult q -> m () }
48+
49+-- | A way of attaching to (and later detaching from) a pipeline
50+newtype Registrar q = Registrar { unRegistrar :: Recipient q IO -> IO (QueryHandler q IO, IO ()) }
51+
52+-- | A way of connecting a source of data to a consumer of data. The consumer
53+-- can pull data from the datasource and the datasource can push data to the
54+-- consumer.
55+--
56+-- q is the consumer side
57+-- q' is the datasource side
58+newtype Pipeline m q q' = Pipeline { unPipeline :: QueryHandler q' m -> Recipient q m -> IO (QueryHandler q m, Recipient q' m) }
59+
60+instance Category (Pipeline m) where
61+ id = Pipeline $ \qh r -> return (qh, r)
62+ Pipeline f . Pipeline g = Pipeline $ \qh r -> do
63+ rec (qh'', r') <- g qh' r
64+ (qh', r'') <- f qh r'
65+ return (qh'', r'')
66+
67+-- | A key used to track particular consumers
68+newtype ClientKey = ClientKey { unClientKey :: Integer }
69+ deriving (Eq, Ord, Show)
70+
71+servePostgres
72+ :: ( Monoid q'
73+ , Semigroup q'
74+ )
75+ => Pool Postgresql -- ^ The DB to connect to
76+ -> (forall a. request a -> write a)
77+ -> (ByteString -> q' -> read (QueryResult q'))
78+ -> (q' -> read (QueryResult q))
79+ -> Pipeline IO q q'
80+ -> (Snap () -> IO a)
81+ -> IO a
82+servePostgres db handleApi handleNotify handleQuery pipe = do
83+ (getNextNotification, finalizeListener) <- startNotificationListener db
84+ rec (qh, finalizeFeed) <- feedPipeline (handleNotify <$> getNextNotification) handleQuery r
85+ (qh', r) <- unPipeline pipe qh r'
86+ (r', handleListen) <- connectPipelineToWebsockets "" (RequestHandler handleApi) qh'
87+ return (handleListen, finalizeFeed >> finalizeListener)
88+-}
89+
90+newtype ReadDb a = ReadDb { unReadDb :: ReaderT PG.Connection IO a }
91+ deriving (Functor, Applicative, Monad)
92+
93+readTransaction :: PG.Connection -> ReadDb a -> IO a
94+readTransaction conn (ReadDb r) = PG.withTransactionModeRetry m PG.isSerializationError conn $ runReaderT r conn
95+ where m = PG.TransactionMode
96+ { PG.isolationLevel = PG.Serializable
97+ , PG.readWriteMode = PG.ReadOnly
98+ }
99+
100+newtype WriteDb a = WriteDb { unWriteDb :: ReaderT PG.Connection IO a }
101+ deriving (Functor, Applicative, Monad)
102+
103+writeTransaction :: PG.Connection -> WriteDb a -> IO a
104+writeTransaction conn (WriteDb r) = PG.withTransactionModeRetry m PG.isSerializationError conn $ runReaderT r conn
105+ where m = PG.TransactionMode
106+ { PG.isolationLevel = PG.Serializable
107+ , PG.readWriteMode = PG.ReadWrite
108+ }
109+
110+unsafeReadDb :: (PG.Connection -> IO a) -> ReadDb a
111+unsafeReadDb f = ReadDb $ lift . f =<< ask
112+
113+unsafeWriteDb :: (PG.Connection -> IO a) -> WriteDb a
114+unsafeWriteDb f = WriteDb $ lift . f =<< ask
115+
116+withApiHandler
117+ :: forall query queryResult request a.
118+ ( Monoid (query SelectedCount)
119+ , QueryResult (query (MonoidMap ClientKey SelectedCount)) ~ queryResult (MonoidMap ClientKey SelectedCount)
120+ , QueryResult (query SelectedCount) ~ queryResult SelectedCount
121+ , QueryResult (query ()) ~ queryResult ()
122+ , Foldable queryResult
123+ , Functor query
124+ , Functor queryResult
125+ , FunctorMaybe queryResult
126+ , Monoid (query (MonoidMap ClientKey SelectedCount))
127+ , Monoid (queryResult SelectedCount)
128+ , Eq (queryResult SelectedCount)
129+ , Eq (query (MonoidMap ClientKey SelectedCount))
130+ , Eq (query SelectedCount)
131+ , Group (query SelectedCount)
132+ , ToJSON (queryResult ())
133+ , FromJSON (query ())
134+ , Request request
135+ )
136+ => ByteString -- ^ The URI of the DB to connect to
137+ -> (forall x. request x -> WriteDb x) -- ^ How to handle an API request
138+ -> (forall x. query x -> Transaction -> ReadDb (QueryResult (query x))) -- ^ How to handle a change
139+ -> (forall x. query x -> ReadDb (QueryResult (query x))) -- ^ How to handle a query
140+ -> (Snap () -> IO a)
141+ -> IO a
142+withApiHandler dbUri handleApi handleChange handleQuery go = do
143+ withNonEmptyTransactions dbUri $ \transactions -> do
144+ withConnectionPool dbUri $ \db -> do
145+ let onChange :: Transaction -> query x -> IO (QueryResult (query x))
146+ onChange m q = withResource db $ \conn -> readTransaction conn $ handleChange q m
147+ let pipe :: Pipeline IO (MonoidalMap ClientKey (query SelectedCount)) (query (MonoidMap ClientKey SelectedCount))
148+ pipe = queryMorphismPipeline $ transposeMonoidMap . monoidMapQueryMorphism
149+ rec (qh, finalizeFeed) <- feedPipeline (onChange <$> atomically (readTChan transactions)) (QueryHandler $ \q -> withResource db $ \conn -> readTransaction conn $ handleQuery q) r
150+ (qh', r) <- unPipeline pipe qh r'
151+ (r', handleListen) <- connectPipelineToWebsockets "" (\r'' -> withResource db $ \conn -> writeTransaction conn $ handleApi r'') qh'
152+ go handleListen
153+
154+withNonEmptyTransactions :: ByteString -> (TChan Transaction -> IO a) -> IO a
155+withNonEmptyTransactions dbUri go = do
156+ let opts = def
157+ { _logicalDecodingOptions_pluginOptions = [("skip-empty-xacts", Nothing)]
158+ }
159+ withLogicalDecoding dbUri "test_decoding" opts $ \decodedLines -> do
160+ transactions <- newTChanIO
161+ processLine <- linesToTransactions
162+ let processLines = forever $ do
163+ lineRaw <- atomically $ readTChan decodedLines
164+ let Right l = parseOnly (line <* endOfInput) lineRaw
165+ Right mTransaction <- processLine l
166+ forM_ mTransaction $ \transaction ->
167+ atomically $ writeTChan transactions transaction
168+ withAsync processLines $ \a -> link a >> do
169+ go transactions
170+{-
171+
172+standardChangeChannel :: PG.Identifier
173+standardChangeChannel = "obelisk_api_updates"
174+
175+standardChangeSubscriberTable :: PG.Identifier
176+standardChangeSubscriberTable = "obelisk_api_updates_subscriber"
177+
178+standardChangeSpillTable :: PG.Identifier
179+standardChangeSpillTable = "obelisk_api_updates_spill"
180+
181+-- | Listen on a Postgres notification channel. Uses a connection from the Pool for as long as it is r-- | Listen for Postgresql changes on a particular channel
182+withChangeListener :: FromJSON change => Pool PG.Connection -> (change -> IO ()) -> IO a -> IO a
183+withChangeListener db onChange = withBigNotificationListener db standardChangeChannel standardChangeSpillTable $ \message ->
184+ case Aeson.eitherDecode' $ LBS.fromStrict message of
185+ Left e -> fail $ "withChangeListener: " <> e
186+ Right change -> onChange change
187+-}
lib/backend/src/Obelisk/Api/Pipeline.hsnew file+408
@@ -0,0 +1,408 @@
1+{-# LANGUAGE RecursiveDo #-}
2+{-# LANGUAGE GADTs #-}
3+{-# LANGUAGE FlexibleContexts #-}
4+{-# LANGUAGE UndecidableInstances #-}
5+{-# LANGUAGE DeriveGeneric #-}
6+{-# LANGUAGE ScopedTypeVariables #-}
7+{-# LANGUAGE Rank2Types #-}
8+module Obelisk.Api.Pipeline where
9+
10+import Prelude hiding (id, (.))
11+
12+import Obelisk.Request (Request, SomeRequest (..))
13+
14+import Control.Concurrent
15+import Control.Monad
16+import Control.Monad.State.Strict
17+import Data.Typeable
18+import GHC.Generics
19+import Control.Lens (imapM_)
20+import Data.Aeson
21+import Reflex.Query.Base
22+import Reflex.Query.Class
23+import Reflex.Patch
24+import Reflex.Class
25+import Control.Category
26+import Snap.Core (Snap, MonadSnap)
27+import Data.Text (Text)
28+import Data.Map.Monoidal (MonoidalMap)
29+import qualified Data.AppendMap as Map
30+import Data.MonoidMap (MonoidMap (..), monoidMap)
31+import Data.Foldable
32+import Data.IORef
33+import Debug.Trace
34+import qualified Network.WebSockets as WS
35+import qualified Network.WebSockets.Connection as WS
36+import qualified Network.WebSockets.Stream as WS
37+import Network.WebSockets.Snap
38+import Control.Exception
39+
40+-- | A way for a pipeline to retrieve data
41+newtype QueryHandler q m = QueryHandler
42+ { runQueryHandler :: q -> m (QueryResult q) }
43+
44+-- | A way to push data into a pipeline
45+newtype Recipient q m = Recipient
46+ { tellRecipient :: QueryResult q -> m () }
47+
48+-- | A way of attaching to (and later detaching from) a pipeline
49+newtype Registrar q = Registrar { unRegistrar :: Recipient q IO -> IO (QueryHandler q IO, IO ()) }
50+
51+-- | A way of connecting a source of data to a consumer of data. The consumer
52+-- can pull data from the datasource and the datasource can push data to the
53+-- consumer.
54+--
55+-- q is the consumer side
56+-- q' is the datasource side
57+newtype Pipeline m q q' = Pipeline { unPipeline :: QueryHandler q' m -> Recipient q m -> IO (QueryHandler q m, Recipient q' m) }
58+
59+instance Category (Pipeline m) where
60+ id = Pipeline $ \qh r -> return (qh, r)
61+ Pipeline f . Pipeline g = Pipeline $ \qh r -> do
62+ rec (qh'', r') <- g qh' r
63+ (qh', r'') <- f qh r'
64+ return (qh'', r'')
65+
66+-- | A key used to track particular consumers
67+newtype ClientKey = ClientKey { unClientKey :: Integer }
68+ deriving (Eq, Ord, Show)
69+
70+-- | Produce a multi-client 'Recipient' and a single client 'QueryHandler'
71+fanQuery
72+ :: forall k q. (Ord k, Monoid (QueryResult q))
73+ => (k -> IO (Recipient q IO))
74+ -- ^ Look up a recipient
75+ -> QueryHandler (MonoidalMap k q) IO
76+ -- ^ A 'QueryHandler' for multiple clients
77+ -> ( Recipient (MonoidalMap k q) IO
78+ -- Used to notify recipients of new 'QueryResult' data
79+ , k -> QueryHandler q IO
80+ -- Used by 'multiplexQuery' to lookup the 'QueryHandler' for a given client
81+ )
82+fanQuery lookupRecipient qh = (multiRecipient lookupRecipient, fanQueryHandler qh)
83+ where
84+ -- | Given a recipient lookup function, produces a 'Recipient' that can
85+ -- handle distribution of a map of 'QueryResult's to many recipients
86+ multiRecipient lookupR = Recipient $ imapM_ $ \k qr -> do
87+ s <- lookupR k
88+ tellRecipient s qr
89+ -- | Given a multi-client 'QueryHandler', produces a 'QueryHandler' for
90+ -- a single client
91+ fanQueryHandler qh' = \k -> mapQueryHandler (singletonQuery k) qh'
92+
93+-- | Maintains a Map from connected clients to a 'Recipient' that can be used to transmit data to the clients
94+--
95+-- Takes a function that looks up a the 'QueryHandler' for a given client
96+-- Returns: 1. A lookup function into the Map of clients
97+-- 2. A way to register a new client
98+-- a. A 'QueryHandler' for the newly registered client
99+-- b. A removal callback to de-register a particular client
100+multiplexQuery
101+ :: (MonadIO m, Group q)
102+ => (ClientKey -> QueryHandler q m)
103+ -> IO ( ClientKey -> IO (Recipient q m)
104+ , Recipient q m -> IO (QueryHandler q m, m ())
105+ )
106+multiplexQuery lookupQueryHandler = do
107+ clients <- newIORef (ClientKey 0, Map.empty :: MonoidalMap ClientKey (Recipient q m, q))
108+ let
109+ lookupRecipient k = do
110+ (_, cs) <- readIORef clients
111+ case Map.lookup k cs of
112+ Nothing -> do
113+ putStrLn $ mconcat
114+ [ "Rhyolite.Backend.App.multiplexQuery: Failed to find sender for key"
115+ , show k
116+ , " in known keys "
117+ , show $ Map.keys cs
118+ ]
119+ return $ Recipient $ const $ return ()
120+ Just s -> return $ fst s
121+
122+ registerRecipient s = do
123+ cid <- atomicModifyIORef' clients $ \(cid, recipients) ->
124+ ((ClientKey (unClientKey cid + 1), Map.insert cid (s, mempty) recipients), cid)
125+ let
126+ queryHandler = QueryHandler $ \q -> do
127+ liftIO $ atomicModifyIORef' clients $ \(nextCid, recipients) ->
128+ ((nextCid, Map.update (\(r, oldQ) -> Just (r, oldQ <> q)) cid recipients), ())
129+ runQueryHandler (lookupQueryHandler cid) q
130+
131+ unregisterRecipient = do
132+ antiQ <- liftIO $ atomicModifyIORef' clients $ \(nextCid, recipients) ->
133+ case Map.updateLookupWithKey (\_ _ -> Nothing) cid recipients of
134+ (Nothing, _) -> trace
135+ ("Rhyolite.Backend.App.multiplexQuery: Tried to unregister a client key that is not registered " <> show cid)
136+ ((nextCid, recipients), mempty)
137+ (Just (_, removedQuery), newRecipients) -> ((nextCid, newRecipients), negateG removedQuery)
138+
139+ -- TODO: Should we have a way of ensuring that this doesn't actually cause a query to be run?
140+ -- It shouldn't cause the query to be run again but it depends on if the callee will notice
141+ -- that the new query is strictly smaller than the old one.
142+ _ <- runQueryHandler (lookupQueryHandler cid) antiQ
143+ return ()
144+
145+ return (queryHandler, unregisterRecipient)
146+
147+ return (lookupRecipient, registerRecipient)
148+
149+-- | Represents a WebSocket message from one of two channels: ViewSelector declarations or API requests
150+data WebSocketRequest q r
151+ = WebSocketRequest_ViewSelector q
152+ | WebSocketRequest_Api (TaggedRequest r)
153+ deriving (Typeable, Generic)
154+
155+instance (Request r, FromJSON q) => FromJSON (WebSocketRequest q r)
156+instance (Request r, ToJSON q) => ToJSON (WebSocketRequest q r)
157+
158+-- | Represents a WebSocket response from one of three channels: incoming 'View's, API responses, or version info
159+data WebSocketResponse q
160+ = WebSocketResponse_View (QueryResult q)
161+ | WebSocketResponse_Api TaggedResponse
162+ | WebSocketResponse_Version Text
163+ deriving (Typeable, Generic)
164+
165+instance FromJSON (QueryResult q) => FromJSON (WebSocketResponse q)
166+instance ToJSON (QueryResult q) => ToJSON (WebSocketResponse q)
167+
168+-- | A request tagged with an identifier
169+data TaggedRequest r = TaggedRequest Value (SomeRequest r)
170+ deriving (Typeable, Generic)
171+
172+instance Request r => FromJSON (TaggedRequest r)
173+instance Request r => ToJSON (TaggedRequest r)
174+
175+-- | A response tagged with an identifier matching the one in the 'TaggedRequest'. The identifier is the first argument.
176+data TaggedResponse = TaggedResponse Value Value
177+ deriving (Typeable, Generic)
178+
179+instance FromJSON TaggedResponse
180+instance ToJSON TaggedResponse
181+
182+-- | Handles a websocket connection
183+handleWebsocket
184+ :: forall q qr r.
185+ ( Eq (q SelectedCount)
186+ , Eq (qr SelectedCount)
187+ , QueryResult (q SelectedCount) ~ qr SelectedCount
188+ , QueryResult (q ()) ~ qr ()
189+ , ToJSON (qr ())
190+ , Functor qr
191+ , Group (q SelectedCount)
192+ , Monoid (qr SelectedCount)
193+ , FromJSON (q ())
194+ , Request r
195+ , Functor q
196+ )
197+ => Text -- ^ Version
198+ -> (forall a. r a -> IO a)
199+ -> Registrar (q SelectedCount)
200+ -> Snap ()
201+handleWebsocket v rh register = withWebsocketsConnection $ \conn -> do
202+ let sender = Recipient $ sendEncodedDataMessage conn . (\a -> WebSocketResponse_View (void a) :: WebSocketResponse (q ()))
203+ sendEncodedDataMessage conn (WebSocketResponse_Version v :: WebSocketResponse (q ()))
204+ bracket (unRegistrar register sender) snd $ \(vsHandler, _) -> flip evalStateT mempty $ forever $ do
205+ (wsr :: WebSocketRequest (q ()) r) <- liftIO $ getDataMessage conn
206+ case wsr of
207+ WebSocketRequest_Api (TaggedRequest reqId (SomeRequest req)) -> lift $ do
208+ a <- rh req
209+ sendEncodedDataMessage conn
210+ (WebSocketResponse_Api $ TaggedResponse reqId (toJSON a) :: WebSocketResponse (q ()))
211+ WebSocketRequest_ViewSelector new -> do
212+ old <- get
213+ let new' = SelectedCount 1 <$ new
214+ vsDiff = new' ~~ old
215+ when (vsDiff /= mempty) $ do
216+ qr <- lift $ runQueryHandler vsHandler vsDiff
217+ put new'
218+ when (qr /= mempty) $ lift $
219+ sendEncodedDataMessage conn (WebSocketResponse_View (void qr) :: WebSocketResponse (q ()))
220+
221+-------------------------------------------------------------------------------
222+
223+-- | Connect a datasource (e.g., a database) to a pipeline
224+--
225+-- Data taken from 'getNextNotification' is pushed into the pipeline and
226+-- when the pipeline pulls data, it is retrieved using 'qh'
227+feedPipeline
228+ :: Monoid q
229+ => IO (q -> IO (QueryResult q))
230+ -- ^ Get the next notification to be sent to the pipeline. If no notification
231+ -- is available, this should block until one is available
232+ -> QueryHandler q IO
233+ -- ^ Retrieve data when requested by pipeline
234+ -> Recipient q IO
235+ -- ^ A way to push data into the pipeline
236+ -> IO (QueryHandler q IO, IO ())
237+ -- ^ A way for the pipeline to request data
238+feedPipeline getNextNotification qh r = do
239+ currentQuery <- newIORef mempty
240+ let qhSaveQuery = QueryHandler $ \q -> do
241+ atomicModifyIORef' currentQuery $ \old -> (q <> old, ())
242+ runQueryHandler qh q
243+ tid <- forkIO . forever $ do
244+ nm <- getNextNotification
245+ q <- readIORef currentQuery
246+ qr <- nm q
247+ tellRecipient r qr
248+ return (qhSaveQuery, killThread tid)
249+
250+-- | Connects the pipeline to websockets consumers
251+connectPipelineToWebsockets
252+ :: ( Eq (q SelectedCount)
253+ , QueryResult (q SelectedCount) ~ qr SelectedCount
254+ , QueryResult (q ()) ~ qr ()
255+ , Group (q SelectedCount)
256+ , Monoid (qr SelectedCount)
257+ , Eq (qr SelectedCount)
258+ , ToJSON (qr ())
259+ , FromJSON (q ())
260+ , Request r
261+ , Functor qr
262+ , Functor q
263+ )
264+ => Text
265+ -> (forall a. r a -> IO a)
266+ -- ^ API handler
267+ -> QueryHandler (MonoidalMap ClientKey (q SelectedCount)) IO
268+ -- ^ A way to retrieve more data for each consumer
269+ -> IO (Recipient (MonoidalMap ClientKey (q SelectedCount)) IO, Snap ())
270+ -- ^ A way to send data to many consumers and a handler for websockets connections
271+connectPipelineToWebsockets ver rh qh = do
272+ (allRecipients, registerRecipient) <- connectPipelineToWebsockets' qh
273+ return (allRecipients, handleWebsocket ver rh registerRecipient)
274+
275+-- | Like 'connectPipelineToWebsockets' but returns a Registrar that can
276+-- be used to construct a handler for a particular client
277+connectPipelineToWebsockets'
278+ :: (Monoid (QueryResult q), Group q)
279+ => QueryHandler (MonoidalMap ClientKey q) IO
280+ -> IO (Recipient (MonoidalMap ClientKey q) IO, Registrar q)
281+ -- ^ A way to send data to many consumers, and a way to register new consumers
282+connectPipelineToWebsockets' qh = do
283+ rec (lookupRecipient, registerRecipient) <- multiplexQuery clientQueryHandler
284+ let (allRecipients, clientQueryHandler) = fanQuery lookupRecipient qh
285+ return (allRecipients, Registrar registerRecipient)
286+
287+-- | Extends a 'Registrar' with a 'Pipeline'
288+extendRegistrar :: Pipeline IO q q' -> Registrar q' -> Registrar q
289+extendRegistrar (Pipeline p) (Registrar r) = Registrar $ \recipient -> do
290+ rec (qh', close) <- r recipient'
291+ (qh, recipient') <- p qh' recipient
292+ return (qh, close)
293+
294+-------------------------------------------------------------------------------
295+
296+singletonQuery
297+ :: ( Monoid (QueryResult q)
298+ , Ord k
299+ )
300+ => k
301+ -> QueryMorphism q (MonoidalMap k q)
302+singletonQuery k = QueryMorphism { _queryMorphism_mapQuery = Map.singleton k
303+ , _queryMorphism_mapQueryResult = Map.findWithDefault mempty k
304+ }
305+
306+monoidMapQueryMorphism :: (Eq q, Monoid q) => QueryMorphism (MonoidalMap k q) (MonoidMap k q)
307+monoidMapQueryMorphism = QueryMorphism
308+ { _queryMorphism_mapQuery = monoidMap
309+ , _queryMorphism_mapQueryResult = unMonoidMap
310+ }
311+
312+transposeMonoidMap
313+ :: forall a a' k q qr.
314+ ( Ord k
315+ , Eq a
316+ , Monoid a
317+ , Monoid a'
318+ , Eq (q (MonoidMap k a))
319+ , Eq (QueryResult (q a))
320+ , Foldable qr
321+ , Functor q
322+ , Functor qr
323+ , FunctorMaybe qr
324+ , Monoid (q (MonoidMap k a))
325+ , Monoid (QueryResult (q a))
326+ , QueryResult (q (MonoidMap k a)) ~ qr (MonoidMap k a')
327+ , QueryResult (q a) ~ qr a'
328+ )
329+ => QueryMorphism (MonoidMap k (q a)) (q (MonoidMap k a))
330+transposeMonoidMap = QueryMorphism
331+ { _queryMorphism_mapQuery = aggregateQueries
332+ , _queryMorphism_mapQueryResult = distributeResults
333+ }
334+ where
335+ aggregateQueries :: MonoidMap k (q a) -> q (MonoidMap k a)
336+ aggregateQueries = fold . monoidMap . Map.mapWithKey (\k q -> fmap (monoidMap . Map.singleton k) q) . unMonoidMap
337+ distributeResults :: qr (MonoidMap k a') -> MonoidMap k (qr a')
338+ distributeResults v = monoidMap $ Map.mapWithKey (\k _ -> fmapMaybe (Map.lookup k . unMonoidMap) v) $ fold $ fmap unMonoidMap v
339+
340+mapQueryHandlerAndRecipient
341+ :: Functor f
342+ => QueryMorphism q q'
343+ -> QueryHandler q' f
344+ -> Recipient q f
345+ -> (QueryHandler q f, Recipient q' f)
346+mapQueryHandlerAndRecipient qm qh s = (mapQueryHandler qm qh, mapRecipient qm s)
347+
348+mapQueryHandler :: Functor f => QueryMorphism q q' -> QueryHandler q' f -> QueryHandler q f
349+mapQueryHandler qm qh = QueryHandler $ \qs -> mapQueryResult qm <$> runQueryHandler qh (mapQuery qm qs)
350+
351+mapRecipient :: QueryMorphism q q' -> Recipient q m -> Recipient q' m
352+mapRecipient qm s = Recipient $ \qr -> tellRecipient s $ mapQueryResult qm qr
353+
354+fmapQueryMorphism
355+ :: ( Functor f, QueryResult (f q) ~ f (QueryResult q)
356+ , QueryResult (f q') ~ f (QueryResult q') )
357+ => QueryMorphism q q'
358+ -> QueryMorphism (f q) (f q')
359+fmapQueryMorphism qm = QueryMorphism
360+ { _queryMorphism_mapQuery = fmap $ _queryMorphism_mapQuery qm
361+ , _queryMorphism_mapQueryResult = fmap $ _queryMorphism_mapQueryResult qm
362+ }
363+
364+queryMorphismPipeline :: Functor m => QueryMorphism q q' -> Pipeline m q q'
365+queryMorphismPipeline qm = Pipeline $ \qh r -> pure $ mapQueryHandlerAndRecipient qm qh r
366+
367+-- | Accepts a websockets connection and runs the supplied action with it
368+withWebsocketsConnection :: MonadSnap m => (WS.Connection -> IO ()) -> m ()
369+withWebsocketsConnection f = runWebSocketsSnap $ \pc -> do
370+ conn <- WS.acceptRequest pc
371+ handleSomeException $ handleConnectionException pc $ f conn
372+ where
373+ handleSomeException = handle $ \(SomeException e) -> putStrLn $ "withWebsocketsConnection: " <> displayException e
374+ handleConnectionException pc = handle $ \e -> case e of
375+ WS.ConnectionClosed -> return ()
376+ WS.CloseRequest _ _ -> print e >> WS.close (WS.pendingStream pc) >> throwIO e
377+ _ -> do putStr $ "withWebsocketsConnection: Exception: " <> displayException e
378+ throwIO e
379+
380+-- | Attempts to json decode a websockets data message
381+decodeWebsocketsDataMessage :: FromJSON a => WS.DataMessage -> Either String a
382+decodeWebsocketsDataMessage dm = eitherDecode' $ case dm of
383+ WS.Text r' _ -> r'
384+ WS.Binary r' -> r'
385+
386+-- | Parse and process a single websocket data message
387+getDataMessage
388+ :: FromJSON a
389+ => WS.Connection
390+ -> IO a
391+getDataMessage conn = do
392+ dm <- WS.receiveDataMessage conn
393+ case decodeWebsocketsDataMessage dm of
394+ Left err -> liftIO $ throwIO $ AssertionFailed $ mconcat
395+ [ "getDataMessage: error: "
396+ , err
397+ , "; received: "
398+ , show dm
399+ ]
400+ Right a -> return a
401+
402+-- | Send a json encoded data message over the websocket connection
403+sendEncodedDataMessage
404+ :: ToJSON a
405+ => WS.Connection
406+ -> a
407+ -> IO ()
408+sendEncodedDataMessage conn = WS.sendDataMessage conn . (\x -> WS.Text x Nothing) . encode
lib/backend/src/Obelisk/Db.hsnew file+51
@@ -0,0 +1,51 @@
1+module Obelisk.Db (withDb, withDbUri, withConnectionPool) where
2+
3+import Control.Exception (bracket)
4+import Data.ByteString (ByteString)
5+import qualified Data.ByteString.Char8 as BS
6+import Data.Pool (Pool, createPool, destroyAllResources)
7+import Database.PostgreSQL.Simple as PG
8+import Gargoyle
9+import Gargoyle.PostgreSQL.Nix (postgresNix)
10+import System.Directory (doesFileExist)
11+import System.FilePath
12+
13+-- | Connects to the database using information at the given location
14+withDb
15+ :: FilePath
16+ -- ^ The path of either a 'Gargoyle'-wrapped Postgres database or a file
17+ -- containing the URL of a Postgres database. If the path does not exist, it
18+ -- will be created with 'Gargoyle'.
19+ -> (Pool PG.Connection -> IO a)
20+ -- ^ An IO action to run while the database is open; the database will remain
21+ -- open until this action exits.
22+ -> IO a
23+withDb dbPath a = withDbUri dbPath $ \dbUri -> withConnectionPool dbUri a
24+
25+withDbUri :: FilePath -> (ByteString -> IO a) -> IO a
26+withDbUri dbPath a = do
27+ dbExists <- doesFileExist dbPath
28+ if dbExists
29+ -- use the file contents as the uri for an existing server
30+ then do
31+ dbUri <- BS.readFile dbPath
32+ a dbUri
33+ -- otherwise assume its a folder for a local database
34+ else do
35+ defaultConfig <- postgresNix
36+ let myConfig = defaultConfig
37+ { _gargoyle_init = \workDir -> do
38+ _gargoyle_init defaultConfig workDir
39+ appendFile (workDir </> "postgresql.conf") "\n# Added by Obelisk.Db\nwal_level = logical\nmax_wal_senders = 10\n"
40+ appendFile (workDir </> "pg_hba.conf") "\n# Added by Obelisk.Db\nlocal replication postgres trust\n"
41+ }
42+ withGargoyle myConfig dbPath a
43+
44+openDbUri :: ByteString -> IO (Pool PG.Connection)
45+openDbUri dbUri = do
46+ let openPostgresql = connectPostgreSQL dbUri
47+ closePostgresql p = close p
48+ createPool openPostgresql closePostgresql 1 5 20
49+
50+withConnectionPool :: ByteString -> (Pool PG.Connection -> IO a) -> IO a
51+withConnectionPool dbUri = bracket (openDbUri dbUri) destroyAllResources
lib/backend/src/Obelisk/Postgres/LogicalDecoding/Plugins/TestDecoding.hsnew file+221
@@ -0,0 +1,221 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+{-# LANGUAGE LambdaCase #-}
3+module Obelisk.Postgres.LogicalDecoding.Plugins.TestDecoding
4+ ( line
5+ , Transaction
6+ , Change (..)
7+ , Literal (..)
8+ , LineError (..)
9+ , TypeName (..)
10+ , linesToTransactions
11+ ) where
12+
13+import Prelude hiding (takeWhile)
14+
15+import Control.Applicative
16+import Control.Monad
17+import Data.Attoparsec.ByteString (word8, notWord8)
18+import Data.Attoparsec.ByteString.Char8
19+import Data.ByteString (ByteString)
20+import qualified Data.ByteString as BS
21+import Data.Char (ord)
22+import qualified Data.Char as Char
23+import Data.Word
24+import Data.HashMap.Strict (HashMap)
25+import qualified Data.HashMap.Strict as HashMap
26+import Data.Sequence (Seq, (|>))
27+import Data.Text (Text)
28+import Data.Text.Encoding
29+import Database.PostgreSQL.Simple.Types
30+import qualified Data.Map as Map
31+import Data.Map (Map)
32+import Data.IORef
33+import Data.Maybe
34+
35+--------------------------------------------------------------------------------
36+-- Potentially upstreamable
37+--------------------------------------------------------------------------------
38+
39+stringWithSingleQuote :: Parser ByteString
40+stringWithSingleQuote = stringWithQuote $ fromIntegral $ ord '\''
41+
42+stringWithDoubleQuote :: Parser ByteString
43+stringWithDoubleQuote = stringWithQuote $ fromIntegral $ ord '"'
44+
45+stringWithQuote :: Word8 -> Parser ByteString
46+stringWithQuote q = do
47+ _ <- word8 q
48+ s <- many $ notWord8 q <|> doubledWord8 q
49+ _ <- word8 q
50+ pure $ BS.pack s
51+
52+doubledWord8 :: Word8 -> Parser Word8
53+doubledWord8 c = do
54+ _ <- word8 c
55+ _ <- word8 c
56+ pure c
57+
58+identifierLike :: (Char -> Bool) -> (Char -> Bool) -> Parser Text
59+identifierLike validFirstChar validOtherChar = fmap decodeUtf8 stringWithDoubleQuote <|> do
60+ -- Avoid plucking the first character off, so that we don't have to cons it back on (which would reallocate the ByteString)
61+ guard . validFirstChar =<< peekChar'
62+ decodeUtf8 <$> takeWhile1 validOtherChar
63+
64+identifier :: Parser Identifier
65+identifier = Identifier <$> identifierLike identifierFirstChar identifierOtherChar
66+
67+identifierFirstChar :: Char -> Bool
68+identifierFirstChar c = Char.isLetter c || c == '_'
69+
70+identifierOtherChar :: Char -> Bool
71+identifierOtherChar c = identifierFirstChar c || isDigit c || c == '$'
72+
73+qualifiedIdentifier :: Parser QualifiedIdentifier
74+qualifiedIdentifier = do
75+ namespace <- identifier
76+ _ <- char '.'
77+ name <- identifier
78+ pure $ QualifiedIdentifier (Just $ fromIdentifier namespace) $ fromIdentifier name
79+
80+newtype TypeName = TypeName { unTypeName :: Text } deriving (Show, Read, Eq, Ord)
81+
82+typeName :: Parser TypeName
83+typeName = do
84+ let otherChar c = identifierOtherChar c || c `elem` (" ()," :: String)
85+ TypeName <$> identifierLike identifierFirstChar otherChar
86+
87+newtype Xid = Xid { unXid :: Word64 } deriving (Show, Read, Eq, Ord)
88+
89+xid :: Parser Xid
90+xid = Xid <$> decimal
91+
92+--------------------------------------------------------------------------------
93+
94+rowEntry :: Parser a -> Parser (Identifier, (TypeName, a))
95+rowEntry item = do
96+ colName <- identifier
97+ _ <- char '['
98+ colType <- typeName
99+ _ <- string "]:"
100+ v <- item
101+ pure (colName, (colType, v))
102+
103+rowLike :: Parser a -> Parser (HashMap Identifier (TypeName, a))
104+rowLike item = {- (mempty <$ string "(no-tuple-data)") <|> -} do
105+ fmap HashMap.fromList $ flip sepBy1 (char ' ') $ rowEntry item
106+
107+type Row = HashMap Identifier (TypeName, Maybe Literal)
108+
109+data Literal
110+ = Literal_UnchangedToastDatum
111+ | Literal_Present ByteString
112+ deriving (Show, Read, Eq, Ord)
113+
114+nullableLiteral :: Parser (Maybe Literal)
115+nullableLiteral = Nothing <$ string "null" <|> Just <$> literal
116+
117+literal :: Parser Literal
118+literal = unchangedToastDatum <|> presentDatum
119+ where
120+ unchangedToastDatum = Literal_UnchangedToastDatum <$ string "unchanged-toast-datum"
121+ -- | We convert all present datums to their normal Postgres ByteString
122+ -- encoding, so that we don't have to preserve special cases throughout our
123+ -- code
124+ presentDatum :: Parser Literal
125+ presentDatum = Literal_Present <$> foldr1 (<|>)
126+ [ stringWithSingleQuote
127+ , char 'B' *> stringWithSingleQuote
128+ , "t" <$ string "true"
129+ , "f" <$ string "false"
130+ -- We assume anything else must be numeric
131+ , takeWhile1 (/= ' ')
132+ ]
133+
134+row :: Parser Row
135+row = rowLike nullableLiteral
136+
137+type Key = HashMap Identifier (TypeName, Literal) -- Keys can't contain nulls
138+
139+key :: Parser Key
140+key = rowLike literal
141+
142+data Change
143+ = Change_Insert Row
144+ | Change_Update Key Row
145+ | Change_Delete Key
146+ deriving (Show, Read, Eq, Ord)
147+
148+change :: Parser Change
149+change = do
150+ t <- takeWhile (/= ':')
151+ _ <- string ": "
152+ case t of
153+ "INSERT" -> Change_Insert <$> row
154+ "UPDATE" -> foldr1 (<|>)
155+ [ do _ <- string "old-key: "
156+ old <- key
157+ _ <- string " new-tuple: "
158+ new <- row
159+ pure $ Change_Update old new
160+ , Change_Update mempty <$> row
161+ ]
162+ "DELETE" -> Change_Delete <$> key
163+ _ -> fail $ "Unrecognized change type: " <> show t
164+
165+data Line
166+ = Line_Begin Xid
167+ | Line_Change QualifiedIdentifier Change
168+ | Line_Commit Xid
169+ deriving (Show, Read, Eq, Ord)
170+
171+line :: Parser Line
172+line = do
173+ leader <- takeWhile (/= ' ')
174+ _ <- char ' '
175+ case leader of
176+ "BEGIN" -> Line_Begin <$> xid
177+ "table" -> do
178+ table <- qualifiedIdentifier
179+ _ <- string ": "
180+ Line_Change table <$> change
181+ "COMMIT" -> Line_Commit <$> xid
182+ _ -> fail $ "Unrecognized line leader: " <> show leader
183+
184+type Transaction = (Xid, Map QualifiedIdentifier (Seq Change))
185+
186+data LineError
187+ = LineError_OtherTransactionAlreadyInProgress
188+ Xid -- Existing transaction ID
189+ Xid -- New transaction ID that tried to start
190+ | LineError_CommittingWithoutTransaction
191+ Xid -- Transaction ID that tried to commit
192+ | LineError_CommittingWrongTransaction
193+ Xid -- Existing transaction ID
194+ Xid -- New transaction ID that tried to start
195+ | LineError_ChangeOutsideTransaction
196+
197+-- | Create a function that will statefully accumulate lines until a Transaction
198+-- is produced. If the given line is not valid in the current state, an error
199+-- will be produced and the line will be ignored. This should never happen for
200+-- a valid LogicalDecoding session, so all errors should be treated as serious.
201+linesToTransactions :: IO (Line -> IO (Either LineError (Maybe Transaction)))
202+linesToTransactions = do
203+ pending <- newIORef Nothing
204+ return $ \l -> do
205+ let -- Update the pending transaction and possibly emit a message
206+ update :: Maybe Transaction -> Either LineError (Maybe Transaction, Maybe Transaction)
207+ update = case l of
208+ Line_Begin newXid -> \case
209+ Nothing -> Right (Just (newXid, mempty), Nothing)
210+ Just (existingXid, _) -> Left $ LineError_OtherTransactionAlreadyInProgress existingXid newXid
211+ Line_Commit newXid -> \case
212+ Just txn@(existingXid, _)
213+ | existingXid == newXid -> Right (Nothing, Just txn)
214+ | otherwise -> Left $ LineError_CommittingWrongTransaction existingXid newXid
215+ Nothing -> Left $ LineError_CommittingWithoutTransaction newXid
216+ Line_Change qid c -> \old -> case old of
217+ Just (existingXid, changes) -> Right (Just (existingXid, Map.alter (Just . (|> c) . fromMaybe mempty) qid changes), Nothing)
218+ Nothing -> Left LineError_ChangeOutsideTransaction
219+ atomicModifyIORef pending $ \old -> case update old of
220+ Left e -> (old, Left e)
221+ Right (new, a) -> (new, Right a)
lib/backend/src/Obelisk/Postgres/QQ.hsnew file+69
@@ -0,0 +1,69 @@
1+{-# LANGUAGE TemplateHaskellQuotes #-}
2+module Obelisk.Postgres.QQ
3+ ( queryQ
4+ , executeQ
5+ , sqlQ
6+ , traceQueryQ
7+ ) where
8+
9+import Database.PostgreSQL.Simple.SqlQQ (sql)
10+import Database.PostgreSQL.Simple.ToField (toField, Action)
11+import Language.Haskell.TH (Exp, Name, Q, appE, mkName, tupE, varE, listE, sigE)
12+import Language.Haskell.TH.Quote (QuasiQuoter (..))
13+
14+-- | This quasiquoter is the obvious combination of 'sqlQ' and 'query'.
15+queryQ :: QuasiQuoter
16+queryQ = QuasiQuoter
17+ { quotePat = error "queryQ: quasiquoter used in pattern context"
18+ , quoteType = error "queryQ: quasiquoter used in type context"
19+ , quoteExp = \s -> appE [| uncurry query |] (sqlQExp s)
20+ , quoteDec = error "queryQ: quasiquoter used in declaration context"
21+ }
22+
23+-- | This quasiquoter is the obvious combination of 'sqlQ' and 'execute'.
24+executeQ :: QuasiQuoter
25+executeQ = QuasiQuoter
26+ { quotePat = error "executeQ: quasiquoter used in pattern context"
27+ , quoteType = error "executeQ: quasiquoter used in type context"
28+ , quoteExp = \s -> appE [| uncurry execute |] (sqlQExp s)
29+ , quoteDec = error "executeQ: quasiquoter used in declaration context"
30+ }
31+
32+traceQueryQ :: QuasiQuoter
33+traceQueryQ = QuasiQuoter
34+ { quotePat = error "traceQueryQ: quasiquoter used in pattern context"
35+ , quoteType = error "traceQueryQ: quasiquoter used in type context"
36+ , quoteExp = \s -> appE [| uncurry traceQuery |] (sqlQExp s)
37+ , quoteDec = error "traceQueryQ: quasiquoter used in declaration context"
38+ }
39+-- | This quasiquoter takes a SQL query with named arguments in the form "?var" and generates a pair
40+-- consisting of the Query string itself and a tuple of variables in corresponding order.
41+--
42+-- For example: uncurry query [sqlQ| SELECT * FROM 'Book' b WHERE b.title = ?title AND b.author = ?author |]
43+--
44+-- will be equivalent to query [sql| SELECT * FROM 'Book' b WHERE b.title = ? AND b.author = ? |] [toField title, toField author]
45+sqlQ :: QuasiQuoter
46+sqlQ = QuasiQuoter
47+ { quotePat = error "sqlQ: quasiquoter used in pattern context"
48+ , quoteType = error "sqlQ: quasiquoter used in type context"
49+ , quoteExp = sqlQExp
50+ , quoteDec = error "sqlQ: quasiquoter used in declaration context"
51+ }
52+
53+sqlQExp :: String -> Q Exp
54+sqlQExp s =
55+ let (s',vs) = extractVars s
56+ in tupE [quoteExp sql s', sigE (listE $ map (appE (varE 'toField) . varE) vs) [t| [Action] |]]
57+
58+extractVars :: String -> (String, [Name])
59+extractVars = extractVars'
60+ where
61+ extractVars' [] = ([],[])
62+ extractVars' ('?':s') =
63+ let [(var,rest)] = lex s'
64+ (s'',vars) = extractVars' rest
65+ in ('?':s'', mkName var : vars)
66+ extractVars' s' =
67+ let (pre,post) = break (=='?') s'
68+ (s'',vars) = extractVars' post
69+ in (pre ++ s'', vars)
lib/backend/src/Obelisk/Postgres/Replication.hsnew file+178
@@ -0,0 +1,178 @@
1+{-# LANGUAGE FlexibleContexts #-}
2+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
3+{-# LANGUAGE LambdaCase #-}
4+{-# LANGUAGE OverloadedStrings #-}
5+{-# LANGUAGE QuasiQuotes #-}
6+{-# LANGUAGE ScopedTypeVariables #-}
7+{-# LANGUAGE TypeApplications #-}
8+
9+-- | Listen to database changes as a replication client
10+--
11+-- In order for this to work you need the following settings in postgresql.conf:
12+--
13+-- @
14+-- wal_level = logical
15+-- max_replication_slots = n (where n > 0)
16+-- @
17+module Obelisk.Postgres.Replication
18+ ( withLogicalDecoding
19+ , LogicalDecodingOptions (..)
20+ ) where
21+
22+import Data.Time
23+import Data.Default
24+import Control.Concurrent.Async
25+import Control.Monad.Except
26+import Data.Word
27+import Control.Exception
28+import Control.Concurrent
29+import Control.Concurrent.STM
30+import Data.ByteString (ByteString)
31+import qualified Data.ByteString as BS
32+import qualified Data.ByteString.Lazy as LBS
33+import Data.IORef
34+import Data.Text (Text)
35+import qualified Data.Text as T
36+import Data.Text.Encoding
37+import qualified Database.PostgreSQL.Simple as PG
38+import qualified Database.PostgreSQL.Simple.Copy as PG
39+import qualified Database.PostgreSQL.LibPQ as PQ
40+import qualified Database.PostgreSQL.Simple.Types as PG
41+import qualified Database.PostgreSQL.Simple.Internal as PG
42+import Data.Int
43+import Data.Binary (Binary)
44+import qualified Data.Binary as Binary
45+import qualified Data.Binary.Get as Binary
46+import qualified Data.Binary.Put as Binary
47+import Data.Char
48+
49+import Obelisk.Postgres.QQ
50+
51+-- | Start a replication session using logical decoding
52+withLogicalDecoding
53+ :: ByteString -- ^ Database URI
54+ -> PG.Identifier -- ^ Logical decoding plugin
55+ -> LogicalDecodingOptions -- ^ Options; use `def` for default
56+ -> (TChan ByteString -> IO a) -- ^ Action to run while replication session is active
57+ -> IO a
58+withLogicalDecoding dbUri pluginName opts go = bracket (PQ.connectdb $ dbUri <> "?connect_timeout=0&replication=database") PQ.finish $ \unwrappedConn -> do
59+ wrappedConn <- newMVar unwrappedConn
60+ let withConn :: forall a. (PQ.Connection -> IO a) -> IO a
61+ withConn = withMVar wrappedConn
62+ pgConn <- PG.Connection
63+ <$> pure wrappedConn
64+ <*> newMVar mempty
65+ <*> newIORef 0
66+ backendPid <- PQ.backendPID unwrappedConn
67+ let slotName = PG.Identifier $ slotNamePrefix <> T.pack (show backendPid)
68+ [(_, _, _, _) :: (Text, Text, Maybe Text, Text)] <- uncurry (PG.query pgConn) [sqlQ| CREATE_REPLICATION_SLOT ?slotName TEMPORARY LOGICAL ?pluginName NOEXPORT_SNAPSHOT |]
69+ withConn $ \conn -> do
70+ Just escapedSlotName <- PQ.escapeIdentifier conn $ encodeUtf8 $ PG.fromIdentifier slotName
71+ optionsString <- case _logicalDecodingOptions_pluginOptions opts of
72+ [] -> pure ""
73+ pluginOptions -> fmap ((" (" <>) . (<> ")") . BS.intercalate ", ") $ forM pluginOptions $ \(name, mValue) -> do
74+ Just nameString <- PQ.escapeIdentifier conn $ encodeUtf8 $ PG.fromIdentifier name
75+ valueString <- case mValue of
76+ Just value -> do
77+ Just encodedValue <- PQ.escapeStringConn conn value
78+ pure $ " " <> encodedValue
79+ Nothing -> pure ""
80+ pure $ nameString <> valueString
81+ Just _ <- PQ.exec conn $ "START_REPLICATION SLOT " <> escapedSlotName <> " LOGICAL 0/0" <> optionsString
82+ pure ()
83+ writtenLsn <- newIORef 0
84+ chan <- newTChanIO
85+ let get = PG.getCopyData pgConn >>= \case
86+ PG.CopyOutDone _ -> pure () --TODO: Can this ever actually happen? Maybe on database shutdown
87+ PG.CopyOutRow bs -> case decodeReplicationDownstreamMessage bs of
88+ Left e -> print e
89+ Right msg -> case msg of
90+ ReplicationDownstreamMessage_Data lsn _ _ payload -> do
91+ atomicModifyIORef writtenLsn $ \old -> (max old lsn, ())
92+ atomically $ writeTChan chan payload
93+ ReplicationDownstreamMessage_Keepalive lsn _ shouldReply -> do
94+ atomicModifyIORef writtenLsn $ \old -> (max old lsn, ())
95+ when shouldReply sendStatus
96+ sendStatus = do
97+ lsn <- readIORef writtenLsn
98+ now <- toPostgresTime <$> getCurrentTime
99+ let msg = ReplicationUpstreamMessage_StandbyStatus lsn lsn lsn now True
100+ let encodedMessage = encodeReplicationUpstreamMessage msg
101+ PQ.CopyInOk <- PQ.putCopyData unwrappedConn encodedMessage
102+ PQ.FlushOk <- PQ.flush unwrappedConn
103+ pure ()
104+ withAsync (forever $ sendStatus >> threadDelay (10*1000*1000)) $ \_ ->
105+ withAsync (forever get) $ \_ -> do
106+ go chan
107+
108+data LogicalDecodingOptions = LogicalDecodingOptions
109+ { _logicalDecodingOptions_pluginOptions :: [(PG.Identifier, Maybe ByteString)]
110+ -- ^ Options to pass to the logical decoding plugin
111+ }
112+
113+instance Default LogicalDecodingOptions where
114+ def = LogicalDecodingOptions
115+ { _logicalDecodingOptions_pluginOptions = []
116+ }
117+
118+slotNamePrefix :: Text
119+slotNamePrefix = "obelisk_"
120+
121+-- | See https://www.postgresql.org/docs/current/static/protocol-replication.html
122+data ReplicationDownstreamMessage
123+ = ReplicationDownstreamMessage_Data Int64 Int64 PostgresTime ByteString
124+ | ReplicationDownstreamMessage_Keepalive Int64 PostgresTime Bool
125+ deriving (Show, Read, Eq, Ord)
126+
127+-- | See https://www.postgresql.org/docs/current/static/protocol-replication.html
128+data ReplicationUpstreamMessage
129+ = ReplicationUpstreamMessage_StandbyStatus Int64 Int64 Int64 PostgresTime Bool
130+ | ReplicationUpstreamMessage_HotStandbyStatus PostgresTime Int32 Int32 Int32 Int32
131+ deriving (Show, Read, Eq, Ord)
132+
133+--NOTE: we assume we are talking to a little-endian Postgres instance
134+decodeReplicationDownstreamMessage :: MonadError Text m => ByteString -> m ReplicationDownstreamMessage
135+decodeReplicationDownstreamMessage bs = do
136+ let decodeHeader = do
137+ Binary.get @Word8 >>= \case
138+ -- 'w'
139+ 119 -> do
140+ header :: ByteString -> ReplicationDownstreamMessage
141+ <- ReplicationDownstreamMessage_Data <$> Binary.get <*> Binary.get <*> Binary.get
142+ pure $ pure . header
143+ -- 'k'
144+ 107 -> do
145+ result <- ReplicationDownstreamMessage_Keepalive <$> Binary.get <*> Binary.get <*> Binary.get
146+ pure $ \remaining -> if BS.null remaining
147+ then pure result
148+ else throwError $ "decodeReplicationDownstreamMessage: keepalive message had extra data"
149+ leadByte -> fail $ "decodeReplicationDownstreamMessage: unexpected leading byte: " <> show (chr $ fromIntegral leadByte)
150+ case Binary.runGetOrFail decodeHeader $ LBS.fromStrict bs of
151+ Left (_, offset, e) -> throwError $ T.pack $ "decodeReplicationDownstreamMessage: at " <> show offset <> ": " <> e
152+ Right (remaining, _, finish) -> finish $ LBS.toStrict remaining
153+
154+encodeReplicationUpstreamMessage :: ReplicationUpstreamMessage -> ByteString
155+encodeReplicationUpstreamMessage = LBS.toStrict . Binary.runPut . \case
156+ ReplicationUpstreamMessage_StandbyStatus written flushed applied time pleaseReply -> do
157+ Binary.put @Word8 114 -- 'r'
158+ Binary.put written
159+ Binary.put flushed
160+ Binary.put applied
161+ Binary.put time
162+ Binary.put pleaseReply
163+ ReplicationUpstreamMessage_HotStandbyStatus time xmin xminEpoch lowestCatalogXmin lowestCatalogXminEopch -> do
164+ Binary.put @Word8 104 -- 'h'
165+ Binary.put time
166+ Binary.put xmin
167+ Binary.put xminEpoch
168+ Binary.put lowestCatalogXmin
169+ Binary.put lowestCatalogXminEopch
170+
171+newtype PostgresTime = PostgresTime { unPostgresTime :: Int64 } deriving (Show, Read, Eq, Ord, Binary)
172+
173+toPostgresTime :: UTCTime -> PostgresTime
174+toPostgresTime t = PostgresTime $ fromIntegral $
175+ (toModifiedJulianDay (utctDay t) - toModifiedJulianDay postgresEpoch) * (24*60*60*1000*1000)
176+ + round (utctDayTime t * (1000*1000))
177+ where
178+ postgresEpoch = fromGregorian 2000 1 1
lib/request/obelisk-request.cabalnew file+18
@@ -0,0 +1,18 @@
1+name: obelisk-request
2+version: 0.1
3+cabal-version: >= 1.2
4+build-type: Simple
5+
6+library
7+ hs-source-dirs: src
8+ build-depends: base,
9+ aeson,
10+ constraints,
11+ template-haskell,
12+ vector
13+ exposed-modules:
14+ Obelisk.Request
15+ Obelisk.Request.TH
16+ other-modules:
17+ Obelisk.Request.HList
18+ ghc-options: -Wall
lib/request/src/Obelisk/Request.hsnew file+25
@@ -0,0 +1,25 @@
1+{-# LANGUAGE GADTs #-}
2+
3+module Obelisk.Request
4+ ( Request (..)
5+ , SomeRequest (..)
6+ ) where
7+
8+import Data.Aeson (FromJSON, ToJSON, Value, parseJSON, toJSON)
9+import Data.Aeson.Types (Parser)
10+import Data.Constraint (Dict)
11+
12+class Request r where
13+ requestToJSON :: r a -> Value
14+ requestParseJSON :: Value -> Parser (SomeRequest r)
15+ requestResponseToJSON :: r a -> Dict (ToJSON a)
16+ requestResponseFromJSON :: r a -> Dict (FromJSON a)
17+
18+data SomeRequest t where
19+ SomeRequest :: (FromJSON x, ToJSON x) => t x -> SomeRequest t
20+
21+instance Request r => FromJSON (SomeRequest r) where
22+ parseJSON = requestParseJSON
23+
24+instance Request r => ToJSON (SomeRequest r) where
25+ toJSON (SomeRequest r) = requestToJSON r
lib/request/src/Obelisk/Request/HList.hsnew file+59
@@ -0,0 +1,59 @@
1+{-# LANGUAGE DataKinds #-}
2+{-# LANGUAGE FlexibleContexts #-}
3+{-# LANGUAGE FlexibleInstances #-}
4+{-# LANGUAGE KindSignatures #-}
5+{-# LANGUAGE StandaloneDeriving #-}
6+{-# LANGUAGE TypeFamilies #-}
7+{-# LANGUAGE TypeOperators #-}
8+
9+module Obelisk.Request.HList (HList (..)) where
10+
11+import Data.Aeson (FromJSON, ToJSON, Value (Array), parseJSON, toJSON, withArray)
12+import Data.Vector (Vector)
13+import qualified Data.Vector as V
14+
15+data family HList (l::[*])
16+
17+data instance HList '[] = HNil
18+data instance HList (x ': xs) = x `HCons` HList xs
19+
20+infixr 2 `HCons`
21+
22+deriving instance Eq (HList '[])
23+deriving instance (Eq x, Eq (HList xs)) => Eq (HList (x ': xs))
24+
25+deriving instance Ord (HList '[])
26+deriving instance (Ord x, Ord (HList xs)) => Ord (HList (x ': xs))
27+
28+deriving instance Bounded (HList '[])
29+deriving instance (Bounded x, Bounded (HList xs)) => Bounded (HList (x ': xs))
30+
31+instance (FromJSON h, FromJSON (HList t)) => FromJSON (HList (h ': t)) where
32+ parseJSON = withArray "HList (a ': t)" $ \v -> do
33+ Just (aVal, v') <- return $ vectorView v
34+ a <- parseJSON aVal
35+ b <- parseJSON $ Array v'
36+ return $ HCons a b
37+
38+instance FromJSON (HList '[]) where
39+ parseJSON = withArray "HList '[]" $ \v -> do
40+ Nothing <- return $ vectorView v
41+ return HNil
42+
43+class HListToJSON l where
44+ hListToJSON :: HList l -> [Value]
45+
46+instance (ToJSON h, HListToJSON t) => HListToJSON (h ': t) where
47+ hListToJSON (HCons h t) = toJSON h : hListToJSON t
48+
49+instance HListToJSON '[] where
50+ hListToJSON HNil = []
51+
52+instance HListToJSON l => ToJSON (HList l) where
53+ toJSON = Array . V.fromList . hListToJSON
54+
55+vectorView :: Vector a -> Maybe (a, Vector a)
56+vectorView v =
57+ if V.length v > 0
58+ then Just (V.head v, V.tail v)
59+ else Nothing
lib/request/src/Obelisk/Request/TH.hsnew file+142
@@ -0,0 +1,142 @@
1+{-# LANGUAGE TemplateHaskell #-}
2+
3+module Obelisk.Request.TH
4+ ( makeJson
5+ , makeRequestForDataInstance
6+ , makeRequestForData
7+ ) where
8+
9+import Control.Monad (guard, replicateM)
10+import Data.Aeson.Types
11+import Data.Constraint (Dict (..))
12+import Data.List (isPrefixOf)
13+import Data.Semigroup ((<>))
14+import Language.Haskell.TH
15+
16+import Obelisk.Request
17+import Obelisk.Request.HList (HList (..))
18+
19+makeJson :: Name -> DecsQ
20+makeJson n = do
21+ x <- reify n
22+ let base = nameBase n
23+ toBeStripped = base <> "_"
24+ modifyConName cn = if length cons == 1 then cn else if toBeStripped `isPrefixOf` cn then drop (length toBeStripped) cn else error $ "makeRequest: expecting name beginning with " <> show toBeStripped <> ", got " <> show cn
25+ cons = case x of
26+ TyConI d -> decCons d
27+ _ -> error $ "cant do constructor:" ++ show x
28+ typeNames = map tvbName $ case x of
29+ TyConI d -> decTvbs d
30+ _ -> error $ "cant do typename:" ++ show x
31+ let wild = match wildP (normalB [|fail "invalid message"|]) []
32+ [d|
33+ instance ToJSON $(foldl appT (conT n) $ map varT typeNames) where
34+ toJSON r = $(caseE [|r|] $ map (conToJson modifyConName) cons)
35+ instance FromJSON $(foldl appT (conT n) $ map varT typeNames) where
36+ parseJSON v = do
37+ (tag', v') <- parseJSON v
38+ $(caseE [|tag' :: String|] $ map (conParseJson modifyConName id [|v'|]) cons ++ [wild])
39+ |]
40+
41+makeRequestForDataInstance :: Name -> Name -> DecsQ
42+makeRequestForDataInstance n n' = do
43+ x <- reify n
44+ let base = nameBase n
45+ toBeStripped = base <> "_"
46+ modifyConName cn = if length cons == 1 then cn else if toBeStripped `isPrefixOf` cn then drop (length toBeStripped) cn else error $ "makeRequest: expecting name beginning with " <> show toBeStripped <> ", got " <> show cn
47+ cons = case x of
48+ (FamilyI _ dataInstances) -> do
49+ (DataInstD _ _ (ConT m:_) _ xs _) <- dataInstances
50+ guard $ m == n'
51+ xs
52+ _ -> error $ "cant do family:" ++ show x
53+ let wild = match wildP (normalB [|fail "invalid message"|]) []
54+ [d|
55+ instance Request $(appT (conT n) (conT n')) where
56+ requestToJSON r = $(caseE [|r|] $ map (conToJson modifyConName) cons)
57+ requestParseJSON v = do
58+ (tag', v') <- parseJSON v
59+ $(caseE [|tag' :: String|] $ map (conParseJson modifyConName (\body -> [|SomeRequest <$> $body|]) [|v'|]) cons ++ [wild])
60+ requestResponseToJSON r = $(caseE [|r|] $ map (\c -> match (conP (conName c) $ replicate (conArity c) wildP) (normalB [|Dict|]) []) cons)
61+ requestResponseFromJSON r = $(caseE [|r|] $ map (\c -> match (conP (conName c) $ replicate (conArity c) wildP) (normalB [|Dict|]) []) cons)
62+ |]
63+
64+makeRequestForData :: Name -> DecsQ
65+makeRequestForData n = do
66+ x <- reify n
67+ let base = nameBase n
68+ toBeStripped = base <> "_"
69+ modifyConName cn = if length cons == 1 then cn else if toBeStripped `isPrefixOf` cn then drop (length toBeStripped) cn else error $ "makeRequest: expecting name beginning with " <> show toBeStripped <> ", got " <> show cn
70+ cons ::[Con]
71+ cons = case x of
72+ TyConI (DataD _ _ _ _ xs _) -> xs
73+ _ -> error $ "cant do data:" ++ show x
74+ let wild = match wildP (normalB [|fail "invalid message"|]) []
75+ [d|
76+ instance Request $(conT n) where
77+ requestToJSON r = $(caseE [|r|] $ map (conToJson modifyConName) cons)
78+ requestParseJSON v = do
79+ (tag', v') <- parseJSON v
80+ $(caseE [|tag' :: String|] $ map (conParseJson modifyConName (\body -> [|SomeRequest <$> $body|]) [|v'|]) cons ++ [wild])
81+ requestResponseToJSON r = $(caseE [|r|] $ map (\c -> match (conP (conName c) $ replicate (conArity c) wildP) (normalB [|Dict|]) []) cons)
82+ requestResponseFromJSON r = $(caseE [|r|] $ map (\c -> match (conP (conName c) $ replicate (conArity c) wildP) (normalB [|Dict|]) []) cons)
83+ |]
84+
85+conParseJson :: (String -> String) -> (ExpQ -> ExpQ) -> ExpQ -> Con -> MatchQ
86+conParseJson modifyName wrapBody e c = do
87+ let name = conName c
88+ varNames <- replicateM (conArity c) $ newName "f"
89+ let fields = map varE varNames
90+ tuple = foldr (\a b -> conP 'HCons [varP a, b]) (conP 'HNil []) varNames
91+ body = doE [ bindS tuple [|parseJSON $e|]
92+ , noBindS [|return $(appsE (conE name : fields))|]
93+ ]
94+ match (litP (StringL (modifyName $ nameBase name))) (normalB (wrapBody body)) []
95+
96+conToJson :: (String -> String) -> Con -> MatchQ
97+conToJson modifyName c = do
98+ let name = conName c
99+ base = nameBase name
100+ tag' = modifyName base
101+ varNames <- replicateM (conArity c) $ newName "f"
102+ let tuple = foldr (\a b -> appsE [conE 'HCons, varE a, b]) (conE 'HNil) varNames
103+ body = [|toJSON (tag' :: String, toJSON $tuple)|]
104+ match (conP name $ map varP varNames) (normalB body) []
105+
106+conArity :: Con -> Int
107+conArity c = case c of
108+ NormalC _ ts -> length ts
109+ RecC _ ts -> length ts
110+ InfixC _ _ _ -> 2
111+ ForallC _ _ c' -> conArity c'
112+ GadtC _ ts _ -> length ts
113+ RecGadtC _ ts _ -> length ts
114+
115+
116+decCons :: Dec -> [Con]
117+decCons d = case d of
118+ DataD _ _ _ _ cs _ -> cs
119+ NewtypeD _ _ _ _ c _ -> [c]
120+ _ -> error $ "not a data/newtype:" ++ show d
121+
122+-- | Extracts the name from a type variable binder.
123+tvbName :: TyVarBndr -> Name
124+tvbName (PlainTV name ) = name
125+tvbName (KindedTV name _) = name
126+
127+
128+decTvbs :: Dec -> [TyVarBndr]
129+decTvbs d = case d of
130+ DataD _ _ tvbs _ _ _ -> tvbs
131+ NewtypeD _ _ tvbs _ _ _ -> tvbs
132+ _ -> error $ "not a data/newtype:" ++ show d
133+
134+conName :: Con -> Name
135+conName c = case c of
136+ NormalC n _ -> n
137+ RecC n _ -> n
138+ InfixC _ n _ -> n
139+ ForallC _ _ c' -> conName c'
140+ GadtC [n] _ _ -> n
141+ RecGadtC [n] _ _ -> n
142+ _ -> error "conName: GADT constructors with multiple names not yet supported"
skeleton/.gitignore+1
@@ -5,3 +5,4 @@ result-ios
55result-exe
66.attr-cache
77ghcid-output.txt
8+db
skeleton/backend/backend.cabal+4
@@ -11,7 +11,11 @@ library
1111 , common
1212 , frontend
1313 , obelisk-backend
14+ , obelisk-request
1415 , obelisk-route
16+ , beam-core
17+ , beam-migrate
18+ , beam-postgres
1519 exposed-modules:
1620 Backend
1721 ghc-options: -Wall
skeleton/backend/src/Backend.hs+264−1
@@ -1,15 +1,278 @@
11{-# LANGUAGE EmptyCase #-}
2+{-# LANGUAGE StandaloneDeriving #-}
3+{-# LANGUAGE TypeOperators #-}
4+{-# LANGUAGE UndecidableInstances #-}
5+{-# LANGUAGE MultiParamTypeClasses #-}
6+{-# LANGUAGE AllowAmbiguousTypes #-}
7+{-# LANGUAGE TemplateHaskell #-}
8+{-# LANGUAGE GADTs #-}
9+{-# LANGUAGE TupleSections #-}
10+{-# LANGUAGE DeriveFunctor #-}
11+{-# LANGUAGE DeriveFoldable #-}
12+{-# LANGUAGE DeriveTraversable #-}
13+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
14+{-# LANGUAGE TypeApplications #-}
15+{-# LANGUAGE ScopedTypeVariables #-}
216{-# LANGUAGE FlexibleContexts #-}
17+{-# LANGUAGE FlexibleInstances #-}
318{-# LANGUAGE LambdaCase #-}
19+{-# LANGUAGE OverloadedStrings #-}
420{-# LANGUAGE RankNTypes #-}
521{-# LANGUAGE TypeFamilies #-}
22+
23+{-# OPTIONS_GHC -fno-warn-orphans #-} --TODO: Eliminate this
624module Backend where
725
26+import Control.Exception
27+import Control.Concurrent
28+import Common.Api
29+import Common.Schema
30+import Backend.Schema
831import Common.Route
32+import qualified Data.Map.Monoidal as Map
33+import Data.Pool
34+import Data.Semigroup
935import Obelisk.Backend
36+import Obelisk.Route
37+import Obelisk.Api
38+import Obelisk.Db
39+import Obelisk.Postgres.LogicalDecoding.Plugins.TestDecoding
40+import qualified Data.Map as OldMap
41+import Data.Sequence (Seq)
42+import Data.These
43+import Data.Text.Encoding
44+import Data.HashMap.Strict (HashMap)
45+import qualified Data.HashMap.Strict as HashMap
46+
47+import Database.PostgreSQL.Simple.FromField (FromField (..), runConversion)
48+import Database.PostgreSQL.Simple.Ok
49+import Database.PostgreSQL.Simple.Internal
50+import qualified Database.PostgreSQL.Simple.TypeInfo.Static as TI
51+import qualified Database.PostgreSQL.LibPQ as PQ
52+import Database.Beam
53+import Database.Beam.Backend.Types
54+import Database.Beam.Backend.SQL.Types
55+import Database.Beam.Postgres
56+import Database.Beam.Postgres.Full (PgInsertReturning, returning, runPgInsertReturningList)
57+import Database.Beam.Postgres.Migrate
58+import Database.Beam.Migrate.Simple
59+import Database.Beam.Schema.Tables
60+import Database.PostgreSQL.Simple.Types
61+import Control.Monad.Reader
62+
63+import Data.Proxy
64+
65+import Control.Lens
66+import qualified GHC.Generics as Generic
67+import GHC.Generics hiding (R, C)
68+import GHC.Types
69+
70+import Debug.Trace
1071
1172backend :: Backend BackendRoute FrontendRoute
1273backend = Backend
13− { _backend_run = \serve -> serve $ const $ return ()
74+ { _backend_run = \serve -> do
75+ withDbUri "db" $ \dbUri -> do
76+ withConnectionPool dbUri $ \pool -> do
77+ withResource pool $ \conn -> do
78+ try (runBeamPostgres conn $ autoMigrate migrationBackend checkedDb) >>= \case
79+ Left (_ :: SomeException) -> do
80+ putStrLn "Required migration is unsafe; refusing to start"
81+ forever $ threadDelay maxBound
82+ Right _ -> pure ()
83+ let onNotify :: forall a. VS (Id Task) Task a -> Transaction -> ReadDb (V (Id Task) Task a)
84+ onNotify (VS requested) txn = do
85+ traceM "Hello"
86+ decoded <- ReadDb $ do
87+ conn <- ask
88+ lift $ decodeTransaction conn txn
89+ traceM $ show $ _db_tasks decoded
90+ tasks <- runSelectReturningList' $ select $ all_ $ _db_tasks db
91+ pure $ V $ Map.intersectionWith (,) requested $ Map.fromList $ fmap (\x -> (TaskId $ _task_id x, First $ Just x)) tasks
92+ onSubscribe :: forall a. VS (Id Task) Task a -> ReadDb (V (Id Task) Task a)
93+ onSubscribe (VS requested) = do
94+ tasks <- runSelectReturningList' $ select $ all_ $ _db_tasks db
95+ pure $ V $ Map.intersectionWith (,) requested $ Map.fromList $ fmap (\x -> (TaskId $ _task_id x, First $ Just x)) tasks
96+ onRequest :: forall a. MyRequest a -> WriteDb a
97+ onRequest = \case
98+ MyRequest_Add a -> do
99+ [taskId] <- runInsertReturningList' $ flip returning _task_id $ insert (_db_tasks db) $ insertExpressions $ (:[]) $ Task
100+ { _task_id = default_
101+ , _task_description = val_ a
102+ , _task_completed = val_ False
103+ }
104+ pure $ TaskId taskId
105+ withApiHandler dbUri onRequest onNotify onSubscribe $ \handler -> do
106+ serve $ \case
107+ BackendRoute_Api :/ () -> handler
108+ _ -> return ()
14109 , _backend_routeEncoder = backendRouteEncoder
15110 }
111+
112+class MonadBeamRead m where
113+ runSelectReturningList' :: FromBackendRow Postgres a => SqlSelect Postgres a -> m [a]
114+ runSelectReturningOne' :: FromBackendRow Postgres a => SqlSelect Postgres a -> m (Maybe a)
115+
116+unsafeRunPgReadDb :: Pg a -> ReadDb a
117+unsafeRunPgReadDb a = ReadDb $ do
118+ conn <- ask
119+ lift $ runBeamPostgres conn a
120+
121+unsafeRunPgWriteDb :: Pg a -> WriteDb a
122+unsafeRunPgWriteDb a = WriteDb $ do
123+ conn <- ask
124+ lift $ runBeamPostgres conn a
125+
126+instance MonadBeamRead WriteDb where
127+ runSelectReturningList' = unsafeRunPgWriteDb . runSelectReturningList
128+ runSelectReturningOne' = unsafeRunPgWriteDb . runSelectReturningOne
129+
130+instance MonadBeamRead ReadDb where
131+ runSelectReturningList' = unsafeRunPgReadDb . runSelectReturningList
132+ runSelectReturningOne' = unsafeRunPgReadDb . runSelectReturningOne
133+
134+class MonadBeamWrite m where
135+ runInsert' :: SqlInsert Postgres table -> m ()
136+ runInsertReturningList' :: FromBackendRow Postgres a => PgInsertReturning a -> m [a]
137+ runUpdate' :: SqlUpdate Postgres table -> m ()
138+ runDelete' :: SqlDelete Postgres table -> m ()
139+
140+instance MonadBeamWrite WriteDb where
141+ runInsert' = unsafeRunPgWriteDb . runInsert
142+ runInsertReturningList' = unsafeRunPgWriteDb . runPgInsertReturningList
143+ runUpdate' = unsafeRunPgWriteDb . runUpdate
144+ runDelete' = unsafeRunPgWriteDb . runDelete
145+
146+class GPatchDatabase x where
147+ gDatabasePatchDecoder :: x
148+
149+instance GPatchDatabase (x p) => GPatchDatabase (D1 f x p) where
150+ gDatabasePatchDecoder = M1 gDatabasePatchDecoder
151+
152+instance GPatchDatabase (x p) => GPatchDatabase (C1 f x p) where
153+ gDatabasePatchDecoder = M1 gDatabasePatchDecoder
154+
155+instance (GPatchDatabase (x p), GPatchDatabase (y p)) => GPatchDatabase ((x :*: y) p) where
156+ gDatabasePatchDecoder = gDatabasePatchDecoder :*: gDatabasePatchDecoder
157+
158+instance PatchEntity be a => GPatchDatabase (S1 f (K1 Generic.R (EntityPatchDecoder be a)) p) where
159+ gDatabasePatchDecoder = M1 (K1 patchEntity)
160+
161+class GPatchFields x where
162+ gPatchFields :: x
163+
164+instance GPatchFields (x p) => GPatchFields (D1 f x p) where
165+ gPatchFields = M1 gPatchFields
166+
167+instance GPatchFields (x p) => GPatchFields (C1 f x p) where
168+ gPatchFields = M1 gPatchFields
169+
170+instance (GPatchFields (x p), GPatchFields (y p)) => GPatchFields ((x :*: y) p) where
171+ gPatchFields = gPatchFields :*: gPatchFields
172+
173+instance BackendFromField be a => GPatchFields (S1 f (K1 Generic.R (FieldDecoder be a)) p) where
174+ gPatchFields = M1 (K1 FieldDecoder)
175+
176+class PatchEntity be e where
177+ patchEntity :: EntityPatchDecoder be e
178+
179+from' :: Generic x => x -> Rep x ()
180+from' = Generic.from
181+
182+to' :: Generic x => Rep x () -> x
183+to' = Generic.to
184+
185+instance ( Generic (tbl (FieldDecoder be))
186+ , GPatchFields (Rep (tbl (FieldDecoder be)) ())
187+ , Table tbl
188+ ) => PatchEntity be (TableEntity tbl) where
189+ patchEntity = EntityPatchDecoder_Table $ to' gPatchFields
190+
191+defaultDatabasePatchDecoder
192+ :: forall be db
193+ . ( Generic (db (EntityPatchDecoder be))
194+ , GPatchDatabase (Rep (db (EntityPatchDecoder be)) ())
195+ )
196+ => db (EntityPatchDecoder be)
197+defaultDatabasePatchDecoder = to' gDatabasePatchDecoder
198+
199+class PatchableEntity be e where
200+ patchDecoder :: EntityPatchDecoder be e
201+
202+data EntityPatchDecoder be e where
203+ EntityPatchDecoder_NotDecodable :: EntityPatchDecoder be e
204+ EntityPatchDecoder_Table :: Table tbl => tbl (FieldDecoder be) -> EntityPatchDecoder be (TableEntity tbl)
205+
206+data FieldDecoder be a where
207+ FieldDecoder :: BackendFromField be a => FieldDecoder be a
208+
209+data EntityPatch e where
210+ EntityPatch_NotPatchable :: EntityPatch e
211+ EntityPatch_Table :: Seq (These (PrimaryKey tbl Maybe) (tbl Maybe)) -> EntityPatch (TableEntity tbl) --TODO: Add old keys / deletes
212+
213+deriving instance (Show (PrimaryKey tbl Maybe), Show (tbl Maybe)) => Show (EntityPatch (TableEntity tbl))
214+
215+deriving instance FromField a => FromField (SqlSerial a)
216+
217+decodeRow
218+ :: forall tbl tbl'
219+ . Beamable tbl
220+ => Connection
221+ -> tbl (TableField tbl')
222+ -> tbl (FieldDecoder Postgres)
223+ -> HashMap Identifier (TypeName, Maybe Literal)
224+ -> IO (tbl Maybe)
225+decodeRow conn settings decoders r = zipBeamFieldsM d settings decoders
226+ where d :: forall a. Columnar' (TableField tbl') a -> Columnar' (FieldDecoder Postgres) a -> IO (Columnar' Maybe a)
227+ d (Columnar' fieldSettings) (Columnar' FieldDecoder) = do
228+ let n = _fieldName fieldSettings
229+ result <- case HashMap.lookup (Identifier n) r of
230+ Just (TypeName typeName, v) -> do
231+ let f = Field
232+ { typeOid = case typeName of
233+ "text" -> TI.typoid TI.text
234+ "int8" -> TI.typoid TI.int8
235+ "bigint" -> TI.typoid TI.int8
236+ "character varying" -> TI.typoid TI.text
237+ "boolean" -> TI.typoid TI.bool
238+ _ -> error $ "Unrecognized type: " <> show typeName --TODO: Retrieve types properly
239+ , format = PQ.Text --TODO: Is this right?
240+ , tableOid = Nothing
241+ , name = Just $ encodeUtf8 n
242+ }
243+ case v of
244+ Just (Literal_Present l) -> Just <$> runConversion (fromField f $ Just l) conn
245+ Nothing -> Just <$> runConversion (fromField f Nothing) conn
246+ Just Literal_UnchangedToastDatum -> pure Nothing
247+ Nothing -> pure Nothing
248+ case result of
249+ Just (Ok a) -> pure $ Columnar' $ Just a
250+ Just (Errors e) -> fail $ show e
251+ Nothing -> pure $ Columnar' Nothing
252+
253+decodeChange
254+ :: forall tbl
255+ . Table tbl
256+ => Connection
257+ -> tbl (TableField tbl)
258+ -> tbl (FieldDecoder Postgres)
259+ -> Change
260+ -> IO (These (PrimaryKey tbl Maybe) (tbl Maybe))
261+decodeChange conn settings decoders = \case
262+ Change_Insert r -> That <$> decodeRow conn settings decoders r
263+ Change_Update k r -> These <$> decodeRow conn (primaryKey settings) (primaryKey decoders) (fmap (fmap Just) k) <*> decodeRow conn settings decoders r
264+ Change_Delete k -> This <$> decodeRow conn (primaryKey settings) (primaryKey decoders) (fmap (fmap Just) k)
265+
266+decodeTransaction :: Connection -> Transaction -> IO (Db EntityPatch)
267+decodeTransaction conn (_, tableChanges) = zipTables (Proxy :: Proxy Postgres) f db (defaultDatabasePatchDecoder @Postgres @Db)
268+ where
269+ f :: forall entityType. IsDatabaseEntity Postgres entityType => DatabaseEntity Postgres Db entityType -> EntityPatchDecoder Postgres entityType -> IO (EntityPatch entityType)
270+ f (DatabaseEntity descriptor) decoder =
271+ let n = view dbEntityName descriptor
272+ s = Just "public" -- view dbEntitySchema descriptor --TODO: Beam has 'Nothing' while the replication connection reports 'Just "public"'
273+ vals = OldMap.findWithDefault mempty (QualifiedIdentifier s n) tableChanges
274+ x :: IO (EntityPatch entityType)
275+ x = case decoder of
276+ EntityPatchDecoder_NotDecodable -> pure EntityPatch_NotPatchable
277+ EntityPatchDecoder_Table decoders -> EntityPatch_Table <$> traverse (decodeChange conn (dbTableSettings descriptor) decoders) vals
278+ in x
skeleton/backend/src/Backend/Schema.hsnew file+40
@@ -0,0 +1,40 @@
1+{-# LANGUAGE StandaloneDeriving #-}
2+{-# LANGUAGE TypeApplications #-}
3+{-# LANGUAGE UndecidableInstances #-}
4+{-# LANGUAGE ScopedTypeVariables #-}
5+{-# LANGUAGE MultiParamTypeClasses #-}
6+{-# LANGUAGE TypeFamilies #-}
7+{-# LANGUAGE FlexibleInstances #-}
8+{-# LANGUAGE DeriveGeneric #-}
9+{-# LANGUAGE TypeSynonymInstances #-}
10+
11+{-# OPTIONS_GHC -fno-warn-orphans #-} --TODO: Eliminate this
12+module Backend.Schema where
13+
14+import Common.Schema
15+
16+import Control.Lens
17+import Data.List.NonEmpty (NonEmpty (..))
18+import Data.Proxy
19+import qualified Data.Text as T
20+import Database.Beam
21+import Database.Beam.Schema.Tables
22+import Database.Beam.Migrate.Simple
23+import Database.Beam.Migrate
24+import Database.Beam.Postgres
25+
26+instance Database Postgres Db
27+
28+replaceAllFieldNames :: forall be db. Database be db => CheckedDatabaseSettings be db -> CheckedDatabaseSettings be db
29+replaceAllFieldNames d = runIdentity $ zipTables (Proxy @be) (\_ -> pure . f) d d
30+ where f :: IsDatabaseEntity be e => CheckedDatabaseEntity be db e -> CheckedDatabaseEntity be db e
31+ f (CheckedDatabaseEntity descriptor preds) =
32+ let renameEntity = unChecked . dbEntityName %~ T.drop 1 . T.dropWhile (/= '_')
33+ renameFields = unChecked %~ withFieldRenamer (renamingFields $ \(x :| _) -> T.drop 1 $ T.dropWhile (/= '_') $ T.drop 1 x)
34+ in CheckedDatabaseEntity (renameFields $ renameEntity descriptor) preds
35+
36+checkedDb :: CheckedDatabaseSettings Postgres Db
37+checkedDb = replaceAllFieldNames defaultMigratableDbSettings
38+
39+db :: DatabaseSettings Postgres Db
40+db = unCheckDatabase checkedDb
skeleton/common/src/Common/Api.hs+48−2
@@ -1,4 +1,50 @@
1+{-# LANGUAGE EmptyCase #-}
2+{-# LANGUAGE TemplateHaskell #-}
3+{-# LANGUAGE GADTs #-}
4+{-# LANGUAGE TupleSections #-}
5+{-# LANGUAGE DeriveFunctor #-}
6+{-# LANGUAGE DeriveFoldable #-}
7+{-# LANGUAGE DeriveTraversable #-}
8+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
9+{-# LANGUAGE TypeApplications #-}
10+{-# LANGUAGE ScopedTypeVariables #-}
11+{-# LANGUAGE FlexibleContexts #-}
12+{-# LANGUAGE FlexibleInstances #-}
13+{-# LANGUAGE LambdaCase #-}
14+{-# LANGUAGE OverloadedStrings #-}
15+{-# LANGUAGE RankNTypes #-}
16+{-# LANGUAGE TypeFamilies #-}
117module Common.Api where
218
3−commonStuff :: String
4−commonStuff = "Here is a string defined in code common to the frontend and backend."
19+import Common.Schema
20+import Data.Aeson
21+import Data.AppendMap ()
22+import Data.Functor.Identity
23+import Data.Map.Monoidal (MonoidalMap (..))
24+import qualified Data.Map.Monoidal as Map
25+import Data.Semigroup
26+import Data.Text (Text)
27+import Obelisk.Request.TH
28+import Reflex.Class
29+import Reflex.Query.Class
30+
31+newtype VS k v a = VS (MonoidalMap k a)
32+ deriving (Semigroup, Monoid, Functor, Foldable, Traversable, Group, ToJSON, FromJSON, Eq)
33+
34+newtype V k v a = V (MonoidalMap k (a, First (Maybe v)))
35+ deriving (Semigroup, Monoid, Functor, Foldable, Traversable, ToJSON, FromJSON, Eq)
36+
37+instance FunctorMaybe (V k v) where
38+ fmapMaybe f (V m) = V $ Map.mapMaybe (\(a, v) -> (,v) <$> f a) m
39+
40+instance (Ord k, Semigroup a) => Query (VS k v a) where
41+ type QueryResult (VS k v a) = V k v a
42+ crop _ = id
43+
44+type family Id t where
45+ Id (f Identity) = PrimaryKey f Identity
46+
47+data MyRequest a where
48+ MyRequest_Add :: Text -> MyRequest (Id Task)
49+
50+makeRequestForData ''MyRequest
skeleton/common/src/Common/Route.hs+2
@@ -29,6 +29,7 @@ data BackendRoute :: * -> * where
2929 BackendRoute_Missing :: BackendRoute ()
3030 -- You can define any routes that will be handled specially by the backend here.
3131 -- i.e. These do not serve the frontend, but do something different, such as serving static files.
32+ BackendRoute_Api :: BackendRoute ()
3233
3334data FrontendRoute :: * -> * where
3435 FrontendRoute_Main :: FrontendRoute ()
@@ -40,6 +41,7 @@ backendRouteEncoder = handleEncoder (const (InL BackendRoute_Missing :/ ())) $
4041 pathComponentEncoder $ \case
4142 InL backendRoute -> case backendRoute of
4243 BackendRoute_Missing -> PathSegment "missing" $ unitEncoder mempty
44+ BackendRoute_Api -> PathSegment "api" $ unitEncoder mempty
4345 InR obeliskRoute -> obeliskRouteSegment obeliskRoute $ \case
4446 -- The encoder given to PathEnd determines how to parse query parameters,
4547 -- in this example, we have none, so we insist on it.
skeleton/common/src/Common/Schema.hsnew file+60
@@ -0,0 +1,60 @@
1+{-# LANGUAGE StandaloneDeriving #-}
2+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
3+{-# LANGUAGE UndecidableInstances #-}
4+{-# LANGUAGE ScopedTypeVariables #-}
5+{-# LANGUAGE MultiParamTypeClasses #-}
6+{-# LANGUAGE TypeFamilies #-}
7+{-# LANGUAGE FlexibleInstances #-}
8+{-# LANGUAGE DeriveGeneric #-}
9+{-# LANGUAGE TypeSynonymInstances #-}
10+
11+{-# OPTIONS_GHC -fno-warn-orphans #-} --TODO: Eliminate this
12+module Common.Schema where
13+
14+import Data.Aeson
15+import Data.Int
16+import Data.Text (Text)
17+import Database.Beam
18+import Database.Beam.Backend.SQL.Types
19+
20+data TaskT f = Task
21+ { _task_id :: Columnar f (SqlSerial Int64)
22+ , _task_description :: Columnar f Text
23+ , _task_completed :: Columnar f Bool
24+ }
25+ deriving (Generic)
26+type Task = TaskT Identity
27+
28+deriving instance (Show (Columnar f (SqlSerial Int64)), Show (Columnar f Text), Show (Columnar f Bool)) => Show (TaskT f)
29+
30+deriving instance Eq Task
31+
32+instance Beamable TaskT
33+
34+instance Table TaskT where
35+ newtype PrimaryKey TaskT f = TaskId (Columnar f (SqlSerial Int64))
36+ deriving (Generic)
37+ primaryKey = TaskId . _task_id
38+
39+deriving instance Show (Columnar f (SqlSerial Int64)) => Show (PrimaryKey TaskT f)
40+
41+deriving instance ToJSONKey a => ToJSONKey (SqlSerial a)
42+deriving instance FromJSONKey a => FromJSONKey (SqlSerial a)
43+
44+deriving instance Eq (PrimaryKey TaskT Identity)
45+deriving instance Ord (PrimaryKey TaskT Identity)
46+deriving instance ToJSON (PrimaryKey TaskT Identity)
47+deriving instance FromJSON (PrimaryKey TaskT Identity)
48+deriving instance ToJSONKey (PrimaryKey TaskT Identity)
49+deriving instance FromJSONKey (PrimaryKey TaskT Identity)
50+instance ToJSON (TaskT Identity)
51+instance FromJSON (TaskT Identity)
52+
53+instance Beamable (PrimaryKey TaskT)
54+
55+data Db f = Db
56+ { _db_tasks :: f (TableEntity TaskT)
57+ }
58+ deriving (Generic)
59+
60+deriving instance Show (f (TableEntity TaskT)) => Show (Db f)
skeleton/frontend/src/Frontend.hs+8−7
@@ -1,23 +1,24 @@
11{-# LANGUAGE DataKinds #-}
2+{-# LANGUAGE LambdaCase #-}
3+{-# LANGUAGE TypeFamilies #-}
4+{-# LANGUAGE FlexibleContexts #-}
5+{-# LANGUAGE ScopedTypeVariables #-}
6+{-# LANGUAGE RankNTypes #-}
7+{-# LANGUAGE RecursiveDo #-}
28{-# LANGUAGE OverloadedStrings #-}
39{-# LANGUAGE TypeApplications #-}
410module Frontend where
511
6−import qualified Data.Text as T
12+import Common.Route
713import Obelisk.Frontend
14+import Obelisk.Generated.Static
815import Obelisk.Route
916import Reflex.Dom.Core
1017
11−import Common.Api
12−import Common.Route
13−import Obelisk.Generated.Static
14−
15−
1618frontend :: Frontend (R FrontendRoute)
1719frontend = Frontend
1820 { _frontend_head = el "title" $ text "Obelisk Minimal Example"
1921 , _frontend_body = do
2022 text "Welcome to Obelisk!"
21− el "p" $ text $ T.pack commonStuff
2223 elAttr "img" ("src" =: static @"obelisk.jpg") blank
2324 }