This is a very small and simple web framework, which still aims to tackle complicated problems.
Think re-frame talking to the server out of the box.
If haven't already, you really want to read re-frame tutorial.
If you have used re-frame, you will get a grasp of this one in no time.
Understanding of how reagent components and reactions work is required.
Also, note that remlok is a no-magic framework. It keeps things simple and not surprising, but this also means that you shouldn't be afraid to get your hands dirty, since it doesn't do much by default.
This is what happens when you use remlok:
db -> read -> :loc -> render -> user action -> mut! -> :loc -> db* | | v v :rem :rem | | ------------> send <------------------- | v remote | v merge! | v db*
It's your typical eternal cycle of data, flowing, but with a twist - it has a branch which leads to the remote.
As you can see, remlok allows you to have your say on every step of the application lifecycle. It also tries to be as predictable and reasonable as possible with its default actions.
A query is a pair
[topic args]
Both for reads and mutations.
It is considered appropriate to omit the args
, e. g. [:cur-user]
, [:log-out]
etc.
You set up your read functions with pub
like this
(pub
:cur-user
(fn [db _] ;; this is the read function
{:loc (reaction
(get @db :cur-user))}))
remlok will use the query's topic to decide on the read function.
Read function will receive two arguments, db
and query
.
db
- the application state ratom.
query
- the query to read.
Read function must return
{:loc reaction
:remote-foo query-foo
:bar-remote query-bar
...}
All those fields are optional.
Just use reagent components.
(defn users []
(let [users (read [:users {:first 10}])]
(fn []
[:ul
(for [{:keys [id name]} @users]
^{:key id}
[:li name])])))
You set up your mutation functions with mut
like this
(mut
:logout
(fn [db _] ;; this is the mutation function
{:loc (dissoc db :cur-user)
:rem [:log-out]}))
remlok will use the query's topic to decide on the mutation function.
Mutation function will receive two arguments, db
and query
.
db
- the application state.
query
- the query to read.
Mutation function must return
{:loc db*
:remote-foo query-foo
:bar-remote query-bar
...}
All those fields are optional.
You set up your send function with send
like this
(send
:http-server
(fn [_ req res] ;; this is the send function which will talk to the remote server
(my-network/send
(my-edn/serialize req)
(comp res my-edn/deserialize))))
(send
:indexed-db
(fn [_ req res] ;; this is the send function which will talk to the local IndexedDB
;; some IndexedDB logic
))
As you can see, you can have different send functions for every remote your application will talk to.
Also, it's important to understand that "remote" doesn't necessarily mean a remote machine/process. It just means something outside of remlok.
Send function will receive rem
, req
and res
arguments.
rem
- the remote the request is intended for.
req
- the request.
res
- the callback to call with the response, once it's available from the remote.
The request has the format
{:reads [query0 query1 ...]
:muts [query0 query1 ...]}
Note that remlok will be smart enough to batch the queries.
The novelty must have the format
[[query0 data0] [query1 data1] ...]
You set up your merge function with merge
like this
(merge
:new-score
(fn [db _ score] ;; this is the merge function
(update db :score + score)))
remlok will use the query's topic to decide on the merge function.
Merge function will receive three arguments, db
, query
and data
.
db
- the application state.
query
- the query, the result of which you're merging.
data
- the result itself.
Merge function must return the new application state.
How does merging work?
The function which merges a novelty is called merge!
.
remlok will call it for you, when your send function calls its res
callback.
As we already know, the novelty should have the format
[[query0 data0] [query1 data1] ...]
As you can see, those are just [query data]
pairs, where the data
is the result of the corresponding query
.
For example, if you have a request
{:reads [[:user 1] [:user 2]]
:muts [[:user/new {:id "tmp_id_1" :name "Alice"}]]}
you may receive
[[[:user 1] {:id 1 :name "Bob"}]
[[:user 2] {:id 2 :name "Shmob"}]
[[:user/new {:id "tmp_id_1" :name "Alice"}] {:id 3}]]
By setting up merge handlers for the topics, you can control how all those things are getting integrated into your application state.
For example, you may want to patch your temporary ids like this (super naive but demonstrates the point):
(merge
:user/new
(fn [db [_ {tmp-id :id}] {id :id}]
(let [user (get-in db [:users tmp-id])]
(-> db
(update :users dissoc tmp-id)
(assoc-in [:users id] user)))))
Note that you can call merge!
by yourself at any time with any properly formatted novelty.
This will be usable if you want to handle push updates from the remote (i. e. when there's no send before the merge).
remlok.rem
namespace exposes pub
, mut
, read
and mut!
functions, along with the fallbacks pubf
and mutf
.
read
and mut!
allow you to pass the ctx
, any clojure value, which will be passed to your handler functions.
remlok has no further opinions on how you handle things on your server.
Something like this:
(pub
:users
(fn [db-conn [_ {:keys [name]}]]
(my-sql-lib/select
db-conn
"select * from users where name = :name"
{:name name})))
(def db-conn
(my-sql-lib/open-connection))
(defn endpoint [req]
(let [{:keys [reads]} (my-edn/deserialize req)
res (for [query reads]
[query (read db-conn query)])]
(my-edn/serialize res)))
(my-network/listen!
80
endpoint)
remlok provides fallbacks for everything, so it can function on its own, without you specifying a single handler. (Obviously, the send fallback doesn't actually do anything except emitting a warning that it doesn't do anything.)
Fallbacks have f at the end - pubf
, mutf
, sendf
and mergef
, and are public.
Their docstrings explain what they do (they don't do a whole lot).
Read, mutation and merge fallbacks are initially registered for :remlok/default
topic.
When remlok can't find the handler for a topic, it just uses whatever handles :remlok/default
.
Note that the handler is given the initial query (and, therefore, the initial topic).
Of course, you can set up your own fallback (the "default" handler):
(pub
:remlok/default
(fn [db query]
(println "Warning! Unknown query " (str query))
nil ;; just to emphasize that we are returning an empty result
))
Note that even if you set up your own default handler, you still can use default fallbacks:
(pub
:search-input
pubf ;; simple enough for pubf to handle
)
remlok provides a very convenient middleware mechanism for everything - reads, mutations, sends and merges.
The middleware is registered for :remlok/mware
topic.
Your middleware function is just f -> f*
, much like a Python decorator.
For example
(pub
:remlok/mware
(fn [f] ;; this is the middleware function which will log all the reads
(fn [db query]
(println "Reading: " (str query))
(f db query))))
(send
:remlok/mware
(fn [f] ;; this is the middleware function which will prevent the sends to :bad-remote
(fn [rem req res]
(when (not= rem :bad-remote)
(f rem req res)))))
(Note that your middleware must be a pure function, since remlok may call it many times.)
Check out the examples - remlok-examples.
They feature optimistic updates and all that!
Because you will learn it in dozens of minutes, and it will let you do things that are still often deemed non-trivial.
(Of course, I'm supposing that you already can read and write Clojure and know what reagent is all about.)
Well, first of all, your queries is just data, so they are declarative; they are just not nested out of the box.
It was a very deliberate decision to keep the queries flat, since the API and all the machinery was getting seriously complicated, and remlok was on the verge of stopping being "miniature".
So, much like in re-frame, you can not nest queries, but I strongly believe that not all applications actually need recursive/deeply nested queries.
(Actually, feel free to check recursive-queries branch, which is trying to have om.next-like queries.)
(Also, you can emulate recursive queries to some extent, having all that "friend of friend of friend" madness in your args
.)
The request sent to your remote has :reads
and :muts
to let your remote know how to process each query.
Since queries have exactly the same format for reads and mutations, this will let you know when to use read
and mut!
on the remote.
On the other hand, the response is just a vector of pairs [query data]
.
That's because, from the client's point of view, all that comes from the remote is reads.
For example, if the client sends a mutation [:user/new "Alice"]
, the response [[:user/new "Alice"] {:id 1}]
is not a "mutation", it's a read of the result of that mutation.
Basically, the client sends reads and mutations, and says, "I want the response to be the reads of the results of all those operations I sent".
Just like re-frame, remlok uses global state, so you can have only one application per client context (and only one application per server context, for that matter).
Of course, this solution isn't quite optimal, so any feedback is welcome!
Since you may be composing your response in a non-trivial fashion.
One of the examples, "Wiki Autocompleter", features such a case (it uses core.async to wait for the reads to be completed).
Distributed under the Eclipse Public License, the same as Clojure.