feat(store): fork branches from persisted history
This commit is contained in:
+536
-15
@@ -38,6 +38,22 @@ pub enum StoreError {
|
|||||||
Poisoned,
|
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 {
|
impl From<rusqlite::Error> for StoreError {
|
||||||
fn from(error: rusqlite::Error) -> Self {
|
fn from(error: rusqlite::Error) -> Self {
|
||||||
Self::Sqlite(error.to_string())
|
Self::Sqlite(error.to_string())
|
||||||
@@ -53,6 +69,19 @@ impl From<serde_json::Error> for StoreError {
|
|||||||
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>;
|
||||||
|
|
||||||
|
/// 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_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError>;
|
||||||
|
|
||||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
|
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
|
||||||
@@ -157,6 +186,42 @@ impl StoryStore for InMemoryStoryStore {
|
|||||||
Ok(())
|
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> {
|
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError> {
|
||||||
let data = self.lock()?;
|
let data = self.lock()?;
|
||||||
let story_exists = data
|
let story_exists = data
|
||||||
@@ -174,10 +239,15 @@ impl StoryStore for InMemoryStoryStore {
|
|||||||
story_id: story_id.to_owned(),
|
story_id: story_id.to_owned(),
|
||||||
branch_id: branch_id.to_owned(),
|
branch_id: branch_id.to_owned(),
|
||||||
})?;
|
})?;
|
||||||
data.states
|
let node = data
|
||||||
|
.nodes
|
||||||
.get(&(story_id.to_owned(), head.clone()))
|
.get(&(story_id.to_owned(), head.clone()))
|
||||||
.cloned()
|
.ok_or(StoreError::StateMismatch("branch head has no node"))?;
|
||||||
.ok_or(StoreError::StateMismatch("branch head has no state"))
|
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> {
|
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError> {
|
||||||
@@ -385,6 +455,78 @@ impl StoryStore for SqliteStoryStore {
|
|||||||
Ok(())
|
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> {
|
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError> {
|
||||||
let connection = self.lock()?;
|
let connection = self.lock()?;
|
||||||
let story_exists = connection.query_row(
|
let story_exists = connection.query_row(
|
||||||
@@ -404,7 +546,6 @@ impl StoryStore for SqliteStoryStore {
|
|||||||
JOIN nodes
|
JOIN nodes
|
||||||
ON nodes.story_id = branch_heads.story_id
|
ON nodes.story_id = branch_heads.story_id
|
||||||
AND nodes.node_id = branch_heads.head_node_id
|
AND nodes.node_id = branch_heads.head_node_id
|
||||||
AND nodes.branch_id = branch_heads.branch_id
|
|
||||||
LEFT JOIN materialized_states
|
LEFT JOIN materialized_states
|
||||||
ON materialized_states.story_id = branch_heads.story_id
|
ON materialized_states.story_id = branch_heads.story_id
|
||||||
AND materialized_states.node_id = branch_heads.head_node_id
|
AND materialized_states.node_id = branch_heads.head_node_id
|
||||||
@@ -433,9 +574,7 @@ impl StoryStore for SqliteStoryStore {
|
|||||||
.4
|
.4
|
||||||
.ok_or(StoreError::StateMismatch("branch head has no state"))?;
|
.ok_or(StoreError::StateMismatch("branch head has no state"))?;
|
||||||
let state = deserialize_state(&state_json)?;
|
let state = deserialize_state(&state_json)?;
|
||||||
validate_loaded_state(&state, story_id, &stored.0, branch_id)?;
|
restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id)
|
||||||
validate_state_hash(&node, &state)?;
|
|
||||||
Ok(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError> {
|
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> {
|
fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
||||||
if node.story_id != state.story_id {
|
if node.story_id != state.story_id {
|
||||||
return Err(StoreError::StateMismatch("story_id"));
|
return Err(StoreError::StateMismatch("story_id"));
|
||||||
@@ -947,6 +1100,31 @@ fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result
|
|||||||
Ok(())
|
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> {
|
fn deserialize_node(node_json: &str) -> Result<StoryNode, StoreError> {
|
||||||
serde_json::from_str(node_json).map_err(StoreError::from)
|
serde_json::from_str(node_json).map_err(StoreError::from)
|
||||||
}
|
}
|
||||||
@@ -1020,8 +1198,8 @@ mod tests {
|
|||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, params};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BUSY_TIMEOUT_MILLIS, InMemoryStoryStore, SCHEMA_VERSION, SqliteStoryStore, StoreError,
|
BUSY_TIMEOUT_MILLIS, ForkError, InMemoryStoryStore, SCHEMA_VERSION, SqliteStoryStore,
|
||||||
StoryStore,
|
StoreError, StoryStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn state(node: &str, branch: &str) -> RuntimeState {
|
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]
|
#[test]
|
||||||
fn memory_appends_and_loads_the_branch_head() {
|
fn memory_appends_and_loads_the_branch_head() {
|
||||||
let store = InMemoryStoryStore::new();
|
let store = InMemoryStoryStore::new();
|
||||||
@@ -1265,6 +1590,30 @@ mod tests {
|
|||||||
assert_forks_from_an_old_node(&store);
|
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]
|
#[test]
|
||||||
fn memory_rejects_a_stale_append_without_moving_the_head() {
|
fn memory_rejects_a_stale_append_without_moving_the_head() {
|
||||||
let store = InMemoryStoryStore::new();
|
let store = InMemoryStoryStore::new();
|
||||||
@@ -1385,6 +1734,115 @@ mod tests {
|
|||||||
node_id: "node_001".to_owned()
|
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]
|
#[test]
|
||||||
@@ -1410,10 +1868,22 @@ mod tests {
|
|||||||
store.load_node("story_demo", "node_001"),
|
store.load_node("story_demo", "node_001"),
|
||||||
Err(StoreError::StateMismatch("current_branch"))
|
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]
|
#[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");
|
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||||
store
|
store
|
||||||
.append_node(
|
.append_node(
|
||||||
@@ -1439,12 +1909,17 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("corrupt branch head");
|
.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!(
|
assert_eq!(
|
||||||
store.load_state("story_demo", "branch_main"),
|
store
|
||||||
Err(StoreError::BranchNotFound {
|
.load_node("story_demo", "node_other")
|
||||||
story_id: "story_demo".to_owned(),
|
.expect("immutable node")
|
||||||
branch_id: "branch_main".to_owned()
|
.branch_id,
|
||||||
})
|
"branch_other"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1745,4 +2220,50 @@ mod tests {
|
|||||||
"node_001"
|
"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