Files
BlueArchiveToolkit/infrastructure/src/glossary.rs
T

2015 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Path>) -> Result<Self> {
Self::open_with(path.as_ref(), true).await
}
/// Opens an existing glossary database without creating it.
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
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<Self> {
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<usize>) -> 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<usize>,
) -> 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<GlossaryTerm> {
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::<Result<Vec<_>>>()?;
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<GlossaryReviewStatus>,
limit: usize,
) -> Result<Vec<GlossaryTerm>> {
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<GlossaryReviewStatus>,
) -> Result<Vec<GlossaryTerm>> {
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<GlossarySummary> {
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::<i64, _>("term_count").map_err(db_error)? as u64,
approved_count: row.try_get::<i64, _>("approved_count").map_err(db_error)? as u64,
draft_count: row.try_get::<i64, _>("draft_count").map_err(db_error)? as u64,
deprecated_count: row
.try_get::<i64, _>("deprecated_count")
.map_err(db_error)? as u64,
rejected_count: row.try_get::<i64, _>("rejected_count").map_err(db_error)? as u64,
})
}
/// Adds a term and records its source snapshot.
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
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<String> =
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<String>,
) -> Result<GlossaryTerm> {
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<GlossaryTerm> {
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<String>,
) -> Result<GlossaryTerm> {
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<GlossaryEvaluation> {
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<GlossaryEvaluation> {
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<GlossaryTerm> {
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::<String, _>("review_status")
.map_err(db_error)?
.as_str(),
)?,
source: GlossarySourceRecord {
source_kind: parse_source_kind(
row.try_get::<String, _>("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<GlossaryHistoryRecord> {
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::<String, _>("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<GlossarySchemaState> {
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<usize>,
) -> 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<T: DeserializeOwned>(value: String) -> Result<T> {
serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string()))
}
fn json<T: serde::Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string()))
}
fn parse_review_status(value: &str) -> Result<GlossaryReviewStatus> {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary review status{value}")))
}
fn parse_source_kind(value: &str) -> Result<GlossarySourceKind> {
GlossarySourceKind::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind{value}")))
}
fn to_u64(value: i64, label: &str) -> Result<u64> {
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::<String, _>("deletion_id"), "deletion-legacy-1");
assert_eq!(row.get::<String, _>("term_id"), "historical-term");
assert_eq!(row.get::<String, _>("reviewer"), "deleter");
assert_eq!(row.get::<String, _>("reason"), "legacy cleanup");
assert_eq!(
row.get::<String, _>("source_json"),
"{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}"
);
assert_eq!(
row.get::<String, _>("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::<String, _>("history_json"),
"[{\"history_id\":\"history-approved\"}]"
);
assert_eq!(row.get::<i64, _>("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<String>)> = 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<String>)> = 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);
}
}