☰
MooTool Compose 版跨产品导入幂等设计:DIFF-154 迁移历史与工具草稿去重键的实现与演进
2026/10/15 14:49:39 网站建设 项目流程
  • 桌面应用
  • 开发工具

【免费下载链接】MooTool

Handy tool set for developers

项目地址:https://gitcode.com/gh_mirrors/mo/MooTool
点击查看免费下载

导读

MooTool 的下一代桌面客户端(next-compose,Kotlin Multiplatform / Compose Desktop)需要把 Java 版与 Electron 版旧产品的 SQLite 数据、Vault 目录、收藏与设置平滑迁入自有数据体系。整包指纹(fingerprint)只能挡住"同一来源目录完整再导",却挡不住"改源目录后 fingerprint 变化"或"仅重导历史"导致的 SQLite 行级重复。本文以设计文档 next-compose/docs/diff/154-legacy-import-dedupe-key.md(DIFF-154)为主线,完整拆解其"行级幂等键"方案——history.options.importDedupeKey字段、HistoryRepository.mergeImport的json_extract跳过逻辑,以及工具草稿的applied-tool-drafts.txt去重记录,并结合仓库源码给出可复现的 SQL 与代码证据,最后梳理它在 DIFF-155/156/157/158/161 中的后续演进。

一、问题背景:整包 fingerprint 为什么不够

跨产品导入(CrossProductImporter)的流程是"扫描(inspect)→ 预览(ImportPreview)→ 确认 → 备份 + 应用(apply)"。它首先计算整包指纹:CrossProductImporter.fingerprint()会把来源根路径、笔记/JSON 的相对路径、SQLite 行的id+title、收藏的name+pattern+group、历史的dedupeKey、Host 名与内容长度、翻译词条、Java 配置等全部喂入 SHA-256,产出一个 64 位十六进制摘要,写入imports/fingerprints.txt(见 AppContainer.kt)。

这套机制能解决"整包重复导入",但存在两个明确的盲区,正是 DIFF-154 的背景描述:

  1. 改源目录后 fingerprint 变化:用户在~/.MooTool的某条历史或草稿被修改(mtimeMs、内容字节变化都会改变 SHA-256 输入),或来源目录发生路径搬迁,整包指纹就不再匹配,apply会把全部数据块当作"新包"再次写入。
  2. 仅重导历史:用户只选择重新导入历史行(或仅执行history部分)时,整包指纹并没有命中,行级数据却已在目标库存在。

Electron legacy 的做法是"t_next_migration_run整包记录 +t_next_migration_row逐行记录";Compose 版不能直接依赖 Electron 的目标库(可能根本不存在),所以必须自有一套与来源数据行一一对应的稳定键,这就是 DIFF-154 引入的dedupeKey。

二、核心设计:三处幂等防线

DIFF-154 的"行为"一节规定了三条防线,分别针对历史行、工具草稿与后续回归:

1.LegacyImportOptions.withDedupeKey:把幂等键写进history.options

  • LegacyImportOptions.withDedupeKey(dedupeKey, options)在history.options的 JSON 对象中写入importDedupeKey字段,同时保留原有字段(如migratedFrom)。
  • 写入前会先用Json { ignoreUnknownKeys = true }解析既有options(解析失败按空对象处理),保证migratedFrom等元数据不被覆盖;dedupeKey为空字符串时直接返回原options,不做任何写入。

对应实现位于 LegacyImportOptions.kt:

object LegacyImportOptions { private val json = Json { ignoreUnknownKeys = true } const val DEDUPE_KEY_FIELD = "importDedupeKey" fun withDedupeKey(dedupeKey: String, options: String = ""): String { if (dedupeKey.isBlank()) return options val base = runCatching { json.parseToJsonElement(options).jsonObject }.getOrNull() ?: JsonObject(emptyMap()) return json.encodeToString( buildJsonObject { base.forEach { (key, value) -> put(key, value) } put(DEDUPE_KEY_FIELD, JsonPrimitive(dedupeKey)) } ) } fun readDedupeKey(options: String): String? { ... } }

dedupeKey本身是"来源表 + 来源行 id"的稳定组合,由CrossProductImporter在读取 SQLite 时按来源表固定生成。从 CrossProductImporter.kt 可以归纳出各来源表的键规则:

来源表dedupeKey 格式导入形态
t_func_historyt_func_history:<id>operation = "legacy"历史行
t_func_contentt_func_content:<id>operation = "draft"工具草稿
t_qr_codet_qr_code:<id>operation = "legacy"二维码历史
t_http_request_historyt_http_request_history:<id>HTTP 历史行(toolId = Http)
t_msg_httpt_msg_http:<id>HTTP 请求集合(ImportedHttpCollection)

