feat: introduce global meta store and root store concept, and update CLI to manage active store

This commit is contained in:
2025-12-21 23:59:08 +01:00
parent f45c6ccfcf
commit 346ebccee7
15 changed files with 851 additions and 283 deletions
+1
View File
@@ -15,6 +15,7 @@ dirs = { workspace = true }
blake3 = { workspace = true }
hex = { workspace = true }
redb = { workspace = true }
uuid = { workspace = true }
[build-dependencies]
prost-build = { workspace = true }
+55 -28
View File
@@ -2,19 +2,26 @@
//!
//! Provides platform-specific paths for Lattice data storage:
//! - `identity.key` — Ed25519 private key
//! - `logs/` — Append-only log files per author
//! - `state.db` — KV snapshot and indexes
//! - `meta.db` — Global metadata (stores table)
//! - `stores/{uuid}/logs/{author}.log` — Per-store, per-author logs
//! - `stores/{uuid}/state.db` — Per-store KV state
use std::path::{Path, PathBuf};
use uuid::Uuid;
const APP_NAME: &str = "lattice";
/// Data directory configuration.
///
/// Handles paths for:
/// - `identity.key` — node's private key
/// - `logs/{author_id}.log` — per-author log files
/// - `state.db` — redb database
/// Multi-store layout:
/// ```text
/// base/
/// identity.key
/// meta.db
/// stores/{uuid}/
/// logs/{author}.log
/// state.db
/// ```
#[derive(Debug, Clone)]
pub struct DataDir {
base: PathBuf,
@@ -27,10 +34,6 @@ impl DataDir {
}
/// Create a DataDir using the platform-specific data directory.
///
/// - Linux: `~/.local/share/lattice/`
/// - macOS: `~/Library/Application Support/lattice/`
/// - Windows: `C:\Users\<user>\AppData\Roaming\lattice\`
pub fn default_location() -> Option<Self> {
dirs::data_dir().map(|d| Self::new(d.join(APP_NAME)))
}
@@ -45,25 +48,47 @@ impl DataDir {
self.base.join("identity.key")
}
/// Get the path to the logs directory.
pub fn logs_dir(&self) -> PathBuf {
self.base.join("logs")
/// Get the path to the global metadata database.
pub fn meta_db(&self) -> PathBuf {
self.base.join("meta.db")
}
/// Get the path to a specific author's log file.
pub fn log_file(&self, author_id_hex: &str) -> PathBuf {
self.logs_dir().join(format!("{}.log", author_id_hex))
/// Get the path to the stores directory.
pub fn stores_dir(&self) -> PathBuf {
self.base.join("stores")
}
/// Get the path to the state database.
pub fn state_db(&self) -> PathBuf {
self.base.join("state.db")
/// Get the path to a specific store's directory.
pub fn store_dir(&self, store_id: Uuid) -> PathBuf {
self.stores_dir().join(store_id.to_string())
}
/// Ensure all required directories exist.
/// Get the path to a store's logs directory.
pub fn store_logs_dir(&self, store_id: Uuid) -> PathBuf {
self.store_dir(store_id).join("logs")
}
/// Get the path to a specific author's log file within a store.
pub fn store_log_file(&self, store_id: Uuid, author_id_hex: &str) -> PathBuf {
self.store_logs_dir(store_id).join(format!("{}.log", author_id_hex))
}
/// Get the path to a store's state database.
pub fn store_state_db(&self, store_id: Uuid) -> PathBuf {
self.store_dir(store_id).join("state.db")
}
/// Ensure base directory exists.
pub fn ensure_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.base)?;
std::fs::create_dir_all(self.logs_dir())?;
std::fs::create_dir_all(self.stores_dir())?;
Ok(())
}
/// Ensure directories for a specific store exist.
pub fn ensure_store_dirs(&self, store_id: Uuid) -> std::io::Result<()> {
self.ensure_dirs()?;
std::fs::create_dir_all(self.store_logs_dir(store_id))?;
Ok(())
}
}
@@ -83,29 +108,31 @@ mod tests {
let dd = DataDir::new("/custom/path");
assert_eq!(dd.base(), Path::new("/custom/path"));
assert_eq!(dd.identity_key(), PathBuf::from("/custom/path/identity.key"));
assert_eq!(dd.logs_dir(), PathBuf::from("/custom/path/logs"));
assert_eq!(dd.state_db(), PathBuf::from("/custom/path/state.db"));
assert_eq!(dd.meta_db(), PathBuf::from("/custom/path/meta.db"));
assert_eq!(dd.stores_dir(), PathBuf::from("/custom/path/stores"));
}
#[test]
fn test_log_file_path() {
fn test_store_paths() {
let dd = DataDir::new("/data");
let path = dd.log_file("abc123");
assert_eq!(path, PathBuf::from("/data/logs/abc123.log"));
let store_id = Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap();
assert_eq!(dd.store_dir(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
assert_eq!(dd.store_logs_dir(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/logs"));
assert_eq!(dd.store_log_file(store_id, "abc123"), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/logs/abc123.log"));
assert_eq!(dd.store_state_db(store_id), PathBuf::from("/data/stores/a1b2c3d4-e5f6-7890-abcd-ef1234567890/state.db"));
}
#[test]
fn test_default_location_exists() {
// On most systems, default_location should return Some
let location = DataDir::default_location();
// Just verify it doesn't panic - actual path varies by platform
assert!(location.is_some() || true);
}
#[test]
fn test_default_impl() {
let dd = DataDir::default();
// Should either be platform default or ./data fallback
assert!(dd.base().to_str().is_some());
}
}
+3
View File
@@ -24,6 +24,7 @@ pub mod data_dir;
pub mod signed_entry;
pub mod log;
pub mod store;
pub mod meta_store;
// Constants
/// Maximum size of a serialized SignedEntry (16 MB)
@@ -39,3 +40,5 @@ pub use data_dir::DataDir;
pub use signed_entry::{EntryBuilder, sign_entry, verify_signed_entry, hash_signed_entry};
pub use log::{append_entry, read_entries, LogReader};
pub use store::Store;
pub use meta_store::MetaStore;
pub use uuid::Uuid;
+154
View File
@@ -0,0 +1,154 @@
//! MetaStore - global node metadata in meta.db
//!
//! Tables:
//! - stores: UUID → created_at (Unix ms)
//! - meta: "root_store" → UUID (auto-opened on startup)
use redb::{Database, ReadableTable, TableDefinition};
use std::path::Path;
use thiserror::Error;
use uuid::Uuid;
const STORES_TABLE: TableDefinition<&[u8], u64> = TableDefinition::new("stores");
const META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
const META_ROOT_STORE: &str = "root_store";
#[derive(Error, Debug)]
pub enum MetaStoreError {
#[error("Database error: {0}")]
Database(#[from] redb::DatabaseError),
#[error("Table error: {0}")]
Table(#[from] redb::TableError),
#[error("Transaction error: {0}")]
Transaction(#[from] redb::TransactionError),
#[error("Commit error: {0}")]
Commit(#[from] redb::CommitError),
#[error("Storage error: {0}")]
Storage(#[from] redb::StorageError),
}
/// Global metadata store
pub struct MetaStore {
db: Database,
}
impl MetaStore {
/// Open or create meta.db at the given path
pub fn open(path: impl AsRef<Path>) -> Result<Self, MetaStoreError> {
let db = Database::create(path)?;
// Ensure tables exist
let write_txn = db.begin_write()?;
{
let _ = write_txn.open_table(STORES_TABLE)?;
let _ = write_txn.open_table(META_TABLE)?;
}
write_txn.commit()?;
Ok(Self { db })
}
/// Register a new store
pub fn add_store(&self, store_id: Uuid) -> Result<(), MetaStoreError> {
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(STORES_TABLE)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
table.insert(store_id.as_bytes().as_slice(), now)?;
}
write_txn.commit()?;
Ok(())
}
/// List all registered stores
pub fn list_stores(&self) -> Result<Vec<Uuid>, MetaStoreError> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(STORES_TABLE)?;
let mut stores = Vec::new();
for result in table.iter()? {
let (key, _created_at) = result?;
let bytes: [u8; 16] = key.value().try_into().unwrap_or([0; 16]);
stores.push(Uuid::from_bytes(bytes));
}
Ok(stores)
}
/// Get the root store ID (auto-opened on startup)
pub fn root_store(&self) -> Result<Option<Uuid>, MetaStoreError> {
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(META_TABLE)?;
match table.get(META_ROOT_STORE)? {
Some(value) => {
let bytes: [u8; 16] = value.value().try_into().unwrap_or([0; 16]);
Ok(Some(Uuid::from_bytes(bytes)))
}
None => Ok(None),
}
}
/// Set the root store ID
pub fn set_root_store(&self, store_id: Uuid) -> Result<(), MetaStoreError> {
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(META_TABLE)?;
table.insert(META_ROOT_STORE, store_id.as_bytes().as_slice())?;
}
write_txn.commit()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
#[test]
fn test_add_and_list_stores() {
let path = temp_dir().join("meta_store_test.db");
let _ = std::fs::remove_file(&path);
let meta = MetaStore::open(&path).unwrap();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
meta.add_store(id1).unwrap();
meta.add_store(id2).unwrap();
let stores = meta.list_stores().unwrap();
assert_eq!(stores.len(), 2);
assert!(stores.contains(&id1));
assert!(stores.contains(&id2));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_root_store() {
let path = temp_dir().join("meta_store_root.db");
let _ = std::fs::remove_file(&path);
let meta = MetaStore::open(&path).unwrap();
// Initially no root store
assert_eq!(meta.root_store().unwrap(), None);
let root = Uuid::new_v4();
meta.set_root_store(root).unwrap();
assert_eq!(meta.root_store().unwrap(), Some(root));
let _ = std::fs::remove_file(&path);
}
}
+1
View File
@@ -31,6 +31,7 @@ mod tests {
fn test_entry_with_ops() {
let entry = Entry {
version: 1,
store_id: vec![1u8; 16],
prev_hash: vec![0u8; 32],
seq: 5,
timestamp: Some(Hlc {
+88 -14
View File
@@ -23,6 +23,9 @@ pub enum SigChainError {
#[error("Wrong author: expected {expected}, got {got}")]
WrongAuthor { expected: String, got: String },
#[error("Wrong store_id: expected {expected}, got {got}")]
WrongStoreId { expected: String, got: String },
#[error("Invalid sequence: expected {expected}, got {got}")]
InvalidSequence { expected: u64, got: u64 },
@@ -34,11 +37,14 @@ pub enum SigChainError {
}
/// An append-only log where each entry is cryptographically signed
/// and hash-linked to the previous entry.
/// and hash-linked to the previous entry, scoped to a specific store.
pub struct SigChain {
/// Path to the log file
log_path: PathBuf,
/// Store UUID (16 bytes)
store_id: [u8; 16],
/// Author's public key (32 bytes)
author_id: [u8; 32],
@@ -50,10 +56,11 @@ pub struct SigChain {
}
impl SigChain {
/// Create a new empty sigchain for an author
pub fn new(log_path: impl AsRef<Path>, author_id: [u8; 32]) -> Self {
/// Create a new empty sigchain for a (store, author) pair
pub fn new(log_path: impl AsRef<Path>, store_id: [u8; 16], author_id: [u8; 32]) -> Self {
Self {
log_path: log_path.as_ref().to_path_buf(),
store_id,
author_id,
next_seq: 1,
last_hash: [0u8; 32],
@@ -61,11 +68,11 @@ impl SigChain {
}
/// Load a sigchain from an existing log file
pub fn from_log(log_path: impl AsRef<Path>, author_id: [u8; 32]) -> Result<Self, SigChainError> {
pub fn from_log(log_path: impl AsRef<Path>, store_id: [u8; 16], author_id: [u8; 32]) -> Result<Self, SigChainError> {
let log_path = log_path.as_ref().to_path_buf();
let entries = read_entries(&log_path)?;
let mut chain = Self::new(&log_path, author_id);
let mut chain = Self::new(&log_path, store_id, author_id);
for signed_entry in entries {
// Verify signature
@@ -85,6 +92,18 @@ impl SigChain {
// Decode Entry
let entry = Entry::decode(&signed_entry.entry_bytes[..])?;
// Validate store_id
// Note: Empty/malformed store_id becomes [0u8;16], which fails validation
// against any real UUID store. This intentionally rejects legacy entries.
let entry_store: [u8; 16] = entry.store_id.clone().try_into()
.unwrap_or([0u8; 16]);
if entry_store != store_id {
return Err(SigChainError::WrongStoreId {
expected: hex::encode(store_id),
got: hex::encode(entry_store),
});
}
// Validate sequence
if entry.seq != chain.next_seq {
return Err(SigChainError::InvalidSequence {
@@ -156,6 +175,18 @@ impl SigChain {
// Decode entry
let entry = Entry::decode(&signed_entry.entry_bytes[..])?;
// Validate store_id
// Note: Empty/malformed store_id becomes [0u8;16], which fails validation
// against any real UUID store. This intentionally rejects legacy entries.
let entry_store: [u8; 16] = entry.store_id.clone().try_into()
.unwrap_or([0u8; 16]);
if entry_store != self.store_id {
return Err(SigChainError::WrongStoreId {
expected: hex::encode(self.store_id),
got: hex::encode(entry_store),
});
}
// Validate sequence
if entry.seq != self.next_seq {
return Err(SigChainError::InvalidSequence {
@@ -201,6 +232,7 @@ impl SigChain {
let hlc = HLC::now_with_clock(&SystemClock);
let mut builder = EntryBuilder::new(self.next_seq, hlc)
.store_id(self.store_id.to_vec())
.prev_hash(self.last_hash.to_vec());
// Add operations
@@ -230,12 +262,14 @@ mod tests {
temp_dir().join(format!("lattice_sigchain_test_{}.log", name))
}
const TEST_STORE: [u8; 16] = [1u8; 16];
#[test]
fn test_new_sigchain() {
let path = temp_log_path("new");
let author = [1u8; 32];
let chain = SigChain::new(&path, author);
let chain = SigChain::new(&path, TEST_STORE, author);
assert_eq!(chain.author_id(), &author);
assert_eq!(chain.next_seq(), 1);
@@ -251,10 +285,11 @@ mod tests {
let node = Node::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, author);
let mut chain = SigChain::new(&path, TEST_STORE, author);
let clock = MockClock::new(1000);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/key", b"value".to_vec())
.sign(&node);
@@ -275,11 +310,12 @@ mod tests {
let node = Node::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, author);
let mut chain = SigChain::new(&path, TEST_STORE, author);
let clock = MockClock::new(1000);
for i in 1..=3 {
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash(chain.last_hash.to_vec())
.put(format!("/key/{}", i), format!("value{}", i).into_bytes())
.sign(&node);
@@ -303,9 +339,10 @@ mod tests {
// Write some entries
{
let mut chain = SigChain::new(&path, author);
let mut chain = SigChain::new(&path, TEST_STORE, author);
for i in 1..=3 {
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash(chain.last_hash.to_vec())
.put("/key", b"val".to_vec())
.sign(&node);
@@ -314,7 +351,7 @@ mod tests {
}
// Reload from log
let chain = SigChain::from_log(&path, author).unwrap();
let chain = SigChain::from_log(&path, TEST_STORE, author).unwrap();
assert_eq!(chain.len(), 3);
assert_eq!(chain.next_seq(), 4);
@@ -329,11 +366,12 @@ mod tests {
let node = Node::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, author);
let mut chain = SigChain::new(&path, TEST_STORE, author);
let clock = MockClock::new(1000);
// Try to append with wrong seq (2 instead of 1)
let entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/key", b"val".to_vec())
.sign(&node);
@@ -352,11 +390,12 @@ mod tests {
let node = Node::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, author);
let mut chain = SigChain::new(&path, TEST_STORE, author);
let clock = MockClock::new(1000);
// First entry
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/key", b"v1".to_vec())
.sign(&node);
@@ -364,6 +403,7 @@ mod tests {
// Second entry with wrong prev_hash
let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([99u8; 32].to_vec()) // Wrong!
.put("/key", b"v2".to_vec())
.sign(&node);
@@ -382,11 +422,12 @@ mod tests {
let node = Node::generate();
let other_author = [99u8; 32]; // Different author
let mut chain = SigChain::new(&path, other_author);
let mut chain = SigChain::new(&path, TEST_STORE, other_author);
let clock = MockClock::new(1000);
// Entry signed by node but chain expects other_author
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(TEST_STORE.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/key", b"val".to_vec())
.sign(&node);
@@ -405,7 +446,7 @@ mod tests {
let node = Node::generate();
let author = node.public_key_bytes();
let mut chain = SigChain::new(&path, author);
let mut chain = SigChain::new(&path, TEST_STORE, author);
let ops = vec![
Operation {
@@ -427,4 +468,37 @@ mod tests {
std::fs::remove_file(&path).ok();
}
#[test]
fn test_reject_wrong_store_id() {
let path_a = temp_log_path("storeid_a");
let path_b = temp_log_path("storeid_b");
std::fs::remove_file(&path_a).ok();
std::fs::remove_file(&path_b).ok();
let node = Node::generate();
let author = node.public_key_bytes();
let clock = MockClock::new(1000);
let store_a = [0xAAu8; 16];
let store_b = [0xBBu8; 16];
// Create valid entry for store A
let mut chain_a = SigChain::new(&path_a, store_a, author);
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
.store_id(store_a.to_vec())
.prev_hash([0u8; 32].to_vec())
.put("/key", b"val".to_vec())
.sign(&node);
chain_a.append(&entry).unwrap();
// Try to replay that entry into store B's chain
let mut chain_b = SigChain::new(&path_b, store_b, author);
let result = chain_b.append(&entry);
assert!(matches!(result, Err(SigChainError::WrongStoreId { .. })));
std::fs::remove_file(&path_a).ok();
std::fs::remove_file(&path_b).ok();
}
}
+9
View File
@@ -32,6 +32,7 @@ pub enum EntryError {
/// Builder for creating Entry messages
pub struct EntryBuilder {
version: u32,
store_id: Vec<u8>,
prev_hash: Vec<u8>,
seq: u64,
timestamp: HLC,
@@ -43,6 +44,7 @@ impl EntryBuilder {
pub fn new(seq: u64, timestamp: HLC) -> Self {
Self {
version: 1,
store_id: Vec::new(), // Empty = legacy single-store
prev_hash: vec![0u8; 32], // Genesis or will be set
seq,
timestamp,
@@ -50,6 +52,12 @@ impl EntryBuilder {
}
}
/// Set the store ID (16-byte UUID)
pub fn store_id(mut self, id: impl Into<Vec<u8>>) -> Self {
self.store_id = id.into();
self
}
/// Set the previous entry hash (for chaining)
pub fn prev_hash(mut self, hash: impl Into<Vec<u8>>) -> Self {
self.prev_hash = hash.into();
@@ -87,6 +95,7 @@ impl EntryBuilder {
pub fn build(self) -> Entry {
Entry {
version: self.version,
store_id: self.store_id,
prev_hash: self.prev_hash,
seq: self.seq,
timestamp: Some(Hlc {