{:deps {us.whitford/fulcro-rad-datalevin {:git/url "https://github.com/michaelwhitford/fulcro-rad-datalevin"
:git/sha "LATEST_SHA"}}}
A database adapter plugin for Fulcro RAD that provides support for Datalevin (1.0.0), an embedded Datalog database.
Automatic schema generation from RAD attributes
Save and delete middleware for RAD forms
Works with both Pathom 2 and Pathom 3 — the adapter has no hard dependency on either; you bring your own
Automatic resolver generation (id-resolvers, all-ids resolvers, full-text search resolvers, and vector similarity resolvers)
Full-text search: declare ::dlo/fulltext? on an attribute and get schema, search-domain config, and a relevance-ordered :<entity>/search resolver automatically
Vector similarity search: declare a :vec attribute and get an :<entity>/similar nearest-neighbor resolver automatically — pair with full-text for hybrid keyword + semantic search
Read-your-writes: a form save’s mutation returns the just-saved entity within the same request
Support for all RAD attribute types, including :enum and :vec (vector / HNSW index)
Native :db/id identity support (::dlo/native-id?)
Connection and database management utilities
Datalevin 1.0.0 capabilities surfaced through the adapter:
:conn-opts pass-through (:auto-entity-time?, :validate-data?, :closed-schema?, :wal?, :search-domains, …)
Database-side attribute predicates via native :db.attr/preds
Transaction post-conditions via :db/ensure (::dlo/raw-txn / append-to-raw-txn)
Schema verification (schema-problems / verify-schema!)
Per-transaction timeouts via with-transaction (::dlo/transaction-timeout-ms)
Production-ready error handling with detailed context (failures propagate; they are never swallowed)
Configurable batch-size safety limit
Input validation with helpful error messages
Add to your deps.edn:
{:deps {us.whitford/fulcro-rad-datalevin {:git/url "https://github.com/michaelwhitford/fulcro-rad-datalevin"
:git/sha "LATEST_SHA"}}}
Or use a local checkout:
{:deps {us.whitford/fulcro-rad-datalevin {:local/root "/path/to/fulcro-rad-datalevin"}}}
This library does not bundle Pathom. Add the Pathom version your app
uses to your own deps.edn — com.wsscode/pathom (Pathom 2) or
com.wsscode/pathom3 (Pathom 3). generate-resolvers returns Pathom-2-shape
resolver maps, which work directly in a Pathom 2 parser and are auto-converted
by RAD’s Pathom 3 processor (see 3. Configure Pathom Parser).
|
(ns com.example.model.account
(:require
[com.fulcrologic.rad.attributes :as attr :refer [defattr]]
[us.whitford.fulcro.rad.database-adapters.datalevin-options :as dlo]))
(defattr id :account/id :uuid
{::attr/schema :main
::attr/identity? true})
(defattr name :account/name :string
{::attr/schema :main
::attr/identities #{:account/id}
::attr/required? true})
(defattr email :account/email :string
{::attr/schema :main
::attr/identities #{:account/id}
;; Merge native Datalevin schema keys via ::dlo/attribute-schema
::dlo/attribute-schema {:db/unique :db.unique/value}})
;; Enum attributes
(defattr role :account/role :enum
{::attr/schema :main
::attr/identities #{:account/id}
::attr/enumerated-values #{:admin :user :guest}
::attr/enumerated-labels {:admin "Administrator"
:user "Regular User"
:guest "Guest User"}})
(defattr status :account/status :enum
{::attr/schema :main
::attr/identities #{:account/id}
;; Can use fully-qualified keywords
::attr/enumerated-values #{:status/active :status/inactive}})
(defattr permissions :account/permissions :enum
{::attr/schema :main
::attr/identities #{:account/id}
::attr/cardinality :many ;; Multiple values allowed
::attr/enumerated-values #{:read :write :execute}})
(def attributes [id name email role status permissions])
When querying enum attributes directly with d/pull, use pull patterns with :db/ident to get the keyword value:
(d/pull db [:account/id {:account/role [:db/ident]}] [:account/id id]) returns
{:account/id uuid :account/role {:db/ident :account.role/admin}}. The auto-generated
resolvers do this mapping for you and return the plain keyword (:account.role/admin).
|
(ns com.example.components.database
(:require
[us.whitford.fulcro.rad.database-adapters.datalevin :as dl]
[com.example.model.account :as account]))
(defonce connections (atom {}))
(defn start! []
(let [conn (dl/start-database!
{:path "/var/data/my-app"
:schema :main
:attributes account/attributes
;; optional: native Datalevin get-conn options (Datalevin 1.0.0)
:conn-opts {:auto-entity-time? true
:validate-data? true}})]
(swap! connections assoc :main conn)))
(defn stop! []
(doseq [[_ conn] @connections]
(dl/stop-database! conn))
(reset! connections {}))
The adapter works with both Pathom 2 and Pathom 3. generate-resolvers
returns Pathom-2-shape resolver maps (plain data, no Pathom dependency needed to
build them); they register directly in a Pathom 2 parser and are auto-converted
by RAD’s Pathom 3 processor.
Two pieces wire the adapter into the parser env, per Pathom version:
Pathom 2: dl/pathom-plugin — a ::p/wrap-parser plugin.
Pathom 3: dl/wrap-env — an (fn [env] env') you compose into the
processor’s env-middleware.
Both inject ::dlo/connections and the atom-backed ::dlo/databases snapshot
into the env (see Read-your-writes).
(ns com.example.components.parser
(:require
[com.fulcrologic.rad.attributes :as attr]
[com.fulcrologic.rad.form :as form]
[com.fulcrologic.rad.pathom :as pathom] ;; you provide com.wsscode/pathom
[us.whitford.fulcro.rad.database-adapters.datalevin :as dl]
[com.example.model.account :as account]
[com.example.components.database :as db]))
(def all-attributes
(vec (concat account/attributes
;; other model attributes
)))
(def parser
(pathom/new-parser {}
[(attr/pathom-plugin all-attributes)
(form/pathom-plugin (dl/wrap-datalevin-save) (dl/wrap-datalevin-delete))
;; dl/pathom-plugin takes a (fn [env]) returning the schema -> connection map
(dl/pathom-plugin (fn [_env] @db/connections))]
;; generate-resolvers returns Pathom-2-shape maps; add RAD's form mutations
[(dl/generate-resolvers all-attributes :main)
form/resolvers]))
;; Run a query: (parser {} [{[:account/id id] [:account/name]}])
(ns com.example.components.parser
(:require
[com.fulcrologic.rad.attributes :as attr]
[com.fulcrologic.rad.form :as form]
[com.fulcrologic.rad.pathom3 :as pathom3] ;; you provide com.wsscode/pathom3
[us.whitford.fulcro.rad.database-adapters.datalevin :as dl]
[com.example.model.account :as account]
[com.example.components.database :as db]))
(def all-attributes (vec (concat account/attributes)))
(def processor
(pathom3/new-processor {}
;; env-middleware: compose attr, form, and the adapter's wrap-env
(-> (attr/wrap-env all-attributes)
(form/wrap-env (dl/wrap-datalevin-save) (dl/wrap-datalevin-delete))
(dl/wrap-env (fn [_env] @db/connections)))
[] ;; extra Pathom 3 plugins
;; new-processor auto-converts the Pathom-2-shape resolvers for you
[(dl/generate-resolvers all-attributes :main)
form/resolvers]))
;; Run a query: (processor {} [{[:account/id id] [:account/name]}])
If you build a Pathom 3 index yourself (pci/register) instead of using
new-processor, convert the resolvers first — either call
dl/generate-resolvers-pathom3 (native Pathom 3 records) or run RAD’s
com.fulcrologic.rad.pathom3/convert-resolvers on generate-resolvers output.
|
(ns com.example.components.middleware
(:require
[us.whitford.fulcro.rad.database-adapters.datalevin :as dl]
[com.fulcrologic.rad.middleware.save-middleware :as save-mw]))
;; Middleware wraps a base handler. Use the 1-arity form to compose,
;; or the 0-arity form as a terminal handler.
(def save-middleware
(-> (dl/wrap-datalevin-save) ;; terminal
(save-mw/wrap-rewrite-values)))
(def delete-middleware
(dl/wrap-datalevin-delete)) ;; terminal
;; Composing over your own handler:
(def save-middleware-composed
(dl/wrap-datalevin-save my-base-save-handler))
Schema is determined from each attribute’s ::attr/schema; the middleware does
not take an options map.
automatic-schemaGenerate a Datalevin schema (map-of-maps) from RAD attributes
(dl/automatic-schema :main account/attributes)
;; => {:account/id {:db/valueType :db.type/uuid
;; :db/unique :db.unique/identity}
;; :account/name {:db/valueType :db.type/string}
;; :account/email {:db/valueType :db.type/string
;; :db/unique :db.unique/value}}
ensure-schema!Ensure the connection’s schema matches the RAD-derived schema (updates if needed)
(dl/ensure-schema! conn schema-map)
schema-problemsCompare the RAD-derived expected schema against a connection’s live schema; returns a seq of :missing / :mismatch problem maps for adapter-managed keys (:db/valueType, :db/cardinality, :db/unique).
(dl/schema-problems conn :main account/attributes)
;; => () when the live schema satisfies the expected schema
verify-schema!Like schema-problems, but throws ex-info when problems exist and returns true otherwise.
(dl/verify-schema! conn :main account/attributes)
start-database!Start a database connection with automatic schema. Config keys: :path, :schema, :attributes, :auto-schema? (default true), :conn-opts (optional native get-conn options; merged with adapter-derived :vector-domains).
(dl/start-database!
{:path "/var/data/mydb"
:schema :main
:attributes all-attributes
:auto-schema? true
:conn-opts {:auto-entity-time? true
:closed-schema? true}})
start-databasesStart several schemas at once. Takes a config holding ::dlo/databases (schema → per-database config) and an options map carrying the shared :attributes.
(dl/start-databases
{::dlo/databases {:main {:path "/var/data/main"
:conn-opts {:auto-entity-time? true}}
:reports {:path "/var/data/reports"}}}
{:attributes all-attributes})
;; => {:main conn :reports conn}
stop-database! / stop-databasesClose connection(s)
(dl/stop-database! conn)
(dl/stop-databases connections-map)
seed-database!Seed a database with initial data (raw transaction)
(dl/seed-database! conn [{:account/id id :account/name "Alice"}])
Temporary/throwaway databases for tests are provided by the test-only
helpers with-test-conn / with-test-conn-attrs and mock-resolver-env in
…database-adapters.test-utils (test source), not the main public API.
|
wrap-datalevin-saveForm save middleware that transacts deltas to Datalevin. Arities: ([]) terminal, ([handler]) composing.
(def save-middleware (dl/wrap-datalevin-save)) ;; terminal
(def save-middleware (dl/wrap-datalevin-save handler)) ;; compose
wrap-datalevin-deleteForm delete middleware. Arities: ([]) terminal, ([handler]) composing.
(def delete-middleware (dl/wrap-datalevin-delete))
append-to-raw-txnFrom your own save middleware, append native Datalevin transaction forms (e.g. [:db/ensure …] post-conditions) to a save.
(dl/append-to-raw-txn env [[:db/ensure `my.app.rules/balance-non-negative? [:account/id id]]])
generate-resolversGenerate resolvers from attributes for a schema (schema arg required). Produces an id-resolver and an all-ids resolver per identity. Returns Pathom-2-shape resolver maps (plain data keyed by :com.wsscode.pathom.connect/{sym,input,output,batch?,resolve}) — these register directly in a Pathom 2 parser and are auto-converted by RAD’s Pathom 3 new-processor.
(def resolvers (dl/generate-resolvers all-attributes :main))
generate-resolvers-pathom3Like generate-resolvers, but returns native Pathom 3 resolver records. Use only when you build a Pathom 3 index directly (e.g. pci/register) rather than via new-processor. Pathom 3 is resolved lazily at call time, so it stays optional.
(def resolvers (dl/generate-resolvers-pathom3 all-attributes :main))
get-by-idsFetch multiple entities by their identity attribute values
(dl/get-by-ids db :account/id [id1 id2] [:account/name :account/email])
;; => {id1 {:account/name "Alice" ...} id2 {...}}
Native-id helpers: native-id?, pathom-query→datalevin-query,
datalevin-result→pathom-result support attributes that map onto Datalevin’s
internal :db/id (see ::dlo/native-id?).
The adapter is Pathom-version-agnostic. Use pathom-plugin for Pathom 2 and
wrap-env for Pathom 3; both inject ::dlo/connections and the atom-backed
::dlo/databases snapshot into the env. See 3. Configure Pathom Parser for full wiring.
pathom-pluginCreate a Pathom 2 plugin (a :com.wsscode.pathom.core/wrap-parser map) that injects ::dlo/connections and ::dlo/databases into the parse env. Pass a (fn [env]) returning the schema → connection map.
(dl/pathom-plugin (fn [_env] @connections))
wrap-envThe Pathom 3 integration point: an (fn [env] env') that injects ::dlo/connections and ::dlo/databases. Compose it into the env-middleware you pass to RAD’s new-processor (it also underlies the Pathom 2 pathom-plugin).
(-> (attr/wrap-env all-attributes)
(form/wrap-env save-mw delete-mw)
(dl/wrap-env (fn [_env] @connections)))
::dlo/databases holds {schema → atom<db>} — an atom per schema, seeded by
pathom-plugin / wrap-env at the start of each request. After a successful
save or delete, the middleware publishes the transaction report’s :db-after
into the relevant atom, so resolvers that run later in the same Pathom request
see the write. In practice this means a form/save-form mutation returns the
just-saved (or updated) entity, not only its :tempids — matching the Datomic
and XTDB adapters. This uses Datalevin’s native :db-after for read-your-writes
plus request-scoped snapshot consistency. Resolvers deref the snapshot leniently,
so a bare db value in a hand-built env still works.
qExecute Datalog queries
(dl/q '[:find ?e ?name
:where [?e :account/name ?name]]
db)
pullPull entity data
(dl/pull db [:account/name :account/email] eid)
pull-manyPull multiple entities
(dl/pull-many db [:account/name] eids)
Lower-level helpers used by the save middleware, re-exported for advanced use:
delta→txn, keys-in-delta, schemas-for-delta, save-form!.
The datalevin-options namespace (::dlo/*) provides configuration keys:
(require '[us.whitford.fulcro.rad.database-adapters.datalevin-options :as dlo])
;; Environment keys (injected by pathom-plugin (Pathom 2) / wrap-env (Pathom 3))
::dlo/connections ; Map of schema -> connection
::dlo/databases ; Map of schema -> atom<db> (per-request snapshot; save/delete
; publish :db-after here for read-your-writes)
;; Attribute-level options
::dlo/attribute-schema ; Merge/override native Datalevin schema keys
; (e.g. :db/unique, :db.attr/preds)
::dlo/native-id? ; Use Datalevin's internal :db/id (attribute must be :long)
::dlo/generate-resolvers? ; If false, skip automatic resolver generation
::dlo/wrap-resolve ; (fn [resolve]) => (fn [env input]) wrapping an id-resolver
::dlo/schema ; Schema name for this attribute
;; Save-env keys
::dlo/transact-options ; Passed as tx-meta (3rd arg) to transact!
::dlo/raw-txn ; Vector of native txn forms to append (e.g. :db/ensure)
::dlo/transaction-timeout-ms ; Per-txn timeout via with-transaction (Datalevin 1.0.0)
;; Resolver-env key
::dlo/max-batch-size ; Max entities per batch query (default: 1000)
Attribute predicates (:db.attr/preds) enforce database-side value validation.
The predicate must be a qualified symbol (the schema is persisted and resolved
via requiring-resolve), is invoked as (pred value), and must return strictly
true — any other result aborts the write with a :transact/attr-pred error:
;; my.app.validation
(defn valid-email? [v] (boolean (re-matches #".+@.+\..+" v)))
(defattr email :account/email :string
{::attr/identity? true
::dlo/attribute-schema {:db.attr/preds 'my.app.validation/valid-email?}})
For transaction-level assertions (spanning attributes/entities), append a
[:db/ensure pred & args] form via ::dlo/raw-txn / append-to-raw-txn. The
predicate runs against db-after and aborts the transaction on any falsey
result.
:vec) and Similarity SearchRAD attributes of type :vec map to :db.type/vec and build a Datalevin
HNSW vector index. The index configuration (:dimensions, :metric-type)
lives on the connection, not the schema; the adapter derives it automatically
and passes :vector-domains to d/get-conn. Put :db.vec/dimensions (and
optionally :db.vec/metric-type) in the attribute’s ::dlo/attribute-schema
and the adapter strips them from the schema and moves them to the connection
options:
(defattr embedding :account/embedding :vec
{ao/identities #{:account/id}
::dlo/attribute-schema {:db.vec/dimensions 768
:db.vec/metric-type :cosine}})
Every entity type with at least one :vec attribute automatically gets an
:<entity>/similar resolver — parameterized nearest-neighbor search returning
idents in similarity order (nearest first), with columns filled by the
batched id-resolver:
;; EQL (what a load/report sends):
[{(:account/similar {:vector query-embedding :top 10}) [:account/id :account/name]}]
;; => {:account/similar [{:account/id #uuid "..." :account/name "..."} ...]}
Parameters (read from (:query-params env)):
| Param | Meaning |
|---|---|
| Required. The query embedding — a sequence of numbers matching the
attribute’s |
| Optional qualified keyword narrowing the search to one |
| Cap on neighbors returned (Datalevin default 10). |
Vector indexing is synchronous — saved entities are immediately searchable. Native-id entities are supported (the matched eid is the id).
Hybrid search: combine :<entity>/search (keyword relevance) and
:<entity>/similar (semantic similarity) over the same entities — issue both
loads and merge/re-rank client-side, or use two report sections.
::dlo/fulltext?)Mark any attribute searchable and the adapter wires the whole feature — schema, connection configuration, and a generated, parameterized search resolver:
(defattr name :account/name :string
{ao/identities #{:account/id}
::dlo/fulltext? true})
;; Phrase/proximity search needs positional indexing (opt-in — costs storage):
(defattr bio :account/bio :string
{ao/identities #{:account/id}
::dlo/fulltext? {:index-position? true}})
This produces:
Schema — :db/fulltext true plus a derived :db.fulltext/domains
["account"]: one shared search domain per entity type (named after the
attribute namespace), so all searchable attributes of an entity are searched
together. Hand-written :db.fulltext/domains / :db.fulltext/autoDomain in
::dlo/attribute-schema are respected and suppress derivation.
Connection — map-valued options (like :index-position?) flow into the
:search-domains option passed to d/get-conn by start-database!.
Resolver — generate-resolvers emits an :account/search resolver
returning idents in relevance order (descending score); columns are filled
by the existing batched id-resolver:
;; EQL (what a load/report sends):
[{(:account/search {:query "fox" :top 20}) [:account/id :account/name]}]
;; => {:account/search [{:account/id #uuid "..." :account/name "Fox Fox LLP"} ...]}
Parameters (read from (:query-params env), which both RAD parsers populate
from the load’s {:params …}):
| Param | Meaning |
|---|---|
| Required. A string, a boolean expression vector such as
|
| Cap on engine results (Datalevin default 10). |
| Pagination within the result window. |
Wiring a RAD report is just a source attribute and a control — the control’s
value travels as the :query param:
(report/defsc-report AccountSearchList [this props]
{ro/source-attribute :account/search
ro/row-pk account/id
ro/columns [account/name account/email]
ro/run-on-mount? false
ro/controls {:query {:type :string
:label "Search"
:onChange (fn [this _] (control/run! this))}}})
Notes:
Indexing is synchronous — saved entities are immediately searchable (read-your-writes).
Native-id entities are supported: the matched entity id is returned directly.
Datalevin stores only references to the source text (no double storage).
RAD types are automatically mapped to Datalevin types:
| RAD Type | Datalevin Type |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Numeric values are coerced to the declared RAD type on save (fix-numerics):
:int/:long → long, :double/:float → double, :bigdec → bigdec.
This handles JavaScript clients that transmit integers where doubles are
expected (and vice versa), avoiding :db.type mismatch errors.
The adapter provides structured error handling with detailed context. Save and
delete transaction failures are always propagated (never swallowed) so
attribute-predicate, :db/ensure, and other transaction errors reach the
caller/client.
When a database connection is not configured for a schema, an ex-info exception is thrown with:
:schema - the requested schema key
:available-schemas - vector of configured schema keys
Failed save/delete transactions throw ex-info with context, e.g.:
save: :schema, :txn-data
delete: :ident, :schema
Invalid delta structures are rejected with descriptive errors:
Delta must be a map
Each entry must have [id-attr id] as key
Each change must have :before and :after keys
Batch queries exceeding the max batch size (default 1000, override with
::dlo/max-batch-size in the resolver env) throw with :requested,
:maximum, and :id-attr context.
Key differences from fulcro-rad-datomic:
Schema format: Datalevin uses a map-of-maps schema format, not transaction vectors
No temporal database: Datalevin doesn’t maintain history by default
Embedded database: No separate transactor process needed
Connection management: Uses get-conn instead of connect
Schema updates: Schema passed at connection time, updated via update-schema
Vector search: Native :db.type/vec / HNSW index via :vector-domains
Namespace: Uses us.whitford.fulcro.rad.database-adapters instead of com.fulcrologic.rad.database-adapters
clj-kondo --lint src/main src/test
clojure -M:outdated
Builds use tools.build (see build.clj).
The version defaults to the value in build.clj; set the VERSION env var to
override (CI derives it from the pushed git tag).
clojure -T:build jar # write pom + build thin jar into target/
clojure -T:build install # install to local ~/.m2
clojure -X:deploy # deploy target/ jar to Clojars (separate step;
# needs CLOJARS_USERNAME + CLOJARS_PASSWORD)
Releases are automated: pushing a v1.2.3 (full release) or v1.2.3-RC1
(release candidate) tag triggers the GitHub Actions release workflow, which
runs the tests, deploys to Clojars, and creates a GitHub Release. -alpha /
-beta version suffixes are for local builds only and never trigger a deploy.
Symptoms: Form appears to save but immediately reverts, or changes don’t persist to database.
Common Causes:
Middleware not properly configured - Ensure the save/delete middleware is in your form handler chain:
(def save-middleware
(-> (dl/wrap-datalevin-save)
(save-mw/wrap-rewrite-values)))
Missing database connection - Verify the Pathom env has ::dlo/connections. This is injected from the (fn [env]) you provide to dl/pathom-plugin (Pathom 2) or dl/wrap-env (Pathom 3).
Schema mismatch - Ensure each attribute’s ::attr/schema matches a key in your connections map:
;; Attribute defines :main
(defattr id :account/id :uuid {::attr/schema :main})
;; Connection must use :main
(swap! connections assoc :main conn)
Debug Steps:
Run the test suite to verify the adapter works: clojure -M:run-tests
If tests pass, the issue is in your app configuration
tap> the middleware result and confirm it includes a :tempids key
Check server logs for connection or transaction errors (they are no longer swallowed)
Cause: Your attributes reference a schema that doesn’t have a connection.
Solution: Add the missing connection so every ::attr/schema used has a matching key in the connections map.
Cause: Trying to fetch more than the max batch size (default 1000) in a single query.
Solution: Paginate, or raise the limit via the resolver env:
{::dlo/max-batch-size 5000}
Copyright (c) Michael Whitford
Distributed under the MIT License.
Can you improve this documentation? These fine people already did:
Michael Whitford & ClaudeEdit 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 |