Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/check-generated-openapi.yml
Original file line number Diff line number Diff line change
@@ -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."
5 changes: 5 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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]`
Expand Down Expand Up @@ -253,14 +255,17 @@ 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<ZoneInfo, ZoneTarget>`; 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, <Success>]`. 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, <Success>]` (**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<Success, Target>`, 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.)
Expand All @@ -271,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`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)")
Expand All @@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading