⚠️ Alpha Software - Work in Progress
This project is in early development and rapidly evolving. Expect breaking changes, rough edges, and incomplete documentation.
Help Wanted! If you find this useful, please consider contributing:
- Report bugs and issues you encounter
- Suggest improvements or new features
- Submit pull requests for fixes or enhancements
- Share your configuration patterns and workflows
- Help improve documentation and examples
Your feedback and contributions will help make this tool better for the entire Clojure community!
xitdb-clj is a embedded database for efficiently storing and retrieving immutable, persistent data structures.
The library provides atom-like semantics for working with the database from Clojure.
It is a Clojure interface for xitdb-java, itself a port of xitdb, written in Zig.
swap!) efficiently creates a new "copy" of the database, and past copies can still be read from.Add the dependency to your project, start a REPL.
For the programmer, a xitdb database is like a Clojure atom.
reset! or swap! to reset or update, deref or @ to read.
(require '[xitdb.db :as xdb])
(def db (xdb/xit-db "my-app.db"))
;; Use it like an atom
(reset! db {:users {"alice" {:name "Alice" :age 30}
"bob" {:name "Bob" :age 25}}})
;; Read the entire database
(xdb/materialize @db)
;; => {:users {"alice" {:name "Alice", :age 30}, "bob" {:name "Bob", :age 25}}}
(get-in @db [:users "alice" :age])
;; => 30
(swap! db assoc-in [:users "alice" :age] 31)
(get-in @db [:users "alice" :age])
;; => 31
Reading from the database returns wrappers around cursors in the database file:
(type @db) ;; => xitdb.hash_map.XITDBHashMap
The returned value is a XITDBHashMap which is a wrapper around the xitdb-java's ReadHashMap,
which itself has a cursor to the tree node in the database file.
These wrappers implement the protocols for Clojure collections - vectors, lists, maps and sets,
so they behave exactly like the Clojure native data structures.
Any read operation on these types is going to return new XITDB types:
(type (get-in @db [:users "alice"])) ;; => xitdb.hash_map.XITDBHashMap
So it will not read the entire nested structure into memory, but return a 'cursor' type, which you can operate upon using Clojure functions.
Use materialize to convert a nested XITDB data structure to a native Clojure data structure:
(xdb/materialize (get-in @db [:users "alice"])) ;; => {:name "Alice" :age 31}
Use filter, group-by, reduce, etc.
If you want a query engine, datascript works out of the box, you can store the datoms as a vector in the db.
Here's a taste of how your queries could look like:
(defn titles-of-songs-for-artist
[db artist]
(->> (get-in db [:songs-indices :artist artist])
(map #(get-in db [:songs % :title]))))
(defn what-is-the-most-viewed-song? [db tag]
(let [views (->> (get-in db [:songs-indices :tag tag])
(map (:songs db))
(map (juxt :id :views))
(sort-by #(parse-long (second %))))]
(get-in db [:songs (first (last views))])))
In addition to (unordered) hash maps and sets, xitdb supports on-disk sorted
maps and sets, backed by the engine's rank-augmented B-tree. Store a Clojure
sorted-map / sorted-set and it is persisted as a sorted collection that keeps
its keys/members ordered on disk:
(reset! db (sorted-map "banana" 2 "apple" 1 "cherry" 3))
@db
;; => #XITDBSortedMap{"apple" 1, "banana" 2, "cherry" 3}
(swap! db assoc "date" 4) ;; inserted in order, not appended
Reading back yields an XITDBSortedMap / XITDBSortedSet that implements
Clojure's ordered interfaces, so seq, rseq, nth, subseq and rsubseq
all work and read only what they touch from disk:
(reset! db (into (sorted-map) (map vector (range 0 100 2) (range))))
(nth @db 10) ;; => [20 10] ;; O(log n), no full scan
(subseq @db >= 90) ;; => ([90 45] [92 46] [94 47] [96 48] [98 49])
(rseq @db) ;; => lazy descending seq of entries
Supported key/member types are strings, keywords, booleans, chars, longs,
doubles, UUID, Instant and Date. They are stored with an order-preserving
codec, so they iterate in natural order — numeric for numbers, chronological
for temporals, lexicographic (by code point) for strings, UUID.compareTo
order for UUIDs. Longs and doubles share a single numeric ordering, so they
interleave by value (e.g. 1 < 1.5 < 2). Only the default ordering is
supported: sorted-map-by / sorted-set-by with a custom comparator is
rejected, as are java.util.Date subclasses like java.sql.Timestamp (use
Instant instead).
The xitdb.sorted namespace exposes the B-tree's O(log n) superpowers, which
are handy for building and paging on-disk secondary indexes:
(rank coll k) — number of entries strictly less than k (i.e. the index of
k, or its would-be insertion index if absent).(from-index coll n) — lazy ordered seq starting at rank n.(page coll offset limit) — lazy ordered page [offset, offset+limit).(require '[xitdb.sorted :as xsorted])
;; build a timestamp -> id index; events can arrive out of order
(reset! db (sorted-map))
(doseq [e events]
(swap! db assoc (:ts e) (:id e)))
(xsorted/rank @db some-ts) ;; chronological position of some-ts
(xsorted/page @db 100 20) ;; the 20 entries at ranks [100, 120)
Since the database is immutable, all previous values are accessed by reading
from the respective history index.
The root data structure of a xitdb database is a ArrayList, called 'history'.
Each transaction adds a new entry into this array, which points to the latest value
of the database (usually a map).
(xdb/deref-at db -1) ;; the most recent value, same as @db
(xdb/deref-at db -2) ;; the second most recent value
(xdb/deref-at db 0) ;; the earliest value
(xdb/deref-at db 1) ;; the second value
You can get the latest history index from the count of the database:
(def history-index (dec (count db)))
After making further transactions, you can revert back to it simply like this:
(reset! db (xdb/deref-at db history-index))
It is also possible to create a transaction which returns the previous and current
values of the database, by setting the *return-history?* binding to true.
;; Work with history tracking
(binding [xdb/*return-history?* true]
(let [[history-index old-value new-value] (swap! db assoc :new-key "value")]
(println "old value:" old-value)
(println "new value:" new-value)))
One important distinction from the Clojure atom is that inside a transaction (eg. a swap!), the data is temporarily mutable. This is exactly like Clojure's transients, and it is a very important optimization. However, this can lead to a surprising behavior:
(swap! db (fn [moment]
(let [moment (assoc moment :fruits ["apple" "pear" "grape"])
moment (assoc moment :food (:fruits moment))
moment (update moment :food conj "eggs" "rice" "fish")]
moment)))
;; =>
{:fruits ["apple" "pear" "grape" "eggs" "rice" "fish"]
:food ["apple" "pear" "grape" "eggs" "rice" "fish"]}
;; the fruits vector was mutated!
If you want to prevent data from being mutated within a transaction, you must freeze! it:
(swap! db (fn [moment]
(let [moment (assoc moment :fruits ["apple" "pear" "grape"])
moment (assoc moment :food (xdb/freeze! (:fruits moment)))
moment (update moment :food conj "eggs" "rice" "fish")]
moment)))
;; =>
{:fruits ["apple" "pear" "grape"]
:food ["apple" "pear" "grape" "eggs" "rice" "fish"]}
Note that this is not doing an expensive copy of the fruits vector. We are benefitting from structural sharing, just like in-memory Clojure data. The reason we have to freeze! is because the default is different than Clojure; in Clojure, you must opt-in to temporary mutability by using transients, whereas in xitdb you must opt-out of it.
xitdb-clj builds on xitdb-java which implements:
The Clojure wrapper adds:
IAtom, IDeref)subseq/nth/rankMIT
Can you improve this documentation? These fine people already did:
Florin Braghis, radar roark, Claude & radarroarkEdit 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 |