haematite 0.7.0

Content-addressed, branchable, actor-native storage engine
Documentation
use std::collections::BTreeMap;

use crate::branch::{BranchKind, BranchRefRecord, BranchShardRef};
use crate::tree::{Hash, TreePolicy};

use super::{BrowserLocalError, BrowserSourceError};

const RECORD_MAGIC: &[u8; 4] = b"HBL1";
const NODE_MAGIC: &[u8; 4] = b"HBN1";

/// Legacy (chunking v1) store-format marker. A store initialised before the v2
/// chunking dispatch carries EXACTLY these bytes and opens under
/// [`TreePolicy::V1_DEFAULT`] FOREVER (CHUNKING-POLICY §4.1: v1/unstamped
/// directories are never force-migrated). New stores write [`STORE_FORMAT_V2`].
pub(super) const STORE_FORMAT: &[u8] = b"HAEMATITE-BROWSER-LOCAL\n1\n";

/// The chunking-v2 store-format marker prefix, followed by two little-endian
/// `u64` targets `{leaf_target_bytes, internal_target_bytes}` — the browser
/// seam's on-disk analog of the native config.json chunking stamp. The `format`
/// file is the store's version record, so it is the natural (and only)
/// stamp carrier; no new persisted subsystem is introduced.
pub(super) const STORE_FORMAT_V2: &[u8] = b"HAEMATITE-BROWSER-LOCAL\n2\n";

/// The ratified §2.3 leaf target (64 KiB). The browser-local (wasm) and native
/// config seams are mutually-exclusive cfg builds (CHUNKING-POLICY §4.1 +
/// dispatch-brief amendment 1), so `browser_local` cannot read the native
/// `db::config` stamp constants; it mirrors the signed values here.
pub(super) const BROWSER_LEAF_TARGET_BYTES: u64 = 64 * 1024;
/// The ratified §2.3 internal target (48 KiB); see [`BROWSER_LEAF_TARGET_BYTES`].
pub(super) const BROWSER_INTERNAL_TARGET_BYTES: u64 = 48 * 1024;

/// The [`TreePolicy`] a freshly created browser store stamps and mutates under
/// — v2 from birth, exactly as native `Database::create` (§5). The parallel of
/// native's `default_create_policy`.
pub(super) const fn default_browser_create_policy() -> TreePolicy {
    TreePolicy::v2(BROWSER_LEAF_TARGET_BYTES, BROWSER_INTERNAL_TARGET_BYTES)
}

/// Encode the store-format marker for `policy`: the v2 prefix + two LE `u64`
/// targets for a v2 policy, or the legacy v1 marker for a v1 policy.
pub(super) fn encode_store_format(policy: TreePolicy) -> Vec<u8> {
    match policy {
        TreePolicy::V2 {
            leaf_target_bytes,
            internal_target_bytes,
        } => {
            let mut bytes = STORE_FORMAT_V2.to_vec();
            bytes.extend_from_slice(&leaf_target_bytes.to_le_bytes());
            bytes.extend_from_slice(&internal_target_bytes.to_le_bytes());
            bytes
        }
        TreePolicy::V1(_) => STORE_FORMAT.to_vec(),
    }
}

