prolly-map 0.3.0

Content-addressed versioned map storage primitives.
Documentation
mod common;

use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use prolly::{
    Cid, Config, FileNodeStore, FileNodeStoreError, ManifestStoreScan, NamedRootUpdate,
    NodeStoreScan, Prolly, Store,
};

fn temp_store_dir(name: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or_default();
    let path = std::env::temp_dir().join(format!(
        "prolly-file-node-store-{name}-{}-{nanos}",
        std::process::id()
    ));
    let _ = fs::remove_dir_all(&path);
    path
}

fn config() -> Config {
    Config::builder()
        .min_chunk_size(2)
        .max_chunk_size(4)
        .chunking_factor(2)
        .hash_seed(177)
        .build()
}

#[test]
fn file_node_store_persists_tree_roots_and_nodes_separately() {
    let path = temp_store_dir("persist");
    let store = Arc::new(FileNodeStore::open(&path).unwrap());
    let prolly = Prolly::new(store.clone(), config());
    let mut tree = prolly.create();

    for idx in 0..32 {
        tree = prolly
            .put(
                &tree,
                format!("k{idx:03}").into_bytes(),
                format!("v{idx:03}").into_bytes(),
            )
            .unwrap();
    }
    prolly
        .publish_named_root_at_millis(b"main", &tree, 1_000)
        .unwrap();

    assert!(store.node_namespace_dir().exists());
    assert!(store.root_namespace_dir().exists());
    assert!(!store.list_node_cids().unwrap().is_empty());
    assert_eq!(store.list_roots().unwrap().len(), 1);

    let reopened = Arc::new(FileNodeStore::open(&path).unwrap());
    let reopened_prolly = Prolly::new(reopened.clone(), tree.config.clone());
    let loaded = reopened_prolly.load_named_root(b"main").unwrap().unwrap();

    assert_eq!(loaded, tree);
    assert_eq!(
        reopened_prolly.get(&loaded, b"k031").unwrap(),
        Some(b"v031".to_vec())
    );
    common::assert_tree_invariants(&reopened, &loaded, &loaded.config);

    let _ = fs::remove_dir_all(path);
}

#[test]
fn file_node_store_rejects_and_detects_cid_mismatches() {
    let path = temp_store_dir("cid");
    let store = FileNodeStore::open(&path).unwrap();
    let expected = Cid::from_bytes(b"expected bytes");
    let wrong = b"wrong bytes";

    let err = store.put(expected.as_bytes(), wrong).unwrap_err();
    match err {
        FileNodeStoreError::CidMismatch {
            expected: err_expected,
            actual,
            ..
        } => {
            assert_eq!(err_expected, expected);
            assert_eq!(actual, Cid::from_bytes(wrong));
        }
        other => panic!("expected CidMismatch, got {other:?}"),
    }

    let bytes = b"valid node bytes";
    let cid = Cid::from_bytes(bytes);
    store.put(cid.as_bytes(), bytes).unwrap();
    fs::write(store.path_for_cid(&cid), b"corrupt").unwrap();

    let err = store.get(cid.as_bytes()).unwrap_err();
    assert!(matches!(err, FileNodeStoreError::CidMismatch { .. }));

    let _ = fs::remove_dir_all(path);
}

#[test]
fn file_node_store_named_root_cas_and_gc_scan_work() {
    let path = temp_store_dir("manifest");
    let store = Arc::new(FileNodeStore::open(&path).unwrap());

    common::assert_manifest_store_contract(store.as_ref());
    common::assert_node_store_scan_contract(store.clone());

    let store = Arc::new(FileNodeStore::open(&path).unwrap());
    let prolly = Prolly::new(store.clone(), config());
    let empty = prolly.create();
    let first = prolly.put(&empty, b"k".to_vec(), b"1".to_vec()).unwrap();
    let second = prolly.put(&first, b"k".to_vec(), b"2".to_vec()).unwrap();

    assert!(prolly
        .compare_and_swap_named_root(b"main", None, Some(&first))
        .unwrap()
        .is_applied());
    match prolly
        .compare_and_swap_named_root(b"main", None, Some(&second))
        .unwrap()
    {
        NamedRootUpdate::Conflict { current } => assert_eq!(current, Some(first.clone())),
        other => panic!("expected stale CAS conflict, got {other:?}"),
    }
    assert_eq!(prolly.load_named_root(b"main").unwrap(), Some(first));

    let _ = fs::remove_dir_all(path);
}

#[test]
fn file_node_store_rejects_non_cid_node_keys() {
    let path = temp_store_dir("invalid-key");
    let store = FileNodeStore::open(&path).unwrap();
    let err = store.get(b"not-a-cid").unwrap_err();
    assert!(matches!(err, FileNodeStoreError::InvalidKey { .. }));
    let _ = fs::remove_dir_all(path);
}