← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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

MListless/Sync/PersistenceController.swift | 46++++++++++++++++++++++++++++++++++++++++++++--
MTests/Unit/TaskStoreCompletionTests.swift | 6+++---
MTests/Unit/TaskStoreEdgeCaseTests.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

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))
+        }
+    }
 }