├── README.org ├── deps.edn ├── docs ├── css │ ├── et-book.css │ ├── grid.css │ ├── main.css │ └── post.css ├── fonts │ └── et-book │ │ ├── et-book-bold-line-figures │ │ ├── et-book-bold-line-figures.eot │ │ ├── et-book-bold-line-figures.svg │ │ ├── et-book-bold-line-figures.ttf │ │ └── et-book-bold-line-figures.woff │ │ ├── et-book-display-italic-old-style-figures │ │ ├── et-book-display-italic-old-style-figures.eot │ │ ├── et-book-display-italic-old-style-figures.svg │ │ ├── et-book-display-italic-old-style-figures.ttf │ │ └── et-book-display-italic-old-style-figures.woff │ │ ├── et-book-roman-line-figures │ │ ├── et-book-roman-line-figures.eot │ │ ├── et-book-roman-line-figures.svg │ │ ├── et-book-roman-line-figures.ttf │ │ └── et-book-roman-line-figures.woff │ │ ├── et-book-roman-old-style-figures │ │ ├── et-book-roman-old-style-figures.eot │ │ ├── et-book-roman-old-style-figures.svg │ │ ├── et-book-roman-old-style-figures.ttf │ │ └── et-book-roman-old-style-figures.woff │ │ └── et-book-semi-bold-old-style-figures │ │ ├── et-book-semi-bold-old-style-figures.eot │ │ ├── et-book-semi-bold-old-style-figures.svg │ │ ├── et-book-semi-bold-old-style-figures.ttf │ │ └── et-book-semi-bold-old-style-figures.woff ├── index.html ├── index.org └── js │ └── navigation.js ├── image └── benjamin.jpg ├── meyvn.edn ├── src └── benjamin │ ├── configuration.clj │ └── core.clj └── test └── benjamin └── core_test.clj /README.org: -------------------------------------------------------------------------------- 1 | * Benjamin 2 | 3 | 4 | #+HTML: 5 | 6 | [[https://clojars.org/org.danielsz/benjamin/latest-version.svg]] 7 | 8 | [[https://danielsz.github.io/benjamin/][Documentation]]. 9 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {} 2 | :aliases {:test {:extra-paths ["test"] 3 | :extra-deps {org.danielsz/detijd {:mvn/version "0.2.5"} 4 | io.github.cognitect-labs/test-runner 5 | {:git/url "https://github.com/cognitect-labs/test-runner.git" 6 | :sha "8c3f22363d63715de4087b038d79ae0de36a3263"}} 7 | :main-opts ["-m" "cognitect.test-runner"] 8 | :exec-fn cognitect.test-runner.api/test} 9 | :repl {:extra-deps {cider/cider-nrepl {:mvn/version "0.52.1"} 10 | nrepl/nrepl {:mvn/version "1.3.1"} 11 | org.danielsz/system {:mvn/version "0.5.5"} 12 | org.meyvn/nrepl-middleware {:mvn/version "1.3.7"} 13 | com.kohlschutter.junixsocket/junixsocket-core {:mvn/version "2.10.1"}}}}} 14 | -------------------------------------------------------------------------------- /docs/css/et-book.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | @font-face { 4 | font-family: "et-book"; 5 | src: url("../fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot"); 6 | src: url("../fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot?#iefix") format("embedded-opentype"), url("../fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.woff") format("woff"), url("../fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.ttf") format("truetype"), url("../fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.svg#etbookromanosf") format("svg"); 7 | font-weight: normal; 8 | font-style: normal 9 | } 10 | 11 | @font-face { 12 | font-family: "et-book"; 13 | src: url("../fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot"); 14 | src: url("../fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot?#iefix") format("embedded-opentype"), url("../fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.woff") format("woff"), url("../fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.ttf") format("truetype"), url("../fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.svg#etbookromanosf") format("svg"); 15 | font-weight: normal; 16 | font-style: italic 17 | } 18 | 19 | @font-face { 20 | font-family: "et-book"; 21 | src: url("../fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot"); 22 | src: url("../fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot?#iefix") format("embedded-opentype"), url("../fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.woff") format("woff"), url("../fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.ttf") format("truetype"), url("../fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.svg#etbookromanosf") format("svg"); 23 | font-weight: bold; 24 | font-style: normal 25 | } 26 | -------------------------------------------------------------------------------- /docs/css/grid.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: grid; 3 | grid-template-columns: repeat(12, 1fr); 4 | grid-template-rows: 20px 20px 1fr; 5 | margin:0; 6 | } 7 | 8 | #top { 9 | grid-column: 1 / -1; 10 | grid-row: 1; 11 | } 12 | 13 | #corner { 14 | grid-row: 1; 15 | grid-column: 1; 16 | } 17 | #lambda { 18 | grid-row: -2; 19 | grid-column: 1; 20 | align-self: end; 21 | } 22 | 23 | #content { 24 | grid-row: 3 / -1; 25 | grid-column: 1 / -1; 26 | } 27 | 28 | .square { 29 | width: 20px; 30 | height: 20px; 31 | } 32 | #square1 { 33 | grid-row: 1; 34 | grid-column: 1; 35 | } 36 | 37 | #square2 { 38 | 39 | grid-row-start: 2; 40 | grid-column-start: 2; 41 | 42 | } 43 | 44 | @media (min-width: 600px) { 45 | body{ 46 | grid-template-rows: 100px 100px 1fr; 47 | } 48 | #contra { 49 | 50 | grid-row-start: 2; 51 | grid-column-start: 2; 52 | } 53 | #left { 54 | 55 | grid-row: 1 / -1; 56 | grid-column: 1; 57 | } 58 | #content { 59 | grid-column: 3 / -1; 60 | } 61 | .square { 62 | width: 100px; 63 | height: 100px; 64 | } 65 | #lambda { 66 | grid-column: 2; 67 | padding: 2rem; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/css/main.css: -------------------------------------------------------------------------------- 1 | .title { 2 | font-family: 'et-book', serif; 3 | font-size: 3rem; 4 | text-align: right !important; 5 | border-bottom: 1px solid black; 6 | padding-bottom: 2rem; 7 | margin-bottom: 2rem; 8 | } 9 | 10 | .subtitle { 11 | font-family: 'et-book', serif; 12 | font-size: 2rem; 13 | } 14 | 15 | p, 16 | li { 17 | font-family: 'Source Sans Pro', sans-serif; 18 | font-size: 1.3em; 19 | font-weight: 300; 20 | } 21 | 22 | p { 23 | line-height: 1.4em; 24 | } 25 | 26 | blockquote > p { 27 | font-style: italic; 28 | } 29 | 30 | h1, 31 | h2, 32 | h3, 33 | h4, 34 | h5, 35 | h6 { 36 | font-family: 'Source Sans Pro', sans-serif; 37 | font-weight: 600; 38 | } 39 | 40 | h3 { 41 | font-size: 1.4em; 42 | } 43 | 44 | h4 { 45 | font-size: 1em; 46 | text-transform: uppercase; 47 | } 48 | 49 | #text-table-of-contents { 50 | text-transform: uppercase; 51 | } 52 | 53 | #text-table-of-contents > ul { 54 | list-style-type: none; 55 | } 56 | 57 | #table-of-contents > h2 { 58 | display: none; 59 | } 60 | 61 | #text-table-of-contents li:not(:last-child) { 62 | margin-bottom: 0.3em; 63 | } 64 | 65 | #text-table-of-contents a { 66 | text-decoration: none; 67 | color: inherit; 68 | } 69 | 70 | #text-table-of-contents a:visited { 71 | color: inherit; 72 | } 73 | 74 | div.outline-2, 75 | div.outline-text-2, 76 | #postamble { 77 | display: none; 78 | } 79 | 80 | pre.src { 81 | overflow: auto !important; 82 | } 83 | 84 | pre.src::before { 85 | top: 0 !important; 86 | } 87 | 88 | @media (min-width: 800px) { 89 | #text-table-of-contents > ul { 90 | column-count: 2; 91 | column-gap: 3rem; 92 | column-rule: 1px solid #c79c87; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /docs/css/post.css: -------------------------------------------------------------------------------- 1 | #content { 2 | display: grid; 3 | grid-template-columns: [full-start] minmax(1em, 2fr) [main-start] minmax(0, 38em) [main-end] minmax(1em, 1fr) [full-end]; 4 | } 5 | 6 | #content > * { 7 | grid-column: main; 8 | } 9 | 10 | #content > .org-src-container { 11 | grid-column: full; 12 | } 13 | 14 | #content > blockquote { 15 | grid-column: full; 16 | } 17 | 18 | #org-div-home-and-up { 19 | grid-column-start: -2; 20 | text-align: center; 21 | } 22 | 23 | #content img { 24 | max-width: 100%; 25 | } 26 | -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.ttf -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.woff -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.ttf -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.woff -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.ttf -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.woff -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.ttf -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.woff -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.eot -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.ttf -------------------------------------------------------------------------------- /docs/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/docs/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.woff -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | Benjamin 10 | 11 | 12 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 |
204 |

