← All repositories

listless

A simple list app for Apple platforms

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


commit ebe07dff1a78256cf65cd8099458bfb6661f8fa4
parent cf479e92e14194a6819bfe0fc3ffa3d0e18fd1c4
Author: Michael Camilleri <[email protected]>
Date:   Thu, 19 Feb 2026 05:08:03 +0900

Add CloudKit monitor

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 18++++++++++++++++++
MListless/Extensions/TaskListView+Logic.swift | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
AListless/Extensions/TaskListView+SyncUI.swift | 40++++++++++++++++++++++++++++++++++++++++
MListless/Models/TaskStore.swift | 71++++++++++++++++++++++++++++++++++++++++++-----------------------------
AListless/Sync/CloudKitErrorClassifier.swift | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Sync/CloudKitSyncMonitor.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListless/Sync/PersistenceController.swift | 20+++++++++++++-------
MListlessMac/ListlessMacApp.swift | 5++++-
MListlessMac/Views/TaskListView.swift | 34+++++++++++++++++++++++++++++++++-
MListlessiOS/Extensions/TaskListView+Drag.swift | 6+++++-
MListlessiOS/ListlessiOSApp.swift | 5++++-
MListlessiOS/Views/TaskListView.swift | 34+++++++++++++++++++++++++++++++++-
MTests/Support/TestHelpers.swift | 4++--
MTests/Unit/TaskStoreCompletionTests.swift | 102++++++++++++++++++++++++++++++++++++++++----------------------------------------
MTests/Unit/TaskStoreEdgeCaseTests.swift | 76++++++++++++++++++++++++++++++++++++++--------------------------------------
MTests/Unit/TaskStoreOrderingTests.swift | 84++++++++++++++++++++++++++++++++++++++++----------------------------------------
MTests/Unit/TaskStoreTests.swift | 72++++++++++++++++++++++++++++++++++++------------------------------------

17 files changed, 593 insertions(+), 234 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -18,12 +18,14 @@
 		322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A440253D045A0896D94ADD0 /* TaskListView+Toolbar.swift */; };
 		3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
 		3D1F551A03B97ECF4E3DC8B0 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06485DBE35B60868E14202A /* TaskRowView.swift */; };
+		4E5A0A02121E02124F80E320 /* TaskListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */; };
 		5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */; };
 		5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */; };
 		5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
 		5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; };
 		5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */; };
 		614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
+		642151C8EEA34DAD76C49FA1 /* TaskListView+SyncUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */; };
 		763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; };
 		77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */; };
 		77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; };
@@ -32,6 +34,7 @@
 		80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; };
 		82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */; };
 		882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; };
+		889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
 		8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */; };
 		91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
 		96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
@@ -41,11 +44,14 @@
 		BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
 		C169823665158AA347A63990 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51B8129962E5CC78ECDDC2B /* TaskListView.swift */; };
 		C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+		CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; };
 		CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
 		DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */; };
 		DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
 		DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; };
+		E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
 		E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */; };
+		E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; };
 		E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
 		E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */; };
 		ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; };
@@ -85,11 +91,14 @@
 		5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 		5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; };
 		632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
+		68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorClassifier.swift; sourceTree = "<group>"; };
+		6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitSyncMonitor.swift; sourceTree = "<group>"; };
 		6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableTextField.swift; sourceTree = "<group>"; };
 		712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+NavigationHeader.swift"; sourceTree = "<group>"; };
 		72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Logic.swift"; sourceTree = "<group>"; };
 		74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
 		75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
+		7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+SyncUI.swift"; sourceTree = "<group>"; };
 		7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
 		944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
@@ -232,6 +241,7 @@
 			isa = PBXGroup;
 			children = (
 				72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */,
+				7A7BD42B1E3C71333FA24893 /* TaskListView+SyncUI.swift */,
 			);
 			path = Extensions;
 			sourceTree = "<group>";
@@ -280,6 +290,8 @@
 			isa = PBXGroup;
 			children = (
 				944BAE054AAC1B9C4FC954F9 /* .gitkeep */,
+				68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */,
+				6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */,
 				C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */,
 			);
 			path = Sync;
