feat(runtime): prepare safe context checkpoints

This commit is contained in:
2026-07-29 08:46:23 +08:00
parent d0612b399c
commit b6600c1f06
64 changed files with 2552 additions and 187 deletions
+584 -3
View File
@@ -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(&regenerated_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, &regenerated_node, &regenerated_state);
assert_committed_regeneration(
store,
&authoritative_delta,
&replaced_state,
&regenerated_node,
&regenerated_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();