//! 跨 official release 的 Translation Memory SQLite 仓储。 use crate::path_security::{set_file_mode, STATE_FILE_MODE}; use crate::sqlite_migration::{ self, ExpectedColumn, ExpectedIndex, ExpectedTable, SqliteSchemaSnapshot, }; use async_trait::async_trait; use bat_core::domain::{ TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemoryMatchKind, TranslationMemorySourceKind, TranslationMemorySummary, TranslationMemoryTrustStatus, }; use bat_core::repositories::TranslationMemoryRepository; use bat_core::{Error, Result}; use serde::de::DeserializeOwned; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode}; use sqlx::{Row, SqlitePool}; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// TM SQLite schema 版本。 pub const TRANSLATION_MEMORY_SCHEMA_VERSION: u32 = 2; /// TM schema migration component。 pub const TRANSLATION_MEMORY_SCHEMA_COMPONENT: &str = "translation_memory"; /// 默认 TM 数据库文件名。 pub const TRANSLATION_MEMORY_REPOSITORY_FILE: &str = "translation-memory.sqlite"; /// SQLite-backed Translation Memory 仓储。 #[derive(Debug, Clone)] pub struct SqliteTranslationMemoryRepository { pub(crate) pool: SqlitePool, } impl SqliteTranslationMemoryRepository { /// 创建或打开 TM 数据库并执行迁移。 pub async fn new(path: impl AsRef) -> Result { Self::open_with(path.as_ref(), true).await } /// 只打开已有 TM 数据库,不创建新文件。 pub async fn open(path: impl AsRef) -> Result { Self::open_with(path.as_ref(), false).await } /// 根据 active release 根目录计算默认的跨 release TM 路径。 /// /// 正式 release 根目录形如 `/versions/`,因此默认结果为 /// `/translation-memory.sqlite`,不会写入已发布版本目录。 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(TRANSLATION_MEMORY_REPOSITORY_FILE); } } resource_root.join(TRANSLATION_MEMORY_REPOSITORY_FILE) } async fn open_with(path: &Path, create_if_missing: bool) -> Result { let absolute = bat_infrastructure_absolute(path)?; let parent = absolute.parent().ok_or_else(|| { Error::InvalidArgument(format!("TM 数据库缺少父目录:{}", absolute.display())) })?; ensure_safe_tm_parent(parent)?; if create_if_missing { tokio::fs::create_dir_all(parent).await?; ensure_safe_tm_parent(parent)?; } if let Ok(metadata) = fs::symlink_metadata(&absolute) { if metadata.file_type().is_symlink() { return Err(Error::InvalidArgument(format!( "TM 数据库不能是 symlink:{}", absolute.display() ))); } if !metadata.is_file() { return Err(Error::InvalidArgument(format!( "TM 数据库不是普通文件:{}", absolute.display() ))); } } else if !create_if_missing { return Err(Error::NotFound(absolute.display().to_string())); } if let Ok(metadata) = fs::symlink_metadata(&absolute) { if metadata.len() > 0 { let snapshot = sqlite_migration::read_only_preflight( &absolute, TRANSLATION_MEMORY_SCHEMA_COMPONENT, ) .await .map_err(db_error)?; classify_translation_memory_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)?; set_file_mode(&absolute, STATE_FILE_MODE, "Translation Memory 数据库") .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(), TRANSLATION_MEMORY_SCHEMA_COMPONENT, ) .await .map_err(db_error)?; match classify_translation_memory_schema(&snapshot)? { TranslationMemorySchemaState::Empty => { create_translation_memory_schema(transaction, fail_after_step).await?; sqlite_migration::write_component_version( transaction, TRANSLATION_MEMORY_SCHEMA_COMPONENT, TRANSLATION_MEMORY_SCHEMA_VERSION, ) .await .map_err(db_error)?; } TranslationMemorySchemaState::V1 => { let revalidated = sqlite_migration::snapshot_connection( transaction.as_mut(), TRANSLATION_MEMORY_SCHEMA_COMPONENT, ) .await .map_err(db_error)?; if !matches_translation_memory_v1_fingerprint(&revalidated) && !matches_translation_memory_v1_component_fingerprint(&revalidated) { return Err(invalid_translation_memory_schema( "V1 migration 起点在事务内发生变化".to_string(), )); } if !revalidated .tables .contains_key(sqlite_migration::SCHEMA_MIGRATIONS_TABLE) { sqlite_migration::create_schema_migrations_table(transaction) .await .map_err(db_error)?; } create_translation_memory_governance_table(transaction, fail_after_step).await?; let migrated = sqlite_migration::snapshot_connection( transaction.as_mut(), TRANSLATION_MEMORY_SCHEMA_COMPONENT, ) .await .map_err(db_error)?; if !matches_translation_memory_v2_fingerprint(&migrated) && !matches_translation_memory_v2_component_fingerprint(&migrated) { return Err(invalid_translation_memory_schema( "V1 migration 结果与 V2 fingerprint 不一致".to_string(), )); } sqlite_migration::write_component_version( transaction, TRANSLATION_MEMORY_SCHEMA_COMPONENT, TRANSLATION_MEMORY_SCHEMA_VERSION, ) .await .map_err(db_error)?; } TranslationMemorySchemaState::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, TRANSLATION_MEMORY_SCHEMA_COMPONENT, TRANSLATION_MEMORY_SCHEMA_VERSION, ) .await .map_err(db_error)?; } } } let final_snapshot = sqlite_migration::snapshot_connection( transaction.as_mut(), TRANSLATION_MEMORY_SCHEMA_COMPONENT, ) .await .map_err(db_error)?; if final_snapshot.component_version != Some(i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION)) || !matches_translation_memory_v2_fingerprint(&final_snapshot) { return Err(invalid_translation_memory_schema( "migration 结果与当前 schema fingerprint 不一致".to_string(), )); } Ok(()) } async fn find_optional(&self, record_id: &str) -> Result> { let row = sqlx::query( r#" SELECT record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id FROM translation_memory WHERE record_id = ?1 "#, ) .bind(record_id) .fetch_optional(&self.pool) .await .map_err(db_error)?; row.map(row_to_entry).transpose() } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TranslationMemorySchemaState { Empty, V1, V2, } fn classify_translation_memory_schema( snapshot: &SqliteSchemaSnapshot, ) -> Result { if snapshot.is_empty() { return Ok(TranslationMemorySchemaState::Empty); } if let Some(observed) = snapshot.component_version { if observed > i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION) { return Err(invalid_translation_memory_schema(format!( "不支持的 Translation Memory schema 版本:observed={observed}, supported={TRANSLATION_MEMORY_SCHEMA_VERSION}" ))); } if observed < 1 { return Err(invalid_translation_memory_schema(format!( "schema version 无效:observed={observed}, supported=1..={TRANSLATION_MEMORY_SCHEMA_VERSION}" ))); } } let v1 = matches_translation_memory_v1_fingerprint(snapshot) || (snapshot.component_version.is_none() && matches_translation_memory_v1_component_fingerprint(snapshot)); let v2 = matches_translation_memory_v2_fingerprint(snapshot) || (snapshot.component_version.is_none() && matches_translation_memory_v2_component_fingerprint(snapshot)); match snapshot.component_version { Some(1) if v1 => Ok(TranslationMemorySchemaState::V1), None if v1 => Ok(TranslationMemorySchemaState::V1), Some(2) if v2 => Ok(TranslationMemorySchemaState::V2), None if v2 => Ok(TranslationMemorySchemaState::V2), Some(observed) => Err(invalid_translation_memory_schema(format!( "schema version 与实际结构不一致:observed={observed}, supported={TRANSLATION_MEMORY_SCHEMA_VERSION}" ))), None => Err(invalid_translation_memory_schema( "未识别的 legacy schema,拒绝静默修复".to_string(), )), } } fn invalid_translation_memory_schema(detail: String) -> Error { Error::InvalidArgument(format!("Translation Memory schema 无效:{detail}")) } fn matches_translation_memory_v1_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { matches_translation_memory_fingerprint_with_migrations(snapshot, true, false) } fn matches_translation_memory_v1_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { matches_translation_memory_fingerprint_with_migrations(snapshot, false, false) } fn matches_translation_memory_v2_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { matches_translation_memory_fingerprint_with_migrations(snapshot, true, true) } fn matches_translation_memory_v2_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { matches_translation_memory_fingerprint_with_migrations(snapshot, false, true) } fn matches_translation_memory_fingerprint_with_migrations( snapshot: &SqliteSchemaSnapshot, include_migrations: bool, include_governance: bool, ) -> bool { let mut indexes = vec![ ExpectedIndex { table: "translation_memory", name: "idx_translation_memory_source_hash", columns: &["source_hash"], }, ExpectedIndex { table: "translation_memory", name: "idx_translation_memory_normalized_source", columns: &["normalized_source_text"], }, ExpectedIndex { table: "translation_memory", name: "idx_translation_memory_context", columns: &["source_hash", "source_context_hash"], }, ]; if include_governance { indexes.extend([ ExpectedIndex { table: "translation_memory_trust_events", name: "idx_translation_memory_trust_events_identity", columns: &["source_hash", "source_context_hash"], }, ExpectedIndex { table: "translation_memory_trust_events", name: "idx_translation_memory_trust_events_observed", columns: &["observed_unix_seconds"], }, ]); } let mut tables = vec![ExpectedTable { name: "translation_memory", columns: &TRANSLATION_MEMORY_COLUMNS, }]; if include_governance { tables.push(ExpectedTable { name: "translation_memory_trust_events", columns: &TRANSLATION_MEMORY_TRUST_EVENT_COLUMNS, }); } if include_migrations { tables.insert(0, sqlite_migration::schema_migrations_table()); } sqlite_migration::matches_fingerprint(snapshot, &tables, &indexes) } const TRANSLATION_MEMORY_COLUMNS: [ExpectedColumn<'static>; 20] = [ ExpectedColumn { name: "record_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: true, }, ExpectedColumn { name: "source_text", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_hash", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "normalized_source_text", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_context_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_context_hash", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "translated_text", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "translation_source_kind", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "trust_status", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "official_release_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_trace_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "provider", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "provider_run_id", data_type: "TEXT", not_null: false, 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, }, ExpectedColumn { name: "trusted_unix_seconds", data_type: "INTEGER", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "trusted_by", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "trusted_reason", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "supersedes_record_id", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "superseded_by_record_id", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ]; const TRANSLATION_MEMORY_TRUST_EVENT_COLUMNS: [ExpectedColumn<'static>; 11] = [ ExpectedColumn { name: "event_id", data_type: "TEXT", not_null: true, default_value: None, primary_key: true, }, ExpectedColumn { name: "action", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "winner_record_id", data_type: "TEXT", not_null: false, default_value: None, primary_key: false, }, ExpectedColumn { name: "affected_record_ids_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_text", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_hash", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_context_json", data_type: "TEXT", not_null: true, default_value: None, primary_key: false, }, ExpectedColumn { name: "source_context_hash", 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: false, 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_translation_memory_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 translation_memory ( record_id TEXT PRIMARY KEY NOT NULL, source_text TEXT NOT NULL, source_hash TEXT NOT NULL, normalized_source_text TEXT NOT NULL, source_context_json TEXT NOT NULL, source_context_hash TEXT NOT NULL, translated_text TEXT NOT NULL, translation_source_kind TEXT NOT NULL, trust_status TEXT NOT NULL, official_release_id TEXT NOT NULL, source_trace_json TEXT NOT NULL, provider TEXT, provider_run_id TEXT, created_unix_seconds INTEGER NOT NULL, updated_unix_seconds INTEGER NOT NULL, trusted_unix_seconds INTEGER, trusted_by TEXT, trusted_reason TEXT, supersedes_record_id TEXT, superseded_by_record_id TEXT, CHECK (length(source_text) > 0), CHECK (length(source_hash) > 0), CHECK (length(source_context_hash) > 0), CHECK (length(official_release_id) > 0), CHECK (translation_source_kind IN ('provider', 'manual', 'imported')), CHECK (trust_status IN ('candidate', 'trusted', 'superseded', 'rejected')) )", "CREATE INDEX idx_translation_memory_source_hash ON translation_memory(source_hash)", "CREATE INDEX idx_translation_memory_normalized_source ON translation_memory(normalized_source_text)", "CREATE INDEX idx_translation_memory_context ON translation_memory(source_hash, source_context_hash)", "CREATE TABLE translation_memory_trust_events ( event_id TEXT PRIMARY KEY NOT NULL, action TEXT NOT NULL, winner_record_id TEXT, affected_record_ids_json TEXT NOT NULL, source_text TEXT NOT NULL, source_hash TEXT NOT NULL, source_context_json TEXT NOT NULL, source_context_hash TEXT NOT NULL, reviewer TEXT NOT NULL, reason TEXT, observed_unix_seconds INTEGER NOT NULL, CHECK (action IN ('confirm', 'supersede', 'resolve_conflict')), CHECK (length(affected_record_ids_json) > 0), CHECK (length(source_text) > 0), CHECK (length(source_hash) > 0), CHECK (length(source_context_hash) > 0), CHECK (length(reviewer) > 0) )", "CREATE INDEX idx_translation_memory_trust_events_identity ON translation_memory_trust_events(source_hash, source_context_hash)", "CREATE INDEX idx_translation_memory_trust_events_observed ON translation_memory_trust_events(observed_unix_seconds)", ]; 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!( "Translation Memory migration failed after step {}", index + 1 ))); } } Ok(()) } async fn create_translation_memory_governance_table( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, fail_after_step: Option, ) -> Result<()> { let steps = [ "CREATE TABLE translation_memory_trust_events ( event_id TEXT PRIMARY KEY NOT NULL, action TEXT NOT NULL, winner_record_id TEXT, affected_record_ids_json TEXT NOT NULL, source_text TEXT NOT NULL, source_hash TEXT NOT NULL, source_context_json TEXT NOT NULL, source_context_hash TEXT NOT NULL, reviewer TEXT NOT NULL, reason TEXT, observed_unix_seconds INTEGER NOT NULL, CHECK (action IN ('confirm', 'supersede', 'resolve_conflict')), CHECK (length(affected_record_ids_json) > 0), CHECK (length(source_text) > 0), CHECK (length(source_hash) > 0), CHECK (length(source_context_hash) > 0), CHECK (length(reviewer) > 0) )", "CREATE INDEX idx_translation_memory_trust_events_identity ON translation_memory_trust_events(source_hash, source_context_hash)", "CREATE INDEX idx_translation_memory_trust_events_observed ON translation_memory_trust_events(observed_unix_seconds)", ]; 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!( "Translation Memory V1 migration failed after step {}", index + 1 ))); } } Ok(()) } #[async_trait] impl TranslationMemoryRepository for SqliteTranslationMemoryRepository { async fn upsert_candidate( &self, draft: TranslationMemoryDraft, ) -> Result { validate_draft(&draft)?; let entry = entry_from_draft(draft)?; if let Some(existing) = self.find_optional(&entry.record_id).await? { if existing.trust_status != TranslationMemoryTrustStatus::Candidate { return Ok(existing); } let source_trace_json = serde_json::to_string(&entry.source_trace) .map_err(|error| Error::Serialization(error.to_string()))?; sqlx::query( r#" UPDATE translation_memory SET source_trace_json = ?2, provider = ?3, provider_run_id = ?4, updated_unix_seconds = ?5 WHERE record_id = ?1 AND trust_status = 'candidate' "#, ) .bind(&entry.record_id) .bind(source_trace_json) .bind(&entry.provider) .bind(&entry.provider_run_id) .bind(i64::try_from(entry.updated_unix_seconds).unwrap_or(i64::MAX)) .execute(&self.pool) .await .map_err(db_error)?; return self.find(&entry.record_id).await; } let source_context_json = serde_json::to_string(&entry.source_context) .map_err(|error| Error::Serialization(error.to_string()))?; let source_trace_json = serde_json::to_string(&entry.source_trace) .map_err(|error| Error::Serialization(error.to_string()))?; let result = sqlx::query( r#" INSERT INTO translation_memory ( record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14, ?15, ?16, ?17, ?18, ?19) ON CONFLICT(record_id) DO UPDATE SET source_trace_json = excluded.source_trace_json, provider = excluded.provider, provider_run_id = excluded.provider_run_id, updated_unix_seconds = excluded.updated_unix_seconds WHERE translation_memory.trust_status = 'candidate' "#, ) .bind(&entry.record_id) .bind(&entry.source_text) .bind(&entry.source_hash) .bind(&entry.normalized_source_text) .bind(source_context_json) .bind(&entry.source_context_hash) .bind(&entry.translated_text) .bind(entry.translation_source_kind.as_str()) .bind(entry.trust_status.as_str()) .bind(&entry.official_release_id) .bind(source_trace_json) .bind(&entry.provider) .bind(&entry.provider_run_id) .bind(i64::try_from(entry.created_unix_seconds).unwrap_or(i64::MAX)) .bind( entry .trusted_unix_seconds .map(|value| i64::try_from(value).unwrap_or(i64::MAX)), ) .bind(&entry.trusted_by) .bind(&entry.trusted_reason) .bind(&entry.supersedes_record_id) .bind(&entry.superseded_by_record_id) .execute(&self.pool) .await .map_err(db_error)?; if result.rows_affected() == 0 { return self.find(&entry.record_id).await; } Ok(entry) } async fn find_matches( &self, source_text: &str, source_context: &TranslationMemoryContext, limit: usize, ) -> Result> { if source_text.is_empty() { return Err(Error::InvalidArgument( "Translation Memory 查询 source_text 不能为空".to_string(), )); } if limit == 0 { return Err(Error::InvalidArgument( "Translation Memory 查询 limit 必须大于 0".to_string(), )); } let source_hash = hash_text(source_text); let normalized_source_text = normalize_source_text(source_text); let rows = sqlx::query( r#" SELECT record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id FROM translation_memory WHERE source_hash = ?1 OR normalized_source_text = ?2 OR source_text = ?3 ORDER BY updated_unix_seconds DESC, record_id ASC "#, ) .bind(source_hash) .bind(&normalized_source_text) .bind(source_text) .fetch_all(&self.pool) .await .map_err(db_error)?; let matches = rows .into_iter() .map(row_to_entry) .collect::>>()? .into_iter(); let exact_trusted_count = matches .clone() .filter(|entry| { entry.source_text == source_text && entry.source_context == *source_context && is_current_trusted(entry) }) .count(); let mut matches = matches .filter_map(|entry| { let raw_exact = entry.source_text == source_text; let normalized_exact = entry.normalized_source_text == normalized_source_text; if !raw_exact && !normalized_exact { return None; } let same_context = raw_exact && entry.source_context == *source_context; let conflict = same_context && exact_trusted_count > 1; let strong = raw_exact && same_context && exact_trusted_count == 1 && is_current_trusted(&entry); let match_kind = if strong { TranslationMemoryMatchKind::StrongExact } else if conflict { TranslationMemoryMatchKind::TrustedConflict } else if raw_exact { TranslationMemoryMatchKind::CandidateExact } else { TranslationMemoryMatchKind::SourceOnly }; Some(TranslationMemoryMatch { can_auto_reuse: strong, entry, match_kind, }) }) .collect::>(); matches.sort_by(|left, right| { match_rank(left) .cmp(&match_rank(right)) .then_with(|| { right .entry .updated_unix_seconds .cmp(&left.entry.updated_unix_seconds) }) .then_with(|| left.entry.record_id.cmp(&right.entry.record_id)) }); matches.truncate(limit); Ok(matches) } async fn confirm( &self, record_id: &str, reviewer: &str, reason: Option, ) -> Result { self.confirm_with_supersede(record_id, reviewer, reason, None) .await } async fn confirm_with_supersede( &self, record_id: &str, reviewer: &str, reason: Option, supersede_record_id: Option<&str>, ) -> Result { if reviewer.trim().is_empty() { return Err(Error::InvalidArgument( "Translation Memory reviewer 不能为空".to_string(), )); } let reviewer = reviewer.trim(); let reason = reason .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let mut transaction = sqlite_migration::begin_immediate(&self.pool) .await .map_err(db_error)?; let result = self .confirm_in_transaction( &mut transaction, record_id, reviewer, reason.as_deref(), supersede_record_id, ) .await; match result { Ok(()) => { transaction.commit().await.map_err(db_error)?; self.find(record_id).await } Err(error) => { let _ = transaction.rollback().await; Err(error) } } } async fn list_conflicts(&self, limit: usize) -> Result> { if limit == 0 || limit > 1000 { return Err(Error::InvalidArgument( "Translation Memory conflict limit 必须在 1..=1000 范围内".to_string(), )); } let entries = self.load_all_entries().await?; Ok(conflict_groups(entries).into_iter().take(limit).collect()) } async fn resolve_conflict( &self, winner_record_id: &str, expected_trusted_record_ids: &[String], reviewer: &str, reason: &str, ) -> Result { if reviewer.trim().is_empty() || reason.trim().is_empty() { return Err(Error::InvalidArgument( "conflict resolution requires non-empty reviewer and reason".to_string(), )); } if expected_trusted_record_ids.is_empty() { return Err(Error::InvalidArgument( "conflict_snapshot_stale: expected_trusted_record_ids 不能为空".to_string(), )); } let mut expected = expected_trusted_record_ids.to_vec(); expected.sort(); expected.dedup(); if expected.len() != expected_trusted_record_ids.len() { return Err(Error::InvalidArgument( "conflict_snapshot_stale: expected_trusted_record_ids 必须唯一".to_string(), )); } let mut transaction = sqlite_migration::begin_immediate(&self.pool) .await .map_err(db_error)?; let result = self .resolve_conflict_in_transaction( &mut transaction, winner_record_id, &expected, reviewer.trim(), reason.trim(), ) .await; match result { Ok(()) => { transaction.commit().await.map_err(db_error)?; self.find(winner_record_id).await } Err(error) => { let _ = transaction.rollback().await; Err(error) } } } async fn find(&self, record_id: &str) -> Result { self.find_optional(record_id) .await? .ok_or_else(|| Error::NotFound(record_id.to_string())) } async fn summary(&self) -> Result { let entries = self.load_all_entries().await?; let record_count = entries.len() as u64; let trusted_count = entries .iter() .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Trusted) .count() as u64; let candidate_count = entries .iter() .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Candidate) .count() as u64; let superseded_count = entries .iter() .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Superseded) .count() as u64; let rejected_count = entries .iter() .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Rejected) .count() as u64; let groups = group_entries(entries); let trusted_conflict_group_count = groups .values() .filter(|entries| current_trusted_ids(entries).len() > 1) .count() as u64; let current_trusted_count = groups .values() .filter(|entries| current_trusted_ids(entries).len() == 1) .count() as u64; Ok(TranslationMemorySummary { schema_version: TRANSLATION_MEMORY_SCHEMA_VERSION, record_count, trusted_count, candidate_count, superseded_count, rejected_count, trusted_conflict_group_count, current_trusted_count, }) } } impl SqliteTranslationMemoryRepository { async fn load_all_entries(&self) -> Result> { let rows = sqlx::query(TRANSLATION_MEMORY_SELECT) .fetch_all(&self.pool) .await .map_err(db_error)?; rows.into_iter().map(row_to_entry).collect() } async fn confirm_in_transaction( &self, transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, record_id: &str, reviewer: &str, reason: Option<&str>, supersede_record_id: Option<&str>, ) -> Result<()> { let target = find_optional_in_transaction(transaction, record_id) .await? .ok_or_else(|| Error::NotFound(record_id.to_string()))?; let identity_entries = exact_identity_entries_in_transaction( transaction, &target.source_text, &target.source_context, ) .await?; let current_ids = current_trusted_ids(&identity_entries); if target.trust_status == TranslationMemoryTrustStatus::Trusted && target.superseded_by_record_id.is_none() { if current_ids.len() > 1 { return Err(trusted_conflict_error(¤t_ids)); } if current_ids == [record_id.to_string()] { return Ok(()); } } if target.trust_status != TranslationMemoryTrustStatus::Candidate { return Err(Error::InvalidArgument(format!( "Translation Memory 记录 {} 当前状态为 {},不能确认", record_id, target.trust_status.as_str() ))); } if current_ids.len() > 1 { return Err(trusted_conflict_error(¤t_ids)); } let now = unix_seconds_now(); if let Some(current_id) = current_ids.first() { if target.translated_text == identity_entries .iter() .find(|entry| entry.record_id == *current_id) .map(|entry| entry.translated_text.as_str()) .unwrap_or_default() { return Err(Error::InvalidArgument(format!( "trusted_translation_already_exists: current_trusted_record_id={current_id}" ))); } let Some(expected) = supersede_record_id else { return Err(Error::InvalidArgument(format!( "explicit_supersede_required: current_trusted_record_id={current_id}" ))); }; if expected != current_id { return Err(Error::InvalidArgument(format!( "stale_supersede_target: current_trusted_record_id={current_id}, expected={expected}" ))); } if reason.is_none_or(str::is_empty) { return Err(Error::InvalidArgument( "supersede requires non-empty reason".to_string(), )); } sqlx::query( "UPDATE translation_memory SET trust_status = 'superseded', superseded_by_record_id = ?2, updated_unix_seconds = ?3 WHERE record_id = ?1 AND trust_status = 'trusted'", ) .bind(current_id) .bind(record_id) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .execute(&mut **transaction) .await .map_err(db_error)?; sqlx::query( "UPDATE translation_memory SET trust_status = 'trusted', trusted_unix_seconds = ?2, trusted_by = ?3, trusted_reason = ?4, supersedes_record_id = ?5, updated_unix_seconds = ?2 WHERE record_id = ?1 AND trust_status = 'candidate'", ) .bind(record_id) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .bind(reviewer) .bind(reason) .bind(current_id) .execute(&mut **transaction) .await .map_err(db_error)?; insert_trust_event( transaction, "supersede", Some(record_id), &[current_id.clone(), record_id.to_string()], &target, reviewer, reason, now, ) .await?; } else { if supersede_record_id.is_some() { return Err(Error::InvalidArgument( "stale_supersede_target: 当前 exact identity 没有 current Trusted".to_string(), )); } sqlx::query( "UPDATE translation_memory SET trust_status = 'trusted', trusted_unix_seconds = ?2, trusted_by = ?3, trusted_reason = ?4, updated_unix_seconds = ?2 WHERE record_id = ?1 AND trust_status = 'candidate'", ) .bind(record_id) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .bind(reviewer) .bind(reason) .execute(&mut **transaction) .await .map_err(db_error)?; insert_trust_event( transaction, "confirm", Some(record_id), &[record_id.to_string()], &target, reviewer, reason, now, ) .await?; } let final_entries = exact_identity_entries_in_transaction( transaction, &target.source_text, &target.source_context, ) .await?; if current_trusted_ids(&final_entries) != [record_id.to_string()] { return Err(Error::InvalidArgument( "Translation Memory confirm 未建立唯一 current Trusted".to_string(), )); } Ok(()) } async fn resolve_conflict_in_transaction( &self, transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, winner_record_id: &str, expected_trusted_record_ids: &[String], reviewer: &str, reason: &str, ) -> Result<()> { let winner = find_optional_in_transaction(transaction, winner_record_id) .await? .ok_or_else(|| Error::NotFound(winner_record_id.to_string()))?; let identity_entries = exact_identity_entries_in_transaction( transaction, &winner.source_text, &winner.source_context, ) .await?; let current_ids = current_trusted_ids(&identity_entries); if current_ids.len() <= 1 || current_ids != expected_trusted_record_ids { return Err(Error::InvalidArgument(format!( "conflict_snapshot_stale: current_trusted_record_ids={}", current_ids.join(",") ))); } if winner.trust_status != TranslationMemoryTrustStatus::Candidate && !current_ids.contains(&winner.record_id) { return Err(Error::InvalidArgument(format!( "invalid_conflict_winner: record_id={winner_record_id}" ))); } let winner_is_candidate = winner.trust_status == TranslationMemoryTrustStatus::Candidate; let loser_ids = current_ids .iter() .filter(|record_id| record_id.as_str() != winner_record_id) .cloned() .collect::>(); if loser_ids.is_empty() && !winner_is_candidate { return Err(Error::InvalidArgument( "invalid_conflict_winner: winner 不属于 conflict set".to_string(), )); } let now = unix_seconds_now(); for loser_id in &loser_ids { sqlx::query( "UPDATE translation_memory SET trust_status = 'superseded', superseded_by_record_id = ?2, updated_unix_seconds = ?3 WHERE record_id = ?1 AND trust_status = 'trusted'", ) .bind(loser_id) .bind(winner_record_id) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .execute(&mut **transaction) .await .map_err(db_error)?; } if winner_is_candidate { let supersedes = (loser_ids.len() == 1).then(|| loser_ids[0].clone()); sqlx::query( "UPDATE translation_memory SET trust_status = 'trusted', trusted_unix_seconds = ?2, trusted_by = ?3, trusted_reason = ?4, supersedes_record_id = ?5, updated_unix_seconds = ?2 WHERE record_id = ?1 AND trust_status = 'candidate'", ) .bind(winner_record_id) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .bind(reviewer) .bind(reason) .bind(supersedes) .execute(&mut **transaction) .await .map_err(db_error)?; } else if loser_ids.len() == 1 { sqlx::query( "UPDATE translation_memory SET supersedes_record_id = ?, updated_unix_seconds = ? WHERE record_id = ?", ) .bind(&loser_ids[0]) .bind(i64::try_from(now).unwrap_or(i64::MAX)) .bind(winner_record_id) .execute(&mut **transaction) .await .map_err(db_error)?; } let mut affected = expected_trusted_record_ids.to_vec(); if winner_is_candidate { affected.push(winner_record_id.to_string()); affected.sort(); } insert_trust_event( transaction, "resolve_conflict", Some(winner_record_id), &affected, &winner, reviewer, Some(reason), now, ) .await?; let final_entries = exact_identity_entries_in_transaction( transaction, &winner.source_text, &winner.source_context, ) .await?; let final_ids = current_trusted_ids(&final_entries); if final_ids != [winner_record_id.to_string()] || expected_trusted_record_ids.iter().any(|record_id| { record_id != winner_record_id && final_entries.iter().any(|entry| { entry.record_id == *record_id && entry.trust_status != TranslationMemoryTrustStatus::Superseded }) }) { return Err(Error::InvalidArgument( "Translation Memory conflict resolution 未建立唯一 current Trusted".to_string(), )); } Ok(()) } } const TRANSLATION_MEMORY_SELECT: &str = r#" SELECT record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id FROM translation_memory "#; async fn find_optional_in_transaction( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, record_id: &str, ) -> Result> { let row = sqlx::query(&format!( "{TRANSLATION_MEMORY_SELECT}\nWHERE record_id = ?1" )) .bind(record_id) .fetch_optional(&mut **transaction) .await .map_err(db_error)?; row.map(row_to_entry).transpose() } async fn exact_identity_entries_in_transaction( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, source_text: &str, source_context: &TranslationMemoryContext, ) -> Result> { let rows = sqlx::query(&format!( "{TRANSLATION_MEMORY_SELECT}\nWHERE source_hash = ?1 OR source_text = ?2" )) .bind(hash_text(source_text)) .bind(source_text) .fetch_all(&mut **transaction) .await .map_err(db_error)?; rows.into_iter() .map(row_to_entry) .collect::>>() .map(|entries| { entries .into_iter() .filter(|entry| { entry.source_text == source_text && entry.source_context == *source_context }) .collect() }) } fn is_current_trusted(entry: &TranslationMemoryEntry) -> bool { entry.trust_status == TranslationMemoryTrustStatus::Trusted && entry.superseded_by_record_id.is_none() } fn current_trusted_ids(entries: &[TranslationMemoryEntry]) -> Vec { let mut ids = entries .iter() .filter(|entry| is_current_trusted(entry)) .map(|entry| entry.record_id.clone()) .collect::>(); ids.sort(); ids } fn group_key(entry: &TranslationMemoryEntry) -> Result<(String, String)> { let context = serde_json::to_string(&entry.source_context) .map_err(|error| Error::Serialization(error.to_string()))?; Ok((entry.source_text.clone(), context)) } fn group_entries( entries: Vec, ) -> std::collections::BTreeMap<(String, String), Vec> { let mut groups = std::collections::BTreeMap::new(); for entry in entries { if let Ok(key) = group_key(&entry) { groups.entry(key).or_insert_with(Vec::new).push(entry); } } groups } fn conflict_groups(entries: Vec) -> Vec { let mut conflicts = group_entries(entries) .into_values() .filter_map(|mut records| { let trusted_record_ids = current_trusted_ids(&records); if trusted_record_ids.len() <= 1 { return None; } records.sort_by(|left, right| left.record_id.cmp(&right.record_id)); let first = records.first()?; Some(TranslationMemoryConflict { source_text: first.source_text.clone(), source_hash: first.source_hash.clone(), source_context: first.source_context.clone(), source_context_hash: first.source_context_hash.clone(), trusted_record_ids, records, }) }) .collect::>(); conflicts.sort_by(|left, right| { left.source_text .cmp(&right.source_text) .then_with(|| left.source_context_hash.cmp(&right.source_context_hash)) }); conflicts } #[allow(clippy::too_many_arguments)] async fn insert_trust_event( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, action: &str, winner_record_id: Option<&str>, affected_record_ids: &[String], identity: &TranslationMemoryEntry, reviewer: &str, reason: Option<&str>, observed_unix_seconds: u64, ) -> Result<()> { let affected_record_ids_json = serde_json::to_string(affected_record_ids) .map_err(|error| Error::Serialization(error.to_string()))?; let source_context_json = serde_json::to_string(&identity.source_context) .map_err(|error| Error::Serialization(error.to_string()))?; let event_id = trust_event_id( action, winner_record_id, affected_record_ids, observed_unix_seconds, ); sqlx::query( "INSERT INTO translation_memory_trust_events ( event_id, action, winner_record_id, affected_record_ids_json, source_text, source_hash, source_context_json, source_context_hash, reviewer, reason, observed_unix_seconds ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", ) .bind(event_id) .bind(action) .bind(winner_record_id) .bind(affected_record_ids_json) .bind(&identity.source_text) .bind(&identity.source_hash) .bind(source_context_json) .bind(&identity.source_context_hash) .bind(reviewer) .bind(reason) .bind(i64::try_from(observed_unix_seconds).unwrap_or(i64::MAX)) .execute(&mut **transaction) .await .map_err(db_error)?; Ok(()) } fn trust_event_id( action: &str, winner_record_id: Option<&str>, affected_record_ids: &[String], observed_unix_seconds: u64, ) -> String { let mut value = format!( "{action}|{}|{observed_unix_seconds}|{}", winner_record_id.unwrap_or_default(), affected_record_ids.join(",") ); value.push('|'); value.push_str( &SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos() .to_string(), ); format!("tm-event-{}", blake3::hash(value.as_bytes()).to_hex()) } fn trusted_conflict_error(record_ids: &[String]) -> Error { Error::InvalidArgument(format!( "trusted_conflict_requires_resolution: trusted_record_ids={}", record_ids.join(",") )) } /// 由 active official release 根目录计算默认 TM 数据库路径。 pub fn translation_memory_repository_path(resource_root: &Path) -> PathBuf { SqliteTranslationMemoryRepository::repository_path(resource_root) } /// 从 TextUnit 定位字段构建 TM 上下文。 #[allow(clippy::too_many_arguments)] pub fn translation_memory_context( destination: &str, archive_entry: Option<&str>, serialized_file: Option<&str>, path_id: Option, class_id: Option, field_path: Option<&str>, format: Option<&str>, asset_name: Option<&str>, text_source_kind: Option<&str>, parser_context: &TranslationMemoryContext, ) -> TranslationMemoryContext { let mut context = TranslationMemoryContext::new(); context.insert("destination".to_string(), destination.to_string()); insert_optional(&mut context, "archive_entry", archive_entry); insert_optional(&mut context, "serialized_file", serialized_file); if let Some(value) = path_id { context.insert("path_id".to_string(), value.to_string()); } if let Some(value) = class_id { context.insert("class_id".to_string(), value.to_string()); } insert_optional(&mut context, "field_path", field_path); insert_optional(&mut context, "format", format); insert_optional(&mut context, "asset_name", asset_name); insert_optional(&mut context, "text_source_kind", text_source_kind); for (key, value) in parser_context { context.insert(format!("context.{key}"), value.clone()); } context } fn insert_optional(context: &mut TranslationMemoryContext, key: &str, value: Option<&str>) { if let Some(value) = value.filter(|value| !value.is_empty()) { context.insert(key.to_string(), value.to_string()); } } fn entry_from_draft(draft: TranslationMemoryDraft) -> Result { let source_hash = hash_text(&draft.source_text); let normalized_source_text = normalize_source_text(&draft.source_text); let source_context_hash = hash_context(&draft.source_context)?; let source_kind = draft.translation_source_kind; let record_id = record_id( &source_hash, &source_context_hash, &draft.translated_text, &draft.official_release_id, &source_kind, ); let observed = draft.observed_unix_seconds; Ok(TranslationMemoryEntry { record_id, source_text: draft.source_text, source_hash, normalized_source_text, source_context: draft.source_context, source_context_hash, translated_text: draft.translated_text, translation_source_kind: source_kind, trust_status: TranslationMemoryTrustStatus::Candidate, official_release_id: draft.official_release_id, source_trace: draft.source_trace, provider: draft.provider, provider_run_id: draft.provider_run_id, created_unix_seconds: observed, updated_unix_seconds: observed, trusted_unix_seconds: None, trusted_by: None, trusted_reason: None, supersedes_record_id: None, superseded_by_record_id: None, }) } fn validate_draft(draft: &TranslationMemoryDraft) -> Result<()> { if draft.source_text.is_empty() { return Err(Error::InvalidArgument( "Translation Memory source_text 不能为空".to_string(), )); } if draft.translated_text.trim().is_empty() { return Err(Error::InvalidArgument( "Translation Memory translated_text 不能为空".to_string(), )); } if draft.official_release_id.trim().is_empty() || draft.source_trace.official_release_id.trim().is_empty() { return Err(Error::InvalidArgument( "Translation Memory official_release_id 不能为空".to_string(), )); } if draft.official_release_id != draft.source_trace.official_release_id { return Err(Error::InvalidArgument( "Translation Memory draft 的 release provenance 不一致".to_string(), )); } Ok(()) } fn row_to_entry(row: sqlx::sqlite::SqliteRow) -> Result { let source_context = parse_json(row.try_get("source_context_json").map_err(db_error)?)?; let source_trace = parse_json(row.try_get("source_trace_json").map_err(db_error)?)?; Ok(TranslationMemoryEntry { record_id: row.try_get("record_id").map_err(db_error)?, source_text: row.try_get("source_text").map_err(db_error)?, source_hash: row.try_get("source_hash").map_err(db_error)?, normalized_source_text: row.try_get("normalized_source_text").map_err(db_error)?, source_context, source_context_hash: row.try_get("source_context_hash").map_err(db_error)?, translated_text: row.try_get("translated_text").map_err(db_error)?, translation_source_kind: parse_source_kind( row.try_get::("translation_source_kind") .map_err(db_error)? .as_str(), )?, trust_status: parse_trust_status( row.try_get::("trust_status") .map_err(db_error)? .as_str(), )?, official_release_id: row.try_get("official_release_id").map_err(db_error)?, source_trace, provider: row.try_get("provider").map_err(db_error)?, provider_run_id: row.try_get("provider_run_id").map_err(db_error)?, created_unix_seconds: i64_to_u64( row.try_get("created_unix_seconds").map_err(db_error)?, "created", )?, updated_unix_seconds: i64_to_u64( row.try_get("updated_unix_seconds").map_err(db_error)?, "updated", )?, trusted_unix_seconds: optional_i64_to_u64( row.try_get("trusted_unix_seconds").map_err(db_error)?, "trusted", )?, trusted_by: row.try_get("trusted_by").map_err(db_error)?, trusted_reason: row.try_get("trusted_reason").map_err(db_error)?, supersedes_record_id: row.try_get("supersedes_record_id").map_err(db_error)?, superseded_by_record_id: row.try_get("superseded_by_record_id").map_err(db_error)?, }) } fn parse_json(value: String) -> Result { serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string())) } fn parse_source_kind(value: &str) -> Result { match value { "provider" => Ok(TranslationMemorySourceKind::Provider), "manual" => Ok(TranslationMemorySourceKind::Manual), "imported" => Ok(TranslationMemorySourceKind::Imported), _ => Err(Error::Serialization(format!( "未知 Translation Memory source kind:{value}" ))), } } fn parse_trust_status(value: &str) -> Result { match value { "candidate" => Ok(TranslationMemoryTrustStatus::Candidate), "trusted" => Ok(TranslationMemoryTrustStatus::Trusted), "superseded" => Ok(TranslationMemoryTrustStatus::Superseded), "rejected" => Ok(TranslationMemoryTrustStatus::Rejected), _ => Err(Error::Serialization(format!( "未知 Translation Memory trust status:{value}" ))), } } fn match_rank(value: &TranslationMemoryMatch) -> u8 { match value.match_kind { TranslationMemoryMatchKind::StrongExact if value.can_auto_reuse => 0, TranslationMemoryMatchKind::TrustedConflict => 1, TranslationMemoryMatchKind::CandidateExact => 2, TranslationMemoryMatchKind::SourceOnly => 3, TranslationMemoryMatchKind::StrongExact => 2, } } fn hash_text(value: &str) -> String { blake3::hash(value.as_bytes()).to_hex().to_string() } fn normalize_source_text(value: &str) -> String { value.replace("\r\n", "\n").replace('\r', "\n") } fn hash_context(context: &TranslationMemoryContext) -> Result { let bytes = serde_json::to_vec(context).map_err(|error| Error::Serialization(error.to_string()))?; Ok(blake3::hash(&bytes).to_hex().to_string()) } fn record_id( source_hash: &str, context_hash: &str, translated_text: &str, official_release_id: &str, source_kind: &TranslationMemorySourceKind, ) -> String { let mut key = Vec::new(); for value in [ source_hash, context_hash, translated_text, official_release_id, source_kind.as_str(), ] { key.extend_from_slice(value.as_bytes()); key.push(0); } format!("tm-{}", blake3::hash(&key).to_hex()) } fn unix_seconds_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn i64_to_u64(value: i64, label: &str) -> Result { u64::try_from(value) .map_err(|_| Error::Serialization(format!("Translation Memory {label} 时间无效"))) } fn optional_i64_to_u64(value: Option, label: &str) -> Result> { value.map(|value| i64_to_u64(value, label)).transpose() } fn db_error(error: sqlx::Error) -> Error { Error::Other(error.into()) } fn bat_infrastructure_absolute(path: &Path) -> Result { crate::path_security::lexical_absolute(path).map_err(Error::InvalidArgument) } fn ensure_safe_tm_parent(parent: &Path) -> Result<()> { crate::path_security::ensure_safe_directory_path(parent, "Translation Memory 数据库") .map_err(Error::InvalidArgument) } #[cfg(test)] mod tests { use super::*; use bat_core::domain::TranslationMemorySourceTrace; use sqlx::sqlite::SqlitePoolOptions; fn draft(release: &str, source: &str, translated: &str) -> TranslationMemoryDraft { let source_trace = TranslationMemorySourceTrace { official_release_id: release.to_string(), unit_id: Some(format!("{release}-unit")), task_id: Some(format!("{release}-task")), destination: Some("Bundles/story.bundle".to_string()), archive_entry: None, serialized_file: Some("CAB-story".to_string()), path_id: Some(1), class_id: Some(49), field_path: Some("m_Text".to_string()), format: Some("plain".to_string()), asset_name: Some("Story".to_string()), text_source_kind: Some("text_asset".to_string()), source_url: Some("https://example.invalid/story".to_string()), }; TranslationMemoryDraft { source_text: source.to_string(), source_context: translation_memory_context( "Bundles/story.bundle", None, Some("CAB-story"), Some(1), Some(49), Some("m_Text"), Some("plain"), Some("Story"), Some("text_asset"), &TranslationMemoryContext::new(), ), translated_text: translated.to_string(), translation_source_kind: TranslationMemorySourceKind::Provider, official_release_id: release.to_string(), source_trace, provider: Some("mock".to_string()), provider_run_id: Some(format!("mock:{release}")), observed_unix_seconds: 1, } } async fn raw_repository( path: &std::path::Path, create_if_missing: bool, ) -> SqliteTranslationMemoryRepository { 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(); SqliteTranslationMemoryRepository { pool } } async fn write_v1_fixture( path: &std::path::Path, with_migrations: bool, records: Vec<(TranslationMemoryDraft, TranslationMemoryTrustStatus)>, ) -> Vec { let repository = raw_repository(path, true).await; if with_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("INSERT INTO schema_migrations(component, version) VALUES (?1, 1)") .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .execute(&repository.pool) .await .unwrap(); } sqlx::query( "CREATE TABLE translation_memory ( record_id TEXT PRIMARY KEY NOT NULL, source_text TEXT NOT NULL, source_hash TEXT NOT NULL, normalized_source_text TEXT NOT NULL, source_context_json TEXT NOT NULL, source_context_hash TEXT NOT NULL, translated_text TEXT NOT NULL, translation_source_kind TEXT NOT NULL, trust_status TEXT NOT NULL, official_release_id TEXT NOT NULL, source_trace_json TEXT NOT NULL, provider TEXT, provider_run_id TEXT, created_unix_seconds INTEGER NOT NULL, updated_unix_seconds INTEGER NOT NULL, trusted_unix_seconds INTEGER, trusted_by TEXT, trusted_reason TEXT, supersedes_record_id TEXT, superseded_by_record_id TEXT, CHECK (length(source_text) > 0), CHECK (length(source_hash) > 0), CHECK (length(source_context_hash) > 0), CHECK (length(official_release_id) > 0), CHECK (translation_source_kind IN ('provider', 'manual', 'imported')), CHECK (trust_status IN ('candidate', 'trusted', 'superseded', 'rejected')) )", ) .execute(&repository.pool) .await .unwrap(); for statement in [ "CREATE INDEX idx_translation_memory_source_hash ON translation_memory(source_hash)", "CREATE INDEX idx_translation_memory_normalized_source ON translation_memory(normalized_source_text)", "CREATE INDEX idx_translation_memory_context ON translation_memory(source_hash, source_context_hash)", ] { sqlx::query(statement) .execute(&repository.pool) .await .unwrap(); } let mut record_ids = Vec::new(); for (draft, status) in records { let entry = entry_from_draft(draft).unwrap(); let source_context_json = serde_json::to_string(&entry.source_context).unwrap(); let source_trace_json = serde_json::to_string(&entry.source_trace).unwrap(); let trusted = status == TranslationMemoryTrustStatus::Trusted; sqlx::query( "INSERT INTO translation_memory ( record_id, source_text, source_hash, normalized_source_text, source_context_json, source_context_hash, translated_text, translation_source_kind, trust_status, official_release_id, source_trace_json, provider, provider_run_id, created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?14, ?15, ?16, ?17, ?18, ?19)", ) .bind(&entry.record_id) .bind(&entry.source_text) .bind(&entry.source_hash) .bind(&entry.normalized_source_text) .bind(source_context_json) .bind(&entry.source_context_hash) .bind(&entry.translated_text) .bind(entry.translation_source_kind.as_str()) .bind(status.as_str()) .bind(&entry.official_release_id) .bind(source_trace_json) .bind(&entry.provider) .bind(&entry.provider_run_id) .bind(i64::try_from(entry.created_unix_seconds).unwrap()) .bind(trusted.then_some(100_i64)) .bind(trusted.then_some("legacy-reviewer")) .bind(trusted.then_some("legacy fixture")) .bind(&entry.supersedes_record_id) .bind(&entry.superseded_by_record_id) .execute(&repository.pool) .await .unwrap(); record_ids.push(entry.record_id); } repository.pool.close().await; record_ids } async fn force_trusted(repository: &SqliteTranslationMemoryRepository, record_id: &str) { sqlx::query( "UPDATE translation_memory SET trust_status = 'trusted', trusted_unix_seconds = 100, trusted_by = 'legacy-reviewer', trusted_reason = 'legacy fixture', superseded_by_record_id = NULL WHERE record_id = ?1", ) .bind(record_id) .execute(&repository.pool) .await .unwrap(); } async fn trust_event_count(repository: &SqliteTranslationMemoryRepository) -> i64 { sqlx::query_scalar("SELECT COUNT(*) FROM translation_memory_trust_events") .fetch_one(&repository.pool) .await .unwrap() } async fn current_trusted_count( repository: &SqliteTranslationMemoryRepository, source_text: &str, source_context: &TranslationMemoryContext, ) -> i64 { let source_hash = hash_text(source_text); let source_context_hash = hash_context(source_context).unwrap(); sqlx::query_scalar( "SELECT COUNT(*) FROM translation_memory WHERE source_hash = ?1 AND source_context_hash = ?2 AND source_text = ?3 AND source_context_json = ?4 AND trust_status = 'trusted' AND superseded_by_record_id IS NULL", ) .bind(source_hash) .bind(source_context_hash) .bind(source_text) .bind(serde_json::to_string(source_context).unwrap()) .fetch_one(&repository.pool) .await .unwrap() } #[tokio::test] async fn sqlite_translation_memory_preserves_data_across_reopen_and_missing_row() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(TRANSLATION_MEMORY_REPOSITORY_FILE); let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); let entry = repository .upsert_candidate(draft("release-1", "Hello", "你好")) .await .unwrap(); let trusted = repository .confirm(&entry.record_id, "reviewer", Some("accepted".to_string())) .await .unwrap(); sqlx::query("DROP TABLE schema_migrations") .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let reopened = SqliteTranslationMemoryRepository::open(&path) .await .unwrap(); let restored = reopened.find(&trusted.record_id).await.unwrap(); assert_eq!(restored.translated_text, "你好"); assert_eq!(restored.trust_status, TranslationMemoryTrustStatus::Trusted); assert_eq!(restored.source_trace.official_release_id, "release-1"); assert_eq!( reopened.summary().await.unwrap().schema_version, TRANSLATION_MEMORY_SCHEMA_VERSION ); } #[tokio::test] async fn sqlite_translation_memory_migrates_independent_v1_fixture_with_historical_conflict() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("legacy-v1.sqlite"); let first = draft("release-1", "Hello", "你好"); let context = first.source_context.clone(); let ids = write_v1_fixture( &path, true, vec![ (first, TranslationMemoryTrustStatus::Trusted), ( draft("release-2", "Hello", "您好"), TranslationMemoryTrustStatus::Trusted, ), ( draft("release-3", "Hello", "你好呀"), TranslationMemoryTrustStatus::Candidate, ), ], ) .await; let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); let summary = repository.summary().await.unwrap(); assert_eq!(summary.schema_version, 2); assert_eq!(summary.record_count, 3); assert_eq!(summary.trusted_count, 2); assert_eq!(summary.trusted_conflict_group_count, 1); assert_eq!(summary.current_trusted_count, 0); let matches = repository .find_matches("Hello", &context, 10) .await .unwrap(); assert_eq!(matches.len(), 3); assert!(matches.iter().all(|item| { item.match_kind == TranslationMemoryMatchKind::TrustedConflict && !item.can_auto_reuse })); assert_eq!( repository.find(&ids[0]).await.unwrap().translated_text, "你好" ); assert_eq!(trust_event_count(&repository).await, 0); } #[tokio::test] async fn sqlite_translation_memory_migrates_v1_without_schema_migrations_row() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("legacy-v1-no-migrations.sqlite"); write_v1_fixture( &path, false, vec![( draft("release-1", "Hello", "你好"), TranslationMemoryTrustStatus::Trusted, )], ) .await; let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); assert_eq!(repository.summary().await.unwrap().record_count, 1); } #[tokio::test] async fn sqlite_translation_memory_v1_migration_rolls_back_and_retries() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("legacy-v1-rollback.sqlite"); write_v1_fixture( &path, true, vec![( draft("release-1", "Hello", "你好"), TranslationMemoryTrustStatus::Candidate, )], ) .await; let repository = raw_repository(&path, false).await; assert!(repository.init_schema_with_test_failure(1).await.is_err()); let governance_tables: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'translation_memory_trust_events'", ) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(governance_tables, 0); let version: i64 = sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 1); repository.init_schema().await.unwrap(); assert_eq!(repository.summary().await.unwrap().schema_version, 2); } #[tokio::test] async fn sqlite_translation_memory_concurrent_v1_open_migrates_once() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("legacy-v1-concurrent.sqlite"); write_v1_fixture( &path, true, vec![( draft("release-1", "Hello", "你好"), TranslationMemoryTrustStatus::Candidate, )], ) .await; let (left, right) = tokio::join!( SqliteTranslationMemoryRepository::new(&path), SqliteTranslationMemoryRepository::new(&path) ); assert!(left.is_ok(), "left migration failed: {left:?}"); assert!(right.is_ok(), "right migration failed: {right:?}"); let repository = left.unwrap(); assert_eq!(repository.summary().await.unwrap().schema_version, 2); assert_eq!(trust_event_count(&repository).await, 0); } #[tokio::test] async fn sqlite_translation_memory_future_schema_is_read_only_failure() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(TRANSLATION_MEMORY_REPOSITORY_FILE); let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); sqlx::query("UPDATE schema_migrations SET version = ?2 WHERE component = ?1") .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .bind(i64::from(TRANSLATION_MEMORY_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 = SqliteTranslationMemoryRepository::new(&path) .await .unwrap_err(); assert!(error .to_string() .contains("不支持的 Translation Memory 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_translation_memory_unknown_schema_fails_closed() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(TRANSLATION_MEMORY_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(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .execute(&repository.pool) .await .unwrap(); sqlx::query("CREATE TABLE translation_memory (record_id TEXT PRIMARY KEY)") .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let before = std::fs::read(&path).unwrap(); let error = SqliteTranslationMemoryRepository::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_translation_memory_failed_new_schema_rolls_back_and_retries() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(TRANSLATION_MEMORY_REPOSITORY_FILE); let repository = raw_repository(&path, true).await; assert!(repository.init_schema_with_test_failure(2).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(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(version, 2); } #[tokio::test] async fn sqlite_translation_memory_concurrent_new_open_has_one_current_schema() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join(TRANSLATION_MEMORY_REPOSITORY_FILE); let (left, right) = tokio::join!( SqliteTranslationMemoryRepository::new(&path), SqliteTranslationMemoryRepository::new(&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, TRANSLATION_MEMORY_SCHEMA_VERSION ); repository.pool.close().await; let reopened = SqliteTranslationMemoryRepository::open(&path) .await .unwrap(); assert_eq!( reopened.summary().await.unwrap().schema_version, TRANSLATION_MEMORY_SCHEMA_VERSION ); } #[tokio::test] async fn initializes_schema_and_reuses_trusted_entry_across_releases() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new( temp.path().join(TRANSLATION_MEMORY_REPOSITORY_FILE), ) .await .unwrap(); let entry = repository .upsert_candidate(draft("release-1", "Hello", "你好")) .await .unwrap(); assert_eq!(repository.summary().await.unwrap().candidate_count, 1); let trusted = repository .confirm(&entry.record_id, "reviewer", Some("accepted".to_string())) .await .unwrap(); assert_eq!(trusted.trust_status, TranslationMemoryTrustStatus::Trusted); let query = draft("release-2", "Hello", "ignored"); let matches = repository .find_matches("Hello", &query.source_context, 10) .await .unwrap(); assert_eq!(matches.len(), 1); assert_eq!( matches[0].match_kind, TranslationMemoryMatchKind::StrongExact ); assert!(matches[0].can_auto_reuse); assert_eq!(matches[0].entry.translated_text, "你好"); assert_eq!(matches[0].entry.official_release_id, "release-1"); } #[tokio::test] async fn rejects_future_schema_version() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("tm.sqlite"); let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); sqlx::query("UPDATE schema_migrations SET version = ?2 WHERE component = ?1") .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) .bind(i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION) + 1) .execute(&repository.pool) .await .unwrap(); repository.pool.close().await; let error = SqliteTranslationMemoryRepository::new(&path) .await .unwrap_err(); assert!(error .to_string() .contains("不支持的 Translation Memory schema")); } #[tokio::test] async fn different_context_is_candidate_only_and_provider_repeat_is_idempotent() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let first = draft("release-1", "Hello", "你好"); repository.upsert_candidate(first.clone()).await.unwrap(); repository.upsert_candidate(first).await.unwrap(); assert_eq!(repository.summary().await.unwrap().record_count, 1); let mut different = draft("release-2", "Hello", "你好"); different .source_context .insert("field_path".to_string(), "m_Other".to_string()); let matches = repository .find_matches("Hello", &different.source_context, 10) .await .unwrap(); assert_eq!( matches[0].match_kind, TranslationMemoryMatchKind::CandidateExact ); assert!(!matches[0].can_auto_reuse); } #[tokio::test] async fn translation_memory_confirm_is_idempotent_and_requires_explicit_supersede() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let trusted_candidate = repository .upsert_candidate(draft("release-1", "Hello", "你好")) .await .unwrap(); let trusted = repository .confirm( &trusted_candidate.record_id, "reviewer-1", Some("accepted".to_string()), ) .await .unwrap(); assert_eq!(trust_event_count(&repository).await, 1); let idempotent = repository .confirm( &trusted.record_id, "reviewer-2", Some("duplicate review".to_string()), ) .await .unwrap(); assert_eq!(idempotent.trusted_by.as_deref(), Some("reviewer-1")); assert_eq!(trust_event_count(&repository).await, 1); let equivalent = repository .upsert_candidate(draft("release-2", "Hello", "你好")) .await .unwrap(); let duplicate_error = repository .confirm(&equivalent.record_id, "reviewer", Some("same".to_string())) .await .unwrap_err(); assert!(duplicate_error .to_string() .contains("trusted_translation_already_exists")); let replacement = repository .upsert_candidate(draft("release-3", "Hello", "您好")) .await .unwrap(); let required_error = repository .confirm( &replacement.record_id, "reviewer", Some("replace".to_string()), ) .await .unwrap_err(); assert!(required_error .to_string() .contains("explicit_supersede_required")); let stale_error = repository .confirm_with_supersede( &replacement.record_id, "reviewer", Some("replace".to_string()), Some("wrong-record"), ) .await .unwrap_err(); assert!(stale_error.to_string().contains("stale_supersede_target")); let superseded = repository .confirm_with_supersede( &replacement.record_id, "reviewer", Some("approved replacement".to_string()), Some(&trusted.record_id), ) .await .unwrap(); assert_eq!( superseded.supersedes_record_id.as_deref(), Some(trusted.record_id.as_str()) ); assert_eq!( repository .find(&trusted.record_id) .await .unwrap() .superseded_by_record_id .as_deref(), Some(replacement.record_id.as_str()) ); assert_eq!(trust_event_count(&repository).await, 2); let summary = repository.summary().await.unwrap(); assert_eq!(summary.trusted_conflict_group_count, 0); assert_eq!(summary.current_trusted_count, 1); } #[tokio::test] async fn translation_memory_historical_conflict_rejects_confirm_and_supports_existing_winner_resolution( ) { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let first = repository .upsert_candidate(draft("release-1", "Hello", "你好")) .await .unwrap(); let second = repository .upsert_candidate(draft("release-2", "Hello", "您好")) .await .unwrap(); let candidate = repository .upsert_candidate(draft("release-3", "Hello", "你好呀")) .await .unwrap(); force_trusted(&repository, &first.record_id).await; force_trusted(&repository, &second.record_id).await; let conflict_error = repository .confirm( &candidate.record_id, "reviewer", Some("resolve".to_string()), ) .await .unwrap_err(); assert!(conflict_error .to_string() .contains("trusted_conflict_requires_resolution")); let conflicts = repository.list_conflicts(10).await.unwrap(); assert_eq!(conflicts.len(), 1); assert_eq!(conflicts[0].trusted_record_ids.len(), 2); assert_eq!(conflicts[0].records.len(), 3); let winner = repository .resolve_conflict( &first.record_id, &[second.record_id.clone(), first.record_id.clone()], "resolver", "keep established translation", ) .await .unwrap(); assert_eq!(winner.record_id, first.record_id); assert_eq!( winner.supersedes_record_id.as_deref(), Some(second.record_id.as_str()) ); let loser = repository.find(&second.record_id).await.unwrap(); assert_eq!( loser.superseded_by_record_id.as_deref(), Some(first.record_id.as_str()) ); assert_eq!( repository .find(&candidate.record_id) .await .unwrap() .trust_status, TranslationMemoryTrustStatus::Candidate ); let stale = repository .resolve_conflict( &first.record_id, &[first.record_id.clone(), second.record_id.clone()], "resolver", "stale retry", ) .await .unwrap_err(); assert!(stale.to_string().contains("conflict_snapshot_stale")); assert_eq!(trust_event_count(&repository).await, 1); let event = sqlx::query( "SELECT action, affected_record_ids_json FROM translation_memory_trust_events", ) .fetch_one(&repository.pool) .await .unwrap(); assert_eq!(event.get::("action"), "resolve_conflict"); let affected: Vec = serde_json::from_str(&event.get::("affected_record_ids_json")).unwrap(); let mut expected_affected = vec![first.record_id.clone(), second.record_id.clone()]; expected_affected.sort(); assert_eq!(affected, expected_affected); } #[tokio::test] async fn translation_memory_conflict_resolution_with_candidate_winner_has_no_fake_chain() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let first = repository .upsert_candidate(draft("release-1", "Hello", "你好")) .await .unwrap(); let second = repository .upsert_candidate(draft("release-2", "Hello", "您好")) .await .unwrap(); let candidate = repository .upsert_candidate(draft("release-3", "Hello", "你好呀")) .await .unwrap(); force_trusted(&repository, &first.record_id).await; force_trusted(&repository, &second.record_id).await; let winner = repository .resolve_conflict( &candidate.record_id, &[first.record_id.clone(), second.record_id.clone()], "resolver", "select candidate", ) .await .unwrap(); assert_eq!(winner.trust_status, TranslationMemoryTrustStatus::Trusted); assert!(winner.supersedes_record_id.is_none()); for loser in [&first.record_id, &second.record_id] { assert_eq!( repository .find(loser) .await .unwrap() .superseded_by_record_id .as_deref(), Some(candidate.record_id.as_str()) ); } let event_ids: Vec = serde_json::from_str( &sqlx::query_scalar::<_, String>( "SELECT affected_record_ids_json FROM translation_memory_trust_events WHERE action = 'resolve_conflict'", ) .fetch_one(&repository.pool) .await .unwrap(), ) .unwrap(); let mut expected_event_ids = vec![ first.record_id.clone(), second.record_id.clone(), candidate.record_id.clone(), ]; expected_event_ids.sort(); assert_eq!(event_ids, expected_event_ids); assert_eq!(repository.summary().await.unwrap().current_trusted_count, 1); } #[tokio::test] async fn translation_memory_concurrent_confirms_and_supersedes_keep_one_current_trusted() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let first = repository .upsert_candidate(draft("release-1", "Hello", "你好")) .await .unwrap(); let second = repository .upsert_candidate(draft("release-2", "Hello", "您好")) .await .unwrap(); let (left, right) = tokio::join!( repository.confirm(&first.record_id, "reviewer-a", Some("a".to_string())), repository.confirm(&second.record_id, "reviewer-b", Some("b".to_string())) ); assert_eq!(left.is_ok() as u8 + right.is_ok() as u8, 1); let errors = [left.as_ref().err(), right.as_ref().err()]; assert!(errors.iter().flatten().any(|error| { error.to_string().contains("explicit_supersede_required") || error .to_string() .contains("trusted_translation_already_exists") })); let current_id = if left.is_ok() { first.record_id.clone() } else { second.record_id.clone() }; let third = repository .upsert_candidate(draft("release-3", "Hello", "你好呀")) .await .unwrap(); let fourth = repository .upsert_candidate(draft("release-4", "Hello", "你好喔")) .await .unwrap(); let (left, right) = tokio::join!( repository.confirm_with_supersede( &third.record_id, "reviewer-c", Some("replace".to_string()), Some(¤t_id), ), repository.confirm_with_supersede( &fourth.record_id, "reviewer-d", Some("replace".to_string()), Some(¤t_id), ) ); assert_eq!(left.is_ok() as u8 + right.is_ok() as u8, 1); assert!(left.is_ok() || right.is_ok()); assert!(left.as_ref().err().is_none_or(|error| { error.to_string().contains("stale_supersede_target") || error .to_string() .contains("trusted_translation_already_exists") })); assert!(right.as_ref().err().is_none_or(|error| { error.to_string().contains("stale_supersede_target") || error .to_string() .contains("trusted_translation_already_exists") })); let identity = draft("release-4", "Hello", "ignored"); assert_eq!( current_trusted_count(&repository, "Hello", &identity.source_context).await, 1 ); } #[tokio::test] async fn rejects_empty_translation_candidates() { let temp = tempfile::TempDir::new().unwrap(); let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) .await .unwrap(); let error = repository .upsert_candidate(draft("release-1", "Hello", " \n")) .await .unwrap_err(); assert!(error.to_string().contains("translated_text")); assert_eq!(repository.summary().await.unwrap().record_count, 0); } }