mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
fix(glossary): 修复历史 V1 漂移并建立 V2 迁移
This commit is contained in:
+7
-5
@@ -38,7 +38,7 @@ job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供
|
||||
Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作
|
||||
typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
|
||||
任务,最终资源报告按 pull plan 顺序输出。
|
||||
项目级 Glossary V1 位于 `<output>/glossary.sqlite`,独立于 release task 和 TM;
|
||||
项目级 Glossary V2 位于 `<output>/glossary.sqlite`,独立于 release task 和 TM;
|
||||
Rust `bat` 持有 term/alias/recommended/allowed/category/priority、全局或
|
||||
TextUnit scope、source history 和 approved review。worker、TM 复用、人工 task
|
||||
结果和 workbench publish 都执行确定性 QA;blocking deviation 必须携带
|
||||
@@ -48,10 +48,12 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
|
||||
|
||||
三个长期 SQLite owner 现在统一使用只读 schema preflight、精确 component
|
||||
fingerprint 和 `BEGIN IMMEDIATE` writer transaction:Translation Tasks 从 V1
|
||||
按显式 `v1 -> v2` step 迁移,当前版本为 V2;Translation Memory 和 Glossary
|
||||
当前版本均为 V1。future、未知或版本与结构不一致的数据库在任何 schema/data
|
||||
mutation 前 fail closed;migration 失败会 rollback,已知无
|
||||
`schema_migrations` 表的历史 fingerprint 可安全补建版本表后重试。正式 schema
|
||||
按显式 `v1 -> v2` step 迁移,当前版本为 V2;Translation Memory 当前版本为 V1,
|
||||
Glossary 当前版本为 V2。Glossary V2 正式吸收历史上未升版本的
|
||||
`glossary_term_deletions` schema drift:原始 V1-A 会在事务内创建 deletion audit
|
||||
表,带 deletion audit 的 V1-B 只提升 bookkeeping version。future、未知或版本与结构
|
||||
不一致的数据库在任何 schema/data mutation 前 fail closed;migration 失败会 rollback,
|
||||
已知无 `schema_migrations` 表的历史 fingerprint 可安全补建版本表后重试。正式 schema
|
||||
路径不再使用 `ensure_column` 隐式补列。
|
||||
|
||||
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
|
||||
|
||||
+3
-3
@@ -261,8 +261,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
交付物:
|
||||
|
||||
1. Translation Memory V1 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted 和 provenance。
|
||||
2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V1 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。
|
||||
3. 已实现显式 per-record confirm;Glossary V1 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
|
||||
2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V2 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。
|
||||
3. 已实现显式 per-record confirm;Glossary V2 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
|
||||
4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。
|
||||
|
||||
验收标准:
|
||||
@@ -462,7 +462,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
|
||||
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、generic manifest V1、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback、Rust-owned 双 release 查询/分发/cleanup V1,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V2、通用 Binary/JSON/Text Patch 基础、generic manifest V1、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback、Rust-owned 双 release 查询/分发/cleanup V1,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。
|
||||
- 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
|
||||
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory V1 和 Glossary V1;Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
|
||||
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory V1 和 Glossary V2;Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints、deletion audit 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
|
||||
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
|
||||
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
|
||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
||||
@@ -31,7 +31,7 @@
|
||||
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
|
||||
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
||||
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝。
|
||||
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
|
||||
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V2 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
|
||||
- SDK、完整 Web 协作后台。
|
||||
|
||||
详细状态见:
|
||||
|
||||
@@ -313,14 +313,13 @@ T03 → T16
|
||||
|
||||
## 完成记录
|
||||
|
||||
已为 Translation Tasks、Translation Memory 和 Glossary 收口统一的 SQLite
|
||||
migration contract:现有数据库先以只读方式读取 schema、component version 和结构
|
||||
fingerprint,future/unknown/mismatch 在任何写入前 fail closed;确认可迁移后在
|
||||
`BEGIN IMMEDIATE` 事务内执行显式版本步骤,重读并校验结构后写入版本并提交。Translation
|
||||
Tasks 按真实历史从 V1 迁移到 V2,Translation Memory 和 Glossary 保持真实的 V1
|
||||
contract,没有凭空引入版本;已覆盖缺失 schema bookkeeping、业务数据保留、失败回滚
|
||||
重试、并发打开和当前版本 reopen no-op。生产路径不再依赖 `ensure_column` 隐式升级,
|
||||
相关状态、架构、RPC 参考和缺口文档已同步。
|
||||
已将 Glossary 历史 schema drift 收口为 V2:精确识别真实历史 V1-A(无
|
||||
`glossary_term_deletions`)和 V1-B(已有 deletion audit 但 version 仍为 1),并在
|
||||
`BEGIN IMMEDIATE` 内分别执行创建 deletion audit 表或仅提升 bookkeeping version 的
|
||||
显式迁移。V1-A/V1-B 均兼容缺失 `schema_migrations` 或 component row,未知和 future
|
||||
schema 仍 fail closed;已验证业务 term/history、deletion audit 保留、失败回滚重试、
|
||||
V1-A 并发迁移和 V2 reopen no-op。当前 Translation Tasks 为 V2、Translation Memory
|
||||
为 V1、Glossary 为 V2。
|
||||
|
||||
---
|
||||
|
||||
@@ -875,7 +874,7 @@ Embedding/vector 不应默认进入第一阶段。
|
||||
|
||||
## 目标
|
||||
|
||||
在现有 Glossary V1 基础上逐步增加:
|
||||
在现有 Glossary V2 基础上逐步增加:
|
||||
|
||||
* bulk import/export;
|
||||
* richer search;
|
||||
|
||||
+1
-1
@@ -320,7 +320,7 @@ RPC `translation.memory.summary`、`translation.memory.query`、`translation.mem
|
||||
context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动
|
||||
建立 trusted 记录。
|
||||
|
||||
### Glossary V1
|
||||
### Glossary V2
|
||||
|
||||
Glossary 由 Rust `bat` 独立持有,默认路径为 `<output>/glossary.sqlite`,不位于
|
||||
`versions/<id>`,也不与 TM 或当前 release 的 task 库共用。配置覆盖方式为
|
||||
|
||||
@@ -196,7 +196,7 @@ pub struct ParserRegistry {
|
||||
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
|
||||
|
||||
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
|
||||
lease/retry、结果落库、项目级 Translation Memory V1 和独立 Glossary V1。TM 位于独立
|
||||
lease/retry、结果落库、项目级 Translation Memory V1 和独立 Glossary V2。TM 位于独立
|
||||
SQLite,按 raw source + 完整 context 做 trusted exact reuse,candidate 必须显式 confirm;
|
||||
Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊
|
||||
匹配和完整 Provider 体系仍属后续缺口。
|
||||
@@ -296,7 +296,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
|
||||
|
||||
### 7. Web 后台 (Vue 3,目标设计)
|
||||
|
||||
当前只有 `bat-api` 内嵌 dashboard MVP;Rust `bat` 的 Glossary V1 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
|
||||
当前只有 `bat-api` 内嵌 dashboard MVP;Rust `bat` 的 Glossary V2 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
|
||||
|
||||
**技术栈**:
|
||||
- Vue 3 + Composition API
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
|
||||
`internal/backendrpc` 消费。
|
||||
|
||||
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有 Glossary V1,Web 术语协作视图仍待建设**;
|
||||
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有 Glossary V2,Web 术语协作视图仍待建设**;
|
||||
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
|
||||
|
||||
---
|
||||
|
||||
@@ -213,8 +213,9 @@ bat i18n worker run \
|
||||
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
|
||||
显式指定 `--interval`。
|
||||
|
||||
Glossary V1 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于
|
||||
`<output>/glossary.sqlite`;也可以用 `--glossary-path`、
|
||||
Glossary V2 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于
|
||||
`<output>/glossary.sqlite`;V2 正式吸收历史上的 `glossary_term_deletions`
|
||||
schema drift;也可以用 `--glossary-path`、
|
||||
`BAT_GLOSSARY_PATH` 或 `[translation.worker].glossary_path` 指定。worker 只把
|
||||
`approved` term 转成 provider-neutral constraints,并在 TM 复用、provider 返回
|
||||
和人工工作台/任务回写时执行相同的确定性 QA。冲突或不符合推荐/允许译法的结果会
|
||||
|
||||
@@ -180,8 +180,11 @@ count,任何一页不一致都会丢弃整个候选快照。
|
||||
覆盖。
|
||||
- `glossary.sqlite`:跨 release 的项目级 Glossary,不位于 `versions/<id>`,也不与
|
||||
`translation-tasks.sqlite` 或 TM 共用;记录 term、alias、推荐/允许译法、scope、
|
||||
priority、review 状态、source provenance 和完整 source/review history;当前
|
||||
schema version 为 V1,正式 schema evolution 不再通过 `ensure_column` 隐式修复。
|
||||
priority、review 状态、source provenance、完整 source/review history 和 deletion
|
||||
audit;当前 schema version 为 V2。V2 正式吸收历史上未升版本的
|
||||
`glossary_term_deletions` drift,打开时通过 fingerprint 和 writer transaction
|
||||
区分 V1-A/V1-B 并 fail closed;正式 schema evolution 不再通过 `ensure_column`
|
||||
隐式修复。
|
||||
默认路径为
|
||||
`<output>/glossary.sqlite`,可由 `BAT_GLOSSARY_PATH`、`[translation.worker].glossary_path`
|
||||
或 CLI 覆盖。
|
||||
|
||||
@@ -74,7 +74,7 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON:
|
||||
2. `catalog.status` available=false 响应。
|
||||
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
|
||||
4. 对应 release 的 `official-sync-snapshot.json`。
|
||||
5. Rust Glossary V1 的 `translation.glossary.query` 响应,至少包含 alias、approved
|
||||
5. Rust Glossary V2 的 `translation.glossary.query` 响应,至少包含 alias、approved
|
||||
review、source provenance 和 created/approved history。
|
||||
|
||||
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
|
||||
|
||||
@@ -162,11 +162,13 @@ trusted 的 raw source + 完整 context exact match,并在复用前执行已
|
||||
确定性 QA。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go
|
||||
不持有 TM 状态。仍缺少模糊匹配和更丰富的导入导出历史能力。
|
||||
|
||||
### G-013:Glossary V1 已实现,协作视图仍缺失
|
||||
### G-013:Glossary V2 已实现,协作视图仍缺失
|
||||
|
||||
Rust `bat` 已提供独立项目级 `glossary.sqlite`,当前 schema version 为 V1;打开
|
||||
遵守只读 fingerprint preflight、writer transaction、rollback 和 future/unknown
|
||||
fail-closed 契约,不再使用隐式 `ensure_column` 修复结构。term/alias/recommended/
|
||||
Rust `bat` 已提供独立项目级 `glossary.sqlite`,当前 schema version 为 V2;V2 正式
|
||||
吸收历史上未升版本的 `glossary_term_deletions` drift,并将历史 V1-A(无 deletion
|
||||
audit)和 V1-B(已有 deletion audit)分别纳入显式迁移。打开遵守只读 fingerprint
|
||||
preflight、writer transaction、rollback 和 future/unknown fail-closed 契约,不再
|
||||
使用隐式 `ensure_column` 修复结构。term/alias/recommended/
|
||||
allowed/category/priority、全局与 TextUnit scope、source history、approved review、
|
||||
冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会
|
||||
先经过 Glossary QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking
|
||||
|
||||
+639
-53
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
|
||||
window 为 7260 秒(默认 3600 秒验证周期和 60 秒失败重试周期)。
|
||||
- 对应 release 的 `official-sync-snapshot.json`。
|
||||
- `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。
|
||||
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
|
||||
- Rust Glossary V2 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
|
||||
|
||||
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
|
||||
daemon socket 或完整发布切换验证。
|
||||
|
||||
Reference in New Issue
Block a user