docs(backup): add v2 modular contributors design

Signed-off-by: George·Dong <GeorgeDong32@qq.com>
This commit is contained in:
George·Dong
2026-06-14 17:35:51 +08:00
parent 94b7641898
commit bb947aae4f

View File

@@ -0,0 +1,295 @@
# 模块化备份 Contributor 架构设计 — Final Review
> **TL;DR**: 把 backup 的集中式规则散落(`DomainRegistry`/`DomainStripper`/`DomainImporter`/`FileCollector`)拆为各业务域声明式的 `BackupContributor`,引入**聚合边界AggregateBoundary**让冲突策略按完整对象边界静态可校验地传播;恢复走整库 DB pre-snapshot`VACUUM INTO`+ 文件快照 + `withWriteTx` 分层执行(文件 IO 事务外、DB 导入事务内,符合 V2 `withWriteTx` 哲学)。
---
## 一、产品需求与模块边界(对照标尺)
> [!NOTE]
> **本节是后续架构(§二)的对照标尺**:每个机制都应能回溯到这里的某条 in-scope 需求;无法回溯的(如 id remap即 over-design应在评审阶段识别而非逐案争论。
### 主场景
- 换机迁移:把用户数据搬到新设备继续使用
- 防本地数据丢失:创建可恢复归档
- 本机回退/撤销:恢复后有限窗口内回到恢复前
### 数据模型前提
- 跨设备身份稳定:本库数据用 UUID 或自然键标识,换机不冲突、无需重新生成 ID
- (技术前提:全库主键为 uuid v4/v7 或自然键、零自增主键;故恢复保留源主键,不做 ID 重映射)
### 做什么in-scope
- 备份范围完整模式含全部内容精简模式含配置、API key、聊天记录、助手与 Agent 配置(不含图片、知识库、文件)
- 恢复规则:默认跳过本机已有的内容;只往本机补充新内容、不删本地已有数据;可选「两边都保留」或「以备份为准」
- 恢复安全:恢复前先保存当前状态,失败或反悔可回到恢复前
- 凭证:自用备份默认含模型服务 API key
### 不做什么non-goals
- 多设备实时同步、远程推送
- 分享 / 排障脱敏导出
- 智能语义合并MERGE
- 用户可见域级选择 UI架构层支持UI 不暴露)
- 自增或前缀主键的 ID 重排(本库不存在此类)
### 标尺用法(识别 over-design
评审任一机制时,先问"它服务上面哪条需求"。例「按完整对象跳过」服务主场景①②「恢复前快照」服务恢复安全与本机撤销主场景③、防丢失而「ID 重映射」找不到对应需求(数据前提已排除)→ 判为多余,移除。
### 阅读引导
读架构(§二)时对照两问:① 每个 contributor 声明能否被类型/codegen/覆盖测试校验,冲突有无聚合边界、恢复有无失败安全边界;② 产品决策(§三)是否符合用户心智。
---
## 二、架构设计
### 1. 这次重构要解决什么
当前分支的 SQLite 备份代码只作规则库存和实现参照,不是要保留的架构形态。分散在多个集中式文件,新增表/引用时需改多处且易导出/恢复语义不一致。
| 现有位置 | 承载内容 | 主要问题 |
|---|---|---|
| `DomainRegistry.ts` | 域到表映射、导入顺序、内部表排除 | 新增表易漏改(如 agent_task |
| `DomainStripper.ts` | 省略被引用域处理、凭证处理 | 引用处理与表归属分离 |
| `DomainImporter.ts` | 唯一键合并、JSON 引用重映射、冲突处理 | object-boundary SKIP 无机制 |
| `FileCollector.ts` | 消息文件引用扫描 | 文件引用来源缺统一分类 |
两个基线:用户可见产品行为基线 = legacy/v1 `BackupManager.ts`IndexedDB/LocalStorage/可选 Data架构设计基线 = 本方案最终 contributor 体系。
### 2. 总体方案Entity facts + Backup policy + Operations+ 聚合边界)
备份生成只操作备份副本并产出归档;备份文件是备份与恢复间唯一交接物;恢复消费默认按 manifest 域与资源执行。
```mermaid
flowchart LR
subgraph S1[备份生成]
A1[选预设 完整或精简] --> A2[ExportOrchestrator]
A2 --> A3[查 ContributorManager]
A3 --> A4[VACUUM INTO 复制为备份副本]
A4 --> A5[beforeArchive 脱敏与偏好过滤]
A5 --> A6[收集文件与知识库资源]
end
subgraph S2[备份归档]
B[manifest 加 backup.sqlite 加 files 加 knowledge]
end
subgraph S3[恢复消费]
C1[读 manifest 定范围] --> C2[建 RestoreRecoveryPoint]
C2 --> C3[聚合边界冲突策略]
C3 --> C4[导入 rows FK OFF 走 withWriteTx]
C4 --> C5[FTS 重建与一致性检查]
C5 --> C6[结果页与撤销入口]
end
A6 --> B
B --> C1
```
每个域由一个 `BackupContributor` 表示:
| 层次 | 放什么 | 不放什么 |
|---|---|---|
| Entity factsschema | 表归属、引用事实、主键形态、聚合边界、file-ref source、JSON 软引用 | SET_NULL/DELETE_ROW 动作、导入顺序、恢复策略 |
| Backup policy | 省略引用 override、唯一键合并 | 数据库 I/O、文件操作、异步 hookremap/idStrategies 已移除) |
| Operations | 文件资源发现、beforeArchive、JSON remap、逐行 transform、afterImport、blob 恢复、cloneAggregate | 可用纯数据表达的事实和策略 |
> [!IMPORTANT]
> **核心机制是 `schema.aggregates`(聚合边界)**,把 object-boundary SKIP/OVERWRITE/RENAME 从文字描述提升为静态可校验机制。
#### 代码架构图Contributor 系统如何落到代码
```mermaid
flowchart TB
D[Drizzle schemas] --> CG[codegen 生成 refs]
CG --> T[表 列 主键 JSON引用 清单]
T --> BC[BackupContributor schema policy operations]
BC --> CM[ContributorManager finalize 19 不变量]
CM --> BR[BackupRegistry]
BR --> EX[ExportOrchestrator]
BR --> IM[ImportOrchestrator]
IM --> RS[RestoreSafetyManager]
```
测试四类tsc + codegen check、coverage、equivalence、restore tests聚合冲突 + pre-snapshot 回滚)。
### 3. Contributor 应该怎么读
| 阅读顺序 | 要回答的问题 | 对应字段 |
|---|---|---|
| Ownership | 这个域拥有哪些用户数据表? | `schema.tables` |
| References | 引用了哪些其它域?哪些 file-ref / JSON 软引用属于本域? | `references``fileRefSourcePolicies``jsonSoftReferences` |
| Identity facts | 每张表的 ID 形态? | `primaryKeys`uuid-v4/uuid-v7/natural/composite |
| Aggregate | 用户可见对象的边界?冲突如何传播? | `aggregates`root/identityKey/members/renamable |
| Backup policy | 被引用域缺失的例外?哪些唯一键要合并? | `omittedReferenceOverrides``uniqueMergeRules` |
| Operations | 有无备份专用行为?没有是否明确 schema-only | `operations` |
内部排除项(`app_state` / `job` / `job_schedule` / `*_fts` / `__drizzle_migrations`)由全局显式排除集维护,带 reason不进 contributor。
### 4. TOPICS contributor 示例
聚合根 `topic` + 成员 `message(topicId)`;冲突 → 整组topic + 其 message 树)按策略处理。
### 5. 其它 contributor 参照
| 域类型 | 聚合边界注意点 |
|---|---|
| ASSISTANTS | RENAME 克隆时成员 assistantId 重映射到新根 PK |
| AGENTS | agent_task/agent_workspace/agent_channel 单表 renamable:falseagent_channel_task 双 FK 走 references optional |
| FILE_STORAGE | restoreResources() 先于 DB 行导入,返回 skippedFileEntryIdsrenamable:falseRENAME 退化为 SKIP |
| PROVIDERS | 聚合 user_provider + user_model(providerId)renamable:falseuser_model.id 派生键) |
### 6. 实现侧类型契约
`EntityGraphSchema``tables` / `references`kind: optional|owning/ `primaryKeys`kind: uuid-v4|uuid-v7|natural|compositeambiguous 标注)/ **`aggregates`**`AggregateBoundary { root, identityKey, members[{table, viaColumn, cascade}], renamable }`/ `fileRefSourcePolicies` / `jsonSoftReferences` / `rowScopes?`(本期不启用)。
`BackupContributorPolicy``omittedReferenceOverrides`(仅例外,须绑定事实+非冗余+reason`uniqueMergeRules`。**不含** restoreRemap / idStrategiesover-design移除
> [!WARNING]
> **类型入口**`DbTableName` / `DbColumnName` 必须来自 Drizzle codegen不能靠手写 as 认证。列名是 camelCase 实际 DB 列名(`topicId` / `providerId` / `fileEntryId`)。
#### Codegen 落地方案
`scripts/generate-backup-schema-refs.ts`tsx发现 `schemas/*.ts``sqliteTable`,经 `getTableConfig()` 读表名/列名/PK稳定排序输出 `dbSchemaRefs.ts``DB_TABLES``DB_COLUMNS_BY_TABLE``DbTableName``DbColumnName<TTable>``DB_PRIMARY_KEYS` 含 uuid-v4/v7 判定与 ambiguous 标注)。不连 DB、不启 Electron。`pnpm backup:refs:generate` 写盘,`pnpm backup:refs:check` byte-for-byte 比对CI 强制)。
```mermaid
flowchart LR
A[编辑 Drizzle schema] --> B[运行 backup refs generate]
B --> C[生成 dbSchemaRefs ts 表 列 主键]
C --> D[review schema 与 refs 两份 diff]
D --> E[backup refs check byte 比对]
E --> F[CI 强制 typecheck 与 registry test]
F --> G[运行时 finalize 再校验 兜底]
```
生成产物:`DB_TABLES``DB_COLUMNS_BY_TABLE`camelCase 实际列名)、`DbTableName``DbColumnName<TTable>``DB_PRIMARY_KEYS`(含 uuid-v4/v7 判定与 ambiguous 标注)。手写 as DbTableName 不算认证路径,须走 helper。
| 四层保护 | 失败时机 |
|---|---|
| TypeScript 拦截不存在的表/列 | 编译期 |
| backup:refs:check 防 schema 与 refs 脱节 | CI |
| registry test 覆盖新增表/列重命名/稳定输出 | 测试 |
| finalize 运行时用 DB_TABLES 再校验 | 启动期 |
#### JSON soft reference 覆盖机制
```mermaid
flowchart TB
subgraph 两类无 FK 软引用
F[file_ref sourceType 多态]
J[JSON blob 内 ID 软引用]
end
F --> FA[fileRefSourcePolicies 穷尽分类]
J --> JA[jsonSoftReferences 声明或排除]
FA --> X[新增未分类则 TS 或 finalize 或 coverage 失败]
JA --> X
```
| 已分类项 | 归属 |
|---|---|
| `chat_message` | TOPICS |
| `knowledge_item` | KNOWLEDGE |
| `painting` | PAINTINGS |
| `temp_session` | excludedruntime |
| `message.data`fileId | TOPICS jsonSoftReferences |
| `agent_session_message.data`fileId | AGENTS jsonSoftReferences |
### 7. 注册模型与启动校验
```mermaid
flowchart TB
C[各域 Contributor 静态导出] --> CM[ContributorManager 收集]
CM --> FN[finalize 启动期校验 19 不变量 不连 DB]
FN --> BR[BackupRegistry 规则视图]
FN --> X[失败则启动中断 报 domain table owner 不变量]
BR --> EX[ExportOrchestrator 查询]
BR --> IM[ImportOrchestrator 查询]
BS[BackupService WhenReady] -->|DependsOn| CM
CT[coverage test CI] -.DB 表覆盖兜底.-> FN
```
注册到消费链路:各域 Contributor 静态导出 → ContributorManager 收集 → finalize 启动期校验 19 不变量(不连 DB→ 通过则产出 BackupRegistry 供 orchestrator 查询,失败则启动中断并报 domain/table/owner/不变量。BackupServiceWhenReady@DependsOn(ContributorManager) 保证 finalize 先完成DB 实际表覆盖由 coverage testCI兜底故 finalize 不连 DB。
各 hook 调用时机与缺省collectFileResources导出前收集文件/缺省空集、beforeArchive剥离后仅改备份副本/no-op、remapJsonFields导入前 RENAME 重映射 fileId/原行不变、transformRow导入前/原行,返回 null 跳过该行、afterImport域导入后 FTS 重建/no-op、restoreResourcesDB 导入前事务外/无、cloneAggregate仅 renamable 聚合 RENAME/缺则 finalize 拒)。**聚合根被 SKIP 时其成员 transformRow 不调用**。
> [!TIP]
> **lifecycle**ContributorManager 与 BackupService 均 WhenReadyBackupService 须 `@DependsOn(ContributorManager)`finalize 只校验静态一致性、**不连 DB**DB 覆盖由 coverage test 保证,避免 WhenReady 服务违规依赖 DbService
### 8. 架构检查清单
| 检查点 | 证据 |
|---|---|
| 表归属 | §1 矩阵 + coverage test39 表全覆盖) |
| 聚合边界 | schema.aggregates + finalize #13-16 |
| 引用事实 | ReferenceKind 派生 + finalize #6/7 |
| JSON 软引用 | D19 + finalize #12 |
| 文件一致性 | restoreResources + 一致性检查 |
| 恢复安全 | RestoreRecoveryPointin-scope |
| 恢复语义 | 合并语义,不差集删除 |
### 9. 恢复前快照与撤销恢复(恢复编排层)
当前文件级回滚只覆盖 FILE_STORAGE 覆盖写入,不覆盖 DB 行导入中途失败,也不覆盖 API key / 偏好 / provider / assistant / agent / 聊天记录等 SQLite 数据。补恢复编排层 RestoreRecoveryPoint整库 DB pre-snapshot + restore journal + 受影响文件快照(同 restoreId。**执行分层严格分离**(符合 V2 withWriteTx「fn 内仅 DB ops、不做文件 IO」约束
1. 停写或持 DbService writeMutex 后用 VACUUM INTO 建快照(须事务外)
2. contributor restoreResources 文件 IO 在 withWriteTx 之外、之前
3. 仅 DB 行导入在 withWriteTx 内
4. 失败整库回滚是应用级动作libsql 持连接无法替换文件:停写→关 DbService 连接→用快照替换 live .sqlite→重连
本方案将其作为 in-scope 必交付项(现状 createSnapshot 已用 VACUUM INTO 建 pre-restore-snapshot但仅创建不使用失败仅 warn 继续、无回滚/撤销/journal/文件快照;本方案补齐持锁快照 + 回滚 + journal + 文件快照 + 失败阻塞)。**snapshot 创建失败 SHALL 阻塞恢复**(现状 warn 继续,属 breaking。**合并语义下首要价值是「撤销成功恢复」**用户回退其次才是失败回滚。contributor 不负责整库快照与回滚。
> [!IMPORTANT]
> 恢复写事务内 PRAGMA foreign_keys=OFFcascade/SET_NULL/DELETE_ROW 由 importer 按 contributor policy 显式执行(不依赖 SQLite ON DELETEFK OFF 使恢复完全依赖 contributor 声明,故 ReferenceKind 须忠实复刻 schema onDeletecascade→owning、set null→optional由 finalize 校验。DB 写走 DbService.withWriteTxfn 内仅 DB ops文件恢复已在事务外
---
## 三、产品决策(已定稿)
### 1. 已定稿决策
| 主题 | 当前方向 |
|---|---|
| UI 模式 | 只暴露「完整 / 精简」 |
| 精简模式范围 | 配置/设置域 + 聊天记录 + Agent 历史/配置PREFERENCES、PROVIDERS、PROMPTS、MCP_SERVERS、TAGS_GROUPS、ASSISTANTS、AGENTS、MINIAPPS、SKILLS、TOPICS |
| 精简模式排除 | KNOWLEDGE、TRANSLATE_HISTORY、PAINTINGS、FILE_STORAGE不导出/恢复 file_entry、file_ref、文件 blob、知识库源文件 |
| API key | 自用完整/精简备份默认含模型服务 API key / auth config结果页统一展示范围不单独强调不做分享/排障脱敏模式 |
| 恢复冲突默认 | 默认 SKIP聚合根边界减少重复项RENAME 显式保留两边OVERWRITE 显式以备份为准 |
| 恢复语义 | 合并语义:仅本地存在记录一律保留,不差集删除 |
| 结果页 | SKIP 后不展示跳过/未导入明细;缺失文件点击 Toast「无法加载文件」 |
### 2. 精简模式设计要点
命名采用「精简」现网已有该口径。tooltip 定稿「精简模式备份时跳过备份图片、知识库、文档、HTML 等数据文件,仅备份聊天记录、配置和 API key减少空间占用加快备份速度」。知识库先排除知识库负责人确认仅需 `{baseId}` 文件夹 + 两表,见第五章)。
### 3. API key 默认随备份走
自用备份默认含 API key符合换机后继续可用预期。企业后台下发 key 不属用户本地备份;不做分享模式;备份加密是独立增强。
### 4. 恢复默认策略
产品诉求"恢复后尽量不要大量重复项",默认做法是「跳过」——本机已有的内容不再重复导入。恢复只会往本机补充备份里的新内容,**不会删除本机已有但备份里没有的数据**(只增不删)。
> [!NOTE]
> 边界:跳过不是智能合并,只处理系统能识别的重复——按完整对象整体判断(一个话题连同它的所有消息、一次 Agent 会话连同它的消息、一套助手或模型服务配置),要么整体导入要么整体跳过,不会出现导入一半。识别「同名助手」这类语义重复需要后续单独做。
### 5. 关联内容缺失如何解释
| 场景 | 页面行为 |
|---|---|
| 精简备份不含附件/文件 | 恢复页不展示"恢复文件"选项tooltip 说明这份备份不含文件资源 |
| 某条内容引用的对象不存在 | 用户点击该引用时 Toast「无法加载文件」不打断恢复 |
| 结果页 | 仅确认完成与范围,不列跳过/未导入明细,诊断留日志 |
### 6. 实施期验证项(不阻塞架构定稿)
| 优先级 | 问题 | 建议输出 |
|---|---|---|
| P0 | 精简模式实际体积分布? | 模拟数据/本地样本统计完整 vs 精简 vs TOPICS 表体积 |
| P1 | 设置类数据默认「跳过」是否符合换机预期(用户换机通常希望用备份的设置)? | 确认设置类是否应默认「以备份为准」 |
| P1 | 选「两边都保留」时文件冲突会被静默跳过(用户无感知),是否需额外提示? | 权衡透明性 vs 减少打扰 |
---
## 四、实施前置约束
- 恢复默认 SKIP 跳过冲突备份内容、保留本地版本;冲突按用户可理解的最小完整对象(聚合根)判断。仅本地存在一律保留(合并语义)。
- 精简备份覆盖换机后最影响继续使用的内容:聊天、助手/Agent 配置、模型服务配置、常用设置不含附件、知识库、翻译历史、paintings。
- 用户自填模型服务密钥默认随自用备份恢复;企业统一下发 key 不属此备份;不做分享模式。
- 恢复前先自动保存当前状态(整库 DB 快照 + 受影响文件快照失败或用户撤销可回到恢复前RestoreRecoveryPoint 为 in-scope 必交付。
- 恢复写路径走 `DbService.withWriteTx` + FK OFF 显式 cascade。
- 实施前提:本方案基于分支目标态(`agent_task` 已合入 main`origin/main` 态任务定义在 `job_schedule`,需切 row-scope。当前分支落后 main`painting`/`agent_workspace`),需先同步。