diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift index 9a76e037..b51776f6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift @@ -51,6 +51,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { --zone Zone name (default: _defaultZone) --fetch-all Auto-paginate all changes --limit Max results per page (1-200) + --fields Comma-separated fields (desiredKeys) + --record-types Comma-separated record types to include --database Database to target --output-format Output format @@ -109,6 +111,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { let (records, newToken) = try await service.fetchAllRecordChanges( zoneID: zoneID, syncToken: config.syncToken, + desiredKeys: config.desiredKeys, + desiredRecordTypes: config.desiredRecordTypes, database: config.base.database ) print("\n✅ Fetched \(records.count) record(s)") @@ -127,6 +131,8 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { zoneID: zoneID, syncToken: config.syncToken, resultsLimit: config.limit ?? 10, + desiredKeys: config.desiredKeys, + desiredRecordTypes: config.desiredRecordTypes, database: config.base.database ) print("\n✅ Fetched \(result.records.count) record(s)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift index 3d95f8f5..28211fb4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -53,6 +53,9 @@ public struct ModifyCommand: MistDemoCommand, OutputFormatting { OPTIONS: --atomic Reject batch if any fails + --zone Target zone for the operations + --fields Comma-separated fields (desiredKeys) + --numbers-as-strings Return numeric fields as strings --output-format Output format NOTES: @@ -100,6 +103,9 @@ public struct ModifyCommand: MistDemoCommand, OutputFormatting { let results = try await client.modifyRecords( operations, atomic: config.atomic, + zoneID: config.zone.map { ZoneID(zoneName: $0, ownerName: nil) }, + desiredKeys: config.desiredKeys, + numbersAsStrings: config.numbersAsStrings, database: config.base.database ) let succeeded = results.compactMap { result in diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index a0e7ebc7..a27a5553 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -51,7 +51,9 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { --filter Filter: field:operator:value --sort Sort (asc/desc) --limit Max records (1-200) - --fields Comma-separated fields + --fields Comma-separated fields (desiredKeys) + --zone-wide Query across all zones + --numbers-as-strings Return numeric fields as strings --output-format Output format """ @@ -71,20 +73,26 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { // Build filters // NOTE: Zone, offset, and continuation marker support require // enhancements to CloudKitService.queryRecords method (GitHub issues #145, #146) - let filters: [QueryFilter]? = + let filters: [QueryFilter] = config.filters.isEmpty - ? nil + ? [] : try config.filters.map { try Self.parseFilter($0) } - let recordInfos = try await client.queryRecords( + let query = Query( recordType: config.recordType, filters: filters, - sortBy: nil, + sortBy: [] + ) + let result = try await client.queryRecords( + query, limit: config.limit, + desiredKeys: config.fields, + zoneWide: config.zoneWide, + numbersAsStrings: config.numbersAsStrings, database: config.base.database ) // Format and output results - try await outputResults(recordInfos, format: config.output) + try await outputResults(result.records, format: config.output) } catch { throw QueryError.operationFailed(error.localizedDescription) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift index fce62fa4..b7768762 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift @@ -46,6 +46,10 @@ public struct FetchChangesConfig: Sendable, ConfigurationParseable { public let fetchAll: Bool /// The optional limit on number of changes to fetch. public let limit: Int? + /// The optional field names limiting the fields returned per record. + public let desiredKeys: [String]? + /// The optional record-type names limiting the change feed. + public let desiredRecordTypes: [String]? /// The output format. public let output: OutputFormat @@ -56,6 +60,8 @@ public struct FetchChangesConfig: Sendable, ConfigurationParseable { zone: String = "_defaultZone", fetchAll: Bool = false, limit: Int? = nil, + desiredKeys: [String]? = nil, + desiredRecordTypes: [String]? = nil, output: OutputFormat = .table ) { self.base = base @@ -63,6 +69,8 @@ public struct FetchChangesConfig: Sendable, ConfigurationParseable { self.zone = zone self.fetchAll = fetchAll self.limit = limit + self.desiredKeys = desiredKeys + self.desiredRecordTypes = desiredRecordTypes self.output = output } @@ -88,6 +96,12 @@ public struct FetchChangesConfig: Sendable, ConfigurationParseable { let fetchAll = configuration.bool(forKey: "fetch.all", default: false) let limit = configuration.int(forKey: "limit") + let desiredKeys = configuration.commaSeparatedList( + forKey: MistDemoConstants.ConfigKeys.fields + ) + let desiredRecordTypes = configuration.commaSeparatedList( + forKey: MistDemoConstants.ConfigKeys.desiredRecordTypes + ) let outputString = configuration.string(forKey: "output.format", default: "table") ?? "table" @@ -99,6 +113,8 @@ public struct FetchChangesConfig: Sendable, ConfigurationParseable { zone: zone, fetchAll: fetchAll, limit: limit, + desiredKeys: desiredKeys, + desiredRecordTypes: desiredRecordTypes, output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift index cccb8c15..eee3b02f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift @@ -143,6 +143,21 @@ public struct MistDemoConfiguration: Sendable { ) } + /// Read an optional bool: `nil` when the key is absent, else its parsed value. + /// + /// Distinguishes "flag not provided" (`nil` → omit from the request) from an + /// explicit `false`, unlike `bool(forKey:default:)` which collapses both. + public func optionalBool(forKey key: String) -> Bool? { + string(forKey: key) != nil ? bool(forKey: key) : nil + } + + /// Read a comma-separated list of strings, or `nil` when the key is absent. + public func commaSeparatedList(forKey key: String) -> [String]? { + string(forKey: key)? + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + } + /// Read a pipe-separated list of strings from configuration. public func filterStrings(forKey key: String) -> [String] { string(forKey: key)? diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift index b7311854..9625d144 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift @@ -44,6 +44,12 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { public let operations: [ModifyOperationInput] /// Whether to perform operations atomically. public let atomic: Bool + /// The optional target zone for the operations. + public let zone: String? + /// The optional field names limiting the fields returned in the response. + public let desiredKeys: [String]? + /// Whether numeric field values are returned as strings. + public let numbersAsStrings: Bool? /// The output format. public let output: OutputFormat @@ -52,11 +58,17 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { base: MistDemoConfig, operations: [ModifyOperationInput], atomic: Bool = false, + zone: String? = nil, + desiredKeys: [String]? = nil, + numbersAsStrings: Bool? = nil, output: OutputFormat = .json ) { self.base = base self.operations = operations self.atomic = atomic + self.zone = zone + self.desiredKeys = desiredKeys + self.numbersAsStrings = numbersAsStrings self.output = output } @@ -85,6 +97,16 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { default: false ) + let zone = configReader.string( + forKey: MistDemoConstants.ConfigKeys.zone + ) + let desiredKeys = configReader.commaSeparatedList( + forKey: MistDemoConstants.ConfigKeys.fields + ) + let numbersAsStrings = configReader.optionalBool( + forKey: MistDemoConstants.ConfigKeys.numbersAsStrings + ) + let outputString = configReader.string( forKey: MistDemoConstants.ConfigKeys.outputFormat, @@ -96,6 +118,9 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { base: baseConfig, operations: operations, atomic: atomic, + zone: zone, + desiredKeys: desiredKeys, + numbersAsStrings: numbersAsStrings, output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift index f5873971..02080c9a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift @@ -56,6 +56,10 @@ public struct QueryConfig: Sendable, ConfigurationParseable { public let fields: [String]? /// The optional continuation marker for pagination. public let continuationMarker: String? + /// Whether to query across all zones rather than a single zone. + public let zoneWide: Bool? + /// Whether numeric field values are returned as strings. + public let numbersAsStrings: Bool? /// The output format. public let output: OutputFormat @@ -70,6 +74,8 @@ public struct QueryConfig: Sendable, ConfigurationParseable { offset: Int = 0, fields: [String]? = nil, continuationMarker: String? = nil, + zoneWide: Bool? = nil, + numbersAsStrings: Bool? = nil, output: OutputFormat = .json ) { self.base = base @@ -81,6 +87,8 @@ public struct QueryConfig: Sendable, ConfigurationParseable { self.offset = offset self.fields = fields self.continuationMarker = continuationMarker + self.zoneWide = zoneWide + self.numbersAsStrings = numbersAsStrings self.output = output } @@ -112,6 +120,12 @@ public struct QueryConfig: Sendable, ConfigurationParseable { offset: parsed.pagination.offset, fields: parsed.pagination.fields, continuationMarker: parsed.pagination.continuationMarker, + zoneWide: configReader.optionalBool( + forKey: MistDemoConstants.ConfigKeys.zoneWide + ), + numbersAsStrings: configReader.optionalBool( + forKey: MistDemoConstants.ConfigKeys.numbersAsStrings + ), output: parsed.pagination.output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift index a5c2d40d..40905b16 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift @@ -83,6 +83,12 @@ public enum MistDemoConstants { public static let atomic = "atomic" /// Batch size configuration key for the auto-chunking `*-all` commands. public static let batchSize = "batch.size" + /// Zone-wide query configuration key (query across all zones). + public static let zoneWide = "zone.wide" + /// Numbers-as-strings configuration key (return numeric fields as strings). + public static let numbersAsStrings = "numbers.as.strings" + /// Desired record types configuration key (limits the change feed). + public static let desiredRecordTypes = "record.types" } // MARK: - Field Names diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ChangesRequestOptionsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ChangesRequestOptionsPhase.swift new file mode 100644 index 00000000..18188e25 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ChangesRequestOptionsPhase.swift @@ -0,0 +1,144 @@ +// +// ChangesRequestOptionsPhase.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 the `records/changes` request options added in #385 +/// (`desiredKeys`, `desiredRecordTypes`). +/// +/// CloudKit only tracks changes in **custom zones** ("cannot get changes in +/// default zone"), so — unlike the surrounding query/modify phases that reuse +/// the shared default-zone records — this phase provisions its own custom zone, +/// writes records into it, fetches the change feed with both options, asserts +/// their effect, and tears the zone down again. The zone delete is best-effort +/// on the failure path so an assertion failure still cleans up. Custom zones +/// are private/shared only, so this phase belongs to the private pipeline. +internal struct ChangesRequestOptionsPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Fetch changes with request options (desiredKeys/recordTypes)" + internal static let emoji = "🎛️ " + internal static let apiName = "fetchRecordChanges" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji)\(Self.title)") + + let zoneName = "mistkit-itest-changes-\(UUID().uuidString.lowercased())" + _ = try await context.service.createZone( + zoneName: zoneName, database: context.database + ) + if context.verbose { + print(" ✅ Created change-tracking zone: \(zoneName)") + } + + do { + try await Self.exercise( + zoneID: ZoneID(zoneName: zoneName), context: context + ) + } catch { + // Preserve the original failure; the zone teardown is best-effort. + try? await context.service.deleteZone( + zoneName: zoneName, database: context.database + ) + throw error + } + + try await context.service.deleteZone( + zoneName: zoneName, database: context.database + ) + if context.verbose { + print(" ✅ Deleted change-tracking zone: \(zoneName)") + } + + return NoState() + } + + /// Writes records into `zoneID`, fetches its change feed with `desiredKeys` + /// and `desiredRecordTypes`, and asserts both options took effect. + private static func exercise(zoneID: ZoneID, context: PhaseContext) async throws { + let operations = (1...2).map { index in + RecordOperation( + operationType: .forceUpdate, + recordType: MistDemoConfig.recordType, + recordName: "mistkit-test-\(UUID().uuidString.lowercased())", + fields: [ + "title": .string("Changes options \(index)"), + "index": .int64(index), + ] + ) + } + _ = try await context.service.modifyRecords( + operations, zoneID: zoneID, database: context.database + ) + + let result = try await context.service.fetchRecordChanges( + zoneID: zoneID, + desiredKeys: ["title"], + desiredRecordTypes: [MistDemoConfig.recordType], + database: context.database + ) + print("✅ Fetched \(result.records.count) change(s) with request options") + + guard result.records.contains(where: { !$0.deleted }) else { + // Change tracking can lag a fresh write; don't fail the run over timing. + print(" ⚠️ Change feed empty — skipping assertions (propagation lag)") + return + } + + // desiredRecordTypes must exclude any type other than ours. Tombstones omit + // their type (recordType == nil), so skip them. + let wrongType = result.records.filter { + guard let type = $0.recordType else { return false } + return type != MistDemoConfig.recordType + } + guard wrongType.isEmpty else { + throw IntegrationTestError.verificationFailed( + "desiredRecordTypes was ignored — changes returned type(s): " + + Set(wrongType.compactMap(\.recordType)).sorted().joined(separator: ", ") + ) + } + print(" ✅ desiredRecordTypes honored: only '\(MistDemoConfig.recordType)' returned") + + // desiredKeys: ["title"] must exclude the "index" field on live records. + guard let sample = result.records.first(where: { !$0.deleted && !$0.fields.isEmpty }) else { + print(" ⚠️ No populated records to check desiredKeys against") + return + } + let unexpected = sample.fields.keys.filter { $0 != "title" } + guard unexpected.isEmpty else { + throw IntegrationTestError.verificationFailed( + "desiredKeys was ignored — changes returned unrequested field(s): " + + unexpected.sorted().joined(separator: ", ") + ) + } + print(" ✅ desiredKeys honored: only \(sample.fields.keys.sorted()) returned") + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRequestOptionsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRequestOptionsPhase.swift new file mode 100644 index 00000000..291b7a12 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRequestOptionsPhase.swift @@ -0,0 +1,96 @@ +// +// ModifyRequestOptionsPhase.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 the `records/modify` request options added in #384 +/// (`zoneID`, `desiredKeys`, `numbersAsStrings`). +/// +/// A `forceUpdate` merges the provided fields without dropping the others, so +/// the record keeps its `index` field server-side. Requesting +/// `desiredKeys: ["title"]` must therefore return the updated record with +/// `title` only and *no* `index` — that omission is the assertion. `zoneID` +/// (default zone) and `numbersAsStrings` are exercised alongside but not +/// asserted, since CloudKit does not echo them in a distinguishable domain +/// form. +internal struct ModifyRequestOptionsPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = NoState + + internal static let title = "Modify with request options (zoneID/desiredKeys)" + internal static let emoji = "🎛️ " + internal static let apiName = "modifyRecords" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji)\(Self.title)") + + guard let recordName = input.names.first else { + print(" ⚠️ No test records available — skipping") + return NoState() + } + + let operation = RecordOperation( + operationType: .forceUpdate, + recordType: MistDemoConfig.recordType, + recordName: recordName, + fields: ["title": .string("Request-options update")] + ) + + let results = try await context.service.modifyRecords( + [operation], + zoneID: .defaultZone, + desiredKeys: ["title"], + numbersAsStrings: true, + database: context.database + ) + + guard case .success(let record) = results.first else { + throw IntegrationTestError.verificationFailed( + "modifyRecords with request options returned no successful result" + ) + } + print("✅ Modified \(record.recordName) with request options") + + // desiredKeys: ["title"] must exclude the "index" field the record still + // carries after a merge-style forceUpdate. + let unexpected = record.fields.keys.filter { $0 != "title" } + guard unexpected.isEmpty else { + throw IntegrationTestError.verificationFailed( + "desiredKeys was ignored — modify response returned unrequested field(s): " + + unexpected.sorted().joined(separator: ", ") + ) + } + print(" ✅ desiredKeys honored: only \(record.fields.keys.sorted()) returned") + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRequestOptionsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRequestOptionsPhase.swift new file mode 100644 index 00000000..def6ec30 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRequestOptionsPhase.swift @@ -0,0 +1,93 @@ +// +// QueryRequestOptionsPhase.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 the `records/query` request options added in #383 +/// (`desiredKeys`, `zoneWide`, `numbersAsStrings`) against the live records +/// created earlier in the pipeline. +/// +/// `desiredKeys` is the one option whose effect is directly observable at the +/// domain layer — CloudKit omits every non-requested field — so this phase +/// asserts that our returned records carry `title` but *not* `index`. The other +/// two options are wire-level hints CloudKit does not echo back in a +/// distinguishable domain form, so they are exercised (the request must +/// succeed) but not asserted. +internal struct QueryRequestOptionsPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = NoState + + internal static let title = "Query with request options (desiredKeys/zoneWide)" + internal static let emoji = "🎛️ " + internal static let apiName = "queryRecords" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji)\(Self.title)") + + do { + let result = try await context.service.queryRecords( + Query(recordType: MistDemoConfig.recordType), + desiredKeys: ["title"], + zoneWide: true, + numbersAsStrings: true, + database: context.database + ) + print("✅ Queried \(result.records.count) record(s) with request options") + + let ours = result.records.filter { input.names.contains($0.recordName) } + guard let sample = ours.first else { + print(" ⚠️ None of our test records came back — skipping desiredKeys assertion") + return NoState() + } + + // desiredKeys: ["title"] must exclude the "index" field we wrote at create. + let unexpected = sample.fields.keys.filter { $0 != "title" } + guard unexpected.isEmpty else { + throw IntegrationTestError.verificationFailed( + "desiredKeys was ignored — query returned unrequested field(s): " + + unexpected.sorted().joined(separator: ", ") + ) + } + print(" ✅ desiredKeys honored: only \(sample.fields.keys.sorted()) returned") + } catch { + // Schema propagation in development can lag behind the first write, so a + // freshly-created record type may still 404. LookupRecordsPhase already + // proves the records exist by name; treat the lag as non-fatal here. + guard case CloudKitError.httpErrorWithDetails(statusCode: 404, _, _) = error else { + throw error + } + print("⚠️ queryRecords returned NOT_FOUND — schema may not be indexed yet (non-fatal)") + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 92c82743..d1823e77 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -54,6 +54,9 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { InitialSyncPhase(), ModifyRecordsPhase(), IncrementalSyncPhase(), + QueryRequestOptionsPhase(), + ModifyRequestOptionsPhase(), + ChangesRequestOptionsPhase(), FinalVerificationPhase(), SubscriptionRoundtripPhase(), TokenRoundtripPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 3e4d0b59..d3ded354 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -60,6 +60,8 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { QueryRecordsPhase(), LookupRecordsPhase(), ModifyRecordsPhase(), + QueryRequestOptionsPhase(), + ModifyRequestOptionsPhase(), FinalVerificationPhase(), CleanupPhase(), ] diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchChangesConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchChangesConfigTests.swift index ab048bb7..bf8aa9c3 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchChangesConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchChangesConfigTests.swift @@ -90,4 +90,26 @@ internal struct FetchChangesConfigTests { #expect(config.limit == nil) } + + @Test("FetchChangesConfig defaults desiredKeys and desiredRecordTypes to nil") + internal func changeFeedOptionsDefaultToNil() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchChangesConfig(base: baseConfig) + + #expect(config.desiredKeys == nil) + #expect(config.desiredRecordTypes == nil) + } + + @Test("FetchChangesConfig carries explicit desiredKeys and desiredRecordTypes") + internal func carriesChangeFeedOptions() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchChangesConfig( + base: baseConfig, + desiredKeys: ["title", "body"], + desiredRecordTypes: ["Note", "Comment"] + ) + + #expect(config.desiredKeys == ["title", "body"]) + #expect(config.desiredRecordTypes == ["Note", "Comment"]) + } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigTests.swift index 11515fee..14e3ffc6 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ModifyConfigTests.swift @@ -67,4 +67,30 @@ internal struct ModifyConfigTests { #expect(config.output == format) } + + @Test("ModifyConfig defaults zone, desiredKeys and numbersAsStrings to nil") + internal func requestOptionsDefaultToNil() async throws { + let baseConfig = try await MistDemoConfig() + let config = ModifyConfig(base: baseConfig, operations: []) + + #expect(config.zone == nil) + #expect(config.desiredKeys == nil) + #expect(config.numbersAsStrings == nil) + } + + @Test("ModifyConfig carries explicit zone, desiredKeys and numbersAsStrings") + internal func carriesRequestOptions() async throws { + let baseConfig = try await MistDemoConfig() + let config = ModifyConfig( + base: baseConfig, + operations: [], + zone: "myZone", + desiredKeys: ["title"], + numbersAsStrings: true + ) + + #expect(config.zone == "myZone") + #expect(config.desiredKeys == ["title"]) + #expect(config.numbersAsStrings == true) + } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+RequestOptions.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+RequestOptions.swift new file mode 100644 index 00000000..0f9747b2 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+RequestOptions.swift @@ -0,0 +1,63 @@ +// +// QueryConfigTests+RequestOptions.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. +// + +internal import Foundation +internal import Testing + +@testable import MistDemoKit + +extension QueryConfigTests { + /// Covers the documented query request options wired for #383 + /// (`zoneWide`, `numbersAsStrings`). Both default to `nil` so they are + /// omitted from the request unless the caller opts in. + @Suite("Request Options") + internal struct RequestOptions { + @Test("QueryConfig defaults zoneWide and numbersAsStrings to nil") + internal func defaultsToNil() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.zoneWide == nil) + #expect(config.numbersAsStrings == nil) + } + + @Test("QueryConfig carries explicit zoneWide and numbersAsStrings") + internal func carriesExplicitValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zoneWide: true, + numbersAsStrings: false + ) + + #expect(config.zoneWide == true) + #expect(config.numbersAsStrings == false) + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations+Deprecated.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations+Deprecated.swift new file mode 100644 index 00000000..be3b9526 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+Operations+Deprecated.swift @@ -0,0 +1,159 @@ +// +// CloudKitService+Operations+Deprecated.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 + +extension CloudKitService { + /// Query records from the default zone + /// + /// Queries CloudKit records with optional filtering and sorting. + /// Supports all CloudKit filter operations (equals, comparisons, + /// string matching, list operations) and field-based sorting. + /// + /// - Parameters: + /// - recordType: The type of records to query (must not be empty) + /// - filters: Optional array of filters to apply to the query + /// - sortBy: Optional array of sort descriptors + /// - limit: Maximum number of records to return + /// (1-200, defaults to `defaultQueryLimit`) + /// - desiredKeys: Optional array of field names to fetch + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) + /// - Returns: Array of matching records + /// - Throws: CloudKitError if validation fails or the request fails + /// + /// # Example: Basic Query + /// ```swift + /// let articles = try await service.queryRecords( + /// recordType: "Article" + /// ) + /// ``` + /// + /// # Example: Query with Filters + /// ```swift + /// let recentArticles = try await service.queryRecords( + /// recordType: "Article", + /// filters: [ + /// .greaterThan("publishedDate", .date(oneWeekAgo)), + /// .equals("status", .string("published")) + /// ], + /// limit: 50 + /// ) + /// ``` + /// + /// # Example: Query with Sorting + /// ```swift + /// let sortedArticles = try await service.queryRecords( + /// recordType: "Article", + /// sortBy: [.descending("publishedDate")], + /// limit: 20 + /// ) + /// ``` + /// + /// - Note: For large result sets, consider using pagination + /// with `continuationMarker` or `queryAllRecords` + @available( + *, deprecated, + message: "Use queryRecords -> QueryResult for pagination, or queryAllRecords to auto-paginate." + ) + public func queryRecords( + recordType: String, + filters: [QueryFilter]? = nil, + sortBy: [QuerySort]? = nil, + limit: Int? = nil, + desiredKeys: [String]? = nil, + database: Database + ) async throws(CloudKitError) -> [RecordInfo] { + let result: QueryResult = try await queryRecords( + recordType: recordType, + filters: filters, + sortBy: sortBy, + limit: limit, + desiredKeys: desiredKeys, + continuationMarker: nil, + database: database + ) + return result.records + } + + /// Query records from the default zone with pagination support + /// + /// Queries CloudKit records with optional filtering, sorting, and pagination. + /// Returns a `QueryResult` containing both the matching records and + /// a `continuationMarker` for fetching subsequent pages. + /// + /// - Parameters: + /// - recordType: The type of records to query (must not be empty) + /// - filters: Optional array of filters to apply to the query + /// - sortBy: Optional array of sort descriptors + /// - limit: Maximum number of records to return + /// (1-200, defaults to `defaultQueryLimit`) + /// - desiredKeys: Optional array of field names to fetch + /// - continuationMarker: Marker from a previous `QueryResult` + /// to fetch the next page of results + /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) + /// - Returns: A `QueryResult` with matching records and an optional + /// continuation marker for the next page + /// - Throws: CloudKitError if validation fails or the request fails + /// + /// # Example: Paginated Query + /// ```swift + /// var marker: String? = nil + /// repeat { + /// let result: QueryResult = try await service.queryRecords( + /// recordType: "Article", + /// limit: 50, + /// continuationMarker: marker + /// ) + /// process(result.records) + /// marker = result.continuationMarker + /// } while marker != nil + /// ``` + @available( + *, deprecated, + message: + "Use queryRecords(_:limit:desiredKeys:continuationMarker:database:) — pass a Query value." + ) + public func queryRecords( + recordType: String, + filters: [QueryFilter]? = nil, + sortBy: [QuerySort]? = nil, + limit: Int? = nil, + desiredKeys: [String]? = nil, + continuationMarker: String? = nil, + database: Database + ) async throws(CloudKitError) -> QueryResult { + try await queryRecords( + Query(recordType: recordType, filters: filters ?? [], sortBy: sortBy ?? []), + limit: limit, + desiredKeys: desiredKeys, + continuationMarker: continuationMarker, + database: database + ) + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift index 0e52d7ca..54e98943 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift @@ -40,133 +40,6 @@ internal import OpenAPIRuntime #endif extension CloudKitService { - /// Query records from the default zone - /// - /// Queries CloudKit records with optional filtering and sorting. - /// Supports all CloudKit filter operations (equals, comparisons, - /// string matching, list operations) and field-based sorting. - /// - /// - Parameters: - /// - recordType: The type of records to query (must not be empty) - /// - filters: Optional array of filters to apply to the query - /// - sortBy: Optional array of sort descriptors - /// - limit: Maximum number of records to return - /// (1-200, defaults to `defaultQueryLimit`) - /// - desiredKeys: Optional array of field names to fetch - /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) - /// - Returns: Array of matching records - /// - Throws: CloudKitError if validation fails or the request fails - /// - /// # Example: Basic Query - /// ```swift - /// let articles = try await service.queryRecords( - /// recordType: "Article" - /// ) - /// ``` - /// - /// # Example: Query with Filters - /// ```swift - /// let recentArticles = try await service.queryRecords( - /// recordType: "Article", - /// filters: [ - /// .greaterThan("publishedDate", .date(oneWeekAgo)), - /// .equals("status", .string("published")) - /// ], - /// limit: 50 - /// ) - /// ``` - /// - /// # Example: Query with Sorting - /// ```swift - /// let sortedArticles = try await service.queryRecords( - /// recordType: "Article", - /// sortBy: [.descending("publishedDate")], - /// limit: 20 - /// ) - /// ``` - /// - /// - Note: For large result sets, consider using pagination - /// with `continuationMarker` or `queryAllRecords` - @available( - *, deprecated, - message: "Use queryRecords -> QueryResult for pagination, or queryAllRecords to auto-paginate." - ) - public func queryRecords( - recordType: String, - filters: [QueryFilter]? = nil, - sortBy: [QuerySort]? = nil, - limit: Int? = nil, - desiredKeys: [String]? = nil, - database: Database - ) async throws(CloudKitError) -> [RecordInfo] { - let result: QueryResult = try await queryRecords( - recordType: recordType, - filters: filters, - sortBy: sortBy, - limit: limit, - desiredKeys: desiredKeys, - continuationMarker: nil, - database: database - ) - return result.records - } - - /// Query records from the default zone with pagination support - /// - /// Queries CloudKit records with optional filtering, sorting, and pagination. - /// Returns a `QueryResult` containing both the matching records and - /// a `continuationMarker` for fetching subsequent pages. - /// - /// - Parameters: - /// - recordType: The type of records to query (must not be empty) - /// - filters: Optional array of filters to apply to the query - /// - sortBy: Optional array of sort descriptors - /// - limit: Maximum number of records to return - /// (1-200, defaults to `defaultQueryLimit`) - /// - desiredKeys: Optional array of field names to fetch - /// - continuationMarker: Marker from a previous `QueryResult` - /// to fetch the next page of results - /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) - /// - Returns: A `QueryResult` with matching records and an optional - /// continuation marker for the next page - /// - Throws: CloudKitError if validation fails or the request fails - /// - /// # Example: Paginated Query - /// ```swift - /// var marker: String? = nil - /// repeat { - /// let result: QueryResult = try await service.queryRecords( - /// recordType: "Article", - /// limit: 50, - /// continuationMarker: marker - /// ) - /// process(result.records) - /// marker = result.continuationMarker - /// } while marker != nil - /// ``` - @available( - *, deprecated, - message: - "Use queryRecords(_:limit:desiredKeys:continuationMarker:database:) — pass a Query value." - ) - public func queryRecords( - recordType: String, - filters: [QueryFilter]? = nil, - sortBy: [QuerySort]? = nil, - limit: Int? = nil, - desiredKeys: [String]? = nil, - continuationMarker: String? = nil, - database: Database - ) async throws(CloudKitError) -> QueryResult { - try await queryRecords( - Query(recordType: recordType, filters: filters ?? [], sortBy: sortBy ?? []), - limit: limit, - desiredKeys: desiredKeys, - continuationMarker: continuationMarker, - database: database - ) - } - /// Query records from the default zone with pagination support. /// /// The unified ``Query`` value carries the `recordType` plus any @@ -180,6 +53,9 @@ extension CloudKitService { /// - desiredKeys: Optional list of field names to fetch. /// - continuationMarker: Marker from a previous ``QueryResult`` to /// fetch the next page. + /// - 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`). /// - database: The CloudKit database scope to query. /// - Returns: A ``QueryResult`` with matching records and an optional /// continuation marker. @@ -189,6 +65,8 @@ extension CloudKitService { limit: Int? = nil, desiredKeys: [String]? = nil, continuationMarker: String? = nil, + zoneWide: Bool? = nil, + numbersAsStrings: Bool? = nil, database: Database ) async throws(CloudKitError) -> QueryResult { let effectiveLimit = limit ?? defaultQueryLimit @@ -208,7 +86,9 @@ extension CloudKitService { resultsLimit: effectiveLimit, query: query.schema, desiredKeys: desiredKeys, - continuationMarker: continuationMarker + continuationMarker: continuationMarker, + zoneWide: zoneWide, + numbersAsStrings: numbersAsStrings ) ) ) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift new file mode 100644 index 00000000..0e918d58 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift @@ -0,0 +1,135 @@ +// +// CloudKitService+RecordWriteConvenience.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 + +extension CloudKitService { + /// Create a single record in CloudKit + /// - Parameters: + /// - recordType: The type of record to create + /// - recordName: Optional unique record name + /// - fields: Dictionary of field names to FieldValue + /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) + /// - Returns: RecordInfo for the created record + /// - Throws: CloudKitError if the operation fails + /// + /// # Example + /// ```swift + /// let article = try await service.createRecord( + /// recordType: "Article", + /// fields: ["title": .string("Hello, CloudKit")], + /// database: .private + /// ) + /// ``` + public func createRecord( + recordType: String, + recordName: String? = nil, + fields: [String: FieldValue], + database: Database + ) async throws(CloudKitError) -> RecordInfo { + let operation = RecordOperation.create( + recordType: recordType, + recordName: recordName, + fields: fields + ) + + let results = try await modifyRecords([operation], database: database) + guard let result = results.first else { + throw CloudKitError.invalidResponse + } + return try result.get() + } + + /// Update a single record in CloudKit + /// - Parameters: + /// - recordType: The type of record to update + /// - recordName: The unique record name + /// - fields: Dictionary of field names to FieldValue + /// - recordChangeTag: Optional change tag for optimistic locking + /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) + /// - Returns: RecordInfo for the updated record + /// - Throws: CloudKitError if the operation fails + /// + /// # Example + /// ```swift + /// let updated = try await service.updateRecord( + /// recordType: "Article", + /// recordName: existing.recordName, + /// fields: ["title": .string("Renamed")], + /// recordChangeTag: existing.recordChangeTag, + /// database: .private + /// ) + /// ``` + public func updateRecord( + recordType: String, + recordName: String, + fields: [String: FieldValue], + recordChangeTag: String? = nil, + database: Database + ) async throws(CloudKitError) -> RecordInfo { + let operation = RecordOperation.update( + recordType: recordType, + recordName: recordName, + fields: fields, + recordChangeTag: recordChangeTag + ) + + let results = try await modifyRecords([operation], database: database) + guard let result = results.first else { + throw CloudKitError.invalidResponse + } + return try result.get() + } + + /// Delete a single record from CloudKit + /// - Parameters: + /// - recordType: The type of record to delete + /// - recordName: The unique record name + /// - recordChangeTag: Optional change tag for optimistic locking + /// - database: The CloudKit database scope to delete from (`.public`, `.private`, `.shared`) + /// - Throws: CloudKitError if the operation fails + public func deleteRecord( + recordType: String, + recordName: String, + recordChangeTag: String? = nil, + database: Database + ) async throws(CloudKitError) { + let operation = RecordOperation.delete( + recordType: recordType, + recordName: recordName, + recordChangeTag: recordChangeTag + ) + + let results = try await modifyRecords([operation], database: database) + for result in results { + // `get()` rethrows a per-record failure as `recordOperationFailed`. + _ = try result.get() + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+SyncOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+SyncOperations.swift index d4790e24..06dfa318 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+SyncOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+SyncOperations.swift @@ -51,6 +51,10 @@ extension CloudKitService { /// (defaults to _defaultZone) /// - syncToken: Optional token from previous fetch (nil = initial fetch) /// - resultsLimit: Optional maximum number of records (1-200) + /// - desiredKeys: Optional list of field names limiting the fields returned per + /// changed record. + /// - desiredRecordTypes: Optional list of record-type names limiting the change feed + /// to specific record types. /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) /// - Returns: RecordChangesResult containing changed records /// and new sync token @@ -82,6 +86,8 @@ extension CloudKitService { zoneID: ZoneID? = nil, syncToken: String? = nil, resultsLimit: Int? = nil, + desiredKeys: [String]? = nil, + desiredRecordTypes: [String]? = nil, database: Database ) async throws(CloudKitError) -> RecordChangesResult { let effectiveZoneID = zoneID ?? .defaultZone @@ -99,7 +105,9 @@ extension CloudKitService { .init( zoneID: Components.Schemas.ZoneID(from: effectiveZoneID), syncToken: syncToken, - resultsLimit: resultsLimit + resultsLimit: resultsLimit, + desiredKeys: desiredKeys, + desiredRecordTypes: desiredRecordTypes ) ) ) @@ -126,6 +134,10 @@ extension CloudKitService { /// (defaults to _defaultZone) /// - syncToken: Optional token from previous fetch (nil = initial fetch) /// - resultsLimit: Optional maximum records per request (1-200) + /// - desiredKeys: Optional list of field names limiting the fields returned per + /// changed record. + /// - desiredRecordTypes: Optional list of record-type names limiting the change feed + /// to specific record types. /// - maxPages: Maximum number of pages to fetch before throwing /// `CloudKitError.paginationLimitExceeded` (defaults to 1,000) /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) @@ -157,6 +169,8 @@ extension CloudKitService { zoneID: ZoneID? = nil, syncToken: String? = nil, resultsLimit: Int? = nil, + desiredKeys: [String]? = nil, + desiredRecordTypes: [String]? = nil, maxPages: Int = 1_000, database: Database ) async throws(CloudKitError) -> (records: [RecordInfo], syncToken: String?) { @@ -183,6 +197,8 @@ extension CloudKitService { zoneID: zoneID, syncToken: currentToken, resultsLimit: resultsLimit, + desiredKeys: desiredKeys, + desiredRecordTypes: desiredRecordTypes, database: database ) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift index 764f1072..94932a81 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift @@ -66,6 +66,10 @@ extension CloudKitService { /// - Parameters: /// - operations: Array of record operations to perform /// - atomic: When true, the entire batch fails if any single operation fails (default: false) + /// - zoneID: Optional target zone for the operations (defaults to the request's zone). + /// - desiredKeys: Optional list of field names limiting the fields returned in the response. + /// - numbersAsStrings: When true, numeric field values are returned as strings (avoids + /// JavaScript precision loss for `INT64`). /// - database: The CloudKit database scope to modify (`.public`, `.private`, `.shared`) /// - Returns: A ``RecordResult`` per operation — `.success` for a saved/deleted /// record, `.failure` (a ``RecordOperationFailure``) for one that CloudKit @@ -74,6 +78,9 @@ extension CloudKitService { public func modifyRecords( _ operations: [RecordOperation], atomic: Bool = false, + zoneID: ZoneID? = nil, + desiredKeys: [String]? = nil, + numbersAsStrings: Bool? = nil, database: Database ) async throws(CloudKitError) -> [RecordResult] { let apiOperations: [Components.Schemas.RecordOperation] @@ -100,7 +107,10 @@ extension CloudKitService { body: .json( .init( operations: apiOperations, - atomic: atomic + atomic: atomic, + zoneID: zoneID.map { Components.Schemas.ZoneID(from: $0) }, + desiredKeys: desiredKeys, + numbersAsStrings: numbersAsStrings ) ) ) @@ -118,107 +128,4 @@ extension CloudKitService { throw CloudKitError.underlyingError(error) } } - - /// Create a single record in CloudKit - /// - Parameters: - /// - recordType: The type of record to create - /// - recordName: Optional unique record name - /// - fields: Dictionary of field names to FieldValue - /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) - /// - Returns: RecordInfo for the created record - /// - Throws: CloudKitError if the operation fails - /// - /// # Example - /// ```swift - /// let article = try await service.createRecord( - /// recordType: "Article", - /// fields: ["title": .string("Hello, CloudKit")], - /// database: .private - /// ) - /// ``` - public func createRecord( - recordType: String, - recordName: String? = nil, - fields: [String: FieldValue], - database: Database - ) async throws(CloudKitError) -> RecordInfo { - let operation = RecordOperation.create( - recordType: recordType, - recordName: recordName, - fields: fields - ) - - let results = try await modifyRecords([operation], database: database) - guard let result = results.first else { - throw CloudKitError.invalidResponse - } - return try result.get() - } - - /// Update a single record in CloudKit - /// - Parameters: - /// - recordType: The type of record to update - /// - recordName: The unique record name - /// - fields: Dictionary of field names to FieldValue - /// - recordChangeTag: Optional change tag for optimistic locking - /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) - /// - Returns: RecordInfo for the updated record - /// - Throws: CloudKitError if the operation fails - /// - /// # Example - /// ```swift - /// let updated = try await service.updateRecord( - /// recordType: "Article", - /// recordName: existing.recordName, - /// fields: ["title": .string("Renamed")], - /// recordChangeTag: existing.recordChangeTag, - /// database: .private - /// ) - /// ``` - public func updateRecord( - recordType: String, - recordName: String, - fields: [String: FieldValue], - recordChangeTag: String? = nil, - database: Database - ) async throws(CloudKitError) -> RecordInfo { - let operation = RecordOperation.update( - recordType: recordType, - recordName: recordName, - fields: fields, - recordChangeTag: recordChangeTag - ) - - let results = try await modifyRecords([operation], database: database) - guard let result = results.first else { - throw CloudKitError.invalidResponse - } - return try result.get() - } - - /// Delete a single record from CloudKit - /// - Parameters: - /// - recordType: The type of record to delete - /// - recordName: The unique record name - /// - recordChangeTag: Optional change tag for optimistic locking - /// - database: The CloudKit database scope to delete from (`.public`, `.private`, `.shared`) - /// - Throws: CloudKitError if the operation fails - public func deleteRecord( - recordType: String, - recordName: String, - recordChangeTag: String? = nil, - database: Database - ) async throws(CloudKitError) { - let operation = RecordOperation.delete( - recordType: recordType, - recordName: recordName, - recordChangeTag: recordChangeTag - ) - - let results = try await modifyRecords([operation], database: database) - for result in results { - // `get()` rethrows a per-record failure as `recordOperationFailed`. - _ = try result.get() - } - } } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 9d1ed41b..37a73e13 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -2946,6 +2946,14 @@ public enum Operations { /// /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/continuationMarker`. public var continuationMarker: Swift.String? + /// If true, query across all zones rather than a single zone. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/zoneWide`. + public var zoneWide: Swift.Bool? + /// If true, return numeric field values as strings to avoid JavaScript precision loss (relevant for INT64). + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/json/numbersAsStrings`. + public var numbersAsStrings: Swift.Bool? /// Creates a new `jsonPayload`. /// /// - Parameters: @@ -2954,18 +2962,24 @@ public enum Operations { /// - query: /// - desiredKeys: List of field names to return /// - continuationMarker: Marker for pagination + /// - zoneWide: If true, query across all zones rather than a single zone. + /// - numbersAsStrings: If true, return numeric field values as strings to avoid JavaScript precision loss (relevant for INT64). public init( zoneID: Components.Schemas.ZoneID? = nil, resultsLimit: Swift.Int? = nil, query: Components.Schemas.Query? = nil, desiredKeys: [Swift.String]? = nil, - continuationMarker: Swift.String? = nil + continuationMarker: Swift.String? = nil, + zoneWide: Swift.Bool? = nil, + numbersAsStrings: Swift.Bool? = nil ) { self.zoneID = zoneID self.resultsLimit = resultsLimit self.query = query self.desiredKeys = desiredKeys self.continuationMarker = continuationMarker + self.zoneWide = zoneWide + self.numbersAsStrings = numbersAsStrings } public enum CodingKeys: String, CodingKey { case zoneID @@ -2973,6 +2987,8 @@ public enum Operations { case query case desiredKeys case continuationMarker + case zoneWide + case numbersAsStrings } } /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/query/POST/requestBody/content/application\/json`. @@ -3572,21 +3588,43 @@ public enum Operations { /// /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/atomic`. public var atomic: Swift.Bool? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// List of field names to limit the fields returned in the modify response. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/desiredKeys`. + public var desiredKeys: [Swift.String]? + /// If true, return numeric field values as strings to avoid JavaScript precision loss (relevant for INT64). + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/json/numbersAsStrings`. + public var numbersAsStrings: Swift.Bool? /// Creates a new `jsonPayload`. /// /// - Parameters: /// - operations: /// - atomic: If true, all operations must succeed or all fail + /// - zoneID: + /// - desiredKeys: List of field names to limit the fields returned in the modify response. + /// - numbersAsStrings: If true, return numeric field values as strings to avoid JavaScript precision loss (relevant for INT64). public init( operations: [Components.Schemas.RecordOperation]? = nil, - atomic: Swift.Bool? = nil + atomic: Swift.Bool? = nil, + zoneID: Components.Schemas.ZoneID? = nil, + desiredKeys: [Swift.String]? = nil, + numbersAsStrings: Swift.Bool? = nil ) { self.operations = operations self.atomic = atomic + self.zoneID = zoneID + self.desiredKeys = desiredKeys + self.numbersAsStrings = numbersAsStrings } public enum CodingKeys: String, CodingKey { case operations case atomic + case zoneID + case desiredKeys + case numbersAsStrings } } /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/modify/POST/requestBody/content/application\/json`. @@ -4817,25 +4855,41 @@ public enum Operations { public var syncToken: Swift.String? /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/resultsLimit`. public var resultsLimit: Swift.Int? + /// List of field names to limit the fields returned per changed record. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/desiredKeys`. + public var desiredKeys: [Swift.String]? + /// List of record-type names to limit the change feed to specific record types. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/json/desiredRecordTypes`. + public var desiredRecordTypes: [Swift.String]? /// Creates a new `jsonPayload`. /// /// - Parameters: /// - zoneID: /// - syncToken: Token from previous sync operation /// - resultsLimit: + /// - desiredKeys: List of field names to limit the fields returned per changed record. + /// - desiredRecordTypes: List of record-type names to limit the change feed to specific record types. public init( zoneID: Components.Schemas.ZoneID? = nil, syncToken: Swift.String? = nil, - resultsLimit: Swift.Int? = nil + resultsLimit: Swift.Int? = nil, + desiredKeys: [Swift.String]? = nil, + desiredRecordTypes: [Swift.String]? = nil ) { self.zoneID = zoneID self.syncToken = syncToken self.resultsLimit = resultsLimit + self.desiredKeys = desiredKeys + self.desiredRecordTypes = desiredRecordTypes } public enum CodingKeys: String, CodingKey { case zoneID case syncToken case resultsLimit + case desiredKeys + case desiredRecordTypes } } /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/changes/POST/requestBody/content/application\/json`. diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift index d84dcb72..1ae00952 100644 --- a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift @@ -74,5 +74,28 @@ extension CloudKitServiceTests.Query { #expect(records.count == 2) #expect(records.map(\.recordName) == ["record-0", "record-1"]) } + + @Test("deprecated queryRecords(recordType:database:) returns the records array") + @available( + *, deprecated, + message: "Exercises the deprecated [RecordInfo] query overload." + ) + internal func deprecatedQueryRecordsArrayOverload() 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 CloudKitServiceTests.QueryPagination.makeSuccessfulService( + recordCount: 2, + continuationMarker: nil + ) + + let records = try await service.queryRecords( + recordType: "TestRecord", + database: .public(.prefers(.serverToServer)) + ) + + #expect(records.map(\.recordName) == ["record-0", "record-1"]) + } } } diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift new file mode 100644 index 00000000..e283bfa0 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience.swift @@ -0,0 +1,150 @@ +// +// CloudKitServiceTests.RecordWriteConvenience.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 { + /// Covers the single-record write conveniences (`createRecord`, + /// `updateRecord`, `deleteRecord`) which delegate to `modifyRecords`. + @Suite("Record Write Convenience") + internal struct RecordWriteConvenience { + /// Reuse the shared mock-transport builders and JSON record fixtures. + private typealias Helper = CloudKitServiceTests.Rereference + + @Test("createRecord returns the saved record") + internal func createReturnsRecord() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-1") + ]) + ]) + + let record = try await service.createRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Hello")], + database: Helper.publicDatabase + ) + + #expect(record.recordName == "note-1") + } + + @Test("createRecord throws invalidResponse when the server returns no records") + internal func createThrowsOnEmptyResponse() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([]) + ]) + + await #expect(throws: CloudKitError.self) { + _ = try await service.createRecord( + recordType: "Note", + fields: ["title": .string("Hello")], + database: Helper.publicDatabase + ) + } + } + + @Test("updateRecord returns the saved record") + internal func updateReturnsRecord() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) + ]) + + let record = try await service.updateRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Renamed")], + recordChangeTag: "tag-1", + database: Helper.publicDatabase + ) + + #expect(record.recordName == "note-1") + #expect(record.recordChangeTag == "tag-2") + } + + @Test("updateRecord throws invalidResponse when the server returns no records") + internal func updateThrowsOnEmptyResponse() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([]) + ]) + + await #expect(throws: CloudKitError.self) { + _ = try await service.updateRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Renamed")], + database: Helper.publicDatabase + ) + } + } + + @Test("deleteRecord completes when the server acknowledges the delete") + internal func deleteCompletes() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) + ]) + + try await service.deleteRecord( + recordType: "Note", + recordName: "note-1", + recordChangeTag: "tag-1", + database: Helper.publicDatabase + ) + + #expect(await provider.callCount(for: "modifyRecords") == 1) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/RequestOptions/CloudKitServiceTests.RequestOptions.swift b/Tests/MistKitTests/CloudKitService/RequestOptions/CloudKitServiceTests.RequestOptions.swift new file mode 100644 index 00000000..cd0103bd --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/RequestOptions/CloudKitServiceTests.RequestOptions.swift @@ -0,0 +1,137 @@ +// +// CloudKitServiceTests.RequestOptions.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 { + /// Coverage for issues #383/#384/#385 (epic #409): the documented request-body options + /// on records/query, records/modify and records/changes must serialize into the request. + @Suite("Request Options") + internal struct RequestOptions { + private static let database: Database = .public(.prefers(.serverToServer)) + + /// Builds a service whose transport records request bodies for later 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 first request body sent for `operationID`, decoded as a JSON object. The body is + /// recorded before the (mock) response is processed, so the call's own result is irrelevant. + private static func sentBody( + for operationID: String, + from provider: ResponseProvider + ) async throws -> [String: Any] { + let bodies = await provider.bodies(for: operationID) + let data = try #require(bodies.compactMap { $0 }.first) + return try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + @Test("records/query serializes zoneWide and numbersAsStrings (#383)") + internal func queryRequestOptions() 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 provider = ResponseProvider.default + let service = try Self.makeService(provider) + + _ = try? await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneWide: true, + numbersAsStrings: true, + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + #expect(body["zoneWide"] as? Bool == true) + #expect(body["numbersAsStrings"] as? Bool == true) + } + + @Test("records/modify serializes zoneID, desiredKeys and numbersAsStrings (#384)") + internal func modifyRequestOptions() 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 provider = ResponseProvider.default + let service = try Self.makeService(provider) + let operation = RecordOperation( + operationType: .create, + recordType: "TestRecord", + recordName: "rec-1", + fields: ["title": .string("Test")] + ) + + _ = try? await service.modifyRecords( + [operation], + zoneID: ZoneID(zoneName: "CustomZone"), + desiredKeys: ["title"], + numbersAsStrings: true, + database: Self.database + ) + + let body = try await Self.sentBody(for: "modifyRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "CustomZone") + #expect(body["desiredKeys"] as? [String] == ["title"]) + #expect(body["numbersAsStrings"] as? Bool == true) + } + + @Test("records/changes serializes desiredKeys and desiredRecordTypes (#385)") + internal func changesRequestOptions() 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 provider = ResponseProvider.default + let service = try Self.makeService(provider) + + _ = try? await service.fetchRecordChanges( + desiredKeys: ["title", "body"], + desiredRecordTypes: ["Note"], + database: Self.database + ) + + let body = try await Self.sentBody(for: "fetchRecordChanges", from: provider) + #expect(body["desiredKeys"] as? [String] == ["title", "body"]) + #expect(body["desiredRecordTypes"] as? [String] == ["Note"]) + } + } +} diff --git a/openapi.yaml b/openapi.yaml index 992f0e8f..1a569195 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -65,6 +65,15 @@ paths: continuationMarker: type: string description: Marker for pagination + zoneWide: + type: boolean + description: >- + If true, query across all zones rather than a single zone. + numbersAsStrings: + type: boolean + description: >- + If true, return numeric field values as strings to avoid + JavaScript precision loss (relevant for INT64). responses: '200': description: Successful query @@ -121,6 +130,20 @@ paths: atomic: type: boolean description: If true, all operations must succeed or all fail + zoneID: + $ref: '#/components/schemas/ZoneID' + desiredKeys: + type: array + items: + type: string + description: >- + List of field names to limit the fields returned in the + modify response. + numbersAsStrings: + type: boolean + description: >- + If true, return numeric field values as strings to avoid + JavaScript precision loss (relevant for INT64). responses: '200': description: Records modified successfully @@ -237,6 +260,20 @@ paths: description: Token from previous sync operation resultsLimit: type: integer + desiredKeys: + type: array + items: + type: string + description: >- + List of field names to limit the fields returned per + changed record. + desiredRecordTypes: + type: array + items: + type: string + description: >- + List of record-type names to limit the change feed to + specific record types. responses: '200': description: Changes retrieved successfully