trove

Package Version Hex Docs

An embedded, crash-safe key-value store for Gleam, inspired by CubDB. Built on an append-only copy-on-write B+ tree with type-safe codecs and explicit key ordering.

Features

Quick Start

gleam add trove
import gleam/io
import gleam/string
import trove
import trove/codec

pub fn main() {
  let config = trove.Config(
    path: "./my_db",
    key_codec: codec.string(),
    value_codec: codec.string(),
    key_compare: string.compare,
    auto_compact: trove.NoAutoCompact,
    auto_file_sync: trove.AutoSync,
    call_timeout: 5000,
  )

  let assert Ok(db) = trove.open(config)
  trove.put(db, key: "language", value: "gleam")
  let assert Ok(value) = trove.get(db, key: "language")
  io.println(value)  // "gleam"
  trove.close(db)
}

Usage

CRUD Operations

trove.put(db, key: "key", value: "value")
let assert Ok("value") = trove.get(db, key: "key")
let assert True = trove.has_key(db, key: "key")
trove.delete(db, key: "key")
let assert Error(Nil) = trove.get(db, key: "key")

Batch Operations

All entries in a batch are written atomically — one header write covers the entire batch.

trove.put_multi(db, entries: [
  #("alice", "100"),
  #("bob", "200"),
  #("charlie", "300"),
])

trove.delete_multi(db, keys: ["alice", "charlie"])

// Or combine puts and deletes in one atomic call:
trove.put_and_delete_multi(
  db,
  puts: [#("dave", "400")],
  deletes: ["bob"],
)

Range Queries

Keys are stored sorted, so range queries are efficient. Results are returned as a List.

import gleam/option.{None, Some}
import trove/range

// All entries, forward
let all = trove.range(db, min: None, max: None, direction: range.Forward)

// Bounded range: keys from "b" (inclusive) to "d" (exclusive)
let bounded = trove.range(
  db,
  min: Some(range.Inclusive("b")),
  max: Some(range.Exclusive("d")),
  direction: range.Forward,
)

// Reverse order
let reversed = trove.range(db, min: None, max: None, direction: range.Reverse)

For large result sets, use with_snapshot and snapshot_range to stream entries lazily without reading them all into memory at once:

import gleam/option.{None}
import gleam/yielder
import trove/range

trove.with_snapshot(db, fn(snap) {
  trove.snapshot_range(snapshot: snap, min: None, max: None, direction: range.Forward)
  |> yielder.each(fn(entry) { /* process entry */ })
})

Transactions

Transactions provide exclusive write access. The callback receives a Tx handle for reads and writes. Return Commit to apply or Cancel to discard.

let total = trove.transaction(db, timeout: 5000, callback: fn(tx) {
  let assert Ok(current) = trove.tx_get(tx, key: "alice")
  let tx = trove.tx_delete(tx, key: "old_key")
  let tx = trove.tx_put(tx, key: "alice", value: "150")
  let tx = trove.tx_put(tx, key: "bob", value: "250")
  trove.Commit(tx:, result: current)
})

To cancel a transaction (discards all writes within it):

let result = trove.transaction(db, timeout: 5000, callback: fn(_tx) {
  trove.Cancel(result: "cancelled")
})
// result == "cancelled"

Snapshots

Snapshots capture a point-in-time view of the database. Writes that happen after the snapshot is taken are not visible to it.

let value = trove.with_snapshot(db, fn(snap) {
  let assert Ok(v) = trove.snapshot_get(snapshot: snap, key: "key")
  v
})

Custom Codecs

A Codec(a) pairs an encode function (fn(a) -> BitArray) with a decode function (fn(BitArray) -> Result(a, Nil)). Built-in codecs cover common types:

import trove/codec

codec.string()      // UTF-8 strings
codec.int()         // 64-bit big-endian integers
codec.bit_array()   // raw bytes (identity)

For custom types, build your own codec:

import gleam/bit_array
import trove/codec

pub type User {
  User(name: String, age: Int)
}

pub fn user_codec() -> codec.Codec(User) {
  codec.Codec(
    encode: fn(user) {
      let name_bytes = bit_array.from_string(user.name)
      let name_size = bit_array.byte_size(name_bytes)
      <<name_size:32, name_bytes:bits, user.age:32>>
    },
    decode: fn(bits) {
      case bits {
        <<name_size:32, name_bytes:bytes-size(name_size), age:32>> ->
          case bit_array.to_string(name_bytes) {
            Ok(name) -> Ok(User(name, age))
            Error(_) -> Error(Nil)
          }
        _ -> Error(Nil)
      }
    },
  )
}

Compaction

Over time the store file accumulates dead entries from updates and deletes. Compaction rebuilds the file, keeping only live data.

// Manual compaction
let assert Ok(Nil) = trove.compact(db, timeout: 60_000)

// Check dirt factor (ratio of dead to total entries)
let df = trove.dirt_factor(db)

// With ManualSync, call file_sync to flush writes to disk:
trove.file_sync(db)

// Enable auto-compaction: triggers when at least 100 dead entries
// and dirt factor exceeds 25%
trove.set_auto_compact(
  db,
  setting: trove.AutoCompact(min_dirt: 100, min_dirt_factor: 0.25),
)

Or configure auto-compaction at open time:

let config = trove.Config(
  path: "./my_db",
  key_codec: codec.string(),
  value_codec: codec.string(),
  key_compare: string.compare,
  auto_compact: trove.AutoCompact(min_dirt: 100, min_dirt_factor: 0.25),
  auto_file_sync: trove.AutoSync,
  call_timeout: 5000,
)

How It Works

trove stores data in an append-only B+ tree on disk. Every write appends new nodes to the file and creates a new root — old data is never overwritten. This gives you:

The file format uses 1024-byte blocks with marker bytes for headers, enabling backward-scanning recovery without a write-ahead log.

Search Document