Independent review of PR #218 at exact head 405575cf4515cc38d7dd2ec75d2aeb47b8ea0129 against 1401d63de7f8c206d2fc216cc999ec6245b61711.
949e56c..47f3601 and 1401d63..405575c have identical non-changelog patches. The sole range difference is placing T-1607 immediately after the now-merged T-1608 changelog entry.30826693239 succeeded on this exact head; local make lint, make test-quick, make build-macos, and exact-range git diff --check passed.Ready to push/merge
No actionable findings. The non-CHANGELOG.md patch is byte-for-byte identical to the pre-rebase range. T-1608 is correctly present in the rebased base changelog, all six EntityQuery fetch failures propagate through their existing throwing contract, and requested local validation is clean.
c885852 T-1607: Surface App Entity query fetch failures 405575c T-1607: Strengthen entity query regression coverage What changed: when Shortcuts asks Transit for projects, tasks, or a newly created task, Transit now reports a database-read problem instead of acting as if the database were empty.
Why it matters: an empty picker can be a real empty list, but it can also be an unreadable store. Callers can now tell those cases apart and retry or show an error.
Completeness assessment: all six affected resolver methods are covered by deterministic failure tests; normal empty results still work.
The change adds narrow throwing fetch seams: ProjectFetching for project lists and shared TaskFetching for task lists. Each static helper and its async throws EntityQuery entry point forwards fetch errors. Identifier parsing remains before I/O, while conversion errors for individual partial CloudKit records remain locally skipped.
Sorting and caps are intentionally unchanged: task entities sort by status-change time, creation results by creation time, and both preserve their existing ten-item prefix before conversion.
The rebase was validated with git range-diff and direct non-changelog unified-diff equality. The only semantic patch is the original T-1607 implementation; moving TaskFetching out of QueryTasksIntent preserves its existing MCP/JSON intent consumers while making it available to the visual EntityQuery tests. The top-level EntityQuery methods already expose async throws, so error propagation adds no API surface and avoids an error-to-empty-array lossy conversion.
T-2081 is intentionally not folded in: switching to conversion-before-limit would alter CloudKit-partial-import selection semantics and is separately tracked.
Transit/Transit/Intents/Shared/Entities/ProjectEntityQuery.swift
Why it matters. Prevents storage failures from being reported to Shortcuts as a valid empty result.
What to look at. ProjectEntityQuery and TaskEntityQuery resolver entry points
Transit/Transit/Intents/Shared/TaskFetching.swift
Why it matters. One testable abstraction exercises all four task-backed failure paths without changing their normal selection behavior.
What to look at. TaskFetching; TaskEntityQuery; TaskCreationResultQuery
Transit/TransitTests/EntityQueryFetchFailureTests.swift
Why it matters. Proves each resolver throws on storage failure and preserves valid empty/invalid identifier behavior.
What to look at. EntityQueryFetchFailureTests; CHANGELOG.md
Both task suggestion queries still sort and take their first ten records before skipping unrenderable records. Changing that selection rule is T-2081, not part of the storage-error fix.
The current App Intents entry points already throw. Returning the fetch failure directly retains the intended contract and avoids treating unreadable storage as a normal empty list.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c58759d..1089477 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -7,10 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] - T-1803: `UpdateStatusIntent` now includes a missing requested `displayId` in its `TASK_NOT_FOUND` hint (`No task with displayId N`), while missing UUIDs retain the existing generic lookup hint and malformed identifiers, duplicate IDs, status validation, and atomic mutation behavior remain unchanged. ### Fixed - T-1608: MCP `query_tasks` now surfaces a tool error rather than a false successful `[]` when either unscoped milestone-name resolution (`Failed to fetch milestones: <error>`) or `milestoneDisplayId` resolution (`Failed to look up milestone: <error>`) cannot read storage. It validates every filter shape before milestone resolution, so malformed filters return their validation error before any milestone lookup outcome. Valid no-match arrays, cross-project same-name aggregation, project-scoped milestone lookup and T-1938 ambiguity handling, response shape, and later task-fetch errors are unchanged; deterministic MCP coverage asserts both exact errors and the legitimate no-match response.+- T-1607: Visual App Entity project/task queries and task-creation result resolution now rethrow storage fetch failures through their existing `async throws` contract instead of returning successful empty arrays. Deterministic regression coverage spans `entities(for:)` and `suggestedEntities()` for all three query types, while valid empty/invalid identifier results, ordering, suggestion limits, and partial relationship skipping remain unchanged. - Display-ID collision guards now build candidate-blocking sets from committed task/milestone IDs fetched through a fresh transient `ModelContext`, unioned with live/pending registered main-context values and allocator-issued IDs (T-1939). The shared guard applies to task and milestone creation, provisional promotion, and duplicate repair, so a stale registered bystander can no longer hide a peer-synced committed ID. Regression coverage commits the peer value through an independent context, proves the receiving bystander remains clean and unrefreshed, and separately verifies unsaved IDs remain blocked. Either store view failing still fails closed; allocation serialization, cancellation, maintenance stale-loser probes, and selective save recovery are unchanged. - MCP `create_task` and JSON `CreateTaskIntent` now reject present non-object `metadata` values (string, array, number, boolean, or null) with field-specific errors before task insertion, while omitted metadata and T-723's tolerant handling of values inside valid objects remain unchanged (T-1991). - T-2036: `QueryTasksIntent` now treats empty `completionDate` and `lastStatusChangeDate` objects as valid no-op filters, including when both are present. Raw-object emptiness is preserved before Codable decoding, while nested nulls, malformed non-empty objects, strict dates, reversed ranges, open bounds, and relative precedence remain covered by regression tests. - MCP request validation now rejects explicit JSON `id: null` as JSON-RPC `-32600 Invalid Request` under protocol `2025-03-26`, while preserving omitted-ID notifications and valid string/integer IDs across single and batch requests (T-1863). - MCP `tools/call` now returns JSON-RPC `-32602 Invalid Params` for unknown tool names and maintenance tools disabled in Settings (T-1883), while retaining useful messages, omitting disabled tools from `tools/list`, and preserving `-32601 Method Not Found` for unsupported top-level methods. Handler and HTTP-route regressions cover the classification boundaries.
diff --git a/Transit/Transit/Intents/QueryTasksIntent.swift b/Transit/Transit/Intents/QueryTasksIntent.swiftindex fc94ae1..23f3dec 100644--- a/Transit/Transit/Intents/QueryTasksIntent.swift+++ b/Transit/Transit/Intents/QueryTasksIntent.swift@@ -1,17 +1,8 @@ import AppIntents import Foundation -/// Narrow seam over the task service's full-table fetch so query intents can be-/// tested against a failing storage layer. `TaskService` conforms directly. [T-1566]-@MainActor-protocol TaskFetching {- func fetchAllTasks() throws -> [TransitTask]-}--extension TaskService: TaskFetching {}- // swiftlint:disable type_body_length /// Queries tasks with optional filters via JSON input. Exposed as "Transit: Query Tasks" /// in Shortcuts. [req 18.1-18.5] struct QueryTasksIntent: AppIntent {
diff --git a/Transit/Transit/Intents/Shared/Entities/ProjectEntityQuery.swift b/Transit/Transit/Intents/Shared/Entities/ProjectEntityQuery.swiftindex d7dbffe..232a2e0 100644--- a/Transit/Transit/Intents/Shared/Entities/ProjectEntityQuery.swift+++ b/Transit/Transit/Intents/Shared/Entities/ProjectEntityQuery.swift@@ -1,24 +1,37 @@ import AppIntents import Foundation +/// Narrow seam over project-list storage reads so EntityQuery tests can+/// deterministically distinguish a failed fetch from an empty database.+@MainActor+protocol ProjectFetching {+ func fetchAllProjects(sortedByName: Bool) throws -> [Project]+}++extension ProjectService: ProjectFetching {}+ struct ProjectEntityQuery: EntityQuery { @Dependency private var projectService: ProjectService @MainActor func entities(for identifiers: [String]) async throws -> [ProjectEntity] {- Self.entities(for: identifiers, projectService: projectService)+ try Self.entities(for: identifiers, projectService: projectService) } @MainActor func suggestedEntities() async throws -> [ProjectEntity] {- Self.suggestedEntities(projectService: projectService)+ try Self.suggestedEntities(projectService: projectService) } @MainActor- static func entities(for identifiers: [String], projectService: ProjectService) -> [ProjectEntity] {+ static func entities(+ for identifiers: [String],+ projectService: ProjectService,+ projectFetcher: (any ProjectFetching)? = nil+ ) throws -> [ProjectEntity] { if identifiers.isEmpty { return [] } var wantedIDs = Set<UUID>()@@ -31,20 +44,23 @@ struct ProjectEntityQuery: EntityQuery { if wantedIDs.isEmpty { return [] } - let projects = (try? projectService.fetchAllProjects()) ?? []+ let projects = try (projectFetcher ?? projectService).fetchAllProjects(sortedByName: false) return projects.compactMap { project in guard wantedIDs.contains(project.id) else { return nil } return ProjectEntity.from(project) } } @MainActor- static func suggestedEntities(projectService: ProjectService) -> [ProjectEntity] {- let projects = (try? projectService.fetchAllProjects(sortedByName: true)) ?? []+ static func suggestedEntities(+ projectService: ProjectService,+ projectFetcher: (any ProjectFetching)? = nil+ ) throws -> [ProjectEntity] {+ let projects = try (projectFetcher ?? projectService).fetchAllProjects(sortedByName: true) if projects.isEmpty { return [] } var entities: [ProjectEntity] = []
diff --git a/Transit/Transit/Intents/Shared/Entities/TaskEntityQuery.swift b/Transit/Transit/Intents/Shared/Entities/TaskEntityQuery.swiftindex 8703f32..ae57079 100644--- a/Transit/Transit/Intents/Shared/Entities/TaskEntityQuery.swift+++ b/Transit/Transit/Intents/Shared/Entities/TaskEntityQuery.swift@@ -5,20 +5,24 @@ struct TaskEntityQuery: EntityQuery { @Dependency private var taskService: TaskService @MainActor func entities(for identifiers: [String]) async throws -> [TaskEntity] {- Self.entities(for: identifiers, taskService: taskService)+ try Self.entities(for: identifiers, taskService: taskService) } @MainActor func suggestedEntities() async throws -> [TaskEntity] {- Self.suggestedEntities(taskService: taskService)+ try Self.suggestedEntities(taskService: taskService) } @MainActor- static func entities(for identifiers: [String], taskService: TaskService) -> [TaskEntity] {+ static func entities(+ for identifiers: [String],+ taskService: TaskService,+ taskFetcher: (any TaskFetching)? = nil+ ) throws -> [TaskEntity] { if identifiers.isEmpty { return [] } var wantedIDs = Set<UUID>()@@ -31,18 +35,21 @@ struct TaskEntityQuery: EntityQuery { if wantedIDs.isEmpty { return [] } - let tasks = (try? taskService.fetchAllTasks()) ?? []+ let tasks = try (taskFetcher ?? taskService).fetchAllTasks() let matchingTasks = tasks.filter { wantedIDs.contains($0.id) } return entities(from: matchingTasks) } @MainActor- static func suggestedEntities(taskService: TaskService) -> [TaskEntity] {- let tasks = (try? taskService.fetchAllTasks()) ?? []+ static func suggestedEntities(+ taskService: TaskService,+ taskFetcher: (any TaskFetching)? = nil+ ) throws -> [TaskEntity] {+ let tasks = try (taskFetcher ?? taskService).fetchAllTasks() if tasks.isEmpty { return [] } let sorted = tasks.sorted { $0.lastStatusChangeDate > $1.lastStatusChangeDate }
diff --git a/Transit/Transit/Intents/Shared/Results/TaskCreationResult.swift b/Transit/Transit/Intents/Shared/Results/TaskCreationResult.swiftindex 6e571d2..3037407 100644--- a/Transit/Transit/Intents/Shared/Results/TaskCreationResult.swift+++ b/Transit/Transit/Intents/Shared/Results/TaskCreationResult.swift@@ -54,20 +54,24 @@ struct TaskCreationResultQuery: EntityQuery { @Dependency private var taskService: TaskService @MainActor func entities(for identifiers: [String]) async throws -> [TaskCreationResult] {- Self.entities(for: identifiers, taskService: taskService)+ try Self.entities(for: identifiers, taskService: taskService) } @MainActor func suggestedEntities() async throws -> [TaskCreationResult] {- Self.suggestedEntities(taskService: taskService)+ try Self.suggestedEntities(taskService: taskService) } @MainActor- static func entities(for identifiers: [String], taskService: TaskService) -> [TaskCreationResult] {+ static func entities(+ for identifiers: [String],+ taskService: TaskService,+ taskFetcher: (any TaskFetching)? = nil+ ) throws -> [TaskCreationResult] { if identifiers.isEmpty { return [] } var wantedIDs = Set<UUID>()@@ -80,11 +84,11 @@ struct TaskCreationResultQuery: EntityQuery { if wantedIDs.isEmpty { return [] } - let tasks = (try? taskService.fetchAllTasks()) ?? []+ let tasks = try (taskFetcher ?? taskService).fetchAllTasks() var results: [TaskCreationResult] = [] results.reserveCapacity(min(tasks.count, wantedIDs.count)) for task in tasks { guard wantedIDs.contains(task.id) else { continue }@@ -95,12 +99,15 @@ struct TaskCreationResultQuery: EntityQuery { return results } @MainActor- static func suggestedEntities(taskService: TaskService) -> [TaskCreationResult] {- let tasks = (try? taskService.fetchAllTasks()) ?? []+ static func suggestedEntities(+ taskService: TaskService,+ taskFetcher: (any TaskFetching)? = nil+ ) throws -> [TaskCreationResult] {+ let tasks = try (taskFetcher ?? taskService).fetchAllTasks() if tasks.isEmpty { return [] } let sorted = tasks.sorted { $0.creationDate > $1.creationDate }
diff --git a/Transit/Transit/Intents/Shared/TaskFetching.swift b/Transit/Transit/Intents/Shared/TaskFetching.swiftnew file mode 100644index 0000000..09dd349--- /dev/null+++ b/Transit/Transit/Intents/Shared/TaskFetching.swift@@ -0,0 +1,10 @@+import Foundation++/// Narrow seam over the task service's full-table fetch so intent and App Entity+/// query paths can be tested against a failing storage layer.+@MainActor+protocol TaskFetching {+ func fetchAllTasks() throws -> [TransitTask]+}++extension TaskService: TaskFetching {}
diff --git a/Transit/TransitTests/AddTaskIntentIntegrationTests.swift b/Transit/TransitTests/AddTaskIntentIntegrationTests.swiftindex 6ab74a9..92a897d 100644--- a/Transit/TransitTests/AddTaskIntentIntegrationTests.swift+++ b/Transit/TransitTests/AddTaskIntentIntegrationTests.swift@@ -62,11 +62,11 @@ struct AddTaskIntentIntegrationTests { type: .research, project: ProjectEntity.from(project), services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project) ) - let entities = TaskEntityQuery.entities(for: [result.taskId.uuidString], taskService: svc.task)+ let entities = try TaskEntityQuery.entities(for: [result.taskId.uuidString], taskService: svc.task) #expect(entities.count == 1) #expect(entities[0].name == "Queryable Task") #expect(entities[0].status == "idea") }
diff --git a/Transit/TransitTests/EntityQueryFetchFailureTests.swift b/Transit/TransitTests/EntityQueryFetchFailureTests.swiftnew file mode 100644index 0000000..8a2c8ac--- /dev/null+++ b/Transit/TransitTests/EntityQueryFetchFailureTests.swift@@ -0,0 +1,189 @@+import Foundation+import SwiftData+import Testing+@testable import Transit++/// Regression tests for T-1607: visual App Entity queries must distinguish a+/// storage fetch failure from a valid empty result.+@MainActor @Suite(.serialized)+struct EntityQueryFetchFailureTests {+ private struct FetchFailure: Swift.Error {}++ private struct FailingProjectFetcher: ProjectFetching {+ func fetchAllProjects(sortedByName: Bool) throws -> [Project] {+ throw FetchFailure()+ }+ }++ private struct FailingTaskFetcher: TaskFetching {+ func fetchAllTasks() throws -> [TransitTask] {+ throw FetchFailure()+ }+ }++ private struct Services {+ let testContainer: TestModelContainer+ let project: ProjectService+ let task: TaskService+ }++ private func makeServices() throws -> Services {+ let testContainer = try TestModelContainer()+ let allocator = DisplayIDAllocator(store: InMemoryCounterStore())+ return Services(+ testContainer: testContainer,+ project: ProjectService(modelContext: testContainer.context),+ task: TaskService(modelContext: testContainer.context, displayIDAllocator: allocator)+ )+ }++ @Test func projectEntitiesForIdentifiersPropagatesFetchFailure() throws {+ let services = try makeServices()++ #expect(throws: FetchFailure.self) {+ _ = try ProjectEntityQuery.entities(+ for: [UUID().uuidString],+ projectService: services.project,+ projectFetcher: FailingProjectFetcher()+ )+ }+ }++ @Test func projectSuggestedEntitiesPropagatesFetchFailure() throws {+ let services = try makeServices()++ #expect(throws: FetchFailure.self) {+ _ = try ProjectEntityQuery.suggestedEntities(+ projectService: services.project,+ projectFetcher: FailingProjectFetcher()+ )+ }+ }++ @Test func taskEntitiesForIdentifiersPropagatesFetchFailure() throws {+ let services = try makeServices()++ #expect(throws: FetchFailure.self) {+ _ = try TaskEntityQuery.entities(+ for: [UUID().uuidString],+ taskService: services.task,+ taskFetcher: FailingTaskFetcher()+ )+ }+ }++ @Test func taskSuggestedEntitiesPropagatesFetchFailure() throws {+ let services = try makeServices()++ #expect(throws: FetchFailure.self) {+ _ = try TaskEntityQuery.suggestedEntities(+ taskService: services.task,+ taskFetcher: FailingTaskFetcher()+ )+ }+ }++ @Test func taskCreationResultEntitiesForIdentifiersPropagatesFetchFailure() throws {+ let services = try makeServices()++ #expect(throws: FetchFailure.self) {+ _ = try TaskCreationResultQuery.entities(+ for: [UUID().uuidString],+ taskService: services.task,+ taskFetcher: FailingTaskFetcher()+ )+ }+ }++ @Test func taskCreationResultSuggestedEntitiesPropagatesFetchFailure() throws {+ let services = try makeServices()++ #expect(throws: FetchFailure.self) {+ _ = try TaskCreationResultQuery.suggestedEntities(+ taskService: services.task,+ taskFetcher: FailingTaskFetcher()+ )+ }+ }++ @Test func projectEntitiesForEmptyOrInvalidIdentifiersDoNotFetch() throws {+ let services = try makeServices()+ let identifierLists: [[String]] = [[], ["not-a-uuid"]]++ for identifiers in identifierLists {+ let entities = try ProjectEntityQuery.entities(+ for: identifiers,+ projectService: services.project,+ projectFetcher: FailingProjectFetcher()+ )+ #expect(entities.isEmpty)+ }+ }++ @Test func taskEntitiesForEmptyOrInvalidIdentifiersDoNotFetch() throws {+ let services = try makeServices()+ let identifierLists: [[String]] = [[], ["not-a-uuid"]]++ for identifiers in identifierLists {+ let entities = try TaskEntityQuery.entities(+ for: identifiers,+ taskService: services.task,+ taskFetcher: FailingTaskFetcher()+ )+ #expect(entities.isEmpty)+ }+ }++ @Test func taskCreationResultEntitiesForEmptyOrInvalidIdentifiersDoNotFetch() throws {+ let services = try makeServices()+ let identifierLists: [[String]] = [[], ["not-a-uuid"]]++ for identifiers in identifierLists {+ let entities = try TaskCreationResultQuery.entities(+ for: identifiers,+ taskService: services.task,+ taskFetcher: FailingTaskFetcher()+ )+ #expect(entities.isEmpty)+ }+ }++ @Test func projectQueryMethodsReturnEmptyForValidEmptyStore() throws {+ let services = try makeServices()++ let resolved = try ProjectEntityQuery.entities(+ for: [UUID().uuidString],+ projectService: services.project+ )+ let suggested = try ProjectEntityQuery.suggestedEntities(projectService: services.project)++ #expect(resolved.isEmpty)+ #expect(suggested.isEmpty)+ }++ @Test func taskEntityQueryMethodsReturnEmptyForValidEmptyStore() throws {+ let services = try makeServices()++ let resolved = try TaskEntityQuery.entities(+ for: [UUID().uuidString],+ taskService: services.task+ )+ let suggested = try TaskEntityQuery.suggestedEntities(taskService: services.task)++ #expect(resolved.isEmpty)+ #expect(suggested.isEmpty)+ }++ @Test func taskCreationResultQueryMethodsReturnEmptyForValidEmptyStore() throws {+ let services = try makeServices()++ let resolved = try TaskCreationResultQuery.entities(+ for: [UUID().uuidString],+ taskService: services.task+ )+ let suggested = try TaskCreationResultQuery.suggestedEntities(taskService: services.task)++ #expect(resolved.isEmpty)+ #expect(suggested.isEmpty)+ }+}
diff --git a/Transit/TransitTests/ProjectEntityTests.swift b/Transit/TransitTests/ProjectEntityTests.swiftindex 1b5548b..30c86f1 100644--- a/Transit/TransitTests/ProjectEntityTests.swift+++ b/Transit/TransitTests/ProjectEntityTests.swift@@ -37,20 +37,20 @@ struct ProjectEntityTests { let alpha = Project(name: "Alpha", description: "desc", gitRepo: nil, colorHex: "#111111") let beta = Project(name: "Beta", description: "desc", gitRepo: nil, colorHex: "#222222") env.context.insert(alpha) env.context.insert(beta) - let entities = ProjectEntityQuery.entities(+ let entities = try ProjectEntityQuery.entities( for: [alpha.id.uuidString, UUID().uuidString, "not-a-uuid"], projectService: env.projectService ) #expect(entities.count == 1) #expect(entities.first?.projectId == alpha.id) } @Test func suggestedEntitiesReturnsEmptyArrayWhenNoProjects() throws { let env = try makeEnv()- let entities = ProjectEntityQuery.suggestedEntities(projectService: env.projectService)+ let entities = try ProjectEntityQuery.suggestedEntities(projectService: env.projectService) #expect(entities.isEmpty) } }
diff --git a/Transit/TransitTests/TaskEntityQueryTests.swift b/Transit/TransitTests/TaskEntityQueryTests.swiftindex 2342ee0..ca025b1 100644--- a/Transit/TransitTests/TaskEntityQueryTests.swift+++ b/Transit/TransitTests/TaskEntityQueryTests.swift@@ -47,11 +47,11 @@ struct TaskEntityQueryTests { let env = try makeEnv() let project = makeProject(in: env.context) let first = makeTask(in: env.context, project: project, name: "First", displayID: 1, lastChange: .now) _ = makeTask(in: env.context, project: project, name: "Second", displayID: 2, lastChange: .now) - let entities = TaskEntityQuery.entities(+ let entities = try TaskEntityQuery.entities( for: [first.id.uuidString, UUID().uuidString, "invalid"], taskService: env.taskService ) #expect(entities.count == 1)@@ -62,11 +62,11 @@ struct TaskEntityQueryTests { let env = try makeEnv() let project = makeProject(in: env.context) let task = makeTask(in: env.context, project: project, name: "Synced", displayID: 10, lastChange: .now) task.project = nil - let entities = TaskEntityQuery.entities(for: [task.id.uuidString], taskService: env.taskService)+ let entities = try TaskEntityQuery.entities(for: [task.id.uuidString], taskService: env.taskService) #expect(entities.isEmpty) } @Test func suggestedEntitiesReturnsMostRecentTen() throws { let env = try makeEnv()@@ -81,11 +81,11 @@ struct TaskEntityQueryTests { displayID: index + 1, lastChange: base.addingTimeInterval(TimeInterval(index)) ) } - let entities = TaskEntityQuery.suggestedEntities(taskService: env.taskService)+ let entities = try TaskEntityQuery.suggestedEntities(taskService: env.taskService) #expect(entities.count == 10) #expect(entities.first?.name == "Task 11") #expect(entities.last?.name == "Task 2") }
diff --git a/specs/bugfixes/app-entity-query-fetch-failures/report.md b/specs/bugfixes/app-entity-query-fetch-failures/report.mdnew file mode 100644index 0000000..32b1dd1--- /dev/null+++ b/specs/bugfixes/app-entity-query-fetch-failures/report.md@@ -0,0 +1,106 @@+# Bugfix Report: App Entity Queries Hide Fetch Failures++**Date:** 2026-08-03+**Status:** Fixed++## Description of the Issue++Visual Shortcuts `AppEntity` queries returned a successful empty array when their SwiftData full-table fetch failed. This made a storage problem indistinguishable from an empty project/task store or an unresolved created task.++**Reproduction steps:**+1. Invoke any affected `entities(for:)` or `suggestedEntities()` resolver with a deterministic failing project/task fetcher.+2. Observe that the resolver catches the fetch error with `try?`.+3. Observe that it returns `[]` instead of propagating the failure through the existing `async throws` App Intents API.++**Impact:** Shortcuts pickers and task-creation result resolution could silently display no data during a storage failure, masking an actionable failure as a valid empty result.++## Investigation Summary++### Phase 1: Initial overview++Expected behavior is for failures from the project/task storage fetch to surface through the existing throwing EntityQuery entry points. Actual behavior coalesced every error into `[]`. The problem occurs on each invocation of the affected resolver when SwiftData cannot complete the full-table fetch.++### Phase 2: Systematic inspection++- **Error handling:** `ProjectEntityQuery.entities(for:)` and `suggestedEntities()` used `(try? projectService.fetchAllProjects(...)) ?? []`.+- **Error handling:** `TaskEntityQuery.entities(for:)` and `suggestedEntities()` used `(try? taskService.fetchAllTasks()) ?? []`.+- **Error handling:** `TaskCreationResultQuery.entities(for:)` and `suggestedEntities()` used the same task-fetch suppression.+- **Boundary behavior to retain:** Empty or wholly invalid identifier arrays return before fetching; task entity/result conversion intentionally skips records missing their project relationship.+- **Ordering and limits to retain:** Project suggestions retain name ordering; task suggestions retain their existing date ordering and ten-item limit.++A focused macOS test suite added six deterministic failure cases—one for each resolver entry point—and all six failed before the fix because no error was thrown.++### Phase 3: Root cause analysis++1. Why did a storage failure look like no data? The fetch errors were converted to optional `nil` with `try?`.+2. Why did `nil` become no data? Each query coalesced it to `[]`.+3. Why was that misleading? `[]` is also the valid result for an empty store, unmatched identifiers, and intentionally skipped partial records.+4. Why could the framework have reported the error? The conforming `EntityQuery` methods already use `async throws`.+5. **Root cause:** Resolver helpers suppressed the only storage operation that can distinguish unreadable storage from valid empty data, despite their callers exposing a throwing App Intents contract.++**Defect type:** Silent error handling / incorrect error recovery.++**Assumptions validated:** The query helpers are the only callers of their full-table resolver paths; `FindTasksIntent` uses the separate `entities(from:)` conversion helper and is unaffected. T-2081 prefix-before-filtering behavior is unrelated and remains out of scope.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Intents/Shared/Entities/ProjectEntityQuery.swift` — added a narrow `ProjectFetching` test seam; both resolvers and their existing `async throws` entry points now rethrow full-table fetch failures.+- `Transit/Transit/Intents/Shared/Entities/TaskEntityQuery.swift` — reused the existing `TaskFetching` seam and rethrows fetch failures without changing filtering, ordering, limits, or partial-entity skipping.+- `Transit/Transit/Intents/Shared/Results/TaskCreationResult.swift` — rethrows fetch failures while retaining created-result conversion and missing-project skipping.+- `Transit/TransitTests/EntityQueryFetchFailureTests.swift` — adds deterministic failure coverage for all six resolver paths.++**Approach rationale:** The App Intents-facing methods already promised `async throws`, so forwarding the original storage error is the smallest correction. Identifier validation remains before the fetch and conversion errors remain intentionally local to individual records.++**Alternatives considered:**+- Returning a typed error entity or converting failures to an empty result — rejected because either changes the existing App Intents contract or repeats the ambiguity that caused the bug.+- Changing T-2081's prefix-before-filtering behavior — excluded as unrelated query-selection scope.++## Regression Test++**Test file:** `Transit/TransitTests/EntityQueryFetchFailureTests.swift`++**Test names:**+- `projectEntitiesForIdentifiersPropagatesFetchFailure`+- `projectSuggestedEntitiesPropagatesFetchFailure`+- `taskEntitiesForIdentifiersPropagatesFetchFailure`+- `taskSuggestedEntitiesPropagatesFetchFailure`+- `taskCreationResultEntitiesForIdentifiersPropagatesFetchFailure`+- `taskCreationResultSuggestedEntitiesPropagatesFetchFailure`++**What it verifies:** Each affected resolver rethrows a deterministic storage fetch error rather than returning a successful empty result.++**Red run command:** `xcodebuild test -project Transit/Transit.xcodeproj -scheme Transit -destination 'platform=macOS' -only-testing:TransitTests/EntityQueryFetchFailureTests`++## Affected Files++| File | Change |+|---|---|+| `Transit/Transit/Intents/Shared/Entities/ProjectEntityQuery.swift` | Propagate project fetch failures through the query contract. |+| `Transit/Transit/Intents/Shared/Entities/TaskEntityQuery.swift` | Propagate task fetch failures through the query contract. |+| `Transit/Transit/Intents/Shared/Results/TaskCreationResult.swift` | Propagate task fetch failures when resolving created-task results. |+| `Transit/TransitTests/EntityQueryFetchFailureTests.swift` | Six deterministic regression tests. |++## Verification++**Automated:**+- [x] Regression test fails before the fix (six expected failure-path tests)+- [x] Focused regression suite passes: `TransitTests/EntityQueryFetchFailureTests`+- [x] macOS unit suite passes: `make test-quick`+- [x] Linter/ownership guard passes: `make lint`+- [x] macOS production build passes: `make build-macos`+- [ ] Full iOS suite passes — `make test` exceeded the 10-minute command cap after compiling and running the relevant tests; the new `EntityQueryFetchFailureTests` passed during that run.+- [ ] UI suite passes — `make test-ui` reproducibly reports unrelated failures in `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`, then exceeds the command cap during cleanup. This fix does not modify those UI flows.++**Manual verification:** Not required; the regressions invoke the same EntityQuery resolver code used by Shortcuts.++## Prevention++- Never use `(try? fetch(...)) ?? []` where callers must distinguish an unreadable store from a valid empty result.+- Give query helpers an injectable throwing fetch seam so failure behavior stays deterministic under test.++## Related++- Transit ticket T-1607+- T-1566 (equivalent JSON query-intent failure handling)+- T-2081 (prefix-before-filtering scope explicitly excluded)
Reused rigorous prior full iOS run evidence because this head differs only by rebase/changelog placement after non-changelog semantic equivalence was proven: 1,224 passed; the only failures were the established unrelated UI baseline failures TransitUITests.testClearAll, TransitUITests.testEditViewPreservesTaskMilestone, and DataMaintenanceUITests.testDataMaintenanceGoldenPath.
A new exact-head <!-- claude-local-review --> verdict was posted to PR #218. GitHub reports zero unresolved diff review threads.