diff --git a/crates/nana-store/src/lib.rs b/crates/nana-store/src/lib.rs index 2b5397b..0ea4605 100644 --- a/crates/nana-store/src/lib.rs +++ b/crates/nana-store/src/lib.rs @@ -38,6 +38,22 @@ pub enum StoreError { Poisoned, } +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ForkError { + #[error("branch already exists: {story_id}/{branch_id}")] + BranchAlreadyExists { story_id: String, branch_id: String }, + #[error("invalid branch id: {0}")] + InvalidBranchId(String), + #[error(transparent)] + Store(#[from] StoreError), +} + +impl From for ForkError { + fn from(error: rusqlite::Error) -> Self { + Self::Store(StoreError::from(error)) + } +} + impl From for StoreError { fn from(error: rusqlite::Error) -> Self { Self::Sqlite(error.to_string()) @@ -53,6 +69,19 @@ impl From for StoreError { pub trait StoryStore: Send + Sync { fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError>; + /// Creates a branch whose initial head is an existing immutable node. + /// + /// The source node and its materialized state are not copied or changed. + /// The returned state is positioned on `new_branch_id`, ready for the next + /// append to use `source_node_id` as its parent. A new branch identifier is + /// 1–128 ASCII bytes containing only letters, numbers, `.`, `_`, or `-`. + fn fork_branch( + &self, + story_id: &str, + source_node_id: &str, + new_branch_id: &str, + ) -> Result; + fn load_state(&self, story_id: &str, branch_id: &str) -> Result; fn load_node(&self, story_id: &str, node_id: &str) -> Result; @@ -157,6 +186,42 @@ impl StoryStore for InMemoryStoryStore { Ok(()) } + fn fork_branch( + &self, + story_id: &str, + source_node_id: &str, + new_branch_id: &str, + ) -> Result { + validate_new_branch_id(new_branch_id)?; + + let branch_key = (story_id.to_owned(), new_branch_id.to_owned()); + let source_key = (story_id.to_owned(), source_node_id.to_owned()); + let mut data = self.lock()?; + + if data.branch_heads.contains_key(&branch_key) { + return Err(ForkError::BranchAlreadyExists { + story_id: story_id.to_owned(), + branch_id: new_branch_id.to_owned(), + }); + } + + let node = data + .nodes + .get(&source_key) + .ok_or_else(|| StoreError::ParentNotFound(source_node_id.to_owned()))?; + let state = data + .states + .get(&source_key) + .ok_or(StoreError::StateMismatch("node has no materialized state"))?; + let restored = + restore_state_for_branch(node, state, story_id, source_node_id, new_branch_id)?; + + // Validation is complete before the only mutation. + data.branch_heads + .insert(branch_key, source_node_id.to_owned()); + Ok(restored) + } + fn load_state(&self, story_id: &str, branch_id: &str) -> Result { let data = self.lock()?; let story_exists = data @@ -174,10 +239,15 @@ impl StoryStore for InMemoryStoryStore { story_id: story_id.to_owned(), branch_id: branch_id.to_owned(), })?; - data.states + let node = data + .nodes .get(&(story_id.to_owned(), head.clone())) - .cloned() - .ok_or(StoreError::StateMismatch("branch head has no state")) + .ok_or(StoreError::StateMismatch("branch head has no node"))?; + let state = data + .states + .get(&(story_id.to_owned(), head.clone())) + .ok_or(StoreError::StateMismatch("branch head has no state"))?; + restore_state_for_branch(node, state, story_id, head, branch_id) } fn load_node(&self, story_id: &str, node_id: &str) -> Result { @@ -385,6 +455,78 @@ impl StoryStore for SqliteStoryStore { Ok(()) } + fn fork_branch( + &self, + story_id: &str, + source_node_id: &str, + new_branch_id: &str, + ) -> Result { + validate_new_branch_id(new_branch_id)?; + + let mut connection = self.lock()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let branch_exists = transaction.query_row( + "SELECT EXISTS( + SELECT 1 FROM branch_heads + WHERE story_id = ?1 AND branch_id = ?2 + )", + params![story_id, new_branch_id], + |row| row.get::<_, bool>(0), + )?; + if branch_exists { + return Err(ForkError::BranchAlreadyExists { + story_id: story_id.to_owned(), + branch_id: new_branch_id.to_owned(), + }); + } + + let stored = transaction + .query_row( + "SELECT nodes.branch_id, nodes.parent_id, nodes.node_json, + materialized_states.state_json + FROM nodes + LEFT JOIN materialized_states + ON materialized_states.story_id = nodes.story_id + AND materialized_states.node_id = nodes.node_id + WHERE nodes.story_id = ?1 AND nodes.node_id = ?2", + params![story_id, source_node_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .optional()? + .ok_or_else(|| StoreError::ParentNotFound(source_node_id.to_owned()))?; + + let node = deserialize_node(&stored.2)?; + validate_loaded_node( + &node, + story_id, + source_node_id, + &stored.0, + stored.1.as_deref(), + )?; + let state_json = stored + .3 + .ok_or(StoreError::StateMismatch("node has no materialized state"))?; + let state = deserialize_state(&state_json)?; + let restored = + restore_state_for_branch(&node, &state, story_id, source_node_id, new_branch_id)?; + + transaction.execute( + "INSERT INTO branch_heads (story_id, branch_id, head_node_id) + VALUES (?1, ?2, ?3)", + params![story_id, new_branch_id, source_node_id], + )?; + transaction.commit()?; + Ok(restored) + } + fn load_state(&self, story_id: &str, branch_id: &str) -> Result { let connection = self.lock()?; let story_exists = connection.query_row( @@ -404,7 +546,6 @@ impl StoryStore for SqliteStoryStore { JOIN nodes ON nodes.story_id = branch_heads.story_id AND nodes.node_id = branch_heads.head_node_id - AND nodes.branch_id = branch_heads.branch_id LEFT JOIN materialized_states ON materialized_states.story_id = branch_heads.story_id AND materialized_states.node_id = branch_heads.head_node_id @@ -433,9 +574,7 @@ impl StoryStore for SqliteStoryStore { .4 .ok_or(StoreError::StateMismatch("branch head has no state"))?; let state = deserialize_state(&state_json)?; - validate_loaded_state(&state, story_id, &stored.0, branch_id)?; - validate_state_hash(&node, &state)?; - Ok(state) + restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id) } fn load_node(&self, story_id: &str, node_id: &str) -> Result { @@ -933,6 +1072,20 @@ fn connection_configuration(detail: &str) -> StoreError { )) } +fn validate_new_branch_id(branch_id: &str) -> Result<(), ForkError> { + const MAX_BRANCH_ID_BYTES: usize = 128; + + let is_valid = !branch_id.is_empty() + && branch_id.len() <= MAX_BRANCH_ID_BYTES + && branch_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')); + if !is_valid { + return Err(ForkError::InvalidBranchId(branch_id.to_owned())); + } + Ok(()) +} + fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> { if node.story_id != state.story_id { return Err(StoreError::StateMismatch("story_id")); @@ -947,6 +1100,31 @@ fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result Ok(()) } +fn restore_state_for_branch( + node: &StoryNode, + state: &RuntimeState, + story_id: &str, + node_id: &str, + branch_id: &str, +) -> Result { + // A branch created from history points at the original immutable node. + // Validate the persisted pair with its original branch identity before + // normalizing only the caller-visible cursor. + validate_loaded_node( + node, + story_id, + node_id, + &node.branch_id, + node.parent_id.as_deref(), + )?; + validate_loaded_state(state, story_id, node_id, &node.branch_id)?; + validate_state_hash(node, state)?; + + let mut restored = state.clone(); + branch_id.clone_into(&mut restored.current_branch); + Ok(restored) +} + fn deserialize_node(node_json: &str) -> Result { serde_json::from_str(node_json).map_err(StoreError::from) } @@ -1020,8 +1198,8 @@ mod tests { use rusqlite::{Connection, params}; use super::{ - BUSY_TIMEOUT_MILLIS, InMemoryStoryStore, SCHEMA_VERSION, SqliteStoryStore, StoreError, - StoryStore, + BUSY_TIMEOUT_MILLIS, ForkError, InMemoryStoryStore, SCHEMA_VERSION, SqliteStoryStore, + StoreError, StoryStore, }; fn state(node: &str, branch: &str) -> RuntimeState { @@ -1241,6 +1419,153 @@ mod tests { ); } + fn seed_historical_main(store: &impl InspectableStoryStore) { + 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"); + } + + fn assert_creates_independent_branches_from_history(store: &impl InspectableStoryStore) { + seed_historical_main(store); + + let alpha = store + .fork_branch("story_demo", "node_001", "branch_alpha") + .expect("first historical fork"); + let beta = store + .fork_branch("story_demo", "node_001", "branch_beta") + .expect("second historical fork"); + + assert_eq!(alpha.current_node, "node_001"); + assert_eq!(alpha.current_branch, "branch_alpha"); + assert_eq!(beta.current_node, "node_001"); + assert_eq!(beta.current_branch, "branch_beta"); + assert_eq!( + store + .load_node("story_demo", "node_001") + .expect("immutable source node") + .branch_id, + "branch_main" + ); + assert_eq!( + store + .inspected_state_at_node("story_demo", "node_001") + .expect("immutable source state") + .current_branch, + "branch_main" + ); + + store + .append_node( + &node("node_alpha", Some("node_001"), "branch_alpha"), + &state("node_alpha", "branch_alpha"), + ) + .expect("append on first fork"); + + assert_eq!( + store + .inspected_branch_head("story_demo", "branch_main") + .expect("main head"), + Some("node_002".to_owned()) + ); + assert_eq!( + store + .inspected_branch_head("story_demo", "branch_alpha") + .expect("alpha head"), + Some("node_alpha".to_owned()) + ); + assert_eq!( + store + .inspected_branch_head("story_demo", "branch_beta") + .expect("beta head"), + Some("node_001".to_owned()) + ); + assert_eq!( + store + .load_state("story_demo", "branch_beta") + .expect("normalized beta state") + .current_branch, + "branch_beta" + ); + + assert_eq!( + store.append_node( + &node("node_stale", Some("node_001"), "branch_alpha"), + &state("node_stale", "branch_alpha"), + ), + Err(StoreError::StaleBranchHead { + expected: "node_alpha".to_owned(), + actual: "node_001".to_owned(), + }) + ); + assert_eq!( + store + .inspected_branch_head("story_demo", "branch_alpha") + .expect("unchanged alpha head"), + Some("node_alpha".to_owned()) + ); + } + + fn assert_rejects_invalid_duplicate_and_unknown_forks(store: &impl InspectableStoryStore) { + store + .append_node( + &node("node_001", None, "branch_main"), + &state("node_001", "branch_main"), + ) + .expect("root append"); + + for invalid in ["", " ", "branch/slash", "分支"] { + assert_eq!( + store.fork_branch("story_demo", "node_001", invalid), + Err(ForkError::InvalidBranchId(invalid.to_owned())) + ); + assert_eq!( + store + .inspected_branch_head("story_demo", invalid) + .expect("invalid branch lookup"), + None + ); + } + + assert_eq!( + store.fork_branch("story_demo", "unknown_node", "branch_unknown"), + Err(ForkError::Store(StoreError::ParentNotFound( + "unknown_node".to_owned() + ))) + ); + assert_eq!( + store + .inspected_branch_head("story_demo", "branch_unknown") + .expect("unknown source fork"), + None + ); + + store + .fork_branch("story_demo", "node_001", "branch_new") + .expect("initial fork"); + assert_eq!( + store.fork_branch("story_demo", "node_001", "branch_new"), + Err(ForkError::BranchAlreadyExists { + story_id: "story_demo".to_owned(), + branch_id: "branch_new".to_owned(), + }) + ); + assert_eq!( + store + .inspected_branch_head("story_demo", "branch_new") + .expect("duplicate fork"), + Some("node_001".to_owned()) + ); + } + #[test] fn memory_appends_and_loads_the_branch_head() { let store = InMemoryStoryStore::new(); @@ -1265,6 +1590,30 @@ mod tests { assert_forks_from_an_old_node(&store); } + #[test] + fn memory_creates_independent_branch_heads_from_historical_nodes() { + let store = InMemoryStoryStore::new(); + assert_creates_independent_branches_from_history(&store); + } + + #[test] + fn sqlite_creates_independent_branch_heads_from_historical_nodes() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + assert_creates_independent_branches_from_history(&store); + } + + #[test] + fn memory_rejects_invalid_duplicate_and_unknown_forks_atomically() { + let store = InMemoryStoryStore::new(); + assert_rejects_invalid_duplicate_and_unknown_forks(&store); + } + + #[test] + fn sqlite_rejects_invalid_duplicate_and_unknown_forks_atomically() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + assert_rejects_invalid_duplicate_and_unknown_forks(&store); + } + #[test] fn memory_rejects_a_stale_append_without_moving_the_head() { let store = InMemoryStoryStore::new(); @@ -1385,6 +1734,115 @@ mod tests { node_id: "node_001".to_owned() }) ); + assert_eq!( + store.fork_branch("story_demo", "node_001", "branch_corrupt"), + Err(ForkError::Store(StoreError::StateHashMismatch { + node_id: "node_001".to_owned() + })) + ); + assert_eq!( + store + .branch_head("story_demo", "branch_corrupt") + .expect("failed corrupt fork"), + None + ); + } + + #[test] + fn memory_rejects_a_corrupt_source_hash_without_creating_a_branch() { + let store = InMemoryStoryStore::new(); + store + .append_node( + &node("node_001", None, "branch_main"), + &state("node_001", "branch_main"), + ) + .expect("root append"); + store + .data + .lock() + .expect("memory store lock") + .states + .get_mut(&("story_demo".to_owned(), "node_001".to_owned())) + .expect("source state") + .world_flags + .insert("silently_changed".to_owned(), true); + + assert_eq!( + store.fork_branch("story_demo", "node_001", "branch_corrupt"), + Err(ForkError::Store(StoreError::StateHashMismatch { + node_id: "node_001".to_owned() + })) + ); + assert_eq!( + store + .branch_head("story_demo", "branch_corrupt") + .expect("failed corrupt fork"), + None + ); + } + + #[test] + fn memory_rejects_a_source_without_materialized_state() { + let store = InMemoryStoryStore::new(); + store + .append_node( + &node("node_001", None, "branch_main"), + &state("node_001", "branch_main"), + ) + .expect("root append"); + store + .data + .lock() + .expect("memory store lock") + .states + .remove(&("story_demo".to_owned(), "node_001".to_owned())); + + assert_eq!( + store.fork_branch("story_demo", "node_001", "branch_missing_state"), + Err(ForkError::Store(StoreError::StateMismatch( + "node has no materialized state" + ))) + ); + assert_eq!( + store + .branch_head("story_demo", "branch_missing_state") + .expect("failed missing-state fork"), + None + ); + } + + #[test] + fn sqlite_rejects_a_source_without_materialized_state() { + let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); + store + .append_node( + &node("node_001", None, "branch_main"), + &state("node_001", "branch_main"), + ) + .expect("root append"); + store + .connection + .lock() + .expect("SQLite connection lock") + .execute( + "DELETE FROM materialized_states + WHERE story_id = ?1 AND node_id = ?2", + params!["story_demo", "node_001"], + ) + .expect("remove source state"); + + assert_eq!( + store.fork_branch("story_demo", "node_001", "branch_missing_state"), + Err(ForkError::Store(StoreError::StateMismatch( + "node has no materialized state" + ))) + ); + assert_eq!( + store + .branch_head("story_demo", "branch_missing_state") + .expect("failed missing-state fork"), + None + ); } #[test] @@ -1410,10 +1868,22 @@ mod tests { store.load_node("story_demo", "node_001"), Err(StoreError::StateMismatch("current_branch")) ); + assert_eq!( + store.fork_branch("story_demo", "node_001", "branch_corrupt"), + Err(ForkError::Store(StoreError::StateMismatch( + "current_branch" + ))) + ); + assert_eq!( + store + .branch_head("story_demo", "branch_corrupt") + .expect("failed corrupt fork"), + None + ); } #[test] - fn sqlite_rejects_a_branch_head_pointing_at_another_branch() { + fn sqlite_normalizes_a_cross_branch_head_after_verifying_the_original_state() { let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store"); store .append_node( @@ -1439,12 +1909,17 @@ mod tests { ) .expect("corrupt branch head"); + let restored = store + .load_state("story_demo", "branch_main") + .expect("cross-branch head"); + assert_eq!(restored.current_node, "node_other"); + assert_eq!(restored.current_branch, "branch_main"); assert_eq!( - store.load_state("story_demo", "branch_main"), - Err(StoreError::BranchNotFound { - story_id: "story_demo".to_owned(), - branch_id: "branch_main".to_owned() - }) + store + .load_node("story_demo", "node_other") + .expect("immutable node") + .branch_id, + "branch_other" ); } @@ -1745,4 +2220,50 @@ mod tests { "node_001" ); } + + #[test] + fn sqlite_reopens_a_historical_fork_and_can_append_from_its_source() { + let database = TemporaryDatabase::new(); + { + let store = SqliteStoryStore::open(database.path()).expect("file SQLite store"); + seed_historical_main(&store); + let forked = store + .fork_branch("story_demo", "node_001", "branch_reopened") + .expect("historical fork"); + assert_eq!(forked.current_node, "node_001"); + assert_eq!(forked.current_branch, "branch_reopened"); + } + + let reopened = SqliteStoryStore::open(database.path()).expect("reopened SQLite store"); + let restored = reopened + .load_state("story_demo", "branch_reopened") + .expect("persisted fork cursor"); + assert_eq!(restored.current_node, "node_001"); + assert_eq!(restored.current_branch, "branch_reopened"); + assert_eq!( + reopened + .branch_head("story_demo", "branch_main") + .expect("unchanged main head"), + Some("node_002".to_owned()) + ); + + reopened + .append_node( + &node("node_reopened", Some("node_001"), "branch_reopened"), + &state("node_reopened", "branch_reopened"), + ) + .expect("append after reopen"); + assert_eq!( + reopened + .branch_head("story_demo", "branch_reopened") + .expect("advanced reopened branch"), + Some("node_reopened".to_owned()) + ); + assert_eq!( + reopened + .branch_head("story_demo", "branch_main") + .expect("main remains unchanged"), + Some("node_002".to_owned()) + ); + } }