From fdd4075e7ecbb039f2fb26820432ee5494a09ff6 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Tue, 1 Sep 2026 00:41:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(resource):=20=E5=A2=9E=E5=8A=A0=20CAS=20?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E4=B8=8E=E7=B4=A2=E5=BC=95=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 doctor cas 只读诊断、校验 CAS 对象分片布局,并将常用 ResourceRepository metadata 查询下推到 SQLite。 Refs G-011 --- CURRENT_STATUS.md | 4 +- PROJECT_PLAN.md | 4 +- README.md | 2 +- core/src/repositories/resource_repository.rs | 6 +- .../architecture/official-resource-backend.md | 8 +- docs/guides/baseline.md | 2 +- docs/guides/official-resource-test-pull.md | 2 +- docs/reference/rpc-backend-api.md | 3 + docs/reports/CURRENT_GAPS.md | 5 +- infrastructure/src/bin/bat/app.rs | 283 +++++++++++++++++- infrastructure/src/bin/bat/app_tests.rs | 74 +++++ infrastructure/src/resources.rs | 96 +++++- 12 files changed, 467 insertions(+), 22 deletions(-) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index fbf8383..6f995d3 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -27,7 +27,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 10. 默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`;官方资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;启用 `--auto-discover` 的 release 会包含 `official-launcher-bootstrap.json`,up-to-date 轮询会为旧 release 补写该产物;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 12. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 -13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `/.cas`、SQLite 索引为 `/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 +13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `/.cas`、SQLite 索引为 `/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 和 `BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。 15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。issue 46 已提供 `bat-api` 内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 @@ -146,7 +146,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker 待完成: -- 基于已接入的 `translation.worker.run` 继续推进翻译记忆、完整 Patch 构建/rollback;继续扩展 CAS 诊断查询面。 +- 基于已接入的 `translation.worker.run` 继续推进翻译记忆、完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit 查询和通用 Patch 发布资源视图。 - 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。 - 增加更多权限和极端文件系统场景测试。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 72c43e2..4296441 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -179,7 +179,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch`、`--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。 5. Go 入口边界:**已冻结**。同步命令行 = Rust `bat`(G-008 关闭);资源分发 = `bat-api` MVP(G-009 部分完成)。详见 `docs/reports/GO_STATUS.md`。 6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。 -7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 可查询离线 TextUnit 翻译任务状态、跳过/失败原因和 worker 结果。剩余工作是 CAS 诊断入口和面向大索引的查询优化。 +7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata,常用 metadata 过滤已下推到 SQLite;`bat doctor cas` 可只读诊断既有 CAS 根目录、对象目录、元数据库文件和对象统计;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 可查询离线 TextUnit 翻译任务状态、跳过/失败原因和 worker 结果。剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。 8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。 9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。 10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。 @@ -405,7 +405,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 3. G-009 / issue #19:`bat-api` 资源分发和同机 `bat.sock` live 联调已完成;拉取仍在 Rust `bat`。 4. 继续 Addressables 结构变体与 UnityFS 复杂对象能力(issue #3 / G-005 的后续阶段)。 5. 基于 `translation.worker.run` 继续推进翻译记忆和 Patch 构建。 -6. 继续扩展 G-011 剩余查询面:`doctor cas` 诊断入口和面向大索引的查询优化。 +6. 继续扩展 G-011 剩余查询面:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。 --- diff --git a/README.md b/README.md index c9f69ab..5e814b6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。 - Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(G-008 已关闭:同步 CLI = Rust `bat`)。 - 官方同步会维护 `/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、archive entry、parse status 和 TextUnit format 分页查询索引。 +- 资源导入链路可配置为在官方 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;`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 交接。 - `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 流程完成。 diff --git a/core/src/repositories/resource_repository.rs b/core/src/repositories/resource_repository.rs index 4630f95..e4d30c9 100644 --- a/core/src/repositories/resource_repository.rs +++ b/core/src/repositories/resource_repository.rs @@ -192,10 +192,10 @@ impl ResourceQuery { } } - /// 是否包含需要读取完整 `Resource` 后才能判断的条件。 + /// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。 /// - /// 基础索引可先用类型、hash、路径模式和 destination 缩小范围;这些条件 - /// 需要再按 metadata 过滤,确保 `list()` 与 `count()` 的语义一致。 + /// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整 + /// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。 pub fn requires_resource_scan(&self) -> bool { self.official_release_id.is_some() || self.platform.is_some() diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index a3b8c2c..add8bc4 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -182,8 +182,10 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat 管理 queued/running/failed/completed/skipped、provider run、lease、失败分类、 重试计划和 TextUnit 级译文结果。`translation.tasks` 优先查询这份状态库, `translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态; -没有状态库的旧 release 才回退到 immutable JSON 队列。G-011 剩余工作是 CAS -诊断入口和面向大索引的查询优化。 +没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas` +已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index` +已把 release、平台、bundle path 和常用数组 metadata 过滤下推到 SQLite。G-011 +剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。 对应实现主要在: @@ -287,7 +289,7 @@ Linux 生产路径: - `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`translation.tasks/handoff/task.update`、`localized.status`、`catalog.*`、`task.*`、文件级 `patch.apply` / `unityfs.patch_*`);`restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 - curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN - `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因 -- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引 +- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引,常用 metadata 过滤已下推到 SQLite,`bat doctor cas` 可只读诊断既有 CAS 目录和对象统计 - 官方 release 发布后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,为后续增量解析和 Crowdin worker 预留稳定输入 - 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch - `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为 diff --git a/docs/guides/baseline.md b/docs/guides/baseline.md index c0d4b90..a42a9ed 100644 --- a/docs/guides/baseline.md +++ b/docs/guides/baseline.md @@ -93,7 +93,7 @@ CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进: 1. 继续联调 Go `bat-api` 与 Rust daemon 的资源分发路径;Go 同步 CLI 不再作为产品目标。 2. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。 3. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建。 -4. 扩展 ResourceRepository 查询面:CAS 诊断入口、面向大索引的查询优化和更丰富 TextUnit 查询。 +4. 扩展 ResourceRepository 查询面:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。 5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和通用 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与受支持 localized patch 发布/rollback 链路已可用。 优先阅读: diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 811931f..384f080 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -215,7 +215,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - 非 dry-run 且启用 `--auto-discover` 时,成功发布的 release 会包含 `official-launcher-bootstrap.json`;up-to-date 轮询发现当前 release 缺少该文件时会补写。官方 launcher/server-info 已更新但 client-patch 资源尚未开放时,不切换 `current`,只在输出根写入 `official-launcher-bootstrap.pending.json` 作为维护期证据。 - 校验和发布完成后会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,写出 `/current/official-resource-changes.json` 和 `/current/crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 改变才算 modified;仅 URL/CDN 根变化但内容一致不会触发解析/翻译候选。新增+变更资源进入解析和 Crowdin 翻译 handoff,删除资源只进入差异记录;当前不会直接调用 Crowdin API。 - 随后会刷新 `/current/official-parse-cache.json`。解析缓存从 `official-download-manifest.json` 的全部条目出发,处理直接 UnityFS bundle 和 zip 内 UnityFS 条目;catalog、hash、媒体等非 UnityFS 文件记录为不支持,不视为同步失败。新 release 会刷新解析缓存;远端和本地都 up-to-date 且已有有效解析缓存时只读取摘要,不重复解析。 -- 需要将已校验官方 release 导入 CAS + SQLite ResourceRepository 时,使用 `--import-repository` 或 `.env` 中 `BAT_IMPORT_REPOSITORY=1`;默认 CAS 为 `/.cas`,默认索引为 `/resources.sqlite`,可用 `--import-cas-root` / `BAT_IMPORT_CAS_ROOT` 和 `--import-resource-db` / `BAT_IMPORT_RESOURCE_DB` 覆盖。`resource.index` RPC 可查询现有索引,索引不存在时返回 `available=false`,不会创建空库。 +- 需要将已校验官方 release 导入 CAS + SQLite ResourceRepository 时,使用 `--import-repository` 或 `.env` 中 `BAT_IMPORT_REPOSITORY=1`;默认 CAS 为 `/.cas`,默认索引为 `/resources.sqlite`,可用 `--import-cas-root` / `BAT_IMPORT_CAS_ROOT` 和 `--import-resource-db` / `BAT_IMPORT_RESOURCE_DB` 覆盖。`resource.index` RPC 可查询现有索引,索引不存在时返回 `available=false`,不会创建空库;`bat doctor cas --output ` 或 `bat doctor cas --import-cas-root ` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计。 - 官方同步报告中的 `localized_release_status=not_localized` 表示原版资源已发布、汉化资源未发布,这是当前官方同步阶段的正常完成状态;后续 Patch 发布完成后才应切换为 `localized`,表示原版和汉化两套资源都已发布。 资源同步状态文件默认分布如下: diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 5856825..8a0a9b0 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -441,6 +441,9 @@ CLI 对应关系: `bat resource-index` 支持 `--offset`、`--limit`、`--resource-type`、`--hash`、 `--path-pattern`、`--release-id`、`--platform`、`--destination`、 `--bundle-path`、`--archive-entry`、`--parse-status` 和 `--format`; +这些常用 metadata 过滤在 SQLite `ResourceRepository` 中下推执行。`bat doctor cas` +是本地只读 CLI 诊断入口,不对应 live RPC 方法;它读取 CLI 指定的 CAS 根目录和 +元数据库路径,报告缺失或对象文件异常,且不会创建空库。 `bat parse-text-units` / `bat parse-errors` 支持 `--offset`、`--limit`、 `--destination`、`--path-pattern`、`--archive-entry`、`--path-id`、 `--class-id`、`--field-path` 和 `--format`;`bat translation-tasks` 支持 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 077d39f..fe63a43 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -291,7 +291,8 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。 - `ResourceImportService` 已能把 manifest 中有数据的资源写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会分类索引。 - 官方同步下载结果可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后自动导入 CAS + ResourceRepository;默认 CAS 为 `/.cas`,默认 SQLite 索引为 `/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖。 -- `resource.index` RPC/CLI 已能按资源类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有 SQLite 索引;数据库不存在时返回 `available=false`,不会因查询创建空库。 +- `resource.index` RPC/CLI 已能按资源类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有 SQLite 索引;release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite;数据库不存在时返回 `available=false`,不会因查询创建空库。 +- `bat doctor cas` 可按 `/.cas` 或 `--import-cas-root` 只读诊断既有 CAS 根目录、对象目录、元数据库文件和对象统计,缺失路径会报告为不健康,不会创建空库。 - 官方 release 发布后会写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。 - `Resource` metadata 已通过 SQLite `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;`resource.index` 会返回这些 metadata。 - 官方 release 发布后会持久化 `official-textunit-index.json`,记录单条 TextUnit 和解析错误;`parse.text_units` / `parse.errors` RPC 和 `parse-text-units` / `parse-errors` CLI 可按 destination、archive entry、path id、class id、field path 和 format 分页过滤。 @@ -575,7 +576,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ 5. **G-009 / issue #19**:资源 bootstrap/分发和同机 live 联调已完成;非「从零实现」。后续真实官方网络长期运行、持久化和完整 launcher/业务链不属于本 issue 关闭条件。 6. issue #2 / G-007(P1):Addressables 可校验字段。 7. issue #3 / G-005(P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。 -8. G-011:翻译任务状态、CAS 诊断和 ResourceRepository 查询面扩展。 +8. G-011:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。 9. G-012 / G-006:Crowdin/翻译系统、复杂 AssetBundle 重打包和 Patch 发布流程统一。 10. G-011D:原版/汉化双发布后的查询、分发和清理策略。 diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 8221a75..42cac50 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -328,6 +328,10 @@ fn run() -> anyhow::Result { let healthy = run_doctor_command(&options)?; Ok(if healthy { 0 } else { EXIT_ERROR }) } + CliCommand::DoctorCas => { + let healthy = run_doctor_cas_command(&options)?; + Ok(if healthy { 0 } else { EXIT_ERROR }) + } CliCommand::CleanStable => { run_clean_stable_command(&options)?; Ok(0) @@ -608,6 +612,7 @@ enum CliCommand { UnityFsPatchStringField, UnityFsPatchField, Doctor, + DoctorCas, Logs, CleanStable, } @@ -5380,6 +5385,31 @@ impl HumanReport for DoctorReport { } } +impl HumanReport for DoctorCasReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("健康", format_bool(self.healthy)); + print_path_field("CAS 目录", &self.cas_root); + print_path_field("对象目录", &self.objects_dir); + print_path_field("元数据库", &self.metadata_db_path); + print_field("对象数", self.object_count); + print_field("总字节", self.total_size); + print_field("无效对象文件", self.invalid_object_count); + println!(" 检查:"); + for check in &self.checks { + println!( + " [{}] {} - {}", + if check.ok { "OK" } else { "FAIL" }, + check.name, + check.message + ); + } + Ok(()) + } +} + impl HumanReport for CleanStableReport { fn print_human(&self) -> anyhow::Result<()> { print_title(self.message); @@ -5593,6 +5623,28 @@ struct DoctorReport { checks: Vec, } +#[derive(Debug, Serialize)] +struct DoctorCasReport { + command: &'static str, + status: &'static str, + message: &'static str, + healthy: bool, + cas_root: PathBuf, + objects_dir: PathBuf, + metadata_db_path: PathBuf, + object_count: u64, + total_size: u64, + invalid_object_count: u64, + checks: Vec, +} + +#[derive(Debug, Default)] +struct CasObjectScanSummary { + object_count: u64, + total_size: u64, + invalid_object_count: u64, +} + fn build_doctor_report( state_dir: &Path, config: &OfficialUpdateConfig, @@ -5773,6 +5825,77 @@ fn build_doctor_report( }) } +fn build_doctor_cas_report(config: &OfficialUpdateConfig) -> anyhow::Result { + let cas_root = config + .import_cas_root + .clone() + .unwrap_or_else(|| config.output_root.join(".cas")); + let objects_dir = cas_root.join("objects"); + let metadata_db_path = cas_root.join("metadata.sqlite"); + let mut checks = vec![ + required_dir_check("cas_root", &cas_root, "CAS 根目录可用"), + required_dir_check("cas_objects_dir", &objects_dir, "CAS 对象目录可用"), + required_file_check("cas_metadata_db", &metadata_db_path, "CAS 元数据库文件可用"), + ]; + + let (object_count, total_size, invalid_object_count) = if objects_dir.is_dir() { + match scan_cas_object_directory(&objects_dir) { + Ok(summary) => { + let ok = summary.invalid_object_count == 0; + checks.push(DoctorCheck { + name: "cas_object_scan", + ok, + message: if ok { + format!( + "对象扫描完成:{} 个对象,{} 字节", + summary.object_count, summary.total_size + ) + } else { + format!( + "对象扫描发现 {} 个无效文件:{} 个对象,{} 字节", + summary.invalid_object_count, summary.object_count, summary.total_size + ) + }, + }); + ( + summary.object_count, + summary.total_size, + summary.invalid_object_count, + ) + } + Err(error) => { + checks.push(DoctorCheck { + name: "cas_object_scan", + ok: false, + message: format!("扫描 CAS 对象失败:{error}"), + }); + (0, 0, 0) + } + } + } else { + (0, 0, 0) + }; + + let healthy = checks.iter().all(|check| check.ok); + Ok(DoctorCasReport { + command: "doctor cas", + status: if healthy { "ok" } else { "issues_found" }, + message: if healthy { + "CAS 诊断通过" + } else { + "CAS 诊断发现问题" + }, + healthy, + cas_root, + objects_dir, + metadata_db_path, + object_count, + total_size, + invalid_object_count, + checks, + }) +} + fn run_doctor_command(options: &CliOptions) -> anyhow::Result { let report = build_doctor_report(&options.state_dir, &options.config)?; let healthy = report.healthy; @@ -5780,6 +5903,13 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { Ok(healthy) } +fn run_doctor_cas_command(options: &CliOptions) -> anyhow::Result { + let report = build_doctor_cas_report(&options.config)?; + let healthy = report.healthy; + print_report(options.output_format, &report)?; + Ok(healthy) +} + fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck { let (ok, message) = if path.is_dir() { (true, format!("{ready_message}:{}", path.display())) @@ -5802,6 +5932,116 @@ fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorChe DoctorCheck { name, ok, message } } +fn required_dir_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck { + if path.is_dir() { + DoctorCheck { + name, + ok: true, + message: format!("{ready_message}:{}", path.display()), + } + } else if path.exists() { + DoctorCheck { + name, + ok: false, + message: format!("路径存在但不是目录:{}", path.display()), + } + } else { + DoctorCheck { + name, + ok: false, + message: format!("目录不存在:{}", path.display()), + } + } +} + +fn required_file_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck { + if path.is_file() { + DoctorCheck { + name, + ok: true, + message: format!("{ready_message}:{}", path.display()), + } + } else if path.exists() { + DoctorCheck { + name, + ok: false, + message: format!("路径存在但不是文件:{}", path.display()), + } + } else { + DoctorCheck { + name, + ok: false, + message: format!("文件不存在:{}", path.display()), + } + } +} + +fn scan_cas_object_directory(objects_dir: &Path) -> anyhow::Result { + let mut summary = CasObjectScanSummary::default(); + scan_cas_object_directory_recursive(objects_dir, objects_dir, &mut summary)?; + Ok(summary) +} + +fn scan_cas_object_directory_recursive( + objects_dir: &Path, + path: &Path, + summary: &mut CasObjectScanSummary, +) -> anyhow::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + let file_type = entry.file_type()?; + let entry_path = entry.path(); + if file_type.is_dir() { + scan_cas_object_directory_recursive(objects_dir, &entry_path, summary)?; + continue; + } + if !file_type.is_file() { + continue; + } + + let metadata = entry.metadata()?; + let file_name = entry.file_name(); + let file_name = file_name.to_str().unwrap_or_default(); + if is_cas_object_path(objects_dir, &entry_path, file_name) { + summary.object_count += 1; + summary.total_size += metadata.len(); + } else { + summary.invalid_object_count += 1; + } + } + Ok(()) +} + +fn is_cas_object_path(objects_dir: &Path, object_path: &Path, file_name: &str) -> bool { + if !is_cas_object_file_name(file_name) { + return false; + } + + let relative_path = match object_path.strip_prefix(objects_dir) { + Ok(path) => path, + Err(_) => return false, + }; + let mut components = relative_path.components(); + let Some(std::path::Component::Normal(prefix1)) = components.next() else { + return false; + }; + let Some(std::path::Component::Normal(prefix2)) = components.next() else { + return false; + }; + let Some(std::path::Component::Normal(name)) = components.next() else { + return false; + }; + + components.next().is_none() + && prefix1.to_str() == Some(&file_name[0..2]) + && prefix2.to_str() == Some(&file_name[2..4]) + && name.to_str() == Some(file_name) +} + +fn is_cas_object_file_name(file_name: &str) -> bool { + file_name.len() == 64 && file_name.chars().all(|ch| ch.is_ascii_hexdigit()) +} + fn safety_check(name: &'static str, result: Result<(), String>, ok_message: &str) -> DoctorCheck { match result { Ok(()) => DoctorCheck { @@ -7101,7 +7341,7 @@ fn parse_args_with_env( raw_args: impl IntoIterator, env_lookup: impl Fn(&str) -> Option, ) -> anyhow::Result { - let mut args = raw_args.into_iter(); + let mut args = raw_args.into_iter().peekable(); let binary = args.next().unwrap_or_else(|| "bat".to_string()); let mut options = CliOptions::default(); // `BAT_*` 环境变量(含 .env 加载的)先作为默认值写入,不标记 explicit; @@ -7196,7 +7436,12 @@ fn parse_args_with_env( } "doctor" => { ensure_command_not_set(options.command, "doctor")?; - options.command = CliCommand::Doctor; + if args.peek().map(|value| value.as_str()) == Some("cas") { + args.next(); + options.command = CliCommand::DoctorCas; + } else { + options.command = CliCommand::Doctor; + } } "logs" => { ensure_command_not_set(options.command, "logs")?; @@ -7845,7 +8090,25 @@ fn parse_args_with_env( options.progress = false; options.banner = false; } - CliCommand::Doctor | CliCommand::CleanStable => { + CliCommand::Doctor => { + if options.sync_option_explicit { + return Err(anyhow::anyhow!( + "doctor 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数" + )); + } + options.progress = false; + options.banner = false; + } + CliCommand::DoctorCas => { + if doctor_cas_has_disallowed_sync_options(&options) { + return Err(anyhow::anyhow!( + "doctor cas 只接受 --output、--import-repository、--import-cas-root、--import-resource-db、--state-dir、--curl、--proxy 和 --unzip 等诊断参数" + )); + } + options.progress = false; + options.banner = false; + } + CliCommand::CleanStable => { if options.sync_option_explicit { return Err(anyhow::anyhow!( "doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数" @@ -8282,6 +8545,19 @@ fn parse_args_with_env( Ok(options) } +fn doctor_cas_has_disallowed_sync_options(options: &CliOptions) -> bool { + let mut allowed = options.env_baseline_config.clone(); + allowed.output_root = options.config.output_root.clone(); + allowed.curl_command = options.config.curl_command.clone(); + allowed.curl_proxy = options.config.curl_proxy.clone(); + allowed.unzip_command = options.config.unzip_command.clone(); + allowed.import_repository = options.config.import_repository; + allowed.import_cas_root = options.config.import_cas_root.clone(); + allowed.import_resource_repository_path = + options.config.import_resource_repository_path.clone(); + options.config != allowed +} + fn parse_resource_command( args: &mut impl Iterator, options: &mut CliOptions, @@ -8623,6 +8899,7 @@ fn print_usage(binary: &str) { eprintln!(" reload Ask daemon to rediscover metadata and force refresh"); eprintln!(" logs Show daemon log tail"); eprintln!(" doctor Run runtime diagnostics"); + eprintln!(" doctor cas Inspect local CAS storage"); eprintln!(" clean-stable Remove .part/.tmp/stale lock, pid, and socket files"); eprintln!(); eprintln!("Examples:"); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 3c6a7ef..9313f87 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -1440,6 +1440,22 @@ fn parses_management_and_resource_commands() { let options = parse(&["bat", "resource", "status"]).unwrap(); assert_eq!(options.command, CliCommand::ResourceIndex); + let doctor_cas = parse(&["bat", "doctor", "cas", "--output", "/tmp/bat-resources"]).unwrap(); + assert_eq!(doctor_cas.command, CliCommand::DoctorCas); + assert_eq!( + doctor_cas.config.output_root, + PathBuf::from("/tmp/bat-resources") + ); + assert!(parse(&[ + "bat", + "doctor", + "cas", + "--output", + "/tmp/bat-resources", + "--force" + ]) + .is_err()); + let index = parse(&[ "bat", "resource-index", @@ -1601,6 +1617,64 @@ fn parses_management_and_resource_commands() { .contains("--resource-type/--hash/--platform/--bundle-path")); } +#[test] +fn doctor_cas_report_counts_objects_read_only() { + let temp = tempfile::TempDir::new().unwrap(); + let output_root = temp.path().join("output"); + let cas_root = output_root.join(".cas"); + let objects_dir = cas_root.join("objects/01/23"); + fs::create_dir_all(&objects_dir).unwrap(); + let object_path = + objects_dir.join("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + fs::write(&object_path, b"payload").unwrap(); + fs::write(cas_root.join("metadata.sqlite"), b"metadata").unwrap(); + + let report = build_doctor_cas_report(&OfficialUpdateConfig { + output_root, + ..OfficialUpdateConfig::default() + }) + .unwrap(); + + assert_eq!(report.command, "doctor cas"); + assert!(report.healthy); + assert_eq!(report.object_count, 1); + assert_eq!(report.total_size, 7); + assert_eq!(report.invalid_object_count, 0); + assert!(report + .checks + .iter() + .any(|check| check.name == "cas_object_scan" && check.ok)); +} + +#[test] +fn doctor_cas_report_flags_invalid_object_layout() { + let temp = tempfile::TempDir::new().unwrap(); + let output_root = temp.path().join("output"); + let cas_root = output_root.join(".cas"); + let objects_dir = cas_root.join("objects/ab/cd"); + fs::create_dir_all(&objects_dir).unwrap(); + fs::write(cas_root.join("metadata.sqlite"), b"metadata").unwrap(); + fs::write( + objects_dir.join("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + b"payload", + ) + .unwrap(); + + let report = build_doctor_cas_report(&OfficialUpdateConfig { + output_root, + ..OfficialUpdateConfig::default() + }) + .unwrap(); + + assert!(!report.healthy); + assert_eq!(report.object_count, 0); + assert_eq!(report.invalid_object_count, 1); + assert!(report + .checks + .iter() + .any(|check| check.name == "cas_object_scan" && !check.ok)); +} + #[test] fn parses_write_patch_commands_without_output_confusion() { let patch = parse(&[ diff --git a/infrastructure/src/resources.rs b/infrastructure/src/resources.rs index b843b40..a405f38 100644 --- a/infrastructure/src/resources.rs +++ b/infrastructure/src/resources.rs @@ -186,6 +186,39 @@ impl SqliteResourceRepository { ) .await?; + Self::execute_query( + &self.pool, + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS idx_resources_release_id + ON resources(json_extract(metadata_json, '$.official_release_id')) + "#, + ), + ) + .await?; + + Self::execute_query( + &self.pool, + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS idx_resources_platform + ON resources(json_extract(metadata_json, '$.platform')) + "#, + ), + ) + .await?; + + Self::execute_query( + &self.pool, + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS idx_resources_bundle_path + ON resources(json_extract(metadata_json, '$.bundle_path')) + "#, + ), + ) + .await?; + Ok(()) } @@ -340,6 +373,51 @@ impl SqliteResourceRepository { builder.push_bind(destination); } + if let Some(release_id) = &query.official_release_id { + push_condition_prefix(builder, &mut has_where); + builder.push("json_extract(metadata_json, '$.official_release_id') = "); + builder.push_bind(release_id); + } + + if let Some(platform) = &query.platform { + push_condition_prefix(builder, &mut has_where); + builder.push("json_extract(metadata_json, '$.platform') = "); + builder.push_bind(platform); + } + + if let Some(bundle_path) = &query.bundle_path { + push_condition_prefix(builder, &mut has_where); + builder.push("json_extract(metadata_json, '$.bundle_path') = "); + builder.push_bind(bundle_path); + } + + if let Some(archive_entry) = &query.archive_entry { + push_condition_prefix(builder, &mut has_where); + builder.push( + "EXISTS (SELECT 1 FROM json_each(metadata_json, '$.archive_entries') AS archive_entries WHERE archive_entries.value = ", + ); + builder.push_bind(archive_entry); + builder.push(")"); + } + + if let Some(parse_status) = &query.parse_status { + push_condition_prefix(builder, &mut has_where); + builder.push( + "EXISTS (SELECT 1 FROM json_each(metadata_json, '$.parse_statuses') AS parse_statuses WHERE parse_statuses.value = ", + ); + builder.push_bind(parse_status); + builder.push(")"); + } + + if let Some(text_unit_format) = &query.text_unit_format { + push_condition_prefix(builder, &mut has_where); + builder.push( + "EXISTS (SELECT 1 FROM json_each(metadata_json, '$.text_unit_formats') AS text_unit_formats WHERE text_unit_formats.value = ", + ); + builder.push_bind(text_unit_format); + builder.push(")"); + } + Ok(()) } @@ -374,10 +452,6 @@ impl SqliteResourceRepository { } async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result { - if query.requires_resource_scan() { - return Ok(self.fetch_resources(query, None).await?.len() as u64); - } - let mut builder = QueryBuilder::::new("SELECT COUNT(*) FROM resources"); Self::apply_filters(&mut builder, query)?; @@ -801,6 +875,20 @@ mod tests { #[tokio::test] async fn sqlite_repository_persists_and_filters_resources() { let (_temp_dir, repository) = sqlite_repository().await; + let indexes = sqlx::query_scalar::<_, String>( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'resources'", + ) + .fetch_all(&repository.pool) + .await + .unwrap(); + assert!(indexes + .iter() + .any(|name| name == "idx_resources_release_id")); + assert!(indexes.iter().any(|name| name == "idx_resources_platform")); + assert!(indexes + .iter() + .any(|name| name == "idx_resources_bundle_path")); + let mut resource = resource( "resource/sqlite-a", "assets/model.bundle",