Unlike traditional embedded database wrappers that eagerly deserialize on-disk records into BEAM heap maps on every read, Xitdb exposes database nodes as Clojure/Datomic-style lazy %Xitdb.Cursor{} handles.
Each %Xitdb.Cursor{} wraps only the NIF database resource reference (db_ref) and a 9-byte Slot coordinate (tag, tag_id, value, moment). Navigating or slicing a cursor resolves only the requested path steps across the NIF boundary without touching or copying sibling subtrees.
1. The 9-Byte Slot Cursor Model & Process Safety
When you read from a %Xitdb{} handle (Xitdb.db(db), Xitdb.as_of(db, moment), or db["users"]):
- Containers return
%Xitdb.Cursor{}in $O(1)$ heap space: If the target slot is a collection (:hash_map,:counted_hash_map,:sorted_map,:array_list,:linked_array_list,:hash_set,:counted_hash_set,:sorted_set),Xitdbreturns a lightweight%Xitdb.Cursor{}struct pointing to that collection's byte offset. - Leaf scalars unwrap automatically:
If the target slot is a scalar (
String.t(),integer(),float(),boolean(), or{:tagged, tag, payload}),Xitdbimmediately decodes and returns the native Elixir value. - Pure Elixir values across BEAM processes:
Because
%Xitdb.Cursor{}holds only anErlNifResourceTypereference (db_ref) and integer slot coordinates—withErlNifRWLockreader/writer synchronization inside the NIF—cursors are pure, immutable Elixir values. You can pass them freely acrossTask.async_stream/3, GenServers,:etstables, and Phoenix LiveView assigns without locks or lifetime hazards:
{:ok, db} = Xitdb.open(backend: :memory)
{:ok, db} =
Xitdb.transact(db, %{
"users" => %{
"alice" => %{"name" => "Alice Liddell", "age" => 30, "roles" => ["admin", "staff"]},
"bob" => %{"name" => "Bob Builder", "age" => 28, "roles" => ["member"]}
}
})
# Grab a lazy cursor to the "users" Hash_Map (O(1), 0 map keys copied)
users_cursor = db["users"]
# Fan out concurrent reads across BEAM schedulers using the same cursor value
names =
["alice", "bob"]
|> Task.async_stream(fn id -> users_cursor[id]["name"] end)
|> Enum.map(fn {:ok, name} -> name end)
# => ["Alice Liddell", "Bob Builder"]2. Reading with Access (cursor[key], get_in/2, Xitdb.at/1)
Both %Xitdb{} and %Xitdb.Cursor{} implement Elixir's @behaviour Access:
root = Xitdb.db(db)
# 1. String and atom keys on maps (Hash_Map, Counted_Hash_Map, Sorted_Map)
root["users"]["alice"]["name"] # => "Alice Liddell"
root[:users][:alice][:age] # => 30
root["users"]["missing"] # => nil
# 2. Positive and negative integer indices on lists (Array_List, Linked_Array_List)
root["users"]["alice"]["roles"][0] # => "admin"
root["users"]["alice"]["roles"][-1] # => "staff"
root["users"]["alice"]["roles"][99] # => nil
# 3. Deep navigation with Kernel.get_in/2
get_in(root, ["users", "alice", "roles", 0])
# => "admin"Using Xitdb.at/1 Instead of Access.at/1
Elixir's built-in Access.at/1 function guards when is_list(data) and raises FunctionClauseError when given a %Xitdb.Cursor{} struct. When building functional lens pipelines with get_in/2 or update_in/3, either pass raw integer indices in the path list or use Xitdb.at/1:
# Direct integer key in path list:
get_in(root, ["users", "alice", "roles", 1])
# => "staff"
# Or using the Xitdb.at/1 lens function:
get_in(root, ["users", "alice", "roles", Xitdb.at(1)])
# => "staff"3. Transactional Access Mutations (put_in/3, update_in/3, pop_in/2)
When you pass a 1-arity callback to Xitdb.transact/3, the callback receives a root %Xitdb.Cursor{} with tx_mode: true.
In tx_mode: true:
put_in/3,update_in/3,pop_in/2, andget_and_update_in/3stage coarse-grained transaction operations on the root cursor.- Read-your-own-writes: Staged puts (
staged_overrides) and removals (deleted_paths) are tracked in memory on the cursor so subsequentcursor[key]orupdate_in/3calls in the same callback see your uncommitted edits immediately. - Native list and subtree helpers (
Xitdb.insert_at/4,Xitdb.delete_at/3,Xitdb.fork/3) pipe directly on the root cursor.
{:ok, db} =
Xitdb.transact(
db,
fn root ->
# 1. Remove a field with pop_in/2
{_removed, root} = pop_in(root["users"]["alice"]["temp_token"])
# 2. Pipe standard Access mutations and native collection operations
root
|> put_in(["users", "alice", "role"], "principal")
|> update_in(["users", "alice", "age"], &(&1 + 1))
|> Xitdb.insert_at(["users", "alice", "roles"], 0, "owner")
|> Xitdb.fork(".users.alice", ".backups.alice")
end,
doc: "Promote Alice via Access pipeline"
)To abort and roll back from inside a Xitdb.transact/3 callback, return {:abort, reason}:
{:error, %Xitdb.Error{reason: :insufficient_balance}} =
Xitdb.transact(db, fn root ->
if root["vault"]["balance"] < 500 do
{:abort, :insufficient_balance}
else
update_in(root["vault"]["balance"], &(&1 - 500))
end
end)4. Enumerable Fast-Path Pushdowns & Stream Pagination
%Xitdb.Cursor{} implements Enumerable with native data-structure pushdowns so common Enum operations execute in $O(1)$ or $O(\log N)$ inside Odin rather than linearly scanning elements in Elixir:
Enum / Stream Operation | Backing NIF Pushdown | Complexity | Supported Collection Kinds |
|---|---|---|---|
Enum.count(cursor) | nif_slot_count | $O(1)$ | Counted_Hash_Map, Counted_Hash_Set, Sorted_Map, Sorted_Set, Array_List, Linked_Array_List (falls back to $O(N)$ reducer on uncounted Hash_Map / Hash_Set) |
Enum.member?(set_cur, "val") | nif_slot_contains | $O(1)$ / $O(\log N)$ | Hash_Set, Counted_Hash_Set ($O(\log_{16} N)$ HAMT), Sorted_Set ($O(\log N)$ B+Tree) |
Enum.member?(map_cur, {k, v}) | Cursor.fetch + equality | $O(1)$ / $O(\log N)$ | Hash_Map, Counted_Hash_Map, Sorted_Map |
Enum.slice(cursor, first..last)<br>Enum.at(cursor, idx) | nif_slot_slice | $O(\log N + K)$ | Array_List, Linked_Array_List, Sorted_Map (yields {k, v} tuples in key order), Sorted_Set (yields members in key order) |
Stream.take(cursor, k)<br>Enum.to_list(cursor) | nif_slot_entries | Adaptive chunks (16 -> 64 -> 256) | All 8 collection types |
Examples
{:ok, db} =
Xitdb.transact(db, %{
"metrics" => Xitdb.counted_map(%{"cpu" => 42, "mem" => 78, "disk" => 19}),
"leaderboard" => Xitdb.sorted_map(%{" rank:1" => "alice", " rank:2" => "bob", " rank:3" => "carol"}),
"permissions" => Xitdb.counted_set(["read", "write", "deploy"]),
"events" => Xitdb.linked_list(["e1", "e2", "e3", "e4", "e5"])
})
root = Xitdb.db(db)
# 1. O(1) count header lookup
Enum.count(root["metrics"]) # => 3
Enum.count(root["permissions"]) # => 3
# 2. O(1) HAMT membership check (does NOT convert the set or map to a list)
Enum.member?(root["permissions"], "deploy") # => true
Enum.member?(root["metrics"], {"cpu", 42}) # => true
# 3. O(log N + K) index-seek slicing on Linked_Array_List and Sorted_Map
Enum.slice(root["events"], 1..3)
# => ["e2", "e3", "e4"]
Enum.slice(root["leaderboard"], 0..1)
# => [{" rank:1", "alice"}, {" rank:2", "bob"}]
# 4. Lazy streaming with adaptive chunking (fetches only the first 16-item page)
root["leaderboard"]
|> Stream.map(fn {_rank, user} -> String.upcase(user) end)
|> Enum.take(2)
# => ["ALICE", "BOB"]5. When to Use Access vs. the Macro DSL (use Xitdb.DSL)
| Use Case | Recommended API | Why |
|---|---|---|
| Interactive exploration, passing subtrees to functions/LiveViews, or shallow reads | Lazy Access (db["users"][id]) | Returns composable %Xitdb.Cursor{} values that work with standard Elixir Enum, Stream, and Access functions. |
| Reading a known deep path in a single NIF hop | Xitdb.select(db, path(users[id].profile.city)) | Resolves the entire selector inside Odin in 1 NIF call instead of 1 NIF call per bracket hop. |
| Projecting multiple fields + bounded list slices from an entity | Xitdb.pull(cursor, [:username, todos: 0..5]) | Projects the exact shape in a single NIF call without materializing unneeded subtrees. |
Writing to deep paths where intermediate maps might not exist yet, or batching list append/insert/slice/fork | Xitdb.DSL.tx db do ... end | Auto-vivifies missing intermediate collections with zero intermediate NIF reads and supports all native xitdb operations declaratively. |