feat(store): add deterministic branch store seam
This commit is contained in:
@@ -1,20 +1,313 @@
|
|||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
sync::{Mutex, MutexGuard},
|
||||||
|
};
|
||||||
|
|
||||||
use nana_domain::{RuntimeState, StoryNode};
|
use nana_domain::{RuntimeState, StoryNode};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
pub enum StoreError {
|
pub enum StoreError {
|
||||||
#[error("story not found: {0}")]
|
#[error("story not found: {0}")]
|
||||||
StoryNotFound(String),
|
StoryNotFound(String),
|
||||||
#[error("storage backend is not initialized")]
|
#[error("branch not found: {story_id}/{branch_id}")]
|
||||||
NotInitialized,
|
BranchNotFound { story_id: String, branch_id: String },
|
||||||
|
#[error("node already exists: {0}")]
|
||||||
|
NodeAlreadyExists(String),
|
||||||
|
#[error("parent node not found: {0}")]
|
||||||
|
ParentNotFound(String),
|
||||||
|
#[error("branch head changed: expected {expected}, got {actual}")]
|
||||||
|
StaleBranchHead { expected: String, actual: String },
|
||||||
|
#[error("node and materialized state disagree: {0}")]
|
||||||
|
StateMismatch(&'static str),
|
||||||
|
#[error("storage lock is poisoned")]
|
||||||
|
Poisoned,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait StoryStore: Send + Sync {
|
pub trait StoryStore: Send + Sync {
|
||||||
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError>;
|
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError>;
|
||||||
|
|
||||||
fn load_state(
|
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct MemoryData {
|
||||||
|
nodes: BTreeMap<(String, String), StoryNode>,
|
||||||
|
states: BTreeMap<(String, String), RuntimeState>,
|
||||||
|
branch_heads: BTreeMap<(String, String), String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic test and development store.
|
||||||
|
///
|
||||||
|
/// Production persistence will implement the same append-only semantics with
|
||||||
|
/// SQLite. Keeping this implementation in the crate lets the engine/runtime
|
||||||
|
/// integration tests exercise branch rules without depending on a database.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct InMemoryStoryStore {
|
||||||
|
data: Mutex<MemoryData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryStoryStore {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn branch_head(
|
||||||
&self,
|
&self,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
branch_id: &str,
|
branch_id: &str,
|
||||||
) -> Result<RuntimeState, StoreError>;
|
) -> Result<Option<String>, StoreError> {
|
||||||
|
let data = self.lock()?;
|
||||||
|
Ok(data
|
||||||
|
.branch_heads
|
||||||
|
.get(&(story_id.to_owned(), branch_id.to_owned()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_state_at_node(
|
||||||
|
&self,
|
||||||
|
story_id: &str,
|
||||||
|
node_id: &str,
|
||||||
|
) -> Result<RuntimeState, StoreError> {
|
||||||
|
let data = self.lock()?;
|
||||||
|
data.states
|
||||||
|
.get(&(story_id.to_owned(), node_id.to_owned()))
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> Result<MutexGuard<'_, MemoryData>, StoreError> {
|
||||||
|
self.data.lock().map_err(|_| StoreError::Poisoned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoryStore for InMemoryStoryStore {
|
||||||
|
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
||||||
|
validate_materialized_state(node, state)?;
|
||||||
|
|
||||||
|
let node_key = (node.story_id.clone(), node.id.clone());
|
||||||
|
let branch_key = (node.story_id.clone(), node.branch_id.clone());
|
||||||
|
let mut data = self.lock()?;
|
||||||
|
|
||||||
|
if data.nodes.contains_key(&node_key) {
|
||||||
|
return Err(StoreError::NodeAlreadyExists(node.id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent_id) = &node.parent_id {
|
||||||
|
if !data
|
||||||
|
.nodes
|
||||||
|
.contains_key(&(node.story_id.clone(), parent_id.clone()))
|
||||||
|
{
|
||||||
|
return Err(StoreError::ParentNotFound(parent_id.clone()));
|
||||||
|
}
|
||||||
|
} else if data
|
||||||
|
.nodes
|
||||||
|
.keys()
|
||||||
|
.any(|(story_id, _)| story_id == &node.story_id)
|
||||||
|
{
|
||||||
|
return Err(StoreError::ParentNotFound(
|
||||||
|
"a non-root node must name its parent".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current_head) = data.branch_heads.get(&branch_key) {
|
||||||
|
let expected_parent = node.parent_id.as_deref().unwrap_or("<none>");
|
||||||
|
if current_head != expected_parent {
|
||||||
|
return Err(StoreError::StaleBranchHead {
|
||||||
|
expected: current_head.clone(),
|
||||||
|
actual: expected_parent.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All validation happens before any map is changed. This mirrors the
|
||||||
|
// all-or-nothing transaction boundary required from the SQLite store.
|
||||||
|
data.nodes.insert(node_key.clone(), node.clone());
|
||||||
|
data.states.insert(node_key, state.clone());
|
||||||
|
data.branch_heads.insert(branch_key, node.id.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError> {
|
||||||
|
let data = self.lock()?;
|
||||||
|
let story_exists = data
|
||||||
|
.nodes
|
||||||
|
.keys()
|
||||||
|
.any(|(stored_story_id, _)| stored_story_id == story_id);
|
||||||
|
if !story_exists {
|
||||||
|
return Err(StoreError::StoryNotFound(story_id.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let head = data
|
||||||
|
.branch_heads
|
||||||
|
.get(&(story_id.to_owned(), branch_id.to_owned()))
|
||||||
|
.ok_or_else(|| StoreError::BranchNotFound {
|
||||||
|
story_id: story_id.to_owned(),
|
||||||
|
branch_id: branch_id.to_owned(),
|
||||||
|
})?;
|
||||||
|
data.states
|
||||||
|
.get(&(story_id.to_owned(), head.clone()))
|
||||||
|
.cloned()
|
||||||
|
.ok_or(StoreError::StateMismatch("branch head has no state"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_materialized_state(
|
||||||
|
node: &StoryNode,
|
||||||
|
state: &RuntimeState,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
if node.story_id != state.story_id {
|
||||||
|
return Err(StoreError::StateMismatch("story_id"));
|
||||||
|
}
|
||||||
|
if node.id != state.current_node {
|
||||||
|
return Err(StoreError::StateMismatch("current_node"));
|
||||||
|
}
|
||||||
|
if node.branch_id != state.current_branch {
|
||||||
|
return Err(StoreError::StateMismatch("current_branch"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use nana_domain::{RuntimeState, StateDelta, StoryNode};
|
||||||
|
|
||||||
|
use super::{InMemoryStoryStore, StoreError, StoryStore};
|
||||||
|
|
||||||
|
fn state(node: &str, branch: &str) -> RuntimeState {
|
||||||
|
RuntimeState {
|
||||||
|
story_id: "story_demo".to_owned(),
|
||||||
|
current_node: node.to_owned(),
|
||||||
|
current_branch: branch.to_owned(),
|
||||||
|
world_flags: BTreeMap::new(),
|
||||||
|
relationships: BTreeMap::new(),
|
||||||
|
relationship_states: Vec::new(),
|
||||||
|
promises: Vec::new(),
|
||||||
|
knowledge: Vec::new(),
|
||||||
|
items: Vec::new(),
|
||||||
|
clocks: Vec::new(),
|
||||||
|
checks: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node(id: &str, parent_id: Option<&str>, branch: &str) -> StoryNode {
|
||||||
|
StoryNode {
|
||||||
|
id: id.to_owned(),
|
||||||
|
story_id: "story_demo".to_owned(),
|
||||||
|
branch_id: branch.to_owned(),
|
||||||
|
parent_id: parent_id.map(ToOwned::to_owned),
|
||||||
|
action_id: format!("action_{id}"),
|
||||||
|
user_input: String::new(),
|
||||||
|
beats: Vec::new(),
|
||||||
|
delta: StateDelta { ops: Vec::new() },
|
||||||
|
state_hash: format!("hash_{id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appends_and_loads_the_branch_head() {
|
||||||
|
let store = InMemoryStoryStore::new();
|
||||||
|
store
|
||||||
|
.append_node(&node("node_001", None, "branch_main"), &state("node_001", "branch_main"))
|
||||||
|
.expect("root append");
|
||||||
|
store
|
||||||
|
.append_node(
|
||||||
|
&node("node_002", Some("node_001"), "branch_main"),
|
||||||
|
&state("node_002", "branch_main"),
|
||||||
|
)
|
||||||
|
.expect("child append");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.load_state("story_demo", "branch_main")
|
||||||
|
.expect("stored state")
|
||||||
|
.current_node,
|
||||||
|
"node_002"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forks_from_an_old_node_without_moving_the_source_head() {
|
||||||
|
let store = InMemoryStoryStore::new();
|
||||||
|
store
|
||||||
|
.append_node(&node("node_001", None, "branch_main"), &state("node_001", "branch_main"))
|
||||||
|
.expect("root append");
|
||||||
|
store
|
||||||
|
.append_node(
|
||||||
|
&node("node_002", Some("node_001"), "branch_main"),
|
||||||
|
&state("node_002", "branch_main"),
|
||||||
|
)
|
||||||
|
.expect("main append");
|
||||||
|
store
|
||||||
|
.append_node(
|
||||||
|
&node("node_003", Some("node_001"), "branch_rewind"),
|
||||||
|
&state("node_003", "branch_rewind"),
|
||||||
|
)
|
||||||
|
.expect("fork append");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.branch_head("story_demo", "branch_main")
|
||||||
|
.expect("main head"),
|
||||||
|
Some("node_002".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.branch_head("story_demo", "branch_rewind")
|
||||||
|
.expect("fork head"),
|
||||||
|
Some("node_003".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_stale_append_without_moving_the_head() {
|
||||||
|
let store = InMemoryStoryStore::new();
|
||||||
|
store
|
||||||
|
.append_node(&node("node_001", None, "branch_main"), &state("node_001", "branch_main"))
|
||||||
|
.expect("root append");
|
||||||
|
store
|
||||||
|
.append_node(
|
||||||
|
&node("node_002", Some("node_001"), "branch_main"),
|
||||||
|
&state("node_002", "branch_main"),
|
||||||
|
)
|
||||||
|
.expect("main append");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.append_node(
|
||||||
|
&node("node_stale", Some("node_001"), "branch_main"),
|
||||||
|
&state("node_stale", "branch_main"),
|
||||||
|
),
|
||||||
|
Err(StoreError::StaleBranchHead {
|
||||||
|
expected: "node_002".to_owned(),
|
||||||
|
actual: "node_001".to_owned(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.branch_head("story_demo", "branch_main")
|
||||||
|
.expect("main head"),
|
||||||
|
Some("node_002".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_state_that_does_not_match_the_node() {
|
||||||
|
let store = InMemoryStoryStore::new();
|
||||||
|
assert_eq!(
|
||||||
|
store.append_node(
|
||||||
|
&node("node_001", None, "branch_main"),
|
||||||
|
&state("different_node", "branch_main"),
|
||||||
|
),
|
||||||
|
Err(StoreError::StateMismatch("current_node"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.branch_head("story_demo", "branch_main")
|
||||||
|
.expect("empty store"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user