Skip to content

About

Use Datalevin in Biff Web framework

Resources

Stars

4 stars

Watchers

1 watching

Forks

Repository files navigation

biff-datalevin

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.

Features

  • Biff 2 database adapter - A biff.core module 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

Installation

Add to your deps.edn:

{:deps {io.github.datalevin/biff-datalevin {:mvn/version "0.3.18"}}}

Biff 2 Integration

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.fx handlers :biff.datalevin.fx/q and :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.

Biff 1 Compatibility

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.

Quick Start

(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)

Modules

Biff 2 Adapter (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"}])

Core (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)))))

Database (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])])

Authentication (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

Sessions (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.

Middleware (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"]]

Schema Reference

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}})

Important Notes

Datalevin vs. Datomic/XTDB

This library is designed for Datalevin, which has some differences from Datomic/XTDB:

  1. 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"}
  2. Entity updates: Use lookup refs as :db/id for updating existing entities:

    {:db/id [:user/id existing-uuid] :user/name "New Name"}
  3. References: Lookup refs like [:user/id uuid] can be used for :db/valueType :db.type/ref attributes, but the referenced entity must exist first.

  4. Retractions: Use entity IDs (numbers) for :db/retractEntity, not lookup refs. The helper functions handle this automatically.

Development

Testing

clj -M:test

Building

# Build JAR only
clj -T:build jar

# Clean target directory
clj -T:build clean

Deploying to Clojars

# 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

License

MIT License

About

Use Datalevin in Biff Web framework

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages