diff --git a/AGENTS.md b/AGENTS.md index 1fbf5c1a..c3df38ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,12 @@ MistKit/ | `CloudKitService+Classification.swift` | operation classification (batch sync result tracking) | | `CloudKitService+ErrorHandling.swift` | error mapping helpers | +**Zone selection on queries (issue #146):** `queryRecords` previously hard-coded `zoneID: .init(zoneName: "_defaultZone")` into the `records/query` body, making custom and shared zones unqueryable. The query path now takes an optional `zoneID: ZoneID? = nil`, threaded through the `Query` primitive, `queryAllRecords` (forwarded on every page), and `fetchExistingRecordNames`. This mirrors the existing `modifyRecords(_:atomic:zoneID:…)` parameter, and uses `ZoneID` rather than a bare `zoneName` string so shared zones can carry `ownerName`. + +`nil` means **omit the `zoneID` key entirely** and let CloudKit resolve the database's default zone — it is not a silent policy default in the sense of `.claude/memory/feedback_no_silent_policy_defaults.md` (no credential or attribution semantics ride on it), and it matches the `zoneID`/`desiredKeys`/`numbersAsStrings` request-option convention already used by `modifyRecords`. The `database:` parameter deliberately still has **no** default. + +`RecordManaging` declares only `queryAllRecords(recordType:)` (`Sources/MistKit/RecordManagement/RecordManaging.swift`) and stays zone-unaware — callers needing a specific zone should call `CloudKitService` directly. + **Sync/Change Operations:** - `fetchRecordChanges(recordType:syncToken:)` → `/records/changes` — returns `RecordChangesResult` with `records`, `syncToken`, `moreComing` - `fetchAllRecordChanges(recordType:syncToken:)` — convenience wrapper that auto-paginates using `moreComing` diff --git a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift index 571186d9..88597b35 100644 --- a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift +++ b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift @@ -27,12 +27,19 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import Foundation internal import MistDemoKit @main internal enum MistDemo { @MainActor - internal static func main() async throws { - try await MistDemoRunner.run() + internal static func main() async { + do { + try await MistDemoRunner.run() + } catch { + // PhasedIntegrationTest prints a copy-friendly message before rethrowing; + // exit cleanly instead of trapping on an uncaught top-level throw. + exit(1) + } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index a27a5553..8309dcc5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -48,6 +48,8 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { OPTIONS: --record-type Record type (default: Note) + --zone Zone to query (default: _defaultZone) + --zone-owner Owner record name for shared zones --filter Filter: field:operator:value --sort Sort (asc/desc) --limit Max records (1-200) @@ -71,8 +73,6 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { let client = try MistKitClientFactory.create(for: config.base) // Build filters - // NOTE: Zone, offset, and continuation marker support require - // enhancements to CloudKitService.queryRecords method (GitHub issues #145, #146) let filters: [QueryFilter] = config.filters.isEmpty ? [] @@ -82,10 +82,13 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { filters: filters, sortBy: [] ) + let zoneID = ZoneID(zoneName: config.zone, ownerName: config.zoneOwner) let result = try await client.queryRecords( query, limit: config.limit, desiredKeys: config.fields, + continuationMarker: config.continuationMarker, + zoneID: zoneID, zoneWide: config.zoneWide, numbersAsStrings: config.numbersAsStrings, database: config.base.database diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift index 6d51e3c9..3a0fc3f9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift @@ -40,6 +40,7 @@ extension QueryConfig { internal struct ParsedOptions { internal let zone: String + internal let zoneOwner: String? internal let recordType: String internal let filters: [String] internal let sort: (field: String, order: SortOrder)? @@ -54,6 +55,9 @@ extension QueryConfig { forKey: MistDemoConstants.ConfigKeys.zone, default: MistDemoConstants.Defaults.zone ) ?? MistDemoConstants.Defaults.zone + let zoneOwner = configReader.string( + forKey: MistDemoConstants.ConfigKeys.zoneOwner + ) let recordType = configReader.string( forKey: MistDemoConstants.ConfigKeys.recordType, @@ -73,6 +77,7 @@ extension QueryConfig { return ParsedOptions( zone: zone, + zoneOwner: zoneOwner, recordType: recordType, filters: filters, sort: sort, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift index 02080c9a..6dc91beb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift @@ -42,6 +42,8 @@ public struct QueryConfig: Sendable, ConfigurationParseable { public let base: MistDemoConfig /// The CloudKit zone name. public let zone: String + /// The optional zone owner (ownerName for shared zones). + public let zoneOwner: String? /// The CloudKit record type. public let recordType: String /// The filter expressions. @@ -67,6 +69,7 @@ public struct QueryConfig: Sendable, ConfigurationParseable { public init( base: MistDemoConfig, zone: String = "_defaultZone", + zoneOwner: String? = nil, recordType: String = "Note", filters: [String] = [], sort: (field: String, order: SortOrder)? = nil, @@ -80,6 +83,7 @@ public struct QueryConfig: Sendable, ConfigurationParseable { ) { self.base = base self.zone = zone + self.zoneOwner = zoneOwner self.recordType = recordType self.filters = filters self.sort = sort @@ -113,6 +117,7 @@ public struct QueryConfig: Sendable, ConfigurationParseable { self.init( base: baseConfig, zone: parsed.zone, + zoneOwner: parsed.zoneOwner, recordType: parsed.recordType, filters: parsed.filters, sort: parsed.sort, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift index 40905b16..2b8d01bb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift @@ -51,6 +51,8 @@ public enum MistDemoConstants { public static let recordName = "record.name" /// Zone configuration key. public static let zone = "zone" + /// Zone owner configuration key (ownerName for shared zones). + public static let zoneOwner = "zone.owner" /// Limit configuration key. public static let limit = "limit" /// Fields configuration key. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift index a35f93b0..8feca476 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift @@ -57,7 +57,8 @@ extension PhasedIntegrationTest { completed.append(index) } } catch { - print("\n\u{274C} Error: \(error)") + let message = Self.failureMessage(for: error) + print("\n\u{274C} Error: \(message)") let cleanupAlreadyRan = phases.enumerated().contains { index, phase in phase is any CleanupPhaseMarker && completed.contains(index) } @@ -74,7 +75,8 @@ extension PhasedIntegrationTest { ) } printSummary( - completed: completed, skipped: skipped, errored: true + completed: completed, skipped: skipped, errored: true, + failureMessage: message ) throw error } @@ -127,7 +129,8 @@ extension PhasedIntegrationTest { } private func printSummary( - completed: [Int], skipped: [Int], errored: Bool + completed: [Int], skipped: [Int], errored: Bool, + failureMessage: String? = nil ) { print("\n" + String(repeating: "=", count: 80)) let header = @@ -164,5 +167,18 @@ extension PhasedIntegrationTest { print(" \u{2022} Run with --verbose for detailed output") let tip = " \u{2022} Use --skip-cleanup to inspect records" print("\(tip) in CloudKit Console") + if let failureMessage { + print("\nFailure message (copy):") + print(failureMessage) + } + } + + private static func failureMessage(for error: any Error) -> String { + if let localized = error as? any LocalizedError, + let description = localized.errorDescription + { + return description + } + return String(describing: error) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift new file mode 100644 index 00000000..c6f5ca64 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift @@ -0,0 +1,167 @@ +// +// CustomZoneQueryPhase.swift +// MistDemo +// +// 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. +// + +internal import Foundation +internal import MistKit + +/// Exercises `queryRecords` with an explicit `zoneID` against a live custom +/// zone: create the zone, write records into it, query them back, and verify +/// ``ZoneID/defaultZone`` does not see them. Owns zone teardown because +/// ``CleanupPhase`` issues deletes without a `zoneID`. +internal struct CustomZoneQueryPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Query records in a custom zone" + internal static let emoji = "🗂️" + internal static let apiName = "records/query (zoneID)" + + internal func run( + input: NoState, + context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let zoneName = "MistDemoZoneQuery-\(UUID().uuidString.prefix(8))" + let zoneID = ZoneID(zoneName: zoneName, ownerName: nil) + + _ = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(zoneName)") + } + + let recordName1 = "mistkit-zone-query-\(UUID().uuidString.lowercased())" + let recordName2 = "mistkit-zone-query-\(UUID().uuidString.lowercased())" + let expectedNames = Set([recordName1, recordName2]) + + do { + _ = try await context.service.modifyRecords( + [ + .create( + recordType: MistDemoConfig.recordType, + recordName: recordName1, + fields: [ + "title": .string("Zone query 1"), + "index": .int64(1), + ] + ), + .create( + recordType: MistDemoConfig.recordType, + recordName: recordName2, + fields: [ + "title": .string("Zone query 2"), + "index": .int64(2), + ] + ), + ], + zoneID: zoneID, + database: context.database + ) + + let query = Query(recordType: MistDemoConfig.recordType) + + do { + let result = try await context.service.queryRecords( + query, + zoneID: zoneID, + database: context.database + ) + let foundNames = Set(result.records.map(\.recordName)) + guard expectedNames.isSubset(of: foundNames) else { + try await cleanup(zoneName: zoneName, context: context) + throw IntegrationTestError.verificationFailed( + "zone query did not return both created records" + ) + } + if context.verbose { + print(" ✅ Custom-zone query returned both records") + } + + let defaultResult = try await context.service.queryRecords( + query, + zoneID: .defaultZone, + database: context.database + ) + let defaultNames = Set(defaultResult.records.map(\.recordName)) + let leaked = expectedNames.intersection(defaultNames) + guard leaked.isEmpty else { + try await cleanup(zoneName: zoneName, context: context) + throw IntegrationTestError.verificationFailed( + "default-zone query returned custom-zone records: " + + leaked.sorted().joined(separator: ", ") + ) + } + if context.verbose { + print(" ✅ Default-zone query excluded custom-zone records") + } + } catch { + guard case CloudKitError.notFound = error else { + try? await cleanup(zoneName: zoneName, context: context) + throw error + } + print( + "⚠️ queryRecords returned NOT_FOUND — schema may not be indexed yet (non-fatal)" + ) + } + + try await cleanup(zoneName: zoneName, context: context) + print("✅ Queried records in a custom zone") + } catch let error as IntegrationTestError { + try? await cleanup(zoneName: zoneName, context: context) + throw error + } catch { + try? await cleanup(zoneName: zoneName, context: context) + throw IntegrationTestError.verificationFailed( + "custom zone query failed: \(error.localizedDescription)" + ) + } + + return NoState() + } + + private func cleanup( + zoneName: String, + context: PhaseContext + ) async throws { + if context.skipCleanup { + print(" ⏭️ Skipping zone cleanup — inspect zone '\(zoneName)'") + return + } + try await context.service.deleteZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Deleted zone: \(zoneName)") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift index 281bb9e0..135bc531 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -47,7 +47,7 @@ internal struct LookupRecordsPhase: IntegrationPhase { /// tag is honored. Failing loud here catches a regression in that recovery. private static func verifyTimestampRoundTrip(in records: [RecordInfo]) throws { let expected = CreateRecordsPhase.verificationTimestamp.timeIntervalSince1970 - for record in records { + for record in records where record.fields["timestamp"] != nil { guard case .date(let value)? = record.fields["timestamp"] else { throw IntegrationTestError.verificationFailed( "Record \(record.recordName) timestamp did not round-trip as a date" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index abac15e6..ffc21aca 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -56,6 +56,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { ModifyRecordsPhase(), IncrementalSyncPhase(), QueryRequestOptionsPhase(), + CustomZoneQueryPhase(), ModifyRequestOptionsPhase(), ChangesRequestOptionsPhase(), FinalVerificationPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift index 27adf675..ac7a1f7b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -35,16 +35,22 @@ extension CloudKitService: WebBackend { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, + zoneName: String?, + zoneOwner: String?, database: MistKit.Database ) async throws -> [RecordInfo] { let querySorts = sortBy?.map { sort in QuerySort.sort(sort.field, ascending: sort.ascending) } + let zoneID = zoneName.map { + ZoneID(zoneName: $0, ownerName: zoneOwner) + } let result = try await queryRecords( Query(recordType: recordType, sortBy: querySorts ?? []), limit: limit, desiredKeys: nil, continuationMarker: nil, + zoneID: zoneID, database: database ) return result.records diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index b9a2edb9..6105540e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -43,6 +43,8 @@ internal protocol WebBackend: Sendable { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, + zoneName: String?, + zoneOwner: String?, database: MistKit.Database ) async throws -> [RecordInfo] diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 79c3706e..9f26597a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -58,12 +58,18 @@ internal enum WebRequests { case limit case sortBy case database + case zoneName + case zoneOwner } internal let recordType: String internal let limit: Int? internal let sortBy: [QuerySortField]? internal let database: MistKit.Database + /// Optional zone name for custom/shared zone queries. + internal let zoneName: String? + /// Optional zone owner (ownerName) for shared zones. + internal let zoneOwner: String? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -75,6 +81,19 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zoneName = try container.decodeIfPresent( + String.self, forKey: .zoneName + ) + self.zoneOwner = try container.decodeIfPresent( + String.self, forKey: .zoneOwner + ) + if self.zoneOwner != nil, self.zoneName == nil { + throw DecodingError.dataCorruptedError( + forKey: .zoneOwner, + in: container, + debugDescription: "zoneOwner requires zoneName" + ) + } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift index 1591b9e9..18208e81 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -51,6 +51,8 @@ recordType: body.recordType, limit: body.limit, sortBy: body.sortBy, + zoneName: body.zoneName, + zoneOwner: body.zoneOwner, database: body.database ) return try WebJSON.encoder().encode( diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift index 8d7518c9..b6b1ddd5 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift @@ -51,5 +51,26 @@ extension QueryCommandTests { #expect(config.zone == "customZone") } + + @Test("Default zoneOwner is nil") + internal func defaultZoneOwnerIsNil() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.zoneOwner == nil) + } + + @Test("Custom zoneOwner is preserved") + internal func customZoneOwnerIsPreserved() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zone: "SharedZone", + zoneOwner: "_abc123" + ) + + #expect(config.zone == "SharedZone") + #expect(config.zoneOwner == "_abc123") + } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift index 8b5c1ee5..1a07d6e2 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift @@ -42,6 +42,7 @@ extension QueryConfigTests { let config = QueryConfig(base: baseConfig) #expect(config.zone == "_defaultZone") + #expect(config.zoneOwner == nil) #expect(config.recordType == "Note") #expect(config.filters.isEmpty) #expect(config.sort == nil) @@ -64,6 +65,19 @@ extension QueryConfigTests { #expect(config.recordType == "Note") } + @Test("QueryConfig initializes with custom zone owner") + internal func initializeWithCustomZoneOwner() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zone: "SharedZone", + zoneOwner: "_ownerRecordName" + ) + + #expect(config.zone == "SharedZone") + #expect(config.zoneOwner == "_ownerRecordName") + } + @Test("QueryConfig initializes with custom record type") internal func initializeWithCustomRecordType() async throws { let baseConfig = try await MistDemoConfig() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index f7b01fdc..3baba4b0 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -39,6 +39,8 @@ internal let recordType: String internal let limit: Int? internal let sortBy: [WebRequests.QuerySortField]? + internal let zoneName: String? + internal let zoneOwner: String? internal let database: MistKit.Database } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift index aa2c3a4c..62b908c1 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift @@ -37,12 +37,16 @@ recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, + zoneName: String?, + zoneOwner: String?, database: MistKit.Database ) async throws -> [RecordInfo] { lastQuery = QueryCall( recordType: recordType, limit: limit, sortBy: sortBy, + zoneName: zoneName, + zoneOwner: zoneOwner, database: database ) try consumePendingError() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift new file mode 100644 index 00000000..4e64c6ee --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift @@ -0,0 +1,82 @@ +// +// WebServerTests+QueryZone.swift +// MistDemoTests +// +// 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. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import HTTPTypes + internal import Hummingbird + internal import HummingbirdTesting + internal import MistKit + internal import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("POST /api/records/query rejects zoneOwner without zoneName") + internal func queryRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note","zoneOwner":"_abc"}"#) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/query forwards zoneName and zoneOwner to the backend") + internal func queryForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.zoneName == "Articles") + #expect(captured?.zoneOwner == "_abc123") + } + } +#endif diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift index edc06754..3ddda516 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift @@ -59,17 +59,23 @@ extension CloudKitService { /// - recordType: The CloudKit record type to scan. /// - limit: Optional maximum number of records to fetch (1-200). Defaults /// to CloudKit's per-request maximum. + /// - zoneID: Optional zone to scan. When `nil` (the default) CloudKit + /// resolves the database's default zone. Pass the same ``ZoneID`` you + /// intend to hand `modifyRecords(_:zoneID:)` so the pre-fetch and the + /// modify target the same zone. /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`). /// - Returns: Set of existing record names. /// - Throws: `CloudKitError` if the underlying query fails. public func fetchExistingRecordNames( recordType: String, limit: Int? = nil, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> Set { let result: QueryResult = try await queryRecords( Query(recordType: recordType), limit: limit ?? Self.maxRecordsPerRequest, + zoneID: zoneID, database: database ) return Set(result.records.map(\.recordName)) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift index 54e98943..884fac2b 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift @@ -40,7 +40,7 @@ internal import OpenAPIRuntime #endif extension CloudKitService { - /// Query records from the default zone with pagination support. + /// Query records from a CloudKit zone with pagination support. /// /// The unified ``Query`` value carries the `recordType` plus any /// ``QueryFilter`` predicates and ``QuerySort`` descriptors. The same @@ -53,6 +53,10 @@ extension CloudKitService { /// - desiredKeys: Optional list of field names to fetch. /// - continuationMarker: Marker from a previous ``QueryResult`` to /// fetch the next page. + /// - zoneID: Optional zone to query. When `nil` (the default) the `zoneID` + /// key is omitted from the request and CloudKit resolves the database's + /// default zone (`_defaultZone`). Pass a ``ZoneID`` to target a custom + /// zone, including a shared zone via ``ZoneID/ownerName``. /// - zoneWide: When true, query across all zones rather than a single zone. /// - numbersAsStrings: When true, numeric field values are returned as strings (avoids /// JavaScript precision loss for `INT64`). @@ -65,6 +69,7 @@ extension CloudKitService { limit: Int? = nil, desiredKeys: [String]? = nil, continuationMarker: String? = nil, + zoneID: ZoneID? = nil, zoneWide: Bool? = nil, numbersAsStrings: Bool? = nil, database: Database @@ -82,7 +87,7 @@ extension CloudKitService { ), body: .json( .init( - zoneID: .init(zoneName: "_defaultZone"), + zoneID: zoneID.map { Components.Schemas.ZoneID(from: $0) }, resultsLimit: effectiveLimit, query: query.schema, desiredKeys: desiredKeys, diff --git a/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift index 551bdcdf..96d9a8f6 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift @@ -45,6 +45,9 @@ extension CloudKitService { /// - desiredKeys: Optional array of field names to fetch /// - maxPages: Maximum number of pages to fetch before throwing /// `CloudKitError.paginationLimitExceeded` (defaults to 1,000) + /// - zoneID: Optional zone to query. When `nil` (the default) CloudKit + /// resolves the database's default zone (`_defaultZone`). Pass a + /// ``ZoneID`` to target a custom or shared zone. /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) /// - Returns: Array of all matching records across all pages /// - Throws: `CloudKitError`. When `maxPages` is exceeded, throws @@ -62,6 +65,7 @@ extension CloudKitService { pageSize: Int? = nil, desiredKeys: [String]? = nil, maxPages: Int = 1_000, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> [RecordInfo] { var allRecords: [RecordInfo] = [] @@ -87,6 +91,7 @@ extension CloudKitService { limit: pageSize, desiredKeys: desiredKeys, continuationMarker: currentMarker, + zoneID: zoneID, database: database ) diff --git a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md index 04b429fd..0d756cb5 100644 --- a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md +++ b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md @@ -222,7 +222,7 @@ public struct QueryResult: Codable, Sendable { Two iteration helpers cover the common cases: -- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` — single page. +- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` — single page. - `queryAllRecords(...)` — auto-pagination with an enforced maximum, surfacing ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` with the already-fetched records when the cap is reached. Sync endpoints follow the same shape: ``RecordChangesResult`` and ``ZoneChangesResult`` carry `syncToken` and `moreComing`. `fetchAllRecordChanges(recordType:syncToken:)` walks the cursor automatically. diff --git a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md index 09fa33e6..aa96e372 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md +++ b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md @@ -9,14 +9,14 @@ CloudKit Web Services is a remote API with per-request size limits and per-accou | Concern | Enforced where | Notes | | --- | --- | --- | | Records per query response | CloudKit | Max 200; the `limit` parameter is validated 1–200. | -| Pages per auto-paginated query | MistKit | `maxPages: 1_000` on ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)``. | +| Pages per auto-paginated query | MistKit | `maxPages: 1_000` on ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)``. | | Records per modify batch | CloudKit | Practical cap around 200; chunk larger batches client-side. | | Asset upload size / connection pool | MistKit (transport separation) | `URLSession.shared` used for CDN uploads to avoid HTTP/2 reuse with the API host. | | Requests per second | CloudKit | Server-side rate limit; surfaces as 503/429. | ## Pagination guard -``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks CloudKit's `continuationMarker` for you. Two safeguards prevent runaway iteration: +``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` walks CloudKit's `continuationMarker` for you. Two safeguards prevent runaway iteration: 1. **`maxPages` cap (default `1_000`)** — if the auto-paginator hits the cap, it throws ``CloudKitError/paginationLimitExceeded(maxPages:records:)``. The records collected so far are attached to the error so the caller can resume from a narrowed query or accept a partial result. 2. **Stuck-marker detection** — if CloudKit returns an empty page with the same continuation marker it just gave you, the paginator stops cleanly rather than spinning. This guards against a server-side bug pattern where the cursor never advances. @@ -113,7 +113,7 @@ For custom transports, prefer one transport per `CloudKitService` and reuse the ### Limits - ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` -- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` +- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` - ``CloudKitService/modifyRecords(_:atomic:database:)`` ### Asset uploads diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md index 338c7a3f..62a00cec 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -365,7 +365,7 @@ case .undocumented(let code, _): } ``` -That's correct but tedious for every call site. ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` collapses it to one async call returning ``QueryResult``. The generated layer still does the type-safe HTTP work; the wrapper handles the call-site ergonomics, error mapping, and conversion between generated and domain types. +That's correct but tedious for every call site. ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` collapses it to one async call returning ``QueryResult``. The generated layer still does the type-safe HTTP work; the wrapper handles the call-site ergonomics, error mapping, and conversion between generated and domain types. ## Integration with the wrapper diff --git a/Sources/MistKit/Documentation.docc/HandlingErrors.md b/Sources/MistKit/Documentation.docc/HandlingErrors.md index 85d83ace..d0396665 100644 --- a/Sources/MistKit/Documentation.docc/HandlingErrors.md +++ b/Sources/MistKit/Documentation.docc/HandlingErrors.md @@ -164,7 +164,7 @@ logging; use the cases themselves for control flow. ### `paginationLimitExceeded` carries partial results -``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks the continuation marker for you and stops at `maxPages` (default `1_000`) as a runaway guard. When it trips, the records collected so far are attached to the error so the caller can decide: +``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` walks the continuation marker for you and stops at `maxPages` (default `1_000`) as a runaway guard. When it trips, the records collected so far are attached to the error so the caller can decide: ```swift do { diff --git a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md index 603806d5..c6211dd5 100644 --- a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md +++ b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md @@ -8,7 +8,7 @@ CRUD, batch, and lookup against CloudKit records — the operations you'll reach ## Querying -Use ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` for a single page of results. Filters are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: +Use ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` for a single page of results. Filters are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: ```swift let result = try await service.queryRecords( @@ -28,7 +28,7 @@ for record in result.records { } ``` -For unbounded iteration, ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks the continuation marker for you with a safety guard at `maxPages` (default `1_000`): +For unbounded iteration, ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` walks the continuation marker for you with a safety guard at `maxPages` (default `1_000`): ```swift let allArticles = try await service.queryAllRecords( @@ -40,6 +40,34 @@ let allArticles = try await service.queryAllRecords( > Warning: If `queryAllRecords` hits its page cap, it throws ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` with the records collected so far. See for the recovery pattern. +### Querying a custom or shared zone + +Both query methods accept an optional `zoneID`. When you omit it, the `zoneID` key is left out of the request entirely and CloudKit resolves the database's default zone (`_defaultZone`) — which is the only zone the public database has. + +To read from a custom zone in the private database, pass a ``ZoneID``: + +```swift +let notes = try await service.queryAllRecords( + recordType: "Note", + zoneID: ZoneID(zoneName: "NotesZone"), + database: .private +) +``` + +A shared zone additionally needs the owner's record name, because the zone lives in *their* database: + +```swift +let shared = try await service.queryRecords( + Query(recordType: "Note"), + zoneID: ZoneID(zoneName: "NotesZone", ownerName: "_abc123…"), + database: .shared +) +``` + +Use ``CloudKitService/listZones(database:)`` to discover which zones a database has, and ``ZoneID/defaultZone`` when you want to name the default zone explicitly. + +> Note: `zoneID` and `zoneWide` pull in opposite directions — `zoneWide: true` queries across *every* zone in the database, which makes `zoneID` moot. `zoneWide` is only valid against the private and shared databases. + ## Creating Use ``CloudKitService/createRecord(recordType:recordName:fields:database:)`` for a single create. Fields are a `[String: FieldValue]` dictionary — every CloudKit scalar plus references, locations, assets, and lists are modeled in ``FieldValue``: @@ -175,8 +203,8 @@ The inline DocC on these methods carries fuller examples for initial-vs-incremen ### Read operations -- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` -- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` +- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` +- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` - ``CloudKitService/lookupRecords(recordNames:desiredKeys:database:)`` ### Write operations diff --git a/Sources/MistKit/Models/Queries/Query.swift b/Sources/MistKit/Models/Queries/Query.swift index 312b3003..bb2064c0 100644 --- a/Sources/MistKit/Models/Queries/Query.swift +++ b/Sources/MistKit/Models/Queries/Query.swift @@ -33,7 +33,7 @@ internal import MistKitOpenAPI /// predicates and ``QuerySort`` descriptors. /// /// The same value can be passed to -/// ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:database:)`` +/// ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` /// for a one-off query and embedded in /// ``SubscriptionInfo/Kind/query(_:)`` to describe a query /// subscription's predicate — they share this single representation. diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift new file mode 100644 index 00000000..bc24f40a --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift @@ -0,0 +1,179 @@ +// +// CloudKitServiceTests.Query+ZoneID.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. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Query { + /// Coverage for issue #146: `queryRecords` hard-coded `_defaultZone`, making + /// custom and shared zones unqueryable. The `zoneID` parameter must serialize + /// into the `records/query` request body — including a shared zone's + /// `ownerName` — and must be omitted entirely when the caller passes `nil`. + @Suite("Zone Selection") + internal struct ZoneIDSelection { + private static let database: Database = .public(.prefers(.serverToServer)) + + /// Builds a service whose transport records request bodies for inspection. + private static func makeService( + _ provider: ResponseProvider + ) throws -> CloudKitService { + try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: MockTransport(responseProvider: provider) + ) + } + + /// The `index`-th request body sent for `operationID`, decoded as a JSON object. + private static func sentBody( + for operationID: String, + from provider: ResponseProvider, + at index: Int = 0 + ) async throws -> [String: Any] { + let bodies = await provider.bodies(for: operationID) + let data = try #require(bodies.compactMap { $0 }.dropFirst(index).first) + return try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + @Test("queryRecords() omits zoneID when none is supplied (default-zone behavior)") + internal func queryOmitsZoneIDByDefault() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + // No `zoneID` key at all — CloudKit resolves `_defaultZone` server-side. + #expect(body["zoneID"] == nil) + } + + @Test("queryRecords() forwards a custom zone name into the request body") + internal func queryForwardsCustomZoneName() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: ZoneID(zoneName: "CustomZone"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "CustomZone") + #expect(zoneID["ownerName"] == nil) + } + + @Test("queryRecords() forwards a shared zone's ownerName") + internal func queryForwardsSharedZoneOwnerName() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: ZoneID(zoneName: "SharedZone", ownerName: "_owner-record-name"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "SharedZone") + #expect(zoneID["ownerName"] as? String == "_owner-record-name") + } + + @Test("queryRecords() forwards ZoneID.defaultZone explicitly when asked") + internal func queryForwardsExplicitDefaultZone() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: .defaultZone, + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "_defaultZone") + } + + @Test("queryAllRecords() forwards zoneID on every page it fetches") + internal func queryAllRecordsForwardsZoneIDPerPage() async throws { + // Page 1 carries a continuation marker so the paginator issues a second + // request; the queue then falls through to the marker-less default. + let provider = ResponseProvider( + defaultResponse: try .successfulQueryResponse(recordCount: 1) + ) + await provider.enqueue( + try .successfulQueryResponse(recordCount: 1, continuationMarker: "page-2"), + for: "queryRecords" + ) + let service = try Self.makeService(provider) + + _ = try await service.queryAllRecords( + recordType: "TestRecord", + zoneID: ZoneID(zoneName: "CustomZone", ownerName: "_owner-record-name"), + database: Self.database + ) + + let callCount = await provider.callCount(for: "queryRecords") + #expect(callCount == 2) + + for index in 0..