Benjamin 205 |
206 | Idempotency with side-effects 207 |

208 |
209 |

Table of Contents

210 | 224 |
225 | 226 |
227 |

Description

228 |
229 |

230 | Benjamin gives you a macro that transforms code like this: 231 |

232 | 233 |
234 |
(let [logbook (get-logbook entity)]
235 |   (when (some pred logbook)
236 |     (let [response (body)]
237 |       (when (success? response)
238 |         (write logbook)))))
239 | 
240 |
241 | 242 |

243 | Into this: 244 |

245 | 246 |
247 |
(with-logbook entity :event
248 |   body)
249 | 
250 |
251 | 252 |

253 | There is a blog post that delves in the motivation and backstory. 254 |

255 |
256 |
257 | 258 |
259 |

Usage

260 |
261 |

262 | In your namespace, require: 263 |

264 |
265 |
[benjamin.core :refer [with-logbook]]
266 | 
267 |
268 | 269 |
270 |
(with-logbook user :newsletter
271 |   (email (:email user) newsletter))  
272 | 
273 |
274 | 275 |

276 | Benjamin executes body in a future and returns that future object immediately. Deref'ing the latter is subject to the semantics of futures (blocks until operations complete). success-fn is provided to determine the success of body. success-fn runs within the same thread as body and will not block. 277 |

