feat(store): fork branches from persisted history

This commit is contained in:
Codex
2026-07-28 03:26:37 -04:00
parent 45d2df625c
commit 966c6dc54c
+536 -15
View File
@@ -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
/// 1128 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())
);
}
}