AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/collections/src/store.rs

Latest change c6179dcf3abbf53272e4279e6740397ad2e75726 - AKURAI-FRAMEWORK-1..9 consolidate framework APIs by Ólafur Búi Ólafsson

//! The record store: CRUD + substring search over a single [`BTree`].
//!
//! ## Key layout
//!
//! Every record lives under a per-collection prefix:
//!
//! ```text
//! coll:<name>:<id>     where <id> is a big-endian u64   (the record)
//! coll:<name>:_seq     the auto-increment counter        (last id used, BE u64)
//! ```
//!
//! Because record ids are fixed 8-byte big-endian, they sort in numeric order,
//! so a key range scan over the prefix yields records in id (insertion) order.
//! The `_seq` counter shares the prefix but has a different key length, so range
//! scans filter strictly on `prefix.len() + 8` and never confuse the two.
//!
//! ## Stored shape
//!
//! A stored record is a JSON object whose first two keys are always the
//! engine-assigned `id` (int) and `created` (unix seconds, int), followed by the
//! schema fields that were supplied, in schema declaration order.

use std::time::{SystemTime, UNIX_EPOCH};

use akurai_json::{parse, Value};
use akurai_storage::BTree;

use crate::error::CollError;
use crate::schema::{Collection, Field, FieldKind};

/// Reserved record keys that the engine owns and input may not set.
const RESERVED: [&str; 2] = ["id", "created"];

/// A record store backed by one B+tree file. All collections share the tree,
/// separated by their key prefix.
pub struct Store {
    tree: BTree,
}

impl Store {
    /// Open (or create) the backing tree at `path`.
    pub fn open(path: impl AsRef<std::path::Path>) -> Result<Store, CollError> {
        Ok(Store {
            tree: BTree::open(path)?,
        })
    }

    /// Validate `input` against `coll`'s schema, assign an `id` and `created`
    /// timestamp, persist, commit, and return the stored record.
    ///
    /// Relation fields are type-checked (the value must be an integer id) but
    /// the referenced record's *existence* is not verified — use
    /// [`Store::create_checked`] to opt into referential checking.
    pub fn create(&mut self, coll: &Collection, input: Value) -> Result<Value, CollError> {
        self.create_impl(coll, input, None)
    }

    /// Like [`Store::create`], but additionally verifies that every relation
    /// value references an existing record in its target collection. `all` is
    /// the set of collections the caller knows about: if a relation's target
    /// collection is present in `all`, the referenced id must exist (else a
    /// [`CollError::Validation`] is returned); if the target is *not* in `all`,
    /// the id is accepted without an existence check.
    pub fn create_checked(
        &mut self,
        coll: &Collection,
        input: Value,
        all: &[Collection],
    ) -> Result<Value, CollError> {
        self.create_impl(coll, input, Some(all))
    }

    fn create_impl(
        &mut self,
        coll: &Collection,
        input: Value,
        check: Option<&[Collection]>,
    ) -> Result<Value, CollError> {
        let supplied = as_object(&input, "input must be a JSON object")?;
        validate_create(coll, supplied)?;
        if let Some(all) = check {
            self.verify_relations(coll, supplied, all)?;
        }

        let id = self.next_id(&coll.name)?;
        let created = now_secs();

        let mut record: Vec<(String, Value)> = Vec::with_capacity(coll.fields.len() + 2);
        record.push(("id".into(), Value::Int(id as i64)));
        record.push(("created".into(), Value::Int(created)));
        for field in &coll.fields {
            if let Some(raw) = lookup(supplied, &field.name) {
                if !matches!(raw, Value::Null) {
                    record.push((field.name.clone(), coerce(field, raw)));
                }
            }
        }

        let value = Value::Object(record);
        self.tree
            .insert(&record_key(&coll.name, id), value.to_json().as_bytes())?;
        self.tree.commit()?;
        Ok(value)
    }

    /// Fetch a single record by id, or `None` if it does not exist.
    pub fn get(&mut self, coll: &Collection, id: u64) -> Result<Option<Value>, CollError> {
        match self.tree.get(&record_key(&coll.name, id))? {
            Some(bytes) => Ok(Some(decode(&bytes)?)),
            None => Ok(None),
        }
    }

    /// List records, newest first (highest id first). `limit` caps the count.
    pub fn list(
        &mut self,
        coll: &Collection,
        limit: Option<usize>,
    ) -> Result<Vec<Value>, CollError> {
        let mut records = self.scan(&coll.name)?;
        records.reverse();
        if let Some(n) = limit {
            records.truncate(n);
        }
        Ok(records)
    }

    /// Apply a partial `patch` to an existing record. Only the keys present in
    /// `patch` are changed; each changed field is re-validated against the
    /// schema. Returns the updated record, or `None` if the id does not exist.
    pub fn update(
        &mut self,
        coll: &Collection,
        id: u64,
        patch: Value,
    ) -> Result<Option<Value>, CollError> {
        self.update_impl(coll, id, patch, None)
    }

    /// Like [`Store::update`], but additionally verifies that any relation
    /// value present in `patch` references an existing record in its target
    /// collection (see [`Store::create_checked`] for the `all` semantics).
    pub fn update_checked(
        &mut self,
        coll: &Collection,
        id: u64,
        patch: Value,
        all: &[Collection],
    ) -> Result<Option<Value>, CollError> {
        self.update_impl(coll, id, patch, Some(all))
    }

    fn update_impl(
        &mut self,
        coll: &Collection,
        id: u64,
        patch: Value,
        check: Option<&[Collection]>,
    ) -> Result<Option<Value>, CollError> {
        let key = record_key(&coll.name, id);
        let existing = match self.tree.get(&key)? {
            Some(bytes) => decode(&bytes)?,
            None => return Ok(None),
        };
        let mut pairs = match existing {
            Value::Object(p) => p,
            _ => return Err(CollError::Corrupt("stored record is not an object")),
        };
        let updates = as_object(&patch, "patch must be a JSON object")?;
        if let Some(all) = check {
            self.verify_relations(coll, updates, all)?;
        }

        for (key_name, raw) in updates {
            if RESERVED.contains(&key_name.as_str()) {
                return Err(CollError::validation(format!(
                    "field '{key_name}' is reserved and cannot be patched"
                )));
            }
            let field = coll
                .field(key_name)
                .ok_or_else(|| CollError::validation(format!("unknown field '{key_name}'")))?;
            if matches!(raw, Value::Null) {
                if field.required {
                    return Err(CollError::validation(format!(
                        "required field '{key_name}' cannot be set to null"
                    )));
                }
                pairs.retain(|(k, _)| k != key_name);
                continue;
            }
            check_type(field, raw)?;
            let new_value = coerce(field, raw);
            set_field(&mut pairs, key_name, new_value);
        }

        let value = Value::Object(pairs);
        self.tree.insert(&key, value.to_json().as_bytes())?;
        self.tree.commit()?;
        Ok(Some(value))
    }

    /// Delete a record by id. Returns `true` if a record was removed.
    pub fn delete(&mut self, coll: &Collection, id: u64) -> Result<bool, CollError> {
        let removed = self.tree.delete(&record_key(&coll.name, id))?;
        if removed {
            self.tree.commit()?;
        }
        Ok(removed)
    }

    /// Case-insensitive substring search across the collection's `Text` fields.
    /// Honest baseline only — semantic ranking is layered on by the CLI. Results
    /// are newest first; `limit` caps the count. An empty query matches all.
    pub fn search(
        &mut self,
        coll: &Collection,
        query: &str,
        limit: Option<usize>,
    ) -> Result<Vec<Value>, CollError> {
        let needle = query.to_lowercase();
        let text_fields: Vec<&str> = coll
            .fields
            .iter()
            .filter(|f| f.kind == FieldKind::Text)
            .map(|f| f.name.as_str())
            .collect();

        let mut records = self.scan(&coll.name)?;
        records.reverse();

        let mut out = Vec::new();
        for record in records {
            if record_matches(&record, &text_fields, &needle) {
                out.push(record);
                if let Some(n) = limit {
                    if out.len() >= n {
                        break;
                    }
                }
            }
        }
        Ok(out)
    }

    /// Fetch a record by id and inline its relation references.
    ///
    /// For every name in `expand` that is a relation field on `coll`, the
    /// referenced record (looked up in its target collection within `all`) is
    /// inlined under a sibling key `"<field>_expanded"`. A reference that is
    /// absent, that points at a missing record, or whose target collection is
    /// not present in `all`, expands to `null`. Names in `expand` that are not
    /// relation fields are silently ignored. The original relation id key is
    /// left untouched. Returns `None` if the record itself does not exist.
    pub fn get_expanded(
        &mut self,
        coll: &Collection,
        id: u64,
        expand: &[&str],
        all: &[Collection],
    ) -> Result<Option<Value>, CollError> {
        let record = match self.get(coll, id)? {
            Some(r) => r,
            None => return Ok(None),
        };
        Ok(Some(self.expand_record(coll, record, expand, all)?))
    }

