feat(store): fork branches from persisted history
This commit is contained in:
+536
-15
@@ -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<rusqlite::Error> for ForkError {
|
||||
fn from(error: rusqlite::Error) -> Self {
|
||||
Self::Store(StoreError::from(error))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for StoreError {
|
||||
fn from(error: rusqlite::Error) -> Self {
|
||||
Self::Sqlite(error.to_string())
|
||||
@@ -53,6 +69,19 @@ impl From<serde_json::Error> 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<RuntimeState, ForkError>;
|
||||
|
||||
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError>;
|
||||
|
||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
|
||||
@@ -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<RuntimeState, ForkError> {
|
||||
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<RuntimeState, StoreError> {
|
||||
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<StoryNode, StoreError> {
|
||||
@@ -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<RuntimeState, ForkError> {
|
||||
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<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(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<RuntimeState, StoreError> {
|
||||
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<StoryNode, StoreError> {
|
||||
@@ -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<RuntimeState, StoreError> {
|
||||
// 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<StoryNode, StoreError> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user