feat(app): complete playable branch sessions
verify / verify (push) Has been cancelled

This commit is contained in:
Codex
2026-07-28 04:42:40 -04:00
parent 449515fcfe
commit 1f935a3206
26 changed files with 3472 additions and 67 deletions
+32 -11
View File
@@ -4,18 +4,20 @@ use std::{
};
use nana_domain::{
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord, Persona,
PlayerItemView, PlayerKnowledgeView, PlayerPromiseView, PlayerView, PlotEvent, PlotModule,
PlotOutcome, PlotPressure, PresentationBeat, PresentationCharacter, PresentationScene,
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
RelationshipAxes, RelationshipBand, RelationshipDimension, RelationshipState, RelationshipView,
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary, CharacterCard,
CharacterJudgmentRule, CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState,
DemoPackSummary, ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition,
ItemInstance, ItemMechanics, ItemPlacement, ItemSpec, KnowledgeCertainty, KnowledgeRecord,
LappMode, LappModelOption, LappSettings, Persona, PlayerItemView, PlayerKnowledgeView,
PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure,
PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise,
PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand,
RelationshipDimension, RelationshipState, RelationshipView, RenameBranchRequest,
ResourceBundle, ResourceHeader, ResourceId, ResourceKind, ResourceRef, RuntimeState,
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, TurnFailure, TurnFailureCode,
TurnIntent, TurnRequest, TurnResult, ValidationCode, ValidationIssue, ValidationReport,
VisualDirective, WorldBook, WorldBookEntry,
SkillValue, StateDelta, StateOp, Story, StoryBinding, StoryNode, SwitchBranchRequest,
SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
UpdateLappSettingsRequest, ValidationCode, ValidationIssue, ValidationReport, VisualDirective,
WorldBook, WorldBookEntry,
};
use schemars::{JsonSchema, schema_for};
use serde::Serialize;
@@ -74,6 +76,16 @@ fn generated_outputs(root: &Path) -> Result<GeneratedOutputs, Box<dyn std::error
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
add_schema::<ForkBranchRequest>(&mut outputs, &schema_dir, "fork-branch-request")?;
add_schema::<ForkBranchResult>(&mut outputs, &schema_dir, "fork-branch-result")?;
add_schema::<BranchList>(&mut outputs, &schema_dir, "branch-list")?;
add_schema::<SwitchBranchRequest>(&mut outputs, &schema_dir, "switch-branch-request")?;
add_schema::<SwitchBranchResult>(&mut outputs, &schema_dir, "switch-branch-result")?;
add_schema::<RenameBranchRequest>(&mut outputs, &schema_dir, "rename-branch-request")?;
add_schema::<LappSettings>(&mut outputs, &schema_dir, "lapp-settings")?;
add_schema::<UpdateLappSettingsRequest>(
&mut outputs,
&schema_dir,
"update-lapp-settings-request",
)?;
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
@@ -154,6 +166,15 @@ fn generated_declarations() -> String {
TurnResult::decl(),
ForkBranchRequest::decl(),
ForkBranchResult::decl(),
BranchSummary::decl(),
BranchList::decl(),
SwitchBranchRequest::decl(),
SwitchBranchResult::decl(),
RenameBranchRequest::decl(),
LappModelOption::decl(),
LappMode::decl(),
LappSettings::decl(),
UpdateLappSettingsRequest::decl(),
TurnFailureCode::decl(),
TurnFailure::decl(),
AppInfo::decl(),
+85
View File
@@ -745,6 +745,91 @@ pub struct ForkBranchResult {
pub player_view: PlayerView,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct BranchSummary {
pub branch_id: String,
pub name: String,
pub head_node_id: String,
pub head_label: String,
pub source_node_id: Option<String>,
pub is_active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct BranchList {
pub story_id: String,
pub active_branch_id: String,
pub branches: Vec<BranchSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct SwitchBranchRequest {
pub story_id: String,
pub branch_id: String,
pub expected_active_branch_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct SwitchBranchResult {
pub branch_id: String,
pub player_view: PlayerView,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct RenameBranchRequest {
pub story_id: String,
pub branch_id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct LappModelOption {
pub provider_id: String,
pub provider_name: Option<String>,
pub model_id: String,
pub model_name: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum LappMode {
Lapp,
Demo,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct LappSettings {
pub mode: LappMode,
pub selected_provider_id: Option<String>,
pub selected_model_id: Option<String>,
#[serde(default)]
pub available_models: Vec<LappModelOption>,
pub status_message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct UpdateLappSettingsRequest {
pub provider_id: String,
pub model_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
+34 -6
View File
@@ -79,13 +79,34 @@ pub struct OpenLappChatExecutor {
impl OpenLappChatExecutor {
pub fn from_profile(profile: &Profile) -> Result<Self, ProviderError> {
Self::from_profile_with_selector(profile, ModelSelector::Default("chat".to_owned()))
}
pub fn from_profile_and_model(
profile: &Profile,
provider_id: &str,
model_id: &str,
) -> Result<Self, ProviderError> {
Self::from_profile_with_selector(
profile,
ModelSelector::Explicit {
provider_id: provider_id.to_owned(),
model: model_id.to_owned(),
},
)
}
fn from_profile_with_selector(
profile: &Profile,
selector: ModelSelector,
) -> Result<Self, ProviderError> {
let (commands, receiver) = mpsc::channel();
let (initialized, initialization) = mpsc::sync_channel(1);
let profile = profile.clone();
let _worker = thread::Builder::new()
.name("nana-lapp-chat".into())
.spawn(move || run_chat_worker(profile, receiver, initialized))
.spawn(move || run_chat_worker(profile, selector, receiver, initialized))
.map_err(|_| ProviderError::Configuration { code: None })?;
initialization
@@ -120,6 +141,7 @@ struct ChatCommand {
#[allow(clippy::needless_pass_by_value)]
fn run_chat_worker(
profile: Profile,
selector: ModelSelector,
commands: mpsc::Receiver<ChatCommand>,
initialized: mpsc::SyncSender<Result<(), ProviderError>>,
) {
@@ -131,11 +153,7 @@ fn run_chat_worker(
return;
};
let resolver: Arc<dyn CredentialResolver> = Arc::new(DefaultCredentialResolver::system());
let client = match Client::new(
&profile,
&ModelSelector::Default("chat".to_owned()),
resolver,
) {
let client = match Client::new(&profile, &selector, resolver) {
Ok(client) => client,
Err(error) => {
let _ = initialized.send(Err(ProviderError::Configuration {
@@ -212,6 +230,16 @@ impl LappAdjudicationModel<OpenLappChatExecutor> {
pub fn from_profile(profile: &Profile, bundle: ResourceBundle) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile(profile).map(|executor| Self::new(executor, bundle))
}
pub fn from_profile_and_model(
profile: &Profile,
provider_id: &str,
model_id: &str,
bundle: ResourceBundle,
) -> Result<Self, ProviderError> {
OpenLappChatExecutor::from_profile_and_model(profile, provider_id, model_id)
.map(|executor| Self::new(executor, bundle))
}
}
impl<Executor: ChatExecutor> AdjudicationModel for LappAdjudicationModel<Executor> {
+778 -7
View File
@@ -6,10 +6,11 @@ use std::{
};
use nana_domain::{RuntimeState, StoryNode, stable_json_hash};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
use thiserror::Error;
const SCHEMA_VERSION: i64 = 1;
const SCHEMA_VERSION: i64 = 2;
const LEGACY_SCHEMA_VERSION: i64 = 1;
#[cfg(test)]
const BUSY_TIMEOUT_MILLIS: i64 = 5_000;
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
@@ -38,6 +39,15 @@ pub enum StoreError {
Poisoned,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredBranch {
pub branch_id: String,
pub name: String,
pub head_node_id: String,
pub source_node_id: Option<String>,
pub ordinal: u32,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ForkError {
#[error("branch already exists: {story_id}/{branch_id}")]
@@ -85,6 +95,19 @@ pub trait StoryStore: Send + Sync {
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 list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError>;
fn active_branch(&self, story_id: &str) -> Result<String, StoreError>;
fn switch_active_branch(
&self,
story_id: &str,
expected_active_branch_id: &str,
branch_id: &str,
) -> Result<RuntimeState, StoreError>;
fn rename_branch(&self, story_id: &str, branch_id: &str, name: &str) -> Result<(), StoreError>;
}
#[derive(Debug, Default)]
@@ -92,6 +115,8 @@ struct MemoryData {
nodes: BTreeMap<(String, String), StoryNode>,
states: BTreeMap<(String, String), RuntimeState>,
branch_heads: BTreeMap<(String, String), String>,
branch_metadata: BTreeMap<(String, String), StoredBranch>,
active_branches: BTreeMap<String, String>,
}
/// Deterministic test and development store.
@@ -182,7 +207,36 @@ impl StoryStore for InMemoryStoryStore {
// 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());
data.branch_heads
.insert(branch_key.clone(), node.id.clone());
if !data.branch_metadata.contains_key(&branch_key) {
let ordinal = u32::try_from(
data.branch_metadata
.keys()
.filter(|(story_id, _)| story_id == &node.story_id)
.count()
+ 1,
)
.unwrap_or(u32::MAX);
data.branch_metadata.insert(
branch_key,
StoredBranch {
branch_id: node.branch_id.clone(),
name: default_branch_name(ordinal),
head_node_id: node.id.clone(),
source_node_id: None,
ordinal,
},
);
data.active_branches
.entry(node.story_id.clone())
.or_insert_with(|| node.branch_id.clone());
} else if let Some(metadata) = data
.branch_metadata
.get_mut(&(node.story_id.clone(), node.branch_id.clone()))
{
metadata.head_node_id.clone_from(&node.id);
}
Ok(())
}
@@ -218,7 +272,27 @@ impl StoryStore for InMemoryStoryStore {
// Validation is complete before the only mutation.
data.branch_heads
.insert(branch_key, source_node_id.to_owned());
.insert(branch_key.clone(), source_node_id.to_owned());
let ordinal = u32::try_from(
data.branch_metadata
.keys()
.filter(|(stored_story_id, _)| stored_story_id == story_id)
.count()
+ 1,
)
.unwrap_or(u32::MAX);
data.branch_metadata.insert(
branch_key,
StoredBranch {
branch_id: new_branch_id.to_owned(),
name: default_branch_name(ordinal),
head_node_id: source_node_id.to_owned(),
source_node_id: Some(source_node_id.to_owned()),
ordinal,
},
);
data.active_branches
.insert(story_id.to_owned(), new_branch_id.to_owned());
Ok(restored)
}
@@ -257,6 +331,86 @@ impl StoryStore for InMemoryStoryStore {
.cloned()
.ok_or_else(|| StoreError::ParentNotFound(node_id.to_owned()))
}
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, StoreError> {
let data = self.lock()?;
if !data
.nodes
.keys()
.any(|(stored_story_id, _)| stored_story_id == story_id)
{
return Err(StoreError::StoryNotFound(story_id.to_owned()));
}
let mut branches = data
.branch_metadata
.iter()
.filter(|((stored_story_id, _), _)| stored_story_id == story_id)
.map(|(_, branch)| branch.clone())
.collect::<Vec<_>>();
branches.sort_by_key(|branch| branch.ordinal);
Ok(branches)
}
fn active_branch(&self, story_id: &str) -> Result<String, StoreError> {
let data = self.lock()?;
data.active_branches
.get(story_id)
.cloned()
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))
}
fn switch_active_branch(
&self,
story_id: &str,
expected_active_branch_id: &str,
branch_id: &str,
) -> Result<RuntimeState, StoreError> {
let mut data = self.lock()?;
let active = data
.active_branches
.get(story_id)
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))?;
if active != expected_active_branch_id {
return Err(StoreError::StaleBranchHead {
expected: active.clone(),
actual: expected_active_branch_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(),
})?
.clone();
let node = data
.nodes
.get(&(story_id.to_owned(), head.clone()))
.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"))?;
let restored = restore_state_for_branch(node, state, story_id, &head, branch_id)?;
data.active_branches
.insert(story_id.to_owned(), branch_id.to_owned());
Ok(restored)
}
fn rename_branch(&self, story_id: &str, branch_id: &str, name: &str) -> Result<(), StoreError> {
let name = validate_branch_name(name)?;
let mut data = self.lock()?;
let branch = data
.branch_metadata
.get_mut(&(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(),
})?;
name.clone_into(&mut branch.name);
Ok(())
}
}
/// Durable `SQLite` implementation of the append-only story store.
@@ -347,6 +501,42 @@ impl SqliteStoryStore {
Ok(state)
}
pub fn get_app_setting(&self, key: &str) -> Result<Option<String>, StoreError> {
let connection = self.lock()?;
connection
.query_row(
"SELECT value FROM app_settings WHERE key = ?1",
params![key],
|row| row.get(0),
)
.optional()
.map_err(StoreError::from)
}
pub fn set_app_setting(&self, key: &str, value: &str) -> Result<(), StoreError> {
let connection = self.lock()?;
connection.execute(
"INSERT INTO app_settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
pub fn set_app_settings(&self, entries: &[(&str, &str)]) -> Result<(), StoreError> {
let mut connection = self.lock()?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
for (key, value) in entries {
transaction.execute(
"INSERT INTO app_settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
}
transaction.commit()?;
Ok(())
}
fn from_connection(
mut connection: Connection,
database_kind: DatabaseKind,
@@ -451,6 +641,7 @@ impl StoryStore for SqliteStoryStore {
DO UPDATE SET head_node_id = excluded.head_node_id",
params![node.story_id, node.branch_id, node.id],
)?;
ensure_branch_session(&transaction, node)?;
transaction.commit()?;
Ok(())
}
@@ -523,6 +714,31 @@ impl StoryStore for SqliteStoryStore {
VALUES (?1, ?2, ?3)",
params![story_id, new_branch_id, source_node_id],
)?;
let ordinal = transaction.query_row(
"SELECT COALESCE(MAX(ordinal), 0) + 1
FROM branch_metadata WHERE story_id = ?1",
params![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![
story_id,
new_branch_id,
default_branch_name(ordinal),
source_node_id,
ordinal
],
)?;
transaction.execute(
"INSERT INTO story_sessions (story_id, active_branch_id)
VALUES (?1, ?2)
ON CONFLICT(story_id)
DO UPDATE SET active_branch_id = excluded.active_branch_id",
params![story_id, new_branch_id],
)?;
transaction.commit()?;
Ok(restored)
}
@@ -599,6 +815,130 @@ impl StoryStore for SqliteStoryStore {
validate_loaded_node(&node, story_id, node_id, &stored.0, stored.1.as_deref())?;
Ok(node)
}
fn list_branches(&self, story_id: &str) -> Result<Vec<StoredBranch>, 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 mut statement = connection.prepare(
"SELECT branch_heads.branch_id, branch_metadata.name,
branch_heads.head_node_id, branch_metadata.source_node_id,
branch_metadata.ordinal
FROM branch_heads
JOIN branch_metadata
ON branch_metadata.story_id = branch_heads.story_id
AND branch_metadata.branch_id = branch_heads.branch_id
WHERE branch_heads.story_id = ?1
ORDER BY branch_metadata.ordinal, branch_heads.branch_id",
)?;
statement
.query_map(params![story_id], |row| {
Ok(StoredBranch {
branch_id: row.get(0)?,
name: row.get(1)?,
head_node_id: row.get(2)?,
source_node_id: row.get(3)?,
ordinal: row.get(4)?,
})
})?
.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
fn active_branch(&self, story_id: &str) -> Result<String, StoreError> {
let connection = self.lock()?;
connection
.query_row(
"SELECT active_branch_id FROM story_sessions WHERE story_id = ?1",
params![story_id],
|row| row.get(0),
)
.optional()?
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))
}
fn switch_active_branch(
&self,
story_id: &str,
expected_active_branch_id: &str,
branch_id: &str,
) -> Result<RuntimeState, StoreError> {
let mut connection = self.lock()?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
let active = transaction
.query_row(
"SELECT active_branch_id FROM story_sessions WHERE story_id = ?1",
params![story_id],
|row| row.get::<_, String>(0),
)
.optional()?
.ok_or_else(|| StoreError::StoryNotFound(story_id.to_owned()))?;
if active != expected_active_branch_id {
return Err(StoreError::StaleBranchHead {
expected: active,
actual: expected_active_branch_id.to_owned(),
});
}
let stored = load_branch_state(&transaction, story_id, branch_id)?;
transaction.execute(
"UPDATE story_sessions SET active_branch_id = ?2 WHERE story_id = ?1",
params![story_id, branch_id],
)?;
transaction.commit()?;
Ok(stored)
}
fn rename_branch(&self, story_id: &str, branch_id: &str, name: &str) -> Result<(), StoreError> {
let name = validate_branch_name(name)?;
let connection = self.lock()?;
let changed = connection.execute(
"UPDATE branch_metadata SET name = ?3
WHERE story_id = ?1 AND branch_id = ?2",
params![story_id, branch_id, name],
)?;
if changed == 0 {
return Err(StoreError::BranchNotFound {
story_id: story_id.to_owned(),
branch_id: branch_id.to_owned(),
});
}
Ok(())
}
}
fn ensure_branch_session(
transaction: &Transaction<'_>,
node: &StoryNode,
) -> Result<(), StoreError> {
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 OR IGNORE INTO branch_metadata (
story_id, branch_id, name, source_node_id, ordinal
) VALUES (?1, ?2, ?3, NULL, ?4)",
params![
node.story_id,
node.branch_id,
default_branch_name(ordinal),
ordinal
],
)?;
transaction.execute(
"INSERT OR IGNORE INTO story_sessions (story_id, active_branch_id)
VALUES (?1, ?2)",
params![node.story_id, node.branch_id],
)?;
Ok(())
}
fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
@@ -641,11 +981,23 @@ fn configure_journal(
fn initialize_schema(connection: &mut Connection) -> Result<(), StoreError> {
match schema_version(connection)? {
SCHEMA_VERSION => validate_schema(connection, SCHEMA_VERSION),
LEGACY_SCHEMA_VERSION => migrate_legacy_schema(connection),
0 => initialize_unversioned_schema(connection),
found => Err(unsupported_schema_version(found)),
}
}
fn migrate_legacy_schema(connection: &mut Connection) -> Result<(), StoreError> {
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
validate_legacy_schema(&transaction, LEGACY_SCHEMA_VERSION)?;
create_wave5_tables(&transaction)?;
backfill_wave5_tables(&transaction)?;
validate_schema(&transaction, SCHEMA_VERSION)?;
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
transaction.commit()?;
Ok(())
}
fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), StoreError> {
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
let version = schema_version(&transaction)?;
@@ -657,15 +1009,29 @@ fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), Stor
transaction.commit()?;
return Ok(());
}
if version == LEGACY_SCHEMA_VERSION {
validate_legacy_schema(&transaction, LEGACY_SCHEMA_VERSION)?;
create_wave5_tables(&transaction)?;
backfill_wave5_tables(&transaction)?;
validate_schema(&transaction, SCHEMA_VERSION)?;
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
transaction.commit()?;
return Ok(());
}
if version != 0 {
return Err(unsupported_schema_version(version));
}
if schema_has_user_objects(&transaction)? {
if validate_schema(&transaction, 0).is_ok() {
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
transaction.commit()?;
return Ok(());
}
// Wave 2 databases have this exact unversioned layout. Validate every
// required table, column, foreign key, and index before adopting them;
// a partial legacy database must never be repaired with IF NOT EXISTS.
validate_schema(&transaction, 0)?;
validate_legacy_schema(&transaction, 0)?;
} else {
transaction.execute_batch(
"CREATE TABLE nodes (
@@ -704,14 +1070,82 @@ fn initialize_unversioned_schema(connection: &mut Connection) -> Result<(), Stor
ON DELETE RESTRICT
);",
)?;
validate_schema(&transaction, SCHEMA_VERSION)?;
}
create_wave5_tables(&transaction)?;
backfill_wave5_tables(&transaction)?;
validate_schema(&transaction, SCHEMA_VERSION)?;
transaction.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
transaction.commit()?;
Ok(())
}
fn create_wave5_tables(connection: &Connection) -> Result<(), StoreError> {
connection.execute_batch(
"CREATE TABLE branch_metadata (
story_id TEXT NOT NULL,
branch_id TEXT NOT NULL,
name TEXT NOT NULL,
source_node_id TEXT,
ordinal INTEGER NOT NULL,
PRIMARY KEY (story_id, branch_id),
UNIQUE (story_id, ordinal),
FOREIGN KEY (story_id, branch_id)
REFERENCES branch_heads (story_id, branch_id)
ON DELETE RESTRICT,
FOREIGN KEY (story_id, source_node_id)
REFERENCES nodes (story_id, node_id)
ON DELETE RESTRICT
);
CREATE TABLE story_sessions (
story_id TEXT NOT NULL PRIMARY KEY,
active_branch_id TEXT NOT NULL,
FOREIGN KEY (story_id, active_branch_id)
REFERENCES branch_heads (story_id, branch_id)
ON DELETE RESTRICT
);
CREATE TABLE app_settings (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
fn backfill_wave5_tables(connection: &Connection) -> Result<(), StoreError> {
connection.execute_batch(
"WITH ranked AS (
SELECT story_id, branch_id,
ROW_NUMBER() OVER (
PARTITION BY story_id
ORDER BY CASE WHEN branch_id = 'branch_main' THEN 0 ELSE 1 END,
branch_id
) AS ordinal
FROM branch_heads
)
INSERT INTO branch_metadata (
story_id, branch_id, name, source_node_id, ordinal
)
SELECT story_id, branch_id,
CASE WHEN ordinal = 1 THEN '主线路'
ELSE '线路 ' || ordinal END,
NULL, ordinal
FROM ranked;
INSERT INTO story_sessions (story_id, active_branch_id)
SELECT story_id,
COALESCE(
MAX(CASE WHEN branch_id = 'branch_main' THEN branch_id END),
MIN(branch_id)
)
FROM branch_heads
GROUP BY story_id;",
)?;
Ok(())
}
fn schema_version(connection: &Connection) -> Result<i64, StoreError> {
connection
.query_row("PRAGMA user_version", [], |row| row.get(0))
@@ -766,6 +1200,14 @@ struct ExpectedForeignKey<'a> {
}
fn validate_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
validate_legacy_schema(connection, version)?;
validate_branch_metadata_schema(connection, version)?;
validate_story_sessions_schema(connection, version)?;
validate_app_settings_schema(connection, version)?;
Ok(())
}
fn validate_legacy_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
validate_nodes_schema(connection, version)?;
validate_materialized_states_schema(connection, version)?;
validate_branch_heads_schema(connection, version)?;
@@ -773,6 +1215,140 @@ fn validate_schema(connection: &Connection, version: i64) -> Result<(), StoreErr
Ok(())
}
fn validate_branch_metadata_schema(
connection: &Connection,
version: i64,
) -> Result<(), StoreError> {
validate_table(
connection,
version,
"branch_metadata",
&[
ExpectedColumn {
name: "story_id",
declared_type: "TEXT",
not_null: true,
primary_key_position: 1,
},
ExpectedColumn {
name: "branch_id",
declared_type: "TEXT",
not_null: true,
primary_key_position: 2,
},
ExpectedColumn {
name: "name",
declared_type: "TEXT",
not_null: true,
primary_key_position: 0,
},
ExpectedColumn {
name: "source_node_id",
declared_type: "TEXT",
not_null: false,
primary_key_position: 0,
},
ExpectedColumn {
name: "ordinal",
declared_type: "INTEGER",
not_null: true,
primary_key_position: 0,
},
],
&[
ExpectedForeignKey {
sequence: 0,
referenced_table: "nodes",
from_column: "story_id",
to_column: "story_id",
on_delete: "RESTRICT",
},
ExpectedForeignKey {
sequence: 1,
referenced_table: "nodes",
from_column: "source_node_id",
to_column: "node_id",
on_delete: "RESTRICT",
},
ExpectedForeignKey {
sequence: 0,
referenced_table: "branch_heads",
from_column: "story_id",
to_column: "story_id",
on_delete: "RESTRICT",
},
ExpectedForeignKey {
sequence: 1,
referenced_table: "branch_heads",
from_column: "branch_id",
to_column: "branch_id",
on_delete: "RESTRICT",
},
],
)
}
fn validate_story_sessions_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
validate_table(
connection,
version,
"story_sessions",
&[
ExpectedColumn {
name: "story_id",
declared_type: "TEXT",
not_null: true,
primary_key_position: 1,
},
ExpectedColumn {
name: "active_branch_id",
declared_type: "TEXT",
not_null: true,
primary_key_position: 0,
},
],
&[
ExpectedForeignKey {
sequence: 0,
referenced_table: "branch_heads",
from_column: "story_id",
to_column: "story_id",
on_delete: "RESTRICT",
},
ExpectedForeignKey {
sequence: 1,
referenced_table: "branch_heads",
from_column: "active_branch_id",
to_column: "branch_id",
on_delete: "RESTRICT",
},
],
)
}
fn validate_app_settings_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
validate_table(
connection,
version,
"app_settings",
&[
ExpectedColumn {
name: "key",
declared_type: "TEXT",
not_null: true,
primary_key_position: 1,
},
ExpectedColumn {
name: "value",
declared_type: "TEXT",
not_null: true,
primary_key_position: 0,
},
],
&[],
)
}
fn validate_nodes_schema(connection: &Connection, version: i64) -> Result<(), StoreError> {
validate_table(
connection,
@@ -1086,6 +1662,71 @@ fn validate_new_branch_id(branch_id: &str) -> Result<(), ForkError> {
Ok(())
}
fn default_branch_name(ordinal: u32) -> String {
if ordinal == 1 {
"主线路".to_owned()
} else {
format!("线路 {ordinal}")
}
}
fn validate_branch_name(name: &str) -> Result<String, StoreError> {
const MAX_BRANCH_NAME_CHARS: usize = 40;
let normalized = name.trim();
if normalized.is_empty()
|| normalized.chars().count() > MAX_BRANCH_NAME_CHARS
|| normalized.chars().any(char::is_control)
{
return Err(StoreError::StateMismatch("invalid branch name"));
}
Ok(normalized.to_owned())
}
fn load_branch_state(
connection: &Connection,
story_id: &str,
branch_id: &str,
) -> Result<RuntimeState, StoreError> {
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
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)?;
restore_state_for_branch(&node, &state, story_id, &stored.0, branch_id)
}
fn validate_materialized_state(node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError> {
if node.story_id != state.story_id {
return Err(StoreError::StateMismatch("story_id"));
@@ -1566,6 +2207,52 @@ mod tests {
);
}
fn assert_lists_renames_and_switches_active_branches(store: &impl InspectableStoryStore) {
seed_historical_main(store);
assert_eq!(
store
.active_branch("story_demo")
.expect("initial active branch"),
"branch_main"
);
store
.fork_branch("story_demo", "node_001", "branch_second")
.expect("fork becomes active");
assert_eq!(
store
.active_branch("story_demo")
.expect("forked active branch"),
"branch_second"
);
let branches = store.list_branches("story_demo").expect("branch list");
assert_eq!(branches.len(), 2);
assert_eq!(branches[0].name, "主线路");
assert_eq!(branches[1].name, "线路 2");
assert_eq!(branches[1].source_node_id.as_deref(), Some("node_001"));
store
.rename_branch("story_demo", "branch_second", " 等娜娜的线路 ")
.expect("rename branch");
assert_eq!(
store.list_branches("story_demo").expect("renamed list")[1].name,
"等娜娜的线路"
);
let switched = store
.switch_active_branch("story_demo", "branch_second", "branch_main")
.expect("switch back to main");
assert_eq!(switched.current_branch, "branch_main");
assert_eq!(switched.current_node, "node_002");
assert_eq!(
store.switch_active_branch("story_demo", "branch_second", "branch_second"),
Err(StoreError::StaleBranchHead {
expected: "branch_main".to_owned(),
actual: "branch_second".to_owned(),
})
);
}
#[test]
fn memory_appends_and_loads_the_branch_head() {
let store = InMemoryStoryStore::new();
@@ -1602,6 +2289,18 @@ mod tests {
assert_creates_independent_branches_from_history(&store);
}
#[test]
fn memory_lists_renames_and_switches_active_branches() {
let store = InMemoryStoryStore::new();
assert_lists_renames_and_switches_active_branches(&store);
}
#[test]
fn sqlite_lists_renames_and_switches_active_branches() {
let store = SqliteStoryStore::open_in_memory().expect("in-memory SQLite store");
assert_lists_renames_and_switches_active_branches(&store);
}
#[test]
fn memory_rejects_invalid_duplicate_and_unknown_forks_atomically() {
let store = InMemoryStoryStore::new();
@@ -2065,6 +2764,76 @@ mod tests {
);
}
#[test]
fn sqlite_migrates_wave4_schema_and_restores_branch_session_metadata() {
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
.connection
.lock()
.expect("SQLite connection lock")
.execute_batch(
"DROP TABLE story_sessions;
DROP TABLE branch_metadata;
DROP TABLE app_settings;
PRAGMA user_version = 1;",
)
.expect("simulate Wave 4 schema");
}
let migrated = SqliteStoryStore::open(database.path()).expect("migrated Wave 4 store");
assert_eq!(
migrated.active_branch("story_demo").expect("active branch"),
"branch_main"
);
assert_eq!(
migrated.list_branches("story_demo").expect("branch list")[0].name,
"主线路"
);
assert_eq!(
migrated
.connection
.lock()
.expect("SQLite connection lock")
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
.expect("schema version"),
SCHEMA_VERSION
);
}
#[test]
fn sqlite_persists_app_settings_without_exposing_them_to_story_state() {
let database = TemporaryDatabase::new();
{
let store = SqliteStoryStore::open(database.path()).expect("file SQLite store");
store
.set_app_settings(&[("lapp.provider_id", "provider"), ("lapp.model_id", "model")])
.expect("settings transaction");
}
let reopened = SqliteStoryStore::open(database.path()).expect("reopened store");
assert_eq!(
reopened
.get_app_setting("lapp.provider_id")
.expect("provider setting")
.as_deref(),
Some("provider")
);
assert_eq!(
reopened
.get_app_setting("lapp.model_id")
.expect("model setting")
.as_deref(),
Some("model")
);
}
#[test]
fn sqlite_rejects_an_unknown_future_schema_version_without_changing_it() {
let database = TemporaryDatabase::new();
@@ -2152,7 +2921,9 @@ mod tests {
assert!(matches!(
SqliteStoryStore::open(database.path()),
Err(StoreError::Sqlite(message))
if message.contains("schema version 1 is incomplete or incompatible")
if message.contains(&format!(
"schema version {SCHEMA_VERSION} is incomplete or incompatible"
))
));
let connection = Connection::open(database.path()).expect("reopen raw SQLite database");