A Clojure library that adapts the Biff web framework to use Datalevin as the database. Includes a Biff 2 database adapter and Biff 1.x component compatibility.
biff.core module with KV store, snapshots, on-tx, biff.fx handlers, and biff.graph resolversRequires Java 21 or newer.
Add to your deps.edn:
{:deps {io.github.datalevin/biff-datalevin {:mvn/version "0.3.19"}}}
biff.datalevin.adapter implements Biff 2's
database adapter interface:
a biff.core module with lifecycle functions, a key-value store,
:biff.core/wrap-db-snapshot, :biff.core/on-tx notifications, biff.fx
handlers, and biff.graph resolvers generated from your Datalevin schema.
(ns myapp.modules
(:require [biff.datalevin.adapter :as dl]
[com.biffweb.fx :as biff.fx]
[com.biffweb.graph :as biff.graph]))
(def schema
{:user/id {:db/valueType :db.type/uuid :db/unique :db.unique/identity}
:user/email {:db/valueType :db.type/string :db/unique :db.unique/identity}
:user/created-at {:db/valueType :db.type/instant}
:pet/id {:db/valueType :db.type/uuid :db/unique :db.unique/identity}
:pet/name {:db/valueType :db.type/string}
:user/pet-id {:db/valueType :db.type/ref}
;; Refs whose names don't imply their target type need :biff.datalevin/ref:
:group/members {:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:biff.datalevin/ref :user/id}})
(def modules
[(biff.fx/module)
(biff.graph/module)
(dl/module {:biff.datalevin/db-path "data/myapp"
:biff.datalevin/schema schema})])
(def start-order
[:biff.datalevin/module])
The module adds the following to the system map:
:biff.core/kv-get / :biff.core/kv-set / :biff.core/kv-list - used by
Biff libraries such as biff.authenticate:biff.core/wrap-db-snapshot - runs each biff.graph/query in a Datalevin
transaction so all resolvers see a consistent view. Since this serializes
graph queries and blocks writes while they run, you can set
:biff.datalevin/snapshot? false to disable it and get concurrent reads
instead:biff.datalevin/conn - the Datalevin connection:biff.core/on-tx is called after every transaction:biff.datalevin.fx/q and :biff.datalevin.fx/execute-txReads and writes:
(dl/q system '[:find ?e :where [?e :user/email "a@b.com"]])
(dl/execute-tx system [{:user/id (random-uuid)
:user/email "a@b.com"
:user/created-at :db/now}])
Graph queries:
(biff.graph/query system {:user/id user-id}
[:user/email {:user/pet [:pet/name]}])
Ref target types are inferred from attribute names: :session/user joins to
:user/id and :user/pet-id joins to :pet/id. For refs whose names don't
imply their target (e.g. :group/members), set :biff.datalevin/ref in the
schema entry.
The rest of this document covers the Biff 1.x compatibility API
(biff.datalevin.core and friends). It still works with Biff 1.x
applications.
This library is designed to work as a drop-in Datalevin component for Biff applications:
(ns myapp.core
(:require [com.biffweb :as biff]
[biff.datalevin.core :as dl]
[biff.datalevin.db :as db]))
;; Use with Biff's start-system
(def initial-system
{:biff.datalevin/db-path "data/myapp"
:biff.datalevin/schema my-schema
;; ... other Biff config
})
(def components
[dl/use-datalevin ;; Adds :biff.datalevin/conn and :biff/db
dl/use-session-cleanup
;; ... other Biff components
])
;; use-datalevin sets both:
;; :biff.datalevin/conn - The Datalevin connection
;; :biff/db - Database snapshot (Biff compatibility)
If you want a Biff-compatible context that carries :biff/db, you can attach it with assoc-db:
(let [ctx (db/assoc-db ctx)]
(db/lookup ctx :user/email "new@example.com"))
When both :biff.datalevin/conn and :biff/db are present, query helpers prefer
:biff.datalevin/conn.
(ns myapp.core
(:require [biff.datalevin.core :as core]
[biff.datalevin.db :as db]
[biff.datalevin.auth :as auth]
[biff.datalevin.middleware :as mw]))
;; Define your schema
(def schema
{:user/id {:db/valueType :db.type/uuid :db/unique :db.unique/identity}
:user/email {:db/valueType :db.type/string :db/unique :db.unique/identity}
:user/password-hash {:db/valueType :db.type/string}})
;; Start the system
(def system
(core/start-system
{:biff.datalevin/db-path "data/myapp"
:biff.datalevin/schema schema
:biff.datalevin.session/cleanup-interval-ms (* 60 60 1000)}
[core/use-datalevin
core/use-session-cleanup]))
;; Create a user
(let [user-tx (auth/create-user-tx {:user/email "user@example.com"
:password "secret123"})]
(db/submit-tx system [user-tx]))
;; Query users
(db/lookup system :user/email "user@example.com")
;; Stop the system
(core/stop-system system)
biff.datalevin.adapter)The Biff 2 adapter. See Biff 2 Integration above.
;; Create the module
(dl/module {:biff.datalevin/db-path "data/myapp"
:biff.datalevin/schema my-schema
:biff.datalevin/opts {...}}) ; passed to d/get-conn
;; Generate resolvers yourself (usually not needed)
(dl/make-resolvers my-schema)
;; Query and write
(dl/q system '[:find ?e :where [?e :user/email "a@b.com"]])
(dl/execute-tx system [{:user/id (random-uuid) :user/email "a@b.com"}])
biff.datalevin.core)System lifecycle management:
;; Start a system with components
(def system
(core/start-system
{:biff.datalevin/db-path "data/myapp"
:biff.datalevin/schema my-schema
:biff.datalevin.session/cleanup-interval-ms (* 60 60 1000)
:port 8080}
[core/use-datalevin
core/use-session-cleanup
my-custom-component]))
;; Stop the system (calls cleanup functions in reverse order)
(core/stop-system system)
;; Add cleanup functions to a component
(defn my-component [system]
(let [resource (create-resource)]
(-> system
(assoc :my-resource resource)
(core/assoc-stop #(close-resource resource)))))
biff.datalevin.db)Connection and query utilities:
;; Submit transactions with special values
(db/submit-tx system [{:user/id (java.util.UUID/randomUUID)
:user/email "new@example.com"
:user/created-at :db/now}]) ; :db/now -> current Date
;; Lookup single entity
(db/lookup system :user/email "user@example.com")
;; => {:user/id #uuid "...", :user/email "user@example.com", ...}
;; Lookup with custom pull expression
(db/lookup system :user/email "user@example.com" [:user/id :user/email])
;; Lookup all matching entities
(db/lookup-all system :user/role :admin)
;; Check existence
(db/entity-exists? system :user/email "user@example.com")
;; Run queries
(db/q '[:find ?e
:where [?e :user/role :admin]]
system)
;; Update entities
(db/submit-tx system [(db/merge-tx [:user/id user-id]
{:user/name "New Name"})])
;; Delete entities
(db/submit-tx system [(db/delete-tx [:user/id user-id])])
biff.datalevin.auth)Password and OAuth authentication:
;; Password hashing
(auth/hash-password "secret")
(auth/verify-password "secret" hash)
;; Create user with password
(let [user-tx (auth/create-user-tx {:user/email "user@example.com"
:user/username "myuser"
:password "secret123"})]
(db/submit-tx system [user-tx]))
;; create-user-tx rejects nil/blank passwords.
;; Stronger password policy should still be enforced at your request boundary.
;; Authenticate user
(auth/authenticate-user system "user@example.com" "secret123")
;; => {:user/id #uuid "...", :user/email "user@example.com", ...} or nil
;; GitHub OAuth
(auth/github-authorize-url
{:client-id "your-client-id"
:redirect-uri "http://localhost:8080/auth/github/callback"
:state "csrf-token"})
;; Exchange code for token
(let [token-response (auth/github-exchange-code
{:client-id "..."
:client-secret "..."
:code code
:redirect-uri "..."})]
(let [gh-user (auth/github-get-user (:access_token token-response))]
(when-not (auth/find-user-by-github-id system (:id gh-user))
(db/submit-tx system [(auth/github-create-user-tx gh-user)]))))
;; Email verification tokens
(let [{:keys [token tx]} (auth/create-verification-token user-id)]
(db/submit-tx system [tx])
;; Send token to user via email...
)
;; Verify token
(auth/verify-token system token)
;; => user-id or nil
biff.datalevin.session)Datalevin-backed session management:
;; Create a session
(let [{:keys [session-id tx]} (session/create-session user-id)]
(db/submit-tx system [tx])
session-id)
;; Get session with user data
(session/get-session system session-id)
;; => {:session/id ..., :session/user {:user/id ..., ...}, :session/expires-at ...}
;; Get just the user
(session/get-session-user system session-id)
;; Delete session
(when-let [delete-tx (session/delete-session-tx system session-id)]
(db/submit-tx system [delete-tx]))
;; Remove expired sessions immediately
(session/cleanup-expired-sessions! system)
;; => number of deleted sessions
;; JWT tokens for stateless auth
(def secret "your-32-byte-secret-key-here!!!")
(session/create-session-token session-id {:secret secret})
(session/verify-session-token token secret)
;; Ring session store
(require '[ring.middleware.session :refer [wrap-session]])
(-> handler
(wrap-session {:store (session/datalevin-session-store conn)}))
For automatic cleanup in long-running processes, add core/use-session-cleanup
after core/use-datalevin. It runs once at startup and then hourly by default.
(core/start-system
{:biff.datalevin/db-path "data/myapp"
:biff.datalevin/schema my-schema
:biff.datalevin.session/cleanup-interval-ms (* 60 60 1000)}
[core/use-datalevin
core/use-session-cleanup
my-app-component])
If you prefer external scheduling, call session/cleanup-expired-sessions!
from cron or a job runner.
biff.datalevin.middleware)Ring middleware stack:
;; Full site middleware (sessions, CSRF, auth)
(def handler
(-> my-routes
(mw/wrap-site-defaults
{:context {:biff.datalevin/conn conn}
:session-secret "your-32-byte-secret!!!!"
:csrf? true
:auth? true})))
;; API middleware (JWT auth, no CSRF)
(def api-handler
(-> my-api-routes
(mw/wrap-api-defaults
{:context {:biff.datalevin/conn conn}
:session-secret "your-32-byte-secret!!!!"})))
;; Require authentication
(-> handler
(mw/wrap-require-auth {:redirect "/login"}))
;; Require specific role
(-> handler
(mw/wrap-require-role {:role :admin :redirect "/forbidden"}))
;; CSRF token in forms
[:form {:method "post"}
(mw/csrf-input)
[:button "Submit"]]
Recommended schema for common entities:
(def schema
{;; Users
:user/id {:db/valueType :db.type/uuid :db/unique :db.unique/identity}
:user/email {:db/valueType :db.type/string :db/unique :db.unique/identity}
:user/username {:db/valueType :db.type/string :db/unique :db.unique/identity}
:user/password-hash {:db/valueType :db.type/string}
:user/github-id {:db/valueType :db.type/long :db/unique :db.unique/identity}
:user/github-username {:db/valueType :db.type/string}
:user/avatar-url {:db/valueType :db.type/string}
:user/role {:db/valueType :db.type/keyword}
:user/created-at {:db/valueType :db.type/instant}
;; Sessions
:session/id {:db/valueType :db.type/uuid :db/unique :db.unique/identity}
:session/user {:db/valueType :db.type/ref}
:session/expires-at {:db/valueType :db.type/instant}
;; Verification tokens
:verification-token/token {:db/valueType :db.type/string :db/unique :db.unique/identity}
:verification-token/user {:db/valueType :db.type/ref}
:verification-token/expires-at {:db/valueType :db.type/instant}})
This library is designed for Datalevin, which has some differences from Datomic/XTDB:
Entity creation: Don't use lookup refs as :db/id for new entities. Just include the unique attribute:
;; Correct
{:user/id (UUID/randomUUID) :user/email "user@example.com"}
;; Incorrect (won't work)
{:db/id [:user/id some-uuid] :user/email "user@example.com"}
Entity updates: Use lookup refs as :db/id for updating existing entities:
{:db/id [:user/id existing-uuid] :user/name "New Name"}
References: Lookup refs like [:user/id uuid] can be used for :db/valueType :db.type/ref attributes, but the referenced entity must exist first.
Retractions: Use entity IDs (numbers) for :db/retractEntity, not lookup refs. The helper functions handle this automatically.
clj -M:test
# Build JAR only
clj -T:build jar
# Clean target directory
clj -T:build clean
# Set credentials (use a deploy token from https://clojars.org/tokens)
export CLOJARS_USERNAME=your-username
export CLOJARS_PASSWORD=CLOJARS_xxxxxxxxx
# Build and deploy
clj -T:build deploy
MIT License
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |