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. diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 00000000..90ff7397 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,127 @@ +#!/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. +# +# 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. +# +# 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 + +echo '{"async": true, "asyncTimeout": 2400000}' + +SETUP_LOG="$HOME/.claude-session-setup.log" +SETUP_DONE="$HOME/.claude-session-setup.done" +rm -f "$SETUP_DONE" +exec >> "$SETUP_LOG" 2>&1 + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" + +# 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 + +install_swift() { + # 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 + + 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 + echo "All system packages already present; skipping apt." + fi + + # 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 +} + +# 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 + +swift --version +touch "$SETUP_DONE" 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\"" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 6506fa44..35dc6812 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,10 @@ dev-debug.log # tasks/ .claude/scheduled_tasks.lock 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/AGENTS.md b/AGENTS.md index 9c55f839..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`). @@ -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/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/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/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/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/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 034e853d..3a312ff4 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 { @@ -53,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/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/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/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/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 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..e4206b89 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift @@ -0,0 +1,169 @@ +// +// 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. 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 ServerErrorCodeDetail(code: .accessDenied, reason: reason) + case .atomicFailure(let reason): + return ServerErrorCodeDetail(code: .atomicError, reason: reason) + case .authenticationFailed(let reason): + return ServerErrorCodeDetail(code: .authenticationFailed, reason: reason) + case .authenticationRequired(let reason): + return ServerErrorCodeDetail(code: .authenticationRequired, reason: reason) + case .badRequest(let reason): + return ServerErrorCodeDetail(code: .badRequest, reason: reason) + case .conflict(let reason): + return ServerErrorCodeDetail(code: .conflict, reason: reason) + case .exists(let reason): + return ServerErrorCodeDetail(code: .exists, reason: reason) + case .internalServerError(let reason): + return ServerErrorCodeDetail(code: .internalError, reason: reason) + case .notFound(let reason): + return ServerErrorCodeDetail(code: .notFound, reason: reason) + case .quotaExceeded(let reason, _): + return ServerErrorCodeDetail(code: .quotaExceeded, reason: reason) + case .throttled(let reason): + return ServerErrorCodeDetail(code: .throttled, reason: reason) + case .tryAgainLater(let reason): + return ServerErrorCodeDetail(code: .tryAgainLater, reason: reason) + case .validatingReferenceError(let reason): + return ServerErrorCodeDetail(code: .validatingReferenceError, reason: reason) + case .zoneNotFound(let reason): + return ServerErrorCodeDetail(code: .zoneNotFound, reason: reason) + case .unknownServerError(let code, let statusCode, let reason): + return ServerErrorCodeDetail( + code: code, + statusCode: statusCode, + summary: ServerErrorCodeDetail.unrecognizedSummary, + reason: 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, 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. + /// + /// - 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. + internal init(serverErrorCode code: String?, statusCode: Int, reason: String?) { + guard let code else { + self = .httpErrorWithDetails(statusCode: statusCode, reason: reason) + return + } + // 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 + ) + } + + /// 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/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/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/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/CloudKitService/ServerErrorCodeDetail.swift b/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift new file mode 100644 index 00000000..b43c3af6 --- /dev/null +++ b/Sources/MistKit/CloudKitService/ServerErrorCodeDetail.swift @@ -0,0 +1,72 @@ +// +// 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 — ``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`. + 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 + } + + /// 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/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/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 0a248a3c..1c5f2486 100644 --- a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -48,14 +48,14 @@ 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 ``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..338c7a3f 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -356,18 +356,16 @@ 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 … } ``` -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/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/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/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/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/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/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/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/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift new file mode 100644 index 00000000..42616492 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift @@ -0,0 +1,128 @@ +// +// 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 Foundation +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( + Query(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..6a1f82c7 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift @@ -0,0 +1,74 @@ +// +// 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 Foundation +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( + Query(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/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) + } } } 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 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 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):