From 984ee4e86e0ea59f1fd9a5de57edd92f4556a09a Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 28 Aug 2026 13:50:52 -0400 Subject: [PATCH 1/7] Model per-zone failures on zones/modify; clarify zones/changes syncToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ZonesModifyResponse.zones` items become `oneOf: [ZoneFetchFailure, Zone]`, matching `changes/database` and `changes/zone`. `zones/modify` is a batch endpoint whose realistic failure mode is partial, and Apple routes all four zone endpoints' failures through the same Zone Fetch Error Dictionary, so the error variant already exists — it just was not wired to this response. The failure variant is listed first, matching every other `oneOf` in the spec. `ZoneFetchFailure` requires `serverErrorCode`, so a success payload fails that branch and falls through to `Zone`. Also rewords the `zones/changes` request `syncToken` description (#433 part 3): it was described as "Meta-sync token", a name the spec does not use for the key. The key stays `syncToken` (#430); only the prose is corrected. Regenerating also repairs Sources/MistKitOpenAPI reproducibility: CodeFactor's bot alphabetized the Foundation imports in Client.swift/Types.swift in 61235b5, so `./Scripts/generate-openapi.sh` no longer reproduced the committed output. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xs1c8vvxjCxqZiStcmuPS2 --- Sources/MistKitOpenAPI/Client.swift | 4 +- Sources/MistKitOpenAPI/Types.swift | 61 +++++++++++++++++++++++++---- openapi.yaml | 24 ++++++++++-- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/Sources/MistKitOpenAPI/Client.swift b/Sources/MistKitOpenAPI/Client.swift index bd5c4df2..9037e6b8 100644 --- a/Sources/MistKitOpenAPI/Client.swift +++ b/Sources/MistKitOpenAPI/Client.swift @@ -3,13 +3,13 @@ // swift-format-ignore-file @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif import HTTPTypes /// CloudKit web services provides an HTTP interface to fetch, create, update, and delete records, zones, and subscriptions. diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 3f6c367d..97efb540 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -3,13 +3,13 @@ // swift-format-ignore-file @_spi(Generated) import OpenAPIRuntime #if os(Linux) +@preconcurrency import struct Foundation.URL @preconcurrency import struct Foundation.Data @preconcurrency import struct Foundation.Date -@preconcurrency import struct Foundation.URL #else +import struct Foundation.URL import struct Foundation.Data import struct Foundation.Date -import struct Foundation.URL #endif /// A type that performs HTTP operations defined by the OpenAPI document. public protocol APIProtocol: Sendable { @@ -2321,15 +2321,60 @@ public enum Components { case zones } } + /// Response body of `zones/modify`. Each entry in `zones` is either a Zone + /// dictionary (success) or a Zone Fetch Error dictionary (failure), per + /// Apple's archived reference. `zones/modify` is a batch endpoint whose + /// realistic failure mode is partial — creating a zone that already exists + /// alongside zones that create cleanly — so a failed entry must not + /// discard the entries that succeeded (see issue #431). + /// + /// /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse`. public struct ZonesModifyResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload`. + @frozen public enum zonesPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload/case1`. + case ZoneFetchFailure(Components.Schemas.ZoneFetchFailure) + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload/case2`. + case Zone(Components.Schemas.Zone) + 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 = .Zone(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 .Zone(value): + try value.encode(to: encoder) + } + } + } /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. - public var zones: [Components.Schemas.Zone]? + public typealias zonesPayload = [Components.Schemas.ZonesModifyResponse.zonesPayloadPayload] + /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. + public var zones: Components.Schemas.ZonesModifyResponse.zonesPayload? /// Creates a new `ZonesModifyResponse`. /// /// - Parameters: /// - zones: - public init(zones: [Components.Schemas.Zone]? = nil) { + public init(zones: Components.Schemas.ZonesModifyResponse.zonesPayload? = nil) { self.zones = zones } public enum CodingKeys: String, CodingKey { @@ -2457,8 +2502,8 @@ public enum Components { case zoneID } } - /// Per-zone error returned inline in the `zones` array of a 200 zone-fetch - /// response (`changes/database`, `changes/zone`). Mirrors + /// Per-zone error returned inline in the `zones` array of a 200 zone + /// response (`changes/database`, `changes/zone`, `zones/modify`). Mirrors /// `RecordOperationFailure` for records, but keyed by `zoneID`. /// /// @@ -8753,14 +8798,14 @@ public enum Operations { @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json`. public struct jsonPayload: Codable, Hashable, Sendable { - /// Meta-sync token from previous operation + /// Sync token returned by a previous `zones/changes` call. Omit it to fetch every zone. Apple's archived reference names this key `metaSyncToken`; MistKit sends `syncToken`, which is also the name Apple's own `moreComing` prose uses. The key is deliberately left as `syncToken` (see issue #430) — only this description is corrected, so the spec no longer describes the field by a name it does not use. /// /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json/syncToken`. public var syncToken: Swift.String? /// Creates a new `jsonPayload`. /// /// - Parameters: - /// - syncToken: Meta-sync token from previous operation + /// - syncToken: Sync token returned by a previous `zones/changes` call. Omit it to fetch every zone. Apple's archived reference names this key `metaSyncToken`; MistKit sends `syncToken`, which is also the name Apple's own `moreComing` prose uses. The key is deliberately left as `syncToken` (see issue #430) — only this description is corrected, so the spec no longer describes the field by a name it does not use. public init(syncToken: Swift.String? = nil) { self.syncToken = syncToken } diff --git a/openapi.yaml b/openapi.yaml index 93797603..45f861ef 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -577,7 +577,14 @@ paths: properties: syncToken: type: string - description: Meta-sync token from previous operation + description: >- + Sync token returned by a previous `zones/changes` call. + Omit it to fetch every zone. Apple's archived reference + names this key `metaSyncToken`; MistKit sends `syncToken`, + which is also the name Apple's own `moreComing` prose uses. + The key is deliberately left as `syncToken` (see issue + #430) — only this description is corrected, so the spec no + longer describes the field by a name it does not use. responses: '200': description: Zone changes retrieved successfully @@ -1871,11 +1878,20 @@ components: ZonesModifyResponse: type: object + description: | + Response body of `zones/modify`. Each entry in `zones` is either a Zone + dictionary (success) or a Zone Fetch Error dictionary (failure), per + Apple's archived reference. `zones/modify` is a batch endpoint whose + realistic failure mode is partial — creating a zone that already exists + alongside zones that create cleanly — so a failed entry must not + discard the entries that succeeded (see issue #431). properties: zones: type: array items: - $ref: '#/components/schemas/Zone' + oneOf: + - $ref: '#/components/schemas/ZoneFetchFailure' + - $ref: '#/components/schemas/Zone' ZoneChangesResponse: type: object @@ -1923,8 +1939,8 @@ components: ZoneFetchFailure: type: object description: | - Per-zone error returned inline in the `zones` array of a 200 zone-fetch - response (`changes/database`, `changes/zone`). Mirrors + Per-zone error returned inline in the `zones` array of a 200 zone + response (`changes/database`, `changes/zone`, `zones/modify`). Mirrors `RecordOperationFailure` for records, but keyed by `zoneID`. required: - serverErrorCode From 4389611b228f02381b711306406384df4c9a611e Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 28 Aug 2026 13:51:13 -0400 Subject: [PATCH 2/7] modifyZones returns [ZoneChangeResult] and surfaces per-zone failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `modifyZones` mapped every entry straight through as a success, so a batch where some zones failed was reported as a partial success naming no zone and discarding CloudKit's `serverErrorCode`/`reason`. It now returns a bare `[ZoneChangeResult]` — one entry per zone the server returned — mirroring how `modifyRecords` returns a bare `[RecordResult]`. `zones/modify` carries no batch-level metadata, so there is deliberately no `DatabaseChangesResult`-style wrapper struct. `ZoneChangeResult` / `ZoneOperationFailure` from #429 are reused rather than duplicated; the only new code is a second `init(from:)` overload keyed off the generated `ZonesModifyResponse.zonesPayloadPayload`. Two convenience-wrapper bugs fall out of this: - `createZone` threw a bare `.invalidResponse` with no code, reason or zone name when CloudKit rejected the create. It now calls `.get()`, throwing `.zoneOperationFailed` with the full failure. - `deleteZone` discarded the result entirely, so a `ZONE_NOT_FOUND` delete was reported to the caller as success. It now checks every entry. `.zones` / `.failures` accessors are added as concrete `Array` extensions (`[ZoneChangeResult]`, plus `.records`/`.failures` on `[RecordResult]`) rather than one generic extension over `OperationResult`: Swift cannot bind free generic parameters in an extension's `where` clause. BREAKING: `modifyZones` returns `[ZoneChangeResult]`, not `[ZoneInfo]`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xs1c8vvxjCxqZiStcmuPS2 --- .../CloudKitService+ModifyZones.swift | 40 +++++++++---- .../MistKit/Models/Array+RecordResult.swift | 54 ++++++++++++++++++ .../Models/Zones/Array+ZoneChangeResult.swift | 56 +++++++++++++++++++ .../Models/Zones/ZoneChangeResult.swift | 24 ++++++-- 4 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 Sources/MistKit/Models/Array+RecordResult.swift create mode 100644 Sources/MistKit/Models/Zones/Array+ZoneChangeResult.swift diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift index 232b73ef..2219e9b9 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift @@ -46,27 +46,40 @@ extension CloudKitService { /// and `.shared` databases — `.public` has only `_defaultZone`, so any /// modify against it is rejected here without a network round-trip. /// + /// `zones/modify` is a batch endpoint whose realistic failure mode is + /// *partial* — creating five zones where one already exists, or deleting + /// zones where one is missing. CloudKit reports those inline in the 200 + /// response, so each entry is a ``ZoneChangeResult``: `.success` for a zone + /// the server returned, `.failure` (a ``ZoneOperationFailure`` carrying the + /// zone name, `serverErrorCode` and `reason`) for one it rejected. Use the + /// `zones` / `failures` array conveniences to split them, or + /// ``OperationResult/get()`` to rethrow a failure. + /// /// - Parameters: /// - operations: Non-empty array of create/delete operations. Each /// operation's `ZoneID` must have a non-empty `zoneName`. /// - database: Target database. Must not be `.public`. - /// - Returns: Array of `ZoneInfo` for the zones returned by the server. + /// - Returns: A ``ZoneChangeResult`` per entry the server returned, in + /// response order. /// - Throws: `CloudKitError` if validation fails or the request fails. /// /// Example - Create and delete in one batch: /// ```swift - /// let zones = try await service.modifyZones( + /// let results = try await service.modifyZones( /// [ /// .create(ZoneID(zoneName: "Articles")), /// .delete(ZoneID(zoneName: "Archive")) /// ], /// database: .private /// ) + /// for failure in results.failures { + /// print("\(failure.zoneName): \(failure.serverErrorCode.rawValue)") + /// } /// ``` public func modifyZones( _ operations: [ZoneOperation], database: Database - ) async throws(CloudKitError) -> [ZoneInfo] { + ) async throws(CloudKitError) -> [ZoneChangeResult] { do { let client = try self.client(for: database) let response = try await client.modifyZones( @@ -87,7 +100,7 @@ extension CloudKitService { let zonesData: Components.Schemas.ZonesModifyResponse = try await responseProcessor.processModifyZonesResponse(response) - return try (zonesData.zones ?? []).map { try ZoneInfo(from: $0) } + return try (zonesData.zones ?? []).map { try ZoneChangeResult(from: $0) } } catch { throw mapToCloudKitError(error, context: "modifyZones") } @@ -105,8 +118,10 @@ extension CloudKitService { /// caller's own zones (typical). /// - database: Target database. Must not be `.public`. /// - Returns: `ZoneInfo` for the created zone. - /// - Throws: `CloudKitError`. ``CloudKitError/invalidResponse`` if the - /// server returns no zone in its response. + /// - Throws: `CloudKitError`. ``CloudKitError/zoneOperationFailed(_:)`` when + /// CloudKit rejects the zone — carrying the zone name, `serverErrorCode` + /// and `reason` — or ``CloudKitError/invalidResponse`` if the server + /// returns no entry at all. /// /// # Example /// ```swift @@ -125,10 +140,10 @@ extension CloudKitService { ) let results = try await modifyZones([operation], database: database) - guard let zone = results.first else { + guard let result = results.first else { throw CloudKitError.invalidResponse } - return zone + return try result.get() } /// Delete a single zone from the target database. @@ -142,7 +157,9 @@ extension CloudKitService { /// - ownerRecordName: Optional owner record name. Pass `nil` for the /// caller's own zones (typical). /// - database: Target database. Must not be `.public`. - /// - Throws: `CloudKitError` if validation fails or the request fails. + /// - Throws: `CloudKitError` if validation fails or the request fails, or + /// ``CloudKitError/zoneOperationFailed(_:)`` when CloudKit rejects the + /// delete — for example `ZONE_NOT_FOUND`. /// /// # Example /// ```swift @@ -160,6 +177,9 @@ extension CloudKitService { ZoneID(zoneName: zoneName, ownerName: ownerRecordName) ) - _ = try await modifyZones([operation], database: database) + let results = try await modifyZones([operation], database: database) + for result in results { + _ = try result.get() + } } } diff --git a/Sources/MistKit/Models/Array+RecordResult.swift b/Sources/MistKit/Models/Array+RecordResult.swift new file mode 100644 index 00000000..03914334 --- /dev/null +++ b/Sources/MistKit/Models/Array+RecordResult.swift @@ -0,0 +1,54 @@ +// +// Array+RecordResult.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. +// + +extension Array where Element == RecordResult { + /// The records the operation succeeded on, dropping any per-record failures. + /// + /// Use the array itself when the failures matter. + public var records: [RecordInfo] { + compactMap { result in + guard case .success(let record) = result else { + return nil + } + return record + } + } + + /// The per-record failures, dropping the successes. + /// + /// Empty when every operation in the batch succeeded. + public var failures: [RecordOperationFailure] { + compactMap { result in + guard case .failure(let failure) = result else { + return nil + } + return failure + } + } +} diff --git a/Sources/MistKit/Models/Zones/Array+ZoneChangeResult.swift b/Sources/MistKit/Models/Zones/Array+ZoneChangeResult.swift new file mode 100644 index 00000000..0034583f --- /dev/null +++ b/Sources/MistKit/Models/Zones/Array+ZoneChangeResult.swift @@ -0,0 +1,56 @@ +// +// Array+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. +// + +extension Array where Element == ZoneChangeResult { + /// The zones the operation succeeded on, dropping any per-zone failures. + /// + /// Use the array itself when the failures matter — mirrors + /// ``DatabaseChangesResult/changedZones``. + public var zones: [ZoneInfo] { + compactMap { result in + guard case .success(let zone) = result else { + return nil + } + return zone + } + } + + /// The per-zone failures, dropping the successes. + /// + /// Empty when every zone in the batch succeeded — mirrors + /// ``DatabaseChangesResult/failures``. + public var failures: [ZoneOperationFailure] { + compactMap { result in + guard case .failure(let failure) = result else { + return nil + } + return failure + } + } +} diff --git a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift index c0132628..4acd0d76 100644 --- a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift +++ b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift @@ -29,11 +29,11 @@ internal import MistKitOpenAPI -/// The outcome for a single zone in a `changes/database` response. +/// The outcome for a single zone in a `changes/database` or `zones/modify` +/// 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. +/// Each entry in the response's `zones` array is either a 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 { @@ -48,4 +48,20 @@ extension OperationResult where Success == ZoneInfo, Target == ZoneTarget { self = .success(try ZoneInfo(fromZoneID: zone.zoneID)) } } + + /// Converts a per-zone entry from a `zones/modify` response. + /// + /// `zones/modify` returns the full Zone dictionary on success — unlike + /// `changes/database`, which returns only the `zoneID` — so the zone-level + /// `syncToken`/`atomic` metadata carries through. + internal init( + from item: Components.Schemas.ZonesModifyResponse.zonesPayloadPayload + ) throws(ConversionError) { + switch item { + case .ZoneFetchFailure(let failure): + self = .failure(try ZoneOperationFailure(from: failure)) + case .Zone(let zone): + self = .success(try ZoneInfo(from: zone)) + } + } } From 35b00fcb07864762ce7b147ae0dfd8c61f089a99 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 28 Aug 2026 13:51:13 -0400 Subject: [PATCH 3/7] Test per-zone modifyZones failures and the create/delete wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a raw-dictionary `ResponseConfig.modifyZonesResponse(zones:)` builder — so a single response can mix success entries and zone error entries — mirroring `databaseChangesResponse(zones:syncToken:moreComing:)`, and a `makeService(zones:)` harness over it. New tests: a mixed batch keeps the successes and reports the failure with its zone name/code/reason; `.get()` on a failed entry throws `.zoneOperationFailed`; zone metadata survives the new success variant; `createZone` surfaces the `ZoneOperationFailure` instead of `.invalidResponse`; and `deleteZone` throws on `ZONE_NOT_FOUND` rather than reporting success. Verified the failure tests fail when the service is reverted to dropping error entries. `ZoneMetadataTests` now matches on the `oneOf` success variant. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xs1c8vvxjCxqZiStcmuPS2 --- ...erviceTests.CreateZone+ErrorHandling.swift | 28 ++++ ...erviceTests.DeleteZone+ErrorHandling.swift | 65 +++++++++ ...rviceTests.ModifyZones+ErrorHandling.swift | 127 ++++++++++++++++++ ...dKitServiceTests.ModifyZones+Helpers.swift | 50 ++++--- ...erviceTests.ModifyZones+SuccessCases.swift | 24 ++-- .../Zones/ZoneMetadataTests+Responses.swift | 6 +- 6 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift create mode 100644 Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift diff --git a/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift index d1ecfa76..20018c0f 100644 --- a/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/CreateZone/CloudKitServiceTests.CreateZone+ErrorHandling.swift @@ -50,5 +50,33 @@ extension CloudKitServiceTests.CreateZone { ) } } + + @Test("createZone() surfaces the per-zone failure rather than .invalidResponse") + internal func createZoneSurfacesZoneOperationFailure() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.ModifyZones.makeService(zones: [ + [ + "zoneID": ["zoneName": "Articles", "ownerName": "_defaultOwner"], + "serverErrorCode": "EXISTS", + "reason": "Zone already exists", + ] + ]) + + do { + _ = try await service.createZone(zoneName: "Articles", database: .private) + 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 == "Articles") + #expect(failure.serverErrorCode == .exists) + #expect(failure.reason == "Zone already exists") + } + } } } diff --git a/Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift new file mode 100644 index 00000000..8e22b790 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/DeleteZone/CloudKitServiceTests.DeleteZone+ErrorHandling.swift @@ -0,0 +1,65 @@ +// +// CloudKitServiceTests.DeleteZone+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.DeleteZone { + @Suite("Error Handling") + internal struct ErrorHandling { + @Test("deleteZone() throws when CloudKit reports the zone was not found") + internal func deleteZoneReportsPerZoneFailure() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try CloudKitServiceTests.ModifyZones.makeService(zones: [ + [ + "zoneID": ["zoneName": "Missing", "ownerName": "_defaultOwner"], + "serverErrorCode": "ZONE_NOT_FOUND", + "reason": "Zone does not exist", + ] + ]) + + do { + try await service.deleteZone(zoneName: "Missing", database: .private) + 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 == "Missing") + #expect(failure.serverErrorCode == .zoneNotFound) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift new file mode 100644 index 00000000..03ab4e08 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift @@ -0,0 +1,127 @@ +// +// CloudKitServiceTests.ModifyZones+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.ModifyZones { + @Suite("Error Handling") + internal struct ErrorHandling { + private typealias Harness = CloudKitServiceTests.ModifyZones + + @Test("modifyZones() 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(zones: [ + ["zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"]], + [ + "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "serverErrorCode": "ZONE_NOT_FOUND", + "reason": "Zone does not exist", + ], + ]) + + let results = try await service.modifyZones( + [ + .create(ZoneID(zoneName: "good-zone", ownerName: nil)), + .delete(ZoneID(zoneName: "bad-zone", ownerName: nil)), + ], + database: .private + ) + + #expect(results.count == 2) + #expect(results.zones.map(\.zoneName) == ["good-zone"]) + + let failure = try #require(results.failures.first) + #expect(failure.zoneName == "bad-zone") + #expect(failure.serverErrorCode == .zoneNotFound) + #expect(failure.reason == "Zone does not exist") + } + + @Test("modifyZones() entry .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(zones: [ + [ + "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "serverErrorCode": "ZONE_NOT_FOUND", + ] + ]) + + let results = try await service.modifyZones( + [.delete(ZoneID(zoneName: "bad-zone", ownerName: nil))], + database: .private + ) + let entry = try #require(results.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") + } + } + + @Test("modifyZones() keeps zone metadata on successful entries") + internal func keepsZoneMetadata() 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(zones: [ + [ + "zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"], + "syncToken": "zone-token", + "atomic": true, + ] + ]) + + let results = try await service.modifyZones( + [.create(ZoneID(zoneName: "good-zone", ownerName: nil))], + database: .private + ) + + let zone = try #require(results.zones.first) + #expect(zone.syncToken == "zone-token") + #expect(zone.atomic == true) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift index 5329f9fd..b9b6b51c 100644 --- a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift @@ -39,9 +39,21 @@ extension CloudKitServiceTests.ModifyZones { internal static func makeSuccessfulService( zoneCount: Int = 1 ) async throws -> CloudKitService { - let responseProvider = try ResponseProvider.successfulModifyZones(zoneCount: zoneCount) - let transport = MockTransport(responseProvider: responseProvider) - return try CloudKitService( + try makeService(responseProvider: .successfulModifyZones(zoneCount: zoneCount)) + } + + /// Builds a service whose `zones/modify` response is the supplied raw zone + /// dictionaries, so tests can mix success and per-zone error entries. + internal static func makeService(zones: [[String: Any]]) throws -> CloudKitService { + try makeService( + responseProvider: ResponseProvider(defaultResponse: .modifyZonesResponse(zones: zones)) + ) + } + + internal static func makeService( + responseProvider: ResponseProvider + ) throws -> CloudKitService { + try CloudKitService( containerIdentifier: TestConstants.serviceContainerIdentifier, credentials: Credentials( apiAuth: APICredentials( @@ -49,7 +61,7 @@ extension CloudKitServiceTests.ModifyZones { webAuthToken: TestConstants.webAuthToken ) ), - transport: transport + transport: MockTransport(responseProvider: responseProvider) ) } } @@ -64,32 +76,28 @@ extension ResponseProvider { extension ResponseConfig { internal static func successfulModifyZonesResponse(zoneCount: Int = 1) throws -> ResponseConfig { - var zones: [[String: Any]] = [] - for index in 0.. ResponseConfig { var headers = HTTPFields() headers[.contentType] = "application/json" return ResponseConfig( statusCode: 200, headers: headers, - body: responseJSON.data(using: .utf8), + body: try JSONSerialization.data(withJSONObject: ["zones": zones]), error: nil ) } diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+SuccessCases.swift index eaae52bd..2cb86d9b 100644 --- a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+SuccessCases.swift @@ -43,13 +43,14 @@ extension CloudKitServiceTests.ModifyZones { } let service = try await CloudKitServiceTests.ModifyZones.makeSuccessfulService(zoneCount: 1) - let zones = try await service.modifyZones( + let results = try await service.modifyZones( [.create(ZoneID(zoneName: "Articles", ownerName: nil))], database: .private ) - #expect(zones.count == 1) - #expect(zones.first?.zoneName == "modified-zone-0") + #expect(results.count == 1) + #expect(results.failures.isEmpty) + #expect(results.zones.map(\.zoneName) == ["modified-zone-0"]) } @Test("modifyZones() returns zone for delete-only batch") @@ -60,12 +61,13 @@ extension CloudKitServiceTests.ModifyZones { } let service = try await CloudKitServiceTests.ModifyZones.makeSuccessfulService(zoneCount: 1) - let zones = try await service.modifyZones( + let results = try await service.modifyZones( [.delete(ZoneID(zoneName: "Archive", ownerName: nil))], database: .private ) - #expect(zones.count == 1) + #expect(results.count == 1) + #expect(results.failures.isEmpty) } @Test("modifyZones() returns zones for mixed create+delete batch") @@ -76,7 +78,7 @@ extension CloudKitServiceTests.ModifyZones { } let service = try await CloudKitServiceTests.ModifyZones.makeSuccessfulService(zoneCount: 2) - let zones = try await service.modifyZones( + let results = try await service.modifyZones( [ .create(ZoneID(zoneName: "NewZone", ownerName: nil)), .delete(ZoneID(zoneName: "OldZone", ownerName: nil)), @@ -84,9 +86,8 @@ extension CloudKitServiceTests.ModifyZones { database: .private ) - #expect(zones.count == 2) - #expect(zones[0].zoneName == "modified-zone-0") - #expect(zones[1].zoneName == "modified-zone-1") + #expect(results.count == 2) + #expect(results.zones.map(\.zoneName) == ["modified-zone-0", "modified-zone-1"]) } @Test("modifyZones() works against shared database") @@ -97,12 +98,13 @@ extension CloudKitServiceTests.ModifyZones { } let service = try await CloudKitServiceTests.ModifyZones.makeSuccessfulService(zoneCount: 1) - let zones = try await service.modifyZones( + let results = try await service.modifyZones( [.create(ZoneID(zoneName: "Shared", ownerName: "other-user"))], database: .shared ) - #expect(zones.count == 1) + #expect(results.count == 1) + #expect(results.failures.isEmpty) } } } diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift index 08f2be3c..ad7960cc 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift @@ -80,7 +80,11 @@ extension ZoneMetadataTests { ) ) - let zone = try #require(response.zones?.first) + let entry = try #require(response.zones?.first) + guard case .Zone(let zone) = entry else { + Issue.record("expected the success variant, got \(entry)") + return + } #expect(zone.syncToken == "modify-token") #expect(zone.atomic == false) } From 73f159299e5bd6ce5187c5774728b7ac4b1051cb Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 28 Aug 2026 13:51:29 -0400 Subject: [PATCH 4/7] MistDemo: adopt the [ZoneChangeResult] return from modifyZones - `modify-zones` announces per-zone rejections on stderr (matching how `modify` reports per-record failures) and keeps stdout to the zones that were actually modified, so the JSON/CSV/table output stays machine-parseable. `outputResults` requires `Encodable` and `OperationResult` is `Sendable`-only, so the results cannot be rendered directly. - `webModifyZones` collapses the results all-or-nothing via `.get()`, matching the documented decision in `webLookupRecords`, so the web panel shows a rejection instead of silently returning fewer zones than were asked for. Its `[ZoneInfo]` signature is unchanged, so `WebBackend`, the routes and the mock backend need no changes. - `ModifyZonesPhase` asserted nothing about the results; it now fails the integration run when a create or the cleanup delete is rejected. Split into `createAndVerify` to stay under the cyclomatic-complexity limit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xs1c8vvxjCxqZiStcmuPS2 --- .../Commands/ModifyZonesCommand.swift | 16 +++- .../Integration/Phases/ModifyZonesPhase.swift | 86 ++++++++++++------- .../Server/CloudKitService+WebBackend.swift | 7 +- 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyZonesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyZonesCommand.swift index 04dfb05e..7b8ebd6e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyZonesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyZonesCommand.swift @@ -74,6 +74,15 @@ public struct ModifyZonesCommand: MistDemoCommand, OutputFormatting { self.config = config } + private static func reportFailures(_ failures: [ZoneOperationFailure]) { + for failure in failures { + let code = failure.serverErrorCode.rawValue + let reasonFragment = failure.reason.map { ": \($0)" } ?? "" + let line = "Warning: zone '\(failure.zoneName)' failed (\(code))\(reasonFragment)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + } + /// Executes the command. public func execute() async throws { if case .public = config.base.database { @@ -96,6 +105,11 @@ public struct ModifyZonesCommand: MistDemoCommand, OutputFormatting { database: config.base.database ) - try await outputResults(results, format: config.output) + // `modifyZones` is a batch: CloudKit can reject individual zones while + // applying the rest. Announce the rejections on stderr — matching how + // `modify` reports per-record failures — and keep stdout to the zones + // that were actually modified so the output stays machine-parseable. + Self.reportFailures(results.failures) + try await outputResults(results.zones, format: config.output) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift index 3b77b42a..cca64fae 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift @@ -41,6 +41,20 @@ internal struct ModifyZonesPhase: IntegrationPhase { internal static let emoji = "🧱" internal static let apiName = "modifyZones" + private static func reportVerifiedZone(_ zone: ZoneInfo) { + print(" ✅ Verified zone via lookupZones") + if let syncToken = zone.syncToken { + print(" Sync Token: \(syncToken)") + } + if let atomic = zone.atomic { + print(" Atomic: \(atomic)") + } + } + + private static func describe(_ failure: ZoneOperationFailure) -> String { + "(\(failure.serverErrorCode.rawValue))" + (failure.reason.map { ": \($0)" } ?? "") + } + internal func run( input: NoState, context: PhaseContext @@ -52,34 +66,7 @@ internal struct ModifyZonesPhase: IntegrationPhase { let zoneID = ZoneID(zoneName: zoneName, ownerName: nil) do { - _ = try await context.service.modifyZones( - [.create(zoneID)], - database: context.database - ) - if context.verbose { - print(" ✅ Created zone: \(zoneName)") - } - - let lookedUp = try await context.service.lookupZones( - zoneIDs: [zoneID], - database: context.database - ) - guard let verifiedZone = lookedUp.first(where: { $0.zoneName == zoneName }) else { - try await cleanup(zoneID: zoneID, context: context) - throw IntegrationTestError.verificationFailed( - "created zone '\(zoneName)' not returned by lookupZones" - ) - } - if context.verbose { - print(" ✅ Verified zone via lookupZones") - if let syncToken = verifiedZone.syncToken { - print(" Sync Token: \(syncToken)") - } - if let atomic = verifiedZone.atomic { - print(" Atomic: \(atomic)") - } - } - + try await createAndVerify(zoneID: zoneID, zoneName: zoneName, context: context) try await cleanup(zoneID: zoneID, context: context) if context.verbose { print(" ✅ Deleted zone: \(zoneName)") @@ -97,13 +84,54 @@ internal struct ModifyZonesPhase: IntegrationPhase { return NoState() } + /// Creates the zone and confirms `lookupZones` reports it back. + /// + /// `modifyZones` returns a per-zone `[ZoneChangeResult]`, so a rejected + /// create is reported inline in a 200 response rather than thrown — check + /// `failures` explicitly instead of discarding the results. + private func createAndVerify( + zoneID: ZoneID, + zoneName: String, + context: PhaseContext + ) async throws { + let created = try await context.service.modifyZones( + [.create(zoneID)], + database: context.database + ) + if let failure = created.failures.first { + throw IntegrationTestError.verificationFailed( + "creating zone '\(zoneName)' failed \(Self.describe(failure))" + ) + } + if context.verbose { + print(" ✅ Created zone: \(zoneName)") + } + + let lookedUp = try await context.service.lookupZones( + zoneIDs: [zoneID], + database: context.database + ) + guard let verifiedZone = lookedUp.first(where: { $0.zoneName == zoneName }) else { + try await cleanup(zoneID: zoneID, context: context) + throw IntegrationTestError.verificationFailed( + "created zone '\(zoneName)' not returned by lookupZones" + ) + } + if context.verbose { + Self.reportVerifiedZone(verifiedZone) + } + } + private func cleanup( zoneID: ZoneID, context: PhaseContext ) async throws { - _ = try await context.service.modifyZones( + let results = try await context.service.modifyZones( [.delete(zoneID)], database: context.database ) + for result in results { + _ = try result.get() + } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift index ac7a1f7b..02b8ab3f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -108,7 +108,12 @@ extension CloudKitService: WebBackend { let operations = create.map { ZoneOperation.create(ZoneID(zoneName: $0)) } + delete.map { ZoneOperation.delete(ZoneID(zoneName: $0)) } - return try await modifyZones(operations, database: database) + let results = try await modifyZones(operations, database: database) + // All-or-nothing, matching `webLookupRecords`: `modifyZones` returns a + // per-zone `[ZoneChangeResult]`, but the demo collapses it so any single + // rejection (e.g. ZONE_NOT_FOUND on a delete) surfaces in the web panel + // instead of silently returning fewer zones than were asked for. + return try results.map { try $0.get() } } internal func webListSubscriptions( From 66f68906da28816ae831ca6db08f757a6a86d543 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 28 Aug 2026 13:51:29 -0400 Subject: [PATCH 5/7] Add CI guard for generated OpenAPI output; document modifyZones results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeFactor's bot committed to `Sources/MistKitOpenAPI/` in 61235b5, reordering imports in generated files, which broke `./Scripts/generate-openapi.sh` reproducibility until this branch regenerated them. Nothing checked that. `check-generated-openapi.yml` now regenerates and runs `git diff --exit-code Sources/MistKitOpenAPI/` on every PR. It builds the generator from `Scripts/OpenAPITools`, whose version is pinned in sync with mise.toml, so the check is self-contained. That fallback build leaves SwiftPM checkouts in `Scripts/OpenAPITools/.build`, which SwiftLint then walked (the bare `.build` exclude only matches the repo root one) — added as an explicit exclude. Docs: AGENTS.md's per-zone-failures paragraph now covers `zones/modify` and records the oneOf ordering rationale, the operations table names the new return type, and README points at `modifyZones` alongside `createZone`/`deleteZone`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xs1c8vvxjCxqZiStcmuPS2 --- .github/workflows/check-generated-openapi.yml | 49 +++++++++++++++++++ .swiftlint.yml | 5 ++ AGENTS.md | 7 ++- README.md | 3 ++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/check-generated-openapi.yml diff --git a/.github/workflows/check-generated-openapi.yml b/.github/workflows/check-generated-openapi.yml new file mode 100644 index 00000000..033745d6 --- /dev/null +++ b/.github/workflows/check-generated-openapi.yml @@ -0,0 +1,49 @@ +name: Check generated OpenAPI output + +on: + push: + branches: [ "main" ] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + regenerate-and-diff: + name: Regenerate Sources/MistKitOpenAPI and diff + runs-on: ubuntu-latest + container: + image: swift:latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + # Scripts/generate-openapi.sh prefers a `swift-openapi-generator` on + # PATH and otherwise builds the version pinned in + # Scripts/OpenAPITools/Package.swift, which is kept in sync with + # mise.toml. Neither mise nor the mise-provisioned binary is installed + # here, so the standalone manifest is what runs — deterministic and + # self-contained. + - name: Regenerate OpenAPI code + shell: bash + run: | + set -euo pipefail + git config --global --add safe.directory "$GITHUB_WORKSPACE" + ./Scripts/generate-openapi.sh + + # Guards against hand-edits and against automated formatters (CodeFactor + # has committed import reordering here before, despite + # Sources/MistKitOpenAPI/** being listed in .codefactor.yml's excludes). + - name: Fail if the committed output does not match + shell: bash + run: | + set -euo pipefail + if ! git diff --exit-code Sources/MistKitOpenAPI/; then + echo "" + echo "::error::Sources/MistKitOpenAPI/ does not match the output of ./Scripts/generate-openapi.sh." + echo "Never hand-edit generated code. Change openapi.yaml, run" + echo "./Scripts/generate-openapi.sh, and commit the regenerated files." + exit 1 + fi + echo "Generated OpenAPI output is reproducible." diff --git a/.swiftlint.yml b/.swiftlint.yml index 849f7194..13a9764f 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -119,6 +119,11 @@ identifier_name: excluded: - DerivedData - .build + # Scripts/generate-openapi.sh falls back to building the generator here when + # the mise-pinned binary is unavailable (CI, Claude Code web), leaving its + # SwiftPM checkouts on disk; the bare `.build` entry above only matches the + # repo-root one. + - Scripts/OpenAPITools/.build - Mint - Examples - Packages diff --git a/AGENTS.md b/AGENTS.md index 5c27a98b..cf326f5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,7 +193,7 @@ MistKit/ | `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+ModifyZones.swift` | `modifyZones(_:database:)` → `[ZoneChangeResult]`, `createZone(...)`, `deleteZone(...)` | | `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(_:)` | | `CloudKitService+LookupAllRecords.swift` | `lookupAllRecords(recordNames:desiredKeys:database:batchSize:)` — auto-chunking convenience over `lookupRecords` | @@ -255,12 +255,15 @@ In MistDemo, integration runs targeting these endpoints use `PhaseContext.userCo - `RecordChangesResult` — `records: [RecordInfo]`, `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?` +- `ZoneChangeResult` — `OperationResult`; the element type of `modifyZones` and of `DatabaseChangesResult.zones` - `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`. +**Per-zone failures (RecordResult pattern):** `changes/database`, `changes/zone` and `zones/modify` return an entry per zone that is *either* a success payload or a zone fetch error, modeled in `openapi.yaml` as `oneOf: [ZoneFetchFailure, ]` (**failure variant first** — every `oneOf` in the spec lists it first, and `ZoneFetchFailure` requires `serverErrorCode`, so a success payload falls through to the success variant). 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`. + +**`modifyZones` (issue #431):** returns a bare `[ZoneChangeResult]` — one entry per zone the server returned, in response order — mirroring how `modifyRecords` returns a bare `[RecordResult]`. `zones/modify` carries no batch-level metadata, so there is deliberately **no** `DatabaseChangesResult`-style wrapper struct. Split the array with the `Array` conveniences `.zones` / `.failures` (`Sources/MistKit/Models/Zones/Array+ZoneChangeResult.swift`; `[RecordResult]` has the parallel `.records` / `.failures` in `Sources/MistKit/Models/Array+RecordResult.swift`). These are concrete `where Element == …` extensions rather than one generic extension over `OperationResult`, because Swift cannot bind free generic parameters in an extension's `where` clause. The single-zone conveniences `createZone` / `deleteZone` call `.get()` on the entry, so a rejected create or a `ZONE_NOT_FOUND` delete throws `CloudKitError.zoneOperationFailed` carrying the zone name, `serverErrorCode` and `reason` — `createZone` no longer collapses that into a bare `.invalidResponse`, and `deleteZone` no longer reports a failed delete as success. - `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/README.md b/README.md index 918ae128..e7dd2bc2 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,9 @@ let zone = try await service.createZone( database: .private ) try await service.deleteZone(zoneName: "Notes", database: .private) +// Batch create/delete via service.modifyZones(_:database:) +// (takes [ZoneOperation], returns [ZoneChangeResult] — inspect +// `.zones` and `.failures` for per-zone outcomes). // Subscriptions let subs = try await service.listSubscriptions(database: .private) From b2563d83f6b442f9a290a4fea583185d6444a265 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 28 Aug 2026 14:10:38 -0400 Subject: [PATCH 6/7] Rename the zones/changes wire token to metaSyncToken (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live container run (iCloud.com.brightdigit.MistDemo / development / private, web-auth) proved `zones/changes` neither returns nor honors `syncToken`: - The response's top-level keys are exactly `[moreComing, metaSyncToken, zones]` — no `syncToken` at all. - Round-tripping the same baseline token: sending `{"syncToken": …}` (what MistKit sent) returned all 40 zones again — the key is silently ignored and page one replays. Sending `{"metaSyncToken": …}` returned 0 zones — honored and correctly advanced. So `fetchZoneChanges` / `fetchAllZoneChanges` pagination has never worked. This supersedes the description-only wording fix in the previous commit, which assumed the mismatch was documentation rather than behavior. Renames the wire key for `zones/changes` **only** — the request body property and `ZoneChangesResponse` — and regenerates. `changes/database`, `changes/zone` and `records/changes` legitimately use `syncToken` and are untouched. Every Swift-facing name is deliberately unchanged: `ZoneChangesResult.syncToken` and its `init(syncToken:)` label, and the `fetchZoneChanges(syncToken:)` / `fetchAllZoneChanges(syncToken:)` argument labels. `MistKitOpenAPI` is an `internal import`, so a wire-key rename is not source-breaking for consumers; only the mapping in `ZoneChangesResult.init(from:)` and the request construction in `CloudKitService+ZoneOperations.swift` change. Adds `CloudKitServiceTests.FetchZoneChanges+WireFormat.swift`, which pins that MistKit sends `metaSyncToken` and never `syncToken`, reads `metaSyncToken` in preference to a decoy `syncToken`, and feeds the previous page's token back under the honored key. Existing `zones/changes` fixtures were emitting the wrong key and are updated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xs1c8vvxjCxqZiStcmuPS2 --- AGENTS.md | 7 +- .../CloudKitService+ZoneOperations.swift | 10 +- .../Models/Zones/ZoneChangesResult.swift | 8 +- Sources/MistKitOpenAPI/Types.swift | 38 +++-- ...erviceTests.FetchZoneChanges+Helpers.swift | 18 ++- ...iceTests.FetchZoneChanges+WireFormat.swift | 149 ++++++++++++++++++ .../Zones/ZoneMetadataTests+Responses.swift | 2 +- openapi.yaml | 33 ++-- 8 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift diff --git a/AGENTS.md b/AGENTS.md index cf326f5a..78c942cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,6 +217,8 @@ MistKit/ - `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`. **Deprecated** (`@available(*, deprecated)`): Apple deprecated `zones/changes` in favor of `changes/database`. Same for `fetchAllZoneChanges`. + + **Wire key is `metaSyncToken`, not `syncToken` (issue #430).** Verified against a live container (`iCloud.com.brightdigit.MistDemo`/`development`/private, web-auth): the response's top-level keys are exactly `[moreComing, metaSyncToken, zones]`, and a request sending `syncToken` is *silently ignored* — CloudKit replays page one instead of advancing, so `fetchZoneChanges`/`fetchAllZoneChanges` pagination never actually worked. Only `zones/changes` is affected; `changes/database`, `changes/zone` and `records/changes` all genuinely use `syncToken` — **do not rename those**. The rename is confined to `openapi.yaml`; every Swift-facing name (`ZoneChangesResult.syncToken`, its `init(syncToken:)` label, the `fetchZoneChanges(syncToken:)`/`fetchAllZoneChanges(syncToken:)` argument labels) is deliberately unchanged, so this is not source-breaking — `MistKitOpenAPI` is an `internal import`. `Tests/.../FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift` pins the sent and read keys. - `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]` @@ -253,7 +255,7 @@ 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` *(deprecated `zones/changes`)* +- `ZoneChangesResult` — `zones: [ZoneInfo]`, `syncToken: String?`, `moreComing: Bool` *(deprecated `zones/changes`; `syncToken` rides the wire as `metaSyncToken` — see #430 above)* - `ZoneInfo` — `zoneName: String`, `ownerRecordName: String?`, `capabilities: [String]`, `syncToken: String?`, `atomic: Bool?` - `ZoneChangeResult` — `OperationResult`; the element type of `modifyZones` and of `DatabaseChangesResult.zones` - `DatabaseChangesResult` — `zones: [ZoneChangeResult]`, `syncToken: String?`, `moreComing: Bool`, plus `changedZones`/`failures` conveniences @@ -274,7 +276,8 @@ three keys Apple's archived ["Zone Dictionary"](https://developer.apple.com/libr documents: `zoneID`, `syncToken`, and `atomic`. These surface on `ZoneInfo` as `syncToken`/`atomic`, both optional — `atomic` is **not** defaulted to `false`, so an absent key stays distinguishable from an explicit `false`. Note the zone-level -`syncToken` is distinct from the response-level `syncToken` on `ZoneChangesResult`. +`syncToken` is distinct from the response-level token on `ZoneChangesResult` (which +is `metaSyncToken` on the wire). `isEager` is **deliberately not modeled**: it appears in no primary Apple source (neither the archived Web Services reference nor `.claude/docs/cloudkitjs.md`). diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift index 7ea158f8..548a12f8 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift @@ -129,8 +129,14 @@ extension CloudKitService { /// > returns the same "which zones changed" information, plus per-zone /// > failures and a `resultsLimit` knob. /// + /// The wire key for this operation's token is `metaSyncToken`, not + /// `syncToken` — verified against a live container (issue #430); a request + /// sending `syncToken` is silently ignored and replays the first page. The + /// Swift-facing name stays `syncToken` to match the rest of the API. + /// /// - Parameters: - /// - syncToken: Optional token from previous fetch (nil = initial fetch) + /// - syncToken: Optional token from previous fetch (nil = initial fetch). + /// Sent on the wire as `metaSyncToken`. /// - 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 @@ -156,7 +162,7 @@ extension CloudKitService { ), body: .json( .init( - syncToken: syncToken + metaSyncToken: syncToken ) ) ) diff --git a/Sources/MistKit/Models/Zones/ZoneChangesResult.swift b/Sources/MistKit/Models/Zones/ZoneChangesResult.swift index bf8e118e..870c991f 100644 --- a/Sources/MistKit/Models/Zones/ZoneChangesResult.swift +++ b/Sources/MistKit/Models/Zones/ZoneChangesResult.swift @@ -36,7 +36,11 @@ internal import MistKitOpenAPI public struct ZoneChangesResult: Codable, Sendable { /// Zones that have changed public let zones: [ZoneInfo] - /// Token to use for next fetch to get incremental changes + /// Token to use for next fetch to get incremental changes. + /// + /// Carried on the wire as `metaSyncToken` — `zones/changes` is the one + /// change-tracking operation that does not name its token `syncToken` + /// (issue #430). The Swift name is unchanged. public let syncToken: String? /// Whether more changes are available (for large zone change sets) public let moreComing: Bool @@ -58,7 +62,7 @@ public struct ZoneChangesResult: Codable, Sendable { zones.append(try ZoneInfo(from: zone)) } self.zones = zones - self.syncToken = response.syncToken + self.syncToken = response.metaSyncToken self.moreComing = response.moreComing ?? false } } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 97efb540..d18e7bf7 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -2381,32 +2381,42 @@ public enum Components { case zones } } + /// Response body of the deprecated `zones/changes` operation. Its token + /// key is `metaSyncToken`: a live container returned exactly + /// `[moreComing, metaSyncToken, zones]` at the top level, with no + /// `syncToken` (issue #430). The other change-tracking operations + /// (`changes/database`, `changes/zone`, `records/changes`) use + /// `syncToken`. + /// + /// /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse`. public struct ZoneChangesResponse: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zones`. public var zones: [Components.Schemas.Zone]? - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/syncToken`. - public var syncToken: Swift.String? + /// Identifies a point in the database's change history. Send it back as `metaSyncToken` on the next request to fetch only newer changes. + /// + /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/metaSyncToken`. + public var metaSyncToken: Swift.String? /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/moreComing`. public var moreComing: Swift.Bool? /// Creates a new `ZoneChangesResponse`. /// /// - Parameters: /// - zones: - /// - syncToken: + /// - metaSyncToken: Identifies a point in the database's change history. Send it back as `metaSyncToken` on the next request to fetch only newer changes. /// - moreComing: public init( zones: [Components.Schemas.Zone]? = nil, - syncToken: Swift.String? = nil, + metaSyncToken: Swift.String? = nil, moreComing: Swift.Bool? = nil ) { self.zones = zones - self.syncToken = syncToken + self.metaSyncToken = metaSyncToken self.moreComing = moreComing } public enum CodingKeys: String, CodingKey { case zones - case syncToken + case metaSyncToken case moreComing } } @@ -8798,19 +8808,21 @@ public enum Operations { @frozen public enum Body: Sendable, Hashable { /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json`. public struct jsonPayload: Codable, Hashable, Sendable { - /// Sync token returned by a previous `zones/changes` call. Omit it to fetch every zone. Apple's archived reference names this key `metaSyncToken`; MistKit sends `syncToken`, which is also the name Apple's own `moreComing` prose uses. The key is deliberately left as `syncToken` (see issue #430) — only this description is corrected, so the spec no longer describes the field by a name it does not use. + /// The `metaSyncToken` returned by a previous `zones/changes` response. Omit it to fetch every zone. + /// Verified against a live container (issue #430): this operation reads and returns `metaSyncToken`, not `syncToken`. A request sending `syncToken` is silently ignored and replays the first page. Apple's archived reference names the key `metaSyncToken` as well; only one line of its `moreComing` prose calls it `syncToken`. + /// This is specific to `zones/changes` — `changes/database`, `changes/zone` and `records/changes` all use `syncToken`. /// - /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json/syncToken`. - public var syncToken: Swift.String? + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/json/metaSyncToken`. + public var metaSyncToken: Swift.String? /// Creates a new `jsonPayload`. /// /// - Parameters: - /// - syncToken: Sync token returned by a previous `zones/changes` call. Omit it to fetch every zone. Apple's archived reference names this key `metaSyncToken`; MistKit sends `syncToken`, which is also the name Apple's own `moreComing` prose uses. The key is deliberately left as `syncToken` (see issue #430) — only this description is corrected, so the spec no longer describes the field by a name it does not use. - public init(syncToken: Swift.String? = nil) { - self.syncToken = syncToken + /// - metaSyncToken: The `metaSyncToken` returned by a previous `zones/changes` response. Omit it to fetch every zone. + public init(metaSyncToken: Swift.String? = nil) { + self.metaSyncToken = metaSyncToken } public enum CodingKeys: String, CodingKey { - case syncToken + case metaSyncToken } } /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/zones/changes/POST/requestBody/content/application\/json`. diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift index 1ef39252..cab4cd15 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift @@ -106,7 +106,7 @@ extension ResponseConfig { let responseJSON = """ { "zones": \(zonesString), - "syncToken": "\(syncToken)", + "metaSyncToken": "\(syncToken)", "moreComing": \(moreComing) } """ @@ -122,6 +122,20 @@ extension ResponseConfig { ) } + /// Builds a `zones/changes` response from a literal JSON body, so tests can + /// pin exactly which token key the decoder reads. + internal static func zoneChangesRawResponse(body: String) -> ResponseConfig { + var headers = HTTPFields() + headers[.contentType] = "application/json" + + return ResponseConfig( + statusCode: 200, + headers: headers, + body: Data(body.utf8), + error: nil + ) + } + internal static func zoneChangesResponseWithNilZoneID() -> ResponseConfig { let responseJSON = """ { @@ -134,7 +148,7 @@ extension ResponseConfig { }, {} ], - "syncToken": "token-with-nil-zone" + "metaSyncToken": "token-with-nil-zone" } """ diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift new file mode 100644 index 00000000..8d62d86a --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift @@ -0,0 +1,149 @@ +// +// CloudKitServiceTests.FetchZoneChanges+WireFormat.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.FetchZoneChanges { + /// Pins the on-the-wire token key for `zones/changes`. + /// + /// Verified against a live container (issue #430): the response's top-level + /// keys are exactly `[moreComing, metaSyncToken, zones]`, and a request + /// sending `syncToken` is silently ignored — CloudKit replays the first page + /// instead of advancing. `zones/changes` is the only change-tracking + /// operation that names its token `metaSyncToken`; `changes/database`, + /// `changes/zone` and `records/changes` all use `syncToken`. + /// + /// The Swift-facing names (`ZoneChangesResult.syncToken`, the + /// `fetchZoneChanges(syncToken:)` label) are deliberately unchanged. + @Suite("Wire Format") + internal struct WireFormat { + private static let operationID = "fetchZoneChanges" + + private 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) + ) + } + + private static func sentBodies(_ provider: ResponseProvider) async throws -> [[String: Any]] { + let bodies = await provider.bodies(for: operationID).compactMap { $0 } + return try bodies.map { data in + try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + } + + @Test("fetchZoneChanges() sends the token as metaSyncToken, never as syncToken") + internal func sendsMetaSyncToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = try ResponseProvider.successfulFetchZoneChanges(zoneCount: 1) + let service = try Self.makeService(provider: provider) + + _ = try await service.fetchZoneChanges(syncToken: "baseline-token", database: .private) + + let sent = try await Self.sentBodies(provider) + #expect(sent.count == 1) + let body = try #require(sent.first) + #expect(body["metaSyncToken"] as? String == "baseline-token") + #expect(body["syncToken"] == nil) + } + + @Test("fetchZoneChanges() reads metaSyncToken and ignores a stray syncToken") + internal func readsMetaSyncToken() 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 + } + // The decoy `syncToken` is what MistKit used to read; the live container + // never sends it. + let provider = ResponseProvider( + defaultResponse: try .zoneChangesRawResponse( + body: """ + { + "zones": [], + "syncToken": "decoy-token", + "metaSyncToken": "real-token", + "moreComing": false + } + """ + ) + ) + let service = try Self.makeService(provider: provider) + + let result = try await service.fetchZoneChanges(database: .private) + + #expect(result.syncToken == "real-token") + } + + @Test("fetchAllZoneChanges() feeds the previous page's metaSyncToken back") + internal func paginationRoundTripsMetaSyncToken() async throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = try ResponseProvider.successfulFetchZoneChanges( + zoneCount: 0, + moreComing: false, + syncToken: "page-2-token" + ) + await provider.enqueue( + try .successfulFetchZoneChangesResponse( + zoneCount: 1, + moreComing: true, + syncToken: "page-1-token" + ), + for: Self.operationID + ) + let service = try Self.makeService(provider: provider) + + _ = try await service.fetchAllZoneChanges(database: .private) + + let sent = try await Self.sentBodies(provider) + #expect(sent.count == 2) + // Page one is the initial fetch; page two must carry page one's token + // under the key CloudKit actually honors, or pagination silently + // replays page one forever (issue #430). + #expect(sent.last?["metaSyncToken"] as? String == "page-1-token") + } + } +} diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift index ad7960cc..851abb84 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift @@ -103,7 +103,7 @@ extension ZoneMetadataTests { "atomic": true } ], - "syncToken": "top-level-token", + "metaSyncToken": "top-level-token", "moreComing": true } """.utf8 diff --git a/openapi.yaml b/openapi.yaml index 45f861ef..856bce20 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -575,16 +575,21 @@ paths: schema: type: object properties: - syncToken: + metaSyncToken: type: string description: >- - Sync token returned by a previous `zones/changes` call. - Omit it to fetch every zone. Apple's archived reference - names this key `metaSyncToken`; MistKit sends `syncToken`, - which is also the name Apple's own `moreComing` prose uses. - The key is deliberately left as `syncToken` (see issue - #430) — only this description is corrected, so the spec no - longer describes the field by a name it does not use. + The `metaSyncToken` returned by a previous `zones/changes` + response. Omit it to fetch every zone. + + Verified against a live container (issue #430): this + operation reads and returns `metaSyncToken`, not + `syncToken`. A request sending `syncToken` is silently + ignored and replays the first page. Apple's archived + reference names the key `metaSyncToken` as well; only one + line of its `moreComing` prose calls it `syncToken`. + + This is specific to `zones/changes` — `changes/database`, + `changes/zone` and `records/changes` all use `syncToken`. responses: '200': description: Zone changes retrieved successfully @@ -1895,13 +1900,23 @@ components: ZoneChangesResponse: type: object + description: | + Response body of the deprecated `zones/changes` operation. Its token + key is `metaSyncToken`: a live container returned exactly + `[moreComing, metaSyncToken, zones]` at the top level, with no + `syncToken` (issue #430). The other change-tracking operations + (`changes/database`, `changes/zone`, `records/changes`) use + `syncToken`. properties: zones: type: array items: $ref: '#/components/schemas/Zone' - syncToken: + metaSyncToken: type: string + description: >- + Identifies a point in the database's change history. Send it back + as `metaSyncToken` on the next request to fetch only newer changes. moreComing: type: boolean From a2055afeef13c11d16205f8528b0f8bbecbcaa37 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 28 Aug 2026 16:19:26 -0400 Subject: [PATCH 7/7] Add tests for [RecordResult].records and .failures accessors Covers the retro-fit Array+RecordResult helpers so codecov patch coverage meets the project threshold on #443. Co-authored-by: Cursor --- Tests/MistKitTests/Models/BatchSyncResultTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Tests/MistKitTests/Models/BatchSyncResultTests.swift b/Tests/MistKitTests/Models/BatchSyncResultTests.swift index 912acfd2..7ff6b4e1 100644 --- a/Tests/MistKitTests/Models/BatchSyncResultTests.swift +++ b/Tests/MistKitTests/Models/BatchSyncResultTests.swift @@ -160,5 +160,16 @@ internal struct BatchSyncResultTests { #expect(result.unclassifiedCount == 0) #expect(result.totalCount == 4) #expect(result.succeededCount == 3) + } + + @Test("[RecordResult].records and .failures partition mixed batches") + internal func recordResultArrayAccessors() { + let results: [RecordResult] = [ + Self.makeSuccess(name: "good"), + Self.makeFailure(name: "bad"), + ] + + #expect(results.records.map(\.recordName) == ["good"]) + #expect(results.failures.map(\.identifier) == ["bad"]) } }