From bdd853297299da27cacddc25b84fbed0565ab7e2 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 14:24:10 -0400 Subject: [PATCH 1/3] Add confirmed zone metadata (syncToken, atomic) to zone schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four zone responses (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`) modeled a zone as just `{ zoneID }`. Introduce a shared `Zone` schema in `openapi.yaml` carrying the metadata Apple's archived "Zone Dictionary" documents, and surface it on the domain `ZoneInfo`. Only fields confirmed against a primary source are encoded: - `syncToken` — "The current point in the zone's change history." - `atomic` — "A Boolean value indicating whether this zone supports atomic operations." Both verified against Apple's archived CloudKit Web Services Reference "Zone Dictionary" (Types.html), which documents exactly three keys: `zoneID`, `syncToken`, `atomic`. Deliberately NOT implemented, because no primary source confirms them: - `isEager` — appears in neither the archived reference nor the local CloudKit JS docs. - `atomic` on the `zones/modify` request — Apple documents the request body as `operations` only. - zone create options on `ZoneOperation` — Apple documents the operation's `zone` as having "a single `zoneID` key". `ZoneInfo.atomic` is `Bool?` rather than defaulting to `false` so an absent key stays distinguishable from an explicit `false`. Both new properties are added with defaulted initializer parameters, keeping the existing public initializer source-compatible. Refs #386 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 16 ++ .../CloudKitService+ModifyZones.swift | 2 +- .../CloudKitService+ZoneOperations.swift | 4 +- .../Models/Zones/ZoneChangesResult.swift | 2 +- Sources/MistKit/Models/Zones/ZoneInfo.swift | 40 ++++- Sources/MistKitOpenAPI/Types.swift | 121 +++++-------- .../Zones/ZoneMetadataTests+Responses.swift | 164 ++++++++++++++++++ ...ZoneMetadataTests+ZoneInfoConversion.swift | 130 ++++++++++++++ .../Models/Zones/ZoneMetadataTests.swift | 38 ++++ openapi.yaml | 41 +++-- 10 files changed, 459 insertions(+), 99 deletions(-) create mode 100644 Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift create mode 100644 Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift create mode 100644 Tests/MistKitTests/Models/Zones/ZoneMetadataTests.swift diff --git a/AGENTS.md b/AGENTS.md index 94c7dc78..e3b61835 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,10 +232,26 @@ In MistDemo, integration runs targeting these endpoints use `PhaseContext.userCo - `QueryResult` — `records: [RecordInfo]`, `continuationMarker: String?` - `RecordChangesResult` — `records: [RecordInfo]`, `syncToken: String?`, `moreComing: Bool` - `ZoneChangesResult` — `zones: [ZoneInfo]`, `syncToken: String?`, `moreComing: Bool` +- `ZoneInfo` — `zoneName: String`, `ownerRecordName: String?`, `capabilities: [String]`, `syncToken: String?`, `atomic: Bool?` - `UserIdentity` — `userRecordName: String?`, `nameComponents: NameComponents?`, `lookupInfo: UserIdentityLookupInfo?` - `UserIdentityLookupInfo` — `emailAddress: String?`, `phoneNumber: String?`, `userRecordName: String?` - `NameComponents` — full personal name parts (givenName, familyName, nickname, etc.) +**Zone metadata (issue #386):** all four zone responses (`zones/list`, `zones/lookup`, +`zones/modify`, `zones/changes`) share one `Zone` schema in `openapi.yaml` carrying the +three keys Apple's archived ["Zone Dictionary"](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html) +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`. + +`isEager` is **deliberately not modeled**: it appears in no primary Apple source +(neither the archived Web Services reference nor `.claude/docs/cloudkitjs.md`). +Likewise `zones/modify` takes **no** `atomic` request flag and `ZoneOperation` has +**no** create options — Apple documents the request body as `operations` only, and each +operation's `zone` as having "a single `zoneID` key". Do not add these speculatively; +confirm against a live response first. + **Protocols:** - `RecordTypeIterating` (`Sources/MistKit/RecordManagement/RecordTypeIterating.swift`) — `forEach(_ action:)` to iterate over CloudKit record types; used by `fetchAllRecordChanges` diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift b/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift index d42fb743..232b73ef 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ModifyZones.swift @@ -87,7 +87,7 @@ extension CloudKitService { let zonesData: Components.Schemas.ZonesModifyResponse = try await responseProcessor.processModifyZonesResponse(response) - return try (zonesData.zones ?? []).map { try ZoneInfo(fromZoneID: $0.zoneID) } + return try (zonesData.zones ?? []).map { try ZoneInfo(from: $0) } } catch { throw mapToCloudKitError(error, context: "modifyZones") } diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift index 9cd10449..4a9afd6f 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ZoneOperations.swift @@ -62,7 +62,7 @@ extension CloudKitService { let zonesData: Components.Schemas.ZonesListResponse = try await responseProcessor.processListZonesResponse(response) - return try (zonesData.zones ?? []).map { try ZoneInfo(fromZoneID: $0.zoneID) } + return try (zonesData.zones ?? []).map { try ZoneInfo(from: $0) } } catch { throw mapToCloudKitError(error, context: "listZones") } @@ -113,7 +113,7 @@ extension CloudKitService { let zonesData: Components.Schemas.ZonesLookupResponse = try await responseProcessor.processLookupZonesResponse(response) - return try (zonesData.zones ?? []).map { try ZoneInfo(fromZoneID: $0.zoneID) } + return try (zonesData.zones ?? []).map { try ZoneInfo(from: $0) } } catch { throw mapToCloudKitError(error, context: "lookupZones") } diff --git a/Sources/MistKit/Models/Zones/ZoneChangesResult.swift b/Sources/MistKit/Models/Zones/ZoneChangesResult.swift index a4cc6a06..bf8e118e 100644 --- a/Sources/MistKit/Models/Zones/ZoneChangesResult.swift +++ b/Sources/MistKit/Models/Zones/ZoneChangesResult.swift @@ -55,7 +55,7 @@ public struct ZoneChangesResult: Codable, Sendable { internal init(from response: Components.Schemas.ZoneChangesResponse) throws(ConversionError) { var zones: [ZoneInfo] = [] for zone in response.zones ?? [] { - zones.append(try ZoneInfo(fromZoneID: zone.zoneID)) + zones.append(try ZoneInfo(from: zone)) } self.zones = zones self.syncToken = response.syncToken diff --git a/Sources/MistKit/Models/Zones/ZoneInfo.swift b/Sources/MistKit/Models/Zones/ZoneInfo.swift index fe0b2c07..18934413 100644 --- a/Sources/MistKit/Models/Zones/ZoneInfo.swift +++ b/Sources/MistKit/Models/Zones/ZoneInfo.swift @@ -39,12 +39,30 @@ public struct ZoneInfo: Codable, Sendable { /// Note: always empty — CloudKit Web Services zone responses do not include /// capabilities in the current OpenAPI schema. public let capabilities: [String] + /// The current point in the zone's change history. + /// + /// Present on zone responses that carry Apple's "Zone Dictionary" payload; + /// `nil` when the server omits it. + public let syncToken: String? + /// Whether this zone supports atomic operations. + /// + /// `nil` when the server omits the key — deliberately *not* defaulted to + /// `false`, so "absent" stays distinguishable from "explicitly not atomic". + public let atomic: Bool? /// Initialize zone information - public init(zoneName: String, ownerRecordName: String?, capabilities: [String]) { + public init( + zoneName: String, + ownerRecordName: String?, + capabilities: [String], + syncToken: String? = nil, + atomic: Bool? = nil + ) { self.zoneName = zoneName self.ownerRecordName = ownerRecordName self.capabilities = capabilities + self.syncToken = syncToken + self.atomic = atomic } /// Convert a CloudKit zone payload's `zoneID` into a `ZoneInfo`. @@ -60,7 +78,11 @@ public struct ZoneInfo: Codable, Sendable { /// and make the generated decoder reject otherwise-valid payloads, so the /// response-side "must be present" rule is enforced here at the domain /// boundary instead. - internal init(fromZoneID zoneID: Components.Schemas.ZoneID?) throws(ConversionError) { + internal init( + fromZoneID zoneID: Components.Schemas.ZoneID?, + syncToken: String? = nil, + atomic: Bool? = nil + ) throws(ConversionError) { guard let zoneID else { try ConversionError.zoneMissingID.reportAndThrow() } @@ -70,7 +92,19 @@ public struct ZoneInfo: Codable, Sendable { self.init( zoneName: zoneName, ownerRecordName: zoneID.ownerName, - capabilities: [] + capabilities: [], + syncToken: syncToken, + atomic: atomic + ) + } + + /// Convert a CloudKit `Zone` payload into a `ZoneInfo`, carrying the + /// zone-level metadata (`syncToken`, `atomic`) alongside the identity. + internal init(from zone: Components.Schemas.Zone) throws(ConversionError) { + try self.init( + fromZoneID: zone.zoneID, + syncToken: zone.syncToken, + atomic: zone.atomic ) } } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 37a73e13..9e3c97f5 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -1959,32 +1959,52 @@ public enum Components { case moreComing } } + /// A record zone as returned by the zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`). Matches the "Zone Dictionary" in Apple's archived CloudKit Web Services Reference, which documents exactly three keys: `zoneID`, `syncToken`, and `atomic`. `isEager` is deliberately absent — it appears in no primary Apple source (see issue #386). + /// + /// + /// - Remark: Generated from `#/components/schemas/Zone`. + public struct Zone: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/Zone/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// The current point in the zone's change history. + /// + /// - Remark: Generated from `#/components/schemas/Zone/syncToken`. + public var syncToken: Swift.String? + /// A Boolean value indicating whether this zone supports atomic operations. + /// + /// + /// - Remark: Generated from `#/components/schemas/Zone/atomic`. + public var atomic: Swift.Bool? + /// Creates a new `Zone`. + /// + /// - Parameters: + /// - zoneID: + /// - syncToken: The current point in the zone's change history. + /// - atomic: A Boolean value indicating whether this zone supports atomic operations. + public init( + zoneID: Components.Schemas.ZoneID? = nil, + syncToken: Swift.String? = nil, + atomic: Swift.Bool? = nil + ) { + self.zoneID = zoneID + self.syncToken = syncToken + self.atomic = atomic + } + public enum CodingKeys: String, CodingKey { + case zoneID + case syncToken + case atomic + } + } /// - Remark: Generated from `#/components/schemas/ZonesListResponse`. public struct ZonesListResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zonesPayload`. - public struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zonesPayload/zoneID`. - public var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - public init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - public enum CodingKeys: String, CodingKey { - case zoneID - } - } /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zones`. - public typealias zonesPayload = [Components.Schemas.ZonesListResponse.zonesPayloadPayload] - /// - Remark: Generated from `#/components/schemas/ZonesListResponse/zones`. - public var zones: Components.Schemas.ZonesListResponse.zonesPayload? + public var zones: [Components.Schemas.Zone]? /// Creates a new `ZonesListResponse`. /// /// - Parameters: /// - zones: - public init(zones: Components.Schemas.ZonesListResponse.zonesPayload? = nil) { + public init(zones: [Components.Schemas.Zone]? = nil) { self.zones = zones } public enum CodingKeys: String, CodingKey { @@ -1993,30 +2013,13 @@ public enum Components { } /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse`. public struct ZonesLookupResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zonesPayload`. - public struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zonesPayload/zoneID`. - public var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - public init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - public enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zones`. - public typealias zonesPayload = [Components.Schemas.ZonesLookupResponse.zonesPayloadPayload] /// - Remark: Generated from `#/components/schemas/ZonesLookupResponse/zones`. - public var zones: Components.Schemas.ZonesLookupResponse.zonesPayload? + public var zones: [Components.Schemas.Zone]? /// Creates a new `ZonesLookupResponse`. /// /// - Parameters: /// - zones: - public init(zones: Components.Schemas.ZonesLookupResponse.zonesPayload? = nil) { + public init(zones: [Components.Schemas.Zone]? = nil) { self.zones = zones } public enum CodingKeys: String, CodingKey { @@ -2025,30 +2028,13 @@ public enum Components { } /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse`. public struct ZonesModifyResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload`. - public struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zonesPayload/zoneID`. - public var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - public init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - public enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. - public typealias zonesPayload = [Components.Schemas.ZonesModifyResponse.zonesPayloadPayload] /// - Remark: Generated from `#/components/schemas/ZonesModifyResponse/zones`. - public var zones: Components.Schemas.ZonesModifyResponse.zonesPayload? + public var zones: [Components.Schemas.Zone]? /// Creates a new `ZonesModifyResponse`. /// /// - Parameters: /// - zones: - public init(zones: Components.Schemas.ZonesModifyResponse.zonesPayload? = nil) { + public init(zones: [Components.Schemas.Zone]? = nil) { self.zones = zones } public enum CodingKeys: String, CodingKey { @@ -2057,25 +2043,8 @@ public enum Components { } /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse`. public struct ZoneChangesResponse: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zonesPayload`. - public struct zonesPayloadPayload: Codable, Hashable, Sendable { - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zonesPayload/zoneID`. - public var zoneID: Components.Schemas.ZoneID? - /// Creates a new `zonesPayloadPayload`. - /// - /// - Parameters: - /// - zoneID: - public init(zoneID: Components.Schemas.ZoneID? = nil) { - self.zoneID = zoneID - } - public enum CodingKeys: String, CodingKey { - case zoneID - } - } - /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zones`. - public typealias zonesPayload = [Components.Schemas.ZoneChangesResponse.zonesPayloadPayload] /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/zones`. - public var zones: Components.Schemas.ZoneChangesResponse.zonesPayload? + public var zones: [Components.Schemas.Zone]? /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/syncToken`. public var syncToken: Swift.String? /// - Remark: Generated from `#/components/schemas/ZoneChangesResponse/moreComing`. @@ -2087,7 +2056,7 @@ public enum Components { /// - syncToken: /// - moreComing: public init( - zones: Components.Schemas.ZoneChangesResponse.zonesPayload? = nil, + zones: [Components.Schemas.Zone]? = nil, syncToken: Swift.String? = nil, moreComing: Swift.Bool? = nil ) { diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift new file mode 100644 index 00000000..08f2be3c --- /dev/null +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift @@ -0,0 +1,164 @@ +// +// ZoneMetadataTests.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 +@testable import MistKitOpenAPI + +extension ZoneMetadataTests { + /// Decoding of the new zone metadata across every zone response shape. + @Suite("Responses") + internal struct Responses { + @Test("ZonesListResponse decodes zone metadata") + internal func listResponseDecodesMetadata() throws { + let response = try JSONDecoder().decode( + Components.Schemas.ZonesListResponse.self, + from: Data( + """ + { + "zones": [ + { + "zoneID": { "zoneName": "Articles" }, + "syncToken": "list-token", + "atomic": true + } + ] + } + """.utf8 + ) + ) + + let zone = try #require(response.zones?.first) + #expect(zone.syncToken == "list-token") + #expect(zone.atomic == true) + } + + @Test("ZonesModifyResponse decodes zone metadata") + internal func modifyResponseDecodesMetadata() throws { + let response = try JSONDecoder().decode( + Components.Schemas.ZonesModifyResponse.self, + from: Data( + """ + { + "zones": [ + { + "zoneID": { "zoneName": "Articles" }, + "syncToken": "modify-token", + "atomic": false + } + ] + } + """.utf8 + ) + ) + + let zone = try #require(response.zones?.first) + #expect(zone.syncToken == "modify-token") + #expect(zone.atomic == false) + } + + @Test("ZoneChangesResponse decodes per-zone metadata alongside the top-level token") + internal func changesResponseDecodesMetadata() throws { + let response = try JSONDecoder().decode( + Components.Schemas.ZoneChangesResponse.self, + from: Data( + """ + { + "zones": [ + { + "zoneID": { "zoneName": "Articles" }, + "syncToken": "zone-level-token", + "atomic": true + } + ], + "syncToken": "top-level-token", + "moreComing": true + } + """.utf8 + ) + ) + + let result = try ZoneChangesResult(from: response) + + // The per-zone token and the response-level token are distinct values. + #expect(result.syncToken == "top-level-token") + #expect(result.moreComing) + let zone = try #require(result.zones.first) + #expect(zone.syncToken == "zone-level-token") + #expect(zone.atomic == true) + } + + @Test("ZonesLookupResponse decodes zone metadata") + internal func lookupResponseDecodesMetadata() throws { + let response = try JSONDecoder().decode( + Components.Schemas.ZonesLookupResponse.self, + from: Data( + """ + { + "zones": [ + { + "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "syncToken": "lookup-token", + "atomic": true + } + ] + } + """.utf8 + ) + ) + + let zone = try #require(response.zones?.first) + let info = try ZoneInfo(from: zone) + #expect(info.syncToken == "lookup-token") + #expect(info.atomic == true) + } + + @Test("ZoneOperation encodes only operationType and zoneID") + internal func zoneOperationEncodesDocumentedKeysOnly() throws { + // Apple documents the operation's `zone` as having "a single zoneID key", + // so no create options are emitted (see issue #386). + let operation = Components.Schemas.ZoneOperation( + from: .create(ZoneID(zoneName: "Articles")) + ) + + let data = try JSONEncoder().encode(operation) + let object = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + #expect(object["operationType"] as? String == "create") + let zone = try #require(object["zone"] as? [String: Any]) + #expect(Set(zone.keys) == ["zoneID"]) + let zoneID = try #require(zone["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + } + } +} diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift new file mode 100644 index 00000000..6ad759f2 --- /dev/null +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift @@ -0,0 +1,130 @@ +// +// ZoneMetadataTests.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 +@testable import MistKitOpenAPI + +extension ZoneMetadataTests { + /// Conversion of a `Zone` payload into the domain `ZoneInfo`. + @Suite("ZoneInfo Conversion") + internal struct ZoneInfoConversion { + /// Decode a JSON zone payload into the generated `Zone` schema type. + internal static func decodeZone(_ json: String) throws -> Components.Schemas.Zone { + try JSONDecoder().decode(Components.Schemas.Zone.self, from: Data(json.utf8)) + } + + @Test("Zone payload decodes syncToken and atomic") + internal func zoneDecodesMetadata() throws { + let zone = try Self.decodeZone( + """ + { + "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "syncToken": "AQAAAAAAAAAB", + "atomic": true + } + """ + ) + + #expect(zone.zoneID?.zoneName == "Articles") + #expect(zone.syncToken == "AQAAAAAAAAAB") + #expect(zone.atomic == true) + } + + @Test("ZoneInfo carries syncToken and atomic from a Zone payload") + internal func zoneInfoCarriesMetadata() throws { + let zone = try Self.decodeZone( + """ + { + "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "syncToken": "AQAAAAAAAAAB", + "atomic": true + } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(info.zoneName == "Articles") + #expect(info.ownerRecordName == "_defaultOwner") + #expect(info.syncToken == "AQAAAAAAAAAB") + #expect(info.atomic == true) + } + + @Test("Absent metadata stays nil rather than defaulting") + internal func absentMetadataStaysNil() throws { + let zone = try Self.decodeZone( + """ + { "zoneID": { "zoneName": "Articles" } } + """ + ) + + let info = try ZoneInfo(from: zone) + + // `atomic` must stay nil so "absent" remains distinguishable from + // an explicit `false`. + #expect(info.syncToken == nil) + #expect(info.atomic == nil) + #expect(info.zoneName == "Articles") + } + + @Test("atomic decodes false without collapsing into nil") + internal func atomicFalseIsPreserved() throws { + let zone = try Self.decodeZone( + """ + { "zoneID": { "zoneName": "Articles" }, "atomic": false } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(try #require(info.atomic) == false) + } + + @Test("ZoneInfo still throws when the zone payload has no zoneName") + internal func missingZoneNameThrows() throws { + let zone = try Self.decodeZone( + """ + { "zoneID": { "ownerName": "_defaultOwner" }, "atomic": true } + """ + ) + + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: ConversionError.self) { + _ = try ZoneInfo(from: zone) + } + } + ) + } + } +} diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests.swift new file mode 100644 index 00000000..d734ee69 --- /dev/null +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests.swift @@ -0,0 +1,38 @@ +// +// ZoneMetadataTests.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 Testing + +/// Coverage for the zone metadata added in issue #386. +/// +/// Apple's archived "Zone Dictionary" documents exactly three keys — +/// `zoneID`, `syncToken`, and `atomic` — and these suites pin the decode +/// path for the latter two across every zone response shape. +@Suite("Zone Metadata") +internal enum ZoneMetadataTests {} diff --git a/openapi.yaml b/openapi.yaml index 1a569195..4bacb352 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1473,16 +1473,34 @@ components: moreComing: type: boolean + Zone: + type: object + description: > + A record zone as returned by the zone endpoints (`zones/list`, + `zones/lookup`, `zones/modify`, `zones/changes`). Matches the + "Zone Dictionary" in Apple's archived CloudKit Web Services + Reference, which documents exactly three keys: `zoneID`, + `syncToken`, and `atomic`. `isEager` is deliberately absent — it + appears in no primary Apple source (see issue #386). + properties: + zoneID: + $ref: '#/components/schemas/ZoneID' + syncToken: + type: string + description: The current point in the zone's change history. + atomic: + type: boolean + description: > + A Boolean value indicating whether this zone supports atomic + operations. + ZonesListResponse: type: object properties: zones: type: array items: - type: object - properties: - zoneID: - $ref: '#/components/schemas/ZoneID' + $ref: '#/components/schemas/Zone' ZonesLookupResponse: type: object @@ -1490,10 +1508,7 @@ components: zones: type: array items: - type: object - properties: - zoneID: - $ref: '#/components/schemas/ZoneID' + $ref: '#/components/schemas/Zone' ZonesModifyResponse: type: object @@ -1501,10 +1516,7 @@ components: zones: type: array items: - type: object - properties: - zoneID: - $ref: '#/components/schemas/ZoneID' + $ref: '#/components/schemas/Zone' ZoneChangesResponse: type: object @@ -1512,10 +1524,7 @@ components: zones: type: array items: - type: object - properties: - zoneID: - $ref: '#/components/schemas/ZoneID' + $ref: '#/components/schemas/Zone' syncToken: type: string moreComing: From 3bc2808173cc2df0e9f6b506366f1f2a99bb087b Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 14:26:17 -0400 Subject: [PATCH 2/3] Record verified CloudKit Zone Dictionary facts in project memory Captures the primary-source verification done for #386: the Zone Dictionary has exactly three keys, and isEager / modify-request `atomic` / zone create options do not exist in any Apple source. Also records the unresolved metaSyncToken discrepancy on zones/changes so it isn't re-investigated from scratch. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/memory/MEMORY.md | 1 + .../reference_cloudkit_zone_dictionary.md | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .claude/memory/reference_cloudkit_zone_dictionary.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 9ccba95a..366ab183 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -12,6 +12,7 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native ## Index - [CloudKit archived endpoints not in local docs](reference_cloudkit_archived_endpoints.md) — Verify CloudKit endpoints (e.g. assets/rereference) against Apple's archived reference, not just .claude/docs/webservices.md +- [CloudKit Zone Dictionary has exactly 3 keys](reference_cloudkit_zone_dictionary.md) — zoneID/syncToken/atomic only; isEager, modify-request `atomic`, and zone create options do NOT exist - [wasm CI failure signatures](reference_wasm_ci_signatures.md) — Two distinct wasm failures: silent exit-1 (OOM on big test target) vs curl exit-7 (SDK download flake, just re-run) - [Swift Testing availability guard](feedback_swift_testing_availability.md) — Never annotate @Suite types with @available; use guard #available inside @Test functions instead - [GitHub Action pinning preference](feedback_action_pinning.md) — Use @v for brightdigit-owned actions; pin third-party actions explicitly diff --git a/.claude/memory/reference_cloudkit_zone_dictionary.md b/.claude/memory/reference_cloudkit_zone_dictionary.md new file mode 100644 index 00000000..aeed8b09 --- /dev/null +++ b/.claude/memory/reference_cloudkit_zone_dictionary.md @@ -0,0 +1,32 @@ +--- +name: reference_cloudkit_zone_dictionary +description: "CloudKit's Zone Dictionary has exactly three keys (zoneID, syncToken, atomic) — isEager and zone create options do not exist" +metadata: + node_type: memory + type: reference +--- + +Verified against Apple's archived CloudKit Web Services Reference during issue #386 / PR #427. + +**Zone Dictionary documents exactly three keys** ([Types.html](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html)): + +| Key | Apple's wording | +|-----|-----------------| +| `zoneID` | "The dictionary that identifies a record zone in the database" | +| `syncToken` | "The current point in the zone's change history." | +| `atomic` | "A Boolean value indicating whether this zone supports atomic operations." | + +All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`) route their **success** payload through this dictionary, and their **failure** payload through the "Zone Fetch Error Dictionary" (`zoneID`, `reason`, `serverErrorCode`, `retryAfter`, `redirectURL`). + +**Things that do NOT exist — do not add them speculatively:** + +- **`isEager`** — appears in no primary Apple source, nor in `.claude/docs/webservices.md` or `.claude/docs/cloudkitjs.md`. It was proposed in issue #386 but is unsourced. +- **`atomic` on the `zones/modify` request** — the request body is `operations` only. `records/modify` *does* document `atomic`; the asymmetry is deliberate. +- **Zone create options on `ZoneOperation`** — the operation's `zone` is documented as having "a single `zoneID` key". + +**Open discrepancies (unresolved, see issue #386 comment):** + +- `zones/changes` documents its token key as **`metaSyncToken`** in both request and response; MistKit sends/reads `syncToken`. Apple's page contradicts itself (the `moreComing` description refers back to "the included `syncToken` key"), so this needs a live-response check before changing. +- `zones/changes` is documented as **deprecated** in favor of `changes/database`. + +Note `ZoneID`'s owner key: Apple documents `ownerRecordName`, while MistKit's `ZoneID` domain type calls it `ownerName` and the wire schema uses `ownerName`. Related: [[reference_cloudkit_archived_endpoints]]. From 556974418e4303b68e7d171e37b3a666024f666d Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 18:43:50 -0400 Subject: [PATCH 3/3] Surface zone syncToken and atomic in MistDemo output Print the new ZoneInfo metadata fields from list/lookup/create CLI and verbose zone integration phases so zone schema changes are testable. Co-authored-by: Cursor --- .../Sources/MistDemoKit/Commands/CreateZoneCommand.swift | 6 ++++++ .../Sources/MistDemoKit/Commands/LookupZonesCommand.swift | 6 ++++++ .../MistDemoKit/Integration/Phases/ListZonesPhase.swift | 6 ++++++ .../MistDemoKit/Integration/Phases/LookupZonePhase.swift | 6 ++++++ .../MistDemoKit/Integration/Phases/ModifyZonesPhase.swift | 8 +++++++- 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateZoneCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateZoneCommand.swift index 101692f9..7c5297f7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateZoneCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateZoneCommand.swift @@ -97,6 +97,12 @@ public struct CreateZoneCommand: MistDemoCommand, OutputFormatting { if !zone.capabilities.isEmpty { print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") } + if let syncToken = zone.syncToken { + print(" Sync Token: \(syncToken)") + } + if let atomic = zone.atomic { + print(" Atomic: \(atomic)") + } print("\n" + String(repeating: "=", count: 60)) print("✅ Zone creation completed!") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift index 9a9b566a..33b0e3b2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift @@ -94,6 +94,12 @@ public struct LookupZonesCommand: MistDemoCommand, OutputFormatting { if !zone.capabilities.isEmpty { print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") } + if let syncToken = zone.syncToken { + print(" Sync Token: \(syncToken)") + } + if let atomic = zone.atomic { + print(" Atomic: \(atomic)") + } } print("\n" + String(repeating: "=", count: 60)) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift index 1ca143cb..6e5b9ad1 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift @@ -52,6 +52,12 @@ internal struct ListZonesPhase: IntegrationPhase { if context.verbose { for zone in zones { print(" - \(zone.zoneName)") + if let syncToken = zone.syncToken { + print(" Sync Token: \(syncToken)") + } + if let atomic = zone.atomic { + print(" Atomic: \(atomic)") + } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift index 30321bf0..c9cef49d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift @@ -62,6 +62,12 @@ internal struct LookupZonePhase: IntegrationPhase { if !zone.capabilities.isEmpty { print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") } + if let syncToken = zone.syncToken { + print(" Sync Token: \(syncToken)") + } + if let atomic = zone.atomic { + print(" Atomic: \(atomic)") + } } return NoState() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift index c3626fc5..3b77b42a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift @@ -64,7 +64,7 @@ internal struct ModifyZonesPhase: IntegrationPhase { zoneIDs: [zoneID], database: context.database ) - guard lookedUp.contains(where: { $0.zoneName == zoneName }) else { + 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" @@ -72,6 +72,12 @@ internal struct ModifyZonesPhase: IntegrationPhase { } 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 cleanup(zoneID: zoneID, context: context)