    /// List records (newest first, `limit`-capped) with relation references
    /// inlined, following the same rules as [`Store::get_expanded`].
    pub fn list_expanded(
        &mut self,
        coll: &Collection,
        limit: Option<usize>,
        expand: &[&str],
        all: &[Collection],
    ) -> Result<Vec<Value>, CollError> {
        let records = self.list(coll, limit)?;
        let mut out = Vec::with_capacity(records.len());
        for record in records {
            out.push(self.expand_record(coll, record, expand, all)?);
        }
        Ok(out)
    }

    /// Inline the requested relation references into one record. See
    /// [`Store::get_expanded`] for the expansion contract.
    fn expand_record(
        &mut self,
        coll: &Collection,
        record: Value,
        expand: &[&str],
        all: &[Collection],
    ) -> Result<Value, CollError> {
        let mut pairs = match record {
            Value::Object(p) => p,
            other => return Ok(other),
        };
        for &name in expand {
            // Ignore names that are not relation fields on this collection.
            let target = match coll.field(name).map(|f| &f.kind) {
                Some(FieldKind::Relation(t)) => t.clone(),
                _ => continue,
            };
            let ref_id = lookup(&pairs, name).and_then(Value::as_i64);
            let expanded = match ref_id {
                Some(rid) if all.iter().any(|c| c.name == target) => {
                    match self.tree.get(&record_key(&target, rid as u64))? {
                        Some(bytes) => decode(&bytes)?,
                        None => Value::Null,
                    }
                }
                _ => Value::Null,
            };
            set_field(&mut pairs, &format!("{name}_expanded"), expanded);
        }
        Ok(Value::Object(pairs))
    }

    /// Verify that every relation value in `supplied` references an existing
    /// record in its target collection. Only relation fields whose target is
    /// present in `all` are checked; unknown targets are accepted as-is. Type
    /// validation (relation == integer) is handled separately by `check_type`.
    fn verify_relations(
        &mut self,
        coll: &Collection,
        supplied: &[(String, Value)],
        all: &[Collection],
    ) -> Result<(), CollError> {
        for field in &coll.fields {
            let target = match &field.kind {
                FieldKind::Relation(t) => t,
                _ => continue,
            };
            // Only check ids that were actually supplied as integers; missing
            // or non-integer values are caught by required/type validation.
            let ref_id = match lookup(supplied, &field.name).and_then(Value::as_i64) {
                Some(id) => id,
                None => continue,
            };
            // Opt-in: only verify existence when the target is a known collection.
            if !all.iter().any(|c| c.name == *target) {
                continue;
            }
            if ref_id < 0 || self.tree.get(&record_key(target, ref_id as u64))?.is_none() {
                return Err(CollError::validation(format!(
                    "relation field '{}' references non-existent {} id {}",
                    field.name, target, ref_id
                )));
            }
        }
        Ok(())
    }

    /// Scan every record under a collection prefix, in ascending id order.
    fn scan(&mut self, name: &str) -> Result<Vec<Value>, CollError> {
        let prefix = record_prefix(name);
        let start = record_key(name, 0);
        let end = prefix_upper_bound(&prefix);
        let entries = self.tree.range(&start, &end)?;

        let mut out = Vec::with_capacity(entries.len());
        for (key, bytes) in entries {
            // Records are exactly prefix + 8 bytes; the `_seq` counter and any
            // future meta keys share the prefix but differ in length.
            if key.len() != prefix.len() + 8 {
                continue;
            }
            out.push(decode(&bytes)?);
        }
        Ok(out)
    }

    /// Read, bump, and persist the per-collection id counter, returning the new
    /// id. The counter is committed together with the record by the caller.
    fn next_id(&mut self, name: &str) -> Result<u64, CollError> {
        let key = seq_key(name);
        let last = match self.tree.get(&key)? {
            Some(bytes) => decode_counter(&bytes)?,
            None => 0,
        };
        let id = last + 1;
        self.tree.insert(&key, &id.to_be_bytes())?;
        Ok(id)
    }
}

// --- key helpers ------------------------------------------------------------

fn record_prefix(name: &str) -> Vec<u8> {
    format!("coll:{name}:").into_bytes()
}

fn record_key(name: &str, id: u64) -> Vec<u8> {
    let mut key = record_prefix(name);
    key.extend_from_slice(&id.to_be_bytes());
    key
}

fn seq_key(name: &str) -> Vec<u8> {
    format!("coll:{name}:_seq").into_bytes()
}