这些键在apply阶段被显式写入历史行:LegacyHistoryImport(options = LegacyImportOptions.withDedupeKey(row.dedupeKey, row.options))(CrossProductImporter.kt#L423-L442)。

2.HistoryRepository.mergeImport:json_extract优先跳过

mergeImport是历史行幂等的关键入口。它以三个递进条件决定某一行是否需要写入(Database.kt#L248-L310):

-- 条件 A:逐行来源登记(DIFF-155 的 import_migration_row 表) SELECT 1 FROM import_migration_row WHERE source_path = ? AND source_table = ? AND source_id = ? LIMIT 1 -- 条件 B:优先用 options 中的 importDedupeKey 判断 SELECT 1 FROM history WHERE json_extract(options, '$.importDedupeKey') = ? LIMIT 1 -- 条件 C:无 dedupeKey 时的兜底(同 tool + summary + input + created_at) SELECT 1 FROM history WHERE tool_id = ? AND summary = ? AND input = ? AND created_at = ? LIMIT 1

执行顺序与细节:

  1. 空行过滤:entry.input.isBlank() && entry.output.isBlank()的行直接return@forEach,不进入任何判断。
  2. createdAt兜底:entry.createdAt.ifBlank { Instant.now().toString() },保证空白时间戳的行也有稳定落库值。
  3. dedupeKey非空时优先双查:先查LegacyMigrationRowRepository.wasMigrated(sourcePath, dedupeKey)(DIFF-155 的逐行来源登记),再查json_extract(options, '$.importDedupeKey');任一命中即跳过。
  4. 无dedupeKey(或上述都没命中)时,再走条件 C 的"内容近似匹配"兜底。
  5. 真正插入后,若sourcePath与dedupeKey均非空,则调用migrationRows.record(sourcePath, entry.dedupeKey, db),把"这个来源的这行"登记到import_migration_row(注意它在同一事务内、插入成功的分支里记录,与条件 A 闭环)。
  6. 每个有实际写入的工具 id 收集进touched,最后统一trimToLimit(db, toolId)——每工具历史上限默认 200 条(HistoryRepository默认limit = 200),导入超限时按created_at DESC, id DESC裁掉最旧记录。

3. 工具草稿:imports/applied-tool-drafts.txt防覆盖

历史行之外,Java 版t_func_content的草稿会被LegacyToolDraftApplier直接写回对应工具的实时会话(代码运行的 Java/Groovy/Python/Node 编辑区、Regex 的 pattern、JSON 编辑器正文、文本对比左右栏、时间转换、计算器、二维码内容),见 LegacyToolDraftApplier.kt。

重复应用草稿会覆盖用户正在编辑的会话,因此 DIFF-154 规定草稿幂等键单独记在数据根目录下:

<dataRoot>/imports/applied-tool-drafts.txt

对应 AppContainer.kt#L299-L315:

fun appliedLegacyDraftKeys(): Set<String> { val file = appliedDraftKeysFile() if (!file.exists()) return emptySet() return file.readLines().map { it.trim() }.filter { it.isNotEmpty() }.toSet() } fun rememberLegacyDraftKeys(keys: Collection<String>) { if (keys.isEmpty()) return val file = appliedDraftKeysFile() file.parent.createDirectories() val next = appliedLegacyDraftKeys() + keys.filter { it.isNotBlank() } file.writeText(next.joinToString("\n")) } private fun appliedDraftKeysFile(): Path = dataDirectories().dataRoot.resolve("imports").resolve("applied-tool-drafts.txt")

该文件与fingerprints.txt同处于imports/目录,按行存储已应用的dedupeKey,追加前先读取已有集合取并集,天然幂等。

三、草稿应用与登记:确认导入时的完整闭环

草稿去重不只是"写个文件",它嵌在设置页"数据迁移"的确认导入流程里。从 MigrationSettingsPanel.kt#L307-L323 可以看到完整链条:

val pendingDrafts = scanned.legacyHistory.filter { row -> row.operation == "draft" && row.dedupeKey.isNotBlank() && row.dedupeKey !in container.appliedLegacyDraftKeys() && !container.migrationRows.wasMigrated(scanned.sourceRoot, row.dedupeKey) } if (pendingDrafts.isNotEmpty()) { container.applyLegacyToolDrafts(pendingDrafts) container.rememberLegacyDraftKeys(pendingDrafts.map { it.dedupeKey }) pendingDrafts.forEach { row -> container.migrationRows.record(scanned.sourceRoot, row.dedupeKey) } }

这里实际上是四重保险:

  1. operation == "draft"限定只处理草稿形态(t_func_content来源);
  2. dedupeKey.isNotBlank()过滤无键行;
  3. applied-tool-drafts.txt中已存在的键不再应用;
  4. import_migration_row表已登记的键不再应用(与历史行共用同一张逐行登记表)。

应用成功后再同步"文件 + 数据库"两份记录,保证重启后、以及从MigrationSettingsPanel之外的其他入口导入时同样被拦截。导入完成后还会调用container.reloadToolSessionsFromStore(),把写好的会话重载到 UI。

四、测试证据:HistoryMergeImportTest

DIFF-154 引用的证据是HistoryMergeImportTest.skipsDuplicateLegacyRowsByImportDedupeKey,完整实现在 HistoryMergeImportTest.kt:

@Test fun skipsDuplicateLegacyRowsByImportDedupeKey() { val root = createTempDirectory("history-dedupe-") val paths = AppPaths.resolve(root.toString()).also { it.ensureCreated() } val database = AppDatabase(paths) val history = HistoryRepository(database) val entry = LegacyHistoryImport( toolId = ToolId.Regex.id, operation = "draft", summary = "Legacy draft", input = "^moo$", output = "", options = LegacyImportOptions.withDedupeKey("t_func_content:1", """{"migratedFrom":"t_func_content"}"""), createdAt = "2026-01-01", dedupeKey = "t_func_content:1" ) assertEquals(1, history.mergeImport(listOf(entry))) assertEquals(0, history.mergeImport(listOf(entry))) assertEquals(1, history.list(ToolId.Regex.id).size) database.close() }

断言链条清晰:第一次mergeImport返回1(导入 1 条);第二次对同一LegacyHistoryImport再导入返回0(被json_extract(options, '$.importDedupeKey')命中跳过);最终该工具历史仅 1 条。注意测试构造的options同时保留了migratedFrom与importDedupeKey两个字段,正好验证了withDedupeKey"保留原字段"的语义。

测试同文件还包含recordsImportMigrationRowForSourcePath(DIFF-155 的证据):携带sourcePath与dedupeKey导入后,migrationRows.wasMigrated(source, "t_func_history:9")为true,再导则返回0。两条测试分别覆盖"库内键去重"与"逐行来源登记去重"两条路径。

五、仍需覆盖的边界与后续演进

DIFF-154 明确列出了三条未覆盖项,仓库后续设计文档逐一补齐:

1. 逐行来源登记与 Electron 目标库对齐(DIFF-155 / 156)

DIFF-154 的history.options.importDedupeKey与草稿文件都是"compose 自有键",但缺少 Electront_next_migration_row等价的「来源路径 + 表 + 行 id」结构。DIFF-155 在 compose 库内落地了 schema v2 的逐行登记表:

CREATE TABLE IF NOT EXISTS import_migration_row ( source_path TEXT NOT NULL, source_table TEXT NOT NULL, source_id TEXT NOT NULL, migrated_at TEXT NOT NULL, PRIMARY KEY (source_path, source_table, source_id) )

表结构见 Database.kt#L126-L146。LegacyMigrationRowRepository的wasMigrated/record会把dedupeKey(如t_func_history:9)拆成source_table+source_id两列,配合规范化绝对路径source_path做主键判重(Database.kt#L339-L392)。当用户直接以含t_next_migration_row的 Electron 数据目录 SQLite作为导入源时,DIFF-156 让inspect把source_table:source_id对齐到dedupeKey预过滤历史,apply开头再把匹配source_path的行种子写入import_migration_row。

2. 读取 Electront_next_migration_run整包记录(DIFF-157)

DIFF-154 背景提到的 Electron 整包记录t_next_migration_run,其读取由 DIFF-157 补齐:inspect检测源库存在该表且任一规范化source_path等于当前sourceRoot时置electronMigrationRunComplete = true,apply跳过整包数据写入但仍合并设置。而 ElectronsourceFingerprint算法与 composefingerprint的对齐,则由 DIFF-158 的LegacySourceFingerprint(递归目录指纹,跳过.git与符号链接)与 DIFF-161 的apply侧importAlreadyRecorded(fingerprint或legacyElectronFingerprint任一命中即跳过)接续处理。

3. safeStorage 密文迁移未做

Electron 设置中的safeStorage加密密文(如 AI 密钥)目前只做"检测到密文则跳过并给出electron:encrypted-secrets-skipped警告"(CrossProductImporter.kt#L169-L171),密文本体的迁移明确不在 DIFF-154 范围。

六、总结:一套"指纹 + 行级键 + 草稿文件"的三层幂等体系

DIFF-154 在整包fingerprint之上补齐了行级稳定键,最终形成三层防御:

层级载体覆盖场景
整包级imports/fingerprints.txt+fingerprint()SHA-256同来源目录完整重复导入
行级(历史)history.options.importDedupeKey+json_extract判断 +import_migration_row表改源目录后 fingerprint 变化、仅重导历史
行级(草稿)imports/applied-tool-drafts.txt+migrationRows.wasMigrated重复应用草稿覆盖用户会话

LegacyImportOptions.withDedupeKey负责在数据进入history前打上不可伪造的来源标记;HistoryRepository.mergeImport用json_extract(options, '$.importDedupeKey')做 O(1) 优先判重,并以内容兜底;applied-tool-drafts.txt与import_migration_row则分别守护会话草稿与跨库来源登记。这组设计与 DIFF-155/156/157/158/161 一起,构成了 MooTool Compose 版跨产品导入在"整包幂等、行级幂等、Electron 已迁识别、safeStorage 安全边界"上的完整闭环——对需要把多代产品数据安全收敛到单一数据体系的桌面应用,是一份可以直接复用的幂等导入参考实现。

  • 桌面应用
  • 开发工具

【免费下载链接】MooTool

Handy tool set for developers

项目地址:https://gitcode.com/gh_mirrors/mo/MooTool
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询