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 2 database adapter - A
biff.coremodule with KV store, snapshots,on-tx, biff.fx handlers, and biff.graph resolvers - System lifecycle management - Simple map-based component system inspired by Biff
- Database utilities - Connection management, transaction helpers, and query utilities
- Authentication - Password hashing with bcrypt, OAuth support (GitHub and generic providers)
- Session management - Datalevin-backed sessions with Ring session store
- Middleware - Authentication, CSRF protection, and request handling
Add to your deps.edn:
{:deps {io.github.datalevin/biff-datalevin {:mvn/version "0.3.18"}}}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 asbiff.authenticate:biff.core/wrap-db-snapshot- runs eachbiff.graph/queryin 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? falseto disable it and get concurrent reads instead:biff.datalevin/conn- the Datalevin connection:biff.core/on-txis called after every transaction- biff.fx handlers
:biff.datalevin.fx/qand:biff.datalevin.fx/execute-tx - one biff.graph resolver per entity type (grouped by attribute namespace), with ref attributes returned as joins
Reads 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)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"}])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)))))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])])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 nilDatalevin-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.
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/idfor 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/idfor 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/refattributes, 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 deployMIT License