278 |
279 |
280 | 281 |
282 |

Configuration

283 |
284 |
    285 |
  • logbook-fn A function that retrieves a logbook given an entity.
  • 286 |
  • persistence-fn A function that persists an updated logbook given an entity and an event
  • 287 |
  • success-fn A predicate function that determines the success of body.
  • 288 |
  • events A Clojure map with events as keys and predicates as values.
  • 289 |
  • allow-undeclared-events? When Benjamin receives an event not found in the events map, it will either allows body to run or forbid it. This boolean setting determines this behavior. Defaults to false.
  • 290 |
291 | 292 |

293 | Tip: system users can configure this library via a component that ships with the latest snapshot. 294 |

295 | 296 |

297 | Manual configuration is done by requiring: 298 |

299 | 300 |
301 |
[benjamin.configuration :refer [set-config!]]
302 | 
303 |
304 |
305 | 306 |
307 |

Accessing the logbook

308 |
309 |
310 |
(set-config! :logbook-fn f)
311 | 
312 |
313 | 314 |

315 | logbook-fn is a function that receives the entity as argument and returns a logbook. 316 | The default is :logbook which will work when the entity map embeds the logbook, as in: 317 |

318 | 319 |
320 |
{:first-name "Benjamin"
321 |  :last-name "Peirce"
322 |  :occupation "Mathematician"
323 |  :email "benjamin.peirce@harvard.edu"
324 |  :logbook [{:welcome-email timestamp}
325 |            {:subscription-reminder timestamp}
326 |            {:subscription-reminder timestamp}
327 |            {:newsletter timestamp}
328 |            {:newsletter timestamp}
329 |            {:newsletter timestamp}]}
330 | 
331 |
332 |
333 |
334 | 335 |
336 |

Deriving predicates from events

337 |
338 |
339 |
(set-config! :events {:event predicate
340 |                       :event predicate
341 |                       :event predicate
342 |                       ...})
343 | 
344 |
345 | 346 |

347 | Predicates are one argument functions that receive a logbook entry. A logbook entry is a map with an event as the key and a timestamp as the value. 348 |

349 | 350 |

351 | The following example checks if the logbook entry was written today. 352 |

353 | 354 |
355 |
#(if-let [date (first (vals %))]
356 |    (time/today? date)
357 |    false)
358 | 
359 |
360 | 361 |

362 | Several predicates are offered in the benjamin.predicates namespace for convenience. That namespace has a dependency that you need to require in your build should you want to use them. This is because benjamin does not have any dependency of its own (it relies entirely on language features). 363 |

364 | 365 | 366 |
367 |

latest-version.svg 368 |

369 |
370 |
371 |
372 |
373 | 374 |
375 |

Getting the logbook

376 |
377 |

378 | :logbook-fn is a function of one argument, entity. Its responsibility is to retrieve the logbook for a entity (user, for example). The return value is a vector of maps, where the map has the event as key, and a date as value. Benjamin will run the predicate associated with the event on the logbook to determine whether the side-effect should is allowed or not. 379 |

380 | 381 |

382 | Tip: If you have dependencies (for example, a database), use a higher–order function that returns logbook-fn. 383 | Tip: The benjamin component in the system library has an option called logbook-fn-wrap-component? that is meant to achieve this. 384 |

385 |
386 |
387 | 388 |
389 |

Writing to the logbook

390 |
391 |

392 | :persistence-fn is a function of two arguments, entity and event. Its responsibility is to append to the logbook and persist the entity. 393 | You have to provide an implementation or an error will be thrown. For example: 394 |

395 | 396 |
397 |
(set-config! :persistence-fn
398 |              (fn [entity event] (let [logbook (conj (:logbook entity) {event (t/now)})]
399 |                                  (assoc entity :logbook logbook)
400 |                                  (save db entity))))
401 | 
402 |
403 | 404 |

405 | Tip: If you have dependencies (for example, a database), use a higher–order function that returns persistence-fn. 406 |

407 | 408 |
409 |
(defn logbook [db :db :as dependencies}]
410 |   (fn [entity event] (let [logbook (conj (:logbook entity) {event (t/now)})]
411 |                        (assoc entity :logbook logbook)
412 |                        (save db entity)))
413 | 
414 |
415 |

416 | Tip: The benjamin component in the system library includes an option, persistence-fn-wrap-component?, that will wrap dependencies associated with it in the system map. 417 |

418 |
419 |
420 | 421 |
422 |

Status of the side-effect

423 |
424 |

425 | The success function is a function of one argument, ie. the return value of the side-effectful body. 426 | It determines if the operation was successful and thus for inclusion in the logbook. 427 |

428 | 429 |
430 |
(set-config! :success-fn (constantly true))
431 | 
432 |
433 | 434 |

435 | The default assumes all your operations will be A-okay. You’ll probably want to pass along something more realistic. 436 |

437 |
438 |
439 | 440 |
441 |

Policy with regard to unknown events

442 |
443 |
444 |
(with-logbook entity event
445 |   body)
446 | 
447 |
448 | 449 |

450 | If the event is unknown, that is if it doesn’t show up in the events map, no predicate can be derived and then we rely on a policy you can set yourself. 451 | Either we accept unknown events and we proceed with the side-effect, or we reject them and return immediately. The default is strict, but you can change that. 452 |

453 | 454 |
455 |
(set-config! :allow-undeclared-events? true)
456 | 
457 |
458 |
459 |
460 | 461 |
462 |

Tests

463 |
464 |

465 | A test suite is provided in benjamin.core-test. Call (test-ns *ns*) in the namespace, or run boot testing for continous testing. 466 |

467 |
468 |
469 | 470 |
471 |

Limitations

472 |
473 |

474 | You can work with as many entities you want. You can declare as many events as you want. You can have any side-effectful procedures in the body. Your success-fn may dispatch on the return value if you run different types of operations in the body. 475 |

476 | 477 |

478 | The configuration is a singleton with dynamic scope, so deal with it to the best of your understanding. Personally, I set it once and treat it as a constant for the lifetime of the application. 479 |

480 |
481 |
482 | 483 |
484 |

License

485 |
486 |

487 | Licensing terms will be revealed shortly. In the meantime, do what you want with it. 488 |

489 |
490 |
491 |
492 |
493 |

Author: Daniel Szmulewicz

494 |

Created: 2024-08-07 Wed 17:47

495 |

Validate

