AkurAI Build
Menu

Public repository / 3a1d4dc0df9425a910df82402300a2aeb40318ae

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();
    }
}