diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift b/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift index d4c11b41..0ce61153 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+QueryPagination.swift @@ -45,13 +45,16 @@ extension CloudKitService { /// (1-200, defaults to `defaultQueryLimit`) /// - desiredKeys: Optional array of field names to fetch /// - maxPages: Maximum number of pages to fetch before throwing - /// `CloudKitError.invalidResponse` (defaults to 1,000) + /// `CloudKitError.paginationLimitExceeded` (defaults to 1,000) /// - Returns: Array of all matching records across all pages - /// - Throws: CloudKitError if any page request fails + /// - Throws: `CloudKitError`. When `maxPages` is exceeded, throws + /// `.paginationLimitExceeded(maxPages:records:)` whose `records` + /// payload contains every record collected before the cap was hit, + /// so callers can resume or surface partial results. /// /// - Warning: Stops early if the server returns the same /// continuation marker with no new records (stuck-marker - /// scenario), or if the page count exceeds `maxPages`. + /// scenario). public func queryAllRecords( recordType: String, filters: [QueryFilter]? = nil, @@ -69,7 +72,7 @@ extension CloudKitService { guard pageCount < maxPages else { throw CloudKitError.paginationLimitExceeded( maxPages: maxPages, - recordsCollected: allRecords.count + records: allRecords ) } diff --git a/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift b/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift index 7ddbe01c..d7af0c32 100644 --- a/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/Service/Extensions/CloudKitService+SyncOperations.swift @@ -135,8 +135,12 @@ extension CloudKitService { /// (defaults to _defaultZone) /// - syncToken: Optional token from previous fetch (nil = initial fetch) /// - resultsLimit: Optional maximum records per request (1-200) + /// - maxPages: Maximum number of pages to fetch before throwing + /// `CloudKitError.paginationLimitExceeded` (defaults to 1,000) /// - Returns: Array of all changed records and final sync token - /// - Throws: CloudKitError if any fetch fails + /// - Throws: `CloudKitError`. When `maxPages` is exceeded, throws + /// `.paginationLimitExceeded(maxPages:records:)` whose `records` + /// payload contains every record collected before the cap was hit. /// /// Example: /// ```swift @@ -153,7 +157,7 @@ extension CloudKitService { /// with manual pagination for better memory control. /// - Warning: This method will stop early if the server repeatedly returns /// `moreComing: true` with no records and the same sync token - /// (stuck-token scenario), or if the page count exceeds 1000. + /// (stuck-token scenario). /// - Note: Makes sequential requests with no backoff or cooperative /// cancellation between pages. For fine-grained control, /// use `fetchRecordChanges(syncToken:)` directly. @@ -161,17 +165,20 @@ extension CloudKitService { zoneID: ZoneID? = nil, syncToken: String? = nil, resultsLimit: Int? = nil, + maxPages: Int = 1_000, database: Database = .public ) async throws(CloudKitError) -> (records: [RecordInfo], syncToken: String?) { var allRecords: [RecordInfo] = [] var currentToken = syncToken - var moreComing = true + var moreComing = false var pageCount = 0 - let maxPages = 1_000 - while moreComing { + repeat { guard pageCount < maxPages else { - throw CloudKitError.invalidResponse + throw CloudKitError.paginationLimitExceeded( + maxPages: maxPages, + records: allRecords + ) } do { @@ -187,6 +194,7 @@ extension CloudKitService { database: database ) + // Stuck-token detection if result.records.isEmpty && result.moreComing && result.syncToken == currentToken { break } @@ -199,7 +207,7 @@ extension CloudKitService { currentToken = result.syncToken moreComing = result.moreComing pageCount += 1 - } + } while moreComing return (allRecords, currentToken) } diff --git a/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift b/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift index c05c7c9e..ef57a1e4 100644 --- a/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift +++ b/Sources/MistKit/Service/ResponseProcessing/CloudKitError.swift @@ -44,7 +44,7 @@ public enum CloudKitError: LocalizedError, Sendable { case decodingError(DecodingError) case networkError(URLError) case unsupportedOperationType(String) - case paginationLimitExceeded(maxPages: Int, recordsCollected: Int) + case paginationLimitExceeded(maxPages: Int, records: [RecordInfo]) case missingCredentials(database: Database, reason: String) case invalidPrivateKey(path: String?, underlying: any Error) @@ -123,10 +123,10 @@ public enum CloudKitError: LocalizedError, Sendable { return message case .unsupportedOperationType(let type): return "Unsupported record operation type: \(type)" - case .paginationLimitExceeded(let maxPages, let recordsCollected): + case .paginationLimitExceeded(let maxPages, let records): return "CloudKit query exceeded pagination limit of \(maxPages) pages " - + "(collected \(recordsCollected) records)" + + "(collected \(records.count) records)" case .missingCredentials(let database, let reason): return "Missing credentials for database '\(database.rawValue)': \(reason)" diff --git a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+ErrorHandling.swift b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+ErrorHandling.swift index 53643789..4815a7c1 100644 --- a/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+ErrorHandling.swift +++ b/Tests/MistKitTests/Service/CloudKitServiceFetchChanges/CloudKitServiceTests.FetchChanges+ErrorHandling.swift @@ -73,6 +73,56 @@ extension CloudKitServiceTests.FetchChanges { } } + @Test("fetchAllRecordChanges() throws paginationLimitExceeded carrying collected records") + internal func fetchAllOverflowReturnsAccumulatedRecords() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // Three pages, each still indicating moreComing:true, so the loop would + // keep going. With maxPages:2 the third page triggers the cap and we + // expect the records from pages 1 and 2 to come back inside the error. + let provider = ResponseProvider( + defaultResponse: try .successfulFetchChangesResponse( + recordCount: 0, + moreComing: true, + syncToken: "default-token" + ) + ) + await provider.enqueue( + try .successfulFetchChangesResponse( + recordCount: 3, + moreComing: true, + syncToken: "token-1" + ), + for: "fetchRecordChanges" + ) + await provider.enqueue( + try .successfulFetchChangesResponse( + recordCount: 2, + moreComing: true, + syncToken: "token-2" + ), + for: "fetchRecordChanges" + ) + let service = try CloudKitServiceTests.makeService(provider: provider) + + do { + _ = try await service.fetchAllRecordChanges(maxPages: 2) + Issue.record("Expected paginationLimitExceeded to be thrown") + } catch CloudKitError.paginationLimitExceeded(let maxPages, let records) { + #expect(maxPages == 2) + #expect(records.count == 5) + #expect( + records.map(\.recordName) == [ + "record-0", "record-1", "record-2", + "record-0", "record-1", + ]) + } catch { + Issue.record("Expected paginationLimitExceeded, got \(error)") + } + } + @Test("fetchAllRecordChanges() propagates a mid-pagination network failure") internal func fetchAllPropagatesMidPaginationFailure() async throws { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { diff --git a/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+ErrorCases.swift b/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+ErrorCases.swift new file mode 100644 index 00000000..f7aed2a1 --- /dev/null +++ b/Tests/MistKitTests/Service/CloudKitServiceQueryPagination/CloudKitServiceTests.QueryPagination+ErrorCases.swift @@ -0,0 +1,71 @@ +// +// CloudKitServiceTests.QueryPagination+ErrorCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation +import Testing + +@testable import MistKit + +extension CloudKitServiceTests.QueryPagination { + @Suite("Error Cases") + internal struct ErrorCases { + @Test("queryAllRecords() throws paginationLimitExceeded carrying collected records") + internal func queryAllRecordsOverflowReturnsAccumulatedRecords() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await CloudKitServiceTests.QueryPagination.makePaginatedService( + pages: [ + (recordCount: 3, continuationMarker: "marker-1"), + (recordCount: 2, continuationMarker: "marker-2"), + (recordCount: 5, continuationMarker: "marker-3"), + ] + ) + + do { + _ = try await service.queryAllRecords( + recordType: "TestRecord", + maxPages: 2 + ) + Issue.record("Expected paginationLimitExceeded to be thrown") + } catch CloudKitError.paginationLimitExceeded(let maxPages, let records) { + #expect(maxPages == 2) + #expect(records.count == 5) + #expect( + records.map(\.recordName) == [ + "record-0", "record-1", "record-2", + "record-0", "record-1", + ]) + } catch { + Issue.record("Expected paginationLimitExceeded, got \(error)") + } + } + } +}