Menu
Public repository / beddeb15df67607b460f8291beb5c20f68db988e
AkurAI-Framework / crates/cli/src/store.rs
Latest change 034b2731466491ed49ba4a443e1255a8c6af5292 - feat(storage): wire B+tree into serve — guestbook persists durably by Ólafur Búi Ólafsson
//! A tiny append-and-list record store on the embedded B+tree.
//!
//! Records are JSON [`Value`]s keyed by a monotonic 64-bit id encoded
//! big-endian, so the tree's natural key order is insertion order — a `range`
//! scan returns records oldest-first. Every append commits, so a record is
//! durable the moment it returns. The `BTree` is single-writer, so it lives
//! behind one mutex; the next id rides inside it to stay consistent under
//! concurrent requests. This is the seed of the per-collection storage the
//! auto-API will grow into.
use akurai_json::Value;
use akurai_storage::BTree;
use std::io;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// A durable, clonable handle to a record store (an `Arc`, cheap to share).
#[derive(Clone)]
pub struct Store {
inner: Arc<Mutex<Inner>>,
}
struct Inner {
tree: BTree,
next: u64,
}
impl Store {
/// Open (or create) the store file at `path`, resuming the id sequence from
/// whatever is already persisted.
pub fn open(path: impl AsRef<Path>) -> io::Result<Store> {
let mut tree = BTree::open(path)?;
let next = match tree.range(&[], &[0xff; 9])?.last() {
Some((key, _)) => key_to_id(key) + 1,
None => 0,
};
Ok(Store {
inner: Arc::new(Mutex::new(Inner { tree, next })),
})
}
/// Append `record`, returning its new id. Durable on return (it commits).
pub fn append(&self, record: &Value) -> io::Result<u64> {
let mut inner = self.inner.lock().unwrap();
let id = inner.next;
inner
.tree
.insert(&id.to_be_bytes(), record.to_json().as_bytes())?;
inner.tree.commit()?;
inner.next = id + 1;
Ok(id)
}
/// All records, oldest-first. Unparseable values are skipped rather than
/// failing the whole listing.
pub fn list(&self) -> io::Result<Vec<Value>> {
let mut inner = self.inner.lock().unwrap();
let rows = inner.tree.range(&[], &[0xff; 9])?;
Ok(rows
.iter()
.filter_map(|(_, bytes)| {
std::str::from_utf8(bytes)
.ok()
.and_then(|s| akurai_json::parse(s).ok())
})
.collect())
}
}
/// Decode an 8-byte big-endian key back into its id.
fn key_to_id(key: &[u8]) -> u64 {
let mut buf = [0u8; 8];
let n = key.len().min(8);
buf[8 - n..].copy_from_slice(&key[key.len() - n..]);
u64::from_be_bytes(buf)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("akurai-store-{}-{}.db", name, std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
fn rec(name: &str) -> Value {
Value::Object(vec![("name".into(), Value::Str(name.into()))])
}
#[test]
fn append_then_list_in_order() {
let path = tmp("order");
let store = Store::open(&path).unwrap();
assert_eq!(store.append(&rec("a")).unwrap(), 0);
assert_eq!(store.append(&rec("b")).unwrap(), 1);
let names: Vec<String> = store
.list()
.unwrap()
.iter()
.filter_map(|v| v.get("name").and_then(Value::as_str).map(String::from))
.collect();
assert_eq!(names, vec!["a", "b"]);
std::fs::remove_file(&path).unwrap();
}
#[test]
fn persists_and_resumes_ids_across_reopen() {
let path = tmp("persist");
{
let store = Store::open(&path).unwrap();
store.append(&rec("first")).unwrap();
store.append(&rec("second")).unwrap();
}
// Reopen: data survives and the id sequence continues.
let store = Store::open(&path).unwrap();
assert_eq!(store.list().unwrap().len(), 2);
assert_eq!(store.append(&rec("third")).unwrap(), 2);
assert_eq!(store.list().unwrap().len(), 3);
std::fs::remove_file(&path).unwrap();
}
}