From 5feda121f41f837909bd8b02e06697e386237927 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:33:07 +0000 Subject: [PATCH 01/11] Add SessionStart hook to provision Swift toolchain (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code on the web sessions start without a Swift toolchain, so any build, test, or lint step the agent attempts fails immediately. Add a SessionStart hook that provisions Swift 6.1 (matching Package.swift's swift-tools-version) plus the tooling pinned in mise.toml. The script is guarded by CLAUDE_CODE_REMOTE so it is a no-op for local sessions, and each step is idempotent so a warm container re-runs it in seconds rather than minutes. Note that download.swift.org spells the platform two different ways: the URL path segment is dotless (ubuntu2404) while the archive and extracted directory keep the dot (ubuntu24.04). Using a single variable for both returns a 404, so they are kept separate. Provisioning failures exit 0 rather than blocking the session — the agent can still read and edit code, it just cannot build. Registering the hook additionally requires a .claude/settings.json SessionStart entry, which is not included here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LxPMhShjWhhDgPkt7CNHPy --- .claude/hooks/session-start.sh | 124 +++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100755 .claude/hooks/session-start.sh diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 00000000..e1f5c1cc --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# SessionStart hook — provision the Swift toolchain and the pinned project +# tooling so Claude Code on the web sessions can run swift build / swift test / +# swift-format / swiftlint / periphery without manual setup. +# +# No-op for local sessions (CLAUDE_CODE_REMOTE unset). Idempotent: a warm +# container re-runs this in seconds. + +set -uo pipefail + +[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0 + +# Matches Package.swift's swift-tools-version. +readonly SWIFT_VERSION="6.1" +readonly SWIFT_RELEASE="swift-${SWIFT_VERSION}-RELEASE" +# download.swift.org spells the platform two different ways: the URL path +# segment is dotless ("ubuntu2404") while the archive/extracted directory name +# keeps the dot ("ubuntu24.04"). Using one for both yields a 404. +readonly SWIFT_PLATFORM="ubuntu24.04" +readonly SWIFT_PLATFORM_PATH="ubuntu2404" +readonly SWIFT_DIR="${HOME}/.swift" +readonly SWIFT_ROOT="${SWIFT_DIR}/${SWIFT_RELEASE}-${SWIFT_PLATFORM}" +readonly SWIFT_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/${SWIFT_PLATFORM_PATH}/${SWIFT_RELEASE}/${SWIFT_RELEASE}-${SWIFT_PLATFORM}.tar.gz" + +log() { printf '[session-start] %s\n' "$*" >&2; } + +SUDO="" +if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" +fi + +# 1. Swift runtime dependencies. Third-party PPAs in the base image can fail +# `apt-get update`; that must not abort provisioning, hence the `|| true`. +install_apt_dependencies() { + if [ -f "${SWIFT_DIR}/.apt-done" ]; then + log "apt dependencies already installed, skipping" + return 0 + fi + log "installing Swift runtime apt dependencies" + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get update -qq || true + $SUDO apt-get install -y -qq --no-install-recommends \ + binutils git gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libgcc-13-dev \ + libncurses-dev libpython3-dev libsqlite3-0 libstdc++-13-dev libxml2-dev \ + libz3-dev pkg-config tzdata unzip zlib1g-dev curl ca-certificates || + log "WARNING: some apt packages failed to install; continuing" + mkdir -p "${SWIFT_DIR}" && touch "${SWIFT_DIR}/.apt-done" +} + +# 2. Swift toolchain. +install_swift() { + if [ -x "${SWIFT_ROOT}/usr/bin/swift" ]; then + log "Swift ${SWIFT_VERSION} already present at ${SWIFT_ROOT}" + return 0 + fi + log "downloading Swift ${SWIFT_VERSION}" + mkdir -p "${SWIFT_DIR}" + local archive="${SWIFT_DIR}/${SWIFT_RELEASE}-${SWIFT_PLATFORM}.tar.gz" + if ! curl -fsSL --retry 3 --retry-delay 2 -o "${archive}" "${SWIFT_URL}"; then + log "ERROR: failed to download Swift toolchain" + return 1 + fi + log "extracting toolchain" + if ! tar -xzf "${archive}" -C "${SWIFT_DIR}"; then + log "ERROR: extraction failed" + return 1 + fi + rm -f "${archive}" + if [ ! -x "${SWIFT_ROOT}/usr/bin/swift" ]; then + log "ERROR: swift binary missing after extract" + return 1 + fi +} + +# 3. mise + the tools pinned in mise.toml (swift-format, swiftlint, periphery, +# swift-openapi-generator). The spm: backends build from source, so the +# toolchain has to be on PATH before this runs. +install_mise() { + if ! command -v mise >/dev/null 2>&1 && [ ! -x "${HOME}/.local/bin/mise" ]; then + log "installing mise" + curl -fsSL https://mise.run | sh || { + log "WARNING: mise install failed" + return 0 + } + else + log "mise already installed" + fi + export PATH="${HOME}/.local/bin:${SWIFT_ROOT}/usr/bin:${PATH}" + if [ -f "${CLAUDE_PROJECT_DIR:-.}/mise.toml" ]; then + log "running mise install (the slow step on a cold container)" + (cd "${CLAUDE_PROJECT_DIR:-.}" && mise install -y) || + log "WARNING: mise install reported errors" + fi +} + +# 4. Persist PATH for the rest of the session. +persist_environment() { + local path_line="export PATH=\"${SWIFT_ROOT}/usr/bin:${HOME}/.local/bin:\${PATH}\"" + if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + if ! grep -qF "${SWIFT_ROOT}/usr/bin" "${CLAUDE_ENV_FILE}" 2>/dev/null; then + printf '%s\n' "${path_line}" >>"${CLAUDE_ENV_FILE}" + fi + log "persisted PATH to CLAUDE_ENV_FILE" + fi + # Also land it in the shell profile so plain non-login shells inherit it. + for profile in "${HOME}/.bashrc" "${HOME}/.profile"; do + [ -f "${profile}" ] || continue + if ! grep -qF "${SWIFT_ROOT}/usr/bin" "${profile}" 2>/dev/null; then + printf '%s\n' "${path_line}" >>"${profile}" + fi + done +} + +install_apt_dependencies +# Never block the session on a provisioning failure — the agent can still read +# and edit code, it just cannot build. +install_swift || exit 0 +install_mise +persist_environment + +export PATH="${SWIFT_ROOT}/usr/bin:${HOME}/.local/bin:${PATH}" +log "provisioning complete: $(swift --version 2>&1 | head -1)" +exit 0 From e105b9d4befc3ed32f24c0df9fc3d08fd2f681ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:37:43 +0000 Subject: [PATCH 02/11] Ignore .claude/worktrees/ Parallel agent runs create git worktrees under .claude/worktrees/. Those are local scratch checkouts, not project content, so keep them out of git status. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LxPMhShjWhhDgPkt7CNHPy --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6506fa44..a6e4eaf3 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,6 @@ dev-debug.log # tasks/ .claude/scheduled_tasks.lock build + +# Git worktrees created for parallel agent runs +.claude/worktrees/ From db65fcde39fcc80a9622c0e1fbd2468270478b62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:51:53 +0000 Subject: [PATCH 03/11] Install Swift 6.2 in the SessionStart hook The root Package.swift declares swift-tools-version 6.1, but every example package (MistDemo, BushelCloud, CelestraCloud) declares 6.2. Installing 6.1 left those unbuildable: error: 'mistdemo': package 'mistdemo' is using Swift tools version 6.2.0 but the installed version is 6.1.0 which meant the example targets could not be compiled or verified at all. Install the highest tools-version any package in the repo requires; a newer toolchain still builds the older root manifest. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LxPMhShjWhhDgPkt7CNHPy --- .claude/hooks/session-start.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index e1f5c1cc..a4beb6e7 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -11,8 +11,12 @@ set -uo pipefail [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0 -# Matches Package.swift's swift-tools-version. -readonly SWIFT_VERSION="6.1" +# The root Package.swift declares swift-tools-version 6.1, but +# Examples/MistDemo declares 6.2 — installing 6.1 makes the example packages +# unbuildable ("package is using Swift tools version 6.2.0 but the installed +# version is 6.1.0"). Install the highest tools-version any package in the +# repo requires; a newer toolchain still builds the older manifests. +readonly SWIFT_VERSION="6.2" readonly SWIFT_RELEASE="swift-${SWIFT_VERSION}-RELEASE" # download.swift.org spells the platform two different ways: the URL path # segment is dotless ("ubuntu2404") while the archive/extracted directory name From 9020dd5080dba5a0e4c566e61ea8025fef01bf1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:47:38 +0000 Subject: [PATCH 04/11] Remove deprecated public API before v1.0.0 (#421) Delete every `@available(*, deprecated)` public declaration from Sources/MistKit: - Delete CloudKitService+Operations+Deprecated.swift, which housed the two deprecated `queryRecords(recordType:filters:sortBy:...)` overloads. Callers use `queryRecords(_:limit:desiredKeys:continuationMarker:...)` with a `Query` value, or `queryAllRecords` to auto-paginate. - Drop the deprecated `queryRecords(recordType:)` requirement from `RecordManaging` plus its deprecated `queryAllRecords(recordType:)` default implementation (which silently returned a single page), and the matching conformance on `CloudKitService`. The protocol survives with `queryAllRecords(recordType:)` and `executeBatchOperations(_:)` as its two requirements, which is all its generic extensions (`sync`, `list`, `query`, the CloudKitRecordCollection helpers) need. - Drop `fetchCurrentUser()`; `users/current` is deprecated by Apple and `fetchCaller()` is the replacement. Follow-up cleanup: remove the two tests that only exercised the deprecated query overloads, migrate the remaining tests and the Examples call sites (MistDemo, BushelCloud, CelestraCloud) to the surviving API, and refresh CLAUDE.md, README.md and the DocC articles. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 4 +- .../BushelCloud/.claude/s2s-auth-details.md | 2 +- .../Commands/StatusCommand.swift | 2 +- .../CloudKit/BushelCloudKitService.swift | 2 +- .../CloudKit/SyncEngine+Export.swift | 8 +- .../CloudKit/MockCloudKitServiceTests.swift | 4 +- .../AuthenticationErrorHandlingTests.swift | 4 +- .../CloudKitErrorHandlingTests.swift | 2 +- .../Mocks/MockCloudKitService.swift | 2 +- .../Protocols/CloudKitRecordOperating.swift | 8 +- .../Services/CloudKitService+Celestra.swift | 2 +- .../Commands/DemoInFilterCommand.swift | 12 +- .../Phases/QueryRecordsPhase.swift | 5 +- .../Server/CloudKitService+WebBackend.swift | 4 +- README.md | 13 +- ...loudKitService+Operations+Deprecated.swift | 159 ------------------ .../CloudKitService+RecordManaging.swift | 22 +-- .../CloudKitService+UserOperations.swift | 9 - .../AbstractionLayerArchitecture.md | 2 +- .../Documentation.docc/ConfiguringMistKit.md | 2 +- .../GeneratedCodeAnalysis.md | 2 +- .../Documentation.docc/WorkingWithRecords.md | 18 +- .../RecordManagement/RecordManaging.swift | 24 --- ...CloudKitServiceTests.Query+EdgeCases.swift | 8 +- ...rviceTests.Query+ExistingRecordNames.swift | 44 ----- ...ceTests.QueryPagination+SuccessCases.swift | 6 +- .../MockRecordManagingService.swift | 5 - 27 files changed, 60 insertions(+), 315 deletions(-) delete mode 100644 Sources/MistKit/CloudKitService/CloudKitService+Operations+Deprecated.swift diff --git a/AGENTS.md b/AGENTS.md index 9c55f839..4c80962d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,7 +192,7 @@ MistKit/ | `CloudKitService+ZoneOperations.swift` | `listZones`, `lookupZones(zoneIDs:)`, `fetchZoneChanges(syncToken:)` | | `CloudKitService+ModifyZones.swift` | `modifyZones(_:database:)` | | `CloudKitService+SyncOperations.swift` | `fetchRecordChanges(recordType:syncToken:)`, `fetchAllRecordChanges(recordType:syncToken:)` | -| `CloudKitService+UserOperations.swift` | `fetchCaller()`, `discoverUserIdentities(lookupInfos:)`, `discoverAllUserIdentities()` *(no-arg address-book form — unavailable, pending #28; distinct from the available `discoverAllUserIdentities(lookupInfos:batchSize:)` chunking overload below)*, `lookupUsersByEmail(_:)`, `lookupUsersByRecordName(_:)`, `fetchCurrentUser()` (deprecated, forwards to `fetchCaller`) | +| `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` | | `CloudKitService+UserIdentityChunking.swift` | `discoverAllUserIdentities(lookupInfos:batchSize:)` — auto-chunking convenience over `discoverUserIdentities` | | `CloudKitService+BatchChunking.swift` | internal `chunkedBatches` helper backing the auto-chunking conveniences | @@ -210,7 +210,7 @@ MistKit/ - `discoverUserIdentities(lookupInfos:)` → POST `/users/discover` — takes `[UserIdentityLookupInfo]`, returns `[UserIdentity]` **User-Identity Operations (public DB + web-auth required):** -- `fetchCaller()` → `/users/caller` — returns `UserInfo`. Replaces deprecated `fetchCurrentUser()` / `users/current`. Only valid against the public database with web-auth credentials. +- `fetchCaller()` → `/users/caller` — returns `UserInfo`. Replaces Apple's deprecated `users/current` endpoint (the `fetchCurrentUser()` wrapper was removed in #421). Only valid against the public database with web-auth credentials. - `discoverAllUserIdentities()` → GET `/users/discover` — returns `[UserIdentity]` for every discoverable user in the caller's address book. - `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`. - `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`. diff --git a/Examples/BushelCloud/.claude/s2s-auth-details.md b/Examples/BushelCloud/.claude/s2s-auth-details.md index 61b03f72..a98cc590 100644 --- a/Examples/BushelCloud/.claude/s2s-auth-details.md +++ b/Examples/BushelCloud/.claude/s2s-auth-details.md @@ -305,7 +305,7 @@ fields["swiftVersion"] = .reference( **1. Test authentication:** ```swift -let records = try await service.queryRecords(recordType: "RestoreImage", limit: 1) +let records = try await service.queryRecords(Query(recordType: "RestoreImage"), limit: 1) print("✓ Authentication successful, found \(records.count) records") ``` diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift index 74090ca1..1ae93ae3 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift @@ -97,7 +97,7 @@ internal enum StatusCommand { private static func fetchAllMetadata(cloudKitService: BushelCloudKitService) async throws -> [DataSourceMetadata] { - let records = try await cloudKitService.queryRecords(recordType: "DataSourceMetadata") + let records = try await cloudKitService.queryAllRecords(recordType: "DataSourceMetadata") var metadataList: [DataSourceMetadata] = [] diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index 10bd5f9e..410a6f41 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -153,7 +153,7 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol // MARK: - RecordManaging Protocol Requirements /// Query all records of a given type, automatically paginating - public func queryRecords(recordType: String) async throws -> [RecordInfo] { + public func queryAllRecords(recordType: String) async throws -> [RecordInfo] { try await service.queryAllRecords( recordType: recordType, database: .public(.prefers(.serverToServer)) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift index 6116e483..fab3b86d 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift @@ -63,14 +63,14 @@ extension SyncEngine { Self.logger.info("Exporting CloudKit data") Self.logger.debug( - "Using MistKit queryRecords() to fetch all records of each type from the public database" + "Using MistKit queryAllRecords() to fetch all records of each type from the public database" ) ConsoleOutput.print("\n📥 Fetching RestoreImage records...") Self.logger.debug( "Querying CloudKit for recordType: 'RestoreImage' with limit: 200" ) - let restoreImages = try await cloudKitService.queryRecords(recordType: "RestoreImage") + let restoreImages = try await cloudKitService.queryAllRecords(recordType: "RestoreImage") Self.logger.debug( "Retrieved \(restoreImages.count) RestoreImage records" ) @@ -79,7 +79,7 @@ extension SyncEngine { Self.logger.debug( "Querying CloudKit for recordType: 'XcodeVersion' with limit: 200" ) - let xcodeVersions = try await cloudKitService.queryRecords(recordType: "XcodeVersion") + let xcodeVersions = try await cloudKitService.queryAllRecords(recordType: "XcodeVersion") Self.logger.debug( "Retrieved \(xcodeVersions.count) XcodeVersion records" ) @@ -88,7 +88,7 @@ extension SyncEngine { Self.logger.debug( "Querying CloudKit for recordType: 'SwiftVersion' with limit: 200" ) - let swiftVersions = try await cloudKitService.queryRecords(recordType: "SwiftVersion") + let swiftVersions = try await cloudKitService.queryAllRecords(recordType: "SwiftVersion") Self.logger.debug( "Retrieved \(swiftVersions.count) SwiftVersion records" ) diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift index af574f47..63d678fd 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift @@ -42,7 +42,7 @@ internal struct MockCloudKitServiceTests { internal func testQueryEmptyInitially() async throws { let service = MockCloudKitService() - let results = try await service.queryRecords(recordType: "RestoreImage") + let results = try await service.queryAllRecords(recordType: "RestoreImage") #expect(results.isEmpty) } @@ -187,7 +187,7 @@ internal struct MockCloudKitServiceTests { await service.setQueryError(MockCloudKitError.networkError) do { - _ = try await service.queryRecords(recordType: "RestoreImage") + _ = try await service.queryAllRecords(recordType: "RestoreImage") Issue.record("Expected error to be thrown") } catch is MockCloudKitError { // Success - error was thrown as expected diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift index f020c736..deb0752d 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift @@ -41,7 +41,7 @@ internal struct AuthenticationErrorHandlingTests { await service.setQueryError(MockCloudKitError.authenticationFailed) do { - _ = try await service.queryRecords(recordType: "RestoreImage") + _ = try await service.queryAllRecords(recordType: "RestoreImage") Issue.record("Expected authentication error to be thrown") } catch let error as MockCloudKitError { if case .authenticationFailed = error { @@ -61,7 +61,7 @@ internal struct AuthenticationErrorHandlingTests { await service.setQueryError(MockCloudKitError.accessDenied) do { - _ = try await service.queryRecords(recordType: "RestoreImage") + _ = try await service.queryAllRecords(recordType: "RestoreImage") Issue.record("Expected access denied error to be thrown") } catch let error as MockCloudKitError { if case .accessDenied = error { diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift index 27cae07a..9afec1c8 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift @@ -125,7 +125,7 @@ internal struct CloudKitErrorHandlingTests { await service.setQueryError(MockCloudKitError.unknownError("Something went wrong")) do { - _ = try await service.queryRecords(recordType: "RestoreImage") + _ = try await service.queryAllRecords(recordType: "RestoreImage") Issue.record("Expected unknown error to be thrown") } catch let error as MockCloudKitError { if case .unknownError(let message) = error { diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift index a7b67620..c4c3cc4b 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift @@ -75,7 +75,7 @@ internal actor MockCloudKitService: RecordManaging { // MARK: - RecordManaging Protocol - internal func queryRecords(recordType: String) async throws -> [RecordInfo] { + internal func queryAllRecords(recordType: String) async throws -> [RecordInfo] { if shouldFailQuery { throw queryError ?? MockCloudKitError.networkError } diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift index 083a907b..e9002369 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift @@ -109,9 +109,11 @@ extension CloudKitService: CloudKitRecordOperating { desiredKeys: [String]? ) async throws(CloudKitError) -> [RecordInfo] { let result: QueryResult = try await queryRecords( - recordType: recordType, - filters: filters, - sortBy: sortBy, + Query( + recordType: recordType, + filters: filters ?? [], + sortBy: sortBy ?? [] + ), limit: limit, desiredKeys: desiredKeys, continuationMarker: nil, diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift index 8b4cf9a7..13f64704 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift @@ -113,7 +113,7 @@ extension CloudKitService { repeat { let result: QueryResult = try await queryRecords( - recordType: "Feed", + Query(recordType: "Feed"), limit: 200, desiredKeys: ["___recordID"], continuationMarker: continuationMarker, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift index 295fbea5..16d57afb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift @@ -122,10 +122,10 @@ public struct DemoInFilterCommand: MistDemoCommand { ) async throws { print("\nVerifying records are queryable...") let allRecords = try await client.queryRecords( - recordType: recordType, + Query(recordType: recordType), limit: 200, database: config.database - ) + ).records let visible = allRecords.filter { createdNames.contains($0.recordName) } @@ -136,11 +136,13 @@ public struct DemoInFilterCommand: MistDemoCommand { print("\nQuerying with IN filter for [10, 30]...") let results = try await client.queryRecords( - recordType: recordType, - filters: [.in("index", [.int64(10), .int64(30)])], + Query( + recordType: recordType, + filters: [.in("index", [.int64(10), .int64(30)])] + ), limit: 200, database: config.database - ) + ).records let matching = results.filter { createdNames.contains($0.recordName) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift index 034e853d..3d185d42 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift @@ -44,8 +44,9 @@ internal struct QueryRecordsPhase: IntegrationPhase { print("\n\(Self.emoji) \(Self.title)") do { - let records = try await context.service.queryRecords( - recordType: MistDemoConfig.recordType + let records = try await context.service.queryAllRecords( + recordType: MistDemoConfig.recordType, + database: context.database ) print("✅ Queried \(records.count) record(s) of type '\(MistDemoConfig.recordType)'") if context.verbose { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift index 1faa9b33..27adf675 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -41,9 +41,7 @@ extension CloudKitService: WebBackend { QuerySort.sort(sort.field, ascending: sort.ascending) } let result = try await queryRecords( - recordType: recordType, - filters: nil, - sortBy: querySorts, + Query(recordType: recordType, sortBy: querySorts ?? []), limit: limit, desiredKeys: nil, continuationMarker: nil, diff --git a/README.md b/README.md index 12fdd2c4..cbbc024c 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,11 @@ routes via web-auth — MistKit picks the appropriate token manager per call. #### 2. Call an Operation (database chosen per call) ```swift -let records = try await service.queryRecords( - recordType: "Post", +let result = try await service.queryRecords( + Query(recordType: "Post"), database: .public(.prefers(.serverToServer)) ) +let records = result.records ``` `Database.public` carries a `PublicAuthPreference`: @@ -231,9 +232,9 @@ Server-to-server authentication provides enterprise-level access using ECDSA P-2 // Each call selects its database scope explicitly: let records = try await service.queryRecords( - recordType: "Post", + Query(recordType: "Post"), database: .public(.requires(.serverToServer)) - ) + ).records ``` To plug in a custom `TokenManager` (e.g. with shared connection pooling), @@ -267,9 +268,9 @@ do { ) // Perform operations — each call picks its database, e.g.: let posts = try await service.queryRecords( - recordType: "Post", + Query(recordType: "Post"), database: .public(.prefers(.serverToServer)) - ) + ).records } catch let error as CloudKitError { print("CloudKit error: \\(error.localizedDescription)") } catch let error as TokenManagerError { diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations+Deprecated.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations+Deprecated.swift deleted file mode 100644 index be3b9526..00000000 --- a/Sources/MistKit/CloudKitService/CloudKitService+Operations+Deprecated.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// CloudKitService+Operations+Deprecated.swift -// MistKit -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -extension CloudKitService { - /// Query records from the default zone - /// - /// Queries CloudKit records with optional filtering and sorting. - /// Supports all CloudKit filter operations (equals, comparisons, - /// string matching, list operations) and field-based sorting. - /// - /// - Parameters: - /// - recordType: The type of records to query (must not be empty) - /// - filters: Optional array of filters to apply to the query - /// - sortBy: Optional array of sort descriptors - /// - limit: Maximum number of records to return - /// (1-200, defaults to `defaultQueryLimit`) - /// - desiredKeys: Optional array of field names to fetch - /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) - /// - Returns: Array of matching records - /// - Throws: CloudKitError if validation fails or the request fails - /// - /// # Example: Basic Query - /// ```swift - /// let articles = try await service.queryRecords( - /// recordType: "Article" - /// ) - /// ``` - /// - /// # Example: Query with Filters - /// ```swift - /// let recentArticles = try await service.queryRecords( - /// recordType: "Article", - /// filters: [ - /// .greaterThan("publishedDate", .date(oneWeekAgo)), - /// .equals("status", .string("published")) - /// ], - /// limit: 50 - /// ) - /// ``` - /// - /// # Example: Query with Sorting - /// ```swift - /// let sortedArticles = try await service.queryRecords( - /// recordType: "Article", - /// sortBy: [.descending("publishedDate")], - /// limit: 20 - /// ) - /// ``` - /// - /// - Note: For large result sets, consider using pagination - /// with `continuationMarker` or `queryAllRecords` - @available( - *, deprecated, - message: "Use queryRecords -> QueryResult for pagination, or queryAllRecords to auto-paginate." - ) - public func queryRecords( - recordType: String, - filters: [QueryFilter]? = nil, - sortBy: [QuerySort]? = nil, - limit: Int? = nil, - desiredKeys: [String]? = nil, - database: Database - ) async throws(CloudKitError) -> [RecordInfo] { - let result: QueryResult = try await queryRecords( - recordType: recordType, - filters: filters, - sortBy: sortBy, - limit: limit, - desiredKeys: desiredKeys, - continuationMarker: nil, - database: database - ) - return result.records - } - - /// Query records from the default zone with pagination support - /// - /// Queries CloudKit records with optional filtering, sorting, and pagination. - /// Returns a `QueryResult` containing both the matching records and - /// a `continuationMarker` for fetching subsequent pages. - /// - /// - Parameters: - /// - recordType: The type of records to query (must not be empty) - /// - filters: Optional array of filters to apply to the query - /// - sortBy: Optional array of sort descriptors - /// - limit: Maximum number of records to return - /// (1-200, defaults to `defaultQueryLimit`) - /// - desiredKeys: Optional array of field names to fetch - /// - continuationMarker: Marker from a previous `QueryResult` - /// to fetch the next page of results - /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) - /// - Returns: A `QueryResult` with matching records and an optional - /// continuation marker for the next page - /// - Throws: CloudKitError if validation fails or the request fails - /// - /// # Example: Paginated Query - /// ```swift - /// var marker: String? = nil - /// repeat { - /// let result: QueryResult = try await service.queryRecords( - /// recordType: "Article", - /// limit: 50, - /// continuationMarker: marker - /// ) - /// process(result.records) - /// marker = result.continuationMarker - /// } while marker != nil - /// ``` - @available( - *, deprecated, - message: - "Use queryRecords(_:limit:desiredKeys:continuationMarker:database:) — pass a Query value." - ) - public func queryRecords( - recordType: String, - filters: [QueryFilter]? = nil, - sortBy: [QuerySort]? = nil, - limit: Int? = nil, - desiredKeys: [String]? = nil, - continuationMarker: String? = nil, - database: Database - ) async throws(CloudKitError) -> QueryResult { - try await queryRecords( - Query(recordType: recordType, filters: filters ?? [], sortBy: sortBy ?? []), - limit: limit, - desiredKeys: desiredKeys, - continuationMarker: continuationMarker, - database: database - ) - } -} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordManaging.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordManaging.swift index 64af5f13..a3f07090 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+RecordManaging.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordManaging.swift @@ -34,31 +34,13 @@ internal import Foundation /// This extension makes CloudKitService compatible with the generic RecordManaging /// operations, enabling protocol-oriented patterns for CloudKit operations. extension CloudKitService: RecordManaging { - /// Query records of a specific type from CloudKit (deprecated single-page form) + /// Execute a batch of record operations via modify /// /// `RecordManaging` is a database-agnostic abstraction predating per-call /// `PublicAuthPreference`; this conformance targets the public database - /// with `.requires(.serverToServer)` to preserve the previous "S2S when + /// with `.prefers(.serverToServer)` to preserve the previous "S2S when /// configured" behavior. Callers who need different attribution should /// call `CloudKitService` directly with an explicit `Database` value. - @available( - *, deprecated, - message: "Silently truncates at one page. Use queryAllRecords or queryRecords -> QueryResult." - ) - public func queryRecords(recordType: String) async throws -> [RecordInfo] { - let result: QueryResult = try await self.queryRecords( - recordType: recordType, - filters: nil, - sortBy: nil, - limit: 200, - desiredKeys: nil, - continuationMarker: nil, - database: .public(.prefers(.serverToServer)) - ) - return result.records - } - - /// Execute a batch of record operations via modify public func executeBatchOperations(_ operations: [RecordOperation]) async throws { let results = try await self.modifyRecords( operations, diff --git a/Sources/MistKit/CloudKitService/CloudKitService+UserOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+UserOperations.swift index b958b79a..d8c9b356 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+UserOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+UserOperations.swift @@ -69,15 +69,6 @@ extension CloudKitService { } } - /// Fetch the current authenticated user's information. - @available( - *, deprecated, renamed: "fetchCaller", - message: "users/current is deprecated by Apple. Use fetchCaller() instead." - ) - public func fetchCurrentUser() async throws(CloudKitError) -> UserInfo { - try await fetchCaller() - } - /// Look up user identities by email address. /// /// Hits CloudKit's POST `users/lookup/email` endpoint. Each requested email diff --git a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md index 8b98a346..04b429fd 100644 --- a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md +++ b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md @@ -222,7 +222,7 @@ public struct QueryResult: Codable, Sendable { Two iteration helpers cover the common cases: -- ``CloudKitService/queryRecords(recordType:filters:sortBy:limit:desiredKeys:continuationMarker:database:)`` — single page. +- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` — single page. - `queryAllRecords(...)` — auto-pagination with an enforced maximum, surfacing ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` with the already-fetched records when the cap is reached. Sync endpoints follow the same shape: ``RecordChangesResult`` and ``ZoneChangesResult`` carry `syncToken` and `moreComing`. `fetchAllRecordChanges(recordType:syncToken:)` walks the cursor automatically. diff --git a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md index 0a248a3c..918bfcba 100644 --- a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -55,7 +55,7 @@ let environment: Environment = ProcessInfo.processInfo ``CloudKitService`` itself is database-agnostic — there is no `database:` parameter on the initializer. You pick the scope at each call site: ```swift -try await service.queryRecords(recordType: "Note", database: .private) +try await service.queryRecords(Query(recordType: "Note"), database: .private) try await service.createRecord( recordType: "FeaturedPost", fields: fields, diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md index a3d59078..348735a6 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -367,7 +367,7 @@ case .undocumented(let code, _): } ``` -That's correct but tedious for every call site. ``CloudKitService/queryRecords(recordType:filters:sortBy:limit:desiredKeys:continuationMarker:database:)`` collapses it to one async call returning ``QueryResult``. The generated layer still does the type-safe HTTP work; the wrapper handles the call-site ergonomics, error mapping, and conversion between generated and domain types. +That's correct but tedious for every call site. ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` collapses it to one async call returning ``QueryResult``. The generated layer still does the type-safe HTTP work; the wrapper handles the call-site ergonomics, error mapping, and conversion between generated and domain types. ## Integration with the wrapper diff --git a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md index 1d728903..603806d5 100644 --- a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md +++ b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md @@ -8,16 +8,18 @@ CRUD, batch, and lookup against CloudKit records — the operations you'll reach ## Querying -Use ``CloudKitService/queryRecords(recordType:filters:sortBy:limit:desiredKeys:continuationMarker:database:)`` for a single page of results. Filters are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: +Use ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` for a single page of results. Filters are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: ```swift let result = try await service.queryRecords( - recordType: "Article", - filters: [ - .greaterThan("publishedDate", .date(oneWeekAgo)), - .equals("status", .string("published")) - ], - sortBy: [.descending("publishedDate")], + Query( + recordType: "Article", + filters: [ + .greaterThan("publishedDate", .date(oneWeekAgo)), + .equals("status", .string("published")) + ], + sortBy: [.descending("publishedDate")] + ), limit: 50, database: .private ) @@ -173,7 +175,7 @@ The inline DocC on these methods carries fuller examples for initial-vs-incremen ### Read operations -- ``CloudKitService/queryRecords(recordType:filters:sortBy:limit:desiredKeys:continuationMarker:database:)`` +- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` - ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` - ``CloudKitService/lookupRecords(recordNames:desiredKeys:database:)`` diff --git a/Sources/MistKit/RecordManagement/RecordManaging.swift b/Sources/MistKit/RecordManagement/RecordManaging.swift index bafe1b46..c5fadbab 100644 --- a/Sources/MistKit/RecordManagement/RecordManaging.swift +++ b/Sources/MistKit/RecordManagement/RecordManaging.swift @@ -35,17 +35,6 @@ internal import Foundation /// Conforming types must implement the two core operations, while all other /// functionality (listing, syncing, deleting) is provided through protocol extensions. public protocol RecordManaging { - /// Query records of a specific type from CloudKit - /// - /// - Parameter recordType: The CloudKit record type to query - /// - Returns: Array of record information for all matching records - /// - Throws: CloudKit errors if the query fails - @available( - *, deprecated, - message: "Silently truncates at one page. Use queryAllRecords or queryRecords -> QueryResult." - ) - func queryRecords(recordType: String) async throws -> [RecordInfo] - /// Execute a batch of record operations /// /// Handles batching operations to respect CloudKit's 200 operations/request limit. @@ -63,16 +52,3 @@ public protocol RecordManaging { /// - Throws: CloudKit errors if the query fails func queryAllRecords(recordType: String) async throws -> [RecordInfo] } - -extension RecordManaging { - /// Default implementation delegates to the deprecated `queryRecords(recordType:)`, - /// which only returns one page. Conformers should override this with a real - /// auto-paginating implementation (e.g. `CloudKitService.queryAllRecords`). - @available( - *, deprecated, - message: "Default returns one page. Override with a real auto-paginating implementation." - ) - public func queryAllRecords(recordType: String) async throws -> [RecordInfo] { - try await queryRecords(recordType: recordType) - } -} diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+EdgeCases.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+EdgeCases.swift index 619b75ad..87450f5d 100644 --- a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+EdgeCases.swift +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+EdgeCases.swift @@ -47,7 +47,7 @@ extension CloudKitServiceTests.Query { // This test verifies the parameter handling - actual call will fail without auth do { _ = try await service.queryRecords( - recordType: "Article", + Query(recordType: "Article"), limit: nil as Int?, database: .public(.prefers(.serverToServer)) ) @@ -69,8 +69,7 @@ extension CloudKitServiceTests.Query { do { _ = try await service.queryRecords( - recordType: "Article", - filters: [], + Query(recordType: "Article", filters: []), limit: 10, database: .public(.prefers(.serverToServer)) ) @@ -92,8 +91,7 @@ extension CloudKitServiceTests.Query { do { _ = try await service.queryRecords( - recordType: "Article", - sortBy: [], + Query(recordType: "Article", sortBy: []), limit: 10, database: .public(.prefers(.serverToServer)) ) diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift index 1ae00952..322a93d1 100644 --- a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ExistingRecordNames.swift @@ -53,49 +53,5 @@ extension CloudKitServiceTests.Query { #expect(existing == Set(["record-0", "record-1", "record-2"])) } - - @Test("deprecated RecordManaging.queryRecords(recordType:) returns parsed records") - @available( - *, deprecated, - message: "Exercises the deprecated single-page RecordManaging wrapper." - ) - internal func deprecatedQueryRecordsReturnsRecords() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try CloudKitServiceTests.QueryPagination.makeSuccessfulService( - recordCount: 2, - continuationMarker: nil - ) - - let records = try await service.queryRecords(recordType: "TestRecord") - - #expect(records.count == 2) - #expect(records.map(\.recordName) == ["record-0", "record-1"]) - } - - @Test("deprecated queryRecords(recordType:database:) returns the records array") - @available( - *, deprecated, - message: "Exercises the deprecated [RecordInfo] query overload." - ) - internal func deprecatedQueryRecordsArrayOverload() async throws { - guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { - Issue.record("CloudKitService is not available on this operating system.") - return - } - let service = try CloudKitServiceTests.QueryPagination.makeSuccessfulService( - recordCount: 2, - continuationMarker: nil - ) - - let records = try await service.queryRecords( - recordType: "TestRecord", - database: .public(.prefers(.serverToServer)) - ) - - #expect(records.map(\.recordName) == ["record-0", "record-1"]) - } } } diff --git a/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift index ff225664..1fa87db5 100644 --- a/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/QueryPagination/CloudKitServiceTests.QueryPagination+SuccessCases.swift @@ -49,7 +49,7 @@ extension CloudKitServiceTests.QueryPagination { ) let result: QueryResult = try await service.queryRecords( - recordType: "TestRecord", + Query(recordType: "TestRecord"), continuationMarker: nil, database: .public(.prefers(.serverToServer)) ) @@ -70,7 +70,7 @@ extension CloudKitServiceTests.QueryPagination { ) let result: QueryResult = try await service.queryRecords( - recordType: "TestRecord", + Query(recordType: "TestRecord"), database: .public(.prefers(.serverToServer)) ) @@ -90,7 +90,7 @@ extension CloudKitServiceTests.QueryPagination { ) let result: QueryResult = try await service.queryRecords( - recordType: "TestRecord", + Query(recordType: "TestRecord"), continuationMarker: "previous-marker", database: .public(.prefers(.serverToServer)) ) diff --git a/Tests/MistKitTests/RecordManagement/MockRecordManagingService.swift b/Tests/MistKitTests/RecordManagement/MockRecordManagingService.swift index da711077..404f40c7 100644 --- a/Tests/MistKitTests/RecordManagement/MockRecordManagingService.swift +++ b/Tests/MistKitTests/RecordManagement/MockRecordManagingService.swift @@ -39,11 +39,6 @@ internal actor MockRecordManagingService: RecordManaging { internal var batchSizes: [Int] = [] internal var recordsToReturn: [RecordInfo] = [] - internal func queryRecords(recordType: String) async throws -> [RecordInfo] { - queryCallCount += 1 - return recordsToReturn - } - internal func queryAllRecords(recordType: String) async throws -> [RecordInfo] { queryCallCount += 1 return recordsToReturn From caaff9bcde730dc13c1c4485972f471d7ca6e872 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:47:41 +0000 Subject: [PATCH 05/11] Refactor FieldValue <-> Components conversion onto exhaustive dispatch (#378) Replace the hand-rolled switch/if-else chains in the FieldValue conversion layer with two small classifier enums, so every dispatch point is total and `default`-free and the compiler forces new cases to be handled. Response side: - Add `FieldValue.ResponseTypeTag`, a single total mapping from the generated `FieldValueResponse._typePayload` to the value category the tag demands (`.numeric` / `.text` / `.complex`). This replaces four separate switches with `default` fallthroughs: `makeTypedScalar`, `makeTypedNumericScalar`, `makeTypedStringScalar`, and `ExpectedComplexValue.init?`. `ExpectedComplexValue` moves alongside it, unchanged, and still gates the #376 complex/list contradiction check. - Add a private `ScalarPayload` that narrows a decoded `valuePayload` to its five scalar cases. `requireNumeric`, `requireString`, and `makeInferredScalar` now project off it instead of each walking the payload with its own if-chain. Inference stays lazy so the `Int64 -> Int` narrowing only happens on the inference path, as before. - `makeTypedScalar` collapses from three nested functions to one flat switch. Request side: - `Components.Schemas.FieldValueRequest.init(from:)` becomes one exhaustive switch over `FieldValue`, dropping `makeScalarRequest` / `makeComplexRequest` and their unreachable `default` branch. FilterBuilder: - `cloudKitListType(for:)` / `cloudKitComplexListType(for:)` collapse into one exhaustive switch. Behavior is unchanged: request type tagging (TIMESTAMP/BYTES/DOUBLE only), response type recovery over first-match-wins decoding, and the fail-loud `typeValueMismatch` on scalar and complex/list contradictions all keep their existing semantics. Docs referencing the removed helper names are updated. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- .../FieldValue+Components+Scalar.swift | 206 +++++++++--------- .../FieldValues/FieldValue+Components.swift | 35 +-- .../FieldValue+ResponseTypeTag.swift | 102 +++++++++ .../Queries/FilterBuilder/FilterBuilder.swift | 41 ++-- ...Components.Schemas.FieldValueRequest.swift | 77 +++---- docs/internals/field-type-polymorphism.md | 12 +- 7 files changed, 267 insertions(+), 208 deletions(-) create mode 100644 Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift diff --git a/AGENTS.md b/AGENTS.md index 4c80962d..94c7dc78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev - `BYTES` (`.bytes`) — a base64 string, otherwise read as `STRING` - `DOUBLE` (`.double`) — a whole-valued double serializes without a fraction, otherwise read as `INT64` -Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in `makeScalarRequest` (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. +Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in the exhaustive `init(from:)` switch (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. **Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case. `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`). diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift index f4957791..515df0b3 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift @@ -32,6 +32,85 @@ internal import MistKitOpenAPI /// Scalar-value conversions for `FieldValue` ← `Components.Schemas` response types. extension FieldValue { + /// A decoded response `value` narrowed to its five scalar `oneOf` cases. + /// + /// This is the one place the nine `valuePayload` cases are enumerated for scalar work: + /// `requireNumeric`, `requireString`, and `makeInferredScalar` project off it instead of + /// each walking the payload themselves. Every switch over it — here and in ``init(_:)`` — + /// is `default`-free, so a new `oneOf` case breaks the build rather than silently reading + /// as "not a scalar", and a new scalar case has to be classified for all three projections. + private enum ScalarPayload { + case string(String) + case bytes(String) + case int64(Int64) + case double(Double) + /// Milliseconds since the epoch, as CloudKit sends a `TIMESTAMP`. + case date(Double) + + /// The `FieldValue` first-match-wins inference produces for this payload. Lossy for the + /// ambiguous scalars: a base64 BYTES reads back as `.string`, and a whole-number + /// TIMESTAMP reads back as `.int64`. + fileprivate var inferred: FieldValue { + switch self { + case .string(let strVal): + return .string(strVal) + case .bytes(let bytesVal): + return .bytes(bytesVal) + case .int64(let intVal): + return .int64(Int(intVal)) + case .double(let dblVal): + return .double(dblVal) + case .date(let dateVal): + return .date(Date(timeIntervalSince1970: dateVal / 1_000)) + } + } + + /// The numeric payload, or `nil` when the value is string-backed. `Int64Value`, + /// `DoubleValue`, and `DateValue` all arrive as JSON numbers. + fileprivate var number: Double? { + switch self { + case .int64(let intVal): + return Double(intVal) + case .double(let dblVal): + return dblVal + case .date(let dateVal): + return dateVal + case .string, .bytes: + return nil + } + } + + /// The string payload, or `nil` when the value is numeric. `StringValue` and + /// `BytesValue` both arrive as JSON strings. + fileprivate var text: String? { + switch self { + case .string(let strVal), .bytes(let strVal): + return strVal + case .int64, .double, .date: + return nil + } + } + + /// Narrow a decoded response `value` to a scalar, returning `nil` for the structured + /// cases (which are handled by `makeComplexFieldValue`). + fileprivate init?(_ value: Components.Schemas.FieldValueResponse.valuePayload) { + switch value { + case .StringValue(let strVal): + self = .string(strVal) + case .BytesValue(let bytesVal): + self = .bytes(bytesVal) + case .Int64Value(let intVal): + self = .int64(intVal) + case .DoubleValue(let dblVal): + self = .double(dblVal) + case .DateValue(let dateVal): + self = .date(dateVal) + case .LocationValue, .ReferenceValue, .AssetValue, .ListValue: + return nil + } + } + } + internal static func makeSimpleFieldValue( from value: Components.Schemas.FieldValueResponse.valuePayload, type fieldType: Components.Schemas.FieldValueResponse._typePayload?, @@ -51,14 +130,15 @@ extension FieldValue { /// Build a scalar `FieldValue` from an explicit CloudKit `type`, recovering the value /// from whichever undiscriminated `oneOf` case it happened to decode into. /// - /// All five scalar types are validated against the value's category (numeric vs. string). - /// A declared scalar type whose value can't satisfy it (e.g. `TIMESTAMP` over a string) - /// is an internally inconsistent response and throws ``ConversionError/typeValueMismatch`` - /// rather than silently coercing to the value's shape. Only the genuinely ambiguous - /// scalars (`TIMESTAMP`/`DOUBLE`/`BYTES`) produce a value here; `INT64`/`STRING` validate - /// the category then return nil to defer to inference — which already yields the right - /// case and, for `INT64`, avoids truncating a fractional number. A `nil` or complex/list - /// `type` returns nil and is handled by inference or `makeComplexFieldValue`. + /// All five scalar types are validated against the value's category (numeric vs. string) + /// by ``FieldValue/ResponseTypeTag``. A declared scalar type whose value can't satisfy it + /// (e.g. `TIMESTAMP` over a string) is an internally inconsistent response and throws + /// ``ConversionError/typeValueMismatch`` rather than silently coercing to the value's + /// shape. Only the genuinely ambiguous scalars (`TIMESTAMP`/`DOUBLE`/`BYTES`) produce a + /// value here; `INT64`/`STRING` validate the category then return nil to defer to + /// inference — which already yields the right case and, for `INT64`, avoids truncating a + /// fractional number. A `nil` or complex/list `type` returns nil and is handled by + /// inference or `makeComplexFieldValue`. private static func makeTypedScalar( from value: Components.Schemas.FieldValueResponse.valuePayload, type fieldType: Components.Schemas.FieldValueResponse._typePayload?, @@ -67,54 +147,28 @@ extension FieldValue { guard let fieldType else { return nil } - switch fieldType { - case .TIMESTAMP, .DOUBLE, .INT64: - return try makeTypedNumericScalar(from: value, type: fieldType, fieldName: fieldName) - case .BYTES, .STRING: - return try makeTypedStringScalar(from: value, type: fieldType, fieldName: fieldName) - default: - return nil - } - } - - /// Numeric branch of ``makeTypedScalar(from:type:fieldName:)`` — validates the value - /// is numeric, then returns a domain value for `TIMESTAMP`/`DOUBLE` or nil for `INT64` - /// (which defers to inference to avoid truncating a fractional number). - private static func makeTypedNumericScalar( - from value: Components.Schemas.FieldValueResponse.valuePayload, - type fieldType: Components.Schemas.FieldValueResponse._typePayload, - fieldName: String - ) throws(ConversionError) -> FieldValue? { - let number = try requireNumeric( - value, fieldName: fieldName, declaredType: fieldType.rawValue - ) - switch fieldType { - case .TIMESTAMP: + let declared = fieldType.rawValue + switch ResponseTypeTag(fieldType) { + case .numeric(.timestamp): + let number = try requireNumeric(value, fieldName: fieldName, declaredType: declared) return .date(Date(timeIntervalSince1970: number / 1_000)) - case .DOUBLE: - return .double(number) - default: + case .numeric(.double): + return .double(try requireNumeric(value, fieldName: fieldName, declaredType: declared)) + case .numeric(.int64): + // Validate the category, then defer to inference so a fractional number isn't truncated. + _ = try requireNumeric(value, fieldName: fieldName, declaredType: declared) + return nil + case .text(.bytes): + return .bytes(try requireString(value, fieldName: fieldName, declaredType: declared)) + case .text(.string): + // Validate the category, then defer to inference, which already produces `.string`. + _ = try requireString(value, fieldName: fieldName, declaredType: declared) + return nil + case .complex: return nil } } - /// String branch of ``makeTypedScalar(from:type:fieldName:)`` — validates the value - /// is a string, then returns a `.bytes` domain value for `BYTES` or nil for `STRING` - /// (which defers to inference, already producing `.string`). - private static func makeTypedStringScalar( - from value: Components.Schemas.FieldValueResponse.valuePayload, - type fieldType: Components.Schemas.FieldValueResponse._typePayload, - fieldName: String - ) throws(ConversionError) -> FieldValue? { - let string = try requireString( - value, fieldName: fieldName, declaredType: fieldType.rawValue - ) - if case .BYTES = fieldType { - return .bytes(string) - } - return nil - } - /// Require that `value` carries a number, throwing ``ConversionError/typeValueMismatch`` /// when a numeric `type` is declared over a non-numeric value. private static func requireNumeric( @@ -122,7 +176,7 @@ extension FieldValue { fieldName: String, declaredType: String ) throws(ConversionError) -> Double { - guard let number = numericValue(from: value) else { + guard let number = ScalarPayload(value)?.number else { let failure = ConversionError.typeValueMismatch( fieldName: fieldName, declaredType: declaredType, @@ -140,7 +194,7 @@ extension FieldValue { fieldName: String, declaredType: String ) throws(ConversionError) -> String { - guard let string = stringValue(from: value) else { + guard let string = ScalarPayload(value)?.text else { let failure = ConversionError.typeValueMismatch( fieldName: fieldName, declaredType: declaredType, @@ -157,50 +211,6 @@ extension FieldValue { private static func makeInferredScalar( from value: Components.Schemas.FieldValueResponse.valuePayload ) -> FieldValue? { - if case .StringValue(let strVal) = value { - return .string(strVal) - } - if case .Int64Value(let intVal) = value { - return .int64(Int(intVal)) - } - if case .DoubleValue(let dblVal) = value { - return .double(dblVal) - } - if case .BytesValue(let bytesVal) = value { - return .bytes(bytesVal) - } - if case .DateValue(let dateVal) = value { - return .date(Date(timeIntervalSince1970: dateVal / 1_000)) - } - return nil - } - - /// Extract a `Double` from any numeric `oneOf` case (Int64/Double/Date all arrive as numbers). - private static func numericValue( - from value: Components.Schemas.FieldValueResponse.valuePayload - ) -> Double? { - if case .Int64Value(let intVal) = value { - return Double(intVal) - } - if case .DoubleValue(let dblVal) = value { - return dblVal - } - if case .DateValue(let dateVal) = value { - return dateVal - } - return nil - } - - /// Extract a `String` from any string-backed `oneOf` case (String/Bytes both arrive as strings). - private static func stringValue( - from value: Components.Schemas.FieldValueResponse.valuePayload - ) -> String? { - if case .StringValue(let strVal) = value { - return strVal - } - if case .BytesValue(let bytesVal) = value { - return bytesVal - } - return nil + ScalarPayload(value)?.inferred } } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift index 23bc720f..ad318b3a 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift @@ -32,39 +32,6 @@ internal import MistKitOpenAPI /// Extension to convert OpenAPI Components.Schemas.FieldValueResponse to MistKit FieldValue extension FieldValue { - /// The decoded `value` case a complex/list `FieldValueResponse` `type` tag requires (#376). - private enum ExpectedComplexValue { - case reference - case asset - case location - case list - - /// Maps a complex/list response `type` tag to its expected value case; `nil` for a scalar - /// tag (handled by `makeTypedScalar`). `ASSETID` shares `AssetValue` with `ASSET`. - fileprivate init?(_ fieldType: Components.Schemas.FieldValueResponse._typePayload) { - switch fieldType { - case .REFERENCE: self = .reference - case .ASSET, .ASSETID: self = .asset - case .LOCATION: self = .location - case .LIST: self = .list - default: return nil - } - } - - /// Whether `value`'s decoded `oneOf` case satisfies this declared complex/list tag. - fileprivate func matches( - _ value: Components.Schemas.FieldValueResponse.valuePayload - ) -> Bool { - switch (self, value) { - case (.reference, .ReferenceValue), (.asset, .AssetValue), - (.location, .LocationValue), (.list, .ListValue): - return true - default: - return false - } - } - } - /// Initialize from OpenAPI Components.Schemas.FieldValueResponse (from API responses). /// /// - Parameters: @@ -191,7 +158,7 @@ extension FieldValue { fieldName: String ) throws(ConversionError) -> FieldValue? { // A nil or scalar `type` is not our concern — defer to scalar typing / inference. - guard let fieldType, let expected = ExpectedComplexValue(fieldType) else { + guard let fieldType, case .complex(let expected) = ResponseTypeTag(fieldType) else { return nil } // The value's decoded shape must satisfy the declared complex/list tag; a contradiction diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift b/Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift new file mode 100644 index 00000000..58be0c2b --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/FieldValue+ResponseTypeTag.swift @@ -0,0 +1,102 @@ +// +// FieldValue+ResponseTypeTag.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// Classification of an explicit CloudKit response `type` tag. +extension FieldValue { + /// A declared `FieldValueResponse` `type`, grouped by the value category it demands. + /// + /// Every conversion decision keyed on a response `type` routes through ``init(_:)`` — the + /// single, total, `default`-free mapping from the generated `_typePayload`. Adding a tag to + /// the OpenAPI spec therefore breaks the build here until the new tag is classified, rather + /// than silently falling into a `default` branch, which is what keeps the response + /// conversions exhaustive at compile time. + internal enum ResponseTypeTag: Hashable, Sendable { + /// A tag that requires a numeric value (`TIMESTAMP`, `DOUBLE`, `INT64`). + case numeric(NumericScalarTag) + /// A tag that requires a string-backed value (`BYTES`, `STRING`). + case text(TextScalarTag) + /// A tag that requires a structured value (`REFERENCE`, `ASSET`/`ASSETID`, `LOCATION`, + /// `LIST`). + case complex(ExpectedComplexValue) + + /// Classify a declared response `type`. + /// + /// `ASSETID` shares `AssetValue` — and therefore the `.asset` classification — with + /// `ASSET`; there is no distinct domain case for it. + internal init(_ fieldType: Components.Schemas.FieldValueResponse._typePayload) { + switch fieldType { + case .TIMESTAMP: self = .numeric(.timestamp) + case .DOUBLE: self = .numeric(.double) + case .INT64: self = .numeric(.int64) + case .BYTES: self = .text(.bytes) + case .STRING: self = .text(.string) + case .REFERENCE: self = .complex(.reference) + case .ASSET, .ASSETID: self = .complex(.asset) + case .LOCATION: self = .complex(.location) + case .LIST: self = .complex(.list) + } + } + } + + /// A declared scalar `type` whose value must be numeric. + internal enum NumericScalarTag: Hashable, Sendable { + case timestamp + case double + case int64 + } + + /// A declared scalar `type` whose value must be string-backed. + internal enum TextScalarTag: Hashable, Sendable { + case bytes + case string + } + + /// The decoded `value` case a complex/list `FieldValueResponse` `type` tag requires (#376). + internal enum ExpectedComplexValue: Hashable, Sendable { + case reference + case asset + case location + case list + + /// Whether `value`'s decoded `oneOf` case satisfies this declared complex/list tag. + internal func matches( + _ value: Components.Schemas.FieldValueResponse.valuePayload + ) -> Bool { + switch (self, value) { + case (.reference, .ReferenceValue), (.asset, .AssetValue), + (.location, .LocationValue), (.list, .ListValue): + return true + default: + return false + } + } + } +} diff --git a/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift index bd4c03df..faeebb0e 100644 --- a/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift +++ b/Sources/MistKit/Models/Queries/FilterBuilder/FilterBuilder.swift @@ -172,41 +172,34 @@ internal struct FilterBuilder { return cloudKitListType(for: first) } + /// Maps a single element to the `*_LIST` request type its list requires. + /// + /// The `switch` is `default`-free so a new `FieldValue` case has to be classified here + /// rather than silently emitting no `type` tag. private static func cloudKitListType( for first: FieldValue ) -> Components.Schemas.FieldValueRequest._typePayload? { - if case .string = first { + switch first { + case .string: return .STRING_LIST - } - if case .int64 = first { + case .int64: return .INT64_LIST - } - if case .double = first { + case .double: return .DOUBLE_LIST - } - if case .bytes = first { + case .bytes: return .BYTES_LIST - } - if case .date = first { + case .date: return .TIMESTAMP_LIST - } - return cloudKitComplexListType(for: first) - } - - private static func cloudKitComplexListType( - for first: FieldValue - ) -> Components.Schemas.FieldValueRequest._typePayload? { - if case .reference = first { + case .reference: return .REFERENCE_LIST - } - if case .location = first { + case .location: return .LOCATION_LIST - } - if case .asset = first { + case .asset: return .ASSET_LIST + case .list: + // Nested lists aren't valid in IN/NOT_IN; omit the type and let CloudKit reject + // rather than emit an undocumented bare "LIST" tag. + return nil } - // Nested lists aren't valid in IN/NOT_IN; omit the type and let CloudKit reject - // rather than emit an undocumented bare "LIST" tag. - return nil } } diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index 037de292..076f88e7 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -40,11 +40,38 @@ extension Components.Schemas.FieldValueRequest { /// `INT64`/`DOUBLE` number or a `STRING`. For those we tag `type` so CloudKit doesn't /// infer the wrong type and reject the write with `BAD_REQUEST`. Object/array-shaped /// values (reference, asset, location, list) are unambiguous and stay untagged. + /// + /// The `switch` is deliberately `default`-free: it is the single dispatch point from the + /// domain enum to the wire representation, so a new `FieldValue` case breaks the build here + /// instead of silently falling into a catch-all. internal init(from fieldValue: FieldValue) { - if let scalar = Self.makeScalarRequest(from: fieldValue) { - self = scalar - } else { - self = Self.makeComplexRequest(from: fieldValue) + switch fieldValue { + case .string(let value): + self.init(value: .StringValue(value)) + case .int64(let value): + self.init(value: .Int64Value(Int64(value))) + case .double(let value): + // Whole-valued doubles serialize without a fraction and would be read as INT64. + self.init(value: .DoubleValue(value), _type: .DOUBLE) + case .bytes(let value): + // A base64 string is otherwise indistinguishable from a STRING. + self.init(value: .BytesValue(value), _type: .BYTES) + case .date(let value): + // Tag TIMESTAMP (else inferred as INT64/DOUBLE) and round to whole milliseconds: + // CloudKit rejects a fractional TIMESTAMP value (e.g. 1747999812347.89) with + // BAD_REQUEST "expected type TIMESTAMP", and Date carries sub-millisecond precision. + self.init( + value: .DateValue((value.timeIntervalSince1970 * 1_000).rounded()), + _type: .TIMESTAMP + ) + case .location(let location): + self.init(location: location) + case .reference(let reference): + self.init(reference: reference) + case .asset(let asset): + self.init(asset: asset) + case .list(let list): + self.init(list: list) } } @@ -101,46 +128,4 @@ extension Components.Schemas.FieldValueRequest { let listValues = list.map { Components.Schemas.ListValuePayload(from: $0) } self.init(value: .ListValue(listValues)) } - - private static func makeScalarRequest(from fieldValue: FieldValue) -> Self? { - if case .string(let value) = fieldValue { - return Self(value: .StringValue(value)) - } - if case .int64(let value) = fieldValue { - return Self(value: .Int64Value(Int64(value))) - } - if case .double(let value) = fieldValue { - // Whole-valued doubles serialize without a fraction and would be read as INT64. - return Self(value: .DoubleValue(value), _type: .DOUBLE) - } - if case .bytes(let value) = fieldValue { - // A base64 string is otherwise indistinguishable from a STRING. - return Self(value: .BytesValue(value), _type: .BYTES) - } - if case .date(let value) = fieldValue { - // Tag TIMESTAMP (else inferred as INT64/DOUBLE) and round to whole milliseconds: - // CloudKit rejects a fractional TIMESTAMP value (e.g. 1747999812347.89) with - // BAD_REQUEST "expected type TIMESTAMP", and Date carries sub-millisecond precision. - return Self( - value: .DateValue((value.timeIntervalSince1970 * 1_000).rounded()), - _type: .TIMESTAMP - ) - } - return nil - } - - private static func makeComplexRequest(from fieldValue: FieldValue) -> Self { - switch fieldValue { - case .location(let location): - return Self(location: location) - case .reference(let reference): - return Self(reference: reference) - case .asset(let asset): - return Self(asset: asset) - case .list(let list): - return Self(list: list) - default: - return Self(value: .ListValue([])) - } - } } diff --git a/docs/internals/field-type-polymorphism.md b/docs/internals/field-type-polymorphism.md index 610d4a09..a9aacd67 100644 --- a/docs/internals/field-type-polymorphism.md +++ b/docs/internals/field-type-polymorphism.md @@ -99,15 +99,17 @@ No discriminator field is needed in the JSON — the generator relies on structu ```swift internal init(from fieldValue: FieldValue) { - if let scalar = Self.makeScalarRequest(from: fieldValue) { - self = scalar - } else { - self = Self.makeComplexRequest(from: fieldValue) + switch fieldValue { + case .string(let value): self.init(value: .StringValue(value)) + // ... one case per FieldValue case, no `default` + case .list(let list): self.init(list: list) } } ``` -Scalar conversion handles the simple cases (string, int64, double, bytes, date). Complex conversion handles location, reference, asset, and list. Date values are converted from `Date` to milliseconds: +A single `default`-free switch covers the simple cases (string, int64, double, bytes, date) and +the complex ones (location, reference, asset, list), so a new `FieldValue` case is a compile +error rather than a silent fallback. Date values are converted from `Date` to milliseconds: ```swift case .date(let value): From 62964ba066f3dab4def3f99d046ae3b647b44834 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:57:56 +0000 Subject: [PATCH 06/11] Refactor CloudKitError to per-serverErrorCode cases (#358) The eleven documented `serverErrorCode` values that still landed in `.httpErrorWithDetails` now each get a dedicated `CloudKitError` case, so consumers pattern-match by intent instead of string-matching a wire code: ACCESS_DENIED -> .accessDenied(reason:) AUTHENTICATION_FAILED -> .authenticationFailed(reason:) AUTHENTICATION_REQUIRED -> .authenticationRequired(reason:) CONFLICT -> .conflict(reason:) EXISTS -> .exists(reason:) INTERNAL_ERROR -> .internalServerError(reason:) NOT_FOUND -> .notFound(reason:) THROTTLED -> .throttled(reason:) TRY_AGAIN_LATER -> .tryAgainLater(reason:) VALIDATING_REFERENCE_ERROR -> .validatingReferenceError(reason:) ZONE_NOT_FOUND -> .zoneNotFound(reason:) together with the three added in #357 (QUOTA_EXCEEDED, BAD_REQUEST, ATOMIC_ERROR), that covers all fourteen codes enumerated by `ErrorResponse.serverErrorCode` in openapi.yaml. A code MistKit does not model becomes the new `.unknownServerError(code:statusCode:reason:)`, keeping the raw string and the status actually observed, so a future spec revision loses nothing. `.httpErrorWithDetails` is kept but narrowed to `(statusCode:reason:)`: it now means "an HTTP failure whose CloudKit JSON body carried no serverErrorCode". Dropping the `serverErrorCode: String?` payload is what makes the refactor airtight -- with every real code routed to a dedicated case, no `CloudKitError` case hands a caller a code string to switch on, and the `reason` from a codeless body is still preserved rather than degraded to a bare `.httpError`. Supporting changes: - `ServerErrorCodeDetail` + `CloudKitError.serverErrorDetail` hold the single exhaustive case -> (code, documented HTTP status, summary) table. Adding a case to `CloudKitError` fails to compile until it is classified there. - `CloudKitError.init(serverErrorCode:statusCode:reason:)` is the single code -> case dispatch; `init(_:statusCode:)` just delegates to it. - `httpStatusCode` and `errorDescription` are both derived from that table, so descriptions read uniformly ("CloudKit not found (HTTP 404 / NOT_FOUND)"). - New public `serverErrorCode: String?` reads the raw code back off any coded case for logging, documented as diagnostics-only. Migrated every site that matched on `.httpErrorWithDetails(_, "", _)`: MistDemo's delete/update conflict mapping, its error demo output, and the two integration phases that tolerate a 404; CelestraCloud's retriability check now keys off `httpStatusCode`, which picks up `.throttled` / `.tryAgainLater` / `.internalServerError` correctly for the first time. Tests: a parameterized MockTransport roundtrip over all fourteen codes asserts case identity, `serverErrorCode`, `httpStatusCode`, and description; plus forward-compat coverage for `.unknownServerError`, for a codeless body, and for what an unmodelled code does end-to-end today (the generated closed enum rejects it at decode time, so it surfaces as `.decodingError` -- never as a wrong modelled case). This is a deliberate breaking API change. Co-Authored-By: Claude Opus 5 --- .../Services/CelestraError.swift | 15 +- .../MistDemoKit/Commands/DeleteCommand.swift | 8 +- .../Commands/DemoErrorsRunner+Output.swift | 23 +-- .../MistDemoKit/Commands/UpdateCommand.swift | 4 +- .../Phases/QueryRecordsPhase.swift | 8 +- .../Phases/QueryRequestOptionsPhase.swift | 2 +- .../DeleteCommandMapConflictTests.swift | 26 ++- .../CloudKitError+ErrorDescription.swift | 49 ++---- .../CloudKitError+OpenAPI.swift | 34 ++-- .../CloudKitError+ServerErrorCode.swift | 156 ++++++++++++++++++ .../CloudKitService/CloudKitError.swift | 66 ++++++-- .../ServerErrorCodeDetail.swift | 52 ++++++ .../CloudKitLimitsAndPerformance.md | 2 +- .../Documentation.docc/ConfiguringMistKit.md | 2 +- .../GeneratedCodeAnalysis.md | 8 +- .../Documentation.docc/HandlingErrors.md | 59 ++++++- ...erverErrorCodes+ForwardCompatibility.swift | 127 ++++++++++++++ ...erviceTests.ServerErrorCodes+Helpers.swift | 80 +++++++++ ...viceTests.ServerErrorCodes+Roundtrip.swift | 73 ++++++++ ...loudKitServiceTests.ServerErrorCodes.swift | 77 +++++++++ ...dKitServiceTests.Tokens+FailureCases.swift | 10 +- ...KitServiceTests.Upload+ErrorHandling.swift | 8 +- docs/internals/error-code-parsing.md | 46 +++++- 23 files changed, 803 insertions(+), 132 deletions(-) create mode 100644 Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift create mode 100644 Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift create mode 100644 Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift create mode 100644 Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Helpers.swift create mode 100644 Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift create mode 100644 Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes.swift diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift index edbcd542..d46bd60f 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift @@ -138,19 +138,22 @@ public enum CelestraError: LocalizedError { /// Determines if a CloudKit error is retriable based on error type private func isCloudKitErrorRetriable(_ error: CloudKitError) -> Bool { - switch error { - case .httpError(let statusCode), - .httpErrorWithDetails(let statusCode, _, _), - .httpErrorWithRawResponse(let statusCode, _): + // `httpStatusCode` covers the raw HTTP cases *and* every case that models a + // CloudKit `serverErrorCode` (`.throttled`, `.tryAgainLater`, + // `.internalServerError`, …), each reporting its documented status. + if let statusCode = error.httpStatusCode { // Retry on server errors (5xx) and rate limiting (429) // Don't retry on client errors (4xx) except 429 return statusCode >= 500 || statusCode == 429 + } + + switch error { // Network-related/transient errors are retriable case .invalidResponse, .underlyingError, .networkError: return true - // Everything else (decoding, configuration, credential, malformed-request, - // and quota errors) is not retriable. + // Everything else (decoding, configuration, and credential errors) is not + // retriable. default: return false } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift index 4bcbc8b8..af125cf9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift @@ -72,10 +72,14 @@ public struct DeleteCommand: MistDemoCommand, OutputFormatting { guard error.httpStatusCode == 409 else { return nil } - if case .httpErrorWithDetails(_, _, let reason) = error { + switch error { + case .conflict(let reason), .exists(let reason): return .conflict(reason: reason) + case .httpErrorWithDetails(_, let reason): + return .conflict(reason: reason) + default: + return .conflict(reason: nil) } - return .conflict(reason: nil) } /// Executes the command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift index 003c0348..8feaf0e4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift @@ -56,25 +56,10 @@ extension DemoErrorsRunner { let status = error.httpStatusCode.map(String.init) ?? "n/a" let prefix = error.httpStatusCode == expectedStatus ? "✅" : "❌" print("\(prefix) Caught CloudKitError — status: \(status)") - switch error { - case .httpErrorWithDetails(_, let serverErrorCode, let reason): - print(" serverErrorCode: \(serverErrorCode ?? "")") - print(" reason: \(reason ?? "")") - case .badRequest(let reason): - print(" serverErrorCode: BAD_REQUEST") - print(" reason: \(reason ?? "")") - case .quotaExceeded(let reason, let hint): - print(" serverErrorCode: QUOTA_EXCEEDED") - print(" reason: \(reason ?? "")") - if let hint { - print(" hint: \(hint.description)") - } - case .atomicFailure(let reason): - print(" serverErrorCode: ATOMIC_ERROR") - print(" reason: \(reason ?? "")") - default: - print(" detail: \(error.localizedDescription)") - } + // Every documented serverErrorCode now has its own CloudKitError case, so + // the code is read off the error rather than pattern-matched per case. + print(" serverErrorCode: \(error.serverErrorCode ?? "")") + print(" detail: \(error.localizedDescription)") } internal func describe(_ tag: String?) -> String { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift index 9fd3c493..69fd6e0a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift @@ -77,10 +77,12 @@ public struct UpdateCommand: MistDemoCommand, OutputFormatting { _ error: CloudKitError ) -> UpdateError? { switch error { + case .conflict(let reason), .exists(let reason): + return .conflict(reason: reason) case .httpError(let statusCode) where statusCode == 409: return .conflict(reason: nil) case .httpErrorWithDetails( - let statusCode, _, let reason + let statusCode, let reason ) where statusCode == 409: return .conflict(reason: reason) case .httpErrorWithRawResponse( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift index 3d185d42..3a312ff4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift @@ -54,10 +54,10 @@ internal struct QueryRecordsPhase: IntegrationPhase { print(" Found \(ours.count) of our \(input.names.count) test records") } } catch { - // Workaround for Swift 6.3 SIL miscompile (MandatoryAllocBoxToStack) — - // a literal in a destructured-enum `catch` pattern crashes the pass on - // this branch. See SWIFT_COMPILER_BUG.md. Match via `guard case` instead. - guard case CloudKitError.httpErrorWithDetails(statusCode: 404, _, _) = error else { + // `NOT_FOUND` now has its own case, so no enum destructuring with a + // literal is needed here — which also sidesteps the Swift 6.3 SIL + // miscompile (MandatoryAllocBoxToStack) noted in SWIFT_COMPILER_BUG.md. + guard case CloudKitError.notFound = error else { throw error } // Schema propagation in development can lag behind the first write. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRequestOptionsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRequestOptionsPhase.swift index def6ec30..1f521c2b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRequestOptionsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRequestOptionsPhase.swift @@ -82,7 +82,7 @@ internal struct QueryRequestOptionsPhase: IntegrationPhase { // Schema propagation in development can lag behind the first write, so a // freshly-created record type may still 404. LookupRecordsPhase already // proves the records exist by name; treat the lag as non-fatal here. - guard case CloudKitError.httpErrorWithDetails(statusCode: 404, _, _) = error else { + guard case CloudKitError.notFound = error else { throw error } print("⚠️ queryRecords returned NOT_FOUND — schema may not be indexed yet (non-fatal)") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandMapConflictTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandMapConflictTests.swift index 37ceb6df..6d9bf954 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandMapConflictTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/DeleteCommandMapConflictTests.swift @@ -44,12 +44,30 @@ internal struct DeleteCommandMapConflictTests { #expect(reason == nil) } - @Test("Maps httpErrorWithDetails 409 to .conflict including the reason") + @Test("Maps the CONFLICT server code to .conflict including the reason") + internal func conflictServerCode() { + let result = DeleteCommand.mapConflict(.conflict(reason: "Change tag mismatch")) + guard case .conflict(let reason) = result else { + Issue.record("Expected .conflict, got \(String(describing: result))") + return + } + #expect(reason == "Change tag mismatch") + } + + @Test("Maps the EXISTS server code to .conflict including the reason") + internal func existsServerCode() { + let result = DeleteCommand.mapConflict(.exists(reason: "record already exists")) + guard case .conflict(let reason) = result else { + Issue.record("Expected .conflict, got \(String(describing: result))") + return + } + #expect(reason == "record already exists") + } + + @Test("Maps a codeless httpErrorWithDetails 409 to .conflict including the reason") internal func httpErrorWithDetails409() { let result = DeleteCommand.mapConflict( - .httpErrorWithDetails( - statusCode: 409, serverErrorCode: "ATOMIC_ERROR", reason: "Change tag mismatch" - ) + .httpErrorWithDetails(statusCode: 409, reason: "Change tag mismatch") ) guard case .conflict(let reason) = result else { Issue.record("Expected .conflict, got \(String(describing: result))") diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift index 318a57a6..1da2ae11 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift @@ -39,9 +39,9 @@ extension CloudKitError { switch self { case .httpError(let statusCode): return "CloudKit API error: HTTP \(statusCode)" - case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason): - return Self.httpDetailsDescription( - statusCode: statusCode, serverErrorCode: serverErrorCode, reason: reason + case .httpErrorWithDetails(let statusCode, let reason): + return Self.simpleReasonDescription( + prefix: "CloudKit API error: HTTP \(statusCode)", reason: reason ) case .httpErrorWithRawResponse(let statusCode, let rawResponse): return "CloudKit API error: HTTP \(statusCode)\nRaw Response: \(rawResponse)" @@ -82,29 +82,27 @@ extension CloudKitError { let location = path.map { "from '\($0)'" } ?? "from inline material" return "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" - case .quotaExceeded(let reason, let hint): - return Self.quotaExceededDescription(reason: reason, hint: hint) - case .badRequest(let reason): - return Self.simpleReasonDescription( - prefix: "CloudKit bad request (HTTP 400 / BAD_REQUEST)", reason: reason - ) - case .atomicFailure(let reason): - return Self.simpleReasonDescription( - prefix: "CloudKit atomic batch failure (HTTP 400 / ATOMIC_ERROR)", reason: reason - ) + case .accessDenied, .atomicFailure, .authenticationFailed, .authenticationRequired, + .badRequest, .conflict, .exists, .internalServerError, .notFound, .quotaExceeded, + .throttled, .tryAgainLater, .validatingReferenceError, .zoneNotFound, + .unknownServerError: + return serverErrorCodeDescription } } - private static func httpDetailsDescription( - statusCode: Int, serverErrorCode: String?, reason: String? - ) -> String { - var message = "CloudKit API error: HTTP \(statusCode)" - if let serverErrorCode { - message += "\nServer Error Code: \(serverErrorCode)" + /// Uniform description for every case that models a CloudKit + /// `serverErrorCode`, built from ``CloudKitError/serverErrorDetail``. + private var serverErrorCodeDescription: String? { + guard let detail = serverErrorDetail else { + return nil } - if let reason { + var message = "CloudKit \(detail.summary) (HTTP \(detail.statusCode) / \(detail.code))" + if let reason = detail.reason { message += "\nReason: \(reason)" } + if case .quotaExceeded(_, let hint) = self, let hint { + message += "\nHint: \(hint.description)" + } return message } @@ -204,17 +202,6 @@ extension CloudKitError { + "(\(availabilityLabel)): \(reason)" } - private static func quotaExceededDescription(reason: String?, hint: QuotaHint?) -> String { - var message = "CloudKit quota exceeded (HTTP 413 / QUOTA_EXCEEDED)" - if let reason { - message += "\nReason: \(reason)" - } - if let hint { - message += "\nHint: \(hint.description)" - } - return message - } - private static func simpleReasonDescription(prefix: String, reason: String?) -> String { var message = prefix if let reason { diff --git a/Sources/MistKit/CloudKitService/CloudKitError+OpenAPI.swift b/Sources/MistKit/CloudKitService/CloudKitError+OpenAPI.swift index dcfbfb57..b62c3ba3 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+OpenAPI.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+OpenAPI.swift @@ -44,32 +44,18 @@ extension CloudKitError { /// The body schema is identical across status codes — only the code /// disambiguates which CloudKit failure occurred, so the caller supplies it. /// - /// Three server codes are surfaced as dedicated cases: - /// - `QUOTA_EXCEEDED` → `.quotaExceeded(reason:, hint: nil)` — the catch - /// block in the calling operation may enrich `hint` from local context. - /// - `BAD_REQUEST` → `.badRequest(reason:)` - /// - `ATOMIC_ERROR` → `.atomicFailure(reason:)` - /// - /// Every other server code lands in `.httpErrorWithDetails`. + /// Every documented `serverErrorCode` gets its own case, an unrecognized code + /// becomes `.unknownServerError`, and a body with no code at all becomes + /// `.httpErrorWithDetails`. See + /// `CloudKitError.init(serverErrorCode:statusCode:reason:)`. internal init(_ response: Components.Responses.Failure, statusCode: Int) { switch response.body { case .json(let errorResponse): - let code = errorResponse.serverErrorCode?.rawValue - let reason = errorResponse.reason - switch code { - case "QUOTA_EXCEEDED": - self = .quotaExceeded(reason: reason, hint: nil) - case "BAD_REQUEST": - self = .badRequest(reason: reason) - case "ATOMIC_ERROR": - self = .atomicFailure(reason: reason) - default: - self = .httpErrorWithDetails( - statusCode: statusCode, - serverErrorCode: code, - reason: reason - ) - } + self.init( + serverErrorCode: errorResponse.serverErrorCode?.rawValue, + statusCode: statusCode, + reason: errorResponse.reason + ) } } @@ -107,7 +93,7 @@ extension CloudKitError { return .quotaExceeded(reason: reason, hint: hint) case .httpError(let statusCode) where statusCode == 413: return .quotaExceeded(reason: nil, hint: hint) - case .httpErrorWithDetails(let statusCode, _, let reason) where statusCode == 413: + case .httpErrorWithDetails(let statusCode, let reason) where statusCode == 413: return .quotaExceeded(reason: reason, hint: hint) default: return self diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift new file mode 100644 index 00000000..74a6272f --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift @@ -0,0 +1,156 @@ +// +// CloudKitError+ServerErrorCode.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 CloudKitError { + /// The raw CloudKit `serverErrorCode` this error carries, or `nil` when the + /// error did not originate from a coded CloudKit failure. + /// + /// Exposed for logging and diagnostics only. Pattern-match the dedicated + /// cases — ``CloudKitError/notFound(reason:)``, ``CloudKitError/throttled(reason:)`` + /// and friends — for control flow; the string is not a stable contract. + public var serverErrorCode: String? { + serverErrorDetail?.code + } + + /// Code, documented HTTP status, summary, and reason for every case that + /// models a CloudKit `serverErrorCode`; `nil` for all other cases. + /// + /// The switch is deliberately exhaustive: adding a case to ``CloudKitError`` + /// stops compiling here until the new case is classified. + // swiftlint:disable:next cyclomatic_complexity function_body_length + internal var serverErrorDetail: ServerErrorCodeDetail? { + switch self { + case .accessDenied(let reason): + return Self.detail("ACCESS_DENIED", 403, "access denied", reason) + case .atomicFailure(let reason): + return Self.detail("ATOMIC_ERROR", 400, "atomic batch failure", reason) + case .authenticationFailed(let reason): + return Self.detail("AUTHENTICATION_FAILED", 401, "authentication failed", reason) + case .authenticationRequired(let reason): + return Self.detail("AUTHENTICATION_REQUIRED", 421, "authentication required", reason) + case .badRequest(let reason): + return Self.detail("BAD_REQUEST", 400, "bad request", reason) + case .conflict(let reason): + return Self.detail("CONFLICT", 409, "conflict", reason) + case .exists(let reason): + return Self.detail("EXISTS", 409, "already exists", reason) + case .internalServerError(let reason): + return Self.detail("INTERNAL_ERROR", 500, "internal server error", reason) + case .notFound(let reason): + return Self.detail("NOT_FOUND", 404, "not found", reason) + case .quotaExceeded(let reason, _): + return Self.detail("QUOTA_EXCEEDED", 413, "quota exceeded", reason) + case .throttled(let reason): + return Self.detail("THROTTLED", 429, "throttled", reason) + case .tryAgainLater(let reason): + return Self.detail("TRY_AGAIN_LATER", 503, "try again later", reason) + case .validatingReferenceError(let reason): + return Self.detail("VALIDATING_REFERENCE_ERROR", 412, "reference validation error", reason) + case .zoneNotFound(let reason): + return Self.detail("ZONE_NOT_FOUND", 404, "zone not found", reason) + case .unknownServerError(let code, let statusCode, let reason): + return Self.detail(code, statusCode, "unrecognized server error", reason) + case .httpError, .httpErrorWithDetails, .httpErrorWithRawResponse, .invalidResponse, + .incompleteResponse, .conversionFailed, .recordOperationFailed, + .subscriptionOperationFailed, .subscriptionLikelyDuplicate, .underlyingError, + .decodingError, .networkError, .unsupportedOperationType, .paginationLimitExceeded, + .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey: + return nil + } + } + + /// Maps a CloudKit failure body onto the case that models its + /// `serverErrorCode`. + /// + /// - A `nil` code (a failure body that carried no code) becomes + /// ``CloudKitError/httpErrorWithDetails(statusCode:reason:)``, preserving + /// the server `reason`. + /// - Each of the fourteen codes documented in `openapi.yaml` becomes its own + /// dedicated case. + /// - Anything else becomes + /// ``CloudKitError/unknownServerError(code:statusCode:reason:)`` so a code + /// Apple adds after this release still reaches the caller intact. + /// + /// - Parameters: + /// - code: The raw `serverErrorCode` string from the failure body. + /// - statusCode: The HTTP status the failure arrived with. + /// - reason: The server-supplied `reason`, when present. + // swiftlint:disable:next cyclomatic_complexity + internal init(serverErrorCode code: String?, statusCode: Int, reason: String?) { + guard let code else { + self = .httpErrorWithDetails(statusCode: statusCode, reason: reason) + return + } + switch code { + case "ACCESS_DENIED": + self = .accessDenied(reason: reason) + case "ATOMIC_ERROR": + self = .atomicFailure(reason: reason) + case "AUTHENTICATION_FAILED": + self = .authenticationFailed(reason: reason) + case "AUTHENTICATION_REQUIRED": + self = .authenticationRequired(reason: reason) + case "BAD_REQUEST": + self = .badRequest(reason: reason) + case "CONFLICT": + self = .conflict(reason: reason) + case "EXISTS": + self = .exists(reason: reason) + case "INTERNAL_ERROR": + self = .internalServerError(reason: reason) + case "NOT_FOUND": + self = .notFound(reason: reason) + case "QUOTA_EXCEEDED": + // `hint` is enriched later by the calling operation's catch block, which + // is the only place that can see the local request state. + self = .quotaExceeded(reason: reason, hint: nil) + case "THROTTLED": + self = .throttled(reason: reason) + case "TRY_AGAIN_LATER": + self = .tryAgainLater(reason: reason) + case "VALIDATING_REFERENCE_ERROR": + self = .validatingReferenceError(reason: reason) + case "ZONE_NOT_FOUND": + self = .zoneNotFound(reason: reason) + default: + self = .unknownServerError(code: code, statusCode: statusCode, reason: reason) + } + } + + private static func detail( + _ code: String, + _ statusCode: Int, + _ summary: String, + _ reason: String? + ) -> ServerErrorCodeDetail { + ServerErrorCodeDetail( + code: code, statusCode: statusCode, summary: summary, reason: reason + ) + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index 08928e81..8e7b48c8 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -36,10 +36,13 @@ public import Foundation /// Represents errors that can occur when interacting with CloudKit Web Services public enum CloudKitError: LocalizedError, Sendable { case httpError(statusCode: Int) - /// Server-returned error for `serverErrorCode` values **other than** - /// `QUOTA_EXCEEDED`, `BAD_REQUEST`, and `ATOMIC_ERROR` — those have their own - /// dedicated cases (`.quotaExceeded`, `.badRequest`, `.atomicFailure`). - case httpErrorWithDetails(statusCode: Int, serverErrorCode: String?, reason: String?) + /// An HTTP failure whose CloudKit JSON body carried **no** `serverErrorCode`. + /// + /// Every documented `serverErrorCode` has its own case, and any code MistKit + /// does not recognize becomes ``CloudKitError/unknownServerError(code:statusCode:reason:)`` + /// — so this case never carries a server code and consumers never have to + /// string-match one. + case httpErrorWithDetails(statusCode: Int, reason: String?) case httpErrorWithRawResponse(statusCode: Int, rawResponse: String) /// HTTP 413 / `QUOTA_EXCEEDED`. Same server code is used for storage-quota /// exhaustion and per-record / per-asset size limits; `hint` (when non-nil) @@ -51,6 +54,41 @@ public enum CloudKitError: LocalizedError, Sendable { /// HTTP 400 / `ATOMIC_ERROR`. A `modifyRecords` call with `atomic: true` /// rolled back because at least one operation in the batch failed. case atomicFailure(reason: String?) + /// HTTP 403 / `ACCESS_DENIED`. The authenticated principal is not permitted + /// to perform the operation on the target container, database, or record. + case accessDenied(reason: String?) + /// HTTP 401 / `AUTHENTICATION_FAILED`. The supplied credentials were + /// rejected — expired web-auth token, bad signature, or unknown key ID. + case authenticationFailed(reason: String?) + /// HTTP 421 / `AUTHENTICATION_REQUIRED`. The request needs a user-attributed + /// credential; the caller must complete the web-auth sign-in flow. + case authenticationRequired(reason: String?) + /// HTTP 409 / `CONFLICT`. The supplied `recordChangeTag` did not match the + /// server's copy — the record changed since it was fetched. + case conflict(reason: String?) + /// HTTP 409 / `EXISTS`. A record or zone with the requested identifier + /// already exists. + case exists(reason: String?) + /// HTTP 500 / `INTERNAL_ERROR`. CloudKit failed on its side. + case internalServerError(reason: String?) + /// HTTP 404 / `NOT_FOUND`. The requested record, record type, or resource + /// does not exist. + case notFound(reason: String?) + /// HTTP 429 / `THROTTLED`. The caller is being rate limited; back off and + /// retry. + case throttled(reason: String?) + /// HTTP 503 / `TRY_AGAIN_LATER`. CloudKit is temporarily unavailable. + case tryAgainLater(reason: String?) + /// HTTP 412 / `VALIDATING_REFERENCE_ERROR`. A reference field pointed at a + /// record that failed validation — typically a missing target record. + case validatingReferenceError(reason: String?) + /// HTTP 404 / `ZONE_NOT_FOUND`. The named custom zone does not exist in the + /// target database. + case zoneNotFound(reason: String?) + /// A `serverErrorCode` MistKit does not model — Apple added a code after this + /// release. `code` is the raw wire string and `statusCode` the status that + /// actually accompanied it. + case unknownServerError(code: String, statusCode: Int, reason: String?) case invalidResponse /// A multi-step convenience (e.g. `rereferenceAsset`) received a structurally /// valid CloudKit response that lacked data it needed to proceed. `reason` @@ -95,21 +133,21 @@ public enum CloudKitError: LocalizedError, Sendable { case invalidPrivateKey(path: String?, underlying: any Error) /// HTTP status code if this error originated from an HTTP response, otherwise nil. + /// + /// For the cases that model a CloudKit `serverErrorCode` this is the status + /// Apple documents for that code, except for + /// ``unknownServerError(code:statusCode:reason:)``, which reports the status + /// actually observed on the wire. public var httpStatusCode: Int? { + if let serverErrorDetail { + return serverErrorDetail.statusCode + } switch self { case .httpError(let statusCode), - .httpErrorWithDetails(let statusCode, _, _), + .httpErrorWithDetails(let statusCode, _), .httpErrorWithRawResponse(let statusCode, _): return statusCode - case .quotaExceeded: - return 413 - case .badRequest, .atomicFailure: - return 400 - case .invalidResponse, .incompleteResponse, .conversionFailed, .recordOperationFailed, - .subscriptionOperationFailed, .subscriptionLikelyDuplicate, - .underlyingError, .decodingError, .networkError, - .unsupportedOperationType, .paginationLimitExceeded, - .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey: + default: return nil } } diff --git a/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift b/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift new file mode 100644 index 00000000..32d0c881 --- /dev/null +++ b/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift @@ -0,0 +1,52 @@ +// +// ServerErrorCodeDetail.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. +// + +/// Wire identity of a CloudKit failure that carried a `serverErrorCode`. +/// +/// Produced by `CloudKitError.serverErrorDetail` so that a code, the HTTP +/// status Apple documents for it, and the human summary used in error +/// descriptions all live in exactly one place. +internal struct ServerErrorCodeDetail: Sendable { + /// The raw CloudKit `serverErrorCode` string, e.g. `"ACCESS_DENIED"`. + internal let code: String + /// The HTTP status Apple documents for `code`. + internal let statusCode: Int + /// Lowercase human summary used when building the error description. + internal let summary: String + /// The server-supplied `reason`, when the failure body carried one. + internal let reason: String? + + /// Creates a detail describing one CloudKit `serverErrorCode` failure. + internal init(code: String, statusCode: Int, summary: String, reason: String?) { + self.code = code + self.statusCode = statusCode + self.summary = summary + self.reason = reason + } +} diff --git a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md index abe55f6c..09fa33e6 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md +++ b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md @@ -88,7 +88,7 @@ let receipt = try await service.uploadAssets( ) ``` -CloudKit imposes a per-asset size cap (in the tens of megabytes, exact figure documented in [CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices)). Oversized uploads surface as ``CloudKitError/httpErrorWithDetails(statusCode:serverErrorCode:reason:)`` from the CDN. +CloudKit imposes a per-asset size cap (in the tens of megabytes, exact figure documented in [CloudKit Web Services](https://developer.apple.com/documentation/cloudkitwebservices)). Oversized uploads surface as a bare ``CloudKitError/httpError(statusCode:)`` from the CDN, which returns raw HTTP errors rather than CloudKit's JSON failure body; the upload path upgrades a 413 to ``CloudKitError/quotaExceeded(reason:hint:)`` with the byte count attached. ## Rate limiting diff --git a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md index 918bfcba..1c5f2486 100644 --- a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -48,7 +48,7 @@ let environment: Environment = ProcessInfo.processInfo ``Environment/init(caseInsensitive:)`` accepts `"development"` / `"production"` regardless of letter case and returns `nil` on anything else, so a misspelled env var fails closed at startup rather than silently shipping a dev build to prod. -> Warning: CloudKit promotes schema from `development` to `production` explicitly via the Dashboard. Code referencing fields that exist only in dev will succeed against `.development` and fail against `.production` with ``CloudKitError/httpErrorWithDetails(statusCode:serverErrorCode:reason:)``. +> Warning: CloudKit promotes schema from `development` to `production` explicitly via the Dashboard. Code referencing fields that exist only in dev will succeed against `.development` and fail against `.production` with ``CloudKitError/badRequest(reason:)`` or ``CloudKitError/notFound(reason:)``, depending on which lookup misses. ## Database scope at configuration time diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md index 348735a6..338c7a3f 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -356,11 +356,9 @@ case .ok(let ok): for record in body.records ?? [] { /* read record */ } case .badRequest(let resp): let err = try resp.body.json - throw CloudKitError.httpErrorWithDetails( - statusCode: 400, - serverErrorCode: err.serverErrorCode?.rawValue, - reason: err.reason - ) + // …and now switch over err.serverErrorCode yourself to decide + // which failure this actually was. + throw CloudKitError.badRequest(reason: err.reason) case .undocumented(let code, _): throw CloudKitError.httpError(statusCode: code) // … five more cases … diff --git a/Sources/MistKit/Documentation.docc/HandlingErrors.md b/Sources/MistKit/Documentation.docc/HandlingErrors.md index 8ae8daae..85d83ace 100644 --- a/Sources/MistKit/Documentation.docc/HandlingErrors.md +++ b/Sources/MistKit/Documentation.docc/HandlingErrors.md @@ -78,7 +78,7 @@ Every operation on ``CloudKitService`` throws ``CloudKitError``. The cases group | Case | Recoverable? | When it fires | | --- | --- | --- | | ``CloudKitError/httpError(statusCode:)`` | Depends on status code | HTTP non-2xx without parseable body | -| ``CloudKitError/httpErrorWithDetails(statusCode:serverErrorCode:reason:)`` | Depends on `serverErrorCode` | CloudKit returned a structured error | +| ``CloudKitError/httpErrorWithDetails(statusCode:reason:)`` | Depends on status code | CloudKit failure body that carried **no** `serverErrorCode` | | ``CloudKitError/httpErrorWithRawResponse(statusCode:rawResponse:)`` | Sometimes | Validation rejection or unparseable error body | | ``CloudKitError/invalidResponse`` | No | Server returned 2xx but no payload | | ``CloudKitError/incompleteResponse(reason:)`` | No | A composed convenience got a valid response missing data it needed | @@ -107,6 +107,61 @@ do { } ``` +### Every documented `serverErrorCode` has its own case + +CloudKit's top-level failure body carries a `serverErrorCode`. Rather than hand +callers that string to match on, MistKit maps each of the fourteen codes the +OpenAPI spec enumerates onto a dedicated case: + +| `serverErrorCode` | HTTP | Case | +| --- | --- | --- | +| `ACCESS_DENIED` | 403 | ``CloudKitError/accessDenied(reason:)`` | +| `ATOMIC_ERROR` | 400 | ``CloudKitError/atomicFailure(reason:)`` | +| `AUTHENTICATION_FAILED` | 401 | ``CloudKitError/authenticationFailed(reason:)`` | +| `AUTHENTICATION_REQUIRED` | 421 | ``CloudKitError/authenticationRequired(reason:)`` | +| `BAD_REQUEST` | 400 | ``CloudKitError/badRequest(reason:)`` | +| `CONFLICT` | 409 | ``CloudKitError/conflict(reason:)`` | +| `EXISTS` | 409 | ``CloudKitError/exists(reason:)`` | +| `INTERNAL_ERROR` | 500 | ``CloudKitError/internalServerError(reason:)`` | +| `NOT_FOUND` | 404 | ``CloudKitError/notFound(reason:)`` | +| `QUOTA_EXCEEDED` | 413 | ``CloudKitError/quotaExceeded(reason:hint:)`` | +| `THROTTLED` | 429 | ``CloudKitError/throttled(reason:)`` | +| `TRY_AGAIN_LATER` | 503 | ``CloudKitError/tryAgainLater(reason:)`` | +| `VALIDATING_REFERENCE_ERROR` | 412 | ``CloudKitError/validatingReferenceError(reason:)`` | +| `ZONE_NOT_FOUND` | 404 | ``CloudKitError/zoneNotFound(reason:)`` | + +```swift +do { + try await service.deleteRecord( + recordType: "Note", + recordName: name, + recordChangeTag: tag, + database: .private + ) +} catch CloudKitError.conflict(let reason) { + // Someone else changed the record — refetch and merge. + logger.warning("Change tag stale: \(reason ?? "")") +} catch CloudKitError.throttled, CloudKitError.tryAgainLater { + // Back off and retry. +} +``` + +A code MistKit does not model — one Apple adds in a later spec revision — +becomes ``CloudKitError/unknownServerError(code:statusCode:reason:)`` with the +raw string and the status it arrived with, so nothing is lost. A failure body +with no `serverErrorCode` at all becomes +``CloudKitError/httpErrorWithDetails(statusCode:reason:)``, which keeps the +server `reason` but never carries a code. + +``CloudKitError/serverErrorCode`` reads the code back off any of these cases for +logging; use the cases themselves for control flow. + +> Note: ``CloudKitError/httpStatusCode`` reports the status Apple *documents* +> for a given code, not necessarily the status observed on the wire. The one +> exception is ``CloudKitError/unknownServerError(code:statusCode:reason:)``, +> which reports the observed status because there is nothing documented to +> report. + ### `paginationLimitExceeded` carries partial results ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks the continuation marker for you and stops at `maxPages` (default `1_000`) as a runaway guard. When it trips, the records collected so far are attached to the error so the caller can decide: @@ -126,7 +181,7 @@ do { ### Subscription duplicates surface as `INTERNAL_ERROR` -CloudKit Web Services enforces subscription uniqueness on the **`(recordType, firesOn)`** tuple, *not* on `subscriptionID`. A second subscription that repeats an existing `(recordType, firesOn)` pair under a *different* ID is rejected — but the rejection arrives as a generic ``CloudKitError/httpErrorWithDetails(statusCode:serverErrorCode:reason:)`` carrying `serverErrorCode` `INTERNAL_ERROR` and the misleading reason `"could not find subscription we just created"`. There is no formal `CONFLICT`/`EXISTS` server code for this case. +CloudKit Web Services enforces subscription uniqueness on the **`(recordType, firesOn)`** tuple, *not* on `subscriptionID`. A second subscription that repeats an existing `(recordType, firesOn)` pair under a *different* ID is rejected — but the rejection arrives as a generic ``CloudKitError/internalServerError(reason:)`` (`serverErrorCode` `INTERNAL_ERROR`) with the misleading reason `"could not find subscription we just created"`. CloudKit does not use its `CONFLICT`/`EXISTS` server codes for this case. MistKit infers the duplicate from that reason string and surfaces it through two hedged hints: diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift new file mode 100644 index 00000000..2657b872 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift @@ -0,0 +1,127 @@ +// +// CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.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 + +@testable import MistKit + +extension CloudKitServiceTests.ServerErrorCodes { + @Suite("Forward compatibility") + internal struct ForwardCompatibility { + /// A code Apple has not shipped — stands in for a future spec revision. + private static let futureCode = "SOME_FUTURE_CODE" + + @Test("An unmodelled serverErrorCode maps to .unknownServerError") + internal func unmodelledCodeMapsToUnknownServerError() throws { + let error = CloudKitError( + serverErrorCode: Self.futureCode, + statusCode: 418, + reason: "brand new failure" + ) + + guard case .unknownServerError(let code, let statusCode, let reason) = error else { + Issue.record("expected .unknownServerError, got \(error)") + return + } + #expect(code == Self.futureCode) + #expect(statusCode == 418) + #expect(reason == "brand new failure") + #expect(error.serverErrorCode == Self.futureCode) + #expect(error.httpStatusCode == 418) + + let description = try #require(error.errorDescription) + #expect(description.contains(Self.futureCode)) + #expect(description.contains("418")) + #expect(description.contains("brand new failure")) + } + + @Test("A failure body with no serverErrorCode keeps its reason in .httpErrorWithDetails") + internal func missingCodeMapsToHTTPErrorWithDetails() throws { + let error = CloudKitError( + serverErrorCode: nil, + statusCode: 500, + reason: "no code supplied" + ) + + guard case .httpErrorWithDetails(let statusCode, let reason) = error else { + Issue.record("expected .httpErrorWithDetails, got \(error)") + return + } + #expect(statusCode == 500) + #expect(reason == "no code supplied") + #expect(error.serverErrorCode == nil) + + let description = try #require(error.errorDescription) + #expect(description.contains("500")) + #expect(description.contains("no code supplied")) + } + + /// Documents the *current* end-to-end behavior for a code MistKit does not + /// model, which stops one layer short of `.unknownServerError`. + /// + /// `ErrorResponse.serverErrorCode` is a closed `enum` in `openapi.yaml`, so + /// swift-openapi-generator emits a closed Swift enum and an unrecognized + /// string fails to decode — the failure surfaces as `.decodingError` before + /// MistKit's mapping ever runs. The guarantee that matters here is the + /// negative one: an unmodelled code is never silently mistaken for a + /// modelled case. `.unknownServerError` is the seam that takes over the + /// moment the spec stops closing that enum; it is covered directly above. + @Test("An unmodelled serverErrorCode on the wire never matches a modelled case") + internal func unmodelledCodeOverTheWire() 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.ServerErrorCodes.makeService( + statusCode: 500, + serverErrorCode: Self.futureCode, + reason: "brand new failure" + ) + + do { + _ = try await service.queryRecords( + recordType: "Note", + database: .public(.prefers(.serverToServer)) + ) + Issue.record("expected queryRecords to throw") + } catch let error as CloudKitError { + #expect( + error.serverErrorCode == nil, + "an unmodelled code must not be mistaken for a modelled one" + ) + guard case .decodingError = error else { + Issue.record( + "expected .decodingError while the generated enum stays closed, got \(error)" + ) + return + } + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Helpers.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Helpers.swift new file mode 100644 index 00000000..4c8bfbb8 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Helpers.swift @@ -0,0 +1,80 @@ +// +// CloudKitServiceTests.ServerErrorCodes+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.ServerErrorCodes { + /// A service whose transport answers every request with a CloudKit failure + /// body carrying `serverErrorCode` at `statusCode`. + internal static func makeService( + statusCode: Int, + serverErrorCode: String, + reason: String + ) throws -> CloudKitService { + let provider = ResponseProvider( + defaultResponse: .cloudKitError( + statusCode: statusCode, + serverErrorCode: serverErrorCode, + reason: reason + ) + ) + return try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: MockTransport(responseProvider: provider) + ) + } + + /// Payload-free label for the case `error` actually landed in, so a test can + /// assert on case identity without requiring `CloudKitError: Equatable`. + // swiftlint:disable:next cyclomatic_complexity + internal static func caseLabel(of error: CloudKitError) -> String { + switch error { + case .accessDenied: return "accessDenied" + case .atomicFailure: return "atomicFailure" + case .authenticationFailed: return "authenticationFailed" + case .authenticationRequired: return "authenticationRequired" + case .badRequest: return "badRequest" + case .conflict: return "conflict" + case .exists: return "exists" + case .internalServerError: return "internalServerError" + case .notFound: return "notFound" + case .quotaExceeded: return "quotaExceeded" + case .throttled: return "throttled" + case .tryAgainLater: return "tryAgainLater" + case .validatingReferenceError: return "validatingReferenceError" + case .zoneNotFound: return "zoneNotFound" + case .unknownServerError: return "unknownServerError" + case .httpErrorWithDetails: return "httpErrorWithDetails" + default: return "other(\(error))" + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift new file mode 100644 index 00000000..2b8bb665 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift @@ -0,0 +1,73 @@ +// +// CloudKitServiceTests.ServerErrorCodes+Roundtrip.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 + +@testable import MistKit + +extension CloudKitServiceTests.ServerErrorCodes { + @Suite("Roundtrip") + internal struct Roundtrip { + @Test( + "Each documented serverErrorCode surfaces as its dedicated case", + arguments: CloudKitServiceTests.ServerErrorCodes.expectations + ) + internal func documentedCodeMapsToDedicatedCase( + _ expectation: CloudKitServiceTests.ServerErrorCodes.Expectation + ) 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 reason = "reason for \(expectation.code)" + let service = try CloudKitServiceTests.ServerErrorCodes.makeService( + statusCode: expectation.statusCode, + serverErrorCode: expectation.code, + reason: reason + ) + + do { + _ = try await service.queryRecords( + recordType: "Note", + database: .public(.prefers(.serverToServer)) + ) + Issue.record("expected queryRecords to throw for \(expectation.code)") + } catch let error as CloudKitError { + #expect( + CloudKitServiceTests.ServerErrorCodes.caseLabel(of: error) == expectation.caseLabel + ) + #expect(error.serverErrorCode == expectation.code) + #expect(error.httpStatusCode == expectation.statusCode) + let description = try #require(error.errorDescription) + #expect(description.contains(expectation.code)) + #expect(description.contains(reason)) + } + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes.swift new file mode 100644 index 00000000..d62f87bc --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes.swift @@ -0,0 +1,77 @@ +// +// CloudKitServiceTests.ServerErrorCodes.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 + +@testable import MistKit + +extension CloudKitServiceTests { + /// Roundtrip coverage for every documented CloudKit `serverErrorCode`: a + /// mock transport returns the coded failure body and the operation must + /// surface the dedicated ``CloudKitError`` case for it. + @Suite("CloudKitService serverErrorCode mapping", .enabled(if: Platform.isCryptoAvailable)) + internal enum ServerErrorCodes { + /// One row of the roundtrip table: the wire code, the HTTP status it + /// arrives with, and the ``CloudKitError`` case label expected back. + internal struct Expectation: Sendable, CustomStringConvertible { + internal let code: String + internal let statusCode: Int + internal let caseLabel: String + + internal var description: String { + "\(code) → .\(caseLabel)" + } + + internal init(_ code: String, _ statusCode: Int, _ caseLabel: String) { + self.code = code + self.statusCode = statusCode + self.caseLabel = caseLabel + } + } + + /// Every code enumerated by the `ErrorResponse.serverErrorCode` enum in + /// `openapi.yaml`, paired with the case it must map to. + internal static let expectations: [Expectation] = [ + Expectation("ACCESS_DENIED", 403, "accessDenied"), + Expectation("ATOMIC_ERROR", 400, "atomicFailure"), + Expectation("AUTHENTICATION_FAILED", 401, "authenticationFailed"), + Expectation("AUTHENTICATION_REQUIRED", 421, "authenticationRequired"), + Expectation("BAD_REQUEST", 400, "badRequest"), + Expectation("CONFLICT", 409, "conflict"), + Expectation("EXISTS", 409, "exists"), + Expectation("INTERNAL_ERROR", 500, "internalServerError"), + Expectation("NOT_FOUND", 404, "notFound"), + Expectation("QUOTA_EXCEEDED", 413, "quotaExceeded"), + Expectation("THROTTLED", 429, "throttled"), + Expectation("TRY_AGAIN_LATER", 503, "tryAgainLater"), + Expectation("VALIDATING_REFERENCE_ERROR", 412, "validatingReferenceError"), + Expectation("ZONE_NOT_FOUND", 404, "zoneNotFound"), + ] + } +} diff --git a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift index 3ac35ca6..74f76705 100644 --- a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift +++ b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift @@ -71,7 +71,7 @@ extension CloudKitServiceTests.Tokens { } } - @Test("createAPNsToken() maps a 401 to .httpErrorWithDetails") + @Test("createAPNsToken() maps a 401 AUTHENTICATION_FAILED to .authenticationFailed") internal func createMapsUnauthorized() 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.") @@ -89,12 +89,12 @@ extension CloudKitServiceTests.Tokens { ) Issue.record("expected createAPNsToken to throw") } catch let error as CloudKitError { - guard case .httpErrorWithDetails(let statusCode, let serverErrorCode, _) = error else { - Issue.record("expected .httpErrorWithDetails, got \(error)") + guard case .authenticationFailed = error else { + Issue.record("expected .authenticationFailed, got \(error)") return } - #expect(statusCode == 401) - #expect(serverErrorCode == "AUTHENTICATION_FAILED") + #expect(error.httpStatusCode == 401) + #expect(error.serverErrorCode == "AUTHENTICATION_FAILED") } } diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift index 1c013390..e76a10e5 100644 --- a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift @@ -53,12 +53,12 @@ extension CloudKitServiceTests.Upload { ) Issue.record("Expected authentication error") } catch let error as CloudKitError { - if case .httpErrorWithDetails(let statusCode, let serverErrorCode, let reason) = error { - #expect(statusCode == 401, "Should return 401 Unauthorized") - #expect(serverErrorCode == "AUTHENTICATION_FAILED") + if case .authenticationFailed(let reason) = error { + #expect(error.httpStatusCode == 401, "Should return 401 Unauthorized") + #expect(error.serverErrorCode == "AUTHENTICATION_FAILED") #expect(reason == "Authentication failed") } else { - Issue.record("Expected httpErrorWithDetails error, got \(error)") + Issue.record("Expected authenticationFailed error, got \(error)") } } catch { Issue.record("Expected CloudKitError, got \(type(of: error))") diff --git a/docs/internals/error-code-parsing.md b/docs/internals/error-code-parsing.md index 7468a37c..3681785a 100644 --- a/docs/internals/error-code-parsing.md +++ b/docs/internals/error-code-parsing.md @@ -119,8 +119,28 @@ extension Operations.queryRecords.Output: CloudKitResponseType { ```swift public enum CloudKitError: LocalizedError, Sendable { case httpError(statusCode: Int) - case httpErrorWithDetails(statusCode: Int, serverErrorCode: String?, reason: String?) + // A CloudKit failure body that carried *no* serverErrorCode. + case httpErrorWithDetails(statusCode: Int, reason: String?) case httpErrorWithRawResponse(statusCode: Int, rawResponse: String) + + // One case per documented serverErrorCode… + case accessDenied(reason: String?) + case atomicFailure(reason: String?) + case authenticationFailed(reason: String?) + case authenticationRequired(reason: String?) + case badRequest(reason: String?) + case conflict(reason: String?) + case exists(reason: String?) + case internalServerError(reason: String?) + case notFound(reason: String?) + case quotaExceeded(reason: String?, hint: QuotaHint?) + case throttled(reason: String?) + case tryAgainLater(reason: String?) + case validatingReferenceError(reason: String?) + case zoneNotFound(reason: String?) + // …plus a forward-compatible catch-all for codes added later. + case unknownServerError(code: String, statusCode: Int, reason: String?) + case invalidResponse case underlyingError(any Error) case decodingError(DecodingError) @@ -128,7 +148,11 @@ public enum CloudKitError: LocalizedError, Sendable { } ``` -The primary case is `httpErrorWithDetails` — it carries both the HTTP status and the CloudKit-specific error code and reason string. +Each of the 14 codes above gets its own case so callers pattern-match by intent +rather than string-matching a `serverErrorCode`. `unknownServerError` preserves +a code MistKit does not yet model, and `httpErrorWithDetails` is reserved for a +failure body that carried no code at all. `CloudKitError.serverErrorCode` reads +the raw string back off any of the coded cases for logging. ## The Parsing Pipeline @@ -178,9 +202,11 @@ Each status code has a private initializer that extracts the JSON body: ```swift private init(badRequest response: Components.Responses.BadRequest) { if case .json(let errorResponse) = response.body { - self = .httpErrorWithDetails( - statusCode: 400, + // Dispatches on the code: BAD_REQUEST → .badRequest, an unmodelled + // code → .unknownServerError, no code → .httpErrorWithDetails. + self.init( serverErrorCode: errorResponse.serverErrorCode?.rawValue, + statusCode: 400, reason: errorResponse.reason ) } else { @@ -191,6 +217,12 @@ private init(badRequest response: Components.Responses.BadRequest) { If the body isn't JSON (rare), it falls back to a plain `httpError` without details. +The code → case dispatch lives in one place, +`CloudKitError.init(serverErrorCode:statusCode:reason:)` +(`Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift`), and its +inverse — case → code, documented HTTP status, and description summary — lives +next to it as `CloudKitError.serverErrorDetail`. + ## Response Processing Pattern `CloudKitResponseProcessor` applies the error-first pattern to every operation: @@ -251,10 +283,8 @@ Generic initializer: response.isOk == false → tries errorExtractors[0]: badRequestResponse != nil ✓ │ ▼ -Private init(badRequest:): extracts JSON body - → .httpErrorWithDetails(statusCode: 400, - serverErrorCode: "BAD_REQUEST", - reason: "Invalid filter") +Private init(badRequest:): extracts JSON body, dispatches on the code + → .badRequest(reason: "Invalid filter") │ ▼ Thrown as CloudKitError — caller can switch on case From f50e7e4b500359ac2960c87a4e0c73b43c9bd906 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 16:06:39 +0000 Subject: [PATCH 07/11] Migrate #358's new tests off the query overload removed by #421 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-serverErrorCode tests (#358) and the deprecated-API removal (#421) were developed in parallel and merged without a textual conflict, because they touch disjoint files. They are still incompatible: the new ServerErrorCodes tests call queryRecords(recordType:database:), which #421 deleted, so the merged tree built but failed to compile its tests: error: extraneous argument label 'recordType:' in call error: cannot convert value of type 'String' to expected argument type 'Query' Migrate both call sites to the surviving Query-value overload, matching how #421 migrated the other query tests. Also fix a doc comment left pointing at the removed overload. Verified: swift build, swift build --build-tests, and swift test all pass under Swift 6.2 — 552 tests in 176 suites (550 baseline, -2 removed by #421, +4 added by #358). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LxPMhShjWhhDgPkt7CNHPy --- .../CloudKitService/CloudKitService+Classification.swift | 2 +- ...dKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift | 2 +- .../CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift index 5e1c02df..edc06754 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift @@ -48,7 +48,7 @@ extension CloudKitService { /// /// Used as the first step of the pre-fetch + classify pattern for tracking /// creates vs updates in batch modify operations. Internally this calls - /// `queryRecords(recordType:limit:)` and projects the results down to a + /// `queryRecords(_:limit:database:)` and projects the results down to a /// `Set` of record names. /// /// - Important: This issues a single `queryRecords` call. CloudKit caps a diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift index 2657b872..20639cc3 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift @@ -106,7 +106,7 @@ extension CloudKitServiceTests.ServerErrorCodes { do { _ = try await service.queryRecords( - recordType: "Note", + Query(recordType: "Note"), database: .public(.prefers(.serverToServer)) ) Issue.record("expected queryRecords to throw") diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift index 2b8bb665..483398e9 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift @@ -54,7 +54,7 @@ extension CloudKitServiceTests.ServerErrorCodes { do { _ = try await service.queryRecords( - recordType: "Note", + Query(recordType: "Note"), database: .public(.prefers(.serverToServer)) ) Issue.record("expected queryRecords to throw for \(expectation.code)") From 627df8614eac4827ca8d1d543dbcea98ce4d14c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:20:46 +0000 Subject: [PATCH 08/11] Adopt two-tier cloud Swift setup and make regeneration work offline of mise Replaces the per-session toolchain install with the two-tier approach from brightdigit/ConfigKeyKit#7: Scripts/cloud-setup.sh runs once per cloud environment and is captured in the filesystem snapshot, while the SessionStart hook short-circuits when Swift is already present and re-exports PATH into CLAUDE_ENV_FILE (a snapshot restores files, not environment variables). The hook installs via swiftly against the new .swift-version pin rather than a hardcoded tarball URL. Pins .swift-version to 6.3.2, matching MistDemo-Integration.yml's swift:6.3.2-noble container. The previous 6.1 install could not build the example packages at all, which declare swift-tools-version 6.2. Adds Scripts/OpenAPITools, a standalone manifest pinning the same swift-openapi-generator version as mise.toml. mise resolves `spm:` tools through api.github.com, which cloud sessions cannot reach; SwiftPM resolves this over plain git, which they can. Keeping it in its own manifest means the generator never enters MistKit's dependency graph, preserving the no-build-plugin decision. Verified: regeneration reproduces the committed Sources/MistKitOpenAPI output byte-identically. Gates SwiftLint and periphery in Scripts/lint.sh on CLAUDE_CODE_REMOTE, so web sessions run swift-format, the header check and --build-tests instead of failing outright on tooling they cannot install. Refs #295 --- .claude/hooks/session-start.sh | 221 +++++++++++++------------- .gitignore | 4 + .swift-version | 1 + Scripts/OpenAPITools/Package.resolved | 69 ++++++++ Scripts/OpenAPITools/Package.swift | 22 +++ Scripts/cloud-setup.sh | 187 ++++++++++++++++++++++ Scripts/generate-openapi.sh | 24 ++- Scripts/lint.sh | 35 +++- 8 files changed, 444 insertions(+), 119 deletions(-) create mode 100644 .swift-version create mode 100644 Scripts/OpenAPITools/Package.resolved create mode 100644 Scripts/OpenAPITools/Package.swift create mode 100755 Scripts/cloud-setup.sh diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index a4beb6e7..90ff7397 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,128 +1,127 @@ -#!/usr/bin/env bash +#!/bin/bash +set -euo pipefail + +# SessionStart hook: install a Swift toolchain for Claude Code on the web +# (Linux). Only runs in remote sessions; local sessions are untouched. Runs +# async so the session starts immediately: progress lands in +# ~/.claude-session-setup.log and ~/.claude-session-setup.done marks the end. # -# SessionStart hook — provision the Swift toolchain and the pinned project -# tooling so Claude Code on the web sessions can run swift build / swift test / -# swift-format / swiftlint / periphery without manual setup. +# The toolchain is all this installs. Lint tooling is deliberately left out to +# keep cold start short: swift-format ships inside the toolchain, and both +# SwiftLint and periphery are skipped in web sessions (Scripts/lint.sh omits +# them when CLAUDE_CODE_REMOTE is set). Run `make lint` locally, where mise +# provides the pinned versions, to get full coverage. # -# No-op for local sessions (CLAUDE_CODE_REMOTE unset). Idempotent: a warm -# container re-runs this in seconds. - -set -uo pipefail +# This hook is the second tier of a two-tier setup. The first tier is +# Scripts/cloud-setup.sh, pasted into the cloud environment's "Setup script" +# field: it runs once, then the filesystem is snapshotted and later sessions +# reuse it, so the ~1 GB toolchain download happens once per environment +# instead of once per container. When that snapshot exists, the `command -v +# swift` check below short-circuits and this hook finishes in about a second. +# +# The hook is still required on every session for two reasons: a snapshot +# restores files but not environment variables, so PATH has to be re-exported +# into CLAUDE_ENV_FILE each time; and an environment with no setup script +# configured (a fresh clone, another contributor) still needs the toolchain +# installed from here. +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi -[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0 +echo '{"async": true, "asyncTimeout": 2400000}' -# The root Package.swift declares swift-tools-version 6.1, but -# Examples/MistDemo declares 6.2 — installing 6.1 makes the example packages -# unbuildable ("package is using Swift tools version 6.2.0 but the installed -# version is 6.1.0"). Install the highest tools-version any package in the -# repo requires; a newer toolchain still builds the older manifests. -readonly SWIFT_VERSION="6.2" -readonly SWIFT_RELEASE="swift-${SWIFT_VERSION}-RELEASE" -# download.swift.org spells the platform two different ways: the URL path -# segment is dotless ("ubuntu2404") while the archive/extracted directory name -# keeps the dot ("ubuntu24.04"). Using one for both yields a 404. -readonly SWIFT_PLATFORM="ubuntu24.04" -readonly SWIFT_PLATFORM_PATH="ubuntu2404" -readonly SWIFT_DIR="${HOME}/.swift" -readonly SWIFT_ROOT="${SWIFT_DIR}/${SWIFT_RELEASE}-${SWIFT_PLATFORM}" -readonly SWIFT_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/${SWIFT_PLATFORM_PATH}/${SWIFT_RELEASE}/${SWIFT_RELEASE}-${SWIFT_PLATFORM}.tar.gz" +SETUP_LOG="$HOME/.claude-session-setup.log" +SETUP_DONE="$HOME/.claude-session-setup.done" +rm -f "$SETUP_DONE" +exec >> "$SETUP_LOG" 2>&1 -log() { printf '[session-start] %s\n' "$*" >&2; } +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" -SUDO="" -if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then - SUDO="sudo" +# Make swift reachable for the session up front; an entry pointing at a +# not-yet-populated directory is harmless. +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" + echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" + echo "export PATH=\"$HOME/.local/share/swiftly/bin:\$PATH\"" + } >> "$CLAUDE_ENV_FILE" fi -# 1. Swift runtime dependencies. Third-party PPAs in the base image can fail -# `apt-get update`; that must not abort provisioning, hence the `|| true`. -install_apt_dependencies() { - if [ -f "${SWIFT_DIR}/.apt-done" ]; then - log "apt dependencies already installed, skipping" - return 0 - fi - log "installing Swift runtime apt dependencies" - export DEBIAN_FRONTEND=noninteractive - $SUDO apt-get update -qq || true - $SUDO apt-get install -y -qq --no-install-recommends \ - binutils git gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libgcc-13-dev \ - libncurses-dev libpython3-dev libsqlite3-0 libstdc++-13-dev libxml2-dev \ - libz3-dev pkg-config tzdata unzip zlib1g-dev curl ca-certificates || - log "WARNING: some apt packages failed to install; continuing" - mkdir -p "${SWIFT_DIR}" && touch "${SWIFT_DIR}/.apt-done" -} - -# 2. Swift toolchain. install_swift() { - if [ -x "${SWIFT_ROOT}/usr/bin/swift" ]; then - log "Swift ${SWIFT_VERSION} already present at ${SWIFT_ROOT}" - return 0 - fi - log "downloading Swift ${SWIFT_VERSION}" - mkdir -p "${SWIFT_DIR}" - local archive="${SWIFT_DIR}/${SWIFT_RELEASE}-${SWIFT_PLATFORM}.tar.gz" - if ! curl -fsSL --retry 3 --retry-delay 2 -o "${archive}" "${SWIFT_URL}"; then - log "ERROR: failed to download Swift toolchain" - return 1 - fi - log "extracting toolchain" - if ! tar -xzf "${archive}" -C "${SWIFT_DIR}"; then - log "ERROR: extraction failed" - return 1 - fi - rm -f "${archive}" - if [ ! -x "${SWIFT_ROOT}/usr/bin/swift" ]; then - log "ERROR: swift binary missing after extract" - return 1 - fi -} + # System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux + # instructions), plus curl for fetching swiftly. Most are already in the + # base image, so only reach for apt when something is genuinely missing -- + # `apt-get update` alone costs ~10s. + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata + zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done -# 3. mise + the tools pinned in mise.toml (swift-format, swiftlint, periphery, -# swift-openapi-generator). The spm: backends build from source, so the -# toolchain has to be on PATH before this runs. -install_mise() { - if ! command -v mise >/dev/null 2>&1 && [ ! -x "${HOME}/.local/bin/mise" ]; then - log "installing mise" - curl -fsSL https://mise.run | sh || { - log "WARNING: mise install failed" - return 0 - } + if [ "${#missing[@]}" -gt 0 ]; then + echo "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends "${missing[@]}" else - log "mise already installed" - fi - export PATH="${HOME}/.local/bin:${SWIFT_ROOT}/usr/bin:${PATH}" - if [ -f "${CLAUDE_PROJECT_DIR:-.}/mise.toml" ]; then - log "running mise install (the slow step on a cold container)" - (cd "${CLAUDE_PROJECT_DIR:-.}" && mise install -y) || - log "WARNING: mise install reported errors" + echo "All system packages already present; skipping apt." fi -} -# 4. Persist PATH for the rest of the session. -persist_environment() { - local path_line="export PATH=\"${SWIFT_ROOT}/usr/bin:${HOME}/.local/bin:\${PATH}\"" - if [ -n "${CLAUDE_ENV_FILE:-}" ]; then - if ! grep -qF "${SWIFT_ROOT}/usr/bin" "${CLAUDE_ENV_FILE}" 2>/dev/null; then - printf '%s\n' "${path_line}" >>"${CLAUDE_ENV_FILE}" - fi - log "persisted PATH to CLAUDE_ENV_FILE" + # Install swiftly non-interactively, then the toolchain pinned by the + # repo's .swift-version (falling back to latest if no pin resolves). + local workdir + workdir="$(mktemp -d)" + pushd "$workdir" > /dev/null + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" + tar zxf "swiftly-$(uname -m).tar.gz" + ./swiftly init -y --skip-install + popd > /dev/null + rm -rf "$workdir" + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + + cd "$PROJECT_DIR" + if ! swiftly install -y; then + echo "Pinned toolchain install failed; falling back to latest." >&2 + swiftly install -y latest + swiftly use -y latest fi - # Also land it in the shell profile so plain non-login shells inherit it. - for profile in "${HOME}/.bashrc" "${HOME}/.profile"; do - [ -f "${profile}" ] || continue - if ! grep -qF "${SWIFT_ROOT}/usr/bin" "${profile}" 2>/dev/null; then - printf '%s\n' "${path_line}" >>"${profile}" - fi - done } -install_apt_dependencies -# Never block the session on a provisioning failure — the agent can still read -# and edit code, it just cannot build. -install_swift || exit 0 -install_mise -persist_environment +# Pick up a swiftly install from a previous (cached) hook run. +if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" +fi + +if command -v swift > /dev/null 2>&1; then + echo "Swift already installed: $(swift --version 2>&1 | head -1)" +else + install_swift +fi -export PATH="${SWIFT_ROOT}/usr/bin:${HOME}/.local/bin:${PATH}" -log "provisioning complete: $(swift --version 2>&1 | head -1)" -exit 0 +swift --version +touch "$SETUP_DONE" diff --git a/.gitignore b/.gitignore index a6e4eaf3..35dc6812 100644 --- a/.gitignore +++ b/.gitignore @@ -196,3 +196,7 @@ build # Git worktrees created for parallel agent runs .claude/worktrees/ + +# Standalone openapi generator tools package build products +Scripts/OpenAPITools/.build/ +Scripts/OpenAPITools/.swiftpm/ diff --git a/.swift-version b/.swift-version new file mode 100644 index 00000000..91e4a9f2 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.3.2 diff --git a/Scripts/OpenAPITools/Package.resolved b/Scripts/OpenAPITools/Package.resolved new file mode 100644 index 00000000..34e12581 --- /dev/null +++ b/Scripts/OpenAPITools/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "62ee5f3838a918ff1e151deb900814ce4cd6428c37997abf190b7d743d641516", + "pins" : [ + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit", + "state" : { + "revision" : "343b2c1793058fcc53c1bd7e2907f8e3a4d640fb", + "version" : "3.9.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-generator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-generator", + "state" : { + "revision" : "d74223cc5595a8165181c4d9579243c932e5cd07", + "version" : "1.10.3" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } + } + ], + "version" : 3 +} diff --git a/Scripts/OpenAPITools/Package.swift b/Scripts/OpenAPITools/Package.swift new file mode 100644 index 00000000..c52ea244 --- /dev/null +++ b/Scripts/OpenAPITools/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +// +// Standalone tools manifest so `swift-openapi-generator` can be run without +// mise. mise resolves `spm:` tools through api.github.com, which is not +// reachable from Claude Code web sessions; SwiftPM resolves this package over +// plain git, which is. Keeping it in its own manifest means the generator +// never enters the MistKit library's dependency graph, preserving the +// no-build-plugin decision documented in Scripts/generate-openapi.sh. +// +// Version must stay in sync with mise.toml's +// "spm:apple/swift-openapi-generator" pin. +import PackageDescription + +let package = Package( + name: "OpenAPITools", + dependencies: [ + .package( + url: "https://github.com/apple/swift-openapi-generator", + exact: "1.10.3" + ) + ] +) diff --git a/Scripts/cloud-setup.sh b/Scripts/cloud-setup.sh new file mode 100755 index 00000000..ef31971b --- /dev/null +++ b/Scripts/cloud-setup.sh @@ -0,0 +1,187 @@ +#!/bin/bash + +# Setup script for Claude Code on the web (cloud environments). +# +# Paste this into the environment dialog's "Setup script" field at +# claude.ai/code. It is committed here so the content stays reviewable and +# versioned, but the platform reads it from that dialog, not from the repo. +# +# Why here and not in the SessionStart hook: a setup script runs once per +# environment, then Anthropic snapshots the filesystem and reuses that snapshot +# for later sessions, which skip the script entirely. SessionStart hooks re-run +# on every session and get no such caching. The Swift toolchain is a ~1 GB +# download, so it belongs in the snapshot. +# +# .claude/hooks/session-start.sh stays as the fallback: it installs the same +# toolchain when an environment has no setup script configured, and on every +# session it wires PATH into CLAUDE_ENV_FILE (a filesystem snapshot restores +# files, not environment variables). +# +# Requirements this script is written around: +# * Must exit 0 -- a non-zero exit makes the session fail to start. +# * Must finish inside ~5 minutes or the environment cache will not build. +# Measured cold install is ~2 minutes. +# * Runs as root on Ubuntu 24.04, before Claude Code launches. +# * Needs download.swift.org on the environment's allowed-domains list +# (Network access: Custom, with the default package-manager list included). + +# No `set -e`: every failure path has to fall through to `exit 0` so a bad +# install degrades to the SessionStart hook rather than bricking the session. +set -uo pipefail + +# Used only when no .swift-version can be found on disk. Keep in sync with the +# repo's .swift-version. +FALLBACK_SWIFT_VERSION="6.3.2" + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" + +log() { + # stderr, not stdout: resolve_swift_version's value is read via command + # substitution, so any stdout chatter would be captured into the version. + echo "[cloud-setup] $*" >&2 +} + +# The setup script may run before the repository is checked out, and the +# checkout path is not contractual, so look in the likely places and fall back +# to the pinned literal above rather than failing. +resolve_swift_version() { + local candidate + for candidate in \ + "${CLAUDE_PROJECT_DIR:-/nonexistent}/.swift-version" \ + "$PWD/.swift-version" \ + /home/user/*/.swift-version \ + /workspace/*/.swift-version \ + /root/*/.swift-version; do + if [ -f "$candidate" ]; then + local version + version="$(tr -d '[:space:]' < "$candidate")" + if [ -n "$version" ]; then + log "Using Swift $version pinned by $candidate" + printf '%s' "$version" + return 0 + fi + fi + done + log "No .swift-version found; falling back to Swift $FALLBACK_SWIFT_VERSION" + printf '%s' "$FALLBACK_SWIFT_VERSION" +} + +# System dependencies for Swift on Ubuntu 24.04 (per swift.org's Linux +# instructions), plus curl for fetching swiftly. Most are already in the base +# image, so only reach for apt when something is genuinely missing: apt-get +# update alone costs ~10s and pulls in unrelated upgrades. +install_system_packages() { + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata + zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + log "All system packages already present; skipping apt." + return 0 + fi + + log "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq || return 1 + apt-get install -y -qq --no-install-recommends "${missing[@]}" || return 1 +} + +install_swiftly() { + local workdir + workdir="$(mktemp -d)" || return 1 + ( + cd "$workdir" || exit 1 + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" || exit 1 + tar zxf "swiftly-$(uname -m).tar.gz" || exit 1 + ./swiftly init -y --skip-install || exit 1 + ) + local status=$? + rm -rf "$workdir" + return "$status" +} + +# Make swift resolvable for plain login shells too. This is a file, so the +# environment snapshot carries it; the SessionStart hook still handles +# CLAUDE_ENV_FILE for Claude Code's own process. +write_profile_entry() { + cat > /etc/profile.d/swiftly.sh <<'PROFILE' +# Added by MistKit Scripts/cloud-setup.sh +export SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" +export SWIFTLY_BIN_DIR="$HOME/.local/share/swiftly/bin" +case ":$PATH:" in + *":$SWIFTLY_BIN_DIR:"*) ;; + *) export PATH="$SWIFTLY_BIN_DIR:$PATH" ;; +esac +PROFILE +} + +main() { + if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + fi + + if command -v swift > /dev/null 2>&1; then + log "Swift already installed: $(swift --version 2>&1 | head -1)" + write_profile_entry + return 0 + fi + + local version + version="$(resolve_swift_version)" + + install_system_packages || { + log "WARNING: system package install failed; continuing anyway." + } + + install_swiftly || { + log "ERROR: swiftly install failed." + return 1 + } + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" || return 1 + + if ! swiftly install -y "$version"; then + log "Pinned toolchain $version failed to install; falling back to latest." + swiftly install -y latest || return 1 + swiftly use -y latest || return 1 + else + swiftly use -y "$version" || return 1 + fi + + write_profile_entry + swift --version +} + +if main; then + log "Setup complete." +else + log "Setup did not complete; the SessionStart hook will install Swift instead." +fi + +# Always succeed: a non-zero exit here stops the session from starting. +exit 0 diff --git a/Scripts/generate-openapi.sh b/Scripts/generate-openapi.sh index a22afa72..84f91352 100755 --- a/Scripts/generate-openapi.sh +++ b/Scripts/generate-openapi.sh @@ -11,14 +11,32 @@ echo "🔄 Generating OpenAPI code..." SCRIPT_DIR=$(dirname "$(readlink -f "$0")") PACKAGE_DIR="${SCRIPT_DIR}/.." -# Put mise-managed tools on PATH (swift-openapi-generator is provisioned via mise.toml) -if command -v mise >/dev/null 2>&1; then +# Put mise-managed tools on PATH (swift-openapi-generator is provisioned via +# mise.toml). Skipped in Claude Code web sessions: mise resolves `spm:` tools +# through api.github.com, which those sessions cannot reach. +if command -v mise >/dev/null 2>&1 && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi pushd $PACKAGE_DIR -swift-openapi-generator generate \ +# Prefer the mise-pinned binary. Where it is unavailable — Claude Code web +# sessions, or any checkout without mise — fall back to Scripts/OpenAPITools, +# a standalone manifest that pins the same generator version and resolves it +# over plain git (which those sessions can reach). Keeping the generator in +# its own manifest means it never enters MistKit's dependency graph, so the +# no-build-plugin decision above still holds. +# +# The version in Scripts/OpenAPITools/Package.swift must stay in sync with +# mise.toml's "spm:apple/swift-openapi-generator" pin. +if command -v swift-openapi-generator >/dev/null 2>&1; then + GENERATOR=(swift-openapi-generator) +else + echo "ℹ️ swift-openapi-generator not on PATH; building it from Scripts/OpenAPITools." + GENERATOR=(swift run --package-path Scripts/OpenAPITools swift-openapi-generator) +fi + +"${GENERATOR[@]}" generate \ --output-directory Sources/MistKitOpenAPI \ --config openapi-generator-config.yaml \ openapi.yaml diff --git a/Scripts/lint.sh b/Scripts/lint.sh index f110801d..ec4b892c 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -23,11 +23,25 @@ else PACKAGE_DIR="${SRCROOT}" fi -# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action) -if command -v mise >/dev/null 2>&1 && [ -z "$CI" ]; then +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action). +# Skipped in Claude Code web sessions: mise resolves its `spm:`/`aqua:` tools +# through api.github.com, which those sessions cannot reach, so evaluating its +# env would only shadow the toolchain's own swift-format with a broken shim. +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ] \ + && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then eval "$(mise -C "$PACKAGE_DIR" env -s bash)" fi +# SwiftLint and periphery are not installed in Claude Code web sessions (no +# Linux binaries for periphery, and mise is unreachable per above). swift-format +# ships inside the Swift toolchain, so formatting, the header check and the +# build still run there; run ./Scripts/lint.sh locally for full coverage. +if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + RUN_SWIFTLINT=0 +else + RUN_SWIFTLINT=1 +fi + if [ "$LINT_MODE" = "NONE" ]; then exit elif [ "$LINT_MODE" = "STRICT" ]; then @@ -44,12 +58,18 @@ pushd $PACKAGE_DIR if [ -z "$CI" ]; then run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests - run_command swiftlint --fix + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint --fix + fi fi if [ -z "$FORMAT_ONLY" ]; then run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests - run_command swiftlint lint $SWIFTLINT_OPTIONS + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint lint $SWIFTLINT_OPTIONS + else + echo "Skipping SwiftLint (Claude Code web session)." + fi # Check for compilation errors run_command swift build --build-tests fi @@ -58,8 +78,13 @@ $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "Bright # Generated files now automatically include ignore directives via OpenAPI generator configuration -if [ -z "$CI" ]; then +# Periphery does not run in Claude Code web sessions: it would have to be built +# from source there (no Linux binaries, and the session's GitHub gateway rules +# out mise), which is not worth the cold-start cost. +if [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then run_command periphery scan $PERIPHERY_OPTIONS --disable-update-check +elif [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + echo "Skipping periphery scan (Claude Code web session)." fi popd From 8e7e6c23c492b3bd4b22b7f1a411cd753c4fec18 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 13:44:32 -0400 Subject: [PATCH 09/11] Wire SessionStart hook (#295) --- .claude/settings.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..6738f065 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\"" + } + ] + } + ] + } +} From 1735223f34aa704a0e7dc3061f9382b0f57171df Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 13:54:11 -0400 Subject: [PATCH 10/11] Adding Milestone docs --- .claude/docs/MILESTONE-19-HANDOFF.md | 183 +++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 .claude/docs/MILESTONE-19-HANDOFF.md diff --git a/.claude/docs/MILESTONE-19-HANDOFF.md b/.claude/docs/MILESTONE-19-HANDOFF.md new file mode 100644 index 00000000..a75fbcd7 --- /dev/null +++ b/.claude/docs/MILESTONE-19-HANDOFF.md @@ -0,0 +1,183 @@ +# Milestone v1.0.0-beta.4 — Handoff + +Context for continuing milestone 19 work locally. Written at the end of a Claude Code +web session that shipped 4 of the 14 issues. + +- **Branch:** `claude/parallel-agents-work-trees-pbumvn` +- **PR:** [#424](https://github.com/brightdigit/MistKit/pull/424) (draft) → base `v1.0.0-beta.4` (**not** `main`) +- **Head at handoff:** `fb84e52` +- **Milestone:** https://github.com/brightdigit/MistKit/milestone/19 + +```bash +git fetch origin claude/parallel-agents-work-trees-pbumvn +git checkout claude/parallel-agents-work-trees-pbumvn +``` + +--- + +## 1. What shipped in #424 + +| Issue | State | Notes | +|---|---|---| +| #421 Remove deprecated public API | Done | 4 declarations removed. **Breaking.** | +| #378 FieldValue ↔ Components refactor | Done | Exhaustive dispatch; 5 `default:` branches removed | +| #358 CloudKitError per-serverErrorCode | Done | 14 codes + `.unknownServerError` fallback | +| #295 Cloud toolchain setup | Done | Two-tier swiftly setup + `.claude/settings.json` | +| #419 MistDemoApp initializers | **Already fixed** | Not a code change — see below | + +### #419 is closable without code +Both initializers the issue proposes (`NoteEditView.init(mode:onSaved:)`, +`RecordDetailView.init(note:onChange:)`) shipped in `5a58120` (v1.0.0 beta.3), eight days +after the issue was filed. Verified they exist in the tree and that `5a58120` is an +ancestor of HEAD. + +**To close it**, confirm on macOS with Swift 6.3.2: +```bash +cd Examples/MistDemo && swift build --target MistDemoApp +``` +Linux cannot run this — the views are inside `#if canImport(SwiftUI)`. + +--- + +## 2. Remaining milestone work (9 issues) + +**Serialize these three — they all mutate `openapi.yaml`** and will collide if run in +parallel worktrees: + +| Issue | Title | +|---|---| +| #41 | Fetching Record Information (`records/resolve`) | +| #42 | Accepting Share Records (`records/accept`) | +| #47 | Fetching Record Zone Changes (`changes/zone`) | +| #386 | Zone schemas only model `{ zoneID }` — enrich with sync/atomic metadata | +| #401 | Clarify change-tracking endpoint coverage (`changes/*` vs `records/changes`) | + +**Safe to parallelize — independent, no spec changes:** + +| Issue | Title | +|---|---| +| #399 | Fix Over-Extension Use With Protocol Extension Implementation Pattern | +| #407 | Create MistKitConfiguration package for shared CloudKit config glue | +| #146 | Add custom CloudKit zone support for queries | +| #398 | MistDemo web: add phone-number support to `/users/discover` | + +--- + +## 3. Environment & tooling — hard-won details + +### Swift versions differ across the repo +| Package | tools-version | +|---|---| +| root `Package.swift` | 6.1 | +| `Examples/MistDemo`, `BushelCloud`, `CelestraCloud` | **6.2** | +| `.swift-version` (new) | **6.3.2** | + +Installing 6.1 makes every example package unbuildable +(`error: package is using Swift tools version 6.2.0 but the installed version is 6.1.0`). + +### mise vs. cloud sessions +`mise.toml` pins swift-format, SwiftLint, periphery and swift-openapi-generator via +`spm:`/`aqua:` backends, which resolve through **`api.github.com`** — unreachable from +Claude Code web sessions. Locally mise works normally; **run the full lint locally**, +since web sessions skip SwiftLint and periphery: + +```bash +./Scripts/lint.sh # full pipeline — do this before merging #424 +mise exec -- swiftlint +``` + +`Scripts/lint.sh` now gates SwiftLint/periphery on `CLAUDE_CODE_REMOTE`. That gating is +for web sessions only and should not affect local runs. + +### Regenerating OpenAPI code +`Scripts/generate-openapi.sh` prefers the mise binary and falls back to +`Scripts/OpenAPITools/` — a standalone manifest that resolves the generator over plain +git. Either path produces byte-identical output (verified). + +> **Keep the version in `Scripts/OpenAPITools/Package.swift` in sync with `mise.toml`'s +> `spm:apple/swift-openapi-generator` pin (currently `1.10.3`).** Nothing enforces this. + +```bash +./Scripts/generate-openapi.sh +``` + +--- + +## 4. Lessons from the parallel-worktree run + +Four agents ran on isolated worktrees. Worth repeating, and worth knowing the failure mode: + +**Git merged cleanly and both branches were individually green — yet the merge was +broken.** #358's new tests called a query overload that #421 deleted. The branches +touched disjoint files, so there was no conflict, and neither agent could have seen it. +It surfaced only under `--build-tests`, because a plain `swift build` does not compile +test code. + +```bash +# After merging any two parallel branches: +swift build --build-tests # NOT just `swift build` +swift test +``` + +Also: don't pipe test output through `tail` when diagnosing — it reduced a real +compile error to a context-free `error: fatalError` that looked like a passing run. + +Agents could not build the example packages (toolchain mismatch, above), so call-site +edits in `Examples/` were made by reading signatures. Those need a compile before trust. + +--- + +## 5. Open follow-ups + +### 5.1 Unverified 32-bit/WASI narrowing (#378) +The agent introduced, then caught and fixed, an `Int64` → `Int` conversion that would +**trap on wasm32** for large timestamps. There is no 32-bit test coverage. A green wasm +CI lane proves it compiles, not that the path is safe. **Wants a human review.** + +### 5.2 `openapi.yaml` declares `serverErrorCode` as a closed enum +Two places: +- `OperationFailureServerErrorCode` (~line 1716) +- `ErrorResponse.serverErrorCode` (~line 1803) + +The generator emits closed Swift enums, so an unrecognized code fails to decode as +`.decodingError` **before** MistKit's mapping runs — meaning `.unknownServerError` can +never fire from real traffic today. + +Two concrete gaps found: +- The spec's own prose (~line 1795) documents `RECORD_NOT_FOUND` and `PARTIAL_FAILURE`, + neither of which is in the `enum:` below it. +- Apple's CloudKit JS reference names `SERVICE_UNAVAILABLE`, `UNIQUE_FIELD_ERROR`, + `INVALID_ARGUMENTS`, `UNKNOWN_ERROR` — all rejected by the enum. *Caveat: some + CloudKit JS codes are client-side only and may never cross the REST wire. Unverified.* + +Deliberately deferred — the design question (open the enum to plain `string`, vs. keep a +closed enum, vs. restructure `CloudKitError` around a nested `ServerErrorCode` enum with +`.unknown(String)`) was left open. `CloudKitError` is a 30-case public enum with library +evolution **disabled**, so adding cases post-1.0 is source-breaking for exhaustive +switches. Pre-1.0 is the free moment to decide. + +### 5.3 `.claude/docs/webservices.md` is missing +`CLAUDE.md` documents it as present at 289 KB and calls it the primary REST API +reference. It is not in the repo. It is the doc you'd want for §5.2. + +### 5.4 #421's acceptance-criteria grep is vacuous +The issue's verification grep cannot match multi-line `@available(...)` attributes, so it +returns clean whether or not the work was done. Worth fixing in the issue template. +(Confirmed: zero `@available(*, deprecated)` declarations remain in `Sources/MistKit`.) + +--- + +## 6. Verification status of #424 + +Run on Linux x86-64 / Swift 6.2 against the fully merged tree: + +| Check | Result | +|---|---| +| MistKit core | 552 tests / 176 suites passing | +| MistDemo | 970 tests / 289 suites passing | +| MistDemo, CelestraCloud, BushelCloud | all build against modified MistKit | +| swift-format lint + header + `--build-tests` | clean; formatting produced no changes | +| SwiftLint, periphery | **not run** — needs local mise | +| wasm32, Windows, Android, Apple platforms | **not run locally** — CI only | + +Before merging: run `./Scripts/lint.sh` locally and check CI on #424. From c470459319043404d2d3d3ba629529e3b8eb5fa0 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 14:11:31 -0400 Subject: [PATCH 11/11] Centralize serverErrorCode wire constants in CloudKitServerErrorCode. Share raw string, status, and summary via a single catalog used by both CloudKitError init and serverErrorDetail, and fix MemberImportVisibility CI failures in the ServerErrorCodes tests. Co-authored-by: Cursor --- .../CloudKitError+ServerErrorCode.swift | 137 ++++++++++-------- .../ServerErrorCodeDetail.swift | 22 ++- .../Models/CloudKitServerErrorCode.swift | 110 +++++++++++--- Sources/MistKit/Models/OperationFailure.swift | 4 +- ...erverErrorCodes+ForwardCompatibility.swift | 1 + ...viceTests.ServerErrorCodes+Roundtrip.swift | 1 + .../Models/ServerErrorCodeTests.swift | 13 +- 7 files changed, 203 insertions(+), 85 deletions(-) diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift index 74a6272f..e4206b89 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift @@ -42,40 +42,47 @@ extension CloudKitError { /// models a CloudKit `serverErrorCode`; `nil` for all other cases. /// /// The switch is deliberately exhaustive: adding a case to ``CloudKitError`` - /// stops compiling here until the new case is classified. - // swiftlint:disable:next cyclomatic_complexity function_body_length + /// stops compiling here until the new case is classified. Wire strings and + /// status numbers come from ``CloudKitServerErrorCode``'s catalog — never + /// inlined here. + // swiftlint:disable:next cyclomatic_complexity internal var serverErrorDetail: ServerErrorCodeDetail? { switch self { case .accessDenied(let reason): - return Self.detail("ACCESS_DENIED", 403, "access denied", reason) + return ServerErrorCodeDetail(code: .accessDenied, reason: reason) case .atomicFailure(let reason): - return Self.detail("ATOMIC_ERROR", 400, "atomic batch failure", reason) + return ServerErrorCodeDetail(code: .atomicError, reason: reason) case .authenticationFailed(let reason): - return Self.detail("AUTHENTICATION_FAILED", 401, "authentication failed", reason) + return ServerErrorCodeDetail(code: .authenticationFailed, reason: reason) case .authenticationRequired(let reason): - return Self.detail("AUTHENTICATION_REQUIRED", 421, "authentication required", reason) + return ServerErrorCodeDetail(code: .authenticationRequired, reason: reason) case .badRequest(let reason): - return Self.detail("BAD_REQUEST", 400, "bad request", reason) + return ServerErrorCodeDetail(code: .badRequest, reason: reason) case .conflict(let reason): - return Self.detail("CONFLICT", 409, "conflict", reason) + return ServerErrorCodeDetail(code: .conflict, reason: reason) case .exists(let reason): - return Self.detail("EXISTS", 409, "already exists", reason) + return ServerErrorCodeDetail(code: .exists, reason: reason) case .internalServerError(let reason): - return Self.detail("INTERNAL_ERROR", 500, "internal server error", reason) + return ServerErrorCodeDetail(code: .internalError, reason: reason) case .notFound(let reason): - return Self.detail("NOT_FOUND", 404, "not found", reason) + return ServerErrorCodeDetail(code: .notFound, reason: reason) case .quotaExceeded(let reason, _): - return Self.detail("QUOTA_EXCEEDED", 413, "quota exceeded", reason) + return ServerErrorCodeDetail(code: .quotaExceeded, reason: reason) case .throttled(let reason): - return Self.detail("THROTTLED", 429, "throttled", reason) + return ServerErrorCodeDetail(code: .throttled, reason: reason) case .tryAgainLater(let reason): - return Self.detail("TRY_AGAIN_LATER", 503, "try again later", reason) + return ServerErrorCodeDetail(code: .tryAgainLater, reason: reason) case .validatingReferenceError(let reason): - return Self.detail("VALIDATING_REFERENCE_ERROR", 412, "reference validation error", reason) + return ServerErrorCodeDetail(code: .validatingReferenceError, reason: reason) case .zoneNotFound(let reason): - return Self.detail("ZONE_NOT_FOUND", 404, "zone not found", reason) + return ServerErrorCodeDetail(code: .zoneNotFound, reason: reason) case .unknownServerError(let code, let statusCode, let reason): - return Self.detail(code, statusCode, "unrecognized server error", reason) + return ServerErrorCodeDetail( + code: code, + statusCode: statusCode, + summary: ServerErrorCodeDetail.unrecognizedSummary, + reason: reason + ) case .httpError, .httpErrorWithDetails, .httpErrorWithRawResponse, .invalidResponse, .incompleteResponse, .conversionFailed, .recordOperationFailed, .subscriptionOperationFailed, .subscriptionLikelyDuplicate, .underlyingError, @@ -92,7 +99,7 @@ extension CloudKitError { /// ``CloudKitError/httpErrorWithDetails(statusCode:reason:)``, preserving /// the server `reason`. /// - Each of the fourteen codes documented in `openapi.yaml` becomes its own - /// dedicated case. + /// dedicated case, looked up via ``CloudKitServerErrorCode``'s dictionary. /// - Anything else becomes /// ``CloudKitError/unknownServerError(code:statusCode:reason:)`` so a code /// Apple adds after this release still reaches the caller intact. @@ -101,56 +108,62 @@ extension CloudKitError { /// - code: The raw `serverErrorCode` string from the failure body. /// - statusCode: The HTTP status the failure arrived with. /// - reason: The server-supplied `reason`, when present. - // swiftlint:disable:next cyclomatic_complexity internal init(serverErrorCode code: String?, statusCode: Int, reason: String?) { guard let code else { self = .httpErrorWithDetails(statusCode: statusCode, reason: reason) return } - switch code { - case "ACCESS_DENIED": - self = .accessDenied(reason: reason) - case "ATOMIC_ERROR": - self = .atomicFailure(reason: reason) - case "AUTHENTICATION_FAILED": - self = .authenticationFailed(reason: reason) - case "AUTHENTICATION_REQUIRED": - self = .authenticationRequired(reason: reason) - case "BAD_REQUEST": - self = .badRequest(reason: reason) - case "CONFLICT": - self = .conflict(reason: reason) - case "EXISTS": - self = .exists(reason: reason) - case "INTERNAL_ERROR": - self = .internalServerError(reason: reason) - case "NOT_FOUND": - self = .notFound(reason: reason) - case "QUOTA_EXCEEDED": - // `hint` is enriched later by the calling operation's catch block, which - // is the only place that can see the local request state. - self = .quotaExceeded(reason: reason, hint: nil) - case "THROTTLED": - self = .throttled(reason: reason) - case "TRY_AGAIN_LATER": - self = .tryAgainLater(reason: reason) - case "VALIDATING_REFERENCE_ERROR": - self = .validatingReferenceError(reason: reason) - case "ZONE_NOT_FOUND": - self = .zoneNotFound(reason: reason) - default: - self = .unknownServerError(code: code, statusCode: statusCode, reason: reason) - } + // Dictionary lookup in `CloudKitServerErrorCode.init(rawValue:)` — no + // string switch here. Map the typed enum onto the dedicated case. + self = Self.make( + from: CloudKitServerErrorCode(rawValue: code), + statusCode: statusCode, + reason: reason + ) } - private static func detail( - _ code: String, - _ statusCode: Int, - _ summary: String, - _ reason: String? - ) -> ServerErrorCodeDetail { - ServerErrorCodeDetail( - code: code, statusCode: statusCode, summary: summary, reason: reason - ) + /// Builds the dedicated case for a typed ``CloudKitServerErrorCode``. + /// + /// `hint` for ``CloudKitError/quotaExceeded(reason:hint:)`` is enriched later + /// by the calling operation's catch block, which is the only place that can + /// see the local request state. + // swiftlint:disable:next cyclomatic_complexity + private static func make( + from code: CloudKitServerErrorCode, + statusCode: Int, + reason: String? + ) -> CloudKitError { + switch code { + case .accessDenied: + return .accessDenied(reason: reason) + case .atomicError: + return .atomicFailure(reason: reason) + case .authenticationFailed: + return .authenticationFailed(reason: reason) + case .authenticationRequired: + return .authenticationRequired(reason: reason) + case .badRequest: + return .badRequest(reason: reason) + case .conflict: + return .conflict(reason: reason) + case .exists: + return .exists(reason: reason) + case .internalError: + return .internalServerError(reason: reason) + case .notFound: + return .notFound(reason: reason) + case .quotaExceeded: + return .quotaExceeded(reason: reason, hint: nil) + case .throttled: + return .throttled(reason: reason) + case .tryAgainLater: + return .tryAgainLater(reason: reason) + case .validatingReferenceError: + return .validatingReferenceError(reason: reason) + case .zoneNotFound: + return .zoneNotFound(reason: reason) + case .unknown(let raw): + return .unknownServerError(code: raw, statusCode: statusCode, reason: reason) + } } } diff --git a/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift b/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift index 32d0c881..b43c3af6 100644 --- a/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift +++ b/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift @@ -31,8 +31,12 @@ /// /// Produced by `CloudKitError.serverErrorDetail` so that a code, the HTTP /// status Apple documents for it, and the human summary used in error -/// descriptions all live in exactly one place. +/// descriptions all live in exactly one place — ``CloudKitServerErrorCode``'s +/// catalog. internal struct ServerErrorCodeDetail: Sendable { + /// Summary used when the failure carried an unrecognized `serverErrorCode`. + internal static let unrecognizedSummary = "unrecognized server error" + /// The raw CloudKit `serverErrorCode` string, e.g. `"ACCESS_DENIED"`. internal let code: String /// The HTTP status Apple documents for `code`. @@ -49,4 +53,20 @@ internal struct ServerErrorCodeDetail: Sendable { self.summary = summary self.reason = reason } + + /// Builds a detail from a known catalog entry, storing catalog constants at + /// initialization and attaching the server-supplied `reason`. + /// + /// Returns `nil` when `code` is ``CloudKitServerErrorCode/unknown(_:)``. + internal init?(code: CloudKitServerErrorCode, reason: String?) { + guard let entry = code.catalogEntry else { + return nil + } + self.init( + code: entry.raw, + statusCode: entry.statusCode, + summary: entry.summary, + reason: reason + ) + } } diff --git a/Sources/MistKit/Models/CloudKitServerErrorCode.swift b/Sources/MistKit/Models/CloudKitServerErrorCode.swift index c17a0ca9..8dc99db5 100644 --- a/Sources/MistKit/Models/CloudKitServerErrorCode.swift +++ b/Sources/MistKit/Models/CloudKitServerErrorCode.swift @@ -34,6 +34,10 @@ /// codes. Mirrors CloudKit's documented `serverErrorCode` values; an /// ``unknown(_:)`` case carries any code not yet known to this version of /// MistKit so forward-compatibility never drops information. +/// +/// Wire string, documented HTTP status, and human summary for each known code +/// live in ``knownCatalog`` — the single source of truth used by both +/// ``CloudKitError`` construction and ``ServerErrorCodeDetail``. public enum CloudKitServerErrorCode: Codable, Hashable, Sendable { case accessDenied case atomicError @@ -52,36 +56,104 @@ public enum CloudKitServerErrorCode: Codable, Hashable, Sendable { /// A server error code not recognized by this version of MistKit. case unknown(String) - /// The known (case, raw CloudKit string) pairs — the single source of truth - /// for converting in both directions. - private static let knownPairs: [(code: CloudKitServerErrorCode, raw: String)] = [ - (.accessDenied, "ACCESS_DENIED"), - (.atomicError, "ATOMIC_ERROR"), - (.authenticationFailed, "AUTHENTICATION_FAILED"), - (.authenticationRequired, "AUTHENTICATION_REQUIRED"), - (.badRequest, "BAD_REQUEST"), - (.conflict, "CONFLICT"), - (.exists, "EXISTS"), - (.internalError, "INTERNAL_ERROR"), - (.notFound, "NOT_FOUND"), - (.quotaExceeded, "QUOTA_EXCEEDED"), - (.throttled, "THROTTLED"), - (.tryAgainLater, "TRY_AGAIN_LATER"), - (.validatingReferenceError, "VALIDATING_REFERENCE_ERROR"), - (.zoneNotFound, "ZONE_NOT_FOUND"), + /// Catalog row for one documented CloudKit `serverErrorCode`. + internal struct CatalogEntry: Sendable { + /// The typed case this row describes. + internal let code: CloudKitServerErrorCode + /// The raw CloudKit wire string, e.g. `"ACCESS_DENIED"`. + internal let raw: String + /// The HTTP status Apple documents for `raw`. + internal let statusCode: Int + /// Lowercase human summary used in error descriptions. + internal let summary: String + } + + /// The known catalog — single source of truth for raw string, status, and + /// summary in both directions. + internal static let knownCatalog: [CatalogEntry] = [ + CatalogEntry( + code: .accessDenied, raw: "ACCESS_DENIED", statusCode: 403, summary: "access denied" + ), + CatalogEntry( + code: .atomicError, raw: "ATOMIC_ERROR", statusCode: 400, summary: "atomic batch failure" + ), + CatalogEntry( + code: .authenticationFailed, raw: "AUTHENTICATION_FAILED", statusCode: 401, + summary: "authentication failed" + ), + CatalogEntry( + code: .authenticationRequired, raw: "AUTHENTICATION_REQUIRED", statusCode: 421, + summary: "authentication required" + ), + CatalogEntry( + code: .badRequest, raw: "BAD_REQUEST", statusCode: 400, summary: "bad request" + ), + CatalogEntry( + code: .conflict, raw: "CONFLICT", statusCode: 409, summary: "conflict" + ), + CatalogEntry( + code: .exists, raw: "EXISTS", statusCode: 409, summary: "already exists" + ), + CatalogEntry( + code: .internalError, raw: "INTERNAL_ERROR", statusCode: 500, summary: "internal server error" + ), + CatalogEntry( + code: .notFound, raw: "NOT_FOUND", statusCode: 404, summary: "not found" + ), + CatalogEntry( + code: .quotaExceeded, raw: "QUOTA_EXCEEDED", statusCode: 413, summary: "quota exceeded" + ), + CatalogEntry( + code: .throttled, raw: "THROTTLED", statusCode: 429, summary: "throttled" + ), + CatalogEntry( + code: .tryAgainLater, raw: "TRY_AGAIN_LATER", statusCode: 503, summary: "try again later" + ), + CatalogEntry( + code: .validatingReferenceError, raw: "VALIDATING_REFERENCE_ERROR", statusCode: 412, + summary: "reference validation error" + ), + CatalogEntry( + code: .zoneNotFound, raw: "ZONE_NOT_FOUND", statusCode: 404, summary: "zone not found" + ), ] + /// Lookup from raw CloudKit string → known case. + private static let byRawValue: [String: CloudKitServerErrorCode] = Dictionary( + uniqueKeysWithValues: knownCatalog.map { ($0.raw, $0.code) } + ) + + /// Lookup from known case → catalog row. + private static let byCode: [CloudKitServerErrorCode: CatalogEntry] = Dictionary( + uniqueKeysWithValues: knownCatalog.map { ($0.code, $0) } + ) + /// The raw CloudKit string for this code (e.g. `"NOT_FOUND"`). public var rawValue: String { if case .unknown(let raw) = self { return raw } - return Self.knownPairs.first { $0.code == self }?.raw ?? "" + return Self.byCode[self]?.raw ?? "" + } + + /// The HTTP status Apple documents for this code, or `nil` for ``unknown(_:)``. + public var statusCode: Int? { + Self.byCode[self]?.statusCode + } + + /// Lowercase human summary for this code, or `nil` for ``unknown(_:)``. + public var summary: String? { + Self.byCode[self]?.summary + } + + /// Catalog row for a known code, or `nil` for ``unknown(_:)``. + internal var catalogEntry: CatalogEntry? { + Self.byCode[self] } /// Maps a raw CloudKit string to a known case, or ``unknown(_:)``. public init(rawValue: String) { - self = Self.knownPairs.first { $0.raw == rawValue }?.code ?? .unknown(rawValue) + self = Self.byRawValue[rawValue] ?? .unknown(rawValue) } /// Decodes the code from its raw CloudKit string value. diff --git a/Sources/MistKit/Models/OperationFailure.swift b/Sources/MistKit/Models/OperationFailure.swift index e26acb14..1e563f49 100644 --- a/Sources/MistKit/Models/OperationFailure.swift +++ b/Sources/MistKit/Models/OperationFailure.swift @@ -92,13 +92,13 @@ public struct OperationFailure: ) { let serverErrorCode = ServerErrorCode(rawValue: common.serverErrorCode.rawValue) // The generated `OperationFailureServerErrorCode` is a closed enum mirroring - // the schema, so a `.unknown` here means our `knownPairs` table drifted + // the schema, so a `.unknown` here means our `knownCatalog` drifted // from the regenerated schema — assert loudly (test-overridable) while // still preserving the raw code for forward-compatibility in release. if case .unknown(let raw) = serverErrorCode { ConversionFailureReporter.assertionHandler( "Unmapped CloudKit serverErrorCode \"\(raw)\"" - + " — update CloudKitServerErrorCode.knownPairs", + + " — update CloudKitServerErrorCode.knownCatalog", #fileID, #line ) diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift index 20639cc3..42616492 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import Foundation internal import Testing @testable import MistKit diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift index 483398e9..6a1f82c7 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import Foundation internal import Testing @testable import MistKit diff --git a/Tests/MistKitTests/Models/ServerErrorCodeTests.swift b/Tests/MistKitTests/Models/ServerErrorCodeTests.swift index 82f2e584..e4a352bc 100644 --- a/Tests/MistKitTests/Models/ServerErrorCodeTests.swift +++ b/Tests/MistKitTests/Models/ServerErrorCodeTests.swift @@ -48,7 +48,7 @@ internal struct ServerErrorCodeTests { let code = RecordOperationFailure.ServerErrorCode(rawValue: rawValue) // A known raw must map to a concrete case, not the forward-compat fallback… if case .unknown = code { - Issue.record("\(rawValue) decoded as .unknown; missing from knownPairs") + Issue.record("\(rawValue) decoded as .unknown; missing from knownCatalog") } // …and re-encode back to the identical raw string. #expect(code.rawValue == rawValue) @@ -59,5 +59,16 @@ internal struct ServerErrorCodeTests { let code = RecordOperationFailure.ServerErrorCode(rawValue: "FUTURE_CODE") #expect(code == .unknown("FUTURE_CODE")) #expect(code.rawValue == "FUTURE_CODE") + #expect(code.statusCode == nil) + #expect(code.summary == nil) + } + + @Test("exposes catalog status and summary for every known code") + internal func knownCodesExposeCatalogMetadata() { + for entry in CloudKitServerErrorCode.knownCatalog { + #expect(entry.code.statusCode == entry.statusCode) + #expect(entry.code.summary == entry.summary) + #expect(entry.code.rawValue == entry.raw) + } } }