@@ -424,6 +436,8 @@
 			files = (
 				99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */,
 				DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */,
+				E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */,
+				E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */,
 				882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */,
 				172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */,
 				96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
@@ -433,6 +447,7 @@
 				DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */,
 				C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */,
 				E5878BAA0EA66A94440E2B0F /* TaskListView+Logic.swift in Sources */,
+				642151C8EEA34DAD76C49FA1 /* TaskListView+SyncUI.swift in Sources */,
 				322EE74CBED492693B92AD59 /* TaskListView+Toolbar.swift in Sources */,
 				FDD09FECEED48EC9598538F4 /* TaskListView.swift in Sources */,
 				5035EC4C7518A5FF9AD454CA /* TaskRowDragGesture.swift in Sources */,
@@ -447,6 +462,8 @@
 			files = (
 				BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */,
 				5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */,
+				CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */,
+				889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */,
 				E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */,
 				F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */,
 				5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,
@@ -464,6 +481,7 @@
 				5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */,
 				8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */,
 				80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */,
+				4E5A0A02121E02124F80E320 /* TaskListView+SyncUI.swift in Sources */,
 				77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */,
 				C169823665158AA347A63990 /* TaskListView.swift in Sources */,
 				2C53717A0EAFA39240615C9A /* TaskRowDragGesture.swift in Sources */,

diff --git a/Listless/Extensions/TaskListView+Logic.swift b/Listless/Extensions/TaskListView+Logic.swift

@@ -35,6 +35,10 @@ extension TaskListView {
         return nil
     }
 
+    func presentStoreError(_ error: Error) {
+        syncMonitor.ingest(error: error)
+    }
+
     private func isLastActiveTask(_ taskID: UUID) -> Bool {
         guard let lastTask = activeTasks.last else { return false }
         return lastTask.id == taskID
@@ -45,20 +49,29 @@ extension TaskListView {
     func createNewTaskAtTop() -> UUID {
         draggedTaskID = nil
         visualOrder = nil
-        let task = store.createTask(title: "", atBeginning: true)
-        pendingFocus = .task(task.id)
-        focusedField = .task(task.id)
-        selectedTaskID = task.id
-        return task.id
+        do {
+            let task = try store.createTask(title: "", atBeginning: true)
+            pendingFocus = .task(task.id)
+            focusedField = .task(task.id)
+            selectedTaskID = task.id
+            return task.id
+        } catch {
+            presentStoreError(error)
+            return UUID()
+        }
     }
 
     func createNewTask() {
         draggedTaskID = nil
         visualOrder = nil
-        let task = store.createTask(title: "")
-        pendingFocus = .task(task.id)
-        focusedField = .task(task.id)
-        selectedTaskID = task.id
+        do {
+            let task = try store.createTask(title: "")
+            pendingFocus = .task(task.id)
+            focusedField = .task(task.id)
+            selectedTaskID = task.id
+        } catch {
+            presentStoreError(error)
+        }
     }
 
     // MARK: - Interaction Handlers
@@ -115,14 +128,22 @@ extension TaskListView {
 
     func updateTitle(_ task: TaskItem, _ title: String) {
         guard task.title != title else { return }
-        store.updateWithoutSaving(taskID: task.id, title: title)
+        do {
+            try store.updateWithoutSaving(taskID: task.id, title: title)
+        } catch {
+            presentStoreError(error)
+        }
     }
 
     func toggleCompletion(_ task: TaskItem) {
-        if task.isCompleted {
-            store.uncomplete(taskID: task.id)
-        } else {
-            store.complete(taskID: task.id)
+        do {
+            if task.isCompleted {
+                try store.uncomplete(taskID: task.id)
+            } else {
+                try store.complete(taskID: task.id)
+            }
+        } catch {
+            presentStoreError(error)
         }
     }
 
@@ -143,19 +164,25 @@ extension TaskListView {
     func deleteTask(_ task: TaskItem) {
         let taskID = task.id
         print("🔴 deleteTask() called for task \(taskID)")
-
-        if selectedTaskID == taskID {
-            print("🔴 deleteTask() clearing selectedTaskID")
-            selectedTaskID = nil
+        do {
+            try store.delete(taskID: taskID)
+            if selectedTaskID == taskID {
+                print("🔴 deleteTask() clearing selectedTaskID")
+                selectedTaskID = nil
+            }
+            print("🔴 deleteTask() completed")
+        } catch {
+            presentStoreError(error)
         }
-
-        store.delete(taskID: taskID)
-        print("🔴 deleteTask() completed")
     }
 
     func clearCompletedTasks() {
         for task in completedTasks.reversed() {
-            store.delete(taskID: task.id)
+            do {
+                try store.delete(taskID: task.id)
+            } catch {
+                presentStoreError(error)
+            }
         }
     }
 
@@ -265,7 +292,11 @@ extension TaskListView {
         print(
             "🟢 endEditing() called for task \(taskID), shouldCreateNewTask: \(shouldCreateNewTask)"
         )
-        store.save()
+        do {
+            try store.save()
+        } catch {
+            presentStoreError(error)
+        }
 
         let wasLastActiveTask = isLastActiveTask(taskID)
         let willBeDeleted = shouldDeleteIfEmpty(taskID: taskID)
@@ -383,7 +414,11 @@ extension TaskListView {
             return false
         }
 
-        store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
+        do {
+            try store.moveTask(taskID: droppedUUID, toIndex: finalIndex)
+        } catch {
+            presentStoreError(error)
+        }
         draggedTaskID = nil
         visualOrder = nil
 

diff --git a/Listless/Extensions/TaskListView+SyncUI.swift b/Listless/Extensions/TaskListView+SyncUI.swift

@@ -0,0 +1,40 @@
+import SwiftUI
+#if os(iOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+extension TaskListView {
+    @ViewBuilder
+    var syncErrorBanner: some View {
+        if let message = syncMonitor.transientErrorMessage {
+            HStack(spacing: 8) {
+                Image(systemName: "icloud.slash")
+                    .imageScale(.small)
+                Text(message)
+                    .font(.caption)
+                    .lineLimit(2)
+                Spacer(minLength: 0)
+            }
+            .padding(.horizontal, 12)
+            .padding(.vertical, 8)
+            .background(.thinMaterial)
+            .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+            .padding(.horizontal, 12)
+            .padding(.top, 8)
+            .transition(.move(edge: .top).combined(with: .opacity))
+        }
+    }
+
+    func openSystemSettings() {
+        #if os(iOS)
+            guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
+            UIApplication.shared.open(settingsURL)
+        #elseif os(macOS)
+            if let settingsURL = URL(string: "x-apple.systempreferences:") {
+                NSWorkspace.shared.open(settingsURL)
+            }
+        #endif
+    }
+}

diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift

@@ -1,6 +1,20 @@
 import CoreData
 import Foundation
 
+enum TaskStoreError: LocalizedError {
+    case fetchFailed(Error)
+    case saveFailed(Error)
+
+    var errorDescription: String? {
+        switch self {
+        case .fetchFailed(let error):
+            return "Failed to fetch tasks: \(error.localizedDescription)"
+        case .saveFailed(let error):
+            return "Failed to save changes: \(error.localizedDescription)"
+        }
+    }
+}
+
 @MainActor
 final class TaskStore {
     private let persistenceController: PersistenceController
@@ -12,7 +26,7 @@ final class TaskStore {
         self.persistenceController = persistenceController
     }
 
-    func fetchTasks() -> [TaskItem] {
+    func fetchTasks() throws -> [TaskItem] {
         let request = TaskItem.fetchRequest()
         request.sortDescriptors = []
 
@@ -29,17 +43,17 @@ final class TaskStore {
 
             return activeTasks + completedTasks
         } catch {
-            print("Failed to fetch tasks: \(error)")
-            return []
+            throw TaskStoreError.fetchFailed(error)
         }
     }
 
-    func createTask(title: String = "", atBeginning: Bool = false) -> TaskItem {
+    func createTask(title: String = "", atBeginning: Bool = false) throws -> TaskItem {
         let task = TaskItem(context: context)
         task.title = title
 
         context.processPendingChanges()
-        let activeTasks = fetchTasks().filter { !$0.isCompleted }
+        let activeTasks = try fetchTasks().filter { !$0.isCompleted }
+
         if atBeginning {
             let minOrder = activeTasks.map(\.sortOrder).min() ?? 0
             task.sortOrder = minOrder - 1000
@@ -48,20 +62,20 @@ final class TaskStore {
             task.sortOrder = maxOrder + 1000
         }
 
-        save()
+        try save()
         return task
     }
 
-    func complete(taskID: UUID) {
-        guard let task = findTask(id: taskID) else { return }
+    func complete(taskID: UUID) throws {
+        guard let task = try findTask(id: taskID) else { return }
         task.isCompleted = true
-        save()
+        try save()
     }
 
-    func uncomplete(taskID: UUID) {
-        guard let task = findTask(id: taskID) else { return }
+    func uncomplete(taskID: UUID) throws {
+        guard let task = try findTask(id: taskID) else { return }
         let restoredSortOrder = task.sortOrder
-        let activeTasks = fetchTasks().filter { !$0.isCompleted && $0.id != task.id }
+        let activeTasks = try fetchTasks().filter { !$0.isCompleted && $0.id != task.id }
         let hasSortOrderConflict = activeTasks.contains { $0.sortOrder == restoredSortOrder }
 
         if hasSortOrderConflict {
@@ -70,29 +84,29 @@ final class TaskStore {
         }
 
         task.isCompleted = false
-        save()
+        try save()
     }
 
-    func update(taskID: UUID, title: String) {
-        guard let task = findTask(id: taskID) else { return }
+    func update(taskID: UUID, title: String) throws {
+        guard let task = try findTask(id: taskID) else { return }
         task.title = title
-        save()
+        try save()
     }
 
-    func updateWithoutSaving(taskID: UUID, title: String) {
-        guard let task = findTask(id: taskID) else { return }
+    func updateWithoutSaving(taskID: UUID, title: String) throws {
+        guard let task = try findTask(id: taskID) else { return }
         task.title = title
         // Don't save - will be saved when editing ends
     }
 
-    func delete(taskID: UUID) {
-        guard let task = findTask(id: taskID) else { return }
+    func delete(taskID: UUID) throws {
+        guard let task = try findTask(id: taskID) else { return }
         context.delete(task)
-        save()
+        try save()
     }
 
-    func moveTask(taskID: UUID, toIndex: Int) {
-        let activeTasks = fetchTasks().filter { !$0.isCompleted }
+    func moveTask(taskID: UUID, toIndex: Int) throws {
+        let activeTasks = try fetchTasks().filter { !$0.isCompleted }
             .sorted { $0.sortOrder < $1.sortOrder }
 
         guard let currentIndex = activeTasks.firstIndex(where: { $0.id == taskID }) else { return }
@@ -110,10 +124,10 @@ final class TaskStore {
             task.sortOrder = Int64(index) * 1000
         }
 
-        save()
+        try save()
     }
 
-    private func findTask(id: UUID) -> TaskItem? {
+    private func findTask(id: UUID) throws -> TaskItem? {
         let request = TaskItem.fetchRequest()
         request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
         request.fetchLimit = 1
@@ -121,12 +135,11 @@ final class TaskStore {
         do {
             return try context.fetch(request).first
         } catch {
-            print("Failed to find task: \(error)")
-            return nil
+            throw TaskStoreError.fetchFailed(error)
         }
     }
 
-    func save() {
-        persistenceController.save()
+    func save() throws {
+        try persistenceController.save()
     }
 }

diff --git a/Listless/Sync/CloudKitErrorClassifier.swift b/Listless/Sync/CloudKitErrorClassifier.swift

@@ -0,0 +1,101 @@
+import CloudKit
+import Foundation
+
+enum SyncAlertAction {
+    case openSettings
+}
+
+struct SyncAlertItem: Identifiable {
+    let id = UUID()
+    let title: String
+    let message: String
+    let action: SyncAlertAction?
+}
+
+enum SyncIssue {
+    case transient(message: String)
+    case alert(SyncAlertItem)
+}
+
+enum CloudKitErrorClassifier {
+    static func classify(_ error: Error) -> SyncIssue {
+        let rootError = unwrap(error)
+        let nsError = rootError as NSError
+
+        if nsError.domain == CKError.errorDomain,
+            let ckCode = CKError.Code(rawValue: nsError.code)
+        {
+            return classifyCloudKit(code: ckCode)
+        }
+
+        if nsError.domain == NSCocoaErrorDomain {
+            return .alert(
+                SyncAlertItem(
+                    title: "Unable to Save Changes",
+                    message: "Your changes are still local, but syncing encountered an issue. Please try again.",
+                    action: nil
+                ))
+        }
+
+        return .alert(
+            SyncAlertItem(
+                title: "Sync Error",
+                message: rootError.localizedDescription,
+                action: nil
+            ))
+    }
+
+    private static func classifyCloudKit(code: CKError.Code) -> SyncIssue {
+        switch code {
+        case .networkUnavailable, .networkFailure, .serviceUnavailable, .requestRateLimited,
+            .zoneBusy, .serverResponseLost, .operationCancelled:
+            return .transient(message: "Saved locally. iCloud sync will retry automatically.")
+
+        case .notAuthenticated:
+            return .alert(
+                SyncAlertItem(
+                    title: "iCloud Sign-In Required",
+                    message:
+                        "Sign in to iCloud in Settings to continue syncing your tasks across devices.",
+                    action: .openSettings
+                ))
+
+        case .quotaExceeded:
+            return .alert(
+                SyncAlertItem(
+                    title: "iCloud Storage Full",
+                    message:
+                        "Free up iCloud storage or upgrade your plan to continue syncing.",
+                    action: .openSettings
+                ))
+
+        case .permissionFailure, .badContainer, .missingEntitlement:
+            return .alert(
+                SyncAlertItem(
+                    title: "iCloud Sync Unavailable",
+                    message:
+                        "This device currently cannot access iCloud for syncing. Check iCloud settings and try again.",
+                    action: .openSettings
+                ))
+
+        default:
+            return .alert(
+                SyncAlertItem(
+                    title: "Sync Error",
+                    message:
+                        "Changes are saved locally, but iCloud sync failed. The app will try again automatically.",
+                    action: nil
+                ))
+        }
+    }
+
+    private static func unwrap(_ error: Error) -> Error {
+        if let storeError = error as? TaskStoreError {
+            switch storeError {
+            case .fetchFailed(let wrappedError), .saveFailed(let wrappedError):
+                return wrappedError
+            }
+        }
+        return error
+    }
+}

diff --git a/Listless/Sync/CloudKitSyncMonitor.swift b/Listless/Sync/CloudKitSyncMonitor.swift

@@ -0,0 +1,72 @@
+import CoreData
+import Foundation
+
+@MainActor
+final class CloudKitSyncMonitor: ObservableObject {
+    @Published private(set) var transientErrorMessage: String?
+    @Published var actionableAlert: SyncAlertItem?
+
+    private var monitoringTask: Task<Void, Never>?
+
+    func startMonitoring(container: NSPersistentCloudKitContainer) {
+        guard monitoringTask == nil else { return }
+
+        monitoringTask = Task { [weak self] in
+            guard let self else { return }
+
+            for await notification in NotificationCenter.default.notifications(
+                named: NSPersistentCloudKitContainer.eventChangedNotification,
+                object: container
+            ) {
+                guard
+                    let event =
+                        notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
+                        as? NSPersistentCloudKitContainer.Event
+                else {
+                    continue
+                }
+
+                if let error = event.error {
+                    self.handle(issue: CloudKitErrorClassifier.classify(error))
+                } else if self.isSuccessfulSyncCompletion(event) {
+                    self.transientErrorMessage = nil
+                }
+            }
+        }
+    }
+
+    func clearActionableAlert() {
+        actionableAlert = nil
+    }
+
+    func ingest(error: Error) {
+        handle(issue: CloudKitErrorClassifier.classify(error))
+    }
+
+    private func handle(issue: SyncIssue) {
+        switch issue {
+        case .transient(let message):
+            showTransient(message)
+
+        case .alert(let alert):
+            actionableAlert = alert
+        }
+    }
+
+    private func showTransient(_ message: String) {
+        transientErrorMessage = message
+    }
+
+    private func isSuccessfulSyncCompletion(_ event: NSPersistentCloudKitContainer.Event) -> Bool {
+        guard event.endDate != nil else { return false }
+
+        switch event.type {
+        case .import, .export:
+            return true
+        case .setup:
+            return false
+        @unknown default:
+            return false
+        }
+    }
+}

diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift

@@ -49,6 +49,7 @@ final class PersistenceController {
     static let shared = PersistenceController()
 
     let container: NSPersistentCloudKitContainer
+    let syncMonitor: CloudKitSyncMonitor
 
     var viewContext: NSManagedObjectContext {
         container.viewContext
@@ -56,6 +57,7 @@ final class PersistenceController {
 
     init(inMemory: Bool = false) {
         container = NSPersistentCloudKitContainer(name: "Listless")
+        syncMonitor = CloudKitSyncMonitor()
 
         if inMemory {
             container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
@@ -83,18 +85,22 @@ final class PersistenceController {
         container.viewContext.automaticallyMergesChangesFromParent = true
         container.viewContext.mergePolicy = UpdatedAtMergePolicy()
 
+        if !inMemory {
+            syncMonitor.startMonitoring(container: container)
+        }
+
         performDataMigrationIfNeeded()
     }
 
-    func save() {
+    func save() throws {
         let context = container.viewContext
 
-        if context.hasChanges {
-            do {
-                try context.save()
-            } catch {
-                print("Failed to save context: \(error.localizedDescription)")
-            }
+        guard context.hasChanges else { return }
+
+        do {
+            try context.save()
+        } catch {
+            throw TaskStoreError.saveFailed(error)
         }
     }
 

diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift

@@ -12,7 +12,10 @@ struct ListlessMacApp: App {
 
     var body: some Scene {
         WindowGroup {
-            TaskListView(store: TaskStore(persistenceController: persistenceController))
+            TaskListView(
+                store: TaskStore(persistenceController: persistenceController),
+                syncMonitor: persistenceController.syncMonitor
+            )
                 .environment(\.managedObjectContext, persistenceController.viewContext)
         }
         .windowStyle(.hiddenTitleBar)

diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift

@@ -10,6 +10,7 @@ struct TaskListView: View {
     @Environment(\.managedObjectContext) var managedObjectContext
 
     let store: TaskStore
+    @ObservedObject var syncMonitor: CloudKitSyncMonitor
     @FetchRequest(
         sortDescriptors: [
             NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true),
@@ -28,8 +29,9 @@ struct TaskListView: View {
 
     var vStackSpacing: CGFloat { 0 }
 
-    init(store: TaskStore = TaskStore()) {
+    init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) {
         self.store = store
+        self.syncMonitor = syncMonitor
     }
 
     func didStartDrag() {}
@@ -174,5 +176,35 @@ struct TaskListView: View {
         .toolbar {
             platformToolbar
         }
+        .overlay(alignment: .top) {
+            syncErrorBanner
+        }
+        .alert(
+            item: Binding(
+                get: { syncMonitor.actionableAlert },
+                set: { if $0 == nil { syncMonitor.clearActionableAlert() } }
+            )
+        ) { alert in
+            switch alert.action {
+            case .openSettings:
+                return Alert(
+                    title: Text(alert.title),
+                    message: Text(alert.message),
+                    primaryButton: .default(Text("Open Settings")) { openSystemSettings() },
+                    secondaryButton: .cancel(Text("OK")) {
+                        syncMonitor.clearActionableAlert()
+                    }
+                )
+
+            case .none:
+                return Alert(
+                    title: Text(alert.title),
+                    message: Text(alert.message),
+                    dismissButton: .default(Text("OK")) {
+                        syncMonitor.clearActionableAlert()
+                    }
+                )
+            }
+        }
     }
 }

diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/TaskListView+Drag.swift

@@ -36,7 +36,11 @@ extension TaskListView {
             isDragging = false
             return
         }
-        store.moveTask(taskID: draggedID, toIndex: finalIndex)
+        do {
+            try store.moveTask(taskID: draggedID, toIndex: finalIndex)
+        } catch {
+            presentStoreError(error)
+        }
         draggedTaskID = nil
         visualOrder = nil
         isDragging = false

diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift

@@ -6,7 +6,10 @@ struct ListlessiOSApp: App {
 
     var body: some Scene {
         WindowGroup {
-            TaskListView(store: TaskStore(persistenceController: persistenceController))
+            TaskListView(
+                store: TaskStore(persistenceController: persistenceController),
+                syncMonitor: persistenceController.syncMonitor
+            )
                 .safeAreaInset(edge: .top) {
                     Color.clear.frame(height: 8)
                 }

diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift

@@ -10,6 +10,7 @@ struct TaskListView: View {
     @Environment(\.managedObjectContext) var managedObjectContext
 
     let store: TaskStore
+    @ObservedObject var syncMonitor: CloudKitSyncMonitor
     @FetchRequest(
         sortDescriptors: [
             NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true),
@@ -32,8 +33,9 @@ struct TaskListView: View {
     var vStackSpacing: CGFloat { 12 }
     var pullCreateThreshold: CGFloat { 70 }
 
-    init(store: TaskStore = TaskStore()) {
+    init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) {
         self.store = store
+        self.syncMonitor = syncMonitor
     }
 
     func didStartDrag() {
@@ -84,6 +86,36 @@ struct TaskListView: View {
             .toolbar {
                 platformToolbar
             }
+            .overlay(alignment: .top) {
+                syncErrorBanner
+            }
+            .alert(
+                item: Binding(
+                    get: { syncMonitor.actionableAlert },
+                    set: { if $0 == nil { syncMonitor.clearActionableAlert() } }
+                )
+            ) { alert in
+                switch alert.action {
+                case .openSettings:
+                    return Alert(
+                        title: Text(alert.title),
+                        message: Text(alert.message),
+                        primaryButton: .default(Text("Open Settings")) { openSystemSettings() },
+                        secondaryButton: .cancel(Text("OK")) {
+                            syncMonitor.clearActionableAlert()
+                        }
+                    )
+
+                case .none:
+                    return Alert(
+                        title: Text(alert.title),
+                        message: Text(alert.message),
+                        dismissButton: .default(Text("OK")) {
+                            syncMonitor.clearActionableAlert()
+                        }
+                    )
+                }
+            }
     }
 
     private var taskScrollView: some View {

diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift

@@ -16,13 +16,13 @@ func makeTestStore() -> TaskStore {
 ///   - titles: Optional array of titles; if nil, generates "Task 1", "Task 2", etc.
 /// - Returns: Tuple of (store, array of created task IDs)
 @MainActor
-func makeTestStoreWithTasks(count: Int = 3, titles: [String]? = nil) -> (TaskStore, [UUID]) {
+func makeTestStoreWithTasks(count: Int = 3, titles: [String]? = nil) throws -> (TaskStore, [UUID]) {
     let store = makeTestStore()
     var taskIDs: [UUID] = []
 
     for i in 0..<count {
         let title = titles?[safe: i] ?? "Task \(i + 1)"
-        let task = store.createTask(title: title)
+        let task = try store.createTask(title: title)
         taskIDs.append(task.id)
     }
 

diff --git a/Tests/Unit/TaskStoreCompletionTests.swift b/Tests/Unit/TaskStoreCompletionTests.swift

@@ -12,48 +12,48 @@ struct TaskStoreCompletionTests {
     @Test("Complete task")
     func completeTask() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Task to complete")
+        let task = try store.createTask(title: "Task to complete")
 
-        store.complete(taskID: task.id)
+        try store.complete(taskID: task.id)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.isCompleted == true)
     }
 
     @Test("Uncomplete task")
     func uncompleteTask() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Task")
-        store.complete(taskID: task.id)
+        let task = try store.createTask(title: "Task")
+        try store.complete(taskID: task.id)
 
-        store.uncomplete(taskID: task.id)
+        try store.uncomplete(taskID: task.id)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.isCompleted == false)
     }
 
     @Test("Complete with invalid ID does nothing")
     func completeWithInvalidIDDoesNothing() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Task")
+        let task = try store.createTask(title: "Task")
         let invalidID = UUID()
 
-        store.complete(taskID: invalidID)
+        try store.complete(taskID: invalidID)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.isCompleted == false)
     }
 
     @Test("Uncomplete with invalid ID does nothing")
     func uncompleteWithInvalidIDDoesNothing() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Task")
-        store.complete(taskID: task.id)
+        let task = try store.createTask(title: "Task")
+        try store.complete(taskID: task.id)
         let invalidID = UUID()
 
-        store.uncomplete(taskID: invalidID)
+        try store.uncomplete(taskID: invalidID)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.isCompleted == true)
     }
 
@@ -62,15 +62,15 @@ struct TaskStoreCompletionTests {
     @Test("Completing task updates timestamp")
     func completingTaskUpdatesTimestamp() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Task")
+        let task = try store.createTask(title: "Task")
         let originalUpdatedAt = task.updatedAt
 
         // Small delay to ensure timestamp difference
         try await Task.sleep(nanoseconds: 10_000_000) // 10ms
 
-        store.complete(taskID: task.id)
+        try store.complete(taskID: task.id)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         let updatedTask = tasks.first
         #expect(updatedTask?.updatedAt ?? Date() > originalUpdatedAt)
     }
@@ -80,13 +80,13 @@ struct TaskStoreCompletionTests {
     @Test("Active tasks appear before completed tasks")
     func activeTasksAppearBeforeCompletedTasks() async throws {
         let store = makeTestStore()
-        let task1 = store.createTask(title: "Task 1")
-        let task2 = store.createTask(title: "Task 2")
-        let task3 = store.createTask(title: "Task 3")
+        let task1 = try store.createTask(title: "Task 1")
+        let task2 = try store.createTask(title: "Task 2")
+        let task3 = try store.createTask(title: "Task 3")
 
-        store.complete(taskID: task2.id)
+        try store.complete(taskID: task2.id)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks[0].id == task1.id)
         #expect(tasks[1].id == task3.id)
         #expect(tasks[2].id == task2.id)
@@ -95,20 +95,20 @@ struct TaskStoreCompletionTests {
     @Test("Completed tasks sorted by updatedAt")
     func completedTasksSortedByUpdatedAt() async throws {
         let store = makeTestStore()
-        let task1 = store.createTask(title: "Task 1")
-        let task2 = store.createTask(title: "Task 2")
-        let task3 = store.createTask(title: "Task 3")
+        let task1 = try store.createTask(title: "Task 1")
+        let task2 = try store.createTask(title: "Task 2")
+        let task3 = try store.createTask(title: "Task 3")
 
         // Complete in specific order with delays
-        store.complete(taskID: task2.id)
+        try store.complete(taskID: task2.id)
         try await Task.sleep(nanoseconds: 10_000_000) // 10ms
 
-        store.complete(taskID: task1.id)
+        try store.complete(taskID: task1.id)
         try await Task.sleep(nanoseconds: 10_000_000) // 10ms
 
-        store.complete(taskID: task3.id)
+        try store.complete(taskID: task3.id)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         // All completed, should be sorted by updatedAt (most recently completed first)
         #expect(tasks[0].id == task3.id)
         #expect(tasks[1].id == task1.id)
@@ -118,46 +118,46 @@ struct TaskStoreCompletionTests {
     @Test("Toggle completion multiple times")
     func toggleCompletionMultipleTimes() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Task")
+        let task = try store.createTask(title: "Task")
 
-        store.complete(taskID: task.id)
-        var tasks = store.fetchTasks()
+        try store.complete(taskID: task.id)
+        var tasks = try store.fetchTasks()
         #expect(tasks.first?.isCompleted == true)
 
-        store.uncomplete(taskID: task.id)
-        tasks = store.fetchTasks()
+        try store.uncomplete(taskID: task.id)
+        tasks = try store.fetchTasks()
         #expect(tasks.first?.isCompleted == false)
 
-        store.complete(taskID: task.id)
-        tasks = store.fetchTasks()
+        try store.complete(taskID: task.id)
+        tasks = try store.fetchTasks()
         #expect(tasks.first?.isCompleted == true)
     }
 
     @Test("Complete all tasks")
     func completeAllTasks() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 5)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 5)
 
         for id in taskIDs {
-            store.complete(taskID: id)
+            try store.complete(taskID: id)
         }
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.allSatisfy { $0.isCompleted })
         #expect(tasks.count == 5)
     }
 
     @Test("Uncomplete restores previous sortOrder when no active conflict")
     func uncompleteRestoresPreviousSortOrderWhenNoConflict() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
         let taskToRestoreID = taskIDs[1]
 
-        let originalSortOrder = store.fetchTasks().first { $0.id == taskToRestoreID }?.sortOrder
+        let originalSortOrder = try store.fetchTasks().first { $0.id == taskToRestoreID }?.sortOrder
         #expect(originalSortOrder != nil)
 
-        store.complete(taskID: taskToRestoreID)
-        store.uncomplete(taskID: taskToRestoreID)
+        try store.complete(taskID: taskToRestoreID)
+        try store.uncomplete(taskID: taskToRestoreID)
 
-        let activeTasks = store.fetchTasks().filter { !$0.isCompleted }
+        let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
         let restoredTask = activeTasks.first { $0.id == taskToRestoreID }
 
         #expect(restoredTask != nil)
@@ -168,17 +168,17 @@ struct TaskStoreCompletionTests {
     @Test("Uncomplete appends task when restored sortOrder conflicts with active task")
     func uncompleteAppendsWhenRestoredSortOrderConflicts() async throws {
         let store = makeTestStore()
-        let activeTask = store.createTask(title: "Active task")
-        let completedTask = store.createTask(title: "Completed task")
+        let activeTask = try store.createTask(title: "Active task")
+        let completedTask = try store.createTask(title: "Completed task")
 
-        store.complete(taskID: completedTask.id)
-        store.moveTask(taskID: activeTask.id, toIndex: 0)
+        try store.complete(taskID: completedTask.id)
+        try store.moveTask(taskID: activeTask.id, toIndex: 0)
         completedTask.sortOrder = activeTask.sortOrder
-        store.save()
+        try store.save()
 
-        store.uncomplete(taskID: completedTask.id)
+        try store.uncomplete(taskID: completedTask.id)
 
-        let activeTasks = store.fetchTasks().filter { !$0.isCompleted }
+        let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
             .sorted { $0.sortOrder < $1.sortOrder }
         let lastActiveTask = activeTasks.last
 

diff --git a/Tests/Unit/TaskStoreEdgeCaseTests.swift b/Tests/Unit/TaskStoreEdgeCaseTests.swift

@@ -14,9 +14,9 @@ struct TaskStoreEdgeCaseTests {
     func taskWithEmptyTitle() async throws {
         let store = makeTestStore()
 
-        let task = store.createTask(title: "")
+        let task = try store.createTask(title: "")
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.title == "")
     }
 
@@ -25,9 +25,9 @@ struct TaskStoreEdgeCaseTests {
         let store = makeTestStore()
         let longTitle = String(repeating: "A", count: 10_000)
 
-        let task = store.createTask(title: longTitle)
+        let task = try store.createTask(title: longTitle)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.title.count == 10_000)
     }
 
@@ -36,9 +36,9 @@ struct TaskStoreEdgeCaseTests {
         let store = makeTestStore()
         let specialTitle = "Test 🎉 with émojis & spëcial çharacters! @#$%^&*()"
 
-        let task = store.createTask(title: specialTitle)
+        let task = try store.createTask(title: specialTitle)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.title == specialTitle)
     }
 
@@ -47,9 +47,9 @@ struct TaskStoreEdgeCaseTests {
         let store = makeTestStore()
         let multilineTitle = "Line 1\nLine 2\tTabbed"
 
-        let task = store.createTask(title: multilineTitle)
+        let task = try store.createTask(title: multilineTitle)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.title == multilineTitle)
     }
 
@@ -61,10 +61,10 @@ struct TaskStoreEdgeCaseTests {
         let count = 100
 
         for i in 0..<count {
-            _ = store.createTask(title: "Task \(i)")
+            _ = try store.createTask(title: "Task \(i)")
         }
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.count == count)
     }
 
@@ -74,15 +74,15 @@ struct TaskStoreEdgeCaseTests {
         var taskIDs: [UUID] = []
 
         for i in 0..<50 {
-            let task = store.createTask(title: "Task \(i)")
+            let task = try store.createTask(title: "Task \(i)")
             taskIDs.append(task.id)
         }
 
         for id in taskIDs {
-            store.delete(taskID: id)
+            try store.delete(taskID: id)
         }
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.isEmpty)
     }
 
@@ -90,15 +90,15 @@ struct TaskStoreEdgeCaseTests {
 
     @Test("Create task after completing all tasks")
     func createTaskAfterCompletingAllTasks() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
 
         for id in taskIDs {
-            store.complete(taskID: id)
+            try store.complete(taskID: id)
         }
 
-        let newTask = store.createTask(title: "New task")
+        let newTask = try store.createTask(title: "New task")
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         let activeTasks = tasks.filter { !$0.isCompleted }
         #expect(activeTasks.count == 1)
         #expect(activeTasks[0].id == newTask.id)
@@ -107,13 +107,13 @@ struct TaskStoreEdgeCaseTests {
     @Test("Rapid updates to same task")
     func rapidUpdatesToSameTask() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Original")
+        let task = try store.createTask(title: "Original")
 
         for i in 0..<10 {
-            store.update(taskID: task.id, title: "Update \(i)")
+            try store.update(taskID: task.id, title: "Update \(i)")
         }
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.title == "Update 9")
     }
 
@@ -121,29 +121,29 @@ struct TaskStoreEdgeCaseTests {
 
     @Test("Store with only completed tasks")
     func storeWithOnlyCompletedTasks() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 5)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 5)
 
         for id in taskIDs {
-            store.complete(taskID: id)
+            try store.complete(taskID: id)
         }
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.allSatisfy { $0.isCompleted })
         #expect(tasks.count == 5)
     }
 
     @Test("SortOrder after completing all tasks")
     func sortOrderAfterCompletingAllTasks() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
 
         for id in taskIDs {
-            store.complete(taskID: id)
+            try store.complete(taskID: id)
         }
 
-        let newTask1 = store.createTask(title: "New 1")
-        let newTask2 = store.createTask(title: "New 2")
+        let newTask1 = try store.createTask(title: "New 1")
+        let newTask2 = try store.createTask(title: "New 2")
 
-        let activeTasks = store.fetchTasks().filter { !$0.isCompleted }
+        let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(activeTasks[0].id == newTask1.id)
         #expect(activeTasks[1].id == newTask2.id)
         #expect(activeTasks[1].sortOrder > activeTasks[0].sortOrder)
@@ -151,12 +151,12 @@ struct TaskStoreEdgeCaseTests {
 
     @Test("Uncompleting task moves it back to active")
     func uncompletingTaskMovesItBackToActive() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
-        store.complete(taskID: taskIDs[1])
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
+        try store.complete(taskID: taskIDs[1])
 
-        store.uncomplete(taskID: taskIDs[1])
+        try store.uncomplete(taskID: taskIDs[1])
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         let activeTasks = tasks.filter { !$0.isCompleted }
         #expect(activeTasks.count == 3)
         #expect(activeTasks.contains { $0.id == taskIDs[1] })
@@ -165,17 +165,17 @@ struct TaskStoreEdgeCaseTests {
     @Test("Uncomplete legacy sortOrder zero conflict appends to end")
     func uncompleteLegacyZeroConflictAppendsToEnd() async throws {
         let store = makeTestStore()
-        let activeTask = store.createTask(title: "Active")
-        let completedTask = store.createTask(title: "Completed")
+        let activeTask = try store.createTask(title: "Active")
+        let completedTask = try store.createTask(title: "Completed")
 
         activeTask.sortOrder = 0
-        store.complete(taskID: completedTask.id)
+        try store.complete(taskID: completedTask.id)
         completedTask.sortOrder = 0
-        store.save()
+        try store.save()
 
-        store.uncomplete(taskID: completedTask.id)
+        try store.uncomplete(taskID: completedTask.id)
 
-        let activeTasks = store.fetchTasks().filter { !$0.isCompleted }
+        let activeTasks = try store.fetchTasks().filter { !$0.isCompleted }
             .sorted { $0.sortOrder < $1.sortOrder }
         #expect(activeTasks.count == 2)
         #expect(activeTasks[0].id == activeTask.id)

diff --git a/Tests/Unit/TaskStoreOrderingTests.swift b/Tests/Unit/TaskStoreOrderingTests.swift

@@ -13,11 +13,11 @@ struct TaskStoreOrderingTests {
     func initialSortOrderHasThousandPointGaps() async throws {
         let store = makeTestStore()
 
-        let task1 = store.createTask(title: "Task 1")
-        let task2 = store.createTask(title: "Task 2")
-        let task3 = store.createTask(title: "Task 3")
+        let task1 = try store.createTask(title: "Task 1")
+        let task2 = try store.createTask(title: "Task 2")
+        let task3 = try store.createTask(title: "Task 3")
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
 
         // All tasks are active, so they should be the first 3
         #expect(tasks.count == 3)
@@ -42,12 +42,12 @@ struct TaskStoreOrderingTests {
         (from: 2, to: 1),
     ])
     func moveTaskToDifferentPositions(from: Int, to: Int) async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
         let taskToMove = taskIDs[from]
 
-        store.moveTask(taskID: taskToMove, toIndex: to)
+        try store.moveTask(taskID: taskToMove, toIndex: to)
 
-        let tasks = store.fetchTasks().filter { !$0.isCompleted }
+        let tasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(tasks[to].id == taskToMove)
     }
 
@@ -55,11 +55,11 @@ struct TaskStoreOrderingTests {
 
     @Test("Moving maintains 1000-point gaps")
     func movingMaintainsThousandPointGaps() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 4)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 4)
 
-        store.moveTask(taskID: taskIDs[0], toIndex: 2)
+        try store.moveTask(taskID: taskIDs[0], toIndex: 2)
 
-        let tasks = store.fetchTasks().filter { !$0.isCompleted }
+        let tasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(tasks[0].sortOrder == 0)
         #expect(tasks[1].sortOrder == 1000)
         #expect(tasks[2].sortOrder == 2000)
@@ -68,12 +68,12 @@ struct TaskStoreOrderingTests {
 
     @Test("Move task to same index does nothing")
     func moveTaskToSameIndexDoesNothing() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
-        let originalTasks = store.fetchTasks().filter { !$0.isCompleted }
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
+        let originalTasks = try store.fetchTasks().filter { !$0.isCompleted }
 
-        store.moveTask(taskID: taskIDs[1], toIndex: 1)
+        try store.moveTask(taskID: taskIDs[1], toIndex: 1)
 
-        let tasks = store.fetchTasks().filter { !$0.isCompleted }
+        let tasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(tasks[0].id == originalTasks[0].id)
         #expect(tasks[1].id == originalTasks[1].id)
         #expect(tasks[2].id == originalTasks[2].id)
@@ -83,13 +83,13 @@ struct TaskStoreOrderingTests {
 
     @Test("Move with invalid ID does nothing")
     func moveWithInvalidIDDoesNothing() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
-        let originalTasks = store.fetchTasks().filter { !$0.isCompleted }
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
+        let originalTasks = try store.fetchTasks().filter { !$0.isCompleted }
         let invalidID = UUID()
 
-        store.moveTask(taskID: invalidID, toIndex: 0)
+        try store.moveTask(taskID: invalidID, toIndex: 0)
 
-        let tasks = store.fetchTasks().filter { !$0.isCompleted }
+        let tasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(tasks[0].id == originalTasks[0].id)
         #expect(tasks[1].id == originalTasks[1].id)
         #expect(tasks[2].id == originalTasks[2].id)
@@ -97,21 +97,21 @@ struct TaskStoreOrderingTests {
 
     @Test("Move to negative index clamps to 0")
     func moveToNegativeIndexClampsToZero() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
 
-        store.moveTask(taskID: taskIDs[2], toIndex: -5)
+        try store.moveTask(taskID: taskIDs[2], toIndex: -5)
 
-        let tasks = store.fetchTasks().filter { !$0.isCompleted }
+        let tasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(tasks[0].id == taskIDs[2])
     }
 
     @Test("Move to out-of-bounds index clamps to end")
     func moveToOutOfBoundsIndexClampsToEnd() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
 
-        store.moveTask(taskID: taskIDs[0], toIndex: 999)
+        try store.moveTask(taskID: taskIDs[0], toIndex: 999)
 
-        let tasks = store.fetchTasks().filter { !$0.isCompleted }
+        let tasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(tasks[2].id == taskIDs[0])
     }
 
@@ -119,12 +119,12 @@ struct TaskStoreOrderingTests {
 
     @Test("Moving only affects active tasks")
     func movingOnlyAffectsActiveTasks() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 4)
-        store.complete(taskID: taskIDs[3])
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 4)
+        try store.complete(taskID: taskIDs[3])
 
-        store.moveTask(taskID: taskIDs[0], toIndex: 2)
+        try store.moveTask(taskID: taskIDs[0], toIndex: 2)
 
-        let allTasks = store.fetchTasks()
+        let allTasks = try store.fetchTasks()
         let activeTasks = allTasks.filter { !$0.isCompleted }
         let completedTasks = allTasks.filter { $0.isCompleted }
 
@@ -135,13 +135,13 @@ struct TaskStoreOrderingTests {
 
     @Test("Moving completed task does nothing")
     func movingCompletedTaskDoesNothing() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 3)
-        store.complete(taskID: taskIDs[0])
-        let originalTasks = store.fetchTasks()
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 3)
+        try store.complete(taskID: taskIDs[0])
+        let originalTasks = try store.fetchTasks()
 
-        store.moveTask(taskID: taskIDs[0], toIndex: 1)
+        try store.moveTask(taskID: taskIDs[0], toIndex: 1)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks[0].id == originalTasks[0].id)
         #expect(tasks[1].id == originalTasks[1].id)
         #expect(tasks[2].id == originalTasks[2].id)
@@ -152,11 +152,11 @@ struct TaskStoreOrderingTests {
     @Test("Move single task does nothing")
     func moveSingleTaskDoesNothing() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Only task")
+        let task = try store.createTask(title: "Only task")
 
-        store.moveTask(taskID: task.id, toIndex: 0)
+        try store.moveTask(taskID: task.id, toIndex: 0)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.count == 1)
         #expect(tasks[0].id == task.id)
     }
@@ -166,20 +166,20 @@ struct TaskStoreOrderingTests {
         let store = makeTestStore()
         let randomID = UUID()
 
-        store.moveTask(taskID: randomID, toIndex: 0)
+        try store.moveTask(taskID: randomID, toIndex: 0)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.isEmpty)
     }
 
     @Test("Multiple moves maintain order")
     func multipleMoveMaintainOrder() async throws {
-        let (store, taskIDs) = makeTestStoreWithTasks(count: 4)
+        let (store, taskIDs) = try makeTestStoreWithTasks(count: 4)
 
-        store.moveTask(taskID: taskIDs[0], toIndex: 3)
-        store.moveTask(taskID: taskIDs[2], toIndex: 0)
+        try store.moveTask(taskID: taskIDs[0], toIndex: 3)
+        try store.moveTask(taskID: taskIDs[2], toIndex: 0)
 
-        let tasks = store.fetchTasks().filter { !$0.isCompleted }
+        let tasks = try store.fetchTasks().filter { !$0.isCompleted }
         #expect(tasks[0].id == taskIDs[2])
         #expect(tasks[3].id == taskIDs[0])
     }

diff --git a/Tests/Unit/TaskStoreTests.swift b/Tests/Unit/TaskStoreTests.swift

@@ -13,7 +13,7 @@ struct TaskStoreTests {
     func createTaskWithEmptyTitle() async throws {
         let store = makeTestStore()
 
-        let task = store.createTask()
+        let task = try store.createTask()
 
         #expect(task.title == "")
         #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)
@@ -25,7 +25,7 @@ struct TaskStoreTests {
     func createTaskWithTitle() async throws {
         let store = makeTestStore()
 
-        let task = store.createTask(title: "Buy groceries")
+        let task = try store.createTask(title: "Buy groceries")
 
         #expect(task.title == "Buy groceries")
         #expect(task.id != UUID(uuidString: "00000000-0000-0000-0000-000000000000")!)
@@ -35,9 +35,9 @@ struct TaskStoreTests {
     func createMultipleTasksWithUniqueIDs() async throws {
         let store = makeTestStore()
 
-        let task1 = store.createTask(title: "Task 1")
-        let task2 = store.createTask(title: "Task 2")
-        let task3 = store.createTask(title: "Task 3")
+        let task1 = try store.createTask(title: "Task 1")
+        let task2 = try store.createTask(title: "Task 2")
+        let task3 = try store.createTask(title: "Task 3")
 
         #expect(task1.id != task2.id)
         #expect(task2.id != task3.id)
@@ -49,7 +49,7 @@ struct TaskStoreTests {
         let store = makeTestStore()
 
         let beforeCreate = Date()
-        let task = store.createTask(title: "Test")
+        let task = try store.createTask(title: "Test")
         let afterCreate = Date()
 
         #expect(task.createdAt >= beforeCreate)
@@ -64,7 +64,7 @@ struct TaskStoreTests {
     func fetchTasksFromEmptyStore() async throws {
         let store = makeTestStore()
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
 
         #expect(tasks.isEmpty)
     }
@@ -72,10 +72,10 @@ struct TaskStoreTests {
     @Test("Fetch tasks returns created tasks")
     func fetchTasksReturnsCreatedTasks() async throws {
         let store = makeTestStore()
-        _ = store.createTask(title: "Task 1")
-        _ = store.createTask(title: "Task 2")
+        _ = try store.createTask(title: "Task 1")
+        _ = try store.createTask(title: "Task 2")
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
 
         #expect(tasks.count == 2)
         #expect(tasks[0].title == "Task 1")
@@ -87,34 +87,34 @@ struct TaskStoreTests {
     @Test("Update task title")
     func updateTaskTitle() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Original")
+        let task = try store.createTask(title: "Original")
 
-        store.update(taskID: task.id, title: "Updated")
+        try store.update(taskID: task.id, title: "Updated")
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.title == "Updated")
     }
 
     @Test("Update task title without saving")
     func updateTaskTitleWithoutSaving() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Original")
+        let task = try store.createTask(title: "Original")
 
-        store.updateWithoutSaving(taskID: task.id, title: "Updated")
+        try store.updateWithoutSaving(taskID: task.id, title: "Updated")
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.first?.title == "Updated")
     }
 
     @Test("Update with invalid ID does nothing")
     func updateWithInvalidIDDoesNothing() async throws {
         let store = makeTestStore()
-        _ = store.createTask(title: "Task 1")
+        _ = try store.createTask(title: "Task 1")
         let invalidID = UUID()
 
-        store.update(taskID: invalidID, title: "Should not exist")
+        try store.update(taskID: invalidID, title: "Should not exist")
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.count == 1)
         #expect(tasks.first?.title == "Task 1")
     }
@@ -124,12 +124,12 @@ struct TaskStoreTests {
     @Test("Delete task")
     func deleteTask() async throws {
         let store = makeTestStore()
-        let task1 = store.createTask(title: "Task 1")
-        let task2 = store.createTask(title: "Task 2")
+        let task1 = try store.createTask(title: "Task 1")
+        let task2 = try store.createTask(title: "Task 2")
 
-        store.delete(taskID: task1.id)
+        try store.delete(taskID: task1.id)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.count == 1)
         #expect(tasks.first?.id == task2.id)
     }
@@ -137,25 +137,25 @@ struct TaskStoreTests {
     @Test("Delete all tasks")
     func deleteAllTasks() async throws {
         let store = makeTestStore()
-        let task1 = store.createTask(title: "Task 1")
-        let task2 = store.createTask(title: "Task 2")
+        let task1 = try store.createTask(title: "Task 1")
+        let task2 = try store.createTask(title: "Task 2")
 
-        store.delete(taskID: task1.id)
-        store.delete(taskID: task2.id)
+        try store.delete(taskID: task1.id)
+        try store.delete(taskID: task2.id)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.isEmpty)
     }
 
     @Test("Delete with invalid ID does nothing")
     func deleteWithInvalidIDDoesNothing() async throws {
         let store = makeTestStore()
-        _ = store.createTask(title: "Task 1")
+        _ = try store.createTask(title: "Task 1")
         let invalidID = UUID()
 
-        store.delete(taskID: invalidID)
+        try store.delete(taskID: invalidID)
 
-        let tasks = store.fetchTasks()
+        let tasks = try store.fetchTasks()
         #expect(tasks.count == 1)
     }
 
@@ -164,10 +164,10 @@ struct TaskStoreTests {
     @Test("Task IDs persist across fetches")
     func taskIDsPersistAcrossFetches() async throws {
         let store = makeTestStore()
-        let task = store.createTask(title: "Test")
+        let task = try store.createTask(title: "Test")
         let originalID = task.id
 
-        let fetchedTasks = store.fetchTasks()
+        let fetchedTasks = try store.fetchTasks()
         let fetchedID = fetchedTasks.first?.id
 
         #expect(fetchedID == originalID)
@@ -177,9 +177,9 @@ struct TaskStoreTests {
     func createTaskIncrementsSortOrder() async throws {
         let store = makeTestStore()
 
-        let task1 = store.createTask(title: "Task 1")
-        let task2 = store.createTask(title: "Task 2")
-        let task3 = store.createTask(title: "Task 3")
+        let task1 = try store.createTask(title: "Task 1")
+        let task2 = try store.createTask(title: "Task 2")
+        let task3 = try store.createTask(title: "Task 3")
 
         #expect(task2.sortOrder > task1.sortOrder)
         #expect(task3.sortOrder > task2.sortOrder)