fix(glossary): 修复历史 V1 漂移并建立 V2 迁移
bat-rust / Build and test Rust (push) Waiting to run
bat-rust / Build and test Go API (push) Waiting to run

This commit is contained in:
2026-09-17 21:25:29 +08:00
parent 7f7d757f15
commit e486f1aaaa
13 changed files with 679 additions and 86 deletions
+639 -53
View File
@@ -1,4 +1,4 @@
//! Project-level Glossary V1 SQLite repository.
//! Project-level Glossary V2 SQLite repository.
use crate::path_security::{
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
@@ -22,7 +22,7 @@ use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Glossary SQLite schema version.
pub const GLOSSARY_SCHEMA_VERSION: u32 = 1;
pub const GLOSSARY_SCHEMA_VERSION: u32 = 2;
/// Schema migration component.
pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary";
/// Default project-level glossary file.
@@ -149,7 +149,72 @@ impl SqliteGlossaryRepository {
.await
.map_err(db_error)?;
}
GlossarySchemaState::Version(version) if version == GLOSSARY_SCHEMA_VERSION => {
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
@@ -168,18 +233,13 @@ impl SqliteGlossaryRepository {
.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)
|| !matches_glossary_v2_fingerprint(&final_snapshot)
{
return Err(invalid_glossary_schema(
"migration 结果与当前 schema fingerprint 不一致".to_string(),
@@ -721,7 +781,9 @@ fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord>
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GlossarySchemaState {
Empty,
Version(u32),
HistoricalV1Original,
HistoricalV1DeletionDrift,
V2,
}
fn classify_glossary_schema(snapshot: &SqliteSchemaSnapshot) -> Result<GlossarySchemaState> {
@@ -740,16 +802,22 @@ fn classify_glossary_schema(snapshot: &SqliteSchemaSnapshot) -> Result<GlossaryS
)));
}
}
if (matches_glossary_fingerprint(snapshot)
let is_v1_original = matches_glossary_v1_original_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));
}
&& 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}"
))),
@@ -763,7 +831,7 @@ fn invalid_glossary_schema(detail: String) -> Error {
Error::InvalidArgument(format!("Glossary schema 无效:{detail}"))
}
fn matches_glossary_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
fn matches_glossary_v2_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
let tables = [
sqlite_migration::schema_migrations_table(),
ExpectedTable {
@@ -779,22 +847,10 @@ fn matches_glossary_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
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)
matches_glossary_fingerprint_with_tables(snapshot, &tables, &GLOSSARY_INDEXES)
}
fn matches_glossary_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
fn matches_glossary_v2_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool {
let tables = [
ExpectedTable {
name: "glossary_terms",
@@ -809,19 +865,36 @@ fn matches_glossary_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bo
columns: &GLOSSARY_DELETION_COLUMNS,
},
];
let indexes = [
ExpectedIndex {
table: "glossary_terms",
name: "idx_glossary_status",
columns: &["review_status", "priority", "term_id"],
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,
},
ExpectedIndex {
table: "glossary_terms",
name: "idx_glossary_source_term",
columns: &["source_term"],
ExpectedTable {
name: "glossary_term_history",
columns: &GLOSSARY_HISTORY_COLUMNS,
},
];
matches_glossary_fingerprint_with_tables(snapshot, &tables, &indexes)
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(
@@ -832,6 +905,19 @@ fn matches_glossary_fingerprint_with_tables(
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",
@@ -1086,6 +1172,27 @@ const GLOSSARY_DELETION_COLUMNS: [ExpectedColumn<'static>; 8] = [
},
];
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>,
@@ -1234,6 +1341,185 @@ mod tests {
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();
@@ -1269,6 +1555,177 @@ mod tests {
);
}
#[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();
@@ -1293,6 +1750,29 @@ mod tests {
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();
@@ -1344,30 +1824,136 @@ mod tests {
.fetch_one(&repository.pool)
.await
.unwrap();
assert_eq!(version, 1);
assert_eq!(version, 2);
}
#[tokio::test]
async fn sqlite_glossary_concurrent_new_open_has_one_current_schema() {
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::new(&path),
SqliteGlossaryRepository::new(&path)
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,
GLOSSARY_SCHEMA_VERSION
);
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.summary().await.unwrap().schema_version,
GLOSSARY_SCHEMA_VERSION
reopened
.find("historical-term")
.await
.unwrap()
.history
.len(),
2
);
}