/// Resolve the persisted `format` file bytes to the [`TreePolicy`] the store
/// mutates under (CHUNKING-POLICY §4.1 dispatch, browser seam):
///
/// - EXACT legacy v1 marker ⇒ [`TreePolicy::V1_DEFAULT`] (open forever, no
///   migration);
/// - v2 marker + two nonzero LE `u64` targets ⇒ that v2 policy;
/// - v2 marker with a wrong-length or zero-valued stamp ⇒ typed
///   [`BrowserLocalError::InvalidChunkingStamp`] (the engine never guesses a
///   target — the native `InvalidChunkingStamp` discipline, rule 2);
/// - anything else ⇒ [`BrowserLocalError::UnsupportedStoreFormat`].
pub(super) fn decode_store_format(bytes: &[u8]) -> Result<TreePolicy, BrowserLocalError> {
    if bytes == STORE_FORMAT {
        return Ok(TreePolicy::V1_DEFAULT);
    }
    if let Some(stamp) = bytes.strip_prefix(STORE_FORMAT_V2) {
        if stamp.len() != 16 {
            return Err(BrowserLocalError::InvalidChunkingStamp {
                detail: format!(
                    "v2 store-format stamp must be 16 bytes (two u64 targets), got {}",
                    stamp.len()
                ),
            });
        }
        let mut leaf_bytes = [0u8; 8];
        let mut internal_bytes = [0u8; 8];
        leaf_bytes.copy_from_slice(&stamp[0..8]);
        internal_bytes.copy_from_slice(&stamp[8..16]);
        let leaf = u64::from_le_bytes(leaf_bytes);
        let internal = u64::from_le_bytes(internal_bytes);
        if leaf == 0 || internal == 0 {
            return Err(BrowserLocalError::InvalidChunkingStamp {
                detail: format!(
                    "v2 store-format targets must be nonzero: leaf={leaf} internal={internal}"
                ),
            });
        }
        return Ok(TreePolicy::v2(leaf, internal));
    }
    Err(BrowserLocalError::UnsupportedStoreFormat {
        found: String::from_utf8_lossy(bytes).into_owned(),
    })
}

pub(super) fn encode_record(record: &BranchRefRecord, generation: &str) -> Vec<u8> {
    let mut bytes = RECORD_MAGIC.to_vec();
    push_bytes(&mut bytes, generation.as_bytes());
    push_bytes(&mut bytes, record.name.as_bytes());
    push_u64(&mut bytes, record.created);
    push_u64(&mut bytes, record.seq);
    push_u64(&mut bytes, record.timestamp);
    push_u64(&mut bytes, usize_to_u64(record.shards.len()));
    for shard in &record.shards {
        push_u64(&mut bytes, usize_to_u64(shard.shard_id));
        bytes.extend_from_slice(shard.fork_anchor.as_bytes());
        bytes.extend_from_slice(shard.head.as_bytes());
    }
    push_u64(&mut bytes, usize_to_u64(record.parents.len()));
    for parent in &record.parents {
        bytes.extend_from_slice(parent.as_bytes());
    }
    push_u64(
        &mut bytes,
        match record.kind {
            BranchKind::Work => 0,
            BranchKind::Namespace => 1,
        },
    );
    match &record.namespace_lineage {
        Some(lineage) => {
            push_u64(&mut bytes, 1);
            push_bytes(&mut bytes, lineage.as_bytes());
        }
        None => push_u64(&mut bytes, 0),
    }
    bytes
}

pub(super) fn decode_record(bytes: &[u8]) -> Result<(String, BranchRefRecord), BrowserSourceError> {
    let mut reader = Reader::new(bytes);
    reader.magic(RECORD_MAGIC)?;
    let generation = reader.string()?;
    let name = reader.string()?;
    let created = reader.u64()?;
    let seq = reader.u64()?;
    let timestamp = reader.u64()?;
    let shard_count = reader.len()?;
    let mut shards = Vec::with_capacity(shard_count);
    for _ in 0..shard_count {
        shards.push(BranchShardRef {
            shard_id: reader.len()?,
            fork_anchor: reader.hash()?,
            head: reader.hash()?,
        });
    }
    let parent_count = reader.len()?;
    let mut parents = Vec::with_capacity(parent_count);
    for _ in 0..parent_count {
        parents.push(reader.hash()?);
    }
    let kind = match reader.u64()? {
        0 => BranchKind::Work,
        1 => BranchKind::Namespace,
        value => {
            return Err(BrowserSourceError::codec(format!(
                "unknown branch kind {value}"
            )));
        }
    };
    let namespace_lineage = match reader.u64()? {
        0 => None,
        1 => Some(reader.string()?),
        value => {
            return Err(BrowserSourceError::codec(format!(
                "unknown lineage tag {value}"
            )));
        }
    };
    reader.finish()?;
    Ok((
        generation,
        BranchRefRecord {
            name,
            created,
            kind,
            namespace_lineage,
            seq,
            timestamp,
            shards,
            parents,
        },
    ))
}

