feat(runtime): preserve current branch context

This commit is contained in:
2026-07-28 22:52:06 +08:00
parent 37b3397e4a
commit d0612b399c
8 changed files with 1418 additions and 85 deletions
+275 -1
View File
@@ -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(&current_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();