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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
130 changes: 130 additions & 0 deletions Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md
Original file line number Diff line number Diff line change
@@ -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..<min($0 + 200, operations.count)])
}
for chunk in chunked {
_ = try await service.modifyRecords(chunk, atomic: false, database: .private)
}
```

| Choice | When to prefer |
| --- | --- |
| Single-op (`createRecord`, etc.) | Independent operations triggered by user actions; readability over throughput |
| Batched (`modifyRecords`, `atomic: false`) | Throughput-bound work where independent operations can share a request |
| Batched (`modifyRecords`, `atomic: true`) | Semantically linked operations that must commit together |

`atomic: true` is more expensive server-side and fails the entire batch on any one failure. Use it only when the operations are genuinely transactional.

## Asset upload transport

Asset uploads are a two-step workflow: ``CloudKitService/requestAssetUploadURL(recordType:fieldName:recordName:database:)`` returns a one-time URL on `cvws.icloud-content.com`, then ``CloudKitService/uploadAssetData(_:to:using:)`` PUTs the bytes there. MistKit's high-level ``CloudKitService/uploadAssets(data:recordType:fieldName:recordName:using:database:)`` chains both steps.

The CDN upload deliberately does **not** flow through the configured ``ClientTransport``. It uses `URLSession.shared` directly:

> 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 <doc:HandlingErrors> 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

- <doc:WorkingWithRecords>
- <doc:HandlingErrors>
- <doc:ConfiguringMistKit>
163 changes: 163 additions & 0 deletions Sources/MistKit/Documentation.docc/ConfiguringMistKit.md
Original file line number Diff line number Diff line change
@@ -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 <doc:AuthenticationAndDatabases>.

## 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 <doc:AuthenticationAndDatabases> 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 <doc:CloudKitLimitsAndPerformance> 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 <doc:AuthenticationAndDatabases> 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

- <doc:AuthenticationAndDatabases>
- <doc:HandlingErrors>
- <doc:CloudKitLimitsAndPerformance>
16 changes: 16 additions & 0 deletions Sources/MistKit/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. S
### Getting Started

- <doc:AbstractionLayerArchitecture>
- <doc:ConfiguringMistKit>
- <doc:WorkingWithRecords>
- <doc:CloudKitLimitsAndPerformance>
- ``CloudKitService``
- ``Database``
- ``Environment``
Expand Down Expand Up @@ -130,6 +133,19 @@ MistKit runs on macOS, iOS, tvOS, watchOS, visionOS, Linux, WASI, and Windows. S
- ``TokenManagerError``
- ``TokenStorageError``

### Error Handling

- <doc:HandlingErrors>
- ``CloudKitError``
- ``TokenManagerError``
- ``TokenStorageError``
- ``CredentialsValidationError``
- ``InvalidCredentialReason``
- ``AuthenticationFailedReason``
- ``NetworkErrorReason``
- ``InternalErrorReason``
- ``CredentialAvailability``

### Record management

- ``CloudKitRecord``
Expand Down
Loading
Loading