Skip to content

fix(sync): keep placeholder categories from overwriting real ones - #3

Merged
AritxOnly merged 2 commits into
DeadlinerStudio:byosfrom
Tequilaar:mac/fix-category-placeholder
Nov 3, 2026
Merged

AritxOnly merged 2 commits into
DeadlinerStudio:byosfrom
Tequilaar:mac/fix-category-placeholder

Conversation

@Tequilaar

Copy link
Copy Markdown

现象

两台设备同步时,一台设备新建分类后马上新建使用它的习惯(或任务),有一定概率这个分类在所有设备和云端都变成「未命名分类」(灰色文件夹),原来的名称、图标、颜色丢失。Mac 客户端的双设备同步测试大约每 15~30 次复现一次。

原因

两处叠加:

  1. 导出快照不是一致读取

    • SnapshotV3Store.export() 依次查询分类、任务、专注、习惯……没有放在同一个事务里。
    • 自动同步在后台导出时,如果本地恰好先写入分类、再写入习惯,可能导出一份含习惯、不含其分类的快照。
  2. 占位分类的版本比真实分类新

    • 接收方合并这份快照时,upsertHabit → ensureCategoryReference 插入「未命名分类」,时间戳取习惯的 updatedAt。
    • 占位行没有保存版本,下次 export() 时 versionFor(CATEGORY, …) 用这个时间戳生成版本,比真实分类的新。
    • 于是占位分类作为「最新修改」同步回去,覆盖了所有设备上的真实分类。任务也有同样的路径(TaskRepository)。

改动(3 个文件,+51/-3)

  • SnapshotV3Store.export():包在 transactionWithResult 中,保证一致读取。原函数体改名为私有的 exportInTransaction(),内容不变。
  • ensureCategoryReference:占位分类的 createdAt / updatedAt 使用 1970-01-01T00:00:00Z,任何真实版本都能覆盖它。函数签名不变,调用方无需修改。
  • 新增回归测试 DatabaseIntegrationTest.placeholderCategoryFromIncompleteSnapshotNeverOverwritesRealCategory:
    1. 模拟一份「有习惯、没分类」的快照,接收方得到占位分类;
    2. 占位分类同步回来源设备后,真实分类不被覆盖;
    3. 接收方再收到完整快照后,恢复为真实分类。

验证

  • 回归测试在改动前失败(Expected <Real>, actual <未命名分类>),改动后通过。
  • ./gradlew -PlifiMode=stub :shared:macosArm64Test:275 个测试全部通过。
  • ./gradlew -PlifiMode=private :shared:macosArm64Test:329 个测试全部通过。
  • Mac 客户端的双设备同步测试连续运行 60 次全部通过(改动前约每 15~30 次失败一次)。

不影响

  • 公开接口不变。头文件没有变化(export() 签名不变,新增的函数和常量都是 private / internal)。
  • 已经被占位覆盖的分类不会自动恢复(真实数据已经被覆盖);之后只要有设备重新编辑这个分类,就会正常同步。

Tequilaar and others added 2 commits November 1, 2026 22:21
A snapshot export read categories and children in separate queries outside
a transaction, so a concurrent local write could export a habit or task
whose category was missing. The receiving device then inserted the
"未命名分类" placeholder with the child's timestamp; export derived an
unstored version from that timestamp, so the placeholder outranked the real
category and renamed it on every device.

- Export snapshots in one transaction for a consistent read.
- Give placeholder categories the epoch timestamp so any real version wins.
- Add a regression test with a torn snapshot.

@AritxOnly AritxOnly left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the preset collision by giving new placeholder categories a timestamp earlier than the built-in epoch presets. Added a regression covering a missing preset, a higher placeholder device ID, and repair from the real category.

Validation: combined PR #1–#4 checkout with both follow-up fixes; ./gradlew --offline -PlifiMode=stub :shared:macosArm64Test — 280 tests passed, 0 failures. Private Lifi mode and iOS framework linking were not rerun in this pass.

@AritxOnly
AritxOnly merged commit 92c5d67 into DeadlinerStudio:byos Nov 3, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants