feat(runtime): preserve current branch context
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
sync::{Mutex, MutexGuard},
|
||||
time::Duration,
|
||||
@@ -96,6 +96,18 @@ pub trait StoryStore: Send + Sync {
|
||||
|
||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
|
||||
|
||||
/// Loads the immutable path from the story root through `node_id`.
|
||||
///
|
||||
/// Parent links may cross branch identifiers because a branch created from
|
||||
/// history shares its ancestors with the source branch. Implementations
|
||||
/// therefore follow only `(story_id, node_id)` and never filter ancestors
|
||||
/// by the current node's `branch_id`.
|
||||
fn load_ancestor_chain(
|
||||
&self,
|
||||
story_id: &str,
|
||||
node_id: &str,
|
||||
) -> Result<Vec<StoryNode>, StoreError>;
|
||||
|
||||
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError>;
|
||||
|
||||
fn active_branch(&self, story_id: &str) -> Result<String, StoreError>;
|
||||
@@ -332,6 +344,20 @@ impl StoryStore for InMemoryStoryStore {
|
||||
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
|
||||
}
|
||||
|
||||
fn load_ancestor_chain(
|
||||
&self,
|
||||
story_id: &str,
|
||||
node_id: &str,
|
||||
) -> Result<Vec<StoryNode>, StoreError> {
|
||||
let data = self.lock()?;
|
||||
walk_ancestor_chain(node_id, |candidate_id| {
|
||||
Ok(data
|
||||
.nodes
|
||||
.get(&(story_id.to_owned(), candidate_id.to_owned()))
|
||||
.cloned())
|
||||
})
|
||||
}
|
||||
|
||||
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError> {
|
||||
let data = self.lock()?;
|
||||
if !data
|
||||
@@ -816,6 +842,44 @@ impl StoryStore for SqliteStoryStore {
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn load_ancestor_chain(
|
||||
&self,
|
||||
story_id: &str,
|
||||
node_id: &str,
|
||||
) -> Result<Vec<StoryNode>, StoreError> {
|
||||
let connection = self.lock()?;
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT branch_id, parent_id, node_json
|
||||
FROM nodes
|
||||
WHERE story_id = ?1 AND node_id = ?2",
|
||||
)?;
|
||||
|
||||
walk_ancestor_chain(node_id, |candidate_id| {
|
||||
let stored = statement
|
||||
.query_row(params![story_id, candidate_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()?;
|
||||
let Some((branch_id, parent_id, node_json)) = stored else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let node = deserialize_node(&node_json)?;
|
||||
validate_loaded_node(
|
||||
&node,
|
||||
story_id,
|
||||
candidate_id,
|
||||
&branch_id,
|
||||
parent_id.as_deref(),
|
||||
)?;
|
||||
Ok(Some(node))
|
||||
})
|
||||
}
|
||||
|
||||
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError> {
|
||||
let connection = self.lock()?;
|
||||
let story_exists = connection.query_row(
|
||||
@@ -1727,6 +1791,34 @@ fn load_branch_state(
|
||||
restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id)
|
||||
}
|
||||
|
||||
fn walk_ancestor_chain(
|
||||
node_id: &str,
|
||||
mut load_node: impl FnMut(&str) -> Result<Option<StoryNode>, StoreError>,
|
||||
) -> Result<Vec<StoryNode>, StoreError> {
|
||||
let mut current_id = node_id.to_owned();
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut chain = Vec::new();
|
||||
|
||||
loop {
|
||||
if !visited.insert(current_id.clone()) {
|
||||
return Err(StoreError::StateMismatch("ancestry cycle"));
|
||||
}
|
||||
|
||||
let node = load_node(¤t_id)?
|
||||
.ok_or_else(|| StoreError::ParentNotFound(current_id.clone()))?;
|
||||
let parent_id = node.parent_id.clone();
|
||||
chain.push(node);
|
||||
|
||||
let Some(parent_id) = parent_id else {
|
||||
break;
|
||||
};
|
||||
current_id = parent_id;
|
||||
}
|
||||
|
||||
chain.reverse();
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
||||
if node.story_id != state.story_id {
|
||||
return Err(StoreError::StateMismatch("story_id"));
|
||||
@@ -2075,6 +2167,104 @@ mod tests {
|
||||
.expect("main append");
|
||||
}
|
||||
|
||||
fn assert_loads_only_the_selected_branch_path(store: &impl InspectableStoryStore) {
|
||||
seed_historical_main(store);
|
||||
store
|
||||
.fork_branch("story_demo", "node_001", "branch_rewind")
|
||||
.expect("historical fork");
|
||||
store
|
||||
.append_node(
|
||||
&node("node_rewind", Some("node_001"), "branch_rewind"),
|
||||
&state("node_rewind", "branch_rewind"),
|
||||
)
|
||||
.expect("rewind append");
|
||||
|
||||
let main_path = store
|
||||
.load_ancestor_chain("story_demo", "node_002")
|
||||
.expect("main ancestry");
|
||||
assert_eq!(
|
||||
main_path
|
||||
.iter()
|
||||
.map(|node| node.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["node_001", "node_002"]
|
||||
);
|
||||
|
||||
let rewind_path = store
|
||||
.load_ancestor_chain("story_demo", "node_rewind")
|
||||
.expect("rewind ancestry");
|
||||
assert_eq!(
|
||||
rewind_path
|
||||
.iter()
|
||||
.map(|node| (node.id.as_str(), node.branch_id.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("node_001", "branch_main"),
|
||||
("node_rewind", "branch_rewind"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_loads_a_500_node_ancestor_chain(store: &impl InspectableStoryStore) {
|
||||
let mut parent_id = None;
|
||||
for index in 0..500 {
|
||||
let node_id = format!("node_{index:03}");
|
||||
store
|
||||
.append_node(
|
||||
&node(&node_id, parent_id.as_deref(), "branch_main"),
|
||||
&state(&node_id, "branch_main"),
|
||||
)
|
||||
.expect("long-chain append");
|
||||
parent_id = Some(node_id);
|
||||
}
|
||||
|
||||
let chain = store
|
||||
.load_ancestor_chain("story_demo", "node_499")
|
||||
.expect("500-node ancestry");
|
||||
assert_eq!(chain.len(), 500);
|
||||
assert_eq!(chain.first().map(|node| node.id.as_str()), Some("node_000"));
|
||||
assert_eq!(chain.last().map(|node| node.id.as_str()), Some("node_499"));
|
||||
}
|
||||
|
||||
fn assert_reports_an_unknown_ancestor_start(store: &impl InspectableStoryStore) {
|
||||
assert_eq!(
|
||||
store.load_ancestor_chain("story_demo", "node_missing"),
|
||||
Err(StoreError::ParentNotFound("node_missing".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
fn set_memory_parent(store: &InMemoryStoryStore, node_id: &str, parent_id: Option<&str>) {
|
||||
let mut data = store.data.lock().expect("memory store lock");
|
||||
data.nodes
|
||||
.get_mut(&("story_demo".to_owned(), node_id.to_owned()))
|
||||
.expect("test node")
|
||||
.parent_id = parent_id.map(ToOwned::to_owned);
|
||||
}
|
||||
|
||||
fn set_sqlite_parent(store: &SqliteStoryStore, node_id: &str, parent_id: Option<&str>) {
|
||||
let mut corrupted = store
|
||||
.load_node("story_demo", node_id)
|
||||
.expect("stored test node");
|
||||
corrupted.parent_id = parent_id.map(ToOwned::to_owned);
|
||||
let node_json = serde_json::to_string(&corrupted).expect("serializable test node");
|
||||
|
||||
let connection = store.connection.lock().expect("SQLite connection lock");
|
||||
connection
|
||||
.execute_batch("PRAGMA foreign_keys = OFF;")
|
||||
.expect("disable foreign keys for corruption test");
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE nodes
|
||||
SET parent_id = ?1, node_json = ?2
|
||||
WHERE story_id = 'story_demo' AND node_id = ?3",
|
||||
params![parent_id, node_json, node_id],
|
||||
)
|
||||
.expect("corrupt test ancestry");
|
||||
connection
|
||||
.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.expect("restore foreign keys after corruption test");
|
||||
}
|
||||
|
||||
fn assert_creates_independent_branches_from_history(store: &impl InspectableStoryStore) {
|
||||
seed_historical_main(store);
|
||||
|
||||
@@ -2277,6 +2467,90 @@ mod tests {
|
||||
assert_forks_from_an_old_node(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_loads_ancestors_across_shared_branch_history() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
assert_loads_only_the_selected_branch_path(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_loads_ancestors_across_shared_branch_history() {
|
||||
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||
assert_loads_only_the_selected_branch_path(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_loads_500_ancestors_without_a_ui_depth_limit() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
assert_loads_a_500_node_ancestor_chain(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_loads_500_ancestors_without_a_ui_depth_limit() {
|
||||
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||
assert_loads_a_500_node_ancestor_chain(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_reports_an_unknown_ancestor_start() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
assert_reports_an_unknown_ancestor_start(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_reports_an_unknown_ancestor_start() {
|
||||
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||
assert_reports_an_unknown_ancestor_start(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_reports_a_broken_ancestor_link() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
seed_historical_main(&store);
|
||||
set_memory_parent(&store, "node_002", Some("node_missing"));
|
||||
|
||||
assert_eq!(
|
||||
store.load_ancestor_chain("story_demo", "node_002"),
|
||||
Err(StoreError::ParentNotFound("node_missing".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_reports_a_broken_ancestor_link() {
|
||||
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||
seed_historical_main(&store);
|
||||
set_sqlite_parent(&store, "node_002", Some("node_missing"));
|
||||
|
||||
assert_eq!(
|
||||
store.load_ancestor_chain("story_demo", "node_002"),
|
||||
Err(StoreError::ParentNotFound("node_missing".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_reports_an_ancestor_cycle() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
seed_historical_main(&store);
|
||||
set_memory_parent(&store, "node_001", Some("node_002"));
|
||||
|
||||
assert_eq!(
|
||||
store.load_ancestor_chain("story_demo", "node_002"),
|
||||
Err(StoreError::StateMismatch("ancestry cycle"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_reports_an_ancestor_cycle() {
|
||||
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||
seed_historical_main(&store);
|
||||
set_sqlite_parent(&store, "node_001", Some("node_002"));
|
||||
|
||||
assert_eq!(
|
||||
store.load_ancestor_chain("story_demo", "node_002"),
|
||||
Err(StoreError::StateMismatch("ancestry cycle"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_creates_independent_branch_heads_from_historical_nodes() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
|
||||
Reference in New Issue
Block a user