//! Project-level Glossary SQLite persistence schema V2 repository. use crate::path_security::{ ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE, }; use crate::sqlite_migration::{ self, ExpectedColumn, ExpectedIndex, ExpectedTable, SqliteSchemaSnapshot, }; use async_trait::async_trait; use bat_core::domain::{ evaluate_glossary, validate_glossary_draft, GlossaryEvaluation, GlossaryHistoryRecord, GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot, TranslationMemoryContext, }; use bat_core::repositories::GlossaryRepository; use bat_core::{Error, Result}; use serde::de::DeserializeOwned; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode}; use sqlx::{Row, SqlitePool}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// Glossary SQLite schema version. pub const GLOSSARY_SCHEMA_VERSION: u32 = 2; /// Schema migration component. pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary"; /// Default project-level glossary file. pub const GLOSSARY_REPOSITORY_FILE: &str = "glossary.sqlite"; /// SQLite-backed project Glossary repository. #[derive(Debug, Clone)] pub struct SqliteGlossaryRepository { pool: SqlitePool, } impl SqliteGlossaryRepository { /// Creates or opens a glossary database. pub async fn new(path: impl AsRef) -> Result { Self::open_with(path.as_ref(), true).await } /// Opens an existing glossary database without creating it. pub async fn open(path: impl AsRef) -> Result { Self::open_with(path.as_ref(), false).await } /// Returns the project-level glossary path for an official release root. pub fn repository_path(resource_root: &Path) -> PathBuf { if resource_root .parent() .and_then(Path::file_name) .is_some_and(|name| name == "versions") { if let Some(output_root) = resource_root.parent().and_then(Path::parent) { return output_root.join(GLOSSARY_REPOSITORY_FILE); } } resource_root.join(GLOSSARY_REPOSITORY_FILE) } async fn open_with(path: &Path, create_if_missing: bool) -> Result { let absolute = lexical_absolute(path).map_err(Error::InvalidArgument)?; let parent = absolute.parent().ok_or_else(|| { Error::InvalidArgument(format!("Glossary 数据库缺少父目录:{}", absolute.display())) })?; ensure_safe_directory_path(parent, "Glossary 数据库").map_err(Error::InvalidArgument)?; if create_if_missing { tokio::fs::create_dir_all(parent).await?; ensure_safe_directory_path(parent, "Glossary 数据库") .map_err(Error::InvalidArgument)?; } else if !absolute.is_file() { return Err(Error::NotFound(absolute.display().to_string())); } if let Ok(metadata) = std::fs::symlink_metadata(&absolute) { if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(Error::InvalidArgument(format!( "Glossary 数据库必须是普通文件:{}", absolute.display() ))); } if metadata.len() > 0 { let snapshot = sqlite_migration::read_only_preflight(&absolute, GLOSSARY_SCHEMA_COMPONENT) .await .map_err(db_error)?; classify_glossary_schema(&snapshot)?; } } let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display())) .map_err(|error| Error::Other(error.into()))? .create_if_missing(create_if_missing) .journal_mode(SqliteJournalMode::Wal) .busy_timeout(Duration::from_secs(30)); let pool = sqlite_migration::connect_writable_pool(options) .await .map_err(db_error)?; if create_if_missing { set_file_mode(&absolute, STATE_FILE_MODE, "Glossary 数据库") .map_err(Error::InvalidArgument)?; } let repository = Self { pool }; repository.init_schema().await?; Ok(repository) } async fn init_schema(&self) -> Result<()> { self.init_schema_with_failure(None).await } #[cfg(test)] async fn init_schema_with_test_failure(&self, fail_after_step: usize) -> Result<()> { self.init_schema_with_failure(Some(fail_after_step)).await } async fn init_schema_with_failure(&self, fail_after_step: Option) -> Result<()> { let mut transaction = sqlite_migration::begin_immediate(&self.pool) .await .map_err(db_error)?; let result = self .migrate_in_transaction(&mut transaction, fail_after_step) .await; match result { Ok(()) => transaction.commit().await.map_err(db_error), Err(error) => { let _ = transaction.rollback().await; Err(error) } } } async fn migrate_in_transaction( &self, transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, fail_after_step: Option, ) -> Result<()> { let snapshot = sqlite_migration::snapshot_connection(transaction.as_mut(), GLOSSARY_SCHEMA_COMPONENT) .await .map_err(db_error)?; match classify_glossary_schema(&snapshot)? { GlossarySchemaState::Empty => { create_glossary_schema(transaction, fail_after_step).await?; sqlite_migration::write_component_version( transaction, GLOSSARY_SCHEMA_COMPONENT, GLOSSARY_SCHEMA_VERSION, ) .await .map_err(db_error)?; } GlossarySchemaState::HistoricalV1Original => { if !matches_glossary_v1_original_fingerprint(&snapshot) && !matches_glossary_v1_original_component_fingerprint(&snapshot) { return Err(invalid_glossary_schema( "事务内的 V1-A fingerprint 已发生变化".to_string(), )); } if !snapshot .tables .contains_key(sqlite_migration::SCHEMA_MIGRATIONS_TABLE) { sqlite_migration::create_schema_migrations_table(transaction) .await .map_err(db_error)?; } create_glossary_deletions_table(transaction).await?; if fail_after_step == Some(1) { return Err(Error::Other(anyhow::anyhow!( "Glossary V1-A migration failed after deletion table creation" ))); } let migrated_snapshot = sqlite_migration::snapshot_connection( transaction.as_mut(), GLOSSARY_SCHEMA_COMPONENT, ) .await .map_err(db_error)?; if !matches_glossary_v2_fingerprint(&migrated_snapshot) { return Err(invalid_glossary_schema( "V1-A migration 未达到 V2 fingerprint".to_string(), )); } sqlite_migration::write_component_version( transaction, GLOSSARY_SCHEMA_COMPONENT, GLOSSARY_SCHEMA_VERSION, ) .await .map_err(db_error)?; } GlossarySchemaState::HistoricalV1DeletionDrift => { if !matches_glossary_v2_component_fingerprint(&snapshot) && !matches_glossary_v2_fingerprint(&snapshot) { return Err(invalid_glossary_schema( "事务内的 V1-B drift fingerprint 已发生变化".to_string(), )); } if !snapshot .tables .contains_key(sqlite_migration::SCHEMA_MIGRATIONS_TABLE) { sqlite_migration::create_schema_migrations_table(transaction) .await .map_err(db_error)?; } sqlite_migration::write_component_version( transaction, GLOSSARY_SCHEMA_COMPONENT, GLOSSARY_SCHEMA_VERSION, ) .await .map_err(db_error)?; } GlossarySchemaState::V2 => { if snapshot.component_version.is_none() { if !snapshot .tables .contains_key(sqlite_migration::SCHEMA_MIGRATIONS_TABLE) { sqlite_migration::create_schema_migrations_table(transaction) .await .map_err(db_error)?; } sqlite_migration::write_component_version( transaction, GLOSSARY_SCHEMA_COMPONENT, GLOSSARY_SCHEMA_VERSION, ) .await .map_err(db_error)?; } } } let final_snapshot = sqlite_migration::snapshot_connection(transaction.as_mut(), GLOSSARY_SCHEMA_COMPONENT) .await .map_err(db_error)?; if final_snapshot.component_version != Some(i64::from(GLOSSARY_SCHEMA_VERSION)) || !matches_glossary_v2_fingerprint(&final_snapshot) { return Err(invalid_glossary_schema( "migration 结果与当前 schema fingerprint 不一致".to_string(), )); } Ok(()) } /// Returns one term with complete history. pub async fn find(&self, term_id: &str) -> Result { let row = sqlx::query( "SELECT term_id, source_term, aliases_json, recommended_translation, allowed_translations_json, source_language, target_language, category, priority, scope_json, review_status, source_kind, source_ref, source_author, source_note, source_observed_unix_seconds, created_unix_seconds, updated_unix_seconds FROM glossary_terms WHERE term_id = ?1", ) .bind(term_id) .fetch_optional(&self.pool) .await .map_err(db_error)? .ok_or_else(|| Error::NotFound(term_id.to_string()))?; let mut term = row_to_term(row)?; let history_rows = sqlx::query( "SELECT history_id, action, reviewer, reason, source_json, review_status, snapshot_json, observed_unix_seconds FROM glossary_term_history WHERE term_id = ?1 ORDER BY observed_unix_seconds ASC, history_id ASC", ) .bind(term_id) .fetch_all(&self.pool) .await .map_err(db_error)?; term.history = history_rows .into_iter() .map(row_to_history) .collect::>>()?; Ok(term) } /// Queries terms, including non-approved terms for review. pub async fn query( &self, source_text: Option<&str>, category: Option<&str>, review_status: Option, limit: usize, ) -> Result> { if !(1..=1000).contains(&limit) { return Err(Error::InvalidArgument( "Glossary query limit 必须在 1..=1000 范围内".to_string(), )); } let candidates = self.load_terms(category, review_status).await?; let mut terms = Vec::new(); for term in candidates { if source_text.is_none_or(|source| { let mut spellings = vec![term.definition.source_term.as_str()]; spellings.extend(term.definition.aliases.iter().map(String::as_str)); spellings .into_iter() .filter(|spelling| !spelling.is_empty()) .any(|spelling| source.contains(spelling)) }) { terms.push(term); if terms.len() >= limit { break; } } } Ok(terms) } async fn load_terms( &self, category: Option<&str>, review_status: Option, ) -> Result> { let rows = sqlx::query( "SELECT term_id FROM glossary_terms WHERE (?1 IS NULL OR category = ?1) AND (?2 IS NULL OR review_status = ?2) ORDER BY priority DESC, term_id ASC", ) .bind(category) .bind(review_status.map(|value| value.as_str())) .fetch_all(&self.pool) .await .map_err(db_error)?; let mut terms = Vec::new(); for row in rows { let term_id: String = row.try_get("term_id").map_err(db_error)?; terms.push(self.find(&term_id).await?); } Ok(terms) } /// Returns review-state counts. pub async fn summary(&self) -> Result { let row = sqlx::query( "SELECT COUNT(*) AS term_count, SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count, SUM(CASE WHEN review_status = 'draft' THEN 1 ELSE 0 END) AS draft_count, SUM(CASE WHEN review_status = 'deprecated' THEN 1 ELSE 0 END) AS deprecated_count, SUM(CASE WHEN review_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count FROM glossary_terms", ) .fetch_one(&self.pool) .await .map_err(db_error)?; Ok(GlossarySummary { schema_version: GLOSSARY_SCHEMA_VERSION, term_count: row.try_get::("term_count").map_err(db_error)? as u64, approved_count: row.try_get::("approved_count").map_err(db_error)? as u64, draft_count: row.try_get::("draft_count").map_err(db_error)? as u64, deprecated_count: row .try_get::("deprecated_count") .map_err(db_error)? as u64, rejected_count: row.try_get::("rejected_count").map_err(db_error)? as u64, }) } /// Adds a term and records its source snapshot. pub async fn add(&self, draft: GlossaryTermDraft) -> Result { validate_glossary_draft(&draft)?; if draft.review_status != GlossaryReviewStatus::Draft { return Err(Error::InvalidArgument( "Glossary add 只能创建 draft;请通过 review/approve 使术语生效".to_string(), )); } let now = draft.source.observed_unix_seconds; let term = term_from_draft(&draft, now, now); let mut transaction = self.pool.begin().await.map_err(db_error)?; let existing: Option = sqlx::query_scalar("SELECT term_id FROM glossary_terms WHERE term_id = ?1") .bind(&draft.term_id) .fetch_optional(&mut *transaction) .await .map_err(db_error)?; if existing.is_some() { return Err(Error::InvalidArgument(format!( "Glossary term_id 已存在:{}", draft.term_id ))); } insert_term(&mut transaction, &term).await?; insert_history(&mut transaction, &term, "created", None, None, now).await?; transaction.commit().await.map_err(db_error)?; Ok(term) } /// Replaces a term definition and records the previous source history. pub async fn update( &self, draft: GlossaryTermDraft, reviewer: &str, reason: Option, ) -> Result { validate_glossary_draft(&draft)?; if draft.review_status != GlossaryReviewStatus::Draft { return Err(Error::InvalidArgument( "Glossary update 只能写入 draft;修改 approved 术语后必须重新 approve".to_string(), )); } if reviewer.trim().is_empty() { return Err(Error::InvalidArgument( "Glossary update reviewer 不能为空".to_string(), )); } let current = self.find(&draft.term_id).await?; let now = draft.source.observed_unix_seconds; let term = term_from_draft(&draft, current.created_unix_seconds, now); let mut transaction = self.pool.begin().await.map_err(db_error)?; update_term(&mut transaction, &term).await?; insert_history( &mut transaction, &term, "updated", Some(reviewer.trim()), reason.as_deref(), now, ) .await?; transaction.commit().await.map_err(db_error)?; self.find(&draft.term_id).await } /// Permanently removes a term and its stored history after explicit review. pub async fn delete( &self, term_id: &str, reviewer: &str, reason: &str, ) -> Result { if reviewer.trim().is_empty() { return Err(Error::InvalidArgument( "Glossary delete reviewer 不能为空".to_string(), )); } if reason.trim().is_empty() { return Err(Error::InvalidArgument( "Glossary delete reason 不能为空".to_string(), )); } let term = self.find(term_id).await?; let mut transaction = self.pool.begin().await.map_err(db_error)?; let source_json = json(&term.source)?; let snapshot_json = json(&term.definition)?; let history_json = json(&term.history)?; let observed = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); let mut deletion_hasher = blake3::Hasher::new(); for value in [term_id, reviewer.trim(), reason.trim()] { deletion_hasher.update(value.as_bytes()); deletion_hasher.update(&[0]); } deletion_hasher.update(&observed.to_le_bytes()); deletion_hasher.update( &SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos() .to_le_bytes(), ); sqlx::query( "INSERT INTO glossary_term_deletions ( deletion_id, term_id, reviewer, reason, source_json, snapshot_json, history_json, observed_unix_seconds ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", ) .bind(format!("gld-{}", deletion_hasher.finalize().to_hex())) .bind(term_id) .bind(reviewer.trim()) .bind(reason.trim()) .bind(source_json) .bind(snapshot_json) .bind(history_json) .bind(i64::try_from(observed).unwrap_or(i64::MAX)) .execute(&mut *transaction) .await .map_err(db_error)?; sqlx::query("DELETE FROM glossary_term_history WHERE term_id = ?1") .bind(term_id) .execute(&mut *transaction) .await .map_err(db_error)?; sqlx::query("DELETE FROM glossary_terms WHERE term_id = ?1") .bind(term_id) .execute(&mut *transaction) .await .map_err(db_error)?; transaction.commit().await.map_err(db_error)?; Ok(term) } /// Changes review state and records a source/review history entry. pub async fn review( &self, term_id: &str, status: GlossaryReviewStatus, reviewer: &str, reason: Option, ) -> Result { if reviewer.trim().is_empty() { return Err(Error::InvalidArgument( "Glossary reviewer 不能为空".to_string(), )); } if !matches!( status, GlossaryReviewStatus::Approved | GlossaryReviewStatus::Deprecated | GlossaryReviewStatus::Rejected ) { return Err(Error::InvalidArgument( "Glossary review 只允许 approved、deprecated 或 rejected".to_string(), )); } let mut term = self.find(term_id).await?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); term.review_status = status; term.updated_unix_seconds = now; let mut transaction = self.pool.begin().await.map_err(db_error)?; sqlx::query( "UPDATE glossary_terms SET review_status = ?2, updated_unix_seconds = ?3 WHERE term_id = ?1", ) .bind(term_id) .bind(status.as_str()) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .execute(&mut *transaction) .await .map_err(db_error)?; insert_history( &mut transaction, &term, status.as_str(), Some(reviewer.trim()), reason.as_deref(), now, ) .await?; transaction.commit().await.map_err(db_error)?; self.find(term_id).await } /// Evaluates approved terms for one TextUnit. pub async fn diagnose( &self, source_text: &str, context: &TranslationMemoryContext, ) -> Result { let terms = self .load_terms(None, Some(GlossaryReviewStatus::Approved)) .await?; Ok(evaluate_glossary(&terms, source_text, context)) } } #[async_trait] impl GlossaryRepository for SqliteGlossaryRepository { async fn evaluate( &self, source_text: &str, context: &TranslationMemoryContext, ) -> Result { self.diagnose(source_text, context).await } } fn term_from_draft(draft: &GlossaryTermDraft, created: u64, updated: u64) -> GlossaryTerm { GlossaryTerm { term_id: draft.term_id.clone(), definition: draft.definition.clone(), review_status: draft.review_status, source: draft.source.clone(), history: Vec::new(), created_unix_seconds: created, updated_unix_seconds: updated, } } async fn insert_term( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, term: &GlossaryTerm, ) -> Result<()> { sqlx::query( "INSERT INTO glossary_terms ( term_id, source_term, aliases_json, recommended_translation, allowed_translations_json, source_language, target_language, category, priority, scope_json, review_status, source_kind, source_ref, source_author, source_note, source_observed_unix_seconds, created_unix_seconds, updated_unix_seconds ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", ) .bind(&term.term_id) .bind(&term.definition.source_term) .bind(json(&term.definition.aliases)?) .bind(&term.definition.recommended_translation) .bind(json(&term.definition.allowed_translations)?) .bind(&term.definition.source_language) .bind(&term.definition.target_language) .bind(&term.definition.category) .bind(term.definition.priority) .bind(json(&term.definition.scope)?) .bind(term.review_status.as_str()) .bind(term.source.source_kind.as_str()) .bind(&term.source.source_ref) .bind(&term.source.source_author) .bind(&term.source.source_note) .bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX)) .bind(i64::try_from(term.created_unix_seconds).unwrap_or(i64::MAX)) .bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX)) .execute(&mut **transaction) .await .map_err(db_error)?; Ok(()) } async fn update_term( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, term: &GlossaryTerm, ) -> Result<()> { sqlx::query( "UPDATE glossary_terms SET source_term = ?2, aliases_json = ?3, recommended_translation = ?4, allowed_translations_json = ?5, source_language = ?6, target_language = ?7, category = ?8, priority = ?9, scope_json = ?10, review_status = ?11, source_kind = ?12, source_ref = ?13, source_author = ?14, source_note = ?15, source_observed_unix_seconds = ?16, updated_unix_seconds = ?17 WHERE term_id = ?1", ) .bind(&term.term_id) .bind(&term.definition.source_term) .bind(json(&term.definition.aliases)?) .bind(&term.definition.recommended_translation) .bind(json(&term.definition.allowed_translations)?) .bind(&term.definition.source_language) .bind(&term.definition.target_language) .bind(&term.definition.category) .bind(term.definition.priority) .bind(json(&term.definition.scope)?) .bind(term.review_status.as_str()) .bind(term.source.source_kind.as_str()) .bind(&term.source.source_ref) .bind(&term.source.source_author) .bind(&term.source.source_note) .bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX)) .bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX)) .execute(&mut **transaction) .await .map_err(db_error)?; Ok(()) } async fn insert_history( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, term: &GlossaryTerm, action: &str, reviewer: Option<&str>, reason: Option<&str>, observed: u64, ) -> Result<()> { let source_json = json(&term.source)?; let snapshot_json = json(&term.definition)?; let mut history_hasher = blake3::Hasher::new(); for value in [ term.term_id.as_str(), action, reviewer.unwrap_or_default(), reason.unwrap_or_default(), source_json.as_str(), snapshot_json.as_str(), ] { history_hasher.update(value.as_bytes()); history_hasher.update(&[0]); } history_hasher.update(&observed.to_le_bytes()); let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); history_hasher.update(&nonce.to_le_bytes()); let history_id = format!("glh-{}", history_hasher.finalize().to_hex()); sqlx::query( "INSERT INTO glossary_term_history ( history_id, term_id, action, reviewer, reason, source_json, review_status, snapshot_json, observed_unix_seconds ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", ) .bind(history_id) .bind(&term.term_id) .bind(action) .bind(reviewer) .bind(reason.filter(|value| !value.trim().is_empty())) .bind(source_json) .bind(term.review_status.as_str()) .bind(snapshot_json) .bind(i64::try_from(observed).unwrap_or(i64::MAX)) .execute(&mut **transaction) .await .map_err(db_error)?; Ok(()) } fn row_to_term(row: sqlx::sqlite::SqliteRow) -> Result { Ok(GlossaryTerm { term_id: row.try_get("term_id").map_err(db_error)?, definition: GlossaryTermSnapshot { source_term: row.try_get("source_term").map_err(db_error)?, aliases: parse_json(row.try_get("aliases_json").map_err(db_error)?)?, recommended_translation: row.try_get("recommended_translation").map_err(db_error)?, allowed_translations: parse_json( row.try_get("allowed_translations_json").map_err(db_error)?, )?, source_language: row.try_get("source_language").map_err(db_error)?, target_language: row.try_get("target_language").map_err(db_error)?, category: row.try_get("category").map_err(db_error)?, priority: row.try_get("priority").map_err(db_error)?, scope: parse_json(row.try_get("scope_json").map_err(db_error)?)?, }, review_status: parse_review_status( row.try_get::("review_status") .map_err(db_error)? .as_str(), )?, source: GlossarySourceRecord { source_kind: parse_source_kind( row.try_get::("source_kind") .map_err(db_error)? .as_str(), )?, source_ref: row.try_get("source_ref").map_err(db_error)?, source_author: row.try_get("source_author").map_err(db_error)?, source_note: row.try_get("source_note").map_err(db_error)?, observed_unix_seconds: to_u64( row.try_get("source_observed_unix_seconds") .map_err(db_error)?, "source", )?, }, history: Vec::new(), created_unix_seconds: to_u64( row.try_get("created_unix_seconds").map_err(db_error)?, "created", )?, updated_unix_seconds: to_u64( row.try_get("updated_unix_seconds").map_err(db_error)?, "updated", )?, }) } fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result { Ok(GlossaryHistoryRecord { history_id: row.try_get("history_id").map_err(db_error)?, action: row.try_get("action").map_err(db_error)?, reviewer: row.try_get("reviewer").map_err(db_error)?, reason: row.try_get("reason").map_err(db_error)?, source: parse_json(row.try_get("source_json").map_err(db_error)?)?, review_status: parse_review_status( row.try_get::("review_status") .map_err(db_error)? .as_str(), )?, snapshot: parse_json(row.try_get("snapshot_json").map_err(db_error)?)?, observed_unix_seconds: to_u64( row.try_get("observed_unix_seconds").map_err(db_error)?, "history", )?, }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GlossarySchemaState { Empty, HistoricalV1Original, HistoricalV1DeletionDrift, V2, } fn classify_glossary_schema(snapshot: &SqliteSchemaSnapshot) -> Result { if snapshot.is_empty() { return Ok(GlossarySchemaState::Empty); } if let Some(observed) = snapshot.component_version { if observed > i64::from(GLOSSARY_SCHEMA_VERSION) { return Err(invalid_glossary_schema(format!( "不支持的 Glossary schema 版本:observed={observed}, supported={GLOSSARY_SCHEMA_VERSION}" ))); } if observed < 1 { return Err(invalid_glossary_schema(format!( "schema version 无效:observed={observed}, supported=1..={GLOSSARY_SCHEMA_VERSION}" ))); } } let is_v1_original = matches_glossary_v1_original_fingerprint(snapshot) || (snapshot.component_version.is_none() && matches_glossary_v1_original_component_fingerprint(snapshot)); let is_v1_deletion_drift = matches_glossary_v2_fingerprint(snapshot) || (snapshot.component_version.is_none() && matches_glossary_v2_component_fingerprint(snapshot)); match snapshot.component_version { Some(1) if is_v1_original => Ok(GlossarySchemaState::HistoricalV1Original), Some(1) if is_v1_deletion_drift => { Ok(GlossarySchemaState::HistoricalV1DeletionDrift) } Some(2) if is_v1_deletion_drift => Ok(GlossarySchemaState::V2), None if is_v1_original => Ok(GlossarySchemaState::HistoricalV1Original), None if is_v1_deletion_drift => Ok(GlossarySchemaState::HistoricalV1DeletionDrift), Some(observed) => Err(invalid_glossary_schema(format!( "schema version 与实际结构不一致:observed={observed}, supported={GLOSSARY_SCHEMA_VERSION}" ))), None => Err(invalid_glossary_schema( "未识别的 legacy schema,拒绝静默修复".to_string(), )), } } fn invalid_glossary_schema(detail: String) -> Error { Error::InvalidArgument(format!("Glossary schema 无效:{detail}")) } fn matches_glossary_v2_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { let tables = [ sqlite_migration::schema_migrations_table(), ExpectedTable { name: "glossary_terms", columns: &GLOSSARY_TERM_COLUMNS, }, ExpectedTable { name: "glossary_term_history", columns: &GLOSSARY_HISTORY_COLUMNS, }, ExpectedTable { name: "glossary_term_deletions", columns: &GLOSSARY_DELETION_COLUMNS, }, ]; matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES) } fn matches_glossary_v2_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { let tables = [ ExpectedTable { name: "glossary_terms", columns: &GLOSSARY_TERM_COLUMNS, }, ExpectedTable { name: "glossary_term_history", columns: &GLOSSARY_HISTORY_COLUMNS, }, ExpectedTable { name: "glossary_term_deletions", columns: &GLOSSARY_DELETION_COLUMNS, }, ]; matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES) } fn matches_glossary_v1_original_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { let tables = [ sqlite_migration::schema_migrations_table(), ExpectedTable { name: "glossary_terms", columns: &GLOSSARY_TERM_COLUMNS, }, ExpectedTable { name: "glossary_term_history", columns: &GLOSSARY_HISTORY_COLUMNS, }, ]; matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES) } fn matches_glossary_v1_original_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { let tables = [ ExpectedTable { name: "glossary_terms", columns: &GLOSSARY_TERM_COLUMNS, }, ExpectedTable { name: "glossary_term_history", columns: &GLOSSARY_HISTORY_COLUMNS, }, ]; matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES) } fn matches_glossary_fingerprint_with_tables( snapshot: &SqliteSchemaSnapshot, tables: &[ExpectedTable<'_>], indexes: &[ExpectedIndex<'_>], ) -> bool { sqlite_migration::matches_fingerprint(snapshot, tables, indexes) } const GLOSSARY_INDEXES: [ExpectedIndex<'static>; 2] = [ ExpectedIndex { table: "glossary_terms", name: "idx_glossary_status", columns: &["review_status", "priority", "term_id"], }, ExpectedIndex { table: "glossary_terms", name: "idx_glossary_source_term", columns: &["source_term"], }, ]; const GLOSSARY_TERM_COLUMNS: [ExpectedColumn<'static>; 18] = [ ExpectedColumn { name: "term_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: true, }, ExpectedColumn { name: "source_term", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "aliases_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "recommended_translation", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "allowed_translations_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_language", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "target_language", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "category", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "priority", data_type: "INTEGER", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "scope_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "review_status", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_kind", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_ref", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_author", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_note", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_observed_unix_seconds", data_type: "INTEGER", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "created_unix_seconds", data_type: "INTEGER", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "updated_unix_seconds", data_type: "INTEGER", not_null: true, default_value: None, primary_key: false, }, ]; const GLOSSARY_HISTORY_COLUMNS: [ExpectedColumn<'static>; 9] = [ ExpectedColumn { name: "history_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: true, }, ExpectedColumn { name: "term_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "action", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "reviewer", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "reason", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "review_status", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "snapshot_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "observed_unix_seconds", data_type: "INTEGER", not_null: true, default_value: None, primary_key: false, }, ]; const GLOSSARY_DELETION_COLUMNS: [ExpectedColumn<'static>; 8] = [ ExpectedColumn { name: "deletion_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: true, }, ExpectedColumn { name: "term_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "reviewer", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "reason", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "snapshot_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "history_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "observed_unix_seconds", data_type: "INTEGER", not_null: true, default_value: None, primary_key: false, }, ]; async fn create_glossary_deletions_table( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, ) -> Result<()> { sqlx::query( "CREATE TABLE glossary_term_deletions ( deletion_id TEXT PRIMARY KEY NOT NULL, term_id TEXT NOT NULL, reviewer TEXT NOT NULL, reason TEXT NOT NULL, source_json TEXT NOT NULL, snapshot_json TEXT NOT NULL, history_json TEXT NOT NULL, observed_unix_seconds INTEGER NOT NULL )", ) .execute(&mut **transaction) .await .map_err(db_error)?; Ok(()) } async fn create_glossary_schema( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, fail_after_step: Option, ) -> Result<()> { let steps = [ "CREATE TABLE schema_migrations ( component TEXT PRIMARY KEY NOT NULL, version INTEGER NOT NULL CHECK(version >= 1) )", "CREATE TABLE glossary_terms ( term_id TEXT PRIMARY KEY NOT NULL, source_term TEXT NOT NULL, aliases_json TEXT NOT NULL, recommended_translation TEXT NOT NULL, allowed_translations_json TEXT NOT NULL, source_language TEXT, target_language TEXT, category TEXT, priority INTEGER NOT NULL, scope_json TEXT NOT NULL, review_status TEXT NOT NULL, source_kind TEXT NOT NULL, source_ref TEXT, source_author TEXT, source_note TEXT, source_observed_unix_seconds INTEGER NOT NULL, created_unix_seconds INTEGER NOT NULL, updated_unix_seconds INTEGER NOT NULL, CHECK(length(term_id) > 0), CHECK(length(source_term) > 0), CHECK(length(recommended_translation) > 0), CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')), CHECK(source_kind IN ('manual', 'imported')) )", "CREATE TABLE glossary_term_history ( history_id TEXT PRIMARY KEY NOT NULL, term_id TEXT NOT NULL, action TEXT NOT NULL, reviewer TEXT, reason TEXT, source_json TEXT NOT NULL, review_status TEXT NOT NULL, snapshot_json TEXT NOT NULL, observed_unix_seconds INTEGER NOT NULL, FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id) )", "CREATE TABLE glossary_term_deletions ( deletion_id TEXT PRIMARY KEY NOT NULL, term_id TEXT NOT NULL, reviewer TEXT NOT NULL, reason TEXT NOT NULL, source_json TEXT NOT NULL, snapshot_json TEXT NOT NULL, history_json TEXT NOT NULL, observed_unix_seconds INTEGER NOT NULL )", "CREATE INDEX idx_glossary_status ON glossary_terms(review_status, priority DESC, term_id)", "CREATE INDEX idx_glossary_source_term ON glossary_terms(source_term)", ]; for (index, statement) in steps.iter().enumerate() { sqlx::query(statement) .execute(&mut **transaction) .await .map_err(db_error)?; if fail_after_step == Some(index + 1) { return Err(Error::Other(anyhow::anyhow!( "Glossary migration failed after step {}", index + 1 ))); } } Ok(()) } fn parse_json(value: String) -> Result { serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string())) } fn json(value: &T) -> Result { serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string())) } fn parse_review_status(value: &str) -> Result { GlossaryReviewStatus::parse(value) .ok_or_else(|| Error::Serialization(format!("未知 Glossary review status:{value}"))) } fn parse_source_kind(value: &str) -> Result { GlossarySourceKind::parse(value) .ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind:{value}"))) } fn to_u64(value: i64, label: &str) -> Result { u64::try_from(value).map_err(|_| Error::Serialization(format!("Glossary {label} 时间无效"))) } fn db_error(error: sqlx::Error) -> Error { Error::Other(error.into()) } #[cfg(test)] mod tests { use super::*; use sqlx::sqlite::SqlitePoolOptions; use std::collections::BTreeMap; fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft { GlossaryTermDraft { term_id: "term-sensei".to_string(), definition: GlossaryTermSnapshot { source_term: "Sensei".to_string(), aliases: vec!["Teacher".to_string()], recommended_translation: "老师".to_string(), allowed_translations: vec!["老师大人".to_string()], source_language: Some("en".to_string()), target_language: Some("zh-Hans".to_string()), category: Some("person".to_string()), priority: 10, scope: BTreeMap::new(), }, review_status: status, source: GlossarySourceRecord { source_kind: GlossarySourceKind::Manual, source_ref: Some("test".to_string()), source_author: Some("tester".to_string()), source_note: None, observed_unix_seconds: 1, }, } } async fn raw_repository( path: &std::path::Path, create_if_missing: bool, ) -> SqliteGlossaryRepository { let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display())) .unwrap() .create_if_missing(create_if_missing); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .unwrap(); SqliteGlossaryRepository { pool } } // Fixture derived from commit 94483ff: the original Glossary V1 had no // deletion audit table. async fn create_historical_glossary_v1_original( path: &std::path::Path, with_schema_migrations: bool, with_component_version: bool, ) { create_historical_glossary(path, false, with_schema_migrations, with_component_version) .await; } // Fixture derived from the post-0275a890 schema: deletion auditing was // added while the persisted component version incorrectly remained 1. async fn create_historical_glossary_v1_deletion_drift( path: &std::path::Path, with_schema_migrations: bool, with_component_version: bool, ) { create_historical_glossary(path, true, with_schema_migrations, with_component_version) .await; } async fn create_historical_glossary( path: &std::path::Path, with_deletions: bool, with_schema_migrations: bool, with_component_version: bool, ) { let repository = raw_repository(path, true).await; if with_schema_migrations { sqlx::query( "CREATE TABLE schema_migrations ( component TEXT PRIMARY KEY NOT NULL, version INTEGER NOT NULL CHECK(version >= 1) )", ) .execute(&repository.pool) .await .unwrap(); } sqlx::query( "CREATE TABLE glossary_terms ( term_id TEXT PRIMARY KEY NOT NULL, source_term TEXT NOT NULL, aliases_json TEXT NOT NULL, recommended_translation TEXT NOT NULL, allowed_translations_json TEXT NOT NULL, source_language TEXT, target_language TEXT, category TEXT, priority INTEGER NOT NULL, scope_json TEXT NOT NULL, review_status TEXT NOT NULL, source_kind TEXT NOT NULL, source_ref TEXT, source_author TEXT, source_note TEXT, source_observed_unix_seconds INTEGER NOT NULL, created_unix_seconds INTEGER NOT NULL, updated_unix_seconds INTEGER NOT NULL, CHECK(length(term_id) > 0), CHECK(length(source_term) > 0), CHECK(length(recommended_translation) > 0), CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')), CHECK(source_kind IN ('manual', 'imported')) )", ) .execute(&repository.pool) .await .unwrap(); sqlx::query( "CREATE TABLE glossary_term_history ( history_id TEXT PRIMARY KEY NOT NULL, term_id TEXT NOT NULL, action TEXT NOT NULL, reviewer TEXT, reason TEXT, source_json TEXT NOT NULL, review_status TEXT NOT NULL, snapshot_json TEXT NOT NULL, observed_unix_seconds INTEGER NOT NULL, FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id) )", ) .execute(&repository.pool) .await .unwrap(); if with_deletions { sqlx::query( "CREATE TABLE glossary_term_deletions ( deletion_id TEXT PRIMARY KEY NOT NULL, term_id TEXT NOT NULL, reviewer TEXT NOT NULL, reason TEXT NOT NULL, source_json TEXT NOT NULL, snapshot_json TEXT NOT NULL, history_json TEXT NOT NULL, observed_unix_seconds INTEGER NOT NULL )", ) .execute(&repository.pool) .await .unwrap(); } sqlx::query( "CREATE INDEX idx_glossary_status ON glossary_terms(review_status, priority DESC, term_id)", ) .execute(&repository.pool) .await .unwrap(); sqlx::query( "CREATE INDEX idx_glossary_source_term ON glossary_terms(source_term)", ) .execute(&repository.pool) .await .unwrap(); sqlx::query( "INSERT INTO glossary_terms ( term_id, source_term, aliases_json, recommended_translation, allowed_translations_json, source_language, target_language, category, priority, scope_json, review_status, source_kind, source_ref, source_author, source_note, source_observed_unix_seconds, created_unix_seconds, updated_unix_seconds ) VALUES ( 'historical-term', 'Sensei', '[\"Teacher\",\"Master\"]', '老师', '[\"老师大人\",\"老师\"]', 'en', 'zh-Hans', 'person', 10, '{\"destination\":\"Bundles/story.bundle\"}', 'approved', 'manual', 'historical-fixture', 'reviewer', 'legacy', 11, 12, 13 )", ) .execute(&repository.pool) .await .unwrap(); sqlx::query( "INSERT INTO glossary_term_history ( history_id, term_id, action, reviewer, reason, source_json, review_status, snapshot_json, observed_unix_seconds ) VALUES ('history-created', 'historical-term', 'created', NULL, NULL, '{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}', 'draft', '{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}', 12), ('history-approved', 'historical-term', 'approved', 'reviewer', 'legacy approval', '{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}', 'approved', '{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}', 13)", ) .execute(&repository.pool) .await .unwrap(); if with_deletions { sqlx::query( "INSERT INTO glossary_term_deletions ( deletion_id, term_id, reviewer, reason, source_json, snapshot_json, history_json, observed_unix_seconds ) VALUES ( 'deletion-legacy-1', 'historical-term', 'deleter', 'legacy cleanup', '{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}', '{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}', '[{\"history_id\":\"history-approved\"}]', 14 )", ) .execute(&repository.pool) .await .unwrap(); } if with_schema_migrations && with_component_version { sqlx::query( "INSERT INTO schema_migrations(component, version) VALUES (?1, 1)", ) .bind(GLOSSARY_SCHEMA_COMPONENT) .execute(&repository.pool) .await .unwrap(); } repository.pool.close().await; } #[tokio::test] async fn sqlite_glossary_preserves_data_across_reopen_and_missing_row() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); let repository = SqliteGlossaryRepository::new(&path).await.unwrap(); repository .add(draft(GlossaryReviewStatus::Draft)) .await .unwrap(); repository .review( "term-sensei", GlossaryReviewStatus::Approved, "reviewer", Some("accepted".to_string()), ) .await .unwrap(); sqlx::query("DROP TABLE schema_migrations") .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let reopened = SqliteGlossaryRepository::open(&path).await.unwrap(); let term = reopened.find("term-sensei").await.unwrap(); assert_eq!(term.definition.recommended_translation, "老师"); assert_eq!(term.history.len(), 2); assert_eq!(term.history[1].action, "approved"); assert_eq!( reopened.summary().await.unwrap().schema_version, GLOSSARY_SCHEMA_VERSION ); } #[tokio::test] async fn sqlite_glossary_migrates_historical_v1_original_and_preserves_business_data() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); create_historical_glossary_v1_original(&path, true, true).await; let repository = SqliteGlossaryRepository::open(&path).await.unwrap(); let term = repository.find("historical-term").await.unwrap(); assert_eq!(term.definition.aliases, vec!["Teacher", "Master"]); assert_eq!(term.definition.recommended_translation, "老师"); assert_eq!( term.definition.allowed_translations, vec!["老师大人", "老师"] ); assert_eq!(term.definition.priority, 10); assert_eq!( term.definition.scope.get("destination"), Some(&"Bundles/story.bundle".to_string()) ); assert_eq!( term.source.source_ref.as_deref(), Some("historical-fixture") ); assert_eq!(term.source.observed_unix_seconds, 11); assert_eq!(term.created_unix_seconds, 12); assert_eq!(term.updated_unix_seconds, 13); assert_eq!(term.history.len(), 2); assert_eq!(term.history[1].history_id, "history-approved"); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); let deletion_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM glossary_term_deletions") .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(deletion_count, 0); assert_eq!(repository.summary().await.unwrap().schema_version, 2); } #[tokio::test] async fn sqlite_glossary_migrates_v1_original_without_component_row() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); create_historical_glossary_v1_original(&path, true, false).await; let repository = SqliteGlossaryRepository::open(&path).await.unwrap(); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); assert_eq!( repository .find("historical-term") .await .unwrap() .history .len(), 2 ); } #[tokio::test] async fn sqlite_glossary_migrates_v1_original_without_schema_migrations() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); create_historical_glossary_v1_original(&path, false, false).await; let repository = SqliteGlossaryRepository::open(&path).await.unwrap(); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); assert_eq!( repository .find("historical-term") .await .unwrap() .history .len(), 2 ); } #[tokio::test] async fn sqlite_glossary_migrates_historical_v1_deletion_drift_and_preserves_audit() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); create_historical_glossary_v1_deletion_drift(&path, true, true).await; let repository = SqliteGlossaryRepository::open(&path).await.unwrap(); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); let row = sqlx::query( "SELECT deletion_id, term_id, reviewer, reason, source_json, snapshot_json, history_json, observed_unix_seconds FROM glossary_term_deletions", ) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(row.get::("deletion_id"), "deletion-legacy-1"); assert_eq!(row.get::("term_id"), "historical-term"); assert_eq!(row.get::("reviewer"), "deleter"); assert_eq!(row.get::("reason"), "legacy cleanup"); assert_eq!( row.get::("source_json"), "{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}" ); assert_eq!( row.get::("snapshot_json"), "{\"source_term\":\"Sensei\",\"aliases\":[\"Teacher\",\"Master\"],\"recommended_translation\":\"老师\",\"allowed_translations\":[\"老师大人\",\"老师\"],\"source_language\":\"en\",\"target_language\":\"zh-Hans\",\"category\":\"person\",\"priority\":10,\"scope\":{\"destination\":\"Bundles/story.bundle\"}}" ); assert_eq!( row.get::("history_json"), "[{\"history_id\":\"history-approved\"}]" ); assert_eq!(row.get::("observed_unix_seconds"), 14); assert_eq!( repository .find("historical-term") .await .unwrap() .history .len(), 2 ); } #[tokio::test] async fn sqlite_glossary_migrates_deletion_drift_without_component_row_or_table() { for with_schema_migrations in [true, false] { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); create_historical_glossary_v1_deletion_drift(&path, with_schema_migrations, false) .await; let repository = SqliteGlossaryRepository::open(&path).await.unwrap(); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); assert_eq!( sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM glossary_term_deletions") .fetch_one(&repository.pool) .await .unwrap(), 1 ); } } #[tokio::test] async fn sqlite_glossary_future_schema_is_read_only_failure() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); let repository = SqliteGlossaryRepository::new(&path).await.unwrap(); sqlx::query("UPDATE schema_migrations SET version = ?2 WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .bind(i64::from(GLOSSARY_SCHEMA_VERSION) + 1) .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let before = std::fs::read(&path).unwrap(); let wal_path = std::path::PathBuf::from(format!("{}-wal", path.display())); let shm_path = std::path::PathBuf::from(format!("{}-shm", path.display())); let wal_before = std::fs::read(&wal_path).ok(); let shm_before = std::fs::read(&shm_path).ok(); let error = SqliteGlossaryRepository::new(&path).await.unwrap_err(); assert!(error.to_string().contains("不支持的 Glossary schema")); assert_eq!(std::fs::read(&path).unwrap(), before); assert_eq!(std::fs::read(&wal_path).ok(), wal_before); assert_eq!(std::fs::read(&shm_path).ok(), shm_before); } #[tokio::test] async fn sqlite_glossary_v2_malformed_schema_fails_closed() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); let repository = SqliteGlossaryRepository::new(&path).await.unwrap(); sqlx::query("DROP INDEX idx_glossary_status") .execute(&repository.pool) .await .unwrap(); sqlx::query("UPDATE schema_migrations SET version = 2 WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let before = std::fs::read(&path).unwrap(); let error = SqliteGlossaryRepository::open(&path).await.unwrap_err(); assert!(error .to_string() .contains("schema version 与实际结构不一致")); assert_eq!(std::fs::read(&path).unwrap(), before); } #[tokio::test] async fn sqlite_glossary_unknown_schema_fails_closed() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); let repository = raw_repository(&path, true).await; sqlx::query( "CREATE TABLE schema_migrations ( component TEXT PRIMARY KEY NOT NULL, version INTEGER NOT NULL CHECK(version >= 1) )", ) .execute(&repository.pool) .await .unwrap(); sqlx::query("INSERT INTO schema_migrations(component, version) VALUES (?1, 1)") .bind(GLOSSARY_SCHEMA_COMPONENT) .execute(&repository.pool) .await .unwrap(); sqlx::query("CREATE TABLE glossary_terms (term_id TEXT PRIMARY KEY)") .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let before = std::fs::read(&path).unwrap(); let error = SqliteGlossaryRepository::open(&path).await.unwrap_err(); assert!(error .to_string() .contains("schema version 与实际结构不一致")); assert_eq!(std::fs::read(&path).unwrap(), before); } #[tokio::test] async fn sqlite_glossary_failed_new_schema_rolls_back_and_retries() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); let repository = raw_repository(&path, true).await; assert!(repository.init_schema_with_test_failure(3).await.is_err()); let table_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'") .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(table_count, 0); repository.init_schema().await.unwrap(); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); } #[tokio::test] async fn sqlite_glossary_v1_original_migration_rolls_back_and_retries() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); create_historical_glossary_v1_original(&path, true, true).await; let repository = raw_repository(&path, false).await; assert!(repository.init_schema_with_test_failure(1).await.is_err()); assert_eq!( sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'glossary_term_deletions'" ) .fetch_one(&repository.pool) .await .unwrap(), 0 ); assert_eq!( sqlx::query_scalar::<_, i64>( "SELECT version FROM schema_migrations WHERE component = ?1" ) .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(), 1 ); assert_eq!( sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM glossary_terms") .fetch_one(&repository.pool) .await .unwrap(), 1 ); assert_eq!( sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM glossary_term_history") .fetch_one(&repository.pool) .await .unwrap(), 2 ); repository.init_schema().await.unwrap(); assert_eq!( sqlx::query_scalar::<_, i64>( "SELECT version FROM schema_migrations WHERE component = ?1" ) .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(), 2 ); } #[tokio::test] async fn sqlite_glossary_v2_reopen_is_idempotent() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); let repository = SqliteGlossaryRepository::new(&path).await.unwrap(); repository .add(draft(GlossaryReviewStatus::Draft)) .await .unwrap(); let before_objects: Vec<(String, String, Option)> = sqlx::query( "SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name", ) .fetch_all(&repository.pool) .await .unwrap() .into_iter() .map(|row| (row.get("type"), row.get("name"), row.get("sql"))) .collect(); repository.pool.close().await; let reopened = SqliteGlossaryRepository::open(&path).await.unwrap(); let after_objects: Vec<(String, String, Option)> = sqlx::query( "SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name", ) .fetch_all(&reopened.pool) .await .unwrap() .into_iter() .map(|row| (row.get("type"), row.get("name"), row.get("sql"))) .collect(); assert_eq!(before_objects, after_objects); assert_eq!( sqlx::query_scalar::<_, i64>( "SELECT version FROM schema_migrations WHERE component = ?1" ) .bind(GLOSSARY_SCHEMA_COMPONENT) .fetch_one(&reopened.pool) .await .unwrap(), 2 ); assert_eq!(reopened.find("term-sensei").await.unwrap().history.len(), 1); } #[tokio::test] async fn sqlite_glossary_v1_original_concurrent_migration_is_consistent() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(GLOSSARY_REPOSITORY_FILE); create_historical_glossary_v1_original(&path, true, true).await; let (left, right) = tokio::join!( SqliteGlossaryRepository::open(&path), SqliteGlossaryRepository::open(&path) ); assert!(left.is_ok(), "left open failed: {left:?}"); assert!(right.is_ok(), "right open failed: {right:?}"); let repository = left.unwrap(); drop(right); assert_eq!(repository.summary().await.unwrap().schema_version, 2); repository.pool.close().await; let reopened = SqliteGlossaryRepository::open(&path).await.unwrap(); assert_eq!(reopened.summary().await.unwrap().schema_version, 2); assert_eq!( reopened .find("historical-term") .await .unwrap() .history .len(), 2 ); } #[tokio::test] async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite")) .await .unwrap(); assert!(repository .add(draft(GlossaryReviewStatus::Approved)) .await .is_err()); repository .add(draft(GlossaryReviewStatus::Draft)) .await .unwrap(); let before = repository .diagnose("Sensei", &BTreeMap::new()) .await .unwrap(); assert!(before.constraints.is_empty()); repository .review( "term-sensei", GlossaryReviewStatus::Approved, "reviewer", Some("ok".to_string()), ) .await .unwrap(); let after = repository .diagnose("Sensei", &BTreeMap::new()) .await .unwrap(); assert_eq!(after.constraints.len(), 1); let term = repository.find("term-sensei").await.unwrap(); assert_eq!(term.history.len(), 2); assert_eq!(term.history[1].action, "approved"); let summary = repository.summary().await.unwrap(); assert_eq!(summary.approved_count, 1); let deleted = repository .delete("term-sensei", "reviewer", "remove duplicate") .await .unwrap(); assert_eq!(deleted.history.len(), 2); assert!(repository.find("term-sensei").await.is_err()); assert_eq!(repository.summary().await.unwrap().term_count, 0); let deletion_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM glossary_term_deletions") .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(deletion_count, 1); } }