From 1790789a491c2bc80b7206d65a7b072295affcef Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Mon, 18 May 2026 15:04:27 +0100 Subject: [PATCH] Docs: error handling, configuration, records, and limits articles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four DocC articles to close out the open documentation gaps tracked by parent issue #361: - HandlingErrors.md — three-layer error model (construction, token, request) with retry/recovery guidance. - ConfiguringMistKit.md — container/environment/transport/logging inputs; defers credential construction to AuthenticationAndDatabases. - WorkingWithRecords.md — CRUD, batch, lookup, and sync-via-token walks. - CloudKitLimitsAndPerformance.md — pagination guard, batch sizing, asset upload transport split, rate limits. Also adds inline `# Example` blocks to createRecord, updateRecord (CloudKitService+WriteOperations.swift) and lookupRecords (CloudKitService+LookupOperations.swift) to match the existing example style on queryRecords. modifyRecords and deleteRecord examples live in the WorkingWithRecords article rather than inline to keep the write- operations file under the file_length cap. Updates Documentation.md Topics to surface the new articles and adds an Error Handling group covering the typed error and reason enums. Migration guide (mentioned in #160) is deferred — MistKit is at 1.0.0-beta.x with no stable predecessor; that article should land alongside a future release transition. Closes #115 Closes #116 Closes #160 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CloudKitService+LookupOperations.swift | 18 ++ .../CloudKitService+WriteOperations.swift | 20 ++ .../CloudKitLimitsAndPerformance.md | 130 ++++++++++++ .../Documentation.docc/ConfiguringMistKit.md | 163 +++++++++++++++ .../Documentation.docc/Documentation.md | 16 ++ .../Documentation.docc/HandlingErrors.md | 183 +++++++++++++++++ .../Documentation.docc/WorkingWithRecords.md | 192 ++++++++++++++++++ 7 files changed, 722 insertions(+) create mode 100644 Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md create mode 100644 Sources/MistKit/Documentation.docc/ConfiguringMistKit.md create mode 100644 Sources/MistKit/Documentation.docc/HandlingErrors.md create mode 100644 Sources/MistKit/Documentation.docc/WorkingWithRecords.md diff --git a/Sources/MistKit/CloudKitService/CloudKitService+LookupOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+LookupOperations.swift index 842869e9..4996654e 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+LookupOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+LookupOperations.swift @@ -32,6 +32,24 @@ internal import MistKitOpenAPI extension CloudKitService { /// Lookup records by record names + /// - Parameters: + /// - recordNames: Record names to fetch + /// - desiredKeys: Optional array of field names to fetch + /// - database: The CloudKit database scope to read from (`.public`, `.private`, `.shared`) + /// - Returns: Array of RecordInfo for the matched records + /// - Throws: CloudKitError if the operation fails + /// + /// # Example: Bulk lookup with field projection + /// ```swift + /// let articles = try await service.lookupRecords( + /// recordNames: ["article-001", "article-002", "article-003"], + /// desiredKeys: ["title", "publishedDate"], + /// database: .private + /// ) + /// ``` + /// + /// - Note: Pass `desiredKeys` to limit which fields come back. Useful + /// for list views that only need a projection. public func lookupRecords( recordNames: [String], desiredKeys: [String]? = nil, diff --git a/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift index 5708b937..6fd2e864 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+WriteOperations.swift @@ -127,6 +127,15 @@ extension CloudKitService { /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the created record /// - Throws: CloudKitError if the operation fails + /// + /// # Example + /// ```swift + /// let article = try await service.createRecord( + /// recordType: "Article", + /// fields: ["title": .string("Hello, CloudKit")], + /// database: .private + /// ) + /// ``` public func createRecord( recordType: String, recordName: String? = nil, @@ -155,6 +164,17 @@ extension CloudKitService { /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the updated record /// - Throws: CloudKitError if the operation fails + /// + /// # Example + /// ```swift + /// let updated = try await service.updateRecord( + /// recordType: "Article", + /// recordName: existing.recordName, + /// fields: ["title": .string("Renamed")], + /// recordChangeTag: existing.recordChangeTag, + /// database: .private + /// ) + /// ``` public func updateRecord( recordType: String, recordName: String, diff --git a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md new file mode 100644 index 00000000..abe55f6c --- /dev/null +++ b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md @@ -0,0 +1,130 @@ +# CloudKit Limits and Performance + +CloudKit caps per-request work and per-account throughput. This article documents the limits MistKit enforces itself, the limits CloudKit enforces server-side, and the design choices in MistKit that shape performance. + +## Overview + +CloudKit Web Services is a remote API with per-request size limits and per-account rate limits. MistKit adds two small guards of its own — a page cap on auto-pagination and a transport-pool separation for asset uploads — and otherwise defers to CloudKit's documented limits. + +| Concern | Enforced where | Notes | +| --- | --- | --- | +| Records per query response | CloudKit | Max 200; the `limit` parameter is validated 1–200. | +| Pages per auto-paginated query | MistKit | `maxPages: 1_000` on ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)``. | +| Records per modify batch | CloudKit | Practical cap around 200; chunk larger batches client-side. | +| Asset upload size / connection pool | MistKit (transport separation) | `URLSession.shared` used for CDN uploads to avoid HTTP/2 reuse with the API host. | +| Requests per second | CloudKit | Server-side rate limit; surfaces as 503/429. | + +## Pagination guard + +``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks CloudKit's `continuationMarker` for you. Two safeguards prevent runaway iteration: + +1. **`maxPages` cap (default `1_000`)** — if the auto-paginator hits the cap, it throws ``CloudKitError/paginationLimitExceeded(maxPages:records:)``. The records collected so far are attached to the error so the caller can resume from a narrowed query or accept a partial result. +2. **Stuck-marker detection** — if CloudKit returns an empty page with the same continuation marker it just gave you, the paginator stops cleanly rather than spinning. This guards against a server-side bug pattern where the cursor never advances. + +```swift +do { + let all = try await service.queryAllRecords( + recordType: "AuditEvent", + pageSize: 200, + database: .private + ) +} catch CloudKitError.paginationLimitExceeded(let cap, let partial) { + // Either accept partial, raise `maxPages`, or narrow the query with filters. +} +``` + +Raise `maxPages` when you know the result set is genuinely large. Narrow filters when you don't — most production queries return well under 1,000 pages. + +## Batching writes + +CloudKit's `/records/modify` endpoint accepts a batch of operations in a single round-trip. The practical server-side cap is around 200 operations per request. ``CloudKitService/modifyRecords(_:atomic:database:)`` does not chunk for you — split larger batches yourself: + +```swift +let chunked = stride(from: 0, to: operations.count, by: 200).map { + Array(operations[$0.. Warning: CloudKit's API host (`api.apple-cloudkit.com`) and asset CDN (`cvws.icloud-content.com`) are different origins. Reusing the same HTTP/2 connection across both produces 421 Misdirected Request errors. Asset uploads keep a separate connection pool to avoid this. + +This has two implications for consumers: + +1. **Custom transports do not see asset upload bytes.** If you instrumented `transport:` to log requests, asset uploads will be missing. +2. **A custom ``AssetUploader`` must keep its connection pool separate from the API host.** The default implementation uses `URLSession.shared`; only override it for testing or specialized CDN configurations, and never share an HTTP/2 connection with `api.apple-cloudkit.com`. + +```swift +// Default: production-safe, separate connection pool. +let receipt = try await service.uploadAssets( + data: imageData, + recordType: "Photo", + fieldName: "image", + database: .private +) + +// Testing: inject a mock uploader. +let receipt = try await service.uploadAssets( + data: imageData, + recordType: "Photo", + fieldName: "image", + using: { data, url in (statusCode: 200, data: Data()) }, + database: .private +) +``` + +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. + +## Rate limiting + +CloudKit enforces per-account and per-container rate limits server-side. The exact thresholds vary by account tier and are documented by Apple — MistKit does not duplicate the canonical numbers. Surfaces are: + +- HTTP 503 with retry-after — back off and retry. +- HTTP 429 — back off and retry. +- HTTP 5xx generally — treat as transient. + +See for the retry helper pattern. Read ``CloudKitError/httpStatusCode`` to branch on status without switching on every `httpError*` case. + +> Tip: Hammering the API from a hot loop (sync jobs, migrations) is the usual way to trip rate limits. Throttle long-running tasks to a few requests per second per container, and concentrate concurrency in `modifyRecords` batches rather than in many parallel single-record calls. + +## Connection reuse for the API host + +By default ``CloudKitService`` uses `URLSessionTransport` from `swift-openapi-urlsession`, which gives you HTTP/2 multiplexing against `api.apple-cloudkit.com` automatically. There is no per-call session — every operation through one ``CloudKitService`` shares the underlying URLSession's connection pool, so a burst of operations does not pay TCP/TLS setup per call. + +For custom transports, prefer one transport per `CloudKitService` and reuse the same `CloudKitService` across calls. Creating a fresh service per request defeats connection reuse. + +## Topics + +### Limits + +- ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` +- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` +- ``CloudKitService/modifyRecords(_:atomic:database:)`` + +### Asset uploads + +- ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:using:database:)`` +- ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:database:)`` +- ``CloudKitService/uploadAssetData(_:to:using:)`` +- ``AssetUploader`` + +### See Also + +- +- +- diff --git a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md new file mode 100644 index 00000000..0a248a3c --- /dev/null +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -0,0 +1,163 @@ +# Configuring MistKit + +There is no single `MistKitConfiguration` type — configuration is what you pass to ``CloudKitService``: a container identifier, an ``Environment``, ``Credentials``, and (optionally) a custom transport. + +## Overview + +A configured MistKit setup is one call: + +```swift +let service = CloudKitService( + containerIdentifier: "iCloud.com.example.MyApp", + credentials: credentials, + environment: .production +) +``` + +Everything else — which ``Database`` to use, which signing method on the public database, which token to refresh — is decided per call. This article covers the construction-time inputs (container, environment, transport, logging). For credentials and per-call database selection, see . + +## Container identifier + +The container identifier is the iCloud container your records live in. It is the same string you see in the CloudKit Dashboard under **Container ID**, prefixed with `iCloud.`: + +```swift +"iCloud.com.example.MyApp" +``` + +A single container has separate `development` and `production` schemas, separate record stores, and separate user data. You do not switch containers between environments — you switch ``Environment``. + +> Tip: Containers are configured in the [CloudKit Dashboard](https://icloud.developer.apple.com). The container identifier is also visible in your Xcode app target's CloudKit capability. + +## Environment selection + +``Environment`` has two cases: ``Environment/development`` and ``Environment/production``. They map to distinct CloudKit schemas and stores in the same container: + +| Environment | Used for | +| --- | --- | +| `.development` | Schema migrations, local testing, staging deploys | +| `.production` | Released apps, production backends | + +Drive the choice from configuration, not source: + +```swift +let environment: Environment = ProcessInfo.processInfo + .environment["CLOUDKIT_ENVIRONMENT"] + .flatMap(Environment.init(caseInsensitive:)) + ?? .development +``` + +``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:)``. + +## 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.createRecord( + recordType: "FeaturedPost", + fields: fields, + database: .public(.requires(.serverToServer)) +) +``` + +The configuration question for your app is: which credentials does the deployment need to populate so the call sites it makes are reachable? An app that only queries the private database needs a web-auth token. An app that also seeds the public database with server-attributed writes needs server-to-server credentials. An app that does both populates both fields on ``Credentials``. See for the full credential/database matrix. + +## Custom transport + +The default public initializer uses `URLSessionTransport` from `swift-openapi-urlsession`. For testing, request inspection, or platforms without URLSession, supply your own ``ClientTransport`` via the generic initializer: + +```swift +let service = CloudKitService( + containerIdentifier: container, + credentials: credentials, + environment: .development, + transport: customTransport +) +``` + +Common reasons to override the transport: + +- **Tests** — substitute a mock transport that asserts on outgoing requests and returns canned responses. +- **Instrumentation** — wrap `URLSessionTransport` to record request/response pairs for debugging. +- **WASI** — `URLSession` is unavailable, so the WASI build path requires a transport you provide (the public URLSession initializer is gated behind `#if !os(WASI)`). + +> Warning: Asset uploads do **not** flow through the configured `transport`. They use `URLSession.shared` directly to avoid HTTP/2 connection reuse between CloudKit's API host and the CDN, which surfaces as 421 Misdirected Request errors. See for the full rationale. + +## Logging + +MistKit uses [swift-log](https://github.com/apple/swift-log). The package emits to four labeled subsystems; consumers install a `LogHandler` and choose verbosity per subsystem. + +| Subsystem label | Use | +| --- | --- | +| `com.brightdigit.MistKit.api` | CloudKit API operations | +| `com.brightdigit.MistKit.auth` | Authentication and token management | +| `com.brightdigit.MistKit.network` | Network errors | +| `com.brightdigit.MistKit.middleware` | HTTP request/response traces (debug only) | + +Bootstrap the logging system once at process start: + +```swift +import Logging + +LoggingSystem.bootstrap(StreamLogHandler.standardOutput) +``` + +To raise the middleware subsystem to `.debug` for troubleshooting without flooding the rest: + +```swift +LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + if label == "com.brightdigit.MistKit.middleware" { + handler.logLevel = .debug + } + return handler +} +``` + +> Note: Protocol traces — request/response bodies, headers, query params — are only emitted at `.debug`. The middleware guards expensive work (1 MiB body collection, query-param parsing) behind a level check, so the default `.info` level pays no overhead. + +There is no built-in redaction. Sensitive data (tokens, raw bodies) appears only at `.debug`; control exposure by leaving the middleware subsystem at `.info` or higher in production. + +## Environment-variable patterns + +MistKit doesn't prescribe a secrets store, but the conventional env-var names line up with what's documented in and used by the in-repo `MistDemo` integration runner: + +| Variable | Used for | +| --- | --- | +| `CLOUDKIT_CONTAINER` | Container identifier | +| `CLOUDKIT_ENVIRONMENT` | `development` or `production` | +| `CLOUDKIT_API_TOKEN` | API token (public database read, web-auth attribution) | +| `CLOUDKIT_WEB_AUTH_TOKEN` | Web-auth token (private/shared, user-identity routes) | +| `CLOUDKIT_KEY_ID` | Server-to-server key ID | +| `CLOUDKIT_PRIVATE_KEY` / `CLOUDKIT_PRIVATE_KEY_PATH` | ECDSA P-256 PEM material (inline or file) | + +A small helper keeps call sites tidy and fails closed on missing values: + +```swift +func env(_ key: String) throws -> String { + guard let value = ProcessInfo.processInfo.environment[key], + !value.isEmpty else { + throw ConfigError.missing(key) + } + return value +} +``` + +> Tip: Validate every required variable at startup, before constructing ``CloudKitService``. Missing or malformed credentials surface as ``CloudKitError/missingCredentials(database:availability:reason:)`` at the first call that needs them — much later than you want to discover the deployment is misconfigured. + +## Topics + +### Configuration + +- ``CloudKitService`` +- ``Environment`` +- ``Database`` + +### See Also + +- +- +- diff --git a/Sources/MistKit/Documentation.docc/Documentation.md b/Sources/MistKit/Documentation.docc/Documentation.md index e2601ee6..b9f96399 100644 --- a/Sources/MistKit/Documentation.docc/Documentation.md +++ b/Sources/MistKit/Documentation.docc/Documentation.md @@ -76,6 +76,9 @@ MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. S ### Getting Started - +- +- +- - ``CloudKitService`` - ``Database`` - ``Environment`` @@ -130,6 +133,19 @@ MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. S - ``TokenManagerError`` - ``TokenStorageError`` +### Error Handling + +- +- ``CloudKitError`` +- ``TokenManagerError`` +- ``TokenStorageError`` +- ``CredentialsValidationError`` +- ``InvalidCredentialReason`` +- ``AuthenticationFailedReason`` +- ``NetworkErrorReason`` +- ``InternalErrorReason`` +- ``CredentialAvailability`` + ### Record management - ``CloudKitRecord`` diff --git a/Sources/MistKit/Documentation.docc/HandlingErrors.md b/Sources/MistKit/Documentation.docc/HandlingErrors.md new file mode 100644 index 00000000..9b6ddc22 --- /dev/null +++ b/Sources/MistKit/Documentation.docc/HandlingErrors.md @@ -0,0 +1,183 @@ +# Handling Errors + +Three layers of typed errors — construction, authentication, and request — surface MistKit failures with enough detail to route, retry, or report. + +## Overview + +Every MistKit failure is one of a small set of typed errors thrown at a specific layer: + +| Layer | Error type | Surfaces from | +| --- | --- | --- | +| Construction | ``CredentialsValidationError`` | ``Credentials`` initializer | +| Token management | ``TokenManagerError`` | Token manager resolution and refresh | +| Token storage | ``TokenStorageError`` | Custom ``TokenStorage`` implementations | +| Request | ``CloudKitError`` | Every ``CloudKitService`` operation | + +Operation methods declare typed throws — `async throws(CloudKitError)` — so the compiler enforces exhaustive switching at the call site if you choose to switch. + +## Construction-time validation + +``Credentials`` runs cheap validation on the values you pass in. The only thrown case is ``CredentialsValidationError/empty``, raised when neither ``Credentials/serverToServer`` nor ``Credentials/apiAuth`` is populated: + +```swift +do { + let credentials = try Credentials() // both nil +} catch CredentialsValidationError.empty { + // Misconfigured deployment — log and exit, this is a programming error. +} +``` + +Deeper per-field reasons (empty token, malformed PEM, key-ID format) live in ``InvalidCredentialReason`` and surface later, wrapped in ``TokenManagerError/invalidCredentials(_:)``, when the credentials are actually used to authenticate. + +> Note: In debug builds, ``Credentials/init(serverToServer:apiAuth:)`` asserts on empty input. In release it throws. Treat this as a configuration check at process start, not a runtime branch. + +## Token manager errors + +``TokenManagerError`` is thrown from authentication resolution — when MistKit picks a token manager for the current call's ``Database``, validates its inputs, or signs the outgoing request. + +| Case | Recoverable? | Typical cause | +| --- | --- | --- | +| ``TokenManagerError/invalidCredentials(_:)`` | No | Malformed token or key surfaces at first use | +| ``TokenManagerError/authenticationFailed(_:)`` | Sometimes | Server-side rejection or signing error | +| ``TokenManagerError/tokenExpired`` | Yes | Refresh the web-auth token and retry | +| ``TokenManagerError/networkError(_:)`` | Yes | Transient connectivity during auth | +| ``TokenManagerError/internalError(_:)`` | No | Bug or missing platform support | + +The wrapped reason enums (``InvalidCredentialReason``, ``AuthenticationFailedReason``, ``NetworkErrorReason``, ``InternalErrorReason``) carry the concrete cause and a human-readable `description`. Switch on the wrapper first, then on the reason if you need to act on it: + +```swift +do { + _ = try await service.fetchCaller() +} catch let error as TokenManagerError { + switch error { + case .tokenExpired: + try await refreshWebAuthToken() + case .invalidCredentials(let reason): + logger.error("Bad credentials: \(reason.description)") + case .networkError, .authenticationFailed, .internalError: + throw error + } +} +``` + +> Tip: ``TokenManagerError/invalidCredentials(_:)`` with reason ``InvalidCredentialReason/serverToServerOnlySupportsPublicDatabase(_:)`` indicates a programming error — you tried `.private` or `.shared` with server-to-server credentials. Fix the call site; do not retry. + +## Token storage errors + +``TokenStorageError`` is for custom ``TokenStorage`` implementations — keychain wrappers, encrypted file stores, remote secret managers. The built-in `InMemoryTokenStorage` never throws it. If you implement ``TokenStorage`` yourself, raise these so MistKit's diagnostics stay consistent: + +- ``TokenStorageError/storageFailed(reason:)`` — write failed, include backend detail +- ``TokenStorageError/notFound(identifier:)`` — credential lookup missed +- ``TokenStorageError/accessDenied`` — OS-level permission failure (keychain locked, etc.) +- ``TokenStorageError/corruptedStorage`` — stored bytes failed to deserialize + +## Request errors + +Every operation on ``CloudKitService`` throws ``CloudKitError``. The cases group naturally by recoverability: + +| 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/httpErrorWithRawResponse(statusCode:rawResponse:)`` | Sometimes | Validation rejection or unparseable error body | +| ``CloudKitError/invalidResponse`` | No | Server returned 2xx but no payload | +| ``CloudKitError/networkError(_:)`` | Often | URLSession-level failure (timeout, DNS, TLS) | +| ``CloudKitError/decodingError(_:)`` | No | Schema mismatch between OpenAPI client and CloudKit | +| ``CloudKitError/underlyingError(_:)`` | Depends | Unclassified throw from the transport stack | +| ``CloudKitError/unsupportedOperationType(_:)`` | No | Bug — server returned a record op MistKit doesn't model | +| ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` | Yes — but inspect | Auto-paginator hit its guard with records still collected | +| ``CloudKitError/missingCredentials(database:availability:reason:)`` | No | The configured ``Credentials`` don't cover the call's database/auth combo | +| ``CloudKitError/invalidPrivateKey(path:underlying:)`` | No | S2S key file is missing, unreadable, or not a valid ECDSA P-256 PEM | + +Use ``CloudKitError/httpStatusCode`` to read the status code uniformly across the three `httpError*` cases without switching: + +```swift +do { + let records = try await service.queryRecords( + recordType: "Note", + database: .private + ).records +} catch let error as CloudKitError { + if let status = error.httpStatusCode, (500..<600).contains(status) { + // Transient — caller can retry with backoff. + throw RetryableError.serverError(status) + } + throw error +} +``` + +### `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: + +```swift +do { + let all = try await service.queryAllRecords( + recordType: "AuditEvent", + pageSize: 200, + database: .private + ) +} catch CloudKitError.paginationLimitExceeded(let maxPages, let partial) { + logger.warning("Stopped after \(maxPages) pages with \(partial.count) records") + // Either accept the partial result, raise the cap, or narrow the query. +} +``` + +## Retry and recovery + +Recoverable failures fall into three buckets: + +1. **Transient network** (``CloudKitError/networkError(_:)``, ``TokenManagerError/networkError(_:)``) — retry with exponential backoff and a jitter, bounded to ~3 attempts. +2. **Expired web-auth token** (``TokenManagerError/tokenExpired``) — refresh the token in your token store, then retry the call once. +3. **5xx HTTP** (``CloudKitError/httpStatusCode`` in `500..<600`) — same backoff strategy as transient network. + +Everything else — auth failures, decoding errors, missing credentials, 4xx HTTP — is a programming or configuration error. Surface it, do not retry. + +A minimal retry helper: + +```swift +func retrying( + attempts: Int = 3, + _ operation: () async throws -> T +) async throws -> T { + var lastError: any Error + for attempt in 1...attempts { + do { + return try await operation() + } catch let error as CloudKitError where isTransient(error) { + lastError = error + try await Task.sleep(for: .milliseconds(100 * (1 << attempt))) + } catch { + throw error + } + } + throw lastError +} + +func isTransient(_ error: CloudKitError) -> Bool { + if case .networkError = error { return true } + if let status = error.httpStatusCode, (500..<600).contains(status) { + return true + } + return false +} +``` + +> Warning: Do not retry ``CloudKitError/missingCredentials(database:availability:reason:)`` or ``CloudKitError/invalidPrivateKey(path:underlying:)``. These indicate the deployment is misconfigured and the next attempt will fail identically. + +## Topics + +### Error types + +- ``CloudKitError`` +- ``TokenManagerError`` +- ``TokenStorageError`` +- ``CredentialsValidationError`` + +### Failure reasons + +- ``InvalidCredentialReason`` +- ``AuthenticationFailedReason`` +- ``NetworkErrorReason`` +- ``InternalErrorReason`` +- ``CredentialAvailability`` diff --git a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md new file mode 100644 index 00000000..18ead4d6 --- /dev/null +++ b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md @@ -0,0 +1,192 @@ +# Working with Records + +CRUD, batch, and lookup against CloudKit records — the operations you'll reach for most often, with idiomatic ``CloudKitService`` snippets. + +## Overview + +``CloudKitService`` exposes the CloudKit record lifecycle as a handful of focused async methods. This article walks the full surface so you can pick the right one without reading every operation file. For per-method examples, see the inline DocC on each method; for sync-via-change-tokens, see the linked sync section at the end; for limits and performance, see . + +## 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(_:)``: + +```swift +let result = try await service.queryRecords( + recordType: "Article", + filters: [ + .greaterThan("publishedDate", .date(oneWeekAgo)), + .equals("status", .string("published")) + ], + sortBy: [.descending("publishedDate")], + limit: 50, + database: .private +) +for record in result.records { + print(record.recordName) +} +``` + +For unbounded iteration, ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks the continuation marker for you with a safety guard at `maxPages` (default `1_000`): + +```swift +let allArticles = try await service.queryAllRecords( + recordType: "Article", + pageSize: 200, + database: .private +) +``` + +> Warning: If `queryAllRecords` hits its page cap, it throws ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` with the records collected so far. See for the recovery pattern. + +## Creating + +Use ``CloudKitService/createRecord(recordType:recordName:fields:database:)`` for a single create. Fields are a `[String: FieldValue]` dictionary — every CloudKit scalar plus references, locations, assets, and lists are modeled in ``FieldValue``: + +```swift +let article = try await service.createRecord( + recordType: "Article", + fields: [ + "title": .string("Hello, CloudKit"), + "body": .string("First post."), + "wordCount": .int64(2), + "publishedDate": .date(Date()) + ], + database: .private +) +``` + +Omit `recordName` to let CloudKit generate one; pass an explicit string when you need a stable identifier you can lookup later. + +## Updating + +Use ``CloudKitService/updateRecord(recordType:recordName:fields:recordChangeTag:database:)``. Pass `recordChangeTag` to opt into optimistic concurrency — CloudKit rejects the write if the record has been modified since you read it: + +```swift +let updated = try await service.updateRecord( + recordType: "Article", + recordName: existing.recordName, + fields: ["title": .string("Hello, CloudKit (revised)")], + recordChangeTag: existing.recordChangeTag, + database: .private +) +``` + +> Tip: Omitting `recordChangeTag` lets the write win unconditionally (last-writer-wins). Pass the tag whenever the user's intent depends on seeing the current state — collaborative editing, counters, anything where a stale read produces wrong results. + +## Deleting + +Use ``CloudKitService/deleteRecord(recordType:recordName:recordChangeTag:database:)``: + +```swift +try await service.deleteRecord( + recordType: "Article", + recordName: stale.recordName, + database: .private +) +``` + +Pass `recordChangeTag` to refuse the delete if the record changed since you read it. + +## Batching + +When you need to create, update, and delete in one round-trip, use ``CloudKitService/modifyRecords(_:atomic:database:)`` with an array of ``RecordOperation`` values. The convenience factories ``RecordOperation/create(recordType:recordName:fields:)``, ``RecordOperation/update(recordType:recordName:fields:recordChangeTag:)``, and ``RecordOperation/delete(recordType:recordName:recordChangeTag:)`` keep call sites readable: + +```swift +let results = try await service.modifyRecords( + [ + .create( + recordType: "Article", + fields: ["title": .string("New")] + ), + .update( + recordType: "Article", + recordName: "existing-id", + fields: ["title": .string("Renamed")], + recordChangeTag: existing.recordChangeTag + ), + .delete( + recordType: "Article", + recordName: "old-id" + ) + ], + atomic: true, + database: .private +) +``` + +| `atomic:` | Behavior | +| --- | --- | +| `false` (default) | Per-operation success/failure. Successful ops commit; failed ops surface in the response. | +| `true` | All-or-nothing. If any op fails, none commit. | + +Choose `atomic: true` when the operations are semantically linked (paired updates, a transactional rename) and `false` when independent operations are batched purely for throughput. + +> Note: CloudKit caps batch size around 200 operations per request. See for batching guidance. + +## Looking up + +Use ``CloudKitService/lookupRecords(recordNames:desiredKeys:database:)`` to fetch known records by name: + +```swift +let articles = try await service.lookupRecords( + recordNames: ["article-001", "article-002", "article-003"], + desiredKeys: ["title", "publishedDate"], + database: .private +) +``` + +Pass `desiredKeys` to limit which fields come back — useful for list views that only need a subset. + +## Syncing via change tokens + +For incremental sync — pulling only what changed since the last fetch — use ``CloudKitService/fetchRecordChanges(zoneID:syncToken:resultsLimit:database:)`` (single page) or ``CloudKitService/fetchAllRecordChanges(recordType:syncToken:)`` (auto-paginated). The returned ``RecordChangesResult`` carries a fresh `syncToken` to persist for the next call: + +```swift +var token: String? = loadStoredToken() +repeat { + let result = try await service.fetchRecordChanges( + syncToken: token, + database: .private + ) + process(result.records) + token = result.syncToken +} while result.moreComing +saveToken(token) +``` + +The inline DocC on these methods carries fuller examples for initial-vs-incremental sync. + +## Topics + +### Read operations + +- ``CloudKitService/queryRecords(recordType:filters:sortBy:limit:desiredKeys:continuationMarker:database:)`` +- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` +- ``CloudKitService/lookupRecords(recordNames:desiredKeys:database:)`` + +### Write operations + +- ``CloudKitService/createRecord(recordType:recordName:fields:database:)`` +- ``CloudKitService/updateRecord(recordType:recordName:fields:recordChangeTag:database:)`` +- ``CloudKitService/deleteRecord(recordType:recordName:recordChangeTag:database:)`` +- ``CloudKitService/modifyRecords(_:atomic:database:)`` + +### Sync + +- ``CloudKitService/fetchRecordChanges(zoneID:syncToken:resultsLimit:database:)`` +- ``CloudKitService/fetchAllRecordChanges(recordType:syncToken:)`` +- ``RecordChangesResult`` + +### Building filters and sorts + +- ``QueryFilter`` +- ``QuerySort`` +- ``FieldValue`` +- ``RecordOperation`` +- ``RecordInfo`` + +### See Also + +- +- +-