/// The smallest key strictly greater than every key sharing `prefix`. Built by
/// bumping the last non-`0xff` byte; the prefix ends in `:` so this is trivial.
fn prefix_upper_bound(prefix: &[u8]) -> Vec<u8> {
    let mut end = prefix.to_vec();
    while let Some(last) = end.last_mut() {
        if *last < 0xff {
            *last += 1;
            return end;
        }
        end.pop();
    }
    // Prefix was all 0xff (impossible for "coll:...:"); fall back to a max key.
    vec![0xff; prefix.len() + 9]
}

// --- value helpers ----------------------------------------------------------

fn now_secs() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

fn decode(bytes: &[u8]) -> Result<Value, CollError> {
    let text = std::str::from_utf8(bytes)
        .map_err(|_| CollError::Corrupt("stored record is not valid UTF-8"))?;
    Ok(parse(text)?)
}

fn decode_counter(bytes: &[u8]) -> Result<u64, CollError> {
    let arr: [u8; 8] = bytes
        .try_into()
        .map_err(|_| CollError::Corrupt("counter is not 8 bytes"))?;
    Ok(u64::from_be_bytes(arr))
}

fn as_object<'a>(v: &'a Value, msg: &'static str) -> Result<&'a [(String, Value)], CollError> {
    match v {
        Value::Object(pairs) => Ok(pairs),
        _ => Err(CollError::validation(msg)),
    }
}

fn lookup<'a>(pairs: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
    pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}

fn set_field(pairs: &mut Vec<(String, Value)>, key: &str, value: Value) {
    if let Some(slot) = pairs.iter_mut().find(|(k, _)| k == key) {
        slot.1 = value;
    } else {
        pairs.push((key.to_string(), value));
    }
}

fn record_matches(record: &Value, text_fields: &[&str], needle: &str) -> bool {
    for name in text_fields {
        if let Some(Value::Str(s)) = record.get(name) {
            if s.to_lowercase().contains(needle) {
                return true;
            }
        }
    }
    // An empty needle matches everything, even records with no text fields set.
    needle.is_empty()
}

// --- validation -------------------------------------------------------------

fn validate_create(coll: &Collection, input: &[(String, Value)]) -> Result<(), CollError> {
    // Reject unknown and reserved keys.
    for (key, _) in input {
        if RESERVED.contains(&key.as_str()) {
            return Err(CollError::validation(format!(
                "field '{key}' is reserved and assigned by the engine"
            )));
        }
        if coll.field(key).is_none() {
            return Err(CollError::validation(format!("unknown field '{key}'")));
        }
    }
    // Required-present and type checks.
    for field in &coll.fields {
        match lookup(input, &field.name) {
            None | Some(Value::Null) => {
                if field.required {
                    return Err(CollError::validation(format!(
                        "missing required field '{}'",
                        field.name
                    )));
                }
            }
            Some(raw) => check_type(field, raw)?,
        }
    }
    Ok(())
}

/// Verify `raw` matches the field's declared kind. `Int` is accepted where a
/// `Float` is expected (and coerced on store); everything else is strict.
fn check_type(field: &Field, raw: &Value) -> Result<(), CollError> {
    let ok = match &field.kind {
        FieldKind::Text => matches!(raw, Value::Str(_)),
        FieldKind::Int => matches!(raw, Value::Int(_)),
        FieldKind::Float => matches!(raw, Value::Float(_) | Value::Int(_)),
        FieldKind::Bool => matches!(raw, Value::Bool(_)),
        // A relation stores the referenced record's integer id.
        FieldKind::Relation(_) => matches!(raw, Value::Int(_)),
        // A file stores a `{blob,filename,content_type,size}` descriptor object.
        // `null`/omitted is handled by `validate_create` before this is reached,
        // so here a present value must be an object (uploads write it; raw JSON
        // generally leaves it null since files arrive over multipart).
        FieldKind::File => matches!(raw, Value::Object(_)),
    };
    if ok {
        Ok(())
    } else {
        Err(CollError::validation(format!(
            "field '{}' expects {}, got {}",
            field.name,
            field.kind.as_str(),
            type_name(raw)
        )))
    }
}

/// Apply storage-time coercion (currently only `Int` → `Float`). Assumes the
/// value already passed [`check_type`].
fn coerce(field: &Field, raw: &Value) -> Value {
    match (&field.kind, raw) {
        (FieldKind::Float, Value::Int(n)) => Value::Float(*n as f64),
        _ => raw.clone(),
    }
}

fn type_name(v: &Value) -> &'static str {
    match v {
        Value::Null => "null",
        Value::Bool(_) => "bool",
        Value::Int(_) => "int",
        Value::Float(_) => "float",
        Value::Str(_) => "text",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}