diff --git a/AGENTS.md b/AGENTS.md index c3df38ff..5c27a98b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,7 +189,10 @@ MistKit/ | `CloudKitService+Initialization.swift` | initializer overloads (API token, web auth token, server-to-server) | | `CloudKitService+Operations.swift` | `queryRecords`, `queryAllRecords`, `lookupRecords` | | `CloudKitService+WriteOperations.swift` | `modifyRecords`, `createRecord`, `updateRecord`, `deleteRecord` | -| `CloudKitService+ZoneOperations.swift` | `listZones`, `lookupZones(zoneIDs:)`, `fetchZoneChanges(syncToken:)` | +| `CloudKitService+ZoneOperations.swift` | `listZones`, `lookupZones(zoneIDs:)`, `fetchZoneChanges(syncToken:)` *(deprecated — see `CloudKitService+DatabaseChanges.swift`)* | +| `CloudKitService+DatabaseChanges.swift` | `fetchDatabaseChanges(syncToken:resultsLimit:)`, `fetchAllDatabaseChanges(...)` — `changes/database` | +| `CloudKitService+RecordZoneChanges.swift` | `fetchRecordZoneChanges(zones:...)` — `changes/zone` | +| `CloudKitService+RecordZoneChangesPagination.swift` | `fetchAllRecordZoneChanges(zones:...)` — per-zone auto-pagination | | `CloudKitService+ModifyZones.swift` | `modifyZones(_:database:)` | | `CloudKitService+SyncOperations.swift` | `fetchRecordChanges(recordType:syncToken:)`, `fetchAllRecordChanges(recordType:syncToken:)` | | `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()` *(no-arg address-book form — unavailable, pending #28; distinct from the available `discoverAllUserIdentities(lookupInfos:batchSize:)` chunking overload below)*, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)` | @@ -213,7 +216,9 @@ MistKit/ **Sync/Change Operations:** - `fetchRecordChanges(recordType:syncToken:)` → `/records/changes` — returns `RecordChangesResult` with `records`, `syncToken`, `moreComing` - `fetchAllRecordChanges(recordType:syncToken:)` — convenience wrapper that auto-paginates using `moreComing` -- `fetchZoneChanges(syncToken:)` → `/zones/changes` — returns `ZoneChangesResult` +- `fetchZoneChanges(syncToken:)` → `/zones/changes` — returns `ZoneChangesResult`. **Deprecated** (`@available(*, deprecated)`): Apple deprecated `zones/changes` in favor of `changes/database`. Same for `fetchAllZoneChanges`. +- `fetchDatabaseChanges(syncToken:resultsLimit:)` → `/changes/database` — returns `DatabaseChangesResult` (*which zones* changed). Replacement for `fetchZoneChanges`. `fetchAllDatabaseChanges(...)` auto-paginates with `maxPages` + stuck-token detection. +- `fetchRecordZoneChanges(zones:...)` → `/changes/zone` — returns `RecordZoneChangesResult` (records *within* zones). Each zone carries its **own** `syncToken`/`moreComing`, so `fetchAllRecordZoneChanges(...)` re-requests only the zones still reporting `moreComing` and merges each zone's records across rounds (`ZoneChangesAccumulator`). - `lookupZones(zoneIDs:)` → `/zones/lookup` — returns `[ZoneInfo]` - `discoverUserIdentities(lookupInfos:)` → POST `/users/discover` — takes `[UserIdentityLookupInfo]`, returns `[UserIdentity]` @@ -248,8 +253,14 @@ In MistDemo, integration runs targeting these endpoints use `PhaseContext.userCo **Result Types (Sources/MistKit/Models/ and Sources/MistKit/Models/Zones/):** - `QueryResult` — `records: [RecordInfo]`, `continuationMarker: String?` - `RecordChangesResult` — `records: [RecordInfo]`, `syncToken: String?`, `moreComing: Bool` -- `ZoneChangesResult` — `zones: [ZoneInfo]`, `syncToken: String?`, `moreComing: Bool` +- `ZoneChangesResult` — `zones: [ZoneInfo]`, `syncToken: String?`, `moreComing: Bool` *(deprecated `zones/changes`)* - `ZoneInfo` — `zoneName: String`, `ownerRecordName: String?`, `capabilities: [String]`, `syncToken: String?`, `atomic: Bool?` +- `DatabaseChangesResult` — `zones: [ZoneChangeResult]`, `syncToken: String?`, `moreComing: Bool`, plus `changedZones`/`failures` conveniences +- `RecordZoneChangesResult` — `zones: [ZoneRecordChangesResult]`, plus `changes`/`failures`/`moreComing` conveniences (no top-level sync token — `changes/zone` paginates per zone) +- `ZoneRecordChanges` — one zone's `records: [RecordInfo]` + that zone's own `syncToken`/`moreComing` +- `ZoneChangesRequest` — a per-zone entry in a `changes/zone` request (`zoneID` + optional per-zone overrides) + +**Per-zone failures (RecordResult pattern):** `changes/database` and `changes/zone` return an entry per zone that is *either* a success payload or a zone fetch error, modeled in `openapi.yaml` as `oneOf: [ZoneFetchFailure, ]`. These surface as `OperationResult<_, ZoneTarget>` (`ZoneChangeResult` / `ZoneRecordChangesResult`) so a failure on one zone never discards the zones that succeeded — matching the `RecordResult` pattern. `ZoneOperationFailure` is keyed by `zoneName` (CloudKit identifies the failed item by `zoneID`, not a flat string), and `.get()` throws `CloudKitError.zoneOperationFailed`. - `UserIdentity` — `userRecordName: String?`, `nameComponents: NameComponents?`, `lookupInfo: UserIdentityLookupInfo?` - `UserIdentityLookupInfo` — `emailAddress: String?`, `phoneNumber: String?`, `userRecordName: String?` - `NameComponents` — full personal name parts (givenName, familyName, nickname, etc.) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchDatabaseChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchDatabaseChangesCommand.swift new file mode 100644 index 00000000..fe18a0ef --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchDatabaseChangesCommand.swift @@ -0,0 +1,171 @@ +// +// FetchDatabaseChangesCommand.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 + +/// Command to fetch which record zones changed with incremental sync. +/// +/// Wraps `changes/database`, the current replacement for the deprecated +/// `zones/changes` operation. Reports *which* zones changed; pair with +/// `fetch-zone-record-changes` to fetch the record changes inside them. +public struct FetchDatabaseChangesCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = FetchDatabaseChangesConfig + /// The command name. + public static let commandName = "fetch-database-changes" + /// The command abstract. + public static let abstract = + "Fetch which record zones changed with incremental sync" + /// The command help text. + public static let helpText = """ + FETCH-DATABASE-CHANGES - Fetch database (zone-level) changes + + USAGE: + mistdemo fetch-database-changes [options] + + OPTIONS: + --sync-token Sync token from previous fetch + --fetch-all Auto-paginate all changes + --limit Max zone changes per page + --database Database to target + --output-format Output format + + EXAMPLES: + mistdemo fetch-database-changes + mistdemo fetch-database-changes --fetch-all + mistdemo fetch-database-changes --sync-token "token" + + NOTES: + Reports which zones changed; follow up with + fetch-zone-record-changes to fetch the records inside them. + Save the returned sync token for next fetch. + """ + + private let config: FetchDatabaseChangesConfig + + /// Creates a new instance. + public init(config: FetchDatabaseChangesConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("🔄 Fetch Database Changes") + print(String(repeating: "=", count: 60)) + + let service = try MistKitClientFactory.create( + for: config.base + ) + + printSyncTokenStatus() + + if config.fetchAll { + try await fetchAllChanges(service: service) + } else { + try await fetchSinglePage(service: service) + } + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Fetch completed!") + print(String(repeating: "=", count: 60)) + } + + private func printSyncTokenStatus() { + if let token = config.syncToken { + print(" Using sync token: \(token.prefix(20))...") + } else { + print(" Performing initial fetch (no sync token)") + } + } + + private func fetchAllChanges(service: CloudKitService) async throws { + print("\n📦 Fetching all database changes (automatic pagination)...") + let (zones, newToken) = try await service.fetchAllDatabaseChanges( + syncToken: config.syncToken, + resultsLimit: config.limit, + database: config.base.database + ) + print("\n✅ Fetched \(zones.count) changed zone(s)") + displayZones(zones) + if let token = newToken { + print("\n💾 New sync token: \(token.prefix(20))...") + print(" mistdemo fetch-database-changes --sync-token '\(token)'") + } + } + + private func fetchSinglePage(service: CloudKitService) async throws { + print("\n📄 Fetching single page...") + let result = try await service.fetchDatabaseChanges( + syncToken: config.syncToken, + resultsLimit: config.limit, + database: config.base.database + ) + print("\n✅ Fetched \(result.zones.count) zone change(s)") + displayZones(result.zones) + + if result.moreComing, let token = result.syncToken { + print("\n⚠️ More changes available!") + print(" mistdemo fetch-database-changes --sync-token '\(token)'") + } + + if let token = result.syncToken { + print("\n💾 Sync token: \(token.prefix(20))...") + } + print(" More coming: \(result.moreComing)") + } + + private func displayZones(_ zones: [ZoneChangeResult]) { + let changed = zones.compactMap { result in + if case .success(let zone) = result { return zone } + return nil + } + for zone in changed.prefix(10) { + print(" 📁 \(zone.zoneName)") + } + if changed.count > 10 { + print(" ... and \(changed.count - 10) more") + } + displayFailures( + zones.compactMap { result in + if case .failure(let failure) = result { return failure } + return nil + } + ) + } + + private func displayFailures(_ failures: [ZoneOperationFailure]) { + guard !failures.isEmpty else { return } + print("\n⚠️ \(failures.count) zone failure(s):") + for failure in failures { + print(" - \(failure.zoneName): \(failure.serverErrorCode)") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchZoneRecordChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchZoneRecordChangesCommand.swift new file mode 100644 index 00000000..a4cc0894 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchZoneRecordChangesCommand.swift @@ -0,0 +1,158 @@ +// +// FetchZoneRecordChangesCommand.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 + +/// Command to fetch record changes within one or more CloudKit zones. +/// +/// Wraps `changes/zone`. Typically paired with `fetch-database-changes`, +/// which reports *which* zones changed; this command fetches the record +/// changes inside them. Each zone paginates independently. +public struct FetchZoneRecordChangesCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = FetchZoneRecordChangesConfig + /// The command name. + public static let commandName = "fetch-zone-record-changes" + /// The command abstract. + public static let abstract = + "Fetch record changes within one or more CloudKit zones" + /// The command help text. + public static let helpText = """ + FETCH-ZONE-RECORD-CHANGES - Fetch record changes within zones + + USAGE: + mistdemo fetch-zone-record-changes [options] + + OPTIONS: + --zone-names Comma-separated zone names (default: _defaultZone) + --sync-token Sync token applied to every requested zone + --fetch-all Auto-paginate all changes (per zone) + --limit Max results per zone 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 + + EXAMPLES: + mistdemo fetch-zone-record-changes + mistdemo fetch-zone-record-changes --zone-names "Articles,Photos" + mistdemo fetch-zone-record-changes --fetch-all + + NOTES: + Each zone paginates independently — a sync token and moreComing + flag are returned per zone rather than once for the whole request. + """ + + private let config: FetchZoneRecordChangesConfig + + /// Creates a new instance. + public init(config: FetchZoneRecordChangesConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("🔄 Fetch Zone Record Changes") + print(String(repeating: "=", count: 60)) + + let service = try MistKitClientFactory.create( + for: config.base + ) + let zones = config.zones.map { + ZoneChangesRequest( + zoneID: ZoneID(zoneName: $0, ownerName: nil), + syncToken: config.syncToken, + desiredKeys: config.desiredKeys, + resultsLimit: config.limit, + desiredRecordTypes: config.desiredRecordTypes + ) + } + + print("\n📋 Requesting changes for \(zones.count) zone(s):") + for name in config.zones { + print(" - \(name)") + } + + if config.fetchAll { + try await fetchAllChanges(service: service, zones: zones) + } else { + try await fetchSinglePage(service: service, zones: zones) + } + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Fetch completed!") + print(String(repeating: "=", count: 60)) + } + + private func fetchAllChanges( + service: CloudKitService, zones: [ZoneChangesRequest] + ) async throws { + print("\n📦 Fetching all zone record changes (automatic pagination)...") + let result = try await service.fetchAllRecordZoneChanges( + zones: zones, + database: config.base.database + ) + displayResult(result) + } + + private func fetchSinglePage( + service: CloudKitService, zones: [ZoneChangesRequest] + ) async throws { + print("\n📄 Fetching single page for each zone...") + let result = try await service.fetchRecordZoneChanges( + zones: zones, + database: config.base.database + ) + displayResult(result) + } + + private func displayResult(_ result: RecordZoneChangesResult) { + print("\n✅ Fetched changes for \(result.changes.count) zone(s)") + for change in result.changes { + print(" 📁 \(change.zone.zoneName): \(change.records.count) record(s)") + if let token = change.syncToken { + print(" Sync token: \(token.prefix(20))...") + } + if change.moreComing { + print(" ⚠️ More changes available for this zone") + } + } + + if !result.failures.isEmpty { + print("\n⚠️ \(result.failures.count) zone failure(s):") + for failure in result.failures { + print(" - \(failure.zoneName): \(failure.serverErrorCode)") + } + } + + print("\n Overall more coming: \(result.moreComing)") + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift new file mode 100644 index 00000000..6147c319 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift @@ -0,0 +1,97 @@ +// +// FetchDatabaseChangesConfig.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. +// + +public import ConfigKeyKit + +/// Configuration for the `fetch-database-changes` command. +public struct FetchDatabaseChangesConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The optional sync token for incremental changes. + public let syncToken: String? + /// Whether to fetch all changes via auto-pagination. + public let fetchAll: Bool + /// The optional limit on number of zone changes to fetch per page. + public let limit: Int? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + syncToken: String? = nil, + fetchAll: Bool = false, + limit: Int? = nil, + output: OutputFormat = .table + ) { + self.base = base + self.syncToken = syncToken + self.fetchAll = fetchAll + self.limit = limit + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let syncToken = configuration.string(forKey: "sync.token") + let fetchAll = + configuration.bool(forKey: "fetch.all", default: false) + let limit = configuration.int(forKey: "limit") + let outputString = + configuration.string(forKey: "output.format", default: "table") + ?? "table" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init( + base: baseConfig, + syncToken: syncToken, + fetchAll: fetchAll, + limit: limit, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift new file mode 100644 index 00000000..c4ccb236 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift @@ -0,0 +1,126 @@ +// +// FetchZoneRecordChangesConfig.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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `fetch-zone-record-changes` command. +public struct FetchZoneRecordChangesConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The CloudKit zone names to fetch record changes from. + public let zones: [String] + /// The optional sync token applied to every requested zone. + public let syncToken: String? + /// Whether to fetch all changes via auto-pagination (per zone). + public let fetchAll: Bool + /// The optional limit on number of records to fetch per zone per page. + 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 + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zones: [String] = ["_defaultZone"], + syncToken: String? = nil, + fetchAll: Bool = false, + limit: Int? = nil, + desiredKeys: [String]? = nil, + desiredRecordTypes: [String]? = nil, + output: OutputFormat = .table + ) { + self.base = base + self.zones = zones + self.syncToken = syncToken + self.fetchAll = fetchAll + self.limit = limit + self.desiredKeys = desiredKeys + self.desiredRecordTypes = desiredRecordTypes + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let zonesString = + configuration.string(forKey: "zone.names", default: "_defaultZone") + ?? "_defaultZone" + let zones = zonesString.split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces) + } + + let syncToken = configuration.string(forKey: "sync.token") + 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" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init( + base: baseConfig, + zones: zones, + syncToken: syncToken, + fetchAll: fetchAll, + limit: limit, + desiredKeys: desiredKeys, + desiredRecordTypes: desiredRecordTypes, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/ChangeTrackingVerification.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/ChangeTrackingVerification.swift new file mode 100644 index 00000000..4bd4550e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/ChangeTrackingVerification.swift @@ -0,0 +1,142 @@ +// +// ChangeTrackingVerification.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 + +/// Shared setup and assertions for `changes/database` and `changes/zone` +/// integration phases. +internal enum ChangeTrackingVerification { + /// Provisions a uniquely-named custom zone and writes two records into it. + /// + /// CloudKit only tracks record changes in custom zones — not `_defaultZone`. + internal static func provisionCustomZone( + context: PhaseContext + ) async throws -> ChangeTrackingZoneSlot { + let zoneName = "mistkit-itest-zone-changes-\(UUID().uuidString.lowercased())" + let zoneID = ZoneID(zoneName: zoneName) + + _ = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created change-tracking zone: \(zoneName)") + } + + let recordNames = (1...2).map { _ in + "mistkit-zone-changes-\(UUID().uuidString.lowercased())" + } + let operations = recordNames.enumerated().map { index, recordName in + RecordOperation( + operationType: .forceUpdate, + recordType: MistDemoConfig.recordType, + recordName: recordName, + fields: [ + "title": .string("Zone changes \(index + 1)"), + "index": .int64(index + 1), + ] + ) + } + _ = try await context.service.modifyRecords( + operations, + zoneID: zoneID, + database: context.database + ) + + return ChangeTrackingZoneSlot(zoneID: zoneID, recordNames: recordNames) + } + + internal static func deleteCustomZone( + _ slot: ChangeTrackingZoneSlot, + context: PhaseContext + ) async throws { + if context.skipCleanup { + print( + " ⏭️ Skipping zone cleanup — inspect zone '\(slot.zoneID.zoneName)'" + ) + return + } + try await context.service.deleteZone( + zoneName: slot.zoneID.zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Deleted change-tracking zone: \(slot.zoneID.zoneName)") + } + } + + internal static func requireDatabaseSyncToken( + _ token: String?, + operation: String + ) throws { + guard let token, !token.isEmpty else { + throw IntegrationTestError.verificationFailed( + "\(operation) returned no database sync token" + ) + } + } + + internal static func requireNoZoneFailures( + _ failures: [ZoneOperationFailure], + operation: String + ) throws { + guard failures.isEmpty else { + let summary = failures.map { "\($0.zoneName): \($0.serverErrorCode)" } + .joined(separator: ", ") + throw IntegrationTestError.verificationFailed( + "\(operation) reported per-zone failure(s): \(summary)" + ) + } + } + + internal static func recordNames( + in changes: [ZoneRecordChanges] + ) -> Set { + Set( + changes.flatMap(\.records).map(\.recordName) + ) + } + + /// Returns `true` when the change feed was empty and callers should skip + /// record-level assertions (CloudKit propagation lag). + @discardableResult + internal static func warnIfChangeFeedEmpty( + foundCount: Int, + expectedNames: Set + ) -> Bool { + guard foundCount == 0, !expectedNames.isEmpty else { + return false + } + print( + " ⚠️ Change feed empty — skipping record assertions (propagation lag)" + ) + return true + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/ChangeTrackingZoneSlot.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/ChangeTrackingZoneSlot.swift new file mode 100644 index 00000000..c17162da --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/ChangeTrackingZoneSlot.swift @@ -0,0 +1,59 @@ +// +// ChangeTrackingZoneSlot.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 + +/// A custom zone plus records written for `changes/zone` integration phases. +/// +/// CloudKit rejects change tracking in `_defaultZone`; phases thread this slot +/// through ``PhaseState`` so ``FetchAllRecordZoneChangesPhase`` can tear the +/// zone down after exercising auto-pagination. +internal struct ChangeTrackingZoneSlot: PhaseStateDecodable, + PhaseStateEncodable, Sendable +{ + internal let zoneID: ZoneID + internal let recordNames: [String] + + internal init(zoneID: ZoneID, recordNames: [String]) { + self.zoneID = zoneID + self.recordNames = recordNames + } + + internal init(from state: PhaseState) throws { + guard let slot = state.changeTrackingZone else { + throw IntegrationTestError.missingPhaseState("changeTrackingZone") + } + self = slot + } + + internal func encode(to state: inout PhaseState) { + state.changeTrackingZone = self + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift index e674fa62..5d0ba451 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift @@ -42,4 +42,7 @@ internal struct PhaseState: Sendable { internal var createdRecordNames: [String] = [] internal var syncToken: String? internal var currentUser: UserInfo? + /// Custom zone provisioned by ``FetchRecordZoneChangesPhase`` for + /// ``FetchAllRecordZoneChangesPhase`` to exercise and tear down. + internal var changeTrackingZone: ChangeTrackingZoneSlot? } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllRecordZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllRecordZoneChangesPhase.swift new file mode 100644 index 00000000..6d868120 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllRecordZoneChangesPhase.swift @@ -0,0 +1,90 @@ +// +// FetchAllRecordZoneChangesPhase.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 +/// ``CloudKitService/fetchAllRecordZoneChanges(zones:reverse:desiredKeys:resultsLimit:desiredRecordTypes:maxPages:database:)`` +/// on the custom zone provisioned by ``FetchRecordZoneChangesPhase``, then +/// tears the zone down. +internal struct FetchAllRecordZoneChangesPhase: IntegrationPhase { + internal typealias Input = ChangeTrackingZoneSlot + internal typealias Output = NoState + + internal static let title = "Fetch all record zone changes" + internal static let emoji = "📚" + internal static let apiName = "fetchAllRecordZoneChanges" + + internal func run( + input: ChangeTrackingZoneSlot, + context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + do { + let result = try await context.service.fetchAllRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: input.zoneID)], + database: context.database + ) + + try ChangeTrackingVerification.requireNoZoneFailures( + result.failures, + operation: Self.apiName + ) + + let expectedNames = Set(input.recordNames) + let foundNames = ChangeTrackingVerification.recordNames(in: result.changes) + print( + "✅ Fetched changes for \(result.changes.count) zone(s), \(foundNames.count) record(s)" + ) + + if !ChangeTrackingVerification.warnIfChangeFeedEmpty( + foundCount: foundNames.count, + expectedNames: expectedNames + ) { + let matched = expectedNames.intersection(foundNames) + if context.verbose { + print(" Found \(matched.count) of \(expectedNames.count) zone record(s)") + for zone in result.changes where zone.moreComing { + print( + " ⚠️ Zone '\(zone.zone.zoneName)' still reports moreComing after fetch-all" + ) + } + } + } + + try await ChangeTrackingVerification.deleteCustomZone(input, context: context) + return NoState() + } catch { + try? await ChangeTrackingVerification.deleteCustomZone(input, context: context) + throw error + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllZoneChangesPhase.swift index 67bd7581..b3f788b1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllZoneChangesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllZoneChangesPhase.swift @@ -30,36 +30,50 @@ internal import Foundation internal import MistKit -/// Exercises ``CloudKitService/fetchAllZoneChanges(syncToken:maxPages:database:)`` -/// against a live container. Failures are non-fatal (matching -/// ``FetchZoneChangesPhase``) so test pipelines with empty zone change feeds -/// don't fail the whole suite. +/// Exercises +/// ``CloudKitService/fetchAllDatabaseChanges(syncToken:resultsLimit:maxPages:database:)`` +/// against a live container, asserting the auto-paginator completes without +/// per-zone failures and returns a continuation token. internal struct FetchAllZoneChangesPhase: IntegrationPhase { internal typealias Input = NoState internal typealias Output = NoState - internal static let title = "Fetch all zone changes" + internal static let title = "Fetch all database changes" internal static let emoji = "🔁" - internal static let apiName = "fetchAllZoneChanges" + internal static let apiName = "fetchAllDatabaseChanges" internal func run(input: NoState, context: PhaseContext) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - do { - let (zones, token) = try await context.service.fetchAllZoneChanges( - database: context.database - ) - print("✅ Fetched \(zones.count) zone(s) across all pages") - if context.verbose { - for zone in zones { - print(" - \(zone.zoneName)") - } - if let token { - print(" Sync token: \(token.prefix(30))...") - } + let (zoneResults, token) = try await context.service.fetchAllDatabaseChanges( + database: context.database + ) + + let failures = zoneResults.compactMap { result -> ZoneOperationFailure? in + if case .failure(let failure) = result { return failure } + return nil + } + try ChangeTrackingVerification.requireNoZoneFailures( + failures, + operation: Self.apiName + ) + try ChangeTrackingVerification.requireDatabaseSyncToken( + token, + operation: Self.apiName + ) + + let changedZones = zoneResults.compactMap { result -> ZoneInfo? in + if case .success(let zone) = result { return zone } + return nil + } + print("✅ Fetched \(changedZones.count) changed zone(s) across all pages") + if context.verbose { + for zone in changedZones { + print(" - \(zone.zoneName)") + } + if let token { + print(" Sync token: \(token.prefix(30))...") } - } catch { - print("⚠️ fetchAllZoneChanges failed (non-fatal): \(error)") } return NoState() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchRecordZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchRecordZoneChangesPhase.swift new file mode 100644 index 00000000..bfb6346b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchRecordZoneChangesPhase.swift @@ -0,0 +1,89 @@ +// +// FetchRecordZoneChangesPhase.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 +/// ``CloudKitService/fetchRecordZoneChanges(zones:reverse:desiredKeys:resultsLimit:desiredRecordTypes:database:)`` +/// against a live custom zone. CloudKit rejects `changes/zone` on +/// `_defaultZone`, so this phase provisions its own zone and records rather +/// than reusing the default-zone records created earlier in the pipeline. +internal struct FetchRecordZoneChangesPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = ChangeTrackingZoneSlot + + internal static let title = "Fetch record zone changes" + internal static let emoji = "📁" + internal static let apiName = "fetchRecordZoneChanges" + + internal func run( + input: NoState, + context: PhaseContext + ) async throws -> ChangeTrackingZoneSlot { + print("\n\(Self.emoji) \(Self.title)") + + let slot = try await ChangeTrackingVerification.provisionCustomZone( + context: context + ) + + do { + let result = try await context.service.fetchRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: slot.zoneID)], + database: context.database + ) + + try ChangeTrackingVerification.requireNoZoneFailures( + result.failures, + operation: Self.apiName + ) + + let expectedNames = Set(slot.recordNames) + let foundNames = ChangeTrackingVerification.recordNames(in: result.changes) + print( + "✅ Fetched changes for \(result.changes.count) zone(s), \(foundNames.count) record(s)" + ) + + if !ChangeTrackingVerification.warnIfChangeFeedEmpty( + foundCount: foundNames.count, + expectedNames: expectedNames + ) { + let matched = expectedNames.intersection(foundNames) + if context.verbose { + print(" Found \(matched.count) of \(expectedNames.count) zone record(s)") + } + } + + return slot + } catch { + try? await ChangeTrackingVerification.deleteCustomZone(slot, context: context) + throw error + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift index bd536a02..e0a63b9e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift @@ -34,26 +34,53 @@ internal struct FetchZoneChangesPhase: IntegrationPhase { internal typealias Input = NoState internal typealias Output = NoState - internal static let title = "Fetch zone changes" + internal static let title = "Fetch database changes" internal static let emoji = "🔄" - internal static let apiName = "fetchZoneChanges" + internal static let apiName = "fetchDatabaseChanges" internal func run(input: NoState, context: PhaseContext) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - do { - let result = try await context.service.fetchZoneChanges(database: context.database) - print("✅ Fetched \(result.zones.count) zone(s)") - if context.verbose { - for zone in result.zones { - print(" - \(zone.zoneName)") - } - if let token = result.syncToken { - print(" Sync token: \(token.prefix(30))...") - } + let initial = try await context.service.fetchDatabaseChanges( + database: context.database + ) + try ChangeTrackingVerification.requireNoZoneFailures( + initial.failures, + operation: Self.apiName + ) + try ChangeTrackingVerification.requireDatabaseSyncToken( + initial.syncToken, + operation: Self.apiName + ) + + print("✅ Fetched \(initial.changedZones.count) changed zone(s)") + if context.verbose { + for zone in initial.changedZones { + print(" - \(zone.zoneName)") + } + if let token = initial.syncToken { + print(" Sync token: \(token.prefix(30))...") } - } catch { - print("⚠️ fetchZoneChanges failed (non-fatal): \(error)") + print(" More coming: \(initial.moreComing)") + } + + let incremental = try await context.service.fetchDatabaseChanges( + syncToken: initial.syncToken, + database: context.database + ) + try ChangeTrackingVerification.requireNoZoneFailures( + incremental.failures, + operation: "\(Self.apiName) (incremental)" + ) + try ChangeTrackingVerification.requireDatabaseSyncToken( + incremental.syncToken, + operation: "\(Self.apiName) (incremental)" + ) + + if context.verbose { + print( + " ✅ Incremental fetch returned \(incremental.changedZones.count) changed zone(s)" + ) } return NoState() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index ffc21aca..8b92d51b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -45,8 +45,6 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { ModifyZonesPhase(), LookupZonePhase(), ZoneRoundtripPhase(), - FetchZoneChangesPhase(), - FetchAllZoneChangesPhase(), UploadAssetPhase(), CreateRecordsPhase(), RereferenceAssetPhase(), @@ -54,6 +52,10 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { LookupRecordsPhase(), InitialSyncPhase(), ModifyRecordsPhase(), + FetchZoneChangesPhase(), + FetchAllZoneChangesPhase(), + FetchRecordZoneChangesPhase(), + FetchAllRecordZoneChangesPhase(), IncrementalSyncPhase(), QueryRequestOptionsPhase(), CustomZoneQueryPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift index 1bedc3d7..d96a6e7d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift @@ -63,6 +63,8 @@ public enum MistDemoRunner { await registry.register(ValidateCommand.self) await registry.register(DeleteZoneCommand.self) await registry.register(FetchChangesCommand.self) + await registry.register(FetchDatabaseChangesCommand.self) + await registry.register(FetchZoneRecordChangesCommand.self) await registry.register(TestPublicCommand.self) await registry.register(TestPrivateCommand.self) await registry.register(DemoErrorsCommand.self) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 14f41def..6a6ed0d3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -188,9 +188,9 @@

Resolve records/resolve (MistKit pending #4 - +
-

Zones zones/list · zones/lookup · zones/modify · zones/changes

+

Zones zones/list · zones/lookup · zones/modify · changes/database · changes/zone

List zones/list

@@ -234,14 +234,27 @@

Modify zones/modify

(none yet)
-

Changes zones/changes

+

Database changes changes/database

- + +
+
+
(none yet)
+
+

Zone record changes changes/zone

+
+ + + +
+
+
(none yet)
+
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/zones.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/zones.js index 8c8bb555..ddb561c4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/zones.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/zones.js @@ -1,9 +1,6 @@ -// zones/list · zones/lookup · zones/modify · zones/changes panel handlers. -// zones/modify is wired on the demo server (POST /api/zones/modify), so the -// MistKit-mode Create/Delete buttons hit the real CloudKitService. The other -// three (list/lookup/changes) have landed MistKit wrappers (#215, #45, #48, -// #367) but aren't yet exposed on the server, so MistKit-mode calls to them -// still 404. CloudKit JS calls are fully exercisable today. +// zones/list · zones/lookup · zones/modify · changes/database · changes/zone +// panel handlers. All five endpoints are wired on the demo server in MistKit +// mode; CloudKit JS mode hits the browser SDK directly. const zonesListStatus = document.getElementById('zones-list-status'); const zonesListRaw = document.getElementById('zones-list-raw'); @@ -14,6 +11,34 @@ const zonesModifyStatus = document.getElementById('zones-modify-status'); const zonesModifyRaw = document.getElementById('zones-modify-raw'); const zonesChangesStatus = document.getElementById('zones-changes-status'); const zonesChangesRaw = document.getElementById('zones-changes-raw'); +const zonesChangesUseZonesBtn = document.getElementById('zones-changes-use-zones-btn'); +const zonesRecordChangesZones = document.getElementById('zones-record-changes-zones'); +const zonesRecordChangesToken = document.getElementById('zones-record-changes-token'); +const zonesRecordChangesStatus = document.getElementById('zones-record-changes-status'); +const zonesRecordChangesRaw = document.getElementById('zones-record-changes-raw'); + +// Last successful changes/database payload — powers "Use changed zones →". +let lastDatabaseChangesPayload = null; + +function zoneNameFromEntry(entry) { + if (!entry) return null; + if (entry.zoneName) return entry.zoneName; + if (entry.zoneID && entry.zoneID.zoneName) return entry.zoneID.zoneName; + if (entry.zone && entry.zone.zoneName) return entry.zone.zoneName; + return null; +} + +function changedZoneNamesFromDatabaseChanges(payload) { + const zones = (payload && payload.zones) || []; + if (!Array.isArray(zones)) return []; + return zones.map(zoneNameFromEntry).filter(Boolean); +} + +function updateUseChangedZonesButton() { + if (!zonesChangesUseZonesBtn) return; + const names = changedZoneNamesFromDatabaseChanges(lastDatabaseChangesPayload); + zonesChangesUseZonesBtn.disabled = names.length === 0; +} // Both the MistKit (`/api/zones/list`) and CloudKit JS // (`fetchAllRecordZones`) responses wrap the zone array under `zones`; @@ -112,10 +137,10 @@ document.getElementById('zones-modify-delete-btn').addEventListener('click', asy document.getElementById('zones-changes-btn').addEventListener('click', async () => { const token = document.getElementById('zones-changes-token').value.trim() || undefined; - await runPanelOperation({ + const payload = await runPanelOperation({ statusEl: zonesChangesStatus, rawEl: zonesChangesRaw, - label: 'Zone changes', + label: 'Database changes', fn: async () => { if (currentMode === 'mistkit') { return await postJSON('/api/zones/changes', { @@ -123,13 +148,68 @@ document.getElementById('zones-changes-btn').addEventListener('click', async () syncToken: token, }); } - // CloudKit JS doesn't expose a direct zones/changes — the - // equivalent is composed per-zone via fetchRecordChanges, so - // surface that pedagogical asymmetry inline. - // CloudKit JS does expose a database-level changes primitive - // (`fetchDatabaseChanges`, returning changed record zones), which - // is the closest analog to the REST zones/changes endpoint. + // CloudKit JS exposes `fetchDatabaseChanges` (changed record zones), + // the closest analog to REST `changes/database`. return await ckJsDatabase().fetchDatabaseChanges({ syncToken: token }); }, }); + if (payload) { + lastDatabaseChangesPayload = payload; + updateUseChangedZonesButton(); + } +}); + +if (zonesChangesUseZonesBtn) { + zonesChangesUseZonesBtn.addEventListener('click', () => { + const names = changedZoneNamesFromDatabaseChanges(lastDatabaseChangesPayload); + if (names.length === 0) { + setStatus(zonesChangesStatus, 'No changed zones in the last database-changes response.', 'error'); + return; + } + if (zonesRecordChangesZones) { + zonesRecordChangesZones.value = names.join(', '); + } + setStatus( + zonesChangesStatus, + `Prefilled ${names.length} zone name(s) for changes/zone.`, + 'success' + ); + }); +} + +document.getElementById('zones-record-changes-btn').addEventListener('click', async () => { + const zoneNames = csv(zonesRecordChangesZones.value); + if (zoneNames.length === 0) { + setStatus(zonesRecordChangesStatus, 'Provide at least one zone name.', 'error'); + return; + } + const syncToken = zonesRecordChangesToken.value.trim() || undefined; + await runPanelOperation({ + statusEl: zonesRecordChangesStatus, + rawEl: zonesRecordChangesRaw, + label: 'Zone record changes', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/changes/zone', { + database: currentDatabase, + zones: zoneNames.map(zoneName => ({ + zoneName, + syncToken, + })), + }); + } + const results = []; + for (const zoneName of zoneNames) { + const payload = await ckJsDatabase().fetchRecordZoneChanges({ + zoneID: { zoneName }, + syncToken, + }); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || `CloudKit JS changes failed for ${zoneName}`); + } + results.push({ zoneName, payload }); + } + return { zones: results }; + }, + }); }); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Reads.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Reads.swift index 4c12fc8a..9c36b638 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Reads.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Reads.swift @@ -83,7 +83,14 @@ extension CloudKitService { internal func webZoneChanges( syncToken: String?, database: MistKit.Database - ) async throws -> ZoneChangesResult { - try await fetchZoneChanges(syncToken: syncToken, database: database) + ) async throws -> DatabaseChangesResult { + try await fetchDatabaseChanges(syncToken: syncToken, database: database) + } + + internal func webRecordZoneChanges( + zones: [ZoneChangesRequest], + database: MistKit.Database + ) async throws -> RecordZoneChangesResult { + try await fetchRecordZoneChanges(zones: zones, database: database) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 6105540e..b5970795 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -99,7 +99,12 @@ internal protocol WebBackend: Sendable { func webZoneChanges( syncToken: String?, database: MistKit.Database - ) async throws -> ZoneChangesResult + ) async throws -> DatabaseChangesResult + + func webRecordZoneChanges( + zones: [ZoneChangesRequest], + database: MistKit.Database + ) async throws -> RecordZoneChangesResult func webFetchCaller() async throws -> UserInfo diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Zones.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Zones.swift index 2ba551a5..3c230f49 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Zones.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Zones.swift @@ -107,7 +107,8 @@ extension WebRequests { } /// `POST /api/zones/changes` — database-level zone changes since an optional - /// continuation `syncToken`. + /// continuation `syncToken`. Backed by `changes/database`, the current + /// replacement for the deprecated `zones/changes` operation. internal struct ZoneChanges: Decodable { private enum CodingKeys: String, CodingKey { case syncToken @@ -127,4 +128,32 @@ extension WebRequests { ) } } + + /// `POST /api/changes/zone` — record changes within one or more zones. + /// Each entry may carry its own continuation `syncToken`; a bare zone name + /// defaults to an initial fetch of that zone. + internal struct ZoneRecordChanges: Decodable { + /// A single requested zone, with its own optional `syncToken`. + internal struct ZoneRequest: Decodable, Sendable { + internal let zoneName: String + internal let syncToken: String? + } + + private enum CodingKeys: String, CodingKey { + case zones + case database + } + + internal let zones: [ZoneRequest] + internal let database: MistKit.Database + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.zones = + try container.decodeIfPresent([ZoneRequest].self, forKey: .zones) ?? [] + self.database = try WebRequests.decodeDatabase( + from: container, forKey: .database + ) + } + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift index 2f5fcb03..67655019 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift @@ -58,20 +58,54 @@ internal enum WebResponse { } } - /// Body returned by `zones/changes`: the changed zones plus the continuation - /// `syncToken` and `moreComing` flag from `ZoneChangesResult`. + /// Body returned by `zones/changes` (backed by `changes/database`, the + /// current replacement for the deprecated `zones/changes` operation): the + /// changed zones plus any per-zone failures and the continuation + /// `syncToken` / `moreComing` flag from `DatabaseChangesResult`. internal struct ZoneChanges: Encodable { internal let zones: [ZoneInfo] + internal let failures: [ZoneOperationFailure] internal let syncToken: String? internal let moreComing: Bool - internal init(from result: ZoneChangesResult) { - self.zones = result.zones + internal init(from result: DatabaseChangesResult) { + self.zones = result.changedZones + self.failures = result.failures self.syncToken = result.syncToken self.moreComing = result.moreComing } } + /// Body returned by `POST /api/changes/zone`: per-zone record changes, + /// mirroring `RecordZoneChangesResult`. `changes/zone` paginates + /// independently per zone, so there is no top-level `syncToken` — each + /// zone entry carries its own. + internal struct ZoneRecordChanges: Encodable { + /// One requested zone's outcome: its record changes plus its own + /// continuation `syncToken` and `moreComing` flag. + internal struct Zone: Encodable { + internal let zone: ZoneInfo + internal let records: [RecordInfo] + internal let syncToken: String? + internal let moreComing: Bool + + internal init(from changes: MistKit.ZoneRecordChanges) { + self.zone = changes.zone + self.records = changes.records + self.syncToken = changes.syncToken + self.moreComing = changes.moreComing + } + } + + internal let zones: [Zone] + internal let failures: [ZoneOperationFailure] + + internal init(from result: RecordZoneChangesResult) { + self.zones = result.changes.map(Zone.init(from:)) + self.failures = result.failures + } + } + /// Body returned by `users/caller`: the calling user's `UserInfo`. internal struct Caller: Encodable { internal let user: UserInfo diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Zones.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Zones.swift index ff428922..5356bb5a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Zones.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Zones.swift @@ -33,7 +33,8 @@ internal import MistKit extension WebServer { - /// Register every zone route: `modify`, `list`, `lookup`, and `changes`. + /// Register every zone route: `modify`, `list`, `lookup`, `changes`, and + /// the follow-up `changes/zone` record-level route. internal func addZonesEndpoints( api: RouterGroup ) { @@ -41,6 +42,7 @@ addZonesListEndpoint(api: api) addZonesLookupEndpoint(api: api) addZonesChangesEndpoint(api: api) + addZoneRecordChangesEndpoint(api: api) } /// `POST /api/zones/modify` — create and/or delete zones in one batch, @@ -123,7 +125,11 @@ } /// `POST /api/zones/changes` — database-level zone changes since an - /// optional continuation `syncToken`. + /// optional continuation `syncToken`. Backed by `changes/database`, the + /// current replacement for the deprecated `zones/changes` operation; + /// reports *which* zones changed. Follow up with `changes/zone` + /// (``addZoneRecordChangesEndpoint(api:)``) to fetch the record changes + /// inside them. private func addZonesChangesEndpoint( api: RouterGroup ) { @@ -148,5 +154,39 @@ } } } + + /// `POST /api/changes/zone` — record changes within one or more zones, + /// mirroring CloudKit Web Services `changes/zone`. Each zone paginates + /// independently, carrying its own `syncToken` and `moreComing` flag. + private func addZoneRecordChangesEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("changes/zone") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.ZoneRecordChanges.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let zones = body.zones.map { + ZoneChangesRequest( + zoneID: ZoneID(zoneName: $0.zoneName), + syncToken: $0.syncToken + ) + } + let result = try await backend.webRecordZoneChanges( + zones: zones, + database: body.database + ) + return try WebJSON.encoder().encode( + WebResponse.ZoneRecordChanges(from: result) + ) + } + } + } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/FetchDatabaseChangesCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/FetchDatabaseChangesCommandTests.swift new file mode 100644 index 00000000..cea37c19 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/FetchDatabaseChangesCommandTests.swift @@ -0,0 +1,56 @@ +// +// FetchDatabaseChangesCommandTests.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 + +@Suite("FetchDatabaseChangesCommand Tests") +internal struct FetchDatabaseChangesCommandTests { + @Test("Command has correct static properties") + internal func staticProperties() { + #expect(FetchDatabaseChangesCommand.commandName == "fetch-database-changes") + #expect( + FetchDatabaseChangesCommand.abstract + == "Fetch which record zones changed with incremental sync" + ) + #expect( + FetchDatabaseChangesCommand.helpText.contains("FETCH-DATABASE-CHANGES") + ) + } + + @Test("Config defaults") + internal func configDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchDatabaseChangesConfig(base: baseConfig) + #expect(config.fetchAll == false) + #expect(config.output == .table) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/FetchZoneRecordChangesCommandTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/FetchZoneRecordChangesCommandTests.swift new file mode 100644 index 00000000..0f2f61e5 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/FetchZoneRecordChangesCommandTests.swift @@ -0,0 +1,57 @@ +// +// FetchZoneRecordChangesCommandTests.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 + +@Suite("FetchZoneRecordChangesCommand Tests") +internal struct FetchZoneRecordChangesCommandTests { + @Test("Command has correct static properties") + internal func staticProperties() { + #expect(FetchZoneRecordChangesCommand.commandName == "fetch-zone-record-changes") + #expect( + FetchZoneRecordChangesCommand.abstract + == "Fetch record changes within one or more CloudKit zones" + ) + #expect( + FetchZoneRecordChangesCommand.helpText.contains("FETCH-ZONE-RECORD-CHANGES") + ) + } + + @Test("Config defaults") + internal func configDefaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchZoneRecordChangesConfig(base: baseConfig) + #expect(config.zones == ["_defaultZone"]) + #expect(config.fetchAll == false) + #expect(config.output == .table) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchDatabaseChangesConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchDatabaseChangesConfigTests.swift new file mode 100644 index 00000000..38ac533f --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchDatabaseChangesConfigTests.swift @@ -0,0 +1,90 @@ +// +// FetchDatabaseChangesConfigTests.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 + +@Suite("FetchDatabaseChangesConfig Tests") +internal struct FetchDatabaseChangesConfigTests { + @Test("FetchDatabaseChangesConfig defaults fetchAll false, output table") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchDatabaseChangesConfig(base: baseConfig) + + #expect(config.syncToken == nil) + #expect(config.fetchAll == false) + #expect(config.limit == nil) + #expect(config.output == .table) + } + + @Test("FetchDatabaseChangesConfig accepts custom syncToken and limit") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchDatabaseChangesConfig( + base: baseConfig, + syncToken: "tok-1", + fetchAll: true, + limit: 50 + ) + + #expect(config.syncToken == "tok-1") + #expect(config.fetchAll == true) + #expect(config.limit == 50) + } + + @Test( + "FetchDatabaseChangesConfig output formats round-trip", + arguments: [OutputFormat.json, .table, .csv, .yaml] + ) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchDatabaseChangesConfig(base: baseConfig, output: format) + + #expect(config.output == format) + } + + @Test("FetchDatabaseChangesConfig fetchAll true with no syncToken parses as initial fetch") + internal func initialFetchSemantics() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchDatabaseChangesConfig(base: baseConfig, fetchAll: true) + + #expect(config.fetchAll == true) + #expect(config.syncToken == nil) + } + + @Test("FetchDatabaseChangesConfig limit nil means fetch with default page size") + internal func nilLimit() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchDatabaseChangesConfig(base: baseConfig, limit: nil) + + #expect(config.limit == nil) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchZoneRecordChangesConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchZoneRecordChangesConfigTests.swift new file mode 100644 index 00000000..81810782 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/FetchZoneRecordChangesConfigTests.swift @@ -0,0 +1,107 @@ +// +// FetchZoneRecordChangesConfigTests.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 + +@Suite("FetchZoneRecordChangesConfig Tests") +internal struct FetchZoneRecordChangesConfigTests { + @Test("FetchZoneRecordChangesConfig defaults zones to _defaultZone, fetchAll false, output table") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchZoneRecordChangesConfig(base: baseConfig) + + #expect(config.zones == ["_defaultZone"]) + #expect(config.syncToken == nil) + #expect(config.fetchAll == false) + #expect(config.limit == nil) + #expect(config.output == .table) + } + + @Test("FetchZoneRecordChangesConfig accepts multiple zones preserving order") + internal func multipleZones() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchZoneRecordChangesConfig( + base: baseConfig, + zones: ["Articles", "Photos"] + ) + + #expect(config.zones == ["Articles", "Photos"]) + } + + @Test("FetchZoneRecordChangesConfig accepts custom syncToken and limit") + internal func customValues() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchZoneRecordChangesConfig( + base: baseConfig, + syncToken: "tok-1", + fetchAll: true, + limit: 50 + ) + + #expect(config.syncToken == "tok-1") + #expect(config.fetchAll == true) + #expect(config.limit == 50) + } + + @Test( + "FetchZoneRecordChangesConfig output formats round-trip", + arguments: [OutputFormat.json, .table, .csv, .yaml] + ) + internal func outputFormats(format: OutputFormat) async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchZoneRecordChangesConfig(base: baseConfig, output: format) + + #expect(config.output == format) + } + + @Test("FetchZoneRecordChangesConfig defaults desiredKeys and desiredRecordTypes to nil") + internal func changeFeedOptionsDefaultToNil() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchZoneRecordChangesConfig(base: baseConfig) + + #expect(config.desiredKeys == nil) + #expect(config.desiredRecordTypes == nil) + } + + @Test("FetchZoneRecordChangesConfig carries explicit desiredKeys and desiredRecordTypes") + internal func carriesChangeFeedOptions() async throws { + let baseConfig = try await MistDemoConfig() + let config = FetchZoneRecordChangesConfig( + 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/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index 3baba4b0..c0af44f4 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -106,6 +106,12 @@ internal let database: MistKit.Database } + /// Captured arguments from the most recent `webRecordZoneChanges` call. + internal struct ZoneRecordChangesCall: Sendable { + internal let zones: [ZoneChangesRequest] + internal let database: MistKit.Database + } + /// Captured arguments from the most recent `webDiscoverUsers` call. internal struct DiscoverUsersCall: Sendable { internal let emails: [String] diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift index 62b908c1..cbcdf717 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift @@ -185,21 +185,52 @@ internal func webZoneChanges( syncToken: String?, database: MistKit.Database - ) async throws -> ZoneChangesResult { + ) async throws -> DatabaseChangesResult { lastZoneChanges = ZoneChangesCall( syncToken: syncToken, database: database ) try consumePendingError() - return ZoneChangesResult( + return DatabaseChangesResult( zones: [ - ZoneInfo( - zoneName: "_defaultZone", ownerRecordName: nil, capabilities: [] + .success( + ZoneInfo( + zoneName: "_defaultZone", ownerRecordName: nil, capabilities: [] + ) ) ], syncToken: "stub-zone-sync-token", moreComing: false ) } + + internal func webRecordZoneChanges( + zones: [ZoneChangesRequest], + database: MistKit.Database + ) async throws -> RecordZoneChangesResult { + lastZoneRecordChanges = ZoneRecordChangesCall( + zones: zones, + database: database + ) + try consumePendingError() + return RecordZoneChangesResult( + zones: zones.map { request in + .success( + ZoneRecordChanges( + zone: ZoneInfo( + zoneName: request.zoneID.zoneName, + ownerRecordName: nil, + capabilities: [] + ), + records: [ + Self.stubRecord(recordType: "Note", recordName: "changed-1") + ], + syncToken: "stub-zone-record-sync-token", + moreComing: false + ) + ) + } + ) + } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index 8d0d5ab8..a086d500 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -52,6 +52,7 @@ internal var lastListZones: ListZonesCall? internal var lastLookupZones: LookupZonesCall? internal var lastZoneChanges: ZoneChangesCall? + internal var lastZoneRecordChanges: ZoneRecordChangesCall? internal var didFetchCaller = false internal var lastDiscoverUsers: DiscoverUsersCall? internal var didListSubscriptions = false diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+ZoneReads.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+ZoneReads.swift index 1f7fa0d3..98c5493d 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+ZoneReads.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+ZoneReads.swift @@ -48,6 +48,16 @@ let moreComing: Bool } + private struct ZoneRecordChangesPayload: Decodable { + struct Zone: Decodable { + let zone: ZoneInfo + let syncToken: String? + let moreComing: Bool + } + + let zones: [Zone] + } + @Test("POST /api/zones/list forwards the database to the backend") internal func zonesListForwards() async throws { let fixture = Self.makeFixture(authenticated: true) @@ -178,5 +188,56 @@ } } } + + @Test("POST /api/changes/zone forwards zones and sync tokens to the backend") + internal func changesZoneForwards() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = #""" + {"database":"private","zones":[ + {"zoneName":"Articles","syncToken":"token-a"}, + {"zoneName":"Photos"} + ]} + """# + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/changes/zone", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + let payload = try JSONDecoder().decode( + ZoneRecordChangesPayload.self, + from: Data(response.body.readableBytesView) + ) + #expect(payload.zones.map(\.zone.zoneName) == ["Articles", "Photos"]) + #expect(payload.zones.allSatisfy { $0.syncToken != nil }) + } + } + + let captured = await fixture.backend.lastZoneRecordChanges + #expect(captured?.zones.map(\.zoneID.zoneName) == ["Articles", "Photos"]) + #expect(captured?.zones.map(\.syncToken) == ["token-a", nil]) + #expect(captured?.database == .private) + } + + @Test("POST /api/changes/zone returns 401 without a captured auth token") + internal func changesZoneRequiresAuth() async throws { + let fixture = Self.makeFixture(authenticated: false) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/changes/zone", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"zones":[{"zoneName":"Articles"}]}"#) + ) { response in + #expect(response.status == .unauthorized) + } + } + } } #endif diff --git a/README.md b/README.md index adb4b365..918ae128 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,55 @@ let assets = try await service.rereferenceAssets( ) ``` +#### Change Tracking + +CloudKit exposes four change-tracking endpoints. MistKit wraps all four; each +single-request primitive has an auto-paginating `fetchAll…` companion. + +| Apple endpoint | Purpose | MistKit method | Auto-paginating | +|---|---|---|---| +| `records/changes` | Fetching Record Changes | `fetchRecordChanges` | `fetchAllRecordChanges` | +| `changes/database` | Fetching Database Changes — *which zones* changed | `fetchDatabaseChanges` | `fetchAllDatabaseChanges` | +| `changes/zone` | Fetching Record Zone Changes — records *within* zones | `fetchRecordZoneChanges` | `fetchAllRecordZoneChanges` | +| `zones/changes` | Fetching Zone Changes — **deprecated by Apple** | ~~`fetchZoneChanges`~~ | ~~`fetchAllZoneChanges`~~ | + +> `zones/changes` is deprecated by Apple in favor of `changes/database`, so +> `fetchZoneChanges` / `fetchAllZoneChanges` are marked `@available(*, deprecated)`. +> Use `fetchDatabaseChanges` instead. + +The typical database-sync flow asks *which zones changed*, then fetches the +records inside them: + +```swift +// 1. Which zones changed? +let database = try await service.fetchDatabaseChanges( + syncToken: lastDatabaseToken, + database: .private +) + +// 2. What changed inside them? +let result = try await service.fetchAllRecordZoneChanges( + zones: database.changedZones.map { + ZoneChangesRequest(zoneID: ZoneID(zoneName: $0.zoneName)) + }, + database: .private +) + +for change in result.changes { + print("\(change.zone.zoneName): \(change.records.count) changed") + // Persist change.syncToken per zone — each zone paginates independently. +} +``` + +Both operations report per-zone problems as data rather than throwing, so one +bad zone never discards the zones that succeeded: + +```swift +for failure in result.failures { + print("\(failure.zoneName) failed: \(failure.serverErrorCode.rawValue)") +} +``` + #### Auto-Chunking Conveniences CloudKit caps batch requests at 200 items. `lookupAllRecords` and the @@ -451,7 +500,7 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Discovering User Identities (POST users/discover)](https://github.com/brightdigit/MistKit/issues/27) ✅ - [x] [Fetching Record Changes (records/changes)](https://github.com/brightdigit/MistKit/issues/40) ✅ - [x] [Fetching Zones by Identifier (zones/lookup)](https://github.com/brightdigit/MistKit/issues/44) ✅ -- [x] [Fetching Zone Changes (zones/changes)](https://github.com/brightdigit/MistKit/issues/48) ✅ +- [x] [Fetching Zone Changes (zones/changes)](https://github.com/brightdigit/MistKit/issues/48) ✅ *(Apple-deprecated — prefer `fetchDatabaseChanges`)* - [x] [Fix QueryFilter IN/NOT_IN serialization](https://github.com/brightdigit/MistKit/issues/192) ✅ ### v1.0.0-beta.1 @@ -499,13 +548,14 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Fetching Record Information (records/resolve)](https://github.com/brightdigit/MistKit/issues/41) ✅ - [x] [Accepting Share Records (records/accept)](https://github.com/brightdigit/MistKit/issues/42) ✅ - [x] [Curated createShare for share URL creation](https://github.com/brightdigit/MistKit/issues/437) ✅ +- [x] [Fetching Database Changes (changes/database)](https://github.com/brightdigit/MistKit/issues/46) ✅ +- [x] [Fetching Record Zone Changes (changes/zone)](https://github.com/brightdigit/MistKit/issues/47) ✅ +- [x] [Clarify change-tracking endpoint coverage](https://github.com/brightdigit/MistKit/issues/401) ✅ *(deprecates `zones/changes` in favor of `changes/database`)* ### Backlog / Post-beta - [ ] [Discovering All User Identities (GET users/discover)](https://github.com/brightdigit/MistKit/issues/28) - [ ] [Fetching Contacts (users/lookup/contacts)](https://github.com/brightdigit/MistKit/issues/33) -- [ ] [Fetching Database Changes (changes/database)](https://github.com/brightdigit/MistKit/issues/46) -- [ ] [Fetching Record Zone Changes (changes/zone)](https://github.com/brightdigit/MistKit/issues/47) - [ ] [Feature: Add custom CloudKit zone support for queries](https://github.com/brightdigit/MistKit/issues/146) ### v1.0.0 diff --git a/Scripts/lint.sh b/Scripts/lint.sh index ec4b892c..7cb27c71 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -78,13 +78,17 @@ $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "Bright # Generated files now automatically include ignore directives via OpenAPI generator configuration -# Periphery does not run in Claude Code web sessions: it would have to be built -# from source there (no Linux binaries, and the session's GitHub gateway rules -# out mise), which is not worth the cold-start cost. -if [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then +# Periphery is temporarily skipped: Swift PM now writes the index store under a +# triple-specific path (e.g. `.build/arm64-apple-macosx/debug/index/store`) and +# periphery still looks for `.build/debug/index/store`. Re-enable once periphery +# or this script resolves the path. Also skipped in Claude Code web sessions +# (no Linux binaries; mise unreachable there). +if [ "${RUN_PERIPHERY:-}" = "1" ] \ + && [ -z "$CI" ] \ + && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check -elif [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then - echo "Skipping periphery scan (Claude Code web session)." +else + echo "Skipping periphery scan (set RUN_PERIPHERY=1 to opt in)." fi popd diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift index 1da2ae11..de86aaa3 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift @@ -54,6 +54,8 @@ extension CloudKitError { + (conversionError.errorDescription ?? "\(conversionError)") case .recordOperationFailed(let recordError): return Self.recordOperationDescription(recordError) + case .zoneOperationFailed(let zoneError): + return Self.zoneOperationDescription(zoneError) case .subscriptionOperationFailed(let subscriptionError): return Self.subscriptionOperationDescription(subscriptionError) case .subscriptionLikelyDuplicate(let subscriptionError): @@ -71,9 +73,7 @@ extension CloudKitError { "CloudKit query exceeded pagination limit of \(maxPages) pages " + "(collected \(records.count) records)" case .zonePaginationLimitExceeded(let maxPages, let zones): - return - "CloudKit zone-changes exceeded pagination limit of \(maxPages) pages " - + "(collected \(zones.count) zones)" + return Self.zonePaginationDescription(maxPages: maxPages, zoneCount: zones.count) case .missingCredentials(let database, let availability, let reason): return Self.missingCredentialsDescription( database: database, availability: availability, reason: reason diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift index e4206b89..0a944ac6 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift @@ -84,7 +84,7 @@ extension CloudKitError { reason: reason ) case .httpError, .httpErrorWithDetails, .httpErrorWithRawResponse, .invalidResponse, - .incompleteResponse, .conversionFailed, .recordOperationFailed, + .incompleteResponse, .conversionFailed, .recordOperationFailed, .zoneOperationFailed, .subscriptionOperationFailed, .subscriptionLikelyDuplicate, .underlyingError, .decodingError, .networkError, .unsupportedOperationType, .paginationLimitExceeded, .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey: diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ZoneErrorDescription.swift b/Sources/MistKit/CloudKitService/CloudKitError+ZoneErrorDescription.swift new file mode 100644 index 00000000..45a3398a --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitError+ZoneErrorDescription.swift @@ -0,0 +1,55 @@ +// +// CloudKitError+ZoneErrorDescription.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 CloudKitError { + /// Renders a per-zone operation failure into a human-readable description. + /// + /// Split out of `CloudKitError+ErrorDescription.swift` to keep that file + /// within the file-length limit. + internal static func zoneOperationDescription(_ zoneError: ZoneOperationFailure) -> String { + let identifier = zoneError.identifier + let code = zoneError.serverErrorCode.rawValue + var message = "CloudKit zone operation failed for '\(identifier)' (\(code))" + if let reason = zoneError.reason { + message += "\nReason: \(reason)" + } + return message + } + + /// Describes exhausting the zone-changes pagination ceiling. + internal static func zonePaginationDescription( + maxPages: Int, + zoneCount: Int + ) -> String { + "CloudKit zone-changes exceeded pagination limit of \(maxPages) pages " + + "(collected \(zoneCount) zones)" + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index 8e7b48c8..37a22929 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -104,6 +104,10 @@ public enum CloudKitError: LocalizedError, Sendable { /// back as a `RecordOperationFailure`, surfaced by a single-record /// convenience (`createRecord`/`updateRecord`/`deleteRecord`). case recordOperationFailed(RecordOperationFailure) + /// A per-zone entry in a `changes/database` / `changes/zone` response came + /// back as a zone fetch error, surfaced by ``OperationResult/get()`` on a + /// ``ZoneChangeResult``. + case zoneOperationFailed(ZoneOperationFailure) /// A per-subscription operation in a `modifySubscriptions` batch came back as /// a `SubscriptionOperationFailure`, surfaced by the single-subscription /// convenience (`createSubscription`). diff --git a/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Changes.swift b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Changes.swift index c9cfb9d2..e478f67e 100644 --- a/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Changes.swift +++ b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Changes.swift @@ -130,6 +130,40 @@ extension CloudKitResponseProcessor { } } + /// Process fetchDatabaseChanges response + internal func processFetchDatabaseChangesResponse( + _ response: Operations.fetchDatabaseChanges.Output + ) async throws(CloudKitError) -> Components.Schemas.DatabaseChangesResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let changesData): + return changesData + } + case .badRequest, .unauthorized, .forbidden, .notFound, .conflict, + .preconditionFailed, .contentTooLarge, .misdirectedRequest, + .tooManyRequests, .internalServerError, .serviceUnavailable, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } + + /// Process fetchRecordZoneChanges response + internal func processFetchRecordZoneChangesResponse( + _ response: Operations.fetchRecordZoneChanges.Output + ) async throws(CloudKitError) -> Components.Schemas.RecordZoneChangesResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let changesData): + return changesData + } + case .badRequest, .unauthorized, .forbidden, .notFound, .conflict, + .preconditionFailed, .contentTooLarge, .misdirectedRequest, + .tooManyRequests, .internalServerError, .serviceUnavailable, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } + /// Process fetchZoneChanges response internal func processFetchZoneChangesResponse(_ response: Operations.fetchZoneChanges.Output) async throws(CloudKitError) -> Components.Schemas.ZoneChangesResponse diff --git a/Sources/MistKit/CloudKitService/CloudKitService+DatabaseChanges.swift b/Sources/MistKit/CloudKitService/CloudKitService+DatabaseChanges.swift new file mode 100644 index 00000000..61686732 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+DatabaseChanges.swift @@ -0,0 +1,179 @@ +// +// CloudKitService+DatabaseChanges.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 MistKitOpenAPI +internal import OpenAPIRuntime + +#if canImport(FoundationNetworking) + internal import FoundationNetworking +#endif + +#if !os(WASI) + internal import OpenAPIURLSession +#endif + +extension CloudKitService { + /// Fetch the record zones that changed since a sync token. + /// + /// Calls `changes/database` (Fetching Database Changes), the current + /// replacement for the deprecated `zones/changes` operation wrapped by + /// ``fetchZoneChanges(syncToken:database:)``. It reports *which zones* + /// changed; follow up with + /// ``fetchRecordZoneChanges(zones:reverse:desiredKeys:resultsLimit:desiredRecordTypes:database:)`` + /// to fetch the record changes inside each returned zone. + /// + /// - Parameters: + /// - syncToken: Token from a previous fetch (`nil` = initial fetch). + /// - resultsLimit: Optional maximum number of zone changes to return. + /// - database: The CloudKit database scope to query (defaults to `.private`). + /// - Returns: ``DatabaseChangesResult`` containing the per-zone outcomes and + /// a new sync token. + /// - Throws: ``CloudKitError`` if the fetch fails. + /// + /// Example: + /// ```swift + /// let result = try await service.fetchDatabaseChanges() + /// for zone in result.changedZones { + /// print("changed: \(zone.zoneName)") + /// } + /// // Store result.syncToken for the next fetch. + /// ``` + /// + /// - Note: Per-zone failures are surfaced as ``ZoneChangeResult/failure(_:)`` + /// entries in ``DatabaseChangesResult/zones`` rather than thrown, so one + /// bad zone never discards the zones that succeeded. + public func fetchDatabaseChanges( + syncToken: String? = nil, + resultsLimit: Int? = nil, + database: Database = .private + ) async throws(CloudKitError) -> DatabaseChangesResult { + do { + let client = try self.client(for: database) + let response = try await client.fetchDatabaseChanges( + .init( + path: Operations.fetchDatabaseChanges.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + syncToken: syncToken, + resultsLimit: resultsLimit + ) + ) + ) + ) + + let changesData: Components.Schemas.DatabaseChangesResponse = + try await responseProcessor.processFetchDatabaseChangesResponse(response) + + return try DatabaseChangesResult(from: changesData) + } catch { + throw mapToCloudKitError(error, context: "fetchDatabaseChanges") + } + } + + /// Fetch all database changes, handling pagination automatically. + /// + /// Convenience over ``fetchDatabaseChanges(syncToken:resultsLimit:database:)`` + /// that follows the `moreComing` flag until the server reports no more + /// changes, concatenating the per-zone outcomes in the order received. + /// + /// - Parameters: + /// - syncToken: Token from a previous fetch (`nil` = initial fetch). + /// - resultsLimit: Optional maximum number of zone changes per request. + /// - maxPages: Maximum number of pages to fetch before throwing + /// ``CloudKitError/zonePaginationLimitExceeded(maxPages:zones:)`` + /// (defaults to 1,000). + /// - database: The CloudKit database scope to query (defaults to `.private`). + /// - Returns: The accumulated per-zone outcomes and the final sync token. + /// - Throws: ``CloudKitError``. When `maxPages` is exceeded, throws + /// ``CloudKitError/zonePaginationLimitExceeded(maxPages:zones:)`` whose + /// `zones` payload contains every *successfully changed* zone collected + /// before the cap was hit. + /// + /// - Warning: Stops early if the server repeatedly returns `moreComing: true` + /// with no zones and an unchanged sync token (stuck-token scenario). + /// - Note: Makes sequential requests with no backoff between pages. + public func fetchAllDatabaseChanges( + syncToken: String? = nil, + resultsLimit: Int? = nil, + maxPages: Int = 1_000, + database: Database = .private + ) async throws(CloudKitError) -> (zones: [ZoneChangeResult], syncToken: String?) { + var allZones: [ZoneChangeResult] = [] + var currentToken = syncToken + var moreComing = false + var pageCount = 0 + + repeat { + guard pageCount < maxPages else { + throw CloudKitError.zonePaginationLimitExceeded( + maxPages: maxPages, + zones: allZones.compactMap { result in + guard case .success(let zone) = result else { + return nil + } + return zone + } + ) + } + + do { + try Task.checkCancellation() + } catch { + throw mapToCloudKitError(error, context: "fetchAllDatabaseChanges") + } + + let result = try await fetchDatabaseChanges( + syncToken: currentToken, + resultsLimit: resultsLimit, + database: database + ) + + // Stuck-token detection + if result.zones.isEmpty && result.moreComing && result.syncToken == currentToken { + break + } + + if result.moreComing && result.syncToken == nil { + throw CloudKitError.invalidResponse + } + + allZones.append(contentsOf: result.zones) + currentToken = result.syncToken + moreComing = result.moreComing + pageCount += 1 + } while moreComing + + return (allZones, currentToken) + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+FetchAllZoneChanges.swift b/Sources/MistKit/CloudKitService/CloudKitService+FetchAllZoneChanges.swift index 4676050f..9eba91a3 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+FetchAllZoneChanges.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+FetchAllZoneChanges.swift @@ -65,6 +65,17 @@ extension CloudKitService { /// - Note: Makes sequential requests with no backoff or cooperative /// cancellation between pages. For fine-grained control, use /// ``fetchZoneChanges(syncToken:database:)`` directly. + /// + /// > Deprecated: Wraps the deprecated `zones/changes` operation. Use + /// > ``fetchAllDatabaseChanges(syncToken:resultsLimit:maxPages:database:)`` + /// > instead. + @available( + *, deprecated, + message: """ + CloudKit deprecated `zones/changes` in favor of `changes/database`. \ + Use fetchAllDatabaseChanges(syncToken:resultsLimit:maxPages:database:) instead. + """ + ) public func fetchAllZoneChanges( syncToken: String? = nil, maxPages: Int = 1_000, diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChanges.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChanges.swift new file mode 100644 index 00000000..5fe0e462 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChanges.swift @@ -0,0 +1,118 @@ +// +// CloudKitService+RecordZoneChanges.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 MistKitOpenAPI +internal import OpenAPIRuntime + +#if canImport(FoundationNetworking) + internal import FoundationNetworking +#endif + +#if !os(WASI) + internal import OpenAPIURLSession +#endif + +extension CloudKitService { + /// Fetch the records that changed within one or more record zones. + /// + /// Calls `changes/zone` (Fetching Record Zone Changes). Typically paired with + /// ``fetchDatabaseChanges(syncToken:resultsLimit:database:)``, which reports + /// *which* zones changed; this operation fetches the record changes inside + /// them. Intended for custom zones. + /// + /// Each zone paginates independently: the response carries a sync token and + /// `moreComing` flag per zone, not one for the whole request. + /// + /// - Parameters: + /// - zones: The zones to fetch record changes from. Per-zone values + /// override the request-level values below. + /// - reverse: Whether changes are returned in reverse order. + /// - desiredKeys: Field names limiting the fields returned per record. + /// - resultsLimit: Maximum number of records to fetch. + /// - desiredRecordTypes: Record-type names limiting the change feed. + /// - database: The CloudKit database scope to query (defaults to `.private`). + /// - Returns: ``RecordZoneChangesResult`` with one entry per requested zone. + /// - Throws: ``CloudKitError`` if the fetch fails. + /// + /// Example: + /// ```swift + /// let database = try await service.fetchDatabaseChanges() + /// let result = try await service.fetchRecordZoneChanges( + /// zones: database.changedZones.map { + /// ZoneChangesRequest(zoneID: ZoneID(zoneName: $0.zoneName)) + /// } + /// ) + /// for change in result.changes { + /// print("\(change.zone.zoneName): \(change.records.count) changed") + /// } + /// ``` + /// + /// - Note: Per-zone failures are surfaced as + /// ``ZoneRecordChangesResult/failure(_:)`` entries rather than thrown. + public func fetchRecordZoneChanges( + zones: [ZoneChangesRequest], + reverse: Bool? = nil, + desiredKeys: [String]? = nil, + resultsLimit: Int? = nil, + desiredRecordTypes: [String]? = nil, + database: Database = .private + ) async throws(CloudKitError) -> RecordZoneChangesResult { + do { + let client = try self.client(for: database) + let response = try await client.fetchRecordZoneChanges( + .init( + path: Operations.fetchRecordZoneChanges.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: database + ), + body: .json( + .init( + zones: zones.map { + Components.Schemas.RecordZoneChangesRequestZone(from: $0) + }, + reverse: reverse, + desiredKeys: desiredKeys, + resultsLimit: resultsLimit, + desiredRecordTypes: desiredRecordTypes + ) + ) + ) + ) + + let changesData: Components.Schemas.RecordZoneChangesResponse = + try await responseProcessor.processFetchRecordZoneChangesResponse(response) + + return try RecordZoneChangesResult(from: changesData) + } catch { + throw mapToCloudKitError(error, context: "fetchRecordZoneChanges") + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChangesPagination.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChangesPagination.swift new file mode 100644 index 00000000..110d3fcd --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordZoneChangesPagination.swift @@ -0,0 +1,111 @@ +// +// CloudKitService+RecordZoneChangesPagination.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 { + /// Fetch all record zone changes, handling per-zone pagination automatically. + /// + /// Convenience over + /// ``fetchRecordZoneChanges(zones:reverse:desiredKeys:resultsLimit:desiredRecordTypes:database:)``. + /// Because `changes/zone` paginates *per zone*, each round re-requests only + /// the zones still reporting `moreComing`, carrying each zone's own returned + /// sync token forward. Records for a zone are accumulated across rounds and + /// merged into a single ``ZoneRecordChanges`` carrying that zone's final + /// sync token. + /// + /// - Parameters: + /// - zones: The zones to fetch record changes from. + /// - reverse: Whether changes are returned in reverse order. + /// - desiredKeys: Field names limiting the fields returned per record. + /// - resultsLimit: Maximum number of records to fetch per request. + /// - desiredRecordTypes: Record-type names limiting the change feed. + /// - maxPages: Maximum number of rounds before throwing + /// ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` + /// (defaults to 1,000). + /// - database: The CloudKit database scope to query (defaults to `.private`). + /// - Returns: One merged entry per originally-requested zone, in input order. + /// - Throws: ``CloudKitError``. When `maxPages` is exceeded, throws + /// ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` whose + /// `records` payload contains every record collected before the cap. + /// + /// - Warning: A zone that repeatedly reports `moreComing: true` with no + /// records and an unchanged sync token (stuck token) is dropped from the + /// next round rather than looping forever. + /// - Note: Makes sequential requests with no backoff between rounds. + public func fetchAllRecordZoneChanges( + zones: [ZoneChangesRequest], + reverse: Bool? = nil, + desiredKeys: [String]? = nil, + resultsLimit: Int? = nil, + desiredRecordTypes: [String]? = nil, + maxPages: Int = 1_000, + database: Database = .private + ) async throws(CloudKitError) -> RecordZoneChangesResult { + var accumulator = ZoneChangesAccumulator(requested: zones) + var pending = zones + var pageCount = 0 + + while !pending.isEmpty { + guard pageCount < maxPages else { + throw CloudKitError.paginationLimitExceeded( + maxPages: maxPages, + records: accumulator.allRecords + ) + } + + do { + try Task.checkCancellation() + } catch { + throw mapToCloudKitError(error, context: "fetchAllRecordZoneChanges") + } + + let result = try await fetchRecordZoneChanges( + zones: pending, + reverse: reverse, + desiredKeys: desiredKeys, + resultsLimit: resultsLimit, + desiredRecordTypes: desiredRecordTypes, + database: database + ) + + pending = accumulator.merge( + result, + pending: pending, + reverse: reverse, + desiredKeys: desiredKeys, + resultsLimit: resultsLimit, + desiredRecordTypes: desiredRecordTypes + ) + pageCount += 1 + } + + return accumulator.finish() + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift index 4a9afd6f..7ea158f8 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift @@ -122,27 +122,25 @@ extension CloudKitService { /// Fetch zone changes since a sync token /// /// Retrieves all zones that have changed since the provided sync token. - /// Use this for efficient incremental sync at the zone level. + /// + /// > Deprecated: This wraps CloudKit's `zones/changes` operation, which + /// > Apple deprecated in favor of `changes/database`. Use + /// > ``fetchDatabaseChanges(syncToken:resultsLimit:database:)`` instead — it + /// > returns the same "which zones changed" information, plus per-zone + /// > failures and a `resultsLimit` knob. /// /// - Parameters: /// - syncToken: Optional token from previous fetch (nil = initial fetch) /// - database: The CloudKit database scope to query (defaults to `.private`) /// - Returns: ZoneChangesResult containing changed zones and new sync token /// - Throws: CloudKitError if the fetch fails - /// - /// Example - Initial Sync: - /// ```swift - /// let result = try await service.fetchZoneChanges() - /// // Store result.syncToken for next fetch - /// processZones(result.zones) - /// ``` - /// - /// Example - Incremental Sync: - /// ```swift - /// let result = try await service.fetchZoneChanges( - /// syncToken: previousToken - /// ) - /// ``` + @available( + *, deprecated, + message: """ + CloudKit deprecated `zones/changes` in favor of `changes/database`. \ + Use fetchDatabaseChanges(syncToken:resultsLimit:database:) instead. + """ + ) public func fetchZoneChanges( syncToken: String? = nil, database: Database = .private diff --git a/Sources/MistKit/Models/ZoneOperationFailure.swift b/Sources/MistKit/Models/ZoneOperationFailure.swift new file mode 100644 index 00000000..b96d3104 --- /dev/null +++ b/Sources/MistKit/Models/ZoneOperationFailure.swift @@ -0,0 +1,38 @@ +// +// ZoneOperationFailure.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. +// + +/// A per-zone failure returned inline in a CloudKit zone-fetch response. +/// +/// CloudKit's `changes/database` and `changes/zone` operations return an entry +/// per requested zone, each of which is either a success payload or a zone +/// fetch error dictionary. This alias names the failure half; it is surfaced +/// via ``ZoneChangeResult/failure(_:)`` / ``RecordZoneChangesResult`` rather +/// than being thrown, so a partial failure never hides the zones that did +/// succeed. +public typealias ZoneOperationFailure = OperationFailure diff --git a/Sources/MistKit/Models/ZoneTarget.swift b/Sources/MistKit/Models/ZoneTarget.swift new file mode 100644 index 00000000..33165016 --- /dev/null +++ b/Sources/MistKit/Models/ZoneTarget.swift @@ -0,0 +1,78 @@ +// +// ZoneTarget.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 MistKitOpenAPI + +/// Phantom target tagging an ``OperationFailure`` (or ``OperationResult``) as +/// belonging to a per-zone CloudKit batch (`changes/database`, `changes/zone`). +public enum ZoneTarget: OperationFailureTarget { + /// Lifts a per-zone failure into ``CloudKitError/zoneOperationFailed(_:)``. + public static func wrap( + _ failure: OperationFailure + ) -> CloudKitError { + .zoneOperationFailed(failure) + } +} + +extension OperationFailure where Target == ZoneTarget { + /// The zone name of the zone the operation failed on. + /// + /// A named alias for ``OperationFailure/identifier`` scoped to the zone + /// target, matching the `zoneID.zoneName` wire field. CloudKit's zone-fetch + /// error dictionary identifies the failed item by `zoneID` rather than a + /// flat string, so the zone *name* is used as the identifier. + public var zoneName: String { identifier } + + /// Builds a per-zone failure from the generated `ZoneFetchFailure` schema. + /// + /// Unlike `RecordOperationFailure`/`SubscriptionOperationFailure` — which + /// compose `OperationFailureCommon` via `allOf` and carry a flat required + /// string identifier — CloudKit's zone-fetch error dictionary nests its + /// identifier in an optional `zoneID`. A missing `zoneID`/`zoneName` is a + /// conversion failure (consistent with ``ZoneInfo/init(fromZoneID:)``) + /// rather than a silently-dropped error entry. + internal init(from schema: Components.Schemas.ZoneFetchFailure) throws(ConversionError) { + guard let zoneID = schema.zoneID else { + try ConversionError.zoneMissingID.reportAndThrow() + } + guard let zoneName = zoneID.zoneName else { + try ConversionError.zoneMissingName.reportAndThrow() + } + self.init( + identifier: zoneName, + common: Components.Schemas.OperationFailureCommon( + serverErrorCode: schema.serverErrorCode, + reason: schema.reason, + retryAfter: schema.retryAfter, + uuid: schema.uuid, + redirectURL: schema.redirectURL + ) + ) + } +} diff --git a/Sources/MistKit/Models/Zones/DatabaseChangesResult.swift b/Sources/MistKit/Models/Zones/DatabaseChangesResult.swift new file mode 100644 index 00000000..58d401c3 --- /dev/null +++ b/Sources/MistKit/Models/Zones/DatabaseChangesResult.swift @@ -0,0 +1,96 @@ +// +// DatabaseChangesResult.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 MistKitOpenAPI + +/// Result from fetching database changes (`changes/database`). +/// +/// Reports which record zones in the database changed since the provided sync +/// token, along with a new sync token for subsequent fetches. This is the +/// current replacement for the deprecated `zones/changes` operation modeled by +/// ``ZoneChangesResult``. +/// +/// Follow up with ``CloudKitService/fetchRecordZoneChanges(zones:database:)`` +/// to fetch the record changes within each returned zone. +public struct DatabaseChangesResult: Sendable { + /// The per-zone outcomes, in the order CloudKit returned them. Each entry is + /// either a changed zone or a zone fetch error. + public let zones: [ZoneChangeResult] + /// Token to use for the next fetch to get incremental changes. + public let syncToken: String? + /// Whether more changes are available. + public let moreComing: Bool + + /// The zones that changed, dropping any per-zone failures. + /// + /// Use ``zones`` directly when the failures matter. + public var changedZones: [ZoneInfo] { + zones.compactMap { result in + guard case .success(let zone) = result else { + return nil + } + return zone + } + } + + /// The per-zone failures, dropping the successes. + public var failures: [ZoneOperationFailure] { + zones.compactMap { result in + guard case .failure(let failure) = result else { + return nil + } + return failure + } + } + + /// Initialize a database changes result. + public init( + zones: [ZoneChangeResult], + syncToken: String?, + moreComing: Bool = false + ) { + self.zones = zones + self.syncToken = syncToken + self.moreComing = moreComing + } + + internal init( + from response: Components.Schemas.DatabaseChangesResponse + ) throws(ConversionError) { + var zones: [ZoneChangeResult] = [] + for zone in response.zones ?? [] { + zones.append(try ZoneChangeResult(from: zone)) + } + self.init( + zones: zones, + syncToken: response.syncToken, + moreComing: response.moreComing ?? false + ) + } +} diff --git a/Sources/MistKit/Models/Zones/RecordZoneChangesResult.swift b/Sources/MistKit/Models/Zones/RecordZoneChangesResult.swift new file mode 100644 index 00000000..2661a17d --- /dev/null +++ b/Sources/MistKit/Models/Zones/RecordZoneChangesResult.swift @@ -0,0 +1,81 @@ +// +// RecordZoneChangesResult.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 MistKitOpenAPI + +/// Result from fetching record zone changes (`changes/zone`). +/// +/// Carries one entry per requested zone — either that zone's record changes or +/// a zone fetch error. There is no top-level sync token: each zone paginates +/// independently via ``ZoneRecordChanges/syncToken`` and +/// ``ZoneRecordChanges/moreComing``. +public struct RecordZoneChangesResult: Sendable { + /// The per-zone outcomes, in the order CloudKit returned them. + public let zones: [ZoneRecordChangesResult] + + /// The zones whose changes were fetched successfully. + public var changes: [ZoneRecordChanges] { + zones.compactMap { result in + guard case .success(let changes) = result else { + return nil + } + return changes + } + } + + /// The per-zone failures, dropping the successes. + public var failures: [ZoneOperationFailure] { + zones.compactMap { result in + guard case .failure(let failure) = result else { + return nil + } + return failure + } + } + + /// Whether any successfully-fetched zone reports more changes to request. + public var moreComing: Bool { + changes.contains { $0.moreComing } + } + + /// Initialize a record zone changes result. + public init(zones: [ZoneRecordChangesResult]) { + self.zones = zones + } + + internal init( + from response: Components.Schemas.RecordZoneChangesResponse + ) throws(ConversionError) { + var zones: [ZoneRecordChangesResult] = [] + for zone in response.zones ?? [] { + zones.append(try ZoneRecordChangesResult(from: zone)) + } + self.init(zones: zones) + } +} diff --git a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift new file mode 100644 index 00000000..c0132628 --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift @@ -0,0 +1,51 @@ +// +// ZoneChangeResult.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 MistKitOpenAPI + +/// The outcome for a single zone in a `changes/database` response. +/// +/// Each entry in the response's `zones` array is either a changed zone or a +/// zone fetch error, so a failure on one zone never discards the zones that +/// succeeded. +public typealias ZoneChangeResult = OperationResult + +extension OperationResult where Success == ZoneInfo, Target == ZoneTarget { + /// Converts a per-zone entry from a `changes/database` response. + internal init( + from item: Components.Schemas.DatabaseChangesResponse.zonesPayloadPayload + ) throws(ConversionError) { + switch item { + case .ZoneFetchFailure(let failure): + self = .failure(try ZoneOperationFailure(from: failure)) + case .DatabaseChangedZone(let zone): + self = .success(try ZoneInfo(fromZoneID: zone.zoneID)) + } + } +} diff --git a/Sources/MistKit/Models/Zones/ZoneChangesAccumulator.swift b/Sources/MistKit/Models/Zones/ZoneChangesAccumulator.swift new file mode 100644 index 00000000..09aa0257 --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneChangesAccumulator.swift @@ -0,0 +1,162 @@ +// +// ZoneChangesAccumulator.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 + +/// Merges the per-round results of ``CloudKitService/fetchAllRecordZoneChanges`` +/// back into one entry per originally-requested zone. +/// +/// `changes/zone` paginates per zone, so a single logical fetch can span +/// several rounds in which different subsets of zones are still reporting +/// `moreComing`. This accumulator keeps each zone's accumulated records and +/// latest sync token keyed by zone name, and computes the next round's pending +/// requests. +internal struct ZoneChangesAccumulator { + /// A zone's state across rounds. + private struct Entry { + var zone: ZoneInfo? + var records: [RecordInfo] = [] + var syncToken: String? + var failure: ZoneOperationFailure? + } + + /// Zone names in the order originally requested, so output order is stable. + private let order: [String] + private var entries: [String: Entry] + + /// Every record collected so far, across all zones. + internal var allRecords: [RecordInfo] { + order.flatMap { entries[$0]?.records ?? [] } + } + + internal init(requested: [ZoneChangesRequest]) { + var order: [String] = [] + var entries: [String: Entry] = [:] + for request in requested { + let name = request.zoneID.zoneName + if entries[name] == nil { + order.append(name) + entries[name] = Entry(syncToken: request.syncToken) + } + } + self.order = order + self.entries = entries + } + + /// Folds one round's result in and returns the requests for the next round. + /// + /// A zone continues only when it reports `moreComing` *and* made progress — + /// a zone returning no records with an unchanged sync token is treated as + /// stuck and dropped rather than re-requested forever. + internal mutating func merge( + _ result: RecordZoneChangesResult, + pending: [ZoneChangesRequest], + reverse: Bool?, + desiredKeys: [String]?, + resultsLimit: Int?, + desiredRecordTypes: [String]? + ) -> [ZoneChangesRequest] { + let pendingByName = Dictionary( + pending.map { ($0.zoneID.zoneName, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var next: [ZoneChangesRequest] = [] + + for zoneResult in result.zones { + switch zoneResult { + case .failure(let failure): + record(failure: failure) + case .success(let changes): + let name = changes.zone.zoneName + let previousToken = entries[name]?.syncToken + record(changes: changes) + + let madeProgress = !changes.records.isEmpty || changes.syncToken != previousToken + guard changes.moreComing, madeProgress, + let request = pendingByName[name] + else { + continue + } + next.append( + ZoneChangesRequest( + zoneID: request.zoneID, + syncToken: changes.syncToken, + reverse: request.reverse ?? reverse, + desiredKeys: request.desiredKeys ?? desiredKeys, + resultsLimit: request.resultsLimit ?? resultsLimit, + desiredRecordTypes: request.desiredRecordTypes ?? desiredRecordTypes + ) + ) + } + } + + return next + } + + /// Produces the merged result, preserving the originally-requested order. + /// + /// Zones the server never reported on are omitted rather than fabricated. + internal func finish() -> RecordZoneChangesResult { + let zones: [ZoneRecordChangesResult] = order.compactMap { name in + guard let entry = entries[name] else { + return nil + } + if let failure = entry.failure { + return .failure(failure) + } + guard let zone = entry.zone else { + return nil + } + return .success( + ZoneRecordChanges( + zone: zone, + records: entry.records, + syncToken: entry.syncToken, + moreComing: false + ) + ) + } + return RecordZoneChangesResult(zones: zones) + } + + private mutating func record(changes: ZoneRecordChanges) { + let name = changes.zone.zoneName + var entry = entries[name] ?? Entry() + entry.zone = changes.zone + entry.records.append(contentsOf: changes.records) + entry.syncToken = changes.syncToken + entries[name] = entry + } + + private mutating func record(failure: ZoneOperationFailure) { + var entry = entries[failure.zoneName] ?? Entry() + entry.failure = failure + entries[failure.zoneName] = entry + } +} diff --git a/Sources/MistKit/Models/Zones/ZoneChangesRequest.swift b/Sources/MistKit/Models/Zones/ZoneChangesRequest.swift new file mode 100644 index 00000000..6bddc414 --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneChangesRequest.swift @@ -0,0 +1,93 @@ +// +// ZoneChangesRequest.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 MistKitOpenAPI + +// swiftlint:disable line_length - DocC symbol links cannot be wrapped +/// A per-zone entry in a `changes/zone` request. +/// +/// Identifies the zone to fetch record changes from and, optionally, overrides +/// the request-level tuning values for that zone. Values left `nil` here fall +/// back to whatever the enclosing +/// ``CloudKitService/fetchRecordZoneChanges(zones:reverse:desiredKeys:resultsLimit:desiredRecordTypes:database:)`` +/// call specified. +public struct ZoneChangesRequest: Sendable { + // swiftlint:enable line_length + /// The zone to fetch record changes from. + public let zoneID: ZoneID + /// Token from a previous fetch of this zone (`nil` = initial fetch). + public let syncToken: String? + /// Whether this zone's changes are returned in reverse order. + public let reverse: Bool? + /// Field names limiting the fields returned per changed record in this zone. + public let desiredKeys: [String]? + /// Maximum number of records to fetch for this zone. + public let resultsLimit: Int? + /// Record-type names limiting this zone's change feed. + public let desiredRecordTypes: [String]? + + /// Creates a per-zone change request. + /// + /// - Parameters: + /// - zoneID: The zone to fetch record changes from. + /// - syncToken: Token from a previous fetch of this zone. + /// - reverse: Whether changes are returned in reverse order. + /// - desiredKeys: Field names limiting the fields returned per record. + /// - resultsLimit: Maximum number of records to fetch for this zone. + /// - desiredRecordTypes: Record-type names limiting this zone's feed. + public init( + zoneID: ZoneID, + syncToken: String? = nil, + reverse: Bool? = nil, + desiredKeys: [String]? = nil, + resultsLimit: Int? = nil, + desiredRecordTypes: [String]? = nil + ) { + self.zoneID = zoneID + self.syncToken = syncToken + self.reverse = reverse + self.desiredKeys = desiredKeys + self.resultsLimit = resultsLimit + self.desiredRecordTypes = desiredRecordTypes + } +} + +extension Components.Schemas.RecordZoneChangesRequestZone { + /// Converts a domain per-zone change request into its wire representation. + internal init(from request: ZoneChangesRequest) { + self.init( + zoneID: Components.Schemas.ZoneID(from: request.zoneID), + syncToken: request.syncToken, + reverse: request.reverse, + desiredKeys: request.desiredKeys, + resultsLimit: request.resultsLimit, + desiredRecordTypes: request.desiredRecordTypes + ) + } +} diff --git a/Sources/MistKit/Models/Zones/ZoneRecordChanges.swift b/Sources/MistKit/Models/Zones/ZoneRecordChanges.swift new file mode 100644 index 00000000..3128f75b --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneRecordChanges.swift @@ -0,0 +1,74 @@ +// +// ZoneRecordChanges.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 MistKitOpenAPI + +/// The record changes within a single zone, returned by `changes/zone`. +/// +/// Unlike ``RecordChangesResult`` (whose sync token covers the whole request), +/// `changes/zone` returns a sync token and `moreComing` flag *per zone*, so +/// each zone is paginated independently. +public struct ZoneRecordChanges: Sendable { + /// The zone these changes belong to. + public let zone: ZoneInfo + /// Records that changed (created, updated, or deleted) in this zone. + public let records: [RecordInfo] + /// Token to use for the next fetch of this zone's changes. + public let syncToken: String? + /// Whether more changes are available for this zone. + public let moreComing: Bool + + /// Initialize a zone's record changes. + public init( + zone: ZoneInfo, + records: [RecordInfo], + syncToken: String?, + moreComing: Bool = false + ) { + self.zone = zone + self.records = records + self.syncToken = syncToken + self.moreComing = moreComing + } + + internal init( + from result: Components.Schemas.RecordZoneChangesZoneResult + ) throws(ConversionError) { + var records: [RecordInfo] = [] + for record in result.records ?? [] { + records.append(try RecordInfo(from: record)) + } + self.init( + zone: try ZoneInfo(fromZoneID: result.zoneID), + records: records, + syncToken: result.syncToken, + moreComing: result.moreComing ?? false + ) + } +} diff --git a/Sources/MistKit/Models/Zones/ZoneRecordChangesResult.swift b/Sources/MistKit/Models/Zones/ZoneRecordChangesResult.swift new file mode 100644 index 00000000..4108432a --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneRecordChangesResult.swift @@ -0,0 +1,51 @@ +// +// ZoneRecordChangesResult.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 MistKitOpenAPI + +/// The outcome for a single zone in a `changes/zone` response. +/// +/// Each entry in the response's `zones` array is either that zone's record +/// changes or a zone fetch error, so a failure on one zone never discards the +/// zones that succeeded. +public typealias ZoneRecordChangesResult = OperationResult + +extension OperationResult where Success == ZoneRecordChanges, Target == ZoneTarget { + /// Converts a per-zone entry from a `changes/zone` response. + internal init( + from item: Components.Schemas.RecordZoneChangesResponse.zonesPayloadPayload + ) throws(ConversionError) { + switch item { + case .ZoneFetchFailure(let failure): + self = .failure(try ZoneOperationFailure(from: failure)) + case .RecordZoneChangesZoneResult(let result): + self = .success(try ZoneRecordChanges(from: result)) + } + } +} diff --git a/Sources/MistKit/OpenAPI/OperationInputPath.swift b/Sources/MistKit/OpenAPI/OperationInputPath.swift index 93aae9e2..ba3f163f 100644 --- a/Sources/MistKit/OpenAPI/OperationInputPath.swift +++ b/Sources/MistKit/OpenAPI/OperationInputPath.swift @@ -65,8 +65,12 @@ extension OperationInputPath { extension Operations.discoverUserIdentities.Input.Path: OperationInputPath {} +extension Operations.fetchDatabaseChanges.Input.Path: OperationInputPath {} + extension Operations.fetchRecordChanges.Input.Path: OperationInputPath {} +extension Operations.fetchRecordZoneChanges.Input.Path: OperationInputPath {} + extension Operations.fetchZoneChanges.Input.Path: OperationInputPath {} extension Operations.getCaller.Input.Path: OperationInputPath {} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.fetchDatabaseChanges.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.fetchDatabaseChanges.Output.swift new file mode 100644 index 00000000..76738d6d --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.fetchDatabaseChanges.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.fetchDatabaseChanges.Output.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 MistKitOpenAPI + +extension Operations.fetchDatabaseChanges.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.fetchRecordZoneChanges.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.fetchRecordZoneChanges.Output.swift new file mode 100644 index 00000000..a19ce018 --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.fetchRecordZoneChanges.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.fetchRecordZoneChanges.Output.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 MistKitOpenAPI + +extension Operations.fetchRecordZoneChanges.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKitOpenAPI/Client.swift b/Sources/MistKitOpenAPI/Client.swift index 78c69b1f..9037e6b8 100644 --- a/Sources/MistKitOpenAPI/Client.swift +++ b/Sources/MistKitOpenAPI/Client.swift @@ -2535,12 +2535,14 @@ public struct Client: APIProtocol { } ) } - /// Fetch Zone Changes + /// Fetch Zone Changes (deprecated) /// - /// Get all changed zones relative to a meta-sync token + /// Get all changed zones relative to a meta-sync token. + /// **Deprecated by Apple** in favor of `changes/database` (`fetchDatabaseChanges`), which returns the same "which zones changed" information. New code should use `changes/database`. /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. + @available(*, deprecated) public func fetchZoneChanges(_ input: Operations.fetchZoneChanges.Input) async throws -> Operations.fetchZoneChanges.Output { try await client.send( input: input, @@ -2655,6 +2657,642 @@ public struct Client: APIProtocol { } ) } + /// Fetch Database Changes + /// + /// Get the record zones in the database that have changed relative to a sync token. This is the current replacement for the deprecated `zones/changes` operation. Follow up with `changes/zone` to fetch the record changes within each returned zone. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/database`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)`. + public func fetchDatabaseChanges(_ input: Operations.fetchDatabaseChanges.Input) async throws -> Operations.fetchDatabaseChanges.Output { + try await client.send( + input: input, + forOperation: Operations.fetchDatabaseChanges.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/changes/database", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.fetchDatabaseChanges.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.DatabaseChangesResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + case 403: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .forbidden(.init(body: body)) + case 404: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .notFound(.init(body: body)) + case 409: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .conflict(.init(body: body)) + case 412: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .preconditionFailed(.init(body: body)) + case 413: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .contentTooLarge(.init(body: body)) + case 429: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .tooManyRequests(.init(body: body)) + case 421: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .misdirectedRequest(.init(body: body)) + case 500: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .internalServerError(.init(body: body)) + case 503: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .serviceUnavailable(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Fetch Record Zone Changes + /// + /// Get the records that changed within one or more record zones relative to each zone's sync token. Intended for custom zones. Each entry in the response `zones` array is either a per-zone success result or a per-zone error. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/zone`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)`. + public func fetchRecordZoneChanges(_ input: Operations.fetchRecordZoneChanges.Input) async throws -> Operations.fetchRecordZoneChanges.Output { + try await client.send( + input: input, + forOperation: Operations.fetchRecordZoneChanges.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/changes/zone", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.fetchRecordZoneChanges.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.RecordZoneChangesResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + case 403: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .forbidden(.init(body: body)) + case 404: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .notFound(.init(body: body)) + case 409: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .conflict(.init(body: body)) + case 412: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .preconditionFailed(.init(body: body)) + case 413: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .contentTooLarge(.init(body: body)) + case 429: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .tooManyRequests(.init(body: body)) + case 421: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .misdirectedRequest(.init(body: body)) + case 500: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .internalServerError(.init(body: body)) + case 503: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .serviceUnavailable(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } /// List All Subscriptions /// /// Fetch all subscriptions in the database diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index a7d4f2b8..cfe6b9e8 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -98,13 +98,29 @@ public protocol APIProtocol: Sendable { /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/modify`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/modify/post(modifyZones)`. func modifyZones(_ input: Operations.modifyZones.Input) async throws -> Operations.modifyZones.Output - /// Fetch Zone Changes + /// Fetch Zone Changes (deprecated) /// - /// Get all changed zones relative to a meta-sync token + /// Get all changed zones relative to a meta-sync token. + /// **Deprecated by Apple** in favor of `changes/database` (`fetchDatabaseChanges`), which returns the same "which zones changed" information. New code should use `changes/database`. /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. + @available(*, deprecated) func fetchZoneChanges(_ input: Operations.fetchZoneChanges.Input) async throws -> Operations.fetchZoneChanges.Output + /// Fetch Database Changes + /// + /// Get the record zones in the database that have changed relative to a sync token. This is the current replacement for the deprecated `zones/changes` operation. Follow up with `changes/zone` to fetch the record changes within each returned zone. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/database`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)`. + func fetchDatabaseChanges(_ input: Operations.fetchDatabaseChanges.Input) async throws -> Operations.fetchDatabaseChanges.Output + /// Fetch Record Zone Changes + /// + /// Get the records that changed within one or more record zones relative to each zone's sync token. Intended for custom zones. Each entry in the response `zones` array is either a per-zone success result or a per-zone error. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/zone`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)`. + func fetchRecordZoneChanges(_ input: Operations.fetchRecordZoneChanges.Input) async throws -> Operations.fetchRecordZoneChanges.Output /// List All Subscriptions /// /// Fetch all subscriptions in the database @@ -411,12 +427,14 @@ extension APIProtocol { body: body )) } - /// Fetch Zone Changes + /// Fetch Zone Changes (deprecated) /// - /// Get all changed zones relative to a meta-sync token + /// Get all changed zones relative to a meta-sync token. + /// **Deprecated by Apple** in favor of `changes/database` (`fetchDatabaseChanges`), which returns the same "which zones changed" information. New code should use `changes/database`. /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. + @available(*, deprecated) public func fetchZoneChanges( path: Operations.fetchZoneChanges.Input.Path, headers: Operations.fetchZoneChanges.Input.Headers = .init(), @@ -428,6 +446,40 @@ extension APIProtocol { body: body )) } + /// Fetch Database Changes + /// + /// Get the record zones in the database that have changed relative to a sync token. This is the current replacement for the deprecated `zones/changes` operation. Follow up with `changes/zone` to fetch the record changes within each returned zone. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/database`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)`. + public func fetchDatabaseChanges( + path: Operations.fetchDatabaseChanges.Input.Path, + headers: Operations.fetchDatabaseChanges.Input.Headers = .init(), + body: Operations.fetchDatabaseChanges.Input.Body + ) async throws -> Operations.fetchDatabaseChanges.Output { + try await fetchDatabaseChanges(Operations.fetchDatabaseChanges.Input( + path: path, + headers: headers, + body: body + )) + } + /// Fetch Record Zone Changes + /// + /// Get the records that changed within one or more record zones relative to each zone's sync token. Intended for custom zones. Each entry in the response `zones` array is either a per-zone success result or a per-zone error. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/zone`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)`. + public func fetchRecordZoneChanges( + path: Operations.fetchRecordZoneChanges.Input.Path, + headers: Operations.fetchRecordZoneChanges.Input.Headers = .init(), + body: Operations.fetchRecordZoneChanges.Input.Body + ) async throws -> Operations.fetchRecordZoneChanges.Output { + try await fetchRecordZoneChanges(Operations.fetchRecordZoneChanges.Input( + path: path, + headers: headers, + body: body + )) + } /// List All Subscriptions /// /// Fetch all subscriptions in the database @@ -2313,6 +2365,326 @@ public enum Components { case moreComing } } + /// Response body of `changes/database` (Fetching Database Changes). Each + /// entry in `zones` is either a Zone dictionary (success) or a Zone Fetch + /// Error dictionary (failure), per Apple's reference. + /// + /// + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse`. + public struct DatabaseChangesResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse/zonesPayload`. + @frozen public enum zonesPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse/zonesPayload/case1`. + case ZoneFetchFailure(Components.Schemas.ZoneFetchFailure) + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse/zonesPayload/case2`. + case DatabaseChangedZone(Components.Schemas.DatabaseChangedZone) + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self = .ZoneFetchFailure(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .DatabaseChangedZone(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .ZoneFetchFailure(value): + try value.encode(to: encoder) + case let .DatabaseChangedZone(value): + try value.encode(to: encoder) + } + } + } + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse/zones`. + public typealias zonesPayload = [Components.Schemas.DatabaseChangesResponse.zonesPayloadPayload] + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse/zones`. + public var zones: Components.Schemas.DatabaseChangesResponse.zonesPayload? + /// Identifies a point in the database's change history. Pass this in the next request to fetch only newer changes. + /// + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse/syncToken`. + public var syncToken: Swift.String? + /// Whether there are more changes to request using the returned `syncToken`. + /// + /// - Remark: Generated from `#/components/schemas/DatabaseChangesResponse/moreComing`. + public var moreComing: Swift.Bool? + /// Creates a new `DatabaseChangesResponse`. + /// + /// - Parameters: + /// - zones: + /// - syncToken: Identifies a point in the database's change history. Pass this in the next request to fetch only newer changes. + /// - moreComing: Whether there are more changes to request using the returned `syncToken`. + public init( + zones: Components.Schemas.DatabaseChangesResponse.zonesPayload? = nil, + syncToken: Swift.String? = nil, + moreComing: Swift.Bool? = nil + ) { + self.zones = zones + self.syncToken = syncToken + self.moreComing = moreComing + } + public enum CodingKeys: String, CodingKey { + case zones + case syncToken + case moreComing + } + } + /// A zone that changed, as returned by `changes/database`. + /// + /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone`. + public struct DatabaseChangedZone: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// Creates a new `DatabaseChangedZone`. + /// + /// - Parameters: + /// - zoneID: + public init(zoneID: Components.Schemas.ZoneID? = nil) { + self.zoneID = zoneID + } + public enum CodingKeys: String, CodingKey { + case zoneID + } + } + /// Per-zone error returned inline in the `zones` array of a 200 zone-fetch + /// response (`changes/database`, `changes/zone`). Mirrors + /// `RecordOperationFailure` for records, but keyed by `zoneID`. + /// + /// + /// - Remark: Generated from `#/components/schemas/ZoneFetchFailure`. + public struct ZoneFetchFailure: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZoneFetchFailure/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// - Remark: Generated from `#/components/schemas/ZoneFetchFailure/serverErrorCode`. + public var serverErrorCode: Components.Schemas.OperationFailureServerErrorCode + /// A string indicating the reason for the error. + /// + /// - Remark: Generated from `#/components/schemas/ZoneFetchFailure/reason`. + public var reason: Swift.String? + /// Suggested seconds to wait before retrying. Absent if not retryable. + /// + /// - Remark: Generated from `#/components/schemas/ZoneFetchFailure/retryAfter`. + public var retryAfter: Swift.Int? + /// A unique identifier for this error. + /// + /// - Remark: Generated from `#/components/schemas/ZoneFetchFailure/uuid`. + public var uuid: Swift.String? + /// Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. + /// + /// - Remark: Generated from `#/components/schemas/ZoneFetchFailure/redirectURL`. + public var redirectURL: Swift.String? + /// Creates a new `ZoneFetchFailure`. + /// + /// - Parameters: + /// - zoneID: + /// - serverErrorCode: + /// - reason: A string indicating the reason for the error. + /// - retryAfter: Suggested seconds to wait before retrying. Absent if not retryable. + /// - uuid: A unique identifier for this error. + /// - redirectURL: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. + public init( + zoneID: Components.Schemas.ZoneID? = nil, + serverErrorCode: Components.Schemas.OperationFailureServerErrorCode, + reason: Swift.String? = nil, + retryAfter: Swift.Int? = nil, + uuid: Swift.String? = nil, + redirectURL: Swift.String? = nil + ) { + self.zoneID = zoneID + self.serverErrorCode = serverErrorCode + self.reason = reason + self.retryAfter = retryAfter + self.uuid = uuid + self.redirectURL = redirectURL + } + public enum CodingKeys: String, CodingKey { + case zoneID + case serverErrorCode + case reason + case retryAfter + case uuid + case redirectURL + } + } + /// A per-zone request entry in the `zones` array of a `changes/zone` + /// request. Carries the same tuning keys as the enclosing request; values + /// set here override the top-level values for this zone. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone`. + public struct RecordZoneChangesRequestZone: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone/zoneID`. + public var zoneID: Components.Schemas.ZoneID + /// Identifies a point in this zone's change history. Omit on the initial fetch. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone/syncToken`. + public var syncToken: Swift.String? + /// Whether the changes are returned in reverse order. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone/reverse`. + public var reverse: Swift.Bool? + /// Record field names limiting the fields returned per changed record. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone/desiredKeys`. + public var desiredKeys: [Swift.String]? + /// Whether number fields should be represented as strings. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone/numberAsStrings`. + public var numberAsStrings: Swift.Bool? + /// The maximum number of records to fetch for this zone. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone/resultsLimit`. + public var resultsLimit: Swift.Int? + /// Record-type names limiting the change feed for this zone. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesRequestZone/desiredRecordTypes`. + public var desiredRecordTypes: [Swift.String]? + /// Creates a new `RecordZoneChangesRequestZone`. + /// + /// - Parameters: + /// - zoneID: + /// - syncToken: Identifies a point in this zone's change history. Omit on the initial fetch. + /// - reverse: Whether the changes are returned in reverse order. + /// - desiredKeys: Record field names limiting the fields returned per changed record. + /// - numberAsStrings: Whether number fields should be represented as strings. + /// - resultsLimit: The maximum number of records to fetch for this zone. + /// - desiredRecordTypes: Record-type names limiting the change feed for this zone. + public init( + zoneID: Components.Schemas.ZoneID, + syncToken: Swift.String? = nil, + reverse: Swift.Bool? = nil, + desiredKeys: [Swift.String]? = nil, + numberAsStrings: Swift.Bool? = nil, + resultsLimit: Swift.Int? = nil, + desiredRecordTypes: [Swift.String]? = nil + ) { + self.zoneID = zoneID + self.syncToken = syncToken + self.reverse = reverse + self.desiredKeys = desiredKeys + self.numberAsStrings = numberAsStrings + self.resultsLimit = resultsLimit + self.desiredRecordTypes = desiredRecordTypes + } + public enum CodingKeys: String, CodingKey { + case zoneID + case syncToken + case reverse + case desiredKeys + case numberAsStrings + case resultsLimit + case desiredRecordTypes + } + } + /// Response body of `changes/zone` (Fetching Record Zone Changes). Each + /// entry in `zones` is either a Zone Record Fetch dictionary (success) or a + /// Zone Record Fetch Error dictionary (failure), per Apple's reference. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesResponse`. + public struct RecordZoneChangesResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesResponse/zonesPayload`. + @frozen public enum zonesPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesResponse/zonesPayload/case1`. + case ZoneFetchFailure(Components.Schemas.ZoneFetchFailure) + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesResponse/zonesPayload/case2`. + case RecordZoneChangesZoneResult(Components.Schemas.RecordZoneChangesZoneResult) + public init(from decoder: any Decoder) throws { + var errors: [any Error] = [] + do { + self = .ZoneFetchFailure(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + do { + self = .RecordZoneChangesZoneResult(try .init(from: decoder)) + return + } catch { + errors.append(error) + } + throw Swift.DecodingError.failedToDecodeOneOfSchema( + type: Self.self, + codingPath: decoder.codingPath, + errors: errors + ) + } + public func encode(to encoder: any Encoder) throws { + switch self { + case let .ZoneFetchFailure(value): + try value.encode(to: encoder) + case let .RecordZoneChangesZoneResult(value): + try value.encode(to: encoder) + } + } + } + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesResponse/zones`. + public typealias zonesPayload = [Components.Schemas.RecordZoneChangesResponse.zonesPayloadPayload] + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesResponse/zones`. + public var zones: Components.Schemas.RecordZoneChangesResponse.zonesPayload? + /// Creates a new `RecordZoneChangesResponse`. + /// + /// - Parameters: + /// - zones: + public init(zones: Components.Schemas.RecordZoneChangesResponse.zonesPayload? = nil) { + self.zones = zones + } + public enum CodingKeys: String, CodingKey { + case zones + } + } + /// A successful per-zone result of `changes/zone`: the records that changed in that zone plus that zone's own sync token and `moreComing` flag. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesZoneResult`. + public struct RecordZoneChangesZoneResult: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesZoneResult/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesZoneResult/records`. + public var records: [Components.Schemas.RecordResponse]? + /// Identifies a point in this zone's change history. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesZoneResult/syncToken`. + public var syncToken: Swift.String? + /// Whether there are more changes to request for this zone using the returned `syncToken`. + /// + /// - Remark: Generated from `#/components/schemas/RecordZoneChangesZoneResult/moreComing`. + public var moreComing: Swift.Bool? + /// Creates a new `RecordZoneChangesZoneResult`. + /// + /// - Parameters: + /// - zoneID: + /// - records: + /// - syncToken: Identifies a point in this zone's change history. + /// - moreComing: Whether there are more changes to request for this zone using the returned `syncToken`. + public init( + zoneID: Components.Schemas.ZoneID? = nil, + records: [Components.Schemas.RecordResponse]? = nil, + syncToken: Swift.String? = nil, + moreComing: Swift.Bool? = nil + ) { + self.zoneID = zoneID + self.records = records + self.syncToken = syncToken + self.moreComing = moreComing + } + public enum CodingKeys: String, CodingKey { + case zoneID + case records + case syncToken + case moreComing + } + } /// - Remark: Generated from `#/components/schemas/SubscriptionsListResponse`. public struct SubscriptionsListResponse: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/SubscriptionsListResponse/subscriptions`. @@ -8310,9 +8682,10 @@ public enum Operations { } } } - /// Fetch Zone Changes + /// Fetch Zone Changes (deprecated) /// - /// Get all changed zones relative to a meta-sync token + /// Get all changed zones relative to a meta-sync token. + /// **Deprecated by Apple** in favor of `changes/database` (`fetchDatabaseChanges`), which returns the same "which zones changed" information. New code should use `changes/database`. /// /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/zones/changes`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/zones/changes/post(fetchZoneChanges)`. @@ -8574,6 +8947,1270 @@ public enum Operations { } } } + /// Fetch Database Changes + /// + /// Get the record zones in the database that have changed relative to a sync token. This is the current replacement for the deprecated `zones/changes` operation. Follow up with `changes/zone` to fetch the record changes within each returned zone. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/database`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)`. + public enum fetchDatabaseChanges { + public static let id: Swift.String = "fetchDatabaseChanges" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.fetchDatabaseChanges.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.fetchDatabaseChanges.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// Identifies a point in the database's change history. Omit on the initial fetch to start from the beginning. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/requestBody/json/syncToken`. + public var syncToken: Swift.String? + /// The maximum number of zone changes to fetch. Defaults to the maximum allowed in a request. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/requestBody/json/resultsLimit`. + public var resultsLimit: Swift.Int? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - syncToken: Identifies a point in the database's change history. Omit on the initial fetch to start from the beginning. + /// - resultsLimit: The maximum number of zone changes to fetch. Defaults to the maximum allowed in a request. + public init( + syncToken: Swift.String? = nil, + resultsLimit: Swift.Int? = nil + ) { + self.syncToken = syncToken + self.resultsLimit = resultsLimit + } + public enum CodingKeys: String, CodingKey { + case syncToken + case resultsLimit + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/requestBody/content/application\/json`. + case json(Operations.fetchDatabaseChanges.Input.Body.jsonPayload) + } + public var body: Operations.fetchDatabaseChanges.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.fetchDatabaseChanges.Input.Path, + headers: Operations.fetchDatabaseChanges.Input.Headers = .init(), + body: Operations.fetchDatabaseChanges.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/database/POST/responses/200/content/application\/json`. + case json(Components.Schemas.DatabaseChangesResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.DatabaseChangesResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.fetchDatabaseChanges.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.fetchDatabaseChanges.Output.Ok.Body) { + self.body = body + } + } + /// Database changes retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.fetchDatabaseChanges.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.fetchDatabaseChanges.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/database/post(fetchDatabaseChanges)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Fetch Record Zone Changes + /// + /// Get the records that changed within one or more record zones relative to each zone's sync token. Intended for custom zones. Each entry in the response `zones` array is either a per-zone success result or a per-zone error. + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/changes/zone`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)`. + public enum fetchRecordZoneChanges { + public static let id: Swift.String = "fetchRecordZoneChanges" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.fetchRecordZoneChanges.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.fetchRecordZoneChanges.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// A zone request dictionary for each zone to fetch record changes from. Per-zone values override the top-level values in this request. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/json/zones`. + public var zones: [Components.Schemas.RecordZoneChangesRequestZone] + /// Whether the changes are returned in reverse order. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/json/reverse`. + public var reverse: Swift.Bool? + /// Record field names limiting the fields returned per changed record. Omit to fetch all fields. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/json/desiredKeys`. + public var desiredKeys: [Swift.String]? + /// Whether number fields should be represented as strings. Defaults to `false`. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/json/numberAsStrings`. + public var numberAsStrings: Swift.Bool? + /// The maximum number of records to fetch. Defaults to the maximum allowed in a request. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/json/resultsLimit`. + public var resultsLimit: Swift.Int? + /// Record-type names limiting the change feed to specific record types. Omit to fetch changes from all record types. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/json/desiredRecordTypes`. + public var desiredRecordTypes: [Swift.String]? + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - zones: A zone request dictionary for each zone to fetch record changes from. Per-zone values override the top-level values in this request. + /// - reverse: Whether the changes are returned in reverse order. + /// - desiredKeys: Record field names limiting the fields returned per changed record. Omit to fetch all fields. + /// - numberAsStrings: Whether number fields should be represented as strings. Defaults to `false`. + /// - resultsLimit: The maximum number of records to fetch. Defaults to the maximum allowed in a request. + /// - desiredRecordTypes: Record-type names limiting the change feed to specific record types. Omit to fetch changes from all record types. + public init( + zones: [Components.Schemas.RecordZoneChangesRequestZone], + reverse: Swift.Bool? = nil, + desiredKeys: [Swift.String]? = nil, + numberAsStrings: Swift.Bool? = nil, + resultsLimit: Swift.Int? = nil, + desiredRecordTypes: [Swift.String]? = nil + ) { + self.zones = zones + self.reverse = reverse + self.desiredKeys = desiredKeys + self.numberAsStrings = numberAsStrings + self.resultsLimit = resultsLimit + self.desiredRecordTypes = desiredRecordTypes + } + public enum CodingKeys: String, CodingKey { + case zones + case reverse + case desiredKeys + case numberAsStrings + case resultsLimit + case desiredRecordTypes + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/requestBody/content/application\/json`. + case json(Operations.fetchRecordZoneChanges.Input.Body.jsonPayload) + } + public var body: Operations.fetchRecordZoneChanges.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.fetchRecordZoneChanges.Input.Path, + headers: Operations.fetchRecordZoneChanges.Input.Headers = .init(), + body: Operations.fetchRecordZoneChanges.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/changes/zone/POST/responses/200/content/application\/json`. + case json(Components.Schemas.RecordZoneChangesResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.RecordZoneChangesResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.fetchRecordZoneChanges.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.fetchRecordZoneChanges.Output.Ok.Body) { + self.body = body + } + } + /// Record zone changes retrieved successfully + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.fetchRecordZoneChanges.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.fetchRecordZoneChanges.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/changes/zone/post(fetchRecordZoneChanges)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } /// List All Subscriptions /// /// Fetch all subscriptions in the database diff --git a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+ErrorHandling.swift new file mode 100644 index 00000000..c3d29613 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+ErrorHandling.swift @@ -0,0 +1,78 @@ +// +// CloudKitServiceTests.FetchDatabaseChanges+ErrorHandling.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.FetchDatabaseChanges { + @Suite("Error Handling") + internal struct ErrorHandling { + private typealias Harness = CloudKitServiceTests.FetchDatabaseChanges + + @Test("fetchDatabaseChanges() maps an authentication failure") + internal func mapsAuthenticationError() 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 Harness.makeService( + provider: ResponseProvider.authenticationError() + ) + + await #expect(throws: CloudKitError.self) { + _ = try await service.fetchDatabaseChanges(database: .private) + } + } + + @Test("fetchDatabaseChanges() throws when a zone entry has no zoneID") + internal func throwsOnMissingZoneID() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .databaseChangesResponse(zones: [[:]]) + ) + ) + + // Suppress the DEBUG assertion trap so the thrown error is observable. + await ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + await #expect(throws: CloudKitError.self) { + _ = try await service.fetchDatabaseChanges(database: .private) + } + } + ) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Helpers.swift new file mode 100644 index 00000000..1b2778d3 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Helpers.swift @@ -0,0 +1,154 @@ +// +// CloudKitServiceTests.FetchDatabaseChanges+Helpers.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 HTTPTypes +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchDatabaseChanges { + internal static func makeService( + provider: ResponseProvider + ) throws -> CloudKitService { + try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: MockTransport(responseProvider: provider) + ) + } + + internal static func makeSuccessfulService( + zoneCount: Int = 1, + moreComing: Bool = false, + syncToken: String = "db-sync-token-abc" + ) throws -> CloudKitService { + try makeService( + provider: ResponseProvider( + defaultResponse: try .successfulFetchDatabaseChangesResponse( + zoneCount: zoneCount, + moreComing: moreComing, + syncToken: syncToken + ) + ) + ) + } + + internal static func makePaginatedService( + pages: [(zoneCount: Int, syncToken: String)] + ) async throws -> CloudKitService { + let provider = ResponseProvider( + defaultResponse: try .successfulFetchDatabaseChangesResponse( + zoneCount: 0, + moreComing: false, + syncToken: "final-token" + ) + ) + for (index, page) in pages.enumerated() { + await provider.enqueue( + try .successfulFetchDatabaseChangesResponse( + zoneCount: page.zoneCount, + moreComing: index < pages.count - 1, + syncToken: page.syncToken + ), + for: "fetchDatabaseChanges" + ) + } + return try makeService(provider: provider) + } + + internal static func makeStuckTokenService( + syncToken: String = "stuck-token" + ) throws -> CloudKitService { + try makeService( + provider: ResponseProvider( + defaultResponse: try .successfulFetchDatabaseChangesResponse( + zoneCount: 0, + moreComing: true, + syncToken: syncToken + ) + ) + ) + } +} + +// MARK: - FetchDatabaseChanges Response Builders + +extension ResponseConfig { + internal static func successfulFetchDatabaseChangesResponse( + zoneCount: Int = 1, + moreComing: Bool = false, + syncToken: String = "db-sync-token-abc" + ) throws -> ResponseConfig { + let zones: [[String: Any]] = (0.. ResponseConfig { + var body: [String: Any] = [ + "zones": zones, + "moreComing": moreComing, + ] + if let syncToken { + body["syncToken"] = syncToken + } + + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: try JSONSerialization.data(withJSONObject: body), + error: nil + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Pagination.swift b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Pagination.swift new file mode 100644 index 00000000..fbbea9d6 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+Pagination.swift @@ -0,0 +1,131 @@ +// +// CloudKitServiceTests.FetchDatabaseChanges+Pagination.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.FetchDatabaseChanges { + @Suite("Pagination") + internal struct Pagination { + private typealias Harness = CloudKitServiceTests.FetchDatabaseChanges + + @Test("fetchAllDatabaseChanges() accumulates zones across pages") + internal func accumulatesAcrossPages() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await Harness.makePaginatedService(pages: [ + (zoneCount: 2, syncToken: "token-1"), + (zoneCount: 3, syncToken: "token-2"), + ]) + + let (zones, token) = try await service.fetchAllDatabaseChanges(database: .private) + + #expect(zones.count == 5) + #expect(token == "token-2") + } + + @Test("fetchAllDatabaseChanges() handles an empty first page with moreComing") + internal func emptyFirstPage() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await Harness.makePaginatedService(pages: [ + (zoneCount: 0, syncToken: "token-1"), + (zoneCount: 3, syncToken: "token-2"), + ]) + + let (zones, token) = try await service.fetchAllDatabaseChanges(database: .private) + + #expect(zones.count == 3) + #expect(token == "token-2") + } + + @Test("fetchAllDatabaseChanges() stops on a stuck token instead of looping") + internal func stopsOnStuckToken() 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 Harness.makeStuckTokenService(syncToken: "stuck") + + let (zones, token) = try await service.fetchAllDatabaseChanges( + syncToken: "stuck", + database: .private + ) + + #expect(zones.isEmpty) + #expect(token == "stuck") + } + + @Test("fetchAllDatabaseChanges() throws zonePaginationLimitExceeded past maxPages") + internal func throwsPastMaxPages() 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 + } + // Every page reports moreComing with a fresh token, so pagination only + // ends at the maxPages ceiling. + let provider = ResponseProvider( + defaultResponse: try .successfulFetchDatabaseChangesResponse( + zoneCount: 1, + moreComing: true, + syncToken: "token-a" + ) + ) + for index in 0..<4 { + await provider.enqueue( + try .successfulFetchDatabaseChangesResponse( + zoneCount: 1, + moreComing: true, + syncToken: "token-\(index)" + ), + for: "fetchDatabaseChanges" + ) + } + let service = try Harness.makeService(provider: provider) + + do { + _ = try await service.fetchAllDatabaseChanges(maxPages: 2, database: .private) + Issue.record("expected .zonePaginationLimitExceeded") + } catch let error as CloudKitError { + guard case .zonePaginationLimitExceeded(let maxPages, let zones) = error else { + Issue.record("expected .zonePaginationLimitExceeded, got \(error)") + return + } + #expect(maxPages == 2) + #expect(zones.count == 2) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.swift new file mode 100644 index 00000000..0a722882 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.swift @@ -0,0 +1,162 @@ +// +// CloudKitServiceTests.FetchDatabaseChanges+SuccessCases.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.FetchDatabaseChanges { + @Suite("Success Cases") + internal struct SuccessCases { + private typealias Harness = CloudKitServiceTests.FetchDatabaseChanges + + @Test("fetchDatabaseChanges() returns changed zones and sync token") + internal func returnsZonesAndToken() 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 Harness.makeSuccessfulService( + zoneCount: 2, + syncToken: "db-token-xyz" + ) + + let result = try await service.fetchDatabaseChanges(database: .private) + + #expect(result.zones.count == 2) + #expect(result.changedZones.count == 2) + #expect(result.syncToken == "db-token-xyz") + #expect(result.moreComing == false) + #expect(result.failures.isEmpty) + } + + @Test("fetchDatabaseChanges() surfaces zone names") + internal func returnsZoneNames() 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 Harness.makeSuccessfulService(zoneCount: 1) + + let result = try await service.fetchDatabaseChanges(database: .private) + + #expect(result.changedZones.first?.zoneName == "test-zone-0") + } + + @Test("fetchDatabaseChanges() returns empty zones when nothing changed") + internal func returnsEmptyZones() 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 Harness.makeSuccessfulService(zoneCount: 0) + + let result = try await service.fetchDatabaseChanges(database: .private) + + #expect(result.zones.isEmpty) + #expect(result.syncToken != nil) + } + + @Test("fetchDatabaseChanges() reports moreComing") + internal func reportsMoreComing() 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 Harness.makeSuccessfulService(zoneCount: 1, moreComing: true) + + let result = try await service.fetchDatabaseChanges(database: .private) + + #expect(result.moreComing) + } + + @Test("fetchDatabaseChanges() surfaces a per-zone failure without dropping successes") + internal func surfacesPerZoneFailure() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .databaseChangesResponse(zones: [ + ["zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"]], + [ + "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "serverErrorCode": "ZONE_NOT_FOUND", + "reason": "Zone does not exist", + ], + ]) + ) + ) + + let result = try await service.fetchDatabaseChanges(database: .private) + + #expect(result.zones.count == 2) + #expect(result.changedZones.map(\.zoneName) == ["good-zone"]) + + let failure = try #require(result.failures.first) + #expect(failure.zoneName == "bad-zone") + #expect(failure.serverErrorCode == .zoneNotFound) + #expect(failure.reason == "Zone does not exist") + } + + @Test("ZoneChangeResult.get() rethrows a per-zone failure as zoneOperationFailed") + internal func getRethrowsFailure() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .databaseChangesResponse(zones: [ + [ + "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "serverErrorCode": "ZONE_NOT_FOUND", + ] + ]) + ) + ) + + let result = try await service.fetchDatabaseChanges(database: .private) + let entry = try #require(result.zones.first) + + do { + _ = try entry.get() + Issue.record("expected .zoneOperationFailed") + } catch let error as CloudKitError { + guard case .zoneOperationFailed(let failure) = error else { + Issue.record("expected .zoneOperationFailed, got \(error)") + return + } + #expect(failure.zoneName == "bad-zone") + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges.swift b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges.swift new file mode 100644 index 00000000..eaeac906 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchDatabaseChanges/CloudKitServiceTests.FetchDatabaseChanges.swift @@ -0,0 +1,42 @@ +// +// CloudKitServiceTests.FetchDatabaseChanges.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 { + @Suite( + "CloudKitService FetchDatabaseChanges Operations", + .enabled(if: Platform.isCryptoAvailable), + .disabled(if: Platform.isWasm) + ) + internal enum FetchDatabaseChanges {} +} diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+ErrorHandling.swift new file mode 100644 index 00000000..df66bb9d --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+ErrorHandling.swift @@ -0,0 +1,86 @@ +// +// CloudKitServiceTests.FetchRecordZoneChanges+ErrorHandling.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.FetchRecordZoneChanges { + @Suite("Error Handling") + internal struct ErrorHandling { + private typealias Harness = CloudKitServiceTests.FetchRecordZoneChanges + + @Test("fetchRecordZoneChanges() maps an authentication failure") + internal func mapsAuthenticationError() 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 Harness.makeService( + provider: ResponseProvider.authenticationError() + ) + + await #expect(throws: CloudKitError.self) { + _ = try await service.fetchRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a"))], + database: .private + ) + } + } + + @Test("fetchRecordZoneChanges() throws when a zone result has no zoneID") + internal func throwsOnMissingZoneID() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: [ + ["records": [], "syncToken": "tok"] + ]) + ) + ) + + // Suppress the DEBUG assertion trap so the thrown error is observable. + await ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + await #expect(throws: CloudKitError.self) { + _ = try await service.fetchRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a"))], + database: .private + ) + } + } + ) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Helpers.swift new file mode 100644 index 00000000..7d7dd6c2 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Helpers.swift @@ -0,0 +1,106 @@ +// +// CloudKitServiceTests.FetchRecordZoneChanges+Helpers.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 HTTPTypes +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.FetchRecordZoneChanges { + internal static func makeService( + provider: ResponseProvider + ) throws -> CloudKitService { + try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: MockTransport(responseProvider: provider) + ) + } + + /// A single-zone success entry carrying `recordCount` synthetic records. + internal static func zoneEntry( + zoneName: String, + recordCount: Int, + syncToken: String, + moreComing: Bool = false, + recordNamePrefix: String = "record" + ) -> [String: Any] { + let records: [[String: Any]] = (0.. [String: Any] { + [ + "zoneID": ["zoneName": zoneName, "ownerName": "_defaultOwner"], + "serverErrorCode": serverErrorCode, + "reason": reason, + ] + } +} + +// MARK: - FetchRecordZoneChanges Response Builders + +extension ResponseConfig { + internal static func recordZoneChangesResponse( + zones: [[String: Any]] + ) throws -> ResponseConfig { + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: try JSONSerialization.data(withJSONObject: ["zones": zones]), + error: nil + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Pagination.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Pagination.swift new file mode 100644 index 00000000..b34fb7f4 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+Pagination.swift @@ -0,0 +1,166 @@ +// +// CloudKitServiceTests.FetchRecordZoneChanges+Pagination.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.FetchRecordZoneChanges { + @Suite("Pagination") + internal struct Pagination { + private typealias Harness = CloudKitServiceTests.FetchRecordZoneChanges + + private static let operationID = "fetchRecordZoneChanges" + + /// Builds a service whose `changes/zone` responses are the given rounds, + /// in order. + private static func makeService( + rounds: [[[String: Any]]] + ) async throws -> CloudKitService { + let provider = ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: []) + ) + for round in rounds { + await provider.enqueue( + try .recordZoneChangesResponse(zones: round), + for: operationID + ) + } + return try Harness.makeService(provider: provider) + } + + @Test("fetchAllRecordZoneChanges() merges a zone's records across rounds") + internal func mergesAcrossRounds() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await Self.makeService(rounds: [ + [ + Harness.zoneEntry( + zoneName: "zone-a", + recordCount: 2, + syncToken: "tok-1", + moreComing: true, + recordNamePrefix: "first" + ) + ], + [ + Harness.zoneEntry( + zoneName: "zone-a", + recordCount: 3, + syncToken: "tok-2", + recordNamePrefix: "second" + ) + ], + ]) + + let result = try await service.fetchAllRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a"))], + database: .private + ) + + let changes = try #require(result.changes.first) + #expect(changes.records.count == 5) + #expect(changes.syncToken == "tok-2") + // The merged result is fully drained. + #expect(changes.moreComing == false) + #expect(result.moreComing == false) + } + + @Test("fetchAllRecordZoneChanges() re-requests only the zones still reporting moreComing") + internal func reRequestsOnlyPendingZones() 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 + } + // Round 1: zone-a is done, zone-b has more. + // Round 2: only zone-b is requested, and it finishes. + let service = try await Self.makeService(rounds: [ + [ + Harness.zoneEntry(zoneName: "zone-a", recordCount: 1, syncToken: "a-1"), + Harness.zoneEntry( + zoneName: "zone-b", + recordCount: 1, + syncToken: "b-1", + moreComing: true, + recordNamePrefix: "b-first" + ), + ], + [ + Harness.zoneEntry( + zoneName: "zone-b", + recordCount: 2, + syncToken: "b-2", + recordNamePrefix: "b-second" + ) + ], + ]) + + let result = try await service.fetchAllRecordZoneChanges( + zones: [ + ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a")), + ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-b")), + ], + database: .private + ) + + // Output preserves the originally-requested order. + #expect(result.changes.map(\.zone.zoneName) == ["zone-a", "zone-b"]) + #expect(result.changes.map(\.records.count) == [1, 3]) + #expect(result.changes.map(\.syncToken) == ["a-1", "b-2"]) + } + + @Test("fetchAllRecordZoneChanges() keeps a per-zone failure in the merged result") + internal func keepsFailure() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try await Self.makeService(rounds: [ + [ + Harness.zoneEntry(zoneName: "zone-a", recordCount: 1, syncToken: "a-1"), + Harness.zoneErrorEntry(zoneName: "zone-b"), + ] + ]) + + let result = try await service.fetchAllRecordZoneChanges( + zones: [ + ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a")), + ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-b")), + ], + database: .private + ) + + #expect(result.changes.count == 1) + #expect(result.failures.map(\.zoneName) == ["zone-b"]) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.swift new file mode 100644 index 00000000..46eb2086 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.swift @@ -0,0 +1,126 @@ +// +// CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.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.FetchRecordZoneChanges { + /// Guard-rail tests for the record-zone-changes paginator: the stuck-token + /// bail-out and the `maxPages` ceiling. Split from `+Pagination.swift` to + /// keep both files within the file-length limit. + @Suite("Pagination Limits") + internal struct PaginationLimits { + private typealias Harness = CloudKitServiceTests.FetchRecordZoneChanges + + private static let operationID = "fetchRecordZoneChanges" + + @Test("fetchAllRecordZoneChanges() stops on a stuck zone token") + internal func stopsOnStuckToken() 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 + } + // Always reports moreComing with no records and an unchanged token. + let provider = ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: [ + Harness.zoneEntry( + zoneName: "zone-a", + recordCount: 0, + syncToken: "stuck", + moreComing: true + ) + ]) + ) + let service = try Harness.makeService(provider: provider) + + let result = try await service.fetchAllRecordZoneChanges( + zones: [ + ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a"), syncToken: "stuck") + ], + database: .private + ) + + let changes = try #require(result.changes.first) + #expect(changes.records.isEmpty) + #expect(changes.syncToken == "stuck") + } + + @Test("fetchAllRecordZoneChanges() throws paginationLimitExceeded past maxPages") + internal func throwsPastMaxPages() 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 + } + // Every round advances the token and reports moreComing, so only the + // maxPages ceiling ends the loop. + let provider = ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: [ + Harness.zoneEntry( + zoneName: "zone-a", + recordCount: 1, + syncToken: UUID().uuidString, + moreComing: true + ) + ]) + ) + for index in 0..<5 { + await provider.enqueue( + try .recordZoneChangesResponse(zones: [ + Harness.zoneEntry( + zoneName: "zone-a", + recordCount: 1, + syncToken: "tok-\(index)", + moreComing: true, + recordNamePrefix: "round-\(index)" + ) + ]), + for: Self.operationID + ) + } + let service = try Harness.makeService(provider: provider) + + do { + _ = try await service.fetchAllRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a"))], + maxPages: 3, + database: .private + ) + Issue.record("expected .paginationLimitExceeded") + } catch let error as CloudKitError { + guard case .paginationLimitExceeded(let maxPages, let records) = error else { + Issue.record("expected .paginationLimitExceeded, got \(error)") + return + } + #expect(maxPages == 3) + #expect(records.count == 3) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+SuccessCases.swift new file mode 100644 index 00000000..17f016b7 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+SuccessCases.swift @@ -0,0 +1,155 @@ +// +// CloudKitServiceTests.FetchRecordZoneChanges+SuccessCases.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.FetchRecordZoneChanges { + @Suite("Success Cases") + internal struct SuccessCases { + private typealias Harness = CloudKitServiceTests.FetchRecordZoneChanges + + @Test("fetchRecordZoneChanges() returns per-zone records and sync token") + internal func returnsPerZoneChanges() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: [ + Harness.zoneEntry(zoneName: "zone-a", recordCount: 2, syncToken: "tok-a") + ]) + ) + ) + + let result = try await service.fetchRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a"))], + database: .private + ) + + let changes = try #require(result.changes.first) + #expect(changes.zone.zoneName == "zone-a") + #expect(changes.records.count == 2) + #expect(changes.syncToken == "tok-a") + #expect(changes.moreComing == false) + #expect(result.moreComing == false) + } + + @Test("fetchRecordZoneChanges() handles multiple zones independently") + internal func handlesMultipleZones() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: [ + Harness.zoneEntry(zoneName: "zone-a", recordCount: 1, syncToken: "tok-a"), + Harness.zoneEntry( + zoneName: "zone-b", + recordCount: 3, + syncToken: "tok-b", + moreComing: true + ), + ]) + ) + ) + + let result = try await service.fetchRecordZoneChanges( + zones: [ + ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a")), + ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-b")), + ], + database: .private + ) + + #expect(result.changes.count == 2) + #expect(result.changes.map(\.zone.zoneName) == ["zone-a", "zone-b"]) + #expect(result.changes.map(\.records.count) == [1, 3]) + // Per-zone pagination: only zone-b has more. + #expect(result.changes.map(\.moreComing) == [false, true]) + #expect(result.moreComing) + } + + @Test("fetchRecordZoneChanges() surfaces a per-zone failure alongside successes") + internal func surfacesPerZoneFailure() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: [ + Harness.zoneEntry(zoneName: "good-zone", recordCount: 1, syncToken: "tok"), + Harness.zoneErrorEntry(zoneName: "bad-zone"), + ]) + ) + ) + + let result = try await service.fetchRecordZoneChanges( + zones: [ + ZoneChangesRequest(zoneID: ZoneID(zoneName: "good-zone")), + ZoneChangesRequest(zoneID: ZoneID(zoneName: "bad-zone")), + ], + database: .private + ) + + #expect(result.zones.count == 2) + #expect(result.changes.map(\.zone.zoneName) == ["good-zone"]) + + let failure = try #require(result.failures.first) + #expect(failure.zoneName == "bad-zone") + #expect(failure.serverErrorCode == .zoneNotFound) + } + + @Test("fetchRecordZoneChanges() returns an empty result for no zones changed") + internal func returnsEmptyResult() 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 Harness.makeService( + provider: ResponseProvider( + defaultResponse: try .recordZoneChangesResponse(zones: []) + ) + ) + + let result = try await service.fetchRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: ZoneID(zoneName: "zone-a"))], + database: .private + ) + + #expect(result.zones.isEmpty) + #expect(result.moreComing == false) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges.swift new file mode 100644 index 00000000..8ec3f00e --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges.swift @@ -0,0 +1,42 @@ +// +// CloudKitServiceTests.FetchRecordZoneChanges.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 { + @Suite( + "CloudKitService FetchRecordZoneChanges Operations", + .enabled(if: Platform.isCryptoAvailable), + .disabled(if: Platform.isWasm) + ) + internal enum FetchRecordZoneChanges {} +} diff --git a/openapi.yaml b/openapi.yaml index e6e8249b..93797603 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -552,9 +552,15 @@ paths: /database/{version}/{container}/{environment}/{database}/zones/changes: post: - summary: Fetch Zone Changes - description: Get all changed zones relative to a meta-sync token + summary: Fetch Zone Changes (deprecated) + description: >- + Get all changed zones relative to a meta-sync token. + + **Deprecated by Apple** in favor of `changes/database` + (`fetchDatabaseChanges`), which returns the same "which zones changed" + information. New code should use `changes/database`. operationId: fetchZoneChanges + deprecated: true tags: - Zones parameters: @@ -584,6 +590,160 @@ paths: '401': $ref: '#/components/responses/Failure' + /database/{version}/{container}/{environment}/{database}/changes/database: + post: + summary: Fetch Database Changes + description: >- + Get the record zones in the database that have changed relative to a + sync token. This is the current replacement for the deprecated + `zones/changes` operation. Follow up with `changes/zone` to fetch the + record changes within each returned zone. + operationId: fetchDatabaseChanges + tags: + - Zones + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + syncToken: + type: string + description: >- + Identifies a point in the database's change history. Omit + on the initial fetch to start from the beginning. + resultsLimit: + type: integer + description: >- + The maximum number of zone changes to fetch. Defaults to + the maximum allowed in a request. + responses: + '200': + description: Database changes retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DatabaseChangesResponse' + '400': + $ref: '#/components/responses/Failure' + '401': + $ref: '#/components/responses/Failure' + '403': + $ref: '#/components/responses/Failure' + '404': + $ref: '#/components/responses/Failure' + '409': + $ref: '#/components/responses/Failure' + '412': + $ref: '#/components/responses/Failure' + '413': + $ref: '#/components/responses/Failure' + '429': + $ref: '#/components/responses/Failure' + '421': + $ref: '#/components/responses/Failure' + '500': + $ref: '#/components/responses/Failure' + '503': + $ref: '#/components/responses/Failure' + + /database/{version}/{container}/{environment}/{database}/changes/zone: + post: + summary: Fetch Record Zone Changes + description: >- + Get the records that changed within one or more record zones relative + to each zone's sync token. Intended for custom zones. Each entry in the + response `zones` array is either a per-zone success result or a per-zone + error. + operationId: fetchRecordZoneChanges + tags: + - Zones + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - zones + properties: + zones: + type: array + description: >- + A zone request dictionary for each zone to fetch record + changes from. Per-zone values override the top-level values + in this request. + items: + $ref: '#/components/schemas/RecordZoneChangesRequestZone' + reverse: + type: boolean + description: >- + Whether the changes are returned in reverse order. + desiredKeys: + type: array + items: + type: string + description: >- + Record field names limiting the fields returned per changed + record. Omit to fetch all fields. + numberAsStrings: + type: boolean + description: >- + Whether number fields should be represented as strings. + Defaults to `false`. + resultsLimit: + type: integer + description: >- + The maximum number of records to fetch. Defaults to the + maximum allowed in a request. + desiredRecordTypes: + type: array + items: + type: string + description: >- + Record-type names limiting the change feed to specific + record types. Omit to fetch changes from all record types. + responses: + '200': + description: Record zone changes retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/RecordZoneChangesResponse' + '400': + $ref: '#/components/responses/Failure' + '401': + $ref: '#/components/responses/Failure' + '403': + $ref: '#/components/responses/Failure' + '404': + $ref: '#/components/responses/Failure' + '409': + $ref: '#/components/responses/Failure' + '412': + $ref: '#/components/responses/Failure' + '413': + $ref: '#/components/responses/Failure' + '429': + $ref: '#/components/responses/Failure' + '421': + $ref: '#/components/responses/Failure' + '500': + $ref: '#/components/responses/Failure' + '503': + $ref: '#/components/responses/Failure' + /database/{version}/{container}/{environment}/{database}/subscriptions/list: get: summary: List All Subscriptions @@ -1729,6 +1889,135 @@ components: moreComing: type: boolean + DatabaseChangesResponse: + type: object + description: | + Response body of `changes/database` (Fetching Database Changes). Each + entry in `zones` is either a Zone dictionary (success) or a Zone Fetch + Error dictionary (failure), per Apple's reference. + properties: + zones: + type: array + items: + oneOf: + - $ref: '#/components/schemas/ZoneFetchFailure' + - $ref: '#/components/schemas/DatabaseChangedZone' + syncToken: + type: string + description: >- + Identifies a point in the database's change history. Pass this in + the next request to fetch only newer changes. + moreComing: + type: boolean + description: >- + Whether there are more changes to request using the returned + `syncToken`. + + DatabaseChangedZone: + type: object + description: A zone that changed, as returned by `changes/database`. + properties: + zoneID: + $ref: '#/components/schemas/ZoneID' + + ZoneFetchFailure: + type: object + description: | + Per-zone error returned inline in the `zones` array of a 200 zone-fetch + response (`changes/database`, `changes/zone`). Mirrors + `RecordOperationFailure` for records, but keyed by `zoneID`. + required: + - serverErrorCode + properties: + zoneID: + $ref: '#/components/schemas/ZoneID' + serverErrorCode: + $ref: '#/components/schemas/OperationFailureServerErrorCode' + reason: + type: string + description: A string indicating the reason for the error. + retryAfter: + type: integer + description: Suggested seconds to wait before retrying. Absent if not retryable. + uuid: + type: string + description: A unique identifier for this error. + redirectURL: + type: string + description: Redirect URL for sign-in; present when serverErrorCode is AUTHENTICATION_REQUIRED. + + RecordZoneChangesRequestZone: + type: object + description: | + A per-zone request entry in the `zones` array of a `changes/zone` + request. Carries the same tuning keys as the enclosing request; values + set here override the top-level values for this zone. + required: + - zoneID + properties: + zoneID: + $ref: '#/components/schemas/ZoneID' + syncToken: + type: string + description: >- + Identifies a point in this zone's change history. Omit on the + initial fetch. + reverse: + type: boolean + description: Whether the changes are returned in reverse order. + desiredKeys: + type: array + items: + type: string + description: Record field names limiting the fields returned per changed record. + numberAsStrings: + type: boolean + description: Whether number fields should be represented as strings. + resultsLimit: + type: integer + description: The maximum number of records to fetch for this zone. + desiredRecordTypes: + type: array + items: + type: string + description: Record-type names limiting the change feed for this zone. + + RecordZoneChangesResponse: + type: object + description: | + Response body of `changes/zone` (Fetching Record Zone Changes). Each + entry in `zones` is either a Zone Record Fetch dictionary (success) or a + Zone Record Fetch Error dictionary (failure), per Apple's reference. + properties: + zones: + type: array + items: + oneOf: + - $ref: '#/components/schemas/ZoneFetchFailure' + - $ref: '#/components/schemas/RecordZoneChangesZoneResult' + + RecordZoneChangesZoneResult: + type: object + description: >- + A successful per-zone result of `changes/zone`: the records that + changed in that zone plus that zone's own sync token and `moreComing` + flag. + properties: + zoneID: + $ref: '#/components/schemas/ZoneID' + records: + type: array + items: + $ref: '#/components/schemas/RecordResponse' + syncToken: + type: string + description: Identifies a point in this zone's change history. + moreComing: + type: boolean + description: >- + Whether there are more changes to request for this zone using the + returned `syncToken`. + SubscriptionsListResponse: type: object properties: