fix(sqlite): 收口长期状态数据库版本化迁移契约
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-16 21:02:22 +08:00
parent 99355effe4
commit 7f7d757f15
10 changed files with 2487 additions and 421 deletions
+686 -140
View File
@@ -3,6 +3,9 @@
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,
@@ -12,7 +15,7 @@ use bat_core::domain::{
use bat_core::repositories::GlossaryRepository;
use bat_core::{Error, Result};
use serde::de::DeserializeOwned;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
use sqlx::{Row, SqlitePool};
use std::path::{Path, PathBuf};
use std::str::FromStr;
@@ -76,15 +79,20 @@ impl SqliteGlossaryRepository {
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 = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
let pool = sqlite_migration::connect_writable_pool(options)
.await
.map_err(db_error)?;
if create_if_missing {
@@ -97,119 +105,86 @@ impl SqliteGlossaryRepository {
}
async fn init_schema(&self) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_migrations (
component TEXT PRIMARY KEY NOT NULL,
version INTEGER NOT NULL CHECK(version >= 1)
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS 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(&self.pool)
.await
.map_err(db_error)?;
ensure_column(
&self.pool,
"glossary_terms",
"source_observed_unix_seconds",
"INTEGER NOT NULL DEFAULT 1",
)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS 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(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS 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(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_status
ON glossary_terms(review_status, priority DESC, term_id)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_source_term
ON glossary_terms(source_term)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
let current: Option<i64> =
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
.bind(GLOSSARY_SCHEMA_COMPONENT)
.fetch_optional(&self.pool)
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)?;
if current.is_some_and(|version| version > i64::from(GLOSSARY_SCHEMA_VERSION)) {
return Err(Error::InvalidArgument(format!(
"不支持的 Glossary schema 版本:{}",
current.unwrap_or_default()
)));
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::Version(version) if version == GLOSSARY_SCHEMA_VERSION => {
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)?;
}
}
GlossarySchemaState::Version(version) => {
return Err(invalid_glossary_schema(format!(
"内部不支持的迁移起点 {version}"
)));
}
}
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_fingerprint(&final_snapshot)
{
return Err(invalid_glossary_schema(
"migration 结果与当前 schema fingerprint 不一致".to_string(),
));
}
sqlx::query(
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
)
.bind(GLOSSARY_SCHEMA_COMPONENT)
.bind(i64::from(GLOSSARY_SCHEMA_VERSION))
.execute(&self.pool)
.await
.map_err(db_error)?;
Ok(())
}
@@ -743,6 +718,450 @@ fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord>
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GlossarySchemaState {
Empty,
Version(u32),
}
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}"
)));
}
}
if (matches_glossary_fingerprint(snapshot)
|| (snapshot.component_version.is_none()
&& matches_glossary_component_fingerprint(snapshot)))
&& snapshot
.component_version
.is_none_or(|observed| observed == i64::from(GLOSSARY_SCHEMA_VERSION))
{
return Ok(GlossarySchemaState::Version(GLOSSARY_SCHEMA_VERSION));
}
match snapshot.component_version {
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_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,
},
];
let indexes = [
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"],
},
];
matches_glossary_fingerprint_with_tables(snapshot, &tables, &indexes)
}
fn matches_glossary_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,
},
];
let indexes = [
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"],
},
];
matches_glossary_fingerprint_with_tables(snapshot, &tables, &indexes)
}
fn matches_glossary_fingerprint_with_tables(
snapshot: &SqliteSchemaSnapshot,
tables: &[ExpectedTable<'_>],
indexes: &[ExpectedIndex<'_>],
) -> bool {
sqlite_migration::matches_fingerprint(snapshot, tables, indexes)
}
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_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()))
}
@@ -769,35 +1188,10 @@ fn db_error(error: sqlx::Error) -> Error {
Error::Other(error.into())
}
async fn ensure_column(
pool: &SqlitePool,
table: &str,
column: &str,
definition: &str,
) -> Result<()> {
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(pool)
.await
.map_err(db_error)?;
let exists = columns.iter().any(|row| {
row.try_get::<String, _>("name")
.map(|name| name == column)
.unwrap_or(false)
});
if !exists {
sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {definition}"
))
.execute(pool)
.await
.map_err(db_error)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use std::collections::BTreeMap;
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
@@ -825,6 +1219,158 @@ mod tests {
}
}
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 }
}
#[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_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_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, 1);
}
#[tokio::test]
async fn sqlite_glossary_concurrent_new_open_has_one_current_schema() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(GLOSSARY_REPOSITORY_FILE);
let (left, right) = tokio::join!(
SqliteGlossaryRepository::new(&path),
SqliteGlossaryRepository::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,
GLOSSARY_SCHEMA_VERSION
);
repository.pool.close().await;
let reopened = SqliteGlossaryRepository::open(&path).await.unwrap();
assert_eq!(
reopened.summary().await.unwrap().schema_version,
GLOSSARY_SCHEMA_VERSION
);
}
#[tokio::test]
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
let temp = tempfile::TempDir::new().unwrap();