496 |
497 | 498 | 499 | -------------------------------------------------------------------------------- /docs/index.org: -------------------------------------------------------------------------------- 1 | #+title: Benjamin 2 | #+SUBTITLE: Idempotency with side-effects 3 | #+OPTIONS: toc:1 num:nil 4 | #+HTML_HEAD: 5 | #+HTML_HEAD: 6 | #+HTML_HEAD: 7 | #+HTML_HEAD: 8 | #+HTML_HEAD: 9 | 10 | * Description 11 | 12 | Benjamin gives you a macro that transforms code like this: 13 | 14 | #+BEGIN_SRC clojure 15 | (let [logbook (get-logbook entity)] 16 | (when (some pred logbook) 17 | (let [response (body)] 18 | (when (success? response) 19 | (write logbook))))) 20 | #+END_SRC 21 | 22 | Into this: 23 | 24 | #+BEGIN_SRC clojure 25 | (with-logbook entity :event 26 | body) 27 | #+END_SRC 28 | 29 | There is a [[http://danielsz.github.io/2017/08/07/Timed-idempotency][blog]] post that delves in the motivation and backstory. 30 | 31 | * Usage 32 | 33 | In your namespace, require: 34 | #+BEGIN_SRC clojure 35 | [benjamin.core :refer [with-logbook]] 36 | #+END_SRC 37 | 38 | #+begin_src clojure 39 | (with-logbook user :newsletter 40 | (email (:email user) newsletter)) 41 | #+end_src 42 | 43 | Benjamin executes ~body~ in a /future/ and returns that /future/ object immediately. Deref'ing the latter is subject to the semantics of /futures/ (blocks until operations complete). ~success-fn~ is provided to determine the success of ~body~. ~success-fn~ runs within the same thread as ~body~ and will not block. 44 | 45 | * Configuration 46 | 47 | - ~logbook-fn~ A function that retrieves a logbook given an entity and an event. 48 | - ~persistence-fn~ A function that persists an updated logbook given an entity and an event 49 | - ~success-fn~ A predicate function that determines the success of ~body~. 50 | - ~events~ A Clojure map with events as keys and predicates as values. 51 | - ~allow-undeclared-events?~ When Benjamin receives an event not found in the ~events~ map, it will either allows ~body~ to run or forbid it. This boolean setting determines this behavior. Defaults to false. 52 | 53 | *Tip:* ~system~ users can configure this library via a [[https://github.com/danielsz/system/blob/f4acb68d1e136720c1f9ab44d65e2eb763b1e6ef/src/system/components/benjamin.clj][component]] that is built-in. 54 | 55 | Manual configuration is done by requiring: 56 | 57 | #+BEGIN_SRC clojure 58 | [benjamin.configuration :refer [set-config!]] 59 | #+END_SRC 60 | 61 | ** Accessing the logbook 62 | 63 | #+BEGIN_SRC clojure 64 | (set-config! :logbook-fn f) 65 | #+END_SRC 66 | 67 | ~logbook-fn~ is a function that receives the entity and event as argument and returns a logbook. 68 | 69 | #+BEGIN_SRC clojure 70 | {:first-name "Benjamin" 71 | :last-name "Peirce" 72 | :occupation "Mathematician" 73 | :email "benjamin.peirce@harvard.edu" 74 | :logbook [{:welcome-email [timestamp timestamp ...]} 75 | {:subscription-reminder [timestamp timestamp ...]} 76 | {:newsletter [timestamp timestamp ...]}]} 77 | #+END_SRC 78 | 79 | ** Deriving predicates from events 80 | 81 | #+BEGIN_SRC clojure 82 | (set-config! :events {:event predicate 83 | :event predicate 84 | :event predicate 85 | ...}) 86 | #+END_SRC 87 | 88 | Predicates are one argument functions that operates on a timestamp. 89 | Given a logbook, which is a collection of timestamps, predicates will 90 | be used like so: 91 | 92 | #+begin_src elisp :lexical no 93 | (some pred logbook) 94 | #+end_src 95 | 96 | A predicate function can be written by the user, but one may find a 97 | host of ready-to-use predicates in another open-source library of mine 98 | called [[https://clojars.org/org.danielsz/detijd][detijd]]. 99 | 100 | [[https://clojars.org/org.danielsz/detijd/latest-version.svg]] 101 | 102 | For example, ~today?~ is a polymorphic function that can 103 | accommodate an instance of ~java.util.Date~, ~org.joda.timeDateTime~ or 104 | ~java.time.Instant~. 105 | 106 | If you would like to send an email to a user but make sure he doesn't 107 | get more than one mail per day, the map of events would include 108 | something like this: 109 | 110 | #+BEGIN_SRC clojure 111 | {:logbook/daily today?} 112 | #+END_SRC 113 | 114 | Note: ~Benjamin~ does not have any dependency of its own (it relies entirely on language features). 115 | 116 | * Getting the logbook 117 | 118 | ~:logbook-fn~ is a function of two arguments, ~entity~ and ~event~. Its 119 | responsibility is to retrieve the logbook for an entity (a user, for 120 | example) and an event. You should write this function such that it 121 | returns a collection of dates or timestamps. Benjamin will apply the 122 | predicate associated with the event on the logbook to determine 123 | whether the side-effect should run. 124 | 125 | *Tip:* If you have dependencies (for example, a database), use a 126 | higher–order function that returns ~logbook-fn~. 127 | 128 | *Tip:* The ~benjamin~ component in the ~system~ library has an option called 129 | ~logbook-fn-wrap-component?~ that is meant to achieve this. 130 | 131 | * Writing to the logbook 132 | 133 | ~:persistence-fn~ is a function of two arguments, ~entity~ and ~event~. Its 134 | responsibility is to append to the logbook and persist the entity. 135 | You have to provide an implementation or an error will be thrown. For 136 | example: 137 | 138 | #+BEGIN_SRC clojure 139 | (set-config! :persistence-fn 140 | (fn [entity event] (let [logbook (conj (:logbook entity) {event (t/now)})] 141 | (assoc entity :logbook logbook) 142 | (save db entity)))) 143 | #+END_SRC 144 | 145 | *Tip:* If you have dependencies (for example, a database), use a higher–order function that returns ~persistence-fn~. 146 | 147 | #+BEGIN_SRC clojure 148 | (defn logbook [{db :db :as dependencies}] 149 | (fn [entity event] (let [logbook (conj (:logbook entity) {event (t/now)})] 150 | (assoc entity :logbook logbook) 151 | (save db entity))) 152 | #+END_SRC 153 | *Tip:* The ~benjamin~ component in the ~system~ library includes an option, ~persistence-fn-wrap-component?~, that will wrap dependencies associated with it in the system map. 154 | 155 | * Status of the side-effect 156 | 157 | The success function is a function of one argument, ie. the return value of the effectful body. 158 | It determines if the operation was successful and thus for inclusion in the logbook. 159 | 160 | #+BEGIN_SRC clojure 161 | (set-config! :success-fn (constantly true)) 162 | #+END_SRC 163 | 164 | The default assumes all your operations will be A-okay. You’ll probably want to pass along something more realistic. 165 | 166 | * Policy with regard to unknown events 167 | 168 | #+BEGIN_SRC clojure 169 | (with-logbook entity event 170 | body) 171 | #+END_SRC 172 | 173 | If the event is unknown, that is if it doesn’t show up in the events map, no predicate can be derived and then we rely on a policy you can set yourself. 174 | Either we accept unknown events and we proceed with the side-effect, or we reject them and return immediately. The default is strict, but you can change that. 175 | 176 | #+BEGIN_SRC clojure 177 | (set-config! :allow-undeclared-events? true) 178 | #+END_SRC 179 | 180 | * Tests 181 | 182 | A test suite is provided in ~benjamin.core-test~. Call ~(test-ns *ns*)~ in the namespace, or run ~myvn test~ for continuous testing. 183 | 184 | * Limitations 185 | 186 | You can work with as many entities you want. You can declare as many 187 | events as you want. You can have any number of effects in the 188 | body. The ~success-fn~ is applied on the return value of the last effect in the body. 189 | 190 | The configuration is a singleton with dynamic scope, so deal with it 191 | to the best of your understanding. Personally, I set it once and treat 192 | it as a constant for the lifetime of the application. 193 | 194 | * License 195 | Licensing terms will be revealed shortly. In the meantime, do what you want with it. 196 | -------------------------------------------------------------------------------- /docs/js/navigation.js: -------------------------------------------------------------------------------- 1 | function hideAll() { 2 | const els = document.querySelectorAll(".outline-text-2, .outline-2"); 3 | els.forEach(el => { el.style.display = "none";}); 4 | } 5 | function tocAll() { 6 | const els = document.querySelectorAll("#text-table-of-contents ul li a"); 7 | els.forEach(el => { 8 | el.style.fontWeight = 300;}); 9 | } 10 | 11 | window.onpopstate = function(event) { 12 | var el = document.querySelector("a[href='" + document.location.hash + "']"); 13 | // console.log(el); 14 | hideAll(); 15 | tocAll(); 16 | el.style.fontWeight = 400; 17 | let section = document.location.hash.substring(1); 18 | const container = document.getElementById("outline-container-" + section); 19 | container.style.display = "block"; 20 | for (let i = 0; i < container.children.length; i++) { 21 | ( i === 0 ) ? container.children[i].style.display = "none" : container.children[i].style.display = "block"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /image/benjamin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsz/benjamin/22b47e10333a494c3540912d285b82bf8b8dec72/image/benjamin.jpg -------------------------------------------------------------------------------- /meyvn.edn: -------------------------------------------------------------------------------- 1 | {:pom {:group-id "org.danielsz", 2 | :artifact-id "benjamin", 3 | :version "0.1.6", 4 | :name "benjamin" 5 | :licenses [{:name "Eclipse Public License -v 1.0" 6 | :url "https://opensource.org/license/epl-1-0/"}]}, 7 | :build-properties {:project-build-sourceEncoding "UTF-8"}, 8 | :packaging {:jar {:enabled true, 9 | :gpg {:enabled false} 10 | :sources {:enabled false}}}, 11 | :testing {:enabled true, :tools-deps-alias :test}, 12 | :scm {:enabled true}, 13 | :interactive {:enabled true 14 | :system {:enabled false, 15 | :var "a.namespace/system-var", 16 | :restart-on-change ["handler.clj" 17 | "system.clj"]}, 18 | :proxy {:enabled false, 19 | :socks {:host "127.0.0.1", 20 | :port "1080", 21 | :version "5", 22 | :use-system-proxies false}}, 23 | :repl-port :auto, 24 | :repl-host "127.0.0.1", 25 | :repl-unix-socket "nrepl.socket" 26 | :reload-on-save true,}, 27 | :profiles {:enabled false} 28 | :distribution-management {:id "clojars", 29 | :url "https://clojars.org/repo"}} 30 | -------------------------------------------------------------------------------- /src/benjamin/configuration.clj: -------------------------------------------------------------------------------- 1 | (ns benjamin.configuration 2 | (:refer-clojure :exclude [reset!])) 3 | 4 | (def defaults {:persistence-fn (fn [_ _] 5 | (throw (Exception. "Please run 'set-config! :persistence-fn!` with a function of two arguments, entity and event"))) 6 | :success-fn (constantly true) 7 | :events #(throw (Exception. "Please set event and predicate map")) 8 | :logbook-fn (fn [_ _] 9 | (throw (Exception. "Please run 'set-config! :logbook-fn!` with a function of two arguments, entity and event"))) 10 | :allow-undeclared-events? false}) 11 | 12 | (def ^:dynamic config defaults) 13 | 14 | (defn set-config! [k v] 15 | {:pre [(some #{k} [:persistence-fn :success-fn :events :logbook-fn :allow-undeclared-events?])]} 16 | (alter-var-root #'config (fn [config] (assoc config k v)))) 17 | 18 | (def reset! #(alter-var-root #'config (constantly defaults))) 19 | -------------------------------------------------------------------------------- /src/benjamin/core.clj: -------------------------------------------------------------------------------- 1 | (ns benjamin.core 2 | (:require [benjamin.configuration :refer [config]])) 3 | 4 | (def ^:dynamic persistence-fn nil) 5 | (def ^:dynamic success-fn nil) 6 | (def ^:dynamic logbook-fn nil) 7 | (def ^:dynamic events nil) 8 | (def ^:dynamic allow-undeclared-events? nil) 9 | 10 | (defn validate [entity event] 11 | (with-bindings {#'allow-undeclared-events? (:allow-undeclared-events? config) 12 | #'logbook-fn (:logbook-fn config) 13 | #'events (:events config)} 14 | (when (fn? events) (events)) 15 | (let [not-found (if allow-undeclared-events? (constantly false) (constantly true)) 16 | pred (get events event not-found)] 17 | (if-let [logbook (seq (logbook-fn entity event))] 18 | (not (some pred logbook)) 19 | (get events event allow-undeclared-events?))))) 20 | 21 | (defmacro with-logbook [entity event & body] 22 | `(with-bindings {#'persistence-fn (:persistence-fn config) 23 | #'success-fn (:success-fn config)} 24 | (let [logbook# (delay (persistence-fn ~entity ~event))] 25 | (when (validate ~entity ~event) 26 | (future (let [result# ~@body] 27 | (when (success-fn result#) 28 | (force logbook#)) 29 | result#)))))) 30 | -------------------------------------------------------------------------------- /test/benjamin/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns benjamin.core-test 2 | (:require [benjamin.core :refer [with-logbook]] 3 | [benjamin.configuration :as config :refer [set-config!]] 4 | [detijd.predicates :refer [today? last-days? last-months?]] 5 | [clojure.test :refer [testing deftest is use-fixtures]]) 6 | (:import [java.time Instant])) 7 | 8 | (def clean-slate {:name "Benjamin Peirce"}) 9 | (def user (atom clean-slate)) 10 | 11 | (defn persistence-fn [entity event] 12 | (if-let [entry-index (first (keep-indexed (fn [idx entry] (when (= (:event entry) event) idx)) (:logbooks @entity)))] 13 | (swap! entity update-in [:logbooks entry-index :timestamps] conj (Instant/now)) 14 | (swap! entity update :logbooks (fnil #(conj % {:event event :timestamps [(Instant/now)]}) [])))) 15 | 16 | (defn logbook-fn [entity event] 17 | (if-let [logbook (first (filter #(= event (:event %)) (:logbooks @entity)))] 18 | (:timestamps logbook) 19 | [])) 20 | 21 | (def unique? #(some? %)) 22 | 23 | (def last-3-days? #(last-days? % 3)) 24 | 25 | (def last-3-months? #(last-months? % 3)) 26 | 27 | (defn my-fixture [f] 28 | (reset! user clean-slate) 29 | (set-config! :events {:account-blocked last-3-months? 30 | :end-of-trial unique? 31 | :follow-up unique? 32 | :categories-change today? 33 | :newsletter last-3-days? 34 | :always (constantly false) 35 | :never (constantly true)}) 36 | (set-config! :persistence-fn persistence-fn) 37 | (set-config! :logbook-fn logbook-fn) 38 | (set-config! :success-fn (constantly true)) 39 | (f)) 40 | 41 | (use-fixtures :each my-fixture) 42 | 43 | (deftest configuration 44 | (testing "Doesn't throw error when everything is set" 45 | (is (future? (with-logbook user :follow-up 46 | (do))))) 47 | (testing "Sanity check" 48 | (is (= (:name @user) "Benjamin Peirce")) 49 | (is (seq (:logbooks @user))) 50 | (config/reset!) 51 | (testing "Throws error when `events' is not set" 52 | (is (thrown-with-msg? java.lang.Exception #"Please set event and predicate map" 53 | (with-logbook @user :subscribed 54 | (do))))) 55 | (set-config! :events {:account-blocked last-3-months? 56 | :end-of-trial unique? 57 | :follow-up unique? 58 | :categories-change today? 59 | :newsletter last-3-days?}) 60 | (testing "Throws error when `logbook-fn' is not set" 61 | (is (thrown-with-msg? java.lang.Exception #"Please run 'set-config! :logbook-fn!` with a function of two arguments, entity and event" 62 | @(with-logbook user :follow-up 63 | (do))))))) 64 | 65 | (deftest persistence 66 | (testing "Persistence doesn't occur when success is denied" 67 | (set-config! :success-fn (constantly false)) 68 | @(with-logbook user :account-blocked 69 | (do)) 70 | (is (empty? (:logbooks @user)))) 71 | (testing "Persistence occurs when success is confirmed" 72 | (reset! user clean-slate) 73 | (set-config! :success-fn (constantly true)) 74 | @(with-logbook user :account-blocked 75 | (do)) 76 | (is (contains? @user :logbooks)) 77 | (is (boolean (some #(= (:event %) :account-blocked) (:logbooks @user)))))) 78 | 79 | (deftest predicates 80 | (testing "Predicates determine if operation is done, and if logbook gets written." 81 | (testing "`Unique` predicate means operation can be executed once only." 82 | (is (= "I have done something" @(with-logbook user :end-of-trial 83 | (identity "I have done something")))) 84 | (is (= nil (with-logbook user :end-of-trial 85 | (identity "I have done something")))) 86 | (is (= 1 (count (:timestamps (first (filter #(= (:event %) :end-of-trial) (:logbooks @user)))))))) 87 | (testing "`Today` predicate means operation can be executed only if no other operation has been executed during the present day." 88 | (is (= "I have done something" @(with-logbook user :categories-change 89 | (identity "I have done something")))) 90 | (is (= nil (with-logbook user :categories-change 91 | (identity "I have done something")))) 92 | (is (= 1 (count (:timestamps (first (filter #(= (:event %) :categories-change) (:logbooks @user))))))) 93 | (is (= nil (with-logbook user :categories-change 94 | (identity "I have done something")))) 95 | (is (= 1 (count (:timestamps (first (filter #(= (:event %) :categories-change) (:logbooks @user))))))) 96 | (is (= "I have done something" @(with-logbook user :always 97 | (identity "I have done something")))) 98 | (is (= "I have done something" @(with-logbook user :always 99 | (identity "I have done something")))) 100 | (is (= "I have done something" @(with-logbook user :always 101 | (identity "I have done something")))) 102 | (is (= 3 (count (:timestamps (first (filter #(= (:event %) :always) (:logbooks @user)))))))))) 103 | 104 | (deftest events 105 | (testing "If the event is unknown, we don't execute the operation and don't write to the logbook (default)." 106 | (testing "default setting" 107 | (set-config! :allow-undeclared-events? false) 108 | (is (= nil (with-logbook user :subscribed 109 | (identity "I have done something")))) 110 | (is (= nil (with-logbook user :got-pwned 111 | (identity "I have done something"))))) 112 | (testing "If the event is unknown, but we changed the default, we execute the operation and write to the logbook" 113 | (set-config! :allow-undeclared-events? true) 114 | (is (= "I have done something" @(with-logbook user :got-pwned 115 | (identity "I have done something")))) 116 | (is (= "I have done something else" @(with-logbook user :got-pwned 117 | (identity "I have done something else")))) 118 | (is (= 2 (count (:timestamps (first (filter #(= (:event %) :got-pwned) (:logbooks @user)))))))))) 119 | --------------------------------------------------------------------------------