pub(super) fn encode_nodes(nodes: &BTreeMap<Hash, Vec<u8>>) -> Vec<u8> {
    let mut bytes = NODE_MAGIC.to_vec();
    push_u64(&mut bytes, usize_to_u64(nodes.len()));
    for (hash, node) in nodes {
        bytes.extend_from_slice(hash.as_bytes());
        push_bytes(&mut bytes, node);
    }
    bytes
}

pub(super) fn decode_nodes(bytes: &[u8]) -> Result<BTreeMap<Hash, Vec<u8>>, BrowserSourceError> {
    let mut reader = Reader::new(bytes);
    reader.magic(NODE_MAGIC)?;
    let count = reader.len()?;
    let mut nodes = BTreeMap::new();
    for _ in 0..count {
        let hash = reader.hash()?;
        if nodes.insert(hash, reader.bytes()?.to_vec()).is_some() {
            return Err(BrowserSourceError::codec("duplicate node hash"));
        }
    }
    reader.finish()?;
    Ok(nodes)
}

pub(super) fn record_file_name(name: &str) -> String {
    format!("{}.hbr", blake3::hash(name.as_bytes()).to_hex())
}

pub(super) fn generation_id(name: &str) -> String {
    format!(
        "{:016x}-{}",
        super::core::browser_timestamp(),
        blake3::hash(name.as_bytes()).to_hex()
    )
}

fn push_u64(bytes: &mut Vec<u8>, value: u64) {
    bytes.extend_from_slice(&value.to_le_bytes());
}
fn push_bytes(bytes: &mut Vec<u8>, value: &[u8]) {
    push_u64(bytes, usize_to_u64(value.len()));
    bytes.extend_from_slice(value);
}
fn usize_to_u64(value: usize) -> u64 {
    u64::try_from(value).unwrap_or(u64::MAX)
}

struct Reader<'a> {
    bytes: &'a [u8],
    offset: usize,
}
impl<'a> Reader<'a> {
    const fn new(bytes: &'a [u8]) -> Self {
        Self { bytes, offset: 0 }
    }
    fn take(&mut self, count: usize) -> Result<&'a [u8], BrowserSourceError> {
        let end = self
            .offset
            .checked_add(count)
            .ok_or("record length overflow")?;
        let value = self.bytes.get(self.offset..end).ok_or("truncated record")?;
        self.offset = end;
        Ok(value)
    }
    fn magic(&mut self, expected: &[u8]) -> Result<(), BrowserSourceError> {
        if self.take(expected.len())? == expected {
            Ok(())
        } else {
            Err(BrowserSourceError::codec("record magic mismatch"))
        }
    }
    fn u64(&mut self) -> Result<u64, BrowserSourceError> {
        let raw: [u8; 8] = self.take(8)?.try_into().map_err(|_| "truncated integer")?;
        Ok(u64::from_le_bytes(raw))
    }
    fn len(&mut self) -> Result<usize, BrowserSourceError> {
        usize::try_from(self.u64()?).map_err(|_| BrowserSourceError::codec("length exceeds usize"))
    }
    fn bytes(&mut self) -> Result<&'a [u8], BrowserSourceError> {
        let len = self.len()?;
        self.take(len)
    }
    fn string(&mut self) -> Result<String, BrowserSourceError> {
        String::from_utf8(self.bytes()?.to_vec())
            .map_err(|error| BrowserSourceError::codec(error.to_string()))
    }
    fn hash(&mut self) -> Result<Hash, BrowserSourceError> {
        let raw: [u8; 32] = self.take(32)?.try_into().map_err(|_| "truncated hash")?;
        Ok(Hash::from_bytes(raw))
    }
    fn finish(self) -> Result<(), BrowserSourceError> {
        if self.offset == self.bytes.len() {
            Ok(())
        } else {
            Err(BrowserSourceError::codec("trailing record bytes"))
        }
    }
}