From e486f1aaaa12c9e71167afd3b34b1467620e10f9 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Thu, 17 Sep 2026 21:25:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(glossary):=20=E4=BF=AE=E5=A4=8D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=20V1=20=E6=BC=82=E7=A7=BB=E5=B9=B6=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=20V2=20=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CURRENT_STATUS.md | 12 +- PROJECT_PLAN.md | 6 +- README.md | 4 +- TODO.md | 17 +- USERGUIDE.md | 2 +- docs/architecture/README.md | 4 +- ...4-rust-bat-go-bat-api-resource-boundary.md | 2 +- docs/guides/bat-workflows.md | 5 +- docs/reference/rpc-backend-api.md | 7 +- .../BAT_API_CONTRACT_FIXTURE_HANDOFF.md | 2 +- docs/reports/CURRENT_GAPS.md | 10 +- infrastructure/src/glossary.rs | 692 ++++++++++++++++-- internal/api/testdata/contract/README.md | 2 +- 13 files changed, 679 insertions(+), 86 deletions(-) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index d77e879..f9e07ba 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -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 位于 `/glossary.sqlite`,独立于 release task 和 TM; +项目级 Glossary V2 位于 `/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 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 9695e23..28080fe 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -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 兼容和官方资源长期运行报告。 --- diff --git a/README.md b/README.md index 0d1564a..815d93f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - 官方同步会维护 `/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/` 并切换 `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 协作后台。 详细状态见: diff --git a/TODO.md b/TODO.md index d6d81b1..65f93d9 100644 --- a/TODO.md +++ b/TODO.md @@ -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; diff --git a/USERGUIDE.md b/USERGUIDE.md index 5e45301..5da2b05 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -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` 独立持有,默认路径为 `/glossary.sqlite`,不位于 `versions/`,也不与 TM 或当前 release 的 task 库共用。配置覆盖方式为 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 2be0b48..0a1ef1d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 diff --git a/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md b/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md index 2d3a80e..815d32a 100644 --- a/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md +++ b/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md @@ -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 侧拥有第二份状态。 --- diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index 65729c3..6a070b2 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -213,8 +213,9 @@ bat i18n worker run \ `--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须 显式指定 `--interval`。 -Glossary V1 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于 -`/glossary.sqlite`;也可以用 `--glossary-path`、 +Glossary V2 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于 +`/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。冲突或不符合推荐/允许译法的结果会 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 7783c92..484ec02 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -180,8 +180,11 @@ count,任何一页不一致都会丢弃整个候选快照。 覆盖。 - `glossary.sqlite`:跨 release 的项目级 Glossary,不位于 `versions/`,也不与 `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` + 隐式修复。 默认路径为 `/glossary.sqlite`,可由 `BAT_GLOSSARY_PATH`、`[translation.worker].glossary_path` 或 CLI 覆盖。 diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md index a83e979..22afd58 100644 --- a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -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 或临时目录,但不能依赖开发机真实资源目录。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 9bb5314..3e2b063 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -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 diff --git a/infrastructure/src/glossary.rs b/infrastructure/src/glossary.rs index d553e2a..cef0a4e 100644 --- a/infrastructure/src/glossary.rs +++ b/infrastructure/src/glossary.rs @@ -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 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GlossarySchemaState { Empty, - Version(u32), + HistoricalV1Original, + HistoricalV1DeletionDrift, + V2, } fn classify_glossary_schema(snapshot: &SqliteSchemaSnapshot) -> Result { @@ -740,16 +802,22 @@ fn classify_glossary_schema(snapshot: &SqliteSchemaSnapshot) -> Result 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, @@ -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::("deletion_id"), "deletion-legacy-1"); + assert_eq!(row.get::("term_id"), "historical-term"); + assert_eq!(row.get::("reviewer"), "deleter"); + assert_eq!(row.get::("reason"), "legacy cleanup"); + assert_eq!( + row.get::("source_json"), + "{\"source_kind\":\"manual\",\"source_ref\":\"historical-fixture\",\"source_author\":\"reviewer\",\"source_note\":\"legacy\",\"observed_unix_seconds\":11}" + ); + assert_eq!( + row.get::("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::("history_json"), + "[{\"history_id\":\"history-approved\"}]" + ); + assert_eq!(row.get::("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)> = 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)> = 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 ); } diff --git a/internal/api/testdata/contract/README.md b/internal/api/testdata/contract/README.md index 25edc81..ca29207 100644 --- a/internal/api/testdata/contract/README.md +++ b/internal/api/testdata/contract/README.md @@ -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 或完整发布切换验证。