Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -69,7 +72,7 @@ extension CloudKitService {
guard pageCount < maxPages else {
throw CloudKitError.paginationLimitExceeded(
maxPages: maxPages,
recordsCollected: allRecords.count
records: allRecords
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -153,25 +157,28 @@ 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.
public func fetchAllRecordChanges(
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 {
Expand All @@ -187,6 +194,7 @@ extension CloudKitService {
database: database
)

// Stuck-token detection
if result.records.isEmpty && result.moreComing && result.syncToken == currentToken {
break
}
Expand All @@ -199,7 +207,7 @@ extension CloudKitService {
currentToken = result.syncToken
moreComing = result.moreComing
pageCount += 1
}
} while moreComing

return (allRecords, currentToken)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
}
Loading