feat(runtime): prepare safe context checkpoints
This commit is contained in:
@@ -79,6 +79,22 @@ impl From<serde_json::Error> for StoreError {
|
||||
pub trait StoryStore: Send + Sync {
|
||||
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError>;
|
||||
|
||||
/// Atomically commits a narrative-only replacement as a sibling node on a
|
||||
/// new branch, leaving `source_branch_id` and `replaced_node_id` immutable.
|
||||
///
|
||||
/// The caller supplies a node whose parent, player input, authoritative
|
||||
/// delta, and materialized facts match the replaced node. Only presentation,
|
||||
/// node/action identity, and branch identity may differ. The source branch
|
||||
/// must still be active and headed by the replaced node when the transaction
|
||||
/// commits; on success the new branch becomes active.
|
||||
fn append_regenerated_node(
|
||||
&self,
|
||||
source_branch_id: &str,
|
||||
replaced_node_id: &str,
|
||||
node: &StoryNode,
|
||||
state: &RuntimeState,
|
||||
) -> Result<(), ForkError>;
|
||||
|
||||
/// Creates a branch whose initial head is an existing immutable node.
|
||||
///
|
||||
/// The source node and its materialized state are not copied or changed.
|
||||
@@ -94,6 +110,9 @@ pub trait StoryStore: Send + Sync {
|
||||
|
||||
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError>;
|
||||
|
||||
fn load_state_at_node(&self, story_id: &str, node_id: &str)
|
||||
-> Result<RuntimeState, StoreError>;
|
||||
|
||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError>;
|
||||
|
||||
/// Loads the immutable path from the story root through `node_id`.
|
||||
@@ -252,6 +271,110 @@ impl StoryStore for InMemoryStoryStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_regenerated_node(
|
||||
&self,
|
||||
source_branch_id: &str,
|
||||
replaced_node_id: &str,
|
||||
node: &StoryNode,
|
||||
state: &RuntimeState,
|
||||
) -> Result<(), ForkError> {
|
||||
validate_new_branch_id(&node.branch_id)?;
|
||||
validate_materialized_state(node, state)?;
|
||||
|
||||
let node_key = (node.story_id.clone(), node.id.clone());
|
||||
let new_branch_key = (node.story_id.clone(), node.branch_id.clone());
|
||||
let source_branch_key = (node.story_id.clone(), source_branch_id.to_owned());
|
||||
let replaced_key = (node.story_id.clone(), replaced_node_id.to_owned());
|
||||
let mut data = self.lock()?;
|
||||
|
||||
let active_branch = data
|
||||
.active_branches
|
||||
.get(&node.story_id)
|
||||
.ok_or_else(|| StoreError::StoryNotFound(node.story_id.clone()))?;
|
||||
if active_branch != source_branch_id {
|
||||
return Err(StoreError::StaleBranchHead {
|
||||
expected: active_branch.clone(),
|
||||
actual: source_branch_id.to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let source_head = data.branch_heads.get(&source_branch_key).ok_or_else(|| {
|
||||
StoreError::BranchNotFound {
|
||||
story_id: node.story_id.clone(),
|
||||
branch_id: source_branch_id.to_owned(),
|
||||
}
|
||||
})?;
|
||||
if source_head != replaced_node_id {
|
||||
return Err(StoreError::StaleBranchHead {
|
||||
expected: source_head.clone(),
|
||||
actual: replaced_node_id.to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
if data.branch_heads.contains_key(&new_branch_key) {
|
||||
return Err(ForkError::BranchAlreadyExists {
|
||||
story_id: node.story_id.clone(),
|
||||
branch_id: node.branch_id.clone(),
|
||||
});
|
||||
}
|
||||
if data.nodes.contains_key(&node_key) {
|
||||
return Err(StoreError::NodeAlreadyExists(node.id.clone()).into());
|
||||
}
|
||||
|
||||
let replaced_node = data
|
||||
.nodes
|
||||
.get(&replaced_key)
|
||||
.ok_or_else(|| StoreError::ParentNotFound(replaced_node_id.to_owned()))?
|
||||
.clone();
|
||||
let replaced_state = data
|
||||
.states
|
||||
.get(&replaced_key)
|
||||
.ok_or(StoreError::StateMismatch("node has no materialized state"))?
|
||||
.clone();
|
||||
validate_materialized_state(&replaced_node, &replaced_state)?;
|
||||
validate_regenerated_sibling(
|
||||
source_branch_id,
|
||||
&replaced_node,
|
||||
&replaced_state,
|
||||
node,
|
||||
state,
|
||||
)?;
|
||||
|
||||
let ordinal = u32::try_from(
|
||||
data.branch_metadata
|
||||
.keys()
|
||||
.filter(|(story_id, _)| story_id == &node.story_id)
|
||||
.count()
|
||||
+ 1,
|
||||
)
|
||||
.unwrap_or(u32::MAX);
|
||||
let source_node_id = node
|
||||
.parent_id
|
||||
.clone()
|
||||
.ok_or(StoreError::StateMismatch("root node cannot be regenerated"))?;
|
||||
|
||||
// All checks above are complete before any map is changed.
|
||||
data.nodes.insert(node_key.clone(), node.clone());
|
||||
data.states.insert(node_key, state.clone());
|
||||
data.branch_heads
|
||||
.insert(new_branch_key.clone(), node.id.clone());
|
||||
data.branch_metadata.insert(
|
||||
new_branch_key,
|
||||
StoredBranch {
|
||||
branch_id: node.branch_id.clone(),
|
||||
name: default_branch_name(ordinal),
|
||||
head_node_id: node.id.clone(),
|
||||
source_node_id: Some(source_node_id),
|
||||
ordinal,
|
||||
},
|
||||
);
|
||||
data.active_branches
|
||||
.insert(node.story_id.clone(), node.branch_id.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fork_branch(
|
||||
&self,
|
||||
story_id: &str,
|
||||
@@ -336,6 +459,14 @@ impl StoryStore for InMemoryStoryStore {
|
||||
restore_state_for_branch(node, state, story_id, head, branch_id)
|
||||
}
|
||||
|
||||
fn load_state_at_node(
|
||||
&self,
|
||||
story_id: &str,
|
||||
node_id: &str,
|
||||
) -> Result<RuntimeState, StoreError> {
|
||||
InMemoryStoryStore::load_state_at_node(self, story_id, node_id)
|
||||
}
|
||||
|
||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError> {
|
||||
let data = self.lock()?;
|
||||
data.nodes
|
||||
@@ -583,8 +714,8 @@ impl SqliteStoryStore {
|
||||
impl StoryStore for SqliteStoryStore {
|
||||
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
||||
validate_materialized_state(node, state)?;
|
||||
let node_json = serde_json::to_string(node)?;
|
||||
let state_json = serde_json::to_string(state)?;
|
||||
let node_json = serde_json::to_string(node).map_err(StoreError::from)?;
|
||||
let state_json = serde_json::to_string(state).map_err(StoreError::from)?;
|
||||
|
||||
let mut connection = self.lock()?;
|
||||
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
@@ -672,6 +803,46 @@ impl StoryStore for SqliteStoryStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_regenerated_node(
|
||||
&self,
|
||||
source_branch_id: &str,
|
||||
replaced_node_id: &str,
|
||||
node: &StoryNode,
|
||||
state: &RuntimeState,
|
||||
) -> Result<(), ForkError> {
|
||||
validate_new_branch_id(&node.branch_id)?;
|
||||
validate_materialized_state(node, state)?;
|
||||
let node_json = serde_json::to_string(node).map_err(StoreError::from)?;
|
||||
let state_json = serde_json::to_string(state).map_err(StoreError::from)?;
|
||||
|
||||
let mut connection = self.lock()?;
|
||||
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
validate_sqlite_regeneration_target(
|
||||
&transaction,
|
||||
source_branch_id,
|
||||
replaced_node_id,
|
||||
node,
|
||||
)?;
|
||||
let (replaced_node, replaced_state) =
|
||||
load_sqlite_regeneration_source(&transaction, &node.story_id, replaced_node_id)?;
|
||||
validate_regenerated_sibling(
|
||||
source_branch_id,
|
||||
&replaced_node,
|
||||
&replaced_state,
|
||||
node,
|
||||
state,
|
||||
)?;
|
||||
insert_sqlite_regenerated_sibling(
|
||||
&transaction,
|
||||
source_branch_id,
|
||||
node,
|
||||
&node_json,
|
||||
&state_json,
|
||||
)?;
|
||||
transaction.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fork_branch(
|
||||
&self,
|
||||
story_id: &str,
|
||||
@@ -819,6 +990,14 @@ impl StoryStore for SqliteStoryStore {
|
||||
restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id)
|
||||
}
|
||||
|
||||
fn load_state_at_node(
|
||||
&self,
|
||||
story_id: &str,
|
||||
node_id: &str,
|
||||
) -> Result<RuntimeState, StoreError> {
|
||||
SqliteStoryStore::load_state_at_node(self, story_id, node_id)
|
||||
}
|
||||
|
||||
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError> {
|
||||
let connection = self.lock()?;
|
||||
let stored = connection
|
||||
@@ -1005,6 +1184,182 @@ fn ensure_branch_session(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_sqlite_regeneration_target(
|
||||
transaction: &Transaction<'_>,
|
||||
source_branch_id: &str,
|
||||
replaced_node_id: &str,
|
||||
node: &StoryNode,
|
||||
) -> Result<(), ForkError> {
|
||||
let active_branch = transaction
|
||||
.query_row(
|
||||
"SELECT active_branch_id
|
||||
FROM story_sessions
|
||||
WHERE story_id = ?1",
|
||||
params![node.story_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| StoreError::StoryNotFound(node.story_id.clone()))?;
|
||||
if active_branch != source_branch_id {
|
||||
return Err(StoreError::StaleBranchHead {
|
||||
expected: active_branch,
|
||||
actual: source_branch_id.to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let source_head = transaction
|
||||
.query_row(
|
||||
"SELECT head_node_id
|
||||
FROM branch_heads
|
||||
WHERE story_id = ?1 AND branch_id = ?2",
|
||||
params![node.story_id, source_branch_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| StoreError::BranchNotFound {
|
||||
story_id: node.story_id.clone(),
|
||||
branch_id: source_branch_id.to_owned(),
|
||||
})?;
|
||||
if source_head != replaced_node_id {
|
||||
return Err(StoreError::StaleBranchHead {
|
||||
expected: source_head,
|
||||
actual: replaced_node_id.to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let branch_exists = transaction.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM branch_heads
|
||||
WHERE story_id = ?1 AND branch_id = ?2
|
||||
)",
|
||||
params![node.story_id, node.branch_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)?;
|
||||
if branch_exists {
|
||||
return Err(ForkError::BranchAlreadyExists {
|
||||
story_id: node.story_id.clone(),
|
||||
branch_id: node.branch_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let node_exists = transaction.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM nodes WHERE story_id = ?1 AND node_id = ?2
|
||||
)",
|
||||
params![node.story_id, node.id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)?;
|
||||
if node_exists {
|
||||
return Err(StoreError::NodeAlreadyExists(node.id.clone()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_sqlite_regeneration_source(
|
||||
transaction: &Transaction<'_>,
|
||||
story_id: &str,
|
||||
replaced_node_id: &str,
|
||||
) -> Result<(StoryNode, RuntimeState), StoreError> {
|
||||
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, replaced_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(replaced_node_id.to_owned()))?;
|
||||
let replaced_node = deserialize_node(&stored.2)?;
|
||||
validate_loaded_node(
|
||||
&replaced_node,
|
||||
story_id,
|
||||
replaced_node_id,
|
||||
&stored.0,
|
||||
stored.1.as_deref(),
|
||||
)?;
|
||||
let replaced_state_json = stored
|
||||
.3
|
||||
.ok_or(StoreError::StateMismatch("node has no materialized state"))?;
|
||||
let replaced_state = deserialize_state(&replaced_state_json)?;
|
||||
validate_loaded_state(&replaced_state, story_id, replaced_node_id, &stored.0)?;
|
||||
validate_state_hash(&replaced_node, &replaced_state)?;
|
||||
Ok((replaced_node, replaced_state))
|
||||
}
|
||||
|
||||
fn insert_sqlite_regenerated_sibling(
|
||||
transaction: &Transaction<'_>,
|
||||
source_branch_id: &str,
|
||||
node: &StoryNode,
|
||||
node_json: &str,
|
||||
state_json: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
let source_node_id = node
|
||||
.parent_id
|
||||
.as_deref()
|
||||
.ok_or(StoreError::StateMismatch("root node cannot be regenerated"))?;
|
||||
transaction.execute(
|
||||
"INSERT INTO nodes (
|
||||
story_id, node_id, branch_id, parent_id, node_json
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
node.story_id,
|
||||
node.id,
|
||||
node.branch_id,
|
||||
node.parent_id,
|
||||
node_json
|
||||
],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO materialized_states (story_id, node_id, state_json)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![node.story_id, node.id, state_json],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO branch_heads (story_id, branch_id, head_node_id)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![node.story_id, node.branch_id, node.id],
|
||||
)?;
|
||||
let ordinal = transaction.query_row(
|
||||
"SELECT COALESCE(MAX(ordinal), 0) + 1
|
||||
FROM branch_metadata WHERE story_id = ?1",
|
||||
params![node.story_id],
|
||||
|row| row.get::<_, u32>(0),
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO branch_metadata (
|
||||
story_id, branch_id, name, source_node_id, ordinal
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
node.story_id,
|
||||
node.branch_id,
|
||||
default_branch_name(ordinal),
|
||||
source_node_id,
|
||||
ordinal
|
||||
],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"UPDATE story_sessions
|
||||
SET active_branch_id = ?2
|
||||
WHERE story_id = ?1 AND active_branch_id = ?3",
|
||||
params![node.story_id, node.branch_id, source_branch_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
|
||||
connection.busy_timeout(BUSY_TIMEOUT)?;
|
||||
connection.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
@@ -1833,6 +2188,56 @@ fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_regenerated_sibling(
|
||||
source_branch_id: &str,
|
||||
replaced_node: &StoryNode,
|
||||
replaced_state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
state: &RuntimeState,
|
||||
) -> Result<(), StoreError> {
|
||||
if node.story_id != replaced_node.story_id {
|
||||
return Err(StoreError::StateMismatch("story_id"));
|
||||
}
|
||||
if node.branch_id == source_branch_id {
|
||||
return Err(StoreError::StateMismatch(
|
||||
"regeneration must create a new branch",
|
||||
));
|
||||
}
|
||||
if replaced_node.parent_id.is_none() {
|
||||
return Err(StoreError::StateMismatch("root node cannot be regenerated"));
|
||||
}
|
||||
if node.parent_id != replaced_node.parent_id {
|
||||
return Err(StoreError::StateMismatch(
|
||||
"regenerated node must be a sibling",
|
||||
));
|
||||
}
|
||||
if node.user_input != replaced_node.user_input {
|
||||
return Err(StoreError::StateMismatch(
|
||||
"regenerated node changed player input",
|
||||
));
|
||||
}
|
||||
if node.action_id != replaced_node.action_id {
|
||||
return Err(StoreError::StateMismatch(
|
||||
"regenerated node changed player action identity",
|
||||
));
|
||||
}
|
||||
if node.delta != replaced_node.delta {
|
||||
return Err(StoreError::StateMismatch(
|
||||
"regenerated node changed authoritative delta",
|
||||
));
|
||||
}
|
||||
|
||||
let mut expected_state = replaced_state.clone();
|
||||
expected_state.current_node.clone_from(&node.id);
|
||||
expected_state.current_branch.clone_from(&node.branch_id);
|
||||
if expected_state != *state {
|
||||
return Err(StoreError::StateMismatch(
|
||||
"regenerated node changed authoritative state",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_state_for_branch(
|
||||
node: &StoryNode,
|
||||
state: &RuntimeState,
|
||||
@@ -1926,7 +2331,7 @@ mod tests {
|
||||
};
|
||||
|
||||
use nana_domain::{
|
||||
PresentationSnapshot, RuntimeState, StateDelta, StoryNode, stable_json_hash,
|
||||
PresentationSnapshot, RuntimeState, StateDelta, StateOp, StoryNode, stable_json_hash,
|
||||
};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
@@ -2090,6 +2495,170 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn append_regeneration_source(
|
||||
store: &impl InspectableStoryStore,
|
||||
) -> (StateDelta, RuntimeState, StoryNode, RuntimeState) {
|
||||
store
|
||||
.append_node(
|
||||
&node("node_001", None, "branch_main"),
|
||||
&state("node_001", "branch_main"),
|
||||
)
|
||||
.expect("root append");
|
||||
|
||||
let authoritative_delta = StateDelta {
|
||||
ops: vec![StateOp::SetWorldFlag {
|
||||
key: "door_open".to_owned(),
|
||||
value: true,
|
||||
}],
|
||||
};
|
||||
let mut replaced_state = state("node_002", "branch_main");
|
||||
replaced_state
|
||||
.world_flags
|
||||
.insert("door_open".to_owned(), true);
|
||||
let mut replaced_node = node("node_002", Some("node_001"), "branch_main");
|
||||
replaced_node.user_input = "Open the door.".to_owned();
|
||||
replaced_node.delta = authoritative_delta.clone();
|
||||
replaced_node.state_hash = stable_json_hash(
|
||||
&serde_json::to_vec(&replaced_state).expect("serializable replaced state"),
|
||||
);
|
||||
store
|
||||
.append_node(&replaced_node, &replaced_state)
|
||||
.expect("replaced node append");
|
||||
|
||||
let mut regenerated_state = replaced_state.clone();
|
||||
regenerated_state.current_node = "node_regenerated".to_owned();
|
||||
regenerated_state.current_branch = "branch_regenerated".to_owned();
|
||||
let mut regenerated_node = replaced_node.clone();
|
||||
regenerated_node.id = "node_regenerated".to_owned();
|
||||
regenerated_node.branch_id = "branch_regenerated".to_owned();
|
||||
regenerated_node.state_hash = stable_json_hash(
|
||||
&serde_json::to_vec(®enerated_state).expect("serializable regenerated state"),
|
||||
);
|
||||
(
|
||||
authoritative_delta,
|
||||
replaced_state,
|
||||
regenerated_node,
|
||||
regenerated_state,
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_changed_regeneration_is_atomic(
|
||||
store: &impl InspectableStoryStore,
|
||||
regenerated_node: &StoryNode,
|
||||
regenerated_state: &RuntimeState,
|
||||
) {
|
||||
let mut changed_action = regenerated_node.clone();
|
||||
changed_action.action_id = "different_player_action".to_owned();
|
||||
assert_eq!(
|
||||
store.append_regenerated_node(
|
||||
"branch_main",
|
||||
"node_002",
|
||||
&changed_action,
|
||||
regenerated_state,
|
||||
),
|
||||
Err(ForkError::Store(StoreError::StateMismatch(
|
||||
"regenerated node changed player action identity"
|
||||
)))
|
||||
);
|
||||
|
||||
let mut changed_state = regenerated_state.clone();
|
||||
changed_state
|
||||
.world_flags
|
||||
.insert("invented_fact".to_owned(), true);
|
||||
let mut changed_node = regenerated_node.clone();
|
||||
changed_node.state_hash = stable_json_hash(
|
||||
&serde_json::to_vec(&changed_state).expect("serializable changed state"),
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.append_regenerated_node("branch_main", "node_002", &changed_node, &changed_state,),
|
||||
Err(ForkError::Store(StoreError::StateMismatch(
|
||||
"regenerated node changed authoritative state"
|
||||
)))
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.inspected_branch_head("story_demo", "branch_regenerated")
|
||||
.expect("failed branch absent"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.active_branch("story_demo")
|
||||
.expect("source still active"),
|
||||
"branch_main"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_committed_regeneration(
|
||||
store: &impl InspectableStoryStore,
|
||||
authoritative_delta: &StateDelta,
|
||||
replaced_state: &RuntimeState,
|
||||
regenerated_node: &StoryNode,
|
||||
regenerated_state: &RuntimeState,
|
||||
) {
|
||||
store
|
||||
.append_regenerated_node(
|
||||
"branch_main",
|
||||
"node_002",
|
||||
regenerated_node,
|
||||
regenerated_state,
|
||||
)
|
||||
.expect("regenerated sibling append");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.inspected_branch_head("story_demo", "branch_main")
|
||||
.expect("source head"),
|
||||
Some("node_002".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.inspected_branch_head("story_demo", "branch_regenerated")
|
||||
.expect("regenerated head"),
|
||||
Some("node_regenerated".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.active_branch("story_demo")
|
||||
.expect("regenerated branch active"),
|
||||
"branch_regenerated"
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.load_node("story_demo", "node_regenerated")
|
||||
.expect("regenerated node")
|
||||
.delta,
|
||||
*authoritative_delta
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.load_state("story_demo", "branch_regenerated")
|
||||
.expect("regenerated state")
|
||||
.world_flags,
|
||||
replaced_state.world_flags
|
||||
);
|
||||
assert_eq!(
|
||||
store.list_branches("story_demo").expect("branch list")[1]
|
||||
.source_node_id
|
||||
.as_deref(),
|
||||
Some("node_001")
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_atomically_appends_a_regenerated_sibling(store: &impl InspectableStoryStore) {
|
||||
let (authoritative_delta, replaced_state, regenerated_node, regenerated_state) =
|
||||
append_regeneration_source(store);
|
||||
assert_changed_regeneration_is_atomic(store, ®enerated_node, ®enerated_state);
|
||||
assert_committed_regeneration(
|
||||
store,
|
||||
&authoritative_delta,
|
||||
&replaced_state,
|
||||
®enerated_node,
|
||||
®enerated_state,
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_rejects_a_stale_append(store: &impl InspectableStoryStore) {
|
||||
store
|
||||
.append_node(
|
||||
@@ -2467,6 +3036,18 @@ mod tests {
|
||||
assert_forks_from_an_old_node(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_atomically_appends_a_regenerated_sibling() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
assert_atomically_appends_a_regenerated_sibling(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_atomically_appends_a_regenerated_sibling() {
|
||||
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
||||
assert_atomically_appends_a_regenerated_sibling(&store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_loads_ancestors_across_shared_branch_history() {
|
||||
let store = InMemoryStoryStore::new();
|
||||
|
||||
Reference in New Issue
Block a user