listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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