This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user