commit cf479e92e14194a6819bfe0fc3ffa3d0e18fd1c4
parent 7dd8a4dc18d93555849849d3763599c8a67c4618
Author: Michael Camilleri <[email protected]>
Date: Wed, 18 Feb 2026 23:04:31 +0900
Add merge policy to prefer most recent update
Co-Authored-By: Codex GPT 5.3 <[email protected]>
Diffstat:
3 files changed, 144 insertions(+), 5 deletions(-)
diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift
@@ -1,6 +1,49 @@
import CoreData
import Foundation
+private final class UpdatedAtMergePolicy: NSMergePolicy {
+ private let fallbackPolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType)
+
+ init() {
+ super.init(merge: .mergeByPropertyStoreTrumpMergePolicyType)
+ }
+
+ override func resolve(mergeConflicts list: [Any]) throws {
+ var fallbackConflicts: [Any] = []
+
+ for item in list {
+ guard
+ let conflict = item as? NSMergeConflict,
+ let task = conflict.sourceObject as? TaskItem,
+ let objectSnapshot = conflict.objectSnapshot,
+ let persistedSnapshot = conflict.persistedSnapshot,
+ let storeUpdatedAt = persistedSnapshot["updatedAt"] as? Date
+ else {
+ fallbackConflicts.append(item)
+ continue
+ }
+
+ let localUpdatedAt = (objectSnapshot["updatedAt"] as? Date) ?? task.updatedAt
+
+ // Keep local in-memory values if they are newer or equal.
+ guard storeUpdatedAt > localUpdatedAt else { continue }
+
+ // Persisted values are newer; copy them onto the object to resolve conflict.
+ for (key, value) in persistedSnapshot {
+ if value is NSNull {
+ task.setValue(nil, forKey: key)
+ } else {
+ task.setValue(value, forKey: key)
+ }
+ }
+ }
+
+ if !fallbackConflicts.isEmpty {
+ try fallbackPolicy.resolve(mergeConflicts: fallbackConflicts)
+ }
+ }
+}
+
@MainActor
final class PersistenceController {
static let shared = PersistenceController()
@@ -38,8 +81,7 @@ final class PersistenceController {
}
container.viewContext.automaticallyMergesChangesFromParent = true
- container.viewContext.mergePolicy = NSMergePolicy(
- merge: .mergeByPropertyObjectTrumpMergePolicyType)
+ container.viewContext.mergePolicy = UpdatedAtMergePolicy()
performDataMigrationIfNeeded()
}
diff --git a/Tests/Unit/TaskStoreCompletionTests.swift b/Tests/Unit/TaskStoreCompletionTests.swift
@@ -109,10 +109,10 @@ struct TaskStoreCompletionTests {
store.complete(taskID: task3.id)
let tasks = store.fetchTasks()
- // All completed, should be sorted by updatedAt (completion order)
- #expect(tasks[0].id == task2.id)
+ // All completed, should be sorted by updatedAt (most recently completed first)
+ #expect(tasks[0].id == task3.id)
#expect(tasks[1].id == task1.id)
- #expect(tasks[2].id == task3.id)
+ #expect(tasks[2].id == task2.id)
}
@Test("Toggle completion multiple times")
diff --git a/Tests/Unit/TaskStoreEdgeCaseTests.swift b/Tests/Unit/TaskStoreEdgeCaseTests.swift
@@ -1,4 +1,5 @@
import Foundation
+import CoreData
import Testing
@testable import Listless_iOS
@@ -181,4 +182,100 @@ struct TaskStoreEdgeCaseTests {
#expect(activeTasks[1].id == completedTask.id)
#expect(activeTasks[1].sortOrder > activeTasks[0].sortOrder)
}
+
+ @Test("Merge policy prefers store when store updatedAt is newer")
+ func mergePolicyPrefersStoreWhenStoreIsNewer() async throws {
+ let controller = PersistenceController(inMemory: true)
+ let viewContext = controller.viewContext
+ viewContext.automaticallyMergesChangesFromParent = false
+
+ let task = TaskItem(context: viewContext)
+ task.title = "Original"
+ task.updatedAt = Date(timeIntervalSince1970: 100)
+ try viewContext.save()
+
+ let taskID = task.id
+
+ let backgroundContext = controller.container.newBackgroundContext()
+ try await backgroundContext.perform {
+ let request = TaskItem.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let remote = try backgroundContext.fetch(request).first else {
+ Issue.record("Expected task to exist in background context")
+ return
+ }
+
+ remote.title = "Remote newer"
+ remote.updatedAt = Date(timeIntervalSince1970: 200)
+ try backgroundContext.save()
+ }
+
+ task.updatedAt = Date(timeIntervalSince1970: 150)
+ try viewContext.save()
+
+ let verifyContext = controller.container.newBackgroundContext()
+ try await verifyContext.perform {
+ let request = TaskItem.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let resolved = try verifyContext.fetch(request).first else {
+ Issue.record("Expected task to exist in verify context")
+ return
+ }
+
+ #expect(resolved.title == "Remote newer")
+ #expect(resolved.updatedAt == Date(timeIntervalSince1970: 200))
+ }
+ }
+
+ @Test("Merge policy prefers local when local updatedAt is newer")
+ func mergePolicyPrefersLocalWhenLocalIsNewer() async throws {
+ let controller = PersistenceController(inMemory: true)
+ let viewContext = controller.viewContext
+ viewContext.automaticallyMergesChangesFromParent = false
+
+ let task = TaskItem(context: viewContext)
+ task.title = "Original"
+ task.updatedAt = Date(timeIntervalSince1970: 100)
+ try viewContext.save()
+
+ let taskID = task.id
+
+ let backgroundContext = controller.container.newBackgroundContext()
+ try await backgroundContext.perform {
+ let request = TaskItem.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let remote = try backgroundContext.fetch(request).first else {
+ Issue.record("Expected task to exist in background context")
+ return
+ }
+
+ remote.title = "Remote older"
+ remote.updatedAt = Date(timeIntervalSince1970: 200)
+ try backgroundContext.save()
+ }
+
+ task.updatedAt = Date(timeIntervalSince1970: 300)
+ try viewContext.save()
+
+ let verifyContext = controller.container.newBackgroundContext()
+ try await verifyContext.perform {
+ let request = TaskItem.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
+ request.fetchLimit = 1
+
+ guard let resolved = try verifyContext.fetch(request).first else {
+ Issue.record("Expected task to exist in verify context")
+ return
+ }
+
+ #expect(resolved.title == "Original")
+ #expect(resolved.updatedAt == Date(timeIntervalSince1970: 300))
+ }
+ }
}