diff --git a/shared/src/commonMain/kotlin/com/deadliner/db/repository/CategoryReference.kt b/shared/src/commonMain/kotlin/com/deadliner/db/repository/CategoryReference.kt index 025213f..decc52e 100644 --- a/shared/src/commonMain/kotlin/com/deadliner/db/repository/CategoryReference.kt +++ b/shared/src/commonMain/kotlin/com/deadliner/db/repository/CategoryReference.kt @@ -7,7 +7,12 @@ import com.deadliner.domain.category.Category * Ensures that an entity's category foreign key has a backing row before the * entity is written. It is only a compatibility placeholder for malformed or * legacy references; normal preset rows are seeded during database creation. + * + * The placeholder predates the Unix epoch used by built-in presets: snapshot export derives an + * unstored category version from `updatedAt`, and a placeholder must never outrank the real + * category, which would otherwise be renamed to the placeholder on every device. */ +@Suppress("UNUSED_PARAMETER") internal fun AppDatabase.ensureCategoryReference(categoryUid: String?, timestamp: String) { if (categoryUid == null) return val category = Category( @@ -17,8 +22,8 @@ internal fun AppDatabase.ensureCategoryReference(categoryUid: String?, timestamp colorHex = "#808080", isPreset = false, sortOrder = Int.MAX_VALUE, - createdAt = timestamp, - updatedAt = timestamp, + createdAt = PLACEHOLDER_CATEGORY_TIMESTAMP, + updatedAt = PLACEHOLDER_CATEGORY_TIMESTAMP, ) categoryQueries.insertCategory( category.uid, @@ -32,3 +37,5 @@ internal fun AppDatabase.ensureCategoryReference(categoryUid: String?, timestamp category.isDeleted.asLong(), ) } + +internal const val PLACEHOLDER_CATEGORY_TIMESTAMP = "0001-01-01T00:00:00Z" diff --git a/shared/src/commonMain/kotlin/com/deadliner/sync/snapshot/SnapshotV3Store.kt b/shared/src/commonMain/kotlin/com/deadliner/sync/snapshot/SnapshotV3Store.kt index 275eec7..d84f8f1 100644 --- a/shared/src/commonMain/kotlin/com/deadliner/sync/snapshot/SnapshotV3Store.kt +++ b/shared/src/commonMain/kotlin/com/deadliner/sync/snapshot/SnapshotV3Store.kt @@ -37,7 +37,13 @@ import kotlinx.datetime.Clock class SnapshotV3Store( private val database: AppDatabase, ) { - fun export(): SnapshotV3Root { + /** + * One transaction gives a consistent read: without it a concurrent local write can land between + * the category and habit/task queries, exporting a child whose category is missing. + */ + fun export(): SnapshotV3Root = database.transactionWithResult { exportInTransaction() } + + private fun exportInTransaction(): SnapshotV3Root { val memoryUserProfile = database.memoryUserProfileQueries .selectProfileWithUpdatedAt { content, updatedAt -> content to updatedAt } .executeAsOneOrNull() diff --git a/shared/src/macosArm64Test/kotlin/com/deadliner/db/DatabaseIntegrationTest.kt b/shared/src/macosArm64Test/kotlin/com/deadliner/db/DatabaseIntegrationTest.kt index 3ff5772..22d896b 100644 --- a/shared/src/macosArm64Test/kotlin/com/deadliner/db/DatabaseIntegrationTest.kt +++ b/shared/src/macosArm64Test/kotlin/com/deadliner/db/DatabaseIntegrationTest.kt @@ -596,6 +596,77 @@ class DatabaseIntegrationTest { } } + @Test + fun placeholderCategoryFromIncompleteSnapshotNeverOverwritesRealCategory() { + val sourcePath = "${NSTemporaryDirectory()}deadliner-placeholder-source-${NSUUID().UUIDString}.db" + val targetPath = "${NSTemporaryDirectory()}deadliner-placeholder-target-${NSUUID().UUIDString}.db" + val source = DeadlinerDatabase.create(MacosDatabaseDriverFactory(sourcePath, "source-device")) + val target = DeadlinerDatabase.create(MacosDatabaseDriverFactory(targetPath, "target-device")) + try { + val category = Category("category.real", "Real", "flag", "#EF4444", false, 10, NOW, NOW) + source.categories.create(category) + val habit = Habit("habit.placeholder", "Uses real category", categoryUid = category.uid, + period = HabitPeriod.DAILY, createdAt = "2026-07-18T00:05:00Z", updatedAt = "2026-07-18T00:05:00Z") + source.habits.create(habit) + + // A torn export: the habit is present but its category is not. + val payload = Json.decodeFromString(source.sync.mergeRemotePayload(null).payload) + val snapshot = requireNotNull(payload.snapshot) + val torn = payload.copy(snapshot = snapshot.copy(categories = snapshot.categories.filterNot { it.uid == category.uid })) + target.sync.mergeRemotePayload(Json.encodeToString(torn)) + assertEquals("未命名分类", target.categories.find(category.uid)?.name) + + // The placeholder travels back but must not outrank the real category. + source.sync.mergeRemotePayload(target.sync.mergeRemotePayload(null).payload) + assertEquals("Real", source.categories.find(category.uid)?.name) + + // A complete snapshot repairs the placeholder on the other device. + target.sync.mergeRemotePayload(source.sync.mergeRemotePayload(null).payload) + assertEquals("Real", target.categories.find(category.uid)?.name) + } finally { + source.close() + target.close() + remove(sourcePath) + remove(targetPath) + } + } + + @Test + fun placeholderCannotOverwriteEpochPresetEvenWithHigherDeviceId() { + val sourcePath = "${NSTemporaryDirectory()}preset-source-${NSUUID().UUIDString}.db" + val targetPath = "${NSTemporaryDirectory()}preset-target-${NSUUID().UUIDString}.db" + val source = DeadlinerDatabase.create(MacosDatabaseDriverFactory(sourcePath)) + val target = DeadlinerDatabase.create(MacosDatabaseDriverFactory(targetPath)) + try { + val preset = CategoryPresets.all.first() + source.habits.create(Habit("habit.preset", "Preset reference", categoryUid = preset.uid, + period = HabitPeriod.DAILY, createdAt = NOW, updatedAt = NOW)) + val payload = Json.decodeFromString(source.sync.mergeRemotePayload(null).payload) + val snapshot = requireNotNull(payload.snapshot) + // Model an existing receiver without presets (for example after restoring an older backup). + target.database.categoryQueries.deleteAll() + target.sync.mergeRemotePayload(Json.encodeToString(payload.copy(snapshot = snapshot.copy( + categories = snapshot.categories.filterNot { it.uid == preset.uid })))) + assertEquals("未命名分类", target.categories.find(preset.uid)?.name) + val exported = Json.decodeFromString(target.sync.mergeRemotePayload(null).payload) + val placeholderSnapshot = requireNotNull(exported.snapshot) + // Use distinct device IDs explicitly: the native test factory shares process identity. + source.database.legacyV2VersionQueries.upsertVersion("v3/category", preset.uid, preset.updatedAt, 0, "a-source") + val returned = exported.copy(snapshot = placeholderSnapshot.copy(categories = placeholderSnapshot.categories.map { + if (it.uid == preset.uid) it.copy(ver = it.ver.copy(dev = "z-target")) else it + })) + source.sync.mergeRemotePayload(Json.encodeToString(returned)) + assertEquals(preset.name, source.categories.find(preset.uid)?.name) + assertEquals(preset.iconKey, source.categories.find(preset.uid)?.iconKey) + assertEquals(preset.colorHex, source.categories.find(preset.uid)?.colorHex) + target.sync.mergeRemotePayload(source.sync.mergeRemotePayload(null).payload) + assertEquals(preset.name, target.categories.find(preset.uid)?.name) + assertTrue(target.categories.find(preset.uid)?.isPreset == true) + } finally { + source.close(); target.close(); remove(sourcePath); remove(targetPath) + } + } + private companion object { const val NOW = "2026-07-18T00:00:00Z" }