1092 lines
36 KiB
Rust
1092 lines
36 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
path::Path,
|
|
sync::{Mutex, MutexGuard},
|
|
};
|
|
|
|
use nana_domain::{RuntimeState, StoryNode, stable_json_hash};
|
|
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum StoreError {
|
|
#[error("story not found: {0}")]
|
|
StoryNotFound(String),
|
|
#[error("branch not found: {story_id}/{branch_id}")]
|
|
BranchNotFound { story_id: String, branch_id: String },
|
|
#[error("node already exists: {0}")]
|
|
NodeAlreadyExists(String),
|
|
#[error("parent node not found: {0}")]
|
|
ParentNotFound(String),
|
|
#[error("branch head changed: expected {expected}, got {actual}")]
|
|
StaleBranchHead { expected: String, actual: String },
|
|
#[error("node and materialized state disagree: {0}")]
|
|
StateMismatch(&'static str),
|
|
#[error("materialized state hash does not match node: {node_id}")]
|
|
StateHashMismatch { node_id: String },
|
|
#[error("sqlite storage error: {0}")]
|
|
Sqlite(String),
|
|
#[error("JSON serialization error: {0}")]
|
|
Serialization(String),
|
|
#[error("storage lock is poisoned")]
|
|
Poisoned,
|
|
}
|
|
|
|
impl From<rusqlite::Error> for StoreError {
|
|
fn from(error: rusqlite::Error) -> Self {
|
|
Self::Sqlite(error.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<serde_json::Error> for StoreError {
|
|
fn from(error: serde_json::Error) -> Self {
|
|
Self::Serialization(error.to_string())
|
|
}
|
|
}
|
|
|
|
pub trait StoryStore: Send + Sync {
|
|
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), 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>;
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct MemoryData {
|
|
nodes: BTreeMap<(String, String), StoryNode>,
|
|
states: BTreeMap<(String, String), RuntimeState>,
|
|
branch_heads: BTreeMap<(String, String), String>,
|
|
}
|
|
|
|
/// Deterministic test and development store.
|
|
///
|
|
/// Production persistence will implement the same append-only semantics with
|
|
/// SQLite. Keeping this implementation in the crate lets the engine/runtime
|
|
/// integration tests exercise branch rules without depending on a database.
|
|
#[derive(Debug, Default)]
|
|
pub struct InMemoryStoryStore {
|
|
data: Mutex<MemoryData>,
|
|
}
|
|
|
|
impl InMemoryStoryStore {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn branch_head(
|
|
&self,
|
|
story_id: &str,
|
|
branch_id: &str,
|
|
) -> Result<Option<String>, StoreError> {
|
|
let data = self.lock()?;
|
|
Ok(data
|
|
.branch_heads
|
|
.get(&(story_id.to_owned(), branch_id.to_owned()))
|
|
.cloned())
|
|
}
|
|
|
|
pub fn load_state_at_node(
|
|
&self,
|
|
story_id: &str,
|
|
node_id: &str,
|
|
) -> Result<RuntimeState, StoreError> {
|
|
let data = self.lock()?;
|
|
data.states
|
|
.get(&(story_id.to_owned(), node_id.to_owned()))
|
|
.cloned()
|
|
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
|
|
}
|
|
|
|
fn lock(&self) -> Result<MutexGuard<'_, MemoryData>, StoreError> {
|
|
self.data.lock().map_err(|_| StoreError::Poisoned)
|
|
}
|
|
}
|
|
|
|
impl StoryStore for InMemoryStoryStore {
|
|
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
|
validate_materialized_state(node, state)?;
|
|
|
|
let node_key = (node.story_id.clone(), node.id.clone());
|
|
let branch_key = (node.story_id.clone(), node.branch_id.clone());
|
|
let mut data = self.lock()?;
|
|
|
|
if data.nodes.contains_key(&node_key) {
|
|
return Err(StoreError::NodeAlreadyExists(node.id.clone()));
|
|
}
|
|
|
|
if let Some(parent_id) = &node.parent_id {
|
|
if !data
|
|
.nodes
|
|
.contains_key(&(node.story_id.clone(), parent_id.clone()))
|
|
{
|
|
return Err(StoreError::ParentNotFound(parent_id.clone()));
|
|
}
|
|
} else if data
|
|
.nodes
|
|
.keys()
|
|
.any(|(story_id, _)| story_id == &node.story_id)
|
|
{
|
|
return Err(StoreError::ParentNotFound(
|
|
"a non-root node must name its parent".to_owned(),
|
|
));
|
|
}
|
|
|
|
if let Some(current_head) = data.branch_heads.get(&branch_key) {
|
|
let expected_parent = node.parent_id.as_deref().unwrap_or("<none>");
|
|
if current_head != expected_parent {
|
|
return Err(StoreError::StaleBranchHead {
|
|
expected: current_head.clone(),
|
|
actual: expected_parent.to_owned(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// All validation happens before any map is changed. This mirrors the
|
|
// all-or-nothing transaction boundary required from the SQLite store.
|
|
data.nodes.insert(node_key.clone(), node.clone());
|
|
data.states.insert(node_key, state.clone());
|
|
data.branch_heads.insert(branch_key, node.id.clone());
|
|
Ok(())
|
|
}
|
|
|
|
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError> {
|
|
let data = self.lock()?;
|
|
let story_exists = data
|
|
.nodes
|
|
.keys()
|
|
.any(|(stored_story_id, _)| stored_story_id == story_id);
|
|
if !story_exists {
|
|
return Err(StoreError::StoryNotFound(story_id.to_owned()));
|
|
}
|
|
|
|
let head = data
|
|
.branch_heads
|
|
.get(&(story_id.to_owned(), branch_id.to_owned()))
|
|
.ok_or_else(|| StoreError::BranchNotFound {
|
|
story_id: story_id.to_owned(),
|
|
branch_id: branch_id.to_owned(),
|
|
})?;
|
|
data.states
|
|
.get(&(story_id.to_owned(), head.clone()))
|
|
.cloned()
|
|
.ok_or(StoreError::StateMismatch("branch head has no state"))
|
|
}
|
|
|
|
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError> {
|
|
let data = self.lock()?;
|
|
data.nodes
|
|
.get(&(story_id.to_owned(), node_id.to_owned()))
|
|
.cloned()
|
|
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
|
|
}
|
|
}
|
|
|
|
/// Durable SQLite implementation of the append-only story store.
|
|
///
|
|
/// A connection is kept behind a mutex so one store value preserves the same
|
|
/// `Send + Sync` contract as the in-memory implementation. SQLite still
|
|
/// provides the cross-connection transaction boundary when a database is
|
|
/// reopened or shared by multiple store values.
|
|
pub struct SqliteStoryStore {
|
|
connection: Mutex<Connection>,
|
|
}
|
|
|
|
impl SqliteStoryStore {
|
|
/// Opens or creates a story database at `path`.
|
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
|
|
let connection = Connection::open(path)?;
|
|
Self::from_connection(connection)
|
|
}
|
|
|
|
/// Opens a fresh in-memory story database.
|
|
pub fn open_in_memory() -> Result<Self, StoreError> {
|
|
let connection = Connection::open_in_memory()?;
|
|
Self::from_connection(connection)
|
|
}
|
|
|
|
/// Returns the current head node for a branch, if the branch exists.
|
|
pub fn branch_head(
|
|
&self,
|
|
story_id: &str,
|
|
branch_id: &str,
|
|
) -> Result<Option<String>, StoreError> {
|
|
let connection = self.lock()?;
|
|
connection
|
|
.query_row(
|
|
"SELECT head_node_id
|
|
FROM branch_heads
|
|
WHERE story_id = ?1 AND branch_id = ?2",
|
|
params![story_id, branch_id],
|
|
|row| row.get(0),
|
|
)
|
|
.optional()
|
|
.map_err(StoreError::from)
|
|
}
|
|
|
|
/// Loads the materialized state associated with a specific node.
|
|
pub fn load_state_at_node(
|
|
&self,
|
|
story_id: &str,
|
|
node_id: &str,
|
|
) -> Result<RuntimeState, StoreError> {
|
|
let connection = self.lock()?;
|
|
let stored = connection
|
|
.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, 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(node_id.to_owned()))?;
|
|
|
|
let node = deserialize_node(&stored.2)?;
|
|
validate_loaded_node(&node, story_id, 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)?;
|
|
validate_loaded_state(&state, story_id, node_id, &stored.0)?;
|
|
validate_state_hash(&node, &state)?;
|
|
Ok(state)
|
|
}
|
|
|
|
fn from_connection(connection: Connection) -> Result<Self, StoreError> {
|
|
initialize_schema(&connection)?;
|
|
Ok(Self {
|
|
connection: Mutex::new(connection),
|
|
})
|
|
}
|
|
|
|
fn lock(&self) -> Result<MutexGuard<'_, Connection>, StoreError> {
|
|
self.connection.lock().map_err(|_| StoreError::Poisoned)
|
|
}
|
|
}
|
|
|
|
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 mut connection = self.lock()?;
|
|
let transaction =
|
|
connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
|
|
|
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()));
|
|
}
|
|
|
|
if let Some(parent_id) = &node.parent_id {
|
|
let parent_exists = transaction.query_row(
|
|
"SELECT EXISTS(
|
|
SELECT 1 FROM nodes WHERE story_id = ?1 AND node_id = ?2
|
|
)",
|
|
params![node.story_id, parent_id],
|
|
|row| row.get::<_, bool>(0),
|
|
)?;
|
|
if !parent_exists {
|
|
return Err(StoreError::ParentNotFound(parent_id.clone()));
|
|
}
|
|
} else {
|
|
let story_exists = transaction.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE story_id = ?1)",
|
|
params![node.story_id],
|
|
|row| row.get::<_, bool>(0),
|
|
)?;
|
|
if story_exists {
|
|
return Err(StoreError::ParentNotFound(
|
|
"a non-root node must name its parent".to_owned(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let current_head = transaction
|
|
.query_row(
|
|
"SELECT head_node_id
|
|
FROM branch_heads
|
|
WHERE story_id = ?1 AND branch_id = ?2",
|
|
params![node.story_id, node.branch_id],
|
|
|row| row.get::<_, String>(0),
|
|
)
|
|
.optional()?;
|
|
if let Some(current_head) = current_head {
|
|
let expected_parent = node.parent_id.as_deref().unwrap_or("<none>");
|
|
if current_head != expected_parent {
|
|
return Err(StoreError::StaleBranchHead {
|
|
expected: current_head,
|
|
actual: expected_parent.to_owned(),
|
|
});
|
|
}
|
|
}
|
|
|
|
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)
|
|
ON CONFLICT(story_id, branch_id)
|
|
DO UPDATE SET head_node_id = excluded.head_node_id",
|
|
params![node.story_id, node.branch_id, node.id],
|
|
)?;
|
|
transaction.commit()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn load_state(&self, story_id: &str, branch_id: &str) -> Result<RuntimeState, StoreError> {
|
|
let connection = self.lock()?;
|
|
let story_exists = connection.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE story_id = ?1)",
|
|
params![story_id],
|
|
|row| row.get::<_, bool>(0),
|
|
)?;
|
|
if !story_exists {
|
|
return Err(StoreError::StoryNotFound(story_id.to_owned()));
|
|
}
|
|
|
|
let stored = connection
|
|
.query_row(
|
|
"SELECT branch_heads.head_node_id, nodes.branch_id, nodes.parent_id,
|
|
nodes.node_json, materialized_states.state_json
|
|
FROM branch_heads
|
|
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
|
|
WHERE branch_heads.story_id = ?1
|
|
AND branch_heads.branch_id = ?2",
|
|
params![story_id, branch_id],
|
|
|row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, String>(1)?,
|
|
row.get::<_, Option<String>>(2)?,
|
|
row.get::<_, String>(3)?,
|
|
row.get::<_, Option<String>>(4)?,
|
|
))
|
|
},
|
|
)
|
|
.optional()?
|
|
.ok_or_else(|| StoreError::BranchNotFound {
|
|
story_id: story_id.to_owned(),
|
|
branch_id: branch_id.to_owned(),
|
|
})?;
|
|
|
|
let node = deserialize_node(&stored.3)?;
|
|
validate_loaded_node(
|
|
&node,
|
|
story_id,
|
|
&stored.0,
|
|
&stored.1,
|
|
stored.2.as_deref(),
|
|
)?;
|
|
let state_json = stored
|
|
.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)
|
|
}
|
|
|
|
fn load_node(&self, story_id: &str, node_id: &str) -> Result<StoryNode, StoreError> {
|
|
let connection = self.lock()?;
|
|
let stored = connection
|
|
.query_row(
|
|
"SELECT branch_id, parent_id, node_json
|
|
FROM nodes
|
|
WHERE story_id = ?1 AND node_id = ?2",
|
|
params![story_id, node_id],
|
|
|row| {
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, Option<String>>(1)?,
|
|
row.get::<_, String>(2)?,
|
|
))
|
|
},
|
|
)
|
|
.optional()?
|
|
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))?;
|
|
let node = deserialize_node(&stored.2)?;
|
|
validate_loaded_node(&node, story_id, node_id, &stored.0, stored.1.as_deref())?;
|
|
Ok(node)
|
|
}
|
|
}
|
|
|
|
fn initialize_schema(connection: &Connection) -> Result<(), StoreError> {
|
|
connection.execute_batch(
|
|
"PRAGMA foreign_keys = ON;
|
|
BEGIN IMMEDIATE;
|
|
|
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
story_id TEXT NOT NULL,
|
|
node_id TEXT NOT NULL,
|
|
branch_id TEXT NOT NULL,
|
|
parent_id TEXT,
|
|
node_json TEXT NOT NULL,
|
|
PRIMARY KEY (story_id, node_id),
|
|
FOREIGN KEY (story_id, parent_id)
|
|
REFERENCES nodes (story_id, node_id)
|
|
ON DELETE RESTRICT
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS one_root_per_story
|
|
ON nodes (story_id)
|
|
WHERE parent_id IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS materialized_states (
|
|
story_id TEXT NOT NULL,
|
|
node_id TEXT NOT NULL,
|
|
state_json TEXT NOT NULL,
|
|
PRIMARY KEY (story_id, node_id),
|
|
FOREIGN KEY (story_id, node_id)
|
|
REFERENCES nodes (story_id, node_id)
|
|
ON DELETE RESTRICT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS branch_heads (
|
|
story_id TEXT NOT NULL,
|
|
branch_id TEXT NOT NULL,
|
|
head_node_id TEXT NOT NULL,
|
|
PRIMARY KEY (story_id, branch_id),
|
|
FOREIGN KEY (story_id, head_node_id)
|
|
REFERENCES nodes (story_id, node_id)
|
|
ON DELETE RESTRICT
|
|
);
|
|
|
|
COMMIT;",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_materialized_state(
|
|
node: &StoryNode,
|
|
state: &RuntimeState,
|
|
) -> Result<(), StoreError> {
|
|
if node.story_id != state.story_id {
|
|
return Err(StoreError::StateMismatch("story_id"));
|
|
}
|
|
if node.id != state.current_node {
|
|
return Err(StoreError::StateMismatch("current_node"));
|
|
}
|
|
if node.branch_id != state.current_branch {
|
|
return Err(StoreError::StateMismatch("current_branch"));
|
|
}
|
|
validate_state_hash(node, state)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn deserialize_node(node_json: &str) -> Result<StoryNode, StoreError> {
|
|
serde_json::from_str(node_json).map_err(StoreError::from)
|
|
}
|
|
|
|
fn deserialize_state(state_json: &str) -> Result<RuntimeState, StoreError> {
|
|
serde_json::from_str(state_json).map_err(StoreError::from)
|
|
}
|
|
|
|
fn validate_loaded_node(
|
|
node: &StoryNode,
|
|
story_id: &str,
|
|
node_id: &str,
|
|
branch_id: &str,
|
|
parent_id: Option<&str>,
|
|
) -> Result<(), StoreError> {
|
|
if node.story_id != story_id {
|
|
return Err(StoreError::StateMismatch("story_id"));
|
|
}
|
|
if node.id != node_id {
|
|
return Err(StoreError::StateMismatch("current_node"));
|
|
}
|
|
if node.branch_id != branch_id {
|
|
return Err(StoreError::StateMismatch("current_branch"));
|
|
}
|
|
if node.parent_id.as_deref() != parent_id {
|
|
return Err(StoreError::StateMismatch("parent_id"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_loaded_state(
|
|
state: &RuntimeState,
|
|
story_id: &str,
|
|
node_id: &str,
|
|
branch_id: &str,
|
|
) -> Result<(), StoreError> {
|
|
if state.story_id != story_id {
|
|
return Err(StoreError::StateMismatch("story_id"));
|
|
}
|
|
if state.current_node != node_id {
|
|
return Err(StoreError::StateMismatch("current_node"));
|
|
}
|
|
if state.current_branch != branch_id {
|
|
return Err(StoreError::StateMismatch("current_branch"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_state_hash(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
|
|
let bytes = serde_json::to_vec(state)?;
|
|
if stable_json_hash(&bytes) != node.state_hash {
|
|
return Err(StoreError::StateHashMismatch {
|
|
node_id: node.id.clone(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
collections::BTreeMap,
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use nana_domain::{RuntimeState, StateDelta, StoryNode, stable_json_hash};
|
|
use rusqlite::params;
|
|
|
|
use super::{InMemoryStoryStore, SqliteStoryStore, StoreError, StoryStore};
|
|
|
|
fn state(node: &str, branch: &str) -> RuntimeState {
|
|
RuntimeState {
|
|
story_id: "story_demo".to_owned(),
|
|
current_node: node.to_owned(),
|
|
current_branch: branch.to_owned(),
|
|
world_flags: BTreeMap::new(),
|
|
relationships: BTreeMap::new(),
|
|
relationship_states: Vec::new(),
|
|
promises: Vec::new(),
|
|
knowledge: Vec::new(),
|
|
items: Vec::new(),
|
|
clocks: Vec::new(),
|
|
checks: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn node(id: &str, parent_id: Option<&str>, branch: &str) -> StoryNode {
|
|
let materialized = state(id, branch);
|
|
StoryNode {
|
|
id: id.to_owned(),
|
|
story_id: "story_demo".to_owned(),
|
|
branch_id: branch.to_owned(),
|
|
parent_id: parent_id.map(ToOwned::to_owned),
|
|
action_id: format!("action_{id}"),
|
|
user_input: String::new(),
|
|
beats: Vec::new(),
|
|
delta: StateDelta { ops: Vec::new() },
|
|
state_hash: stable_json_hash(
|
|
&serde_json::to_vec(&materialized).expect("serializable test state"),
|
|
),
|
|
}
|
|
}
|
|
|
|
trait InspectableStoryStore: StoryStore {
|
|
fn inspected_branch_head(
|
|
&self,
|
|
story_id: &str,
|
|
branch_id: &str,
|
|
) -> Result<Option<String>, StoreError>;
|
|
|
|
fn inspected_state_at_node(
|
|
&self,
|
|
story_id: &str,
|
|
node_id: &str,
|
|
) -> Result<RuntimeState, StoreError>;
|
|
}
|
|
|
|
impl InspectableStoryStore for InMemoryStoryStore {
|
|
fn inspected_branch_head(
|
|
&self,
|
|
story_id: &str,
|
|
branch_id: &str,
|
|
) -> Result<Option<String>, StoreError> {
|
|
self.branch_head(story_id, branch_id)
|
|
}
|
|
|
|
fn inspected_state_at_node(
|
|
&self,
|
|
story_id: &str,
|
|
node_id: &str,
|
|
) -> Result<RuntimeState, StoreError> {
|
|
self.load_state_at_node(story_id, node_id)
|
|
}
|
|
}
|
|
|
|
impl InspectableStoryStore for SqliteStoryStore {
|
|
fn inspected_branch_head(
|
|
&self,
|
|
story_id: &str,
|
|
branch_id: &str,
|
|
) -> Result<Option<String>, StoreError> {
|
|
self.branch_head(story_id, branch_id)
|
|
}
|
|
|
|
fn inspected_state_at_node(
|
|
&self,
|
|
story_id: &str,
|
|
node_id: &str,
|
|
) -> Result<RuntimeState, StoreError> {
|
|
self.load_state_at_node(story_id, node_id)
|
|
}
|
|
}
|
|
|
|
fn assert_appends_and_loads_the_branch_head(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("child append");
|
|
|
|
assert_eq!(
|
|
store
|
|
.load_state("story_demo", "branch_main")
|
|
.expect("stored state")
|
|
.current_node,
|
|
"node_002"
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.inspected_state_at_node("story_demo", "node_001")
|
|
.expect("root state")
|
|
.current_node,
|
|
"node_001"
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.load_node("story_demo", "node_002")
|
|
.expect("stored node")
|
|
.parent_id
|
|
.as_deref(),
|
|
Some("node_001")
|
|
);
|
|
}
|
|
|
|
fn assert_forks_from_an_old_node(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");
|
|
store
|
|
.append_node(
|
|
&node("node_003", Some("node_001"), "branch_rewind"),
|
|
&state("node_003", "branch_rewind"),
|
|
)
|
|
.expect("fork append");
|
|
|
|
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_rewind")
|
|
.expect("fork head"),
|
|
Some("node_003".to_owned())
|
|
);
|
|
}
|
|
|
|
fn assert_rejects_a_stale_append(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");
|
|
|
|
assert_eq!(
|
|
store.append_node(
|
|
&node("node_stale", Some("node_001"), "branch_main"),
|
|
&state("node_stale", "branch_main"),
|
|
),
|
|
Err(StoreError::StaleBranchHead {
|
|
expected: "node_002".to_owned(),
|
|
actual: "node_001".to_owned(),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.inspected_branch_head("story_demo", "branch_main")
|
|
.expect("main head"),
|
|
Some("node_002".to_owned())
|
|
);
|
|
assert_eq!(
|
|
store.inspected_state_at_node("story_demo", "node_stale"),
|
|
Err(StoreError::ParentNotFound("node_stale".to_owned()))
|
|
);
|
|
}
|
|
|
|
fn assert_rejects_state_mismatches(store: &impl InspectableStoryStore) {
|
|
let root = node("node_001", None, "branch_main");
|
|
|
|
let mut wrong_story = state("node_001", "branch_main");
|
|
wrong_story.story_id = "another_story".to_owned();
|
|
assert_eq!(
|
|
store.append_node(&root, &wrong_story),
|
|
Err(StoreError::StateMismatch("story_id"))
|
|
);
|
|
|
|
assert_eq!(
|
|
store.append_node(&root, &state("different_node", "branch_main")),
|
|
Err(StoreError::StateMismatch("current_node"))
|
|
);
|
|
assert_eq!(
|
|
store.append_node(&root, &state("node_001", "different_branch")),
|
|
Err(StoreError::StateMismatch("current_branch"))
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.inspected_branch_head("story_demo", "branch_main")
|
|
.expect("empty store"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_appends_and_loads_the_branch_head() {
|
|
let store = InMemoryStoryStore::new();
|
|
assert_appends_and_loads_the_branch_head(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_appends_and_loads_the_branch_head() {
|
|
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
|
assert_appends_and_loads_the_branch_head(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_forks_from_an_old_node_without_moving_the_source_head() {
|
|
let store = InMemoryStoryStore::new();
|
|
assert_forks_from_an_old_node(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_forks_from_an_old_node_without_moving_the_source_head() {
|
|
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
|
assert_forks_from_an_old_node(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_rejects_a_stale_append_without_moving_the_head() {
|
|
let store = InMemoryStoryStore::new();
|
|
assert_rejects_a_stale_append(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_rejects_a_stale_append_without_moving_the_head() {
|
|
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
|
assert_rejects_a_stale_append(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_rejects_state_that_does_not_match_the_node() {
|
|
let store = InMemoryStoryStore::new();
|
|
assert_rejects_state_mismatches(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_rejects_state_that_does_not_match_the_node() {
|
|
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
|
assert_rejects_state_mismatches(&store);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_enforces_node_and_parent_rules_atomically() {
|
|
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
|
|
let root = node("node_001", None, "branch_main");
|
|
let root_state = state("node_001", "branch_main");
|
|
store
|
|
.append_node(&root, &root_state)
|
|
.expect("root append");
|
|
|
|
assert_eq!(
|
|
store.append_node(&root, &root_state),
|
|
Err(StoreError::NodeAlreadyExists("node_001".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
store.append_node(
|
|
&node("node_missing_parent", Some("unknown"), "branch_fork"),
|
|
&state("node_missing_parent", "branch_fork"),
|
|
),
|
|
Err(StoreError::ParentNotFound("unknown".to_owned()))
|
|
);
|
|
assert_eq!(
|
|
store.append_node(
|
|
&node("node_second_root", None, "branch_other"),
|
|
&state("node_second_root", "branch_other"),
|
|
),
|
|
Err(StoreError::ParentNotFound(
|
|
"a non-root node must name its parent".to_owned()
|
|
))
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.branch_head("story_demo", "branch_main")
|
|
.expect("main head"),
|
|
Some("node_001".to_owned())
|
|
);
|
|
assert_eq!(
|
|
store
|
|
.branch_head("story_demo", "branch_fork")
|
|
.expect("failed fork"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_reports_corrupt_json_without_panicking() {
|
|
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(
|
|
"UPDATE materialized_states SET state_json = ?1",
|
|
params!["{"],
|
|
)
|
|
.expect("corrupt test state");
|
|
|
|
assert!(matches!(
|
|
store.load_state("story_demo", "branch_main"),
|
|
Err(StoreError::Serialization(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_rejects_valid_state_json_with_a_mismatched_hash() {
|
|
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");
|
|
let mut corrupted = state("node_001", "branch_main");
|
|
corrupted
|
|
.world_flags
|
|
.insert("silently_changed".to_owned(), true);
|
|
let corrupted_json = serde_json::to_string(&corrupted).expect("corrupt test JSON");
|
|
store
|
|
.connection
|
|
.lock()
|
|
.expect("SQLite connection lock")
|
|
.execute(
|
|
"UPDATE materialized_states SET state_json = ?1",
|
|
params![corrupted_json],
|
|
)
|
|
.expect("corrupt test state");
|
|
|
|
assert_eq!(
|
|
store.load_state("story_demo", "branch_main"),
|
|
Err(StoreError::StateHashMismatch {
|
|
node_id: "node_001".to_owned()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_rejects_node_json_that_disagrees_with_structure_columns() {
|
|
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");
|
|
let mut corrupted = node("node_001", None, "branch_main");
|
|
corrupted.branch_id = "hidden_branch".to_owned();
|
|
let corrupted_json = serde_json::to_string(&corrupted).expect("corrupt node JSON");
|
|
store
|
|
.connection
|
|
.lock()
|
|
.expect("SQLite connection lock")
|
|
.execute(
|
|
"UPDATE nodes SET node_json = ?1",
|
|
params![corrupted_json],
|
|
)
|
|
.expect("corrupt test node");
|
|
|
|
assert_eq!(
|
|
store.load_node("story_demo", "node_001"),
|
|
Err(StoreError::StateMismatch("current_branch"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_rejects_a_branch_head_pointing_at_another_branch() {
|
|
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
|
|
.append_node(
|
|
&node("node_other", Some("node_001"), "branch_other"),
|
|
&state("node_other", "branch_other"),
|
|
)
|
|
.expect("other branch append");
|
|
store
|
|
.connection
|
|
.lock()
|
|
.expect("SQLite connection lock")
|
|
.execute(
|
|
"UPDATE branch_heads
|
|
SET head_node_id = 'node_other'
|
|
WHERE story_id = 'story_demo' AND branch_id = 'branch_main'",
|
|
[],
|
|
)
|
|
.expect("corrupt branch head");
|
|
|
|
assert_eq!(
|
|
store.load_state("story_demo", "branch_main"),
|
|
Err(StoreError::BranchNotFound {
|
|
story_id: "story_demo".to_owned(),
|
|
branch_id: "branch_main".to_owned()
|
|
})
|
|
);
|
|
}
|
|
|
|
struct TemporaryDatabase {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TemporaryDatabase {
|
|
fn new() -> Self {
|
|
let nonce = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos();
|
|
Self {
|
|
path: std::env::temp_dir().join(format!(
|
|
"nana-store-{}-{nonce}.sqlite3",
|
|
std::process::id()
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
}
|
|
|
|
impl Drop for TemporaryDatabase {
|
|
fn drop(&mut self) {
|
|
if self.path.exists() {
|
|
fs::remove_file(&self.path).expect("remove temporary SQLite database");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sqlite_reopens_and_preserves_nodes_states_and_heads() {
|
|
let database = TemporaryDatabase::new();
|
|
{
|
|
let store = SqliteStoryStore::open(database.path()).expect("file SQLite store");
|
|
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("child append");
|
|
}
|
|
|
|
let reopened = SqliteStoryStore::open(database.path()).expect("reopened SQLite store");
|
|
assert_eq!(
|
|
reopened
|
|
.branch_head("story_demo", "branch_main")
|
|
.expect("persisted head"),
|
|
Some("node_002".to_owned())
|
|
);
|
|
assert_eq!(
|
|
reopened
|
|
.load_state("story_demo", "branch_main")
|
|
.expect("persisted head state")
|
|
.current_node,
|
|
"node_002"
|
|
);
|
|
assert_eq!(
|
|
reopened
|
|
.load_state_at_node("story_demo", "node_001")
|
|
.expect("persisted root state")
|
|
.current_node,
|
|
"node_001"
|
|
);
|
|
}
|
|
}
|