diff --git a/.codefactor.yml b/.codefactor.yml index 5ba2c8a0..3bb3cbe7 100644 --- a/.codefactor.yml +++ b/.codefactor.yml @@ -1,2 +1,3 @@ exclude: - "Scripts/mermaid-to-pptx.py" + - "Examples/MistDemo/Sources/MistDemoKit/Resources/js/**" diff --git a/.github/actions/setup-mistkit/action.yml b/.github/actions/setup-mistkit/action.yml index 70a23028..6cfbda11 100644 --- a/.github/actions/setup-mistkit/action.yml +++ b/.github/actions/setup-mistkit/action.yml @@ -1,5 +1,5 @@ name: Setup MistKit -description: Replaces the local MistKit path dependency with a remote branch reference +description: Replaces the local MistKit path dependency with a remote reference, pinned to the branch's current commit inputs: branch: @@ -8,19 +8,43 @@ inputs: runs: using: composite steps: + # Resolve the branch to its current HEAD commit and pin the dependency by + # `revision:` rather than `branch:`. This makes the dependency content-addressed, + # so `swift package dump-package` (which swift-build@v1 hashes for its cache key) + # changes whenever the MistKit branch advances — otherwise a new MistKit commit on + # the same branch yields a stale cache hit and is never rebuilt. Falls back to a + # `branch:` pin if the ref can't be resolved (e.g. offline). - name: Update Package.swift (Unix) if: inputs.branch != '' && runner.os != 'Windows' shell: bash run: | + BRANCH='${{ inputs.branch }}' + REF=$(git ls-remote https://github.com/brightdigit/MistKit.git "$BRANCH" | head -n1 | cut -f1) + if [ -n "$REF" ]; then + REQ='revision: "'"$REF"'"' + echo "Pinning MistKit to $BRANCH @ $REF" + else + REQ='branch: "'"$BRANCH"'"' + echo "Could not resolve $BRANCH to a commit; pinning by branch" + fi if [ "$RUNNER_OS" = "macOS" ]; then - sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift + sed -i '' 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", '"$REQ"')|g' Package.swift else - sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", branch: "'"${{ inputs.branch }}"'")|g' Package.swift + sed -i 's|\.package(name: "MistKit", path: "\.\./\.\.")|.package(url: "https://github.com/brightdigit/MistKit.git", '"$REQ"')|g' Package.swift fi rm -f Package.resolved - name: Update Package.swift (Windows) if: inputs.branch != '' && runner.os == 'Windows' shell: pwsh run: | - (Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", branch: `"${{ inputs.branch }}`")" | Set-Content Package.swift + $branch = '${{ inputs.branch }}' + $ref = (git ls-remote https://github.com/brightdigit/MistKit.git $branch | Select-Object -First 1) -split "`t" | Select-Object -First 1 + if ($ref) { + $req = "revision: `"$ref`"" + Write-Host "Pinning MistKit to $branch @ $ref" + } else { + $req = "branch: `"$branch`"" + Write-Host "Could not resolve $branch to a commit; pinning by branch" + } + (Get-Content Package.swift) -replace '\.package\(name: "MistKit", path: "\.\./\.\."\)', ".package(url: `"https://github.com/brightdigit/MistKit.git`", $req)" | Set-Content Package.swift Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue diff --git a/.github/workflows/MistDemo.yml b/.github/workflows/MistDemo.yml index d7a4ee70..747f2378 100644 --- a/.github/workflows/MistDemo.yml +++ b/.github/workflows/MistDemo.yml @@ -201,6 +201,11 @@ jobs: name: Build on macOS runs-on: macos-26 if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + # Forward CI=true into the iOS simulator's test-runner process so + # `TestPlatform.isFlakyTimeoutSimulator` tolerates the cooperative-executor + # timeout flakes on the iOS sim. See the matching env on build-macos-platforms. + env: + TEST_RUNNER_CI: "true" strategy: fail-fast: false matrix: @@ -248,6 +253,17 @@ jobs: needs: configure runs-on: macos-26 if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + # Forward CI=true into the simulator's test-runner process. `xcodebuild + # test` re-exports any host env var prefixed `TEST_RUNNER_` to the test + # runner with the prefix stripped, so `TEST_RUNNER_CI` arrives as `CI` + # inside the sim. An env dump (EnvironmentDiagnosticTests) confirmed the + # earlier `SIMCTL_CHILD_CI` approach never reached the runner — those vars + # only apply to `simctl spawn`/`launch`, not xctest — which is why the + # cooperative-executor flake gates in #334 still surfaced as real failures. + # With `CI` now visible, `TestPlatform.isFlakyTimeoutSimulator` tolerates + # the iOS/watchOS/visionOS sim flakes instead. + env: + TEST_RUNNER_CI: "true" strategy: fail-fast: false matrix: diff --git a/.swiftlint.yml b/.swiftlint.yml index 7f5af019..849f7194 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -11,7 +11,6 @@ opt_in_rules: - contains_over_range_nil_comparison - convenience_type - discouraged_object_literal - - discouraged_optional_boolean - empty_collection_literal - empty_count - empty_string @@ -122,6 +121,7 @@ excluded: - .build - Mint - Examples + - Packages - Sources/MistKitOpenAPI - Package.swift indentation_width: diff --git a/CLAUDE.md b/CLAUDE.md index 657ffea7..294bdae1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,8 +104,18 @@ swift run mistdemo demo-errors swift run mistdemo test-public swift run mistdemo test-private -# Run with specific configuration -swift run mistdemo --config-file ~/.mistdemo/config.json query +# Configuration (no config-file flag — MistDemo uses Swift Configuration): +# highest priority first — (1) CLI args, (2) CLOUDKIT_-prefixed env vars, +# (3) a .env file in the working dir (Examples/MistDemo/.env, CLOUDKIT_-prefixed), +# (4) in-memory defaults. Provide credentials via env vars or .env, e.g.: +# CLOUDKIT_CONTAINER_ID=iCloud.com.yourorg.yourapp +# CLOUDKIT_ENVIRONMENT=development +# CLOUDKIT_API_TOKEN=… CLOUDKIT_WEB_AUTH_TOKEN=… # web-auth scopes +# CLOUDKIT_KEY_ID=… CLOUDKIT_PRIVATE_KEY[_PATH]=… # server-to-server +# Recognized keys: CLOUDKIT_CONTAINER_ID, CLOUDKIT_DATABASE, CLOUDKIT_ENVIRONMENT, +# CLOUDKIT_API_TOKEN, CLOUDKIT_WEB_AUTH_TOKEN, CLOUDKIT_KEY_ID, CLOUDKIT_PRIVATE_KEY, +# CLOUDKIT_PRIVATE_KEY_PATH, CLOUDKIT_LOOKUP_EMAIL, CLOUDKIT_ERROR. +swift run mistdemo query ``` ## Architecture Considerations @@ -116,11 +126,22 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev **Type Layers:** 1. **Domain Layer**: `FieldValue` enum - Pure Swift types, no API metadata (`Sources/MistKit/Models/FieldValues/FieldValue.swift`) -2. **API Request Layer**: `FieldValueRequest` - No type field, CloudKit infers type from value structure +2. **API Request Layer**: `FieldValueRequest` - Optional type field; CloudKit infers type from value structure, except for ambiguous scalars (see below) and IN/NOT_IN list filters, which are tagged explicitly 3. **API Response Layer**: `FieldValueResponse` - Optional type field for explicit type information +**Request type tagging (issue #375):** Most request values omit `type` and let CloudKit infer it from the value structure. Three scalar types are ambiguous on the wire and **must** carry an explicit `type`, otherwise CloudKit infers the wrong type and rejects the write with `BAD_REQUEST`: +- `TIMESTAMP` (`.date`) — a millisecond number, otherwise read as `INT64`/`DOUBLE` +- `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. + +**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`). + +When a scalar `type` *contradicts* the value's category — a numeric type (`TIMESTAMP`/`DOUBLE`/`INT64`) over a non-number, or a string type (`STRING`/`BYTES`) over a non-string — the response is internally inconsistent and the conversion **throws** `ConversionError.typeValueMismatch` (via `requireNumeric`/`requireString`) rather than coercing to the value's shape. This matches the codebase's existing fail-loud `unmappableFieldValue` philosophy. The strict check is scoped to **scalar** type tags; complex/list declared types (`REFERENCE`/`ASSET`/`LOCATION`/`LIST`) are left to the value's self-describing structure and are not validated against the tag. + **Why Separate Request/Response Types?** -- CloudKit API has asymmetric behavior: requests omit type field, responses may include it +- CloudKit API has asymmetric behavior: requests tag type only when ambiguous, responses may always include it - OpenAPI schema accurately models this asymmetry (openapi.yaml:867-920) - Swift code generation produces type-safe request/response types - Compiler prevents accidentally using response types in requests @@ -134,7 +155,7 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev **Conversion:** - Request conversion: `Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift` converts domain `FieldValue` → `FieldValueRequest` -- Response conversion: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` converts `FieldValueResponse` → domain `FieldValue` +- Response conversion: `Sources/MistKit/Models/FieldValues/FieldValue+Components.swift` (entry point + complex types) and `FieldValue+Components+Scalar.swift` (scalar type recovery) convert `FieldValueResponse` → domain `FieldValue` ### Modern Swift Features to Utilize - Swift Concurrency (async/await) for all network operations @@ -169,7 +190,10 @@ 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()` *(unavailable — pending #28)*, `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(_:)`, `fetchCurrentUser()` (deprecated, forwards to `fetchCaller`) | +| `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 | | `CloudKitService+AssetOperations.swift` | `uploadAssets`, `requestAssetUploadURL` | | `CloudKitService+AssetUpload.swift` | `uploadAssetData` | | `CloudKitService+RecordManaging.swift` | record-managing convenience surface | @@ -189,6 +213,17 @@ MistKit/ - `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`. - `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`. +**Batch chunking (issue #307):** the two non-deprecated operations capped at CloudKit's 200-item-per-request limit (`CloudKitService.maxRecordsPerRequest`) each pair a single-request primitive with an auto-chunking convenience that splits the input into ≤`batchSize` batches, calls the primitive per batch, and concatenates results in input order. This mirrors the `queryRecords`/`queryAllRecords` page-primitive + auto-paginating-extension pattern. Because chunk count is `ceil(input.count / batchSize)` — deterministic and finite — there is **no** `maxPages`-style throwing ceiling; `batchSize` (default `maxRecordsPerRequest`, clamped to `1...maxRecordsPerRequest`) is the only knob. The shared engine is `chunkedBatches` (`CloudKitService+BatchChunking.swift`). + +| Primitive (single request) | Auto-chunking convenience | +|----------------------------|---------------------------| +| `lookupRecords(recordNames:desiredKeys:database:)` | `lookupAllRecords(recordNames:desiredKeys:database:batchSize:)` | +| `discoverUserIdentities(lookupInfos:)` | `discoverAllUserIdentities(lookupInfos:batchSize:)` *(overloads the no-arg address-book form)* | + +The `users/lookup/email` and `users/lookup/id` primitives (`lookupUsersByEmail` / `lookupUsersByRecordName`) are **deprecated by Apple** in favor of POST `users/discover` (verified against Apple's archived CloudKit Web Services reference), so they intentionally get **no** chunking convenience — callers needing >200 should use `discoverAllUserIdentities(lookupInfos:)`. `users/lookup/contacts` is likewise deprecated and unwrapped. + +`listZones` is **not** a pagination candidate — `zones/list` (GET) returns every zone in one response with no continuation marker. `modifyRecords`/`sync` already chunk by 200 internally. The `fetchAllRecordChanges` / `fetchAllZoneChanges` paginators already implement the page-primitive pattern with `maxPages` + stuck-token detection. + In MistDemo, integration runs targeting these endpoints use `PhaseContext.userContextService` (a public+web-auth `CloudKitService`) which is built from `CLOUDKIT_API_TOKEN` + `CLOUDKIT_WEB_AUTH_TOKEN` regardless of the primary `--database` selection. The `DatabaseConfiguration` / `AuthenticationCredentials` types in `Examples/MistDemo/Sources/MistDemoKit/Configuration/` enforce valid database+auth combinations at construction time. **Result Types (Sources/MistKit/Models/ and Sources/MistKit/Models/Zones/):** @@ -426,6 +461,38 @@ For detailed schema workflows and integration: - **AI Schema Workflow** (`Examples/CelestraCloud/.claude/AI_SCHEMA_WORKFLOW.md`) - Comprehensive guide for understanding, designing, modifying, and validating CloudKit schemas with text-based tools - **Quick Reference** (`Examples/SCHEMA_QUICK_REFERENCE.md`) - One-page cheat sheet with syntax, patterns, cktool commands, and troubleshooting +## Examples + +The `Examples/` directory contains working applications that dogfood MistKit-under-development (see also the README "Examples" list). These are MistKit-dev test beds, not end-user deployment templates. + +### BushelCloud — the canonical MistKit pattern demonstration + +`Examples/BushelCloud/` is the most complete reference implementation of MistKit's core patterns — the backend that syncs macOS restore images, Xcode, and Swift versions for the [Bushel app](https://getbushel.app): + +- **Server-to-Server authentication** — loading an ECDSA `.pem` key and wiring `ServerToServerAuthManager` into `CloudKitService` (`Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift`, `PEMValidator.swift`). +- **Batch / chunked record operations** — working within CloudKit's 200-operations-per-request limit and aggregating results across batches (`CloudKit/SyncEngine.swift`, `CloudKit/BushelCloudKitService.swift`). +- **Multi-source data integration** — fetching and deduplicating from many upstream APIs (`DataSources/` — IPSW, MESU, AppleDB, XcodeReleases, SwiftVersion, …; `DataSourcePipeline+Deduplication.swift`). +- **CloudKit reference usage** — creating and resolving reference fields between record types (`DataSources/DataSourcePipeline+ReferenceResolution.swift`, `Extensions/XcodeVersionRecord+CloudKit.swift`). +- **Cross-platform logging** — swift-log with MistKit's subsystem organization (`Configuration/BushelConfiguration.swift`). + +### CelestraCloud — query filtering, sorting & web etiquette + +`Examples/CelestraCloud/` is a command-line RSS reader (backend for the [Celestra app](https://celestr.app)) demonstrating MistKit's `QueryFilter`/`QuerySort` APIs, GUID-based duplicate detection, and respectful HTTP client patterns. See its own `CLAUDE.md`. + +### MistDemo — interactive auth & endpoint demo + +`Examples/MistDemo/` is a CLI + App + Web demo exercising the beta.2 endpoint surface with web-auth token capture. See the project-level "MistDemo Commands" section above. + +## Import Conventions + +Every `import` statement must carry an explicit access modifier — `internal import X` or `public import X`. Bare `import X` is forbidden. Default to `internal`; use `public import` only when the module's types appear in this file's `public` API (e.g. `public import HTTPTypes` where `HTTPRequest` is part of a `public` signature). + +Exceptions: +- `@testable import …` is its own modifier — no `internal`/`public` prefix. +- `Sources/MistKitOpenAPI/` is generated by swift-openapi-generator and currently emits a single bare `import HTTPTypes` in `Client.swift`. The generator doesn't expose an `accessModifierOnImports` setting yet, so that one line is a documented carve-out (SwiftLint already excludes this directory). + +The convention is not lint-enforced (SwiftLint has no rule for import visibility), so it's a reviewer responsibility plus the precedent set by the codebase after #159. + ## Additional Notes - We are using explicit ACLs in the Swift code - type order is based on the default in swiftlint: https://realm.github.io/SwiftLint/type_contents_order.html diff --git a/Examples/BushelCloud/.claude/implementation-patterns.md b/Examples/BushelCloud/.claude/implementation-patterns.md index 82085430..adaf2a2f 100644 --- a/Examples/BushelCloud/.claude/implementation-patterns.md +++ b/Examples/BushelCloud/.claude/implementation-patterns.md @@ -275,8 +275,8 @@ for (index, batch) in batches.enumerated() { // Process results immediately for result in results { - if result.recordType == "Unknown" { - // Handle error + if case .failure(let error) = result { + // Handle error (error.serverErrorCode, error.reason, ...) } } } @@ -313,11 +313,12 @@ try await syncXcodeVersions() // References uploaded records **Check for partial failures:** ```swift let results = try await service.modifyRecords(batch) -let errors = results.filter { $0.recordType == "Unknown" } +let errors = results.compactMap(\.error) if !errors.isEmpty { for error in errors { - print("Failed: \(error.recordName ?? "unknown")") + print("Failed: \(error.recordName)") + print("Code: \(error.serverErrorCode.rawValue)") print("Reason: \(error.reason ?? "N/A")") } } diff --git a/Examples/BushelCloud/.claude/s2s-auth-details.md b/Examples/BushelCloud/.claude/s2s-auth-details.md index ae3503f9..61b03f72 100644 --- a/Examples/BushelCloud/.claude/s2s-auth-details.md +++ b/Examples/BushelCloud/.claude/s2s-auth-details.md @@ -242,8 +242,8 @@ func syncRecords(_ records: [RestoreImageRecord]) async throws { let results = try await service.modifyRecords(batch) // Check for partial failures - let failures = results.filter { $0.recordType == "Unknown" } - let successes = results.filter { $0.recordType != "Unknown" } + let failures = results.compactMap(\.error) + let successes = results.compactMap(\.record) print("✓ \(successes.count) succeeded, ❌ \(failures.count) failed") } @@ -258,14 +258,13 @@ CloudKit returns **partial success** - some operations may succeed while others let results = try await service.modifyRecords(batch) for result in results { - if result.recordType == "Unknown" { - // This is an error response - print("❌ Error for \(result.recordName ?? "unknown")") - print(" Code: \(result.serverErrorCode ?? "N/A")") - print(" Reason: \(result.reason ?? "N/A")") - } else { - // Successfully created/updated - print("✓ Success: \(result.recordName ?? "unknown")") + switch result { + case .failure(let error): + print("❌ Error for \(error.recordName)") + print(" Code: \(error.serverErrorCode.rawValue)") + print(" Reason: \(error.reason ?? "N/A")") + case .success(let record): + print("✓ Success: \(record.recordName)") } } ``` @@ -333,10 +332,13 @@ let operation = RecordOperation.create( let results = try await service.modifyRecords([operation]) -if results.first?.recordType == "Unknown" { - print("❌ Failed: \(results.first?.reason ?? "unknown")") -} else { - print("✓ Success! Record created: \(results.first?.recordName ?? "")") +switch results.first { +case .failure(let error): + print("❌ Failed: \(error.reason ?? error.serverErrorCode.rawValue)") +case .success(let record): + print("✓ Success! Record created: \(record.recordName)") +case nil: + print("❌ No result returned") } ``` diff --git a/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml index 3d41f1f8..bcfcd26b 100644 --- a/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml +++ b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml @@ -130,6 +130,14 @@ inputs: description: 'Run export after sync and generate reports' required: false default: 'true' + mistkit-branch: + description: 'MistKit ref to check out when falling back to a fresh build' + required: false + default: 'v1.0.0-beta.2' + configkeykit-branch: + description: 'ConfigKeyKit ref to check out when falling back to a fresh build' + required: false + default: 'main' runs: using: "composite" @@ -147,7 +155,15 @@ runs: - name: Setup MistKit if: steps.download-binary.outcome != 'success' - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ inputs.mistkit-branch }} + + - name: Setup ConfigKeyKit + if: steps.download-binary.outcome != 'success' + uses: brightdigit/ConfigKeyKit/.github/actions/setup-configkeykit@main + with: + branch: ${{ inputs.configkeykit-branch }} - name: Build binary (fallback if artifact unavailable) if: steps.download-binary.outcome != 'success' diff --git a/Examples/BushelCloud/.github/workflows/BushelCloud.yml b/Examples/BushelCloud/.github/workflows/BushelCloud.yml index f83f5a53..2d5732aa 100644 --- a/Examples/BushelCloud/.github/workflows/BushelCloud.yml +++ b/Examples/BushelCloud/.github/workflows/BushelCloud.yml @@ -21,7 +21,8 @@ concurrency: env: PACKAGE_NAME: BushelCloud - MISTKIT_BRANCH: v1.0.0-beta.1 + MISTKIT_BRANCH: v1.0.0-beta.2 + CONFIGKEYKIT_BRANCH: main jobs: configure: @@ -89,6 +90,11 @@ jobs: with: branch: ${{ env.MISTKIT_BRANCH }} + - name: Setup ConfigKeyKit + uses: brightdigit/ConfigKeyKit/.github/actions/setup-configkeykit@main + with: + branch: ${{ env.CONFIGKEYKIT_BRANCH }} + - uses: brightdigit/swift-build@v1 id: build with: @@ -178,6 +184,11 @@ jobs: with: branch: ${{ env.MISTKIT_BRANCH }} + - name: Setup ConfigKeyKit + uses: brightdigit/ConfigKeyKit/.github/actions/setup-configkeykit@main + with: + branch: ${{ env.CONFIGKEYKIT_BRANCH }} + - name: Build and Test id: build uses: brightdigit/swift-build@v1 @@ -243,6 +254,11 @@ jobs: with: branch: ${{ env.MISTKIT_BRANCH }} + - name: Setup ConfigKeyKit + uses: brightdigit/ConfigKeyKit/.github/actions/setup-configkeykit@main + with: + branch: ${{ env.CONFIGKEYKIT_BRANCH }} + - name: Build and Test id: build uses: brightdigit/swift-build@v1 diff --git a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml index 8a0a13d5..aed2ca72 100644 --- a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml +++ b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml @@ -40,7 +40,12 @@ jobs: - name: Setup MistKit uses: brightdigit/MistKit/.github/actions/setup-mistkit@main with: - branch: v1.0.0-beta.1 + branch: v1.0.0-beta.2 + + - name: Setup ConfigKeyKit + uses: brightdigit/ConfigKeyKit/.github/actions/setup-configkeykit@main + with: + branch: main - name: Verify Swift version run: | diff --git a/Examples/BushelCloud/.github/workflows/codeql.yml b/Examples/BushelCloud/.github/workflows/codeql.yml index a653cb18..624ed00b 100644 --- a/Examples/BushelCloud/.github/workflows/codeql.yml +++ b/Examples/BushelCloud/.github/workflows/codeql.yml @@ -71,7 +71,14 @@ jobs: - name: Setup MistKit - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: v1.0.0-beta.2 + + - name: Setup ConfigKeyKit + uses: brightdigit/ConfigKeyKit/.github/actions/setup-configkeykit@main + with: + branch: main # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index 9c3dc537..d9c179c7 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit - commit = 5bb449083cf63d4752dea48fe5579efc16ba7374 - parent = 38f0d77f93f1df4384271be2ff865cae2e2f813d + commit = 1ab45e4fe8420bc52c3c27f3740b770e49e36b9b + parent = fe0a6ae37cc09970570aaa5f1eacef948a81b177 method = merge cmdver = 0.4.9 diff --git a/Examples/BushelCloud/CLAUDE.md b/Examples/BushelCloud/CLAUDE.md index 5c10c079..faa74ea4 100644 --- a/Examples/BushelCloud/CLAUDE.md +++ b/Examples/BushelCloud/CLAUDE.md @@ -452,7 +452,7 @@ See `.claude/s2s-auth-details.md` for detailed batch operation examples and erro - `BushelCloudKitError` enum defines project-specific errors - MistKit operations throw `CloudKitError` for API failures -- Use `RecordInfo.isError` to detect partial batch failures +- `modifyRecords` returns `[RecordResult]`; switch over each (`.success` / `.failure`) to detect partial batch failures - Verbose mode logs error details (serverErrorCode, reason) **Common error codes**: diff --git a/Examples/BushelCloud/Package.resolved b/Examples/BushelCloud/Package.resolved index e0b877de..740e5324 100644 --- a/Examples/BushelCloud/Package.resolved +++ b/Examples/BushelCloud/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c3ac1cf77d89f143a19ef295fe93dc532ed8453816f62104a1d89923205611da", + "originHash" : "19206e85a58e39bd539ec38237e3cc167902ae05697e150f556c029593646dbe", "pins" : [ { "identity" : "bushelkit", diff --git a/Examples/BushelCloud/Package.swift b/Examples/BushelCloud/Package.swift index 00cfd538..f9dc5fbe 100644 --- a/Examples/BushelCloud/Package.swift +++ b/Examples/BushelCloud/Package.swift @@ -87,12 +87,12 @@ let package = Package( .visionOS(.v2) ], products: [ - .library(name: "ConfigKeyKit", targets: ["ConfigKeyKit"]), .library(name: "BushelCloudKit", targets: ["BushelCloudKit"]), .executable(name: "bushel-cloud", targets: ["BushelCloudCLI"]) ], dependencies: [ .package(name: "MistKit", path: "../.."), + .package(name: "ConfigKeyKit", path: "../../Packages/ConfigKeyKit"), .package(url: "https://github.com/brightdigit/BushelKit.git", from: "3.0.0-alpha.2"), .package(url: "https://github.com/brightdigit/IPSWDownloads.git", from: "1.0.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), @@ -103,15 +103,10 @@ let package = Package( ) ], targets: [ - .target( - name: "ConfigKeyKit", - dependencies: [], - swiftSettings: swiftSettings - ), .target( name: "BushelCloudKit", dependencies: [ - .target(name: "ConfigKeyKit"), + .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), .product(name: "BushelLogging", package: "BushelKit"), .product(name: "BushelFoundation", package: "BushelKit"), @@ -130,13 +125,6 @@ let package = Package( ], swiftSettings: swiftSettings ), - .testTarget( - name: "ConfigKeyKitTests", - dependencies: [ - .target(name: "ConfigKeyKit") - ], - swiftSettings: swiftSettings - ), .testTarget( name: "BushelCloudKitTests", dependencies: [ diff --git a/Examples/BushelCloud/README.md b/Examples/BushelCloud/README.md index f74476d8..b902fcb3 100644 --- a/Examples/BushelCloud/README.md +++ b/Examples/BushelCloud/README.md @@ -40,6 +40,21 @@ In Apple's virtualization framework, **restore images** are used to boot virtual ## Architecture +### Data Flow + +The sync pipeline moves version data from external APIs into CloudKit in three phases — **fetch** (parallel API calls), **transform** (deduplicate and resolve references), and **upload** (batched writes): + +```mermaid +graph LR + A[External APIs
IPSW · AppleDB · MESU
XcodeReleases · Swift.org · VirtualBuddy] --> B[Fetchers] + B --> C[DataSourcePipeline] + C --> D[Deduplication
and Merge] + D --> E[RecordBuilder] + E --> F[BushelCloudKitService] + F --> G[MistKit
CloudKitService] + G --> H[(CloudKit
Web Services)] +``` + ### Data Sources The demo integrates with multiple data sources to gather comprehensive version information: @@ -97,6 +112,33 @@ BushelCloud/ └── ExportCommand.swift ``` +The modules interact as follows — the CLI drives `SyncEngine`, which fans out to the data pipeline for fetching and to the service layer for uploading: + +```mermaid +graph TD + CLI[BushelCloudCLI
sync · export · clear · list · status] --> SE[SyncEngine] + SE --> DSP[DataSourcePipeline] + SE --> SVC[BushelCloudKitService] + DSP --> FET[Fetchers
IPSW · AppleDB · MESU
XcodeReleases · SwiftVersion · VirtualBuddy] + SVC --> MK[MistKit
CloudKitService] + MK --> CK[(CloudKit API)] +``` + +### MistKit Integration Pattern + +BushelCloud authenticates with **Server-to-Server** credentials (no signed-in iCloud user). An ECDSA P-256 `.pem` key and key ID build a `ServerToServerAuthManager`, which is injected into `CloudKitService`. The database scope is chosen per call (`.public(.prefers(.serverToServer))`), and writes are chunked to CloudKit's 200-operations-per-request limit: + +```mermaid +graph TD + PEM[ECDSA P-256
.pem private key] --> SAM[ServerToServerAuthManager] + KID[CLOUDKIT_KEY_ID] --> SAM + SAM --> CKS[CloudKitService] + CFG[Container ID
+ environment] --> CKS + CKS --> OPS{Record operations} + OPS -->|chunk into ≤200| BATCH[modifyRecords
per batch] + BATCH --> API[(CloudKit Web Services
public DB · S2S-signed)] +``` + ### BushelKit Integration BushelCloud uses [BushelKit](https://github.com/brightdigit/BushelKit) as its modular foundation, providing: @@ -315,19 +357,17 @@ For Xcode setup and debugging instructions, see the "Xcode Development Setup" se ## CloudKit Schema -The demo uses three record types with relationships: +The demo uses three related record types (plus a standalone `DataSourceMetadata`). `XcodeVersion` holds `CKReference` fields to the macOS restore image it requires and the Swift compiler it bundles: -```text -SwiftVersion - ↑ - | (reference) - | -RestoreImage ← XcodeVersion - ↑ ↑ - | (reference) | - |______________| +```mermaid +graph TD + XV[XcodeVersion] -->|minimumMacOS · CKReference| RI[RestoreImage] + XV -->|swiftVersion · CKReference| SV[SwiftVersion] + DSM[DataSourceMetadata
no references] ``` +Because references must resolve at write time, the referenced records (`RestoreImage`, `SwiftVersion`) are uploaded before `XcodeVersion`. + ### Record Relationships - **XcodeVersion → RestoreImage**: Links Xcode to minimum macOS version required diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/BushelCloudCLI.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/BushelCloudCLI.swift index 5d85bf39..c933076d 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/BushelCloudCLI.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/BushelCloudCLI.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation @main internal struct BushelCloudCLI { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift index 28a2ed29..8141509c 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelCloudKit -import BushelFoundation -import BushelUtilities -import Foundation +internal import BushelCloudKit +internal import BushelFoundation +internal import BushelUtilities +internal import Foundation internal enum ClearCommand { internal static func run(_ args: [String]) async throws { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift index 13315b6b..8dc22875 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelCloudKit -import BushelFoundation -import Foundation -import MistKit +internal import BushelCloudKit +internal import BushelFoundation +internal import Foundation +internal import MistKit internal enum ExportCommand { // MARK: - Export Types @@ -43,7 +43,7 @@ internal enum ExportCommand { private struct RecordExport: Codable { let recordName: String - let recordType: String + let recordType: String? let fields: [String: String] init(from recordInfo: RecordInfo) { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift index ce4dced2..51b2aad8 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelCloudKit -import BushelFoundation -import Foundation -import MistKit +internal import BushelCloudKit +internal import BushelFoundation +internal import Foundation +internal import MistKit internal enum ListCommand { internal static func run(_ args: [String]) async throws { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift index 98b02d87..74090ca1 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelCloudKit -import BushelFoundation -import Foundation +internal import BushelCloudKit +internal import BushelFoundation +internal import Foundation internal import MistKit internal enum StatusCommand { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift index 5313a8bc..77665890 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelCloudKit -import BushelFoundation -import BushelUtilities -import Foundation +internal import BushelCloudKit +internal import BushelFoundation +internal import BushelUtilities +internal import Foundation internal enum SyncCommand { internal static func run(_ args: [String]) async throws { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md index 27bcc79c..28f933d3 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md +++ b/Examples/BushelCloud/Sources/BushelCloudKit/BushelCloud.docc/CloudKitIntegration.md @@ -125,8 +125,11 @@ let results = try await service.modifyRecords( database: .public(.prefers(.serverToServer)) ) for result in results { - if result.isError { - logger.error("Failed: \\(result.serverErrorCode ?? "unknown")") + switch result { + case .success(let record): + logger.debug("Saved: \\(record.recordName)") + case .failure(let error): + logger.error("Failed \\(error.recordName): \\(error.serverErrorCode.rawValue)") } } ``` @@ -155,6 +158,6 @@ This logs: 1. **Batch wisely**: Stay under 200 operations per request 2. **Order matters**: Upload dependencies first (SwiftVersion before XcodeVersion) -3. **Handle partials**: Check `RecordInfo.isError` for each result +3. **Handle partials**: Switch over each `RecordResult` (`.success` / `.failure`) 4. **Use references**: Link related records with CloudKit references 5. **Verbose development**: Use `--verbose` flag during development diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index 9acbe26e..53f0cfce 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -30,11 +30,11 @@ public import BushelFoundation public import BushelLogging public import Foundation -import Logging +internal import Logging public import MistKit #if canImport(FelinePineSwift) - import FelinePineSwift + internal import FelinePineSwift #endif /// CloudKit service wrapper for Bushel demo operations @@ -233,36 +233,41 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol "Calling MistKit service.modifyRecords() with \(batch.count) RecordOperation objects" ) - let results = try await service.modifyRecords( + // Annotate the element type explicitly: the Linux Swift compiler otherwise + // infers `[RecordInfo]` for this call, breaking the .success/.failure switch + // below (see brightdigit/BushelCloud CI on Ubuntu). + let results: [RecordResult] = try await service.modifyRecords( batch, database: .public(.prefers(.serverToServer)) ) Self.logger.debug( - "Received \(results.count) RecordInfo responses from CloudKit" + "Received \(results.count) per-record results from CloudKit" ) // Track results based on classification + var batchSucceeded = 0 + var batchFailed = 0 for result in results { - if result.isError { + switch result { + case .failure(let error): totalFailed += 1 - failedRecordNames.append(result.recordName) + batchFailed += 1 + failedRecordNames.append(error.identifier) Self.logger.debug( - "Error: recordName=\(result.recordName)" + "Error: recordName=\(error.identifier), code=\(error.serverErrorCode.rawValue)" ) - } else { + case .success(let record): + batchSucceeded += 1 // Classify as create or update based on pre-fetch - if classification.creates.contains(result.recordName) { + if classification.creates.contains(record.recordName) { totalCreated += 1 - } else if classification.updates.contains(result.recordName) { + } else if classification.updates.contains(record.recordName) { totalUpdated += 1 } } } - let batchSucceeded = results.filter { !$0.isError }.count - let batchFailed = results.count - batchSucceeded - if batchFailed > 0 { print(" ⚠️ \(batchFailed) operations failed (see verbose logs for details)") print(" ✓ \(batchSucceeded) records confirmed") diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift index c7d00980..6cf6299d 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Authentication method for CloudKit Server-to-Server /// diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift index 88a7cfb7..63de7bb8 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Validates CloudKit Server-to-Server Key ID format internal enum KeyIDValidator { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/OperationClassification.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/OperationClassification.swift index 5fc95db4..bf51d0dc 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/OperationClassification.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/OperationClassification.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Classifies CloudKit operations as creates or updates /// diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift index e81ea290..6512f581 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Validates PEM format for CloudKit Server-to-Server private keys internal enum PEMValidator { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift index 552c51b7..6116e483 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine+Export.swift @@ -27,13 +27,13 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelLogging -import BushelUtilities -import Logging +internal import BushelLogging +internal import BushelUtilities +internal import Logging public import MistKit #if canImport(FelinePineSwift) - import FelinePineSwift + internal import FelinePineSwift #endif // MARK: - Export Operations diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift index eb41ab45..feec6451 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift @@ -31,11 +31,11 @@ public import BushelFoundation public import BushelLogging public import BushelUtilities public import Foundation -import Logging +internal import Logging public import MistKit #if canImport(FelinePineSwift) - import FelinePineSwift + internal import FelinePineSwift #endif /// Orchestrates the complete sync process from data sources to CloudKit diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift index 8ff93192..6d3978de 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift @@ -28,8 +28,8 @@ // public import BushelFoundation -import Foundation -import MistKit +internal import Foundation +internal import MistKit // MARK: - Configuration Error diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift index e948a3b9..077cdb4f 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation public import MistKit // MARK: - CloudKit Configuration diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CommandConfigurations.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CommandConfigurations.swift index 7b0c257d..2f17f396 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CommandConfigurations.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CommandConfigurations.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation // MARK: - Sync Configuration diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigKey+BUSHEL.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigKey+BUSHEL.swift new file mode 100644 index 00000000..9b71aa17 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigKey+BUSHEL.swift @@ -0,0 +1,60 @@ +// +// ConfigKey+BUSHEL.swift +// BushelCloud +// +// 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. +// + +public import ConfigKeyKit + +// MARK: - BushelCloud-Specific Config Key Helpers + +extension ConfigKey { + /// Convenience initializer for keys with `BUSHEL_` environment-variable prefix. + /// - Parameters: + /// - base: Base key string (e.g., "sync.dry_run") + /// - defaultVal: Required default value + public init(bushelPrefixed base: String, default defaultVal: Value) { + self.init(base, envPrefix: "BUSHEL", default: defaultVal) + } +} + +extension ConfigKey where Value == Bool { + /// Convenience initializer for boolean keys with `BUSHEL_` environment-variable prefix. + /// - Parameters: + /// - base: Base key string (e.g., "sync.verbose") + /// - defaultVal: Default value (defaults to false) + public init(bushelPrefixed base: String, default defaultVal: Bool = false) { + self.init(base, envPrefix: "BUSHEL", default: defaultVal) + } +} + +extension OptionalConfigKey { + /// Convenience initializer for optional keys with `BUSHEL_` environment-variable prefix. + /// - Parameter base: Base key string (e.g., "sync.min_interval") + public init(bushelPrefixed base: String) { + self.init(base, envPrefix: "BUSHEL") + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift index 5547878d..ae75d0f7 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import ConfigKeyKit -import Foundation +internal import ConfigKeyKit +internal import Foundation /// Configuration keys for reading from providers internal enum ConfigurationKeys { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift index b64874ed..72fc729f 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import Foundation +internal import BushelFoundation +internal import Foundation // MARK: - Configuration Loading diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift index 566f7951..6379ecd2 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import ConfigKeyKit -import Configuration -import Foundation +internal import ConfigKeyKit +internal import Configuration +internal import Foundation /// Actor responsible for loading configuration from CLI arguments and environment variables public actor ConfigurationLoader { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBEntry.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBEntry.swift index edddd6cd..7ee83ff8 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBEntry.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBEntry.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Represents a single macOS build entry from AppleDB internal struct AppleDBEntry: Codable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBFetcher.swift index 6d1d32fa..83ab1ff4 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBFetcher.swift @@ -29,16 +29,16 @@ public import BushelFoundation public import BushelLogging -import BushelUtilities -import Foundation -import Logging +internal import BushelUtilities +internal import Foundation +internal import Logging #if canImport(FelinePineSwift) - import FelinePineSwift + internal import FelinePineSwift #endif #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for macOS restore images using AppleDB API diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBHashes.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBHashes.swift index 5721fe36..dfd214c1 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBHashes.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBHashes.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Represents file hashes for verification internal struct AppleDBHashes: Codable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBLink.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBLink.swift index 5f47a6db..048c3794 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBLink.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBLink.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Represents a download link for a source internal struct AppleDBLink: Codable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBSource.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBSource.swift index 399f0bd9..d684c5c0 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBSource.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/AppleDBSource.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Represents an installation source (IPSW, OTA, or IA) internal struct AppleDBSource: Codable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommit.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommit.swift index 38c3fb63..5ec9c36a 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommit.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommit.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Represents a commit in GitHub API response internal struct GitHubCommit: Codable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitsResponse.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitsResponse.swift index 56954e42..1ba3dd2b 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitsResponse.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitsResponse.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Response from GitHub API for commits internal struct GitHubCommitsResponse: Codable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitter.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitter.swift index aee3843d..9f63835b 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitter.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/GitHubCommitter.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Represents a committer in GitHub API response internal struct GitHubCommitter: Codable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/SignedStatus.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/SignedStatus.swift index aebc8bbd..b4b36608 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/SignedStatus.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/AppleDB/SignedStatus.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Represents the signing status for a build /// Can be: array of device IDs, boolean true (all signed), or empty array (none signed) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Deduplication.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Deduplication.swift index 70ca5357..74f7a863 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Deduplication.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Deduplication.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import Foundation +internal import BushelFoundation +internal import Foundation // MARK: - Deduplication extension DataSourcePipeline { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Fetchers.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Fetchers.swift index 90b3f797..29397dda 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Fetchers.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+Fetchers.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import Foundation +internal import BushelFoundation +internal import Foundation // MARK: - Private Fetching Methods extension DataSourcePipeline { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+ReferenceResolution.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+ReferenceResolution.swift index d3d05627..8d391818 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+ReferenceResolution.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline+ReferenceResolution.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation +internal import BushelFoundation // MARK: - Reference Resolution extension DataSourcePipeline { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift index 021cae83..2693a273 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/DataSourcePipeline.swift @@ -28,8 +28,8 @@ // public import BushelFoundation -import BushelLogging -import Foundation +internal import BushelLogging +internal import Foundation /// Orchestrates fetching data from all sources with deduplication and relationship resolution public struct DataSourcePipeline: Sendable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/IPSWFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/IPSWFetcher.swift index 85f3d656..d1bf2d92 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/IPSWFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/IPSWFetcher.swift @@ -27,15 +27,15 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import BushelUtilities -import Foundation -import IPSWDownloads -import OpenAPIURLSession -import OSVer +internal import BushelFoundation +internal import BushelUtilities +internal import Foundation +internal import IPSWDownloads +internal import OpenAPIURLSession +internal import OSVer #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for macOS restore images using the IPSWDownloads package diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MESUFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MESUFetcher.swift index 2717d45b..696d577f 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MESUFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MESUFetcher.swift @@ -28,11 +28,11 @@ // public import BushelFoundation -import BushelUtilities -import Foundation +internal import BushelUtilities +internal import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for Apple MESU (Mobile Equipment Software Update) manifest diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MrMacintoshFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MrMacintoshFetcher.swift index 99a64f22..cbe5dd84 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MrMacintoshFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/MrMacintoshFetcher.swift @@ -27,18 +27,18 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation +internal import BushelFoundation public import BushelLogging -import Foundation -import Logging -import SwiftSoup +internal import Foundation +internal import Logging +internal import SwiftSoup #if canImport(FelinePineSwift) - import FelinePineSwift + internal import FelinePineSwift #endif #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for macOS beta/RC restore images from Mr. Macintosh database diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/SwiftVersionFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/SwiftVersionFetcher.swift index 3400d1eb..106ddd5a 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/SwiftVersionFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/SwiftVersionFetcher.swift @@ -27,12 +27,12 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import Foundation -import SwiftSoup +internal import BushelFoundation +internal import Foundation +internal import SwiftSoup #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for Swift compiler versions from swiftversion.net diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/IPSWParser.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/IPSWParser.swift index 009dac43..10bdf73c 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/IPSWParser.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/IPSWParser.swift @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif // MARK: - Errors @@ -58,7 +58,6 @@ internal enum TheAppleWikiError: LocalizedError { // MARK: - Parser /// Fetches macOS IPSW metadata from TheAppleWiki.com -@available(macOS 12.0, *) internal struct IPSWParser: Sendable { private let baseURL = "https://theapplewiki.com" private let apiEndpoint = "/api.php" diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/IPSWVersion.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/IPSWVersion.swift index b020a458..e02b17f1 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/IPSWVersion.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/IPSWVersion.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// IPSW metadata from TheAppleWiki internal struct IPSWVersion: Codable, Sendable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseContent.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseContent.swift index 709c4c7a..b035acfb 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseContent.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseContent.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Parse content container internal struct ParseContent: Codable, Sendable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseResponse.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseResponse.swift index 0ca9cfde..961665d3 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseResponse.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/ParseResponse.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Root response from TheAppleWiki parse API internal struct ParseResponse: Codable, Sendable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/TextContent.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/TextContent.swift index 73258bd6..6b8084d4 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/TextContent.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/Models/TextContent.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Text content with HTML internal struct TextContent: Codable, Sendable { diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift index 2df6e65d..b6346242 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/TheAppleWiki/TheAppleWikiFetcher.swift @@ -27,12 +27,12 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import BushelUtilities -import Foundation +internal import BushelFoundation +internal import BushelUtilities +internal import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for macOS restore images using TheAppleWiki.com diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift index 54efdb3b..9e836dc3 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/VirtualBuddyFetcher.swift @@ -27,13 +27,13 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import BushelUtilities -import BushelVirtualBuddy -import Foundation +internal import BushelFoundation +internal import BushelUtilities +internal import BushelVirtualBuddy +internal import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for enriching restore images with VirtualBuddy TSS signing status diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/XcodeReleasesFetcher.swift b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/XcodeReleasesFetcher.swift index 8e719eb3..b6681909 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/XcodeReleasesFetcher.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/DataSources/XcodeReleasesFetcher.swift @@ -29,15 +29,15 @@ public import BushelFoundation public import BushelLogging -import Foundation -import Logging +internal import Foundation +internal import Logging #if canImport(FelinePineSwift) - import FelinePineSwift + internal import FelinePineSwift #endif #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Fetcher for Xcode releases from xcodereleases.com JSON API diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift index bad03b68..d6f7c8aa 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Utilities/ConsoleOutput.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Synchronization +internal import Foundation +internal import Synchronization /// Console output control for CLI interface /// diff --git a/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigKey.swift b/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigKey.swift deleted file mode 100644 index cac3ef08..00000000 --- a/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigKey.swift +++ /dev/null @@ -1,181 +0,0 @@ -// -// ConfigKey.swift -// BushelCloud -// -// 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. -// - -import Foundation - -// MARK: - Generic Configuration Key - -/// Configuration key for values with default fallbacks -/// -/// Use `ConfigKey` when a configuration value has a sensible default -/// that should be used when not provided by the user. The `read()` method -/// will always return a non-optional value. -/// -/// Example: -/// ```swift -/// let containerID = ConfigKey( -/// base: "cloudkit.container_id", -/// default: "iCloud.com.brightdigit.Bushel" -/// ) -/// // read(containerID) returns String (non-optional) -/// ``` -public struct ConfigKey: ConfigurationKey, Sendable { - private let baseKey: String? - private let styles: [ConfigKeySource: any NamingStyle] - private let explicitKeys: [ConfigKeySource: String] - public let defaultValue: Value // Non-optional! - - /// Initialize with explicit CLI and ENV keys and required default - public init(cli: String? = nil, env: String? = nil, default defaultVal: Value) { - self.baseKey = nil - self.styles = [:] - var keys: [ConfigKeySource: String] = [:] - if let cli = cli { keys[.commandLine] = cli } - if let env = env { keys[.environment] = env } - self.explicitKeys = keys - self.defaultValue = defaultVal - } - - /// Initialize from a base key string with naming styles and required default - /// - Parameters: - /// - base: Base key string (e.g., "cloudkit.container_id") - /// - styles: Dictionary mapping sources to naming styles - /// - defaultVal: Required default value - public init( - base: String, - styles: [ConfigKeySource: any NamingStyle], - default defaultVal: Value - ) { - self.baseKey = base - self.styles = styles - self.explicitKeys = [:] - self.defaultValue = defaultVal - } - - /// Convenience initializer with standard naming conventions and required default - /// - Parameters: - /// - base: Base key string (e.g., "cloudkit.container_id") - /// - envPrefix: Prefix for environment variable (defaults to nil) - /// - defaultVal: Required default value - public init(_ base: String, envPrefix: String? = nil, default defaultVal: Value) { - self.baseKey = base - self.styles = [ - .commandLine: StandardNamingStyle.dotSeparated, - .environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix), - ] - self.explicitKeys = [:] - self.defaultValue = defaultVal - } - - public func key(for source: ConfigKeySource) -> String? { - // Check for explicit key first - if let explicit = explicitKeys[source] { - return explicit - } - - // Generate from base key and style - guard let base = baseKey, let style = styles[source] else { - return nil - } - - return style.transform(base) - } -} - -extension ConfigKey: CustomDebugStringConvertible { - public var debugDescription: String { - let cliKey = key(for: .commandLine) ?? "nil" - let envKey = key(for: .environment) ?? "nil" - return "ConfigKey(cli: \(cliKey), env: \(envKey), default: \(defaultValue))" - } -} - -// MARK: - Convenience Initializers for BUSHEL Prefix - -extension ConfigKey { - /// Convenience initializer for keys with BUSHEL prefix - /// - Parameters: - /// - base: Base key string (e.g., "sync.dry_run") - /// - defaultVal: Required default value - public init(bushelPrefixed base: String, default defaultVal: Value) { - self.init(base, envPrefix: "BUSHEL", default: defaultVal) - } -} - -// MARK: - Specialized Initializers for Booleans - -extension ConfigKey where Value == Bool { - /// Non-optional default value accessor for booleans - @available(*, deprecated, message: "Use defaultValue directly instead") - public var boolDefault: Bool { - defaultValue // Already non-optional! - } - - /// Initialize a boolean configuration key with non-optional default - /// - Parameters: - /// - cli: Command-line argument name - /// - env: Environment variable name - /// - defaultVal: Default value (defaults to false) - public init(cli: String, env: String, default defaultVal: Bool = false) { - self.baseKey = nil - self.styles = [:] - var keys: [ConfigKeySource: String] = [:] - keys[.commandLine] = cli - keys[.environment] = env - self.explicitKeys = keys - self.defaultValue = defaultVal - } - - /// Initialize a boolean configuration key from base string - /// - Parameters: - /// - base: Base key string (e.g., "sync.verbose") - /// - envPrefix: Prefix for environment variable (defaults to nil) - /// - defaultVal: Default value (defaults to false) - public init(_ base: String, envPrefix: String? = nil, default defaultVal: Bool = false) { - self.baseKey = base - self.styles = [ - .commandLine: StandardNamingStyle.dotSeparated, - .environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix), - ] - self.explicitKeys = [:] - self.defaultValue = defaultVal - } -} - -// MARK: - BUSHEL Prefix Convenience - -extension ConfigKey where Value == Bool { - /// Convenience initializer for boolean keys with BUSHEL prefix - /// - Parameters: - /// - base: Base key string (e.g., "sync.verbose") - /// - defaultVal: Default value (defaults to false) - public init(bushelPrefixed base: String, default defaultVal: Bool = false) { - self.init(base, envPrefix: "BUSHEL", default: defaultVal) - } -} diff --git a/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigurationKey.swift b/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigurationKey.swift deleted file mode 100644 index 341a110f..00000000 --- a/Examples/BushelCloud/Sources/ConfigKeyKit/ConfigurationKey.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// ConfigurationKey.swift -// BushelCloud -// -// 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. -// - -import Foundation - -// MARK: - Configuration Key Source - -/// Source for configuration keys (CLI arguments or environment variables) -public enum ConfigKeySource: CaseIterable, Sendable { - /// Command-line arguments (e.g., --cloudkit-container-id) - case commandLine - - /// Environment variables (e.g., CLOUDKIT_CONTAINER_ID) - case environment -} - -// MARK: - Naming Style - -/// Protocol for transforming base key strings into different naming conventions -public protocol NamingStyle: Sendable { - /// Transform a base key string according to this naming style - /// - Parameter base: Base key string (e.g., "cloudkit.container_id") - /// - Returns: Transformed key string - func transform(_ base: String) -> String -} - -/// Common naming styles for configuration keys -public enum StandardNamingStyle: NamingStyle, Sendable { - /// Dot-separated lowercase (e.g., "cloudkit.container_id") - case dotSeparated - - /// Screaming snake case with prefix (e.g., "BUSHEL_CLOUDKIT_CONTAINER_ID") - case screamingSnakeCase(prefix: String?) - - public func transform(_ base: String) -> String { - switch self { - case .dotSeparated: - return base - - case .screamingSnakeCase(let prefix): - let snakeCase = base.uppercased().replacingOccurrences(of: ".", with: "_") - if let prefix = prefix { - return "\(prefix)_\(snakeCase)" - } - return snakeCase - } - } -} - -// MARK: - Configuration Key Protocol - -/// Protocol for configuration keys that support multiple sources -public protocol ConfigurationKey: Sendable { - /// Get the key string for a specific source - /// - Parameter source: The configuration source (CLI or ENV) - /// - Returns: The key string for that source, or nil if the key doesn't support that source - func key(for source: ConfigKeySource) -> String? -} diff --git a/Examples/BushelCloud/Sources/ConfigKeyKit/OptionalConfigKey.swift b/Examples/BushelCloud/Sources/ConfigKeyKit/OptionalConfigKey.swift deleted file mode 100644 index 8e32aaec..00000000 --- a/Examples/BushelCloud/Sources/ConfigKeyKit/OptionalConfigKey.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// OptionalConfigKey.swift -// BushelCloud -// -// 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. -// - -import Foundation - -// MARK: - Optional Configuration Key - -/// Configuration key for optional values without defaults -/// -/// Use `OptionalConfigKey` when a configuration value has no sensible default -/// and should be `nil` when not provided by the user. The `read()` method -/// will return an optional value. -/// -/// Example: -/// ```swift -/// let apiKey = OptionalConfigKey(base: "api.key") -/// // read(apiKey) returns String? -/// ``` -public struct OptionalConfigKey: ConfigurationKey, Sendable { - private let baseKey: String? - private let styles: [ConfigKeySource: any NamingStyle] - private let explicitKeys: [ConfigKeySource: String] - - /// Initialize with explicit CLI and ENV keys (no default) - public init(cli: String? = nil, env: String? = nil) { - self.baseKey = nil - self.styles = [:] - var keys: [ConfigKeySource: String] = [:] - if let cli = cli { keys[.commandLine] = cli } - if let env = env { keys[.environment] = env } - self.explicitKeys = keys - } - - /// Initialize from a base key string with naming styles (no default) - /// - Parameters: - /// - base: Base key string (e.g., "cloudkit.key_id") - /// - styles: Dictionary mapping sources to naming styles - public init( - base: String, - styles: [ConfigKeySource: any NamingStyle] - ) { - self.baseKey = base - self.styles = styles - self.explicitKeys = [:] - } - - /// Convenience initializer with standard naming conventions (no default) - /// - Parameters: - /// - base: Base key string (e.g., "cloudkit.key_id") - /// - envPrefix: Prefix for environment variable (defaults to nil) - public init(_ base: String, envPrefix: String? = nil) { - self.baseKey = base - self.styles = [ - .commandLine: StandardNamingStyle.dotSeparated, - .environment: StandardNamingStyle.screamingSnakeCase(prefix: envPrefix), - ] - self.explicitKeys = [:] - } - - public func key(for source: ConfigKeySource) -> String? { - // Check for explicit key first - if let explicit = explicitKeys[source] { - return explicit - } - - // Generate from base key and style - guard let base = baseKey, let style = styles[source] else { - return nil - } - - return style.transform(base) - } -} - -extension OptionalConfigKey: CustomDebugStringConvertible { - public var debugDescription: String { - let cliKey = key(for: .commandLine) ?? "nil" - let envKey = key(for: .environment) ?? "nil" - return "OptionalConfigKey(cli: \(cliKey), env: \(envKey))" - } -} - -// MARK: - Convenience Initializers for BUSHEL Prefix - -extension OptionalConfigKey { - /// Convenience initializer for keys with BUSHEL prefix - /// - Parameter base: Base key string (e.g., "sync.min_interval") - public init(bushelPrefixed base: String) { - self.init(base, envPrefix: "BUSHEL") - } -} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift index 94f3138d..4898020c 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/MockCloudKitServiceTests.swift @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import BushelFoundation -import Foundation -import MistKit -import Testing +internal import BushelFoundation +internal import Foundation +internal import MistKit +internal import Testing @testable import BushelCloudKit diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift index 08a7a6fd..1bbed1d0 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Testing +internal import Testing @testable import BushelCloudKit diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift index c1315836..a5d8eaee 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift @@ -5,10 +5,10 @@ // Comprehensive tests for ConfigurationLoader // -import Configuration -import Foundation -import MistKit -import Testing +internal import Configuration +internal import Foundation +internal import MistKit +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/FetchConfigurationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/FetchConfigurationTests.swift index 578751cf..7e985096 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/FetchConfigurationTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/FetchConfigurationTests.swift @@ -6,8 +6,8 @@ // Copyright © 2025 BrightDigit. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockAppleDBFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockAppleDBFetcherTests.swift index e8bae23b..bb0fb7e5 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockAppleDBFetcherTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockAppleDBFetcherTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockIPSWFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockIPSWFetcherTests.swift index c3e66c73..8b2f5de6 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockIPSWFetcherTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockIPSWFetcherTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockMESUFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockMESUFetcherTests.swift index 2bf31de9..8ada1321 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockMESUFetcherTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockMESUFetcherTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockSwiftVersionFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockSwiftVersionFetcherTests.swift index 54e22b52..f15ad5a6 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockSwiftVersionFetcherTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockSwiftVersionFetcherTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockXcodeReleasesFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockXcodeReleasesFetcherTests.swift index 187f673f..be93db84 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockXcodeReleasesFetcherTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/MockXcodeReleasesFetcherTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageDeduplicationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageDeduplicationTests.swift index d0be214b..bbafacaa 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageDeduplicationTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageDeduplicationTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageMergeTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageMergeTests.swift index c7432286..326bffef 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageMergeTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/RestoreImageMergeTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/SwiftVersionDeduplicationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/SwiftVersionDeduplicationTests.swift index ca803c67..17b86201 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/SwiftVersionDeduplicationTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/SwiftVersionDeduplicationTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift index 34732c91..4ae97c5b 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/VirtualBuddyFetcherTests.swift @@ -27,14 +27,14 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// All VirtualBuddy tests wrapped in a serialized suite to prevent mock handler conflicts diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionDeduplicationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionDeduplicationTests.swift index 4fb2462c..65087e83 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionDeduplicationTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionDeduplicationTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionReferenceResolutionTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionReferenceResolutionTests.swift index 4619641b..e2f5e5ae 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionReferenceResolutionTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/DataSources/XcodeVersionReferenceResolutionTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift index 9aeadb43..e5b86560 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/AuthenticationErrorHandlingTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing // MARK: - Authentication Error Handling Tests diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift index 968e0732..4236b9e3 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/CloudKitErrorHandlingTests.swift @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import Testing +internal import Foundation +internal import MistKit +internal import Testing @testable import BushelCloudKit diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/GracefulDegradationTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/GracefulDegradationTests.swift index aba5179e..f09f4679 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/GracefulDegradationTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/GracefulDegradationTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing // MARK: - Graceful Degradation Tests diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/NetworkErrorHandlingTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/NetworkErrorHandlingTests.swift index 3163b909..7d0cc87e 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/NetworkErrorHandlingTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/ErrorHandling/NetworkErrorHandlingTests.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing // MARK: - Network Error Handling Tests diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift index 2040b78d..7ac8b34e 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Extensions/FieldValueURLTests.swift @@ -5,9 +5,9 @@ // Created by Claude Code // -import Foundation -import MistKit -import Testing +internal import Foundation +internal import MistKit +internal import Testing @testable import BushelCloudKit diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockAppleDBFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockAppleDBFetcher.swift index dbca9a82..965cb271 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockAppleDBFetcher.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockAppleDBFetcher.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift index 7329f425..831a967c 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockCloudKitService.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit // MARK: - Mock CloudKit Errors diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockFetcherError.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockFetcherError.swift index 0f56c232..42bdd97b 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockFetcherError.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockFetcherError.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation internal enum MockFetcherError: Error, Sendable { case networkError(String) diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockIPSWFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockIPSWFetcher.swift index 73cbb943..2e66edf3 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockIPSWFetcher.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockIPSWFetcher.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockMESUFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockMESUFetcher.swift index 93611dbb..025c213d 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockMESUFetcher.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockMESUFetcher.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockSwiftVersionFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockSwiftVersionFetcher.swift index 2f28cde1..d6b97101 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockSwiftVersionFetcher.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockSwiftVersionFetcher.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockURLProtocol.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockURLProtocol.swift index 368524fe..2c4d791c 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockURLProtocol.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockURLProtocol.swift @@ -30,7 +30,7 @@ public import Foundation #if canImport(FoundationNetworking) - import FoundationNetworking + internal import FoundationNetworking #endif /// Mock URLProtocol for intercepting and simulating HTTP requests in tests diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockXcodeReleasesFetcher.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockXcodeReleasesFetcher.swift index 1b018b2b..4621ca5e 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockXcodeReleasesFetcher.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Mocks/MockXcodeReleasesFetcher.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/DataSourceMetadataTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/DataSourceMetadataTests.swift index 034c7f93..d069d2a8 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/DataSourceMetadataTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/DataSourceMetadataTests.swift @@ -6,9 +6,9 @@ // Copyright © 2025 BrightDigit. // -import BushelFoundation -import MistKit -import Testing +internal import BushelFoundation +internal import MistKit +internal import Testing @testable import BushelCloudKit diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/RestoreImageRecordTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/RestoreImageRecordTests.swift index fcb14180..440788e7 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/RestoreImageRecordTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/RestoreImageRecordTests.swift @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import Testing +internal import Foundation +internal import MistKit +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/SwiftVersionRecordTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/SwiftVersionRecordTests.swift index 03657a35..c12e1c71 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/SwiftVersionRecordTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/SwiftVersionRecordTests.swift @@ -6,8 +6,8 @@ // Copyright © 2025 BrightDigit. // -import MistKit -import Testing +internal import MistKit +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/XcodeVersionRecordTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/XcodeVersionRecordTests.swift index 709754b5..43c786e8 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/XcodeVersionRecordTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Models/XcodeVersionRecordTests.swift @@ -6,8 +6,8 @@ // Copyright © 2025 BrightDigit. // -import MistKit -import Testing +internal import MistKit +internal import Testing @testable import BushelCloudKit @testable import BushelFoundation diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift index 822cdc5f..57806116 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/FieldValue+Assertions.swift @@ -29,7 +29,7 @@ public import Foundation public import MistKit -import Testing +internal import Testing /// Custom assertions for FieldValue comparisons extension FieldValue { diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/MockRecordInfo.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/MockRecordInfo.swift index 03acc4c6..a7f6fc4b 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/MockRecordInfo.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Utilities/MockRecordInfo.swift @@ -27,7 +27,6 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import Foundation public import MistKit /// Helper to create RecordInfo from field dictionaries for testing roundtrips @@ -51,28 +50,4 @@ public enum MockRecordInfo: Sendable { fields: fields ) } - - /// Creates a RecordInfo with an error for testing error handling - /// - /// - Parameters: - /// - recordName: CloudKit record name - /// - errorCode: Server error code (stored in fields for test verification) - /// - reason: Error reason message (stored in fields for test verification) - /// - Returns: A RecordInfo marked as an error (isError == true) - public static func createError( - recordType _: String, - recordName: String, - errorCode: String, - reason: String - ) -> RecordInfo { - RecordInfo( - recordName: recordName, - recordType: "Unknown", // Marks this as an error (isError will be true) - recordChangeTag: nil, - fields: [ - "serverErrorCode": .string(errorCode), - "reason": .string(reason), - ] - ) - } } diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift deleted file mode 100644 index e3dacfdd..00000000 --- a/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeySourceTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ConfigKeySourceTests.swift -// ConfigKeyKit -// -// Tests for ConfigKeySource enum -// - -import Testing - -@testable import ConfigKeyKit - -@Suite("ConfigKeySource Tests") -internal struct ConfigKeySourceTests { - @Test("All cases") - internal func allCases() { - let sources = ConfigKeySource.allCases - #expect(sources.count == 2) - #expect(sources.contains(.commandLine)) - #expect(sources.contains(.environment)) - } -} diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeyTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeyTests.swift deleted file mode 100644 index 512510d8..00000000 --- a/Examples/BushelCloud/Tests/ConfigKeyKitTests/ConfigKeyTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// ConfigKeyTests.swift -// ConfigKeyKit -// -// Tests for ConfigKey configuration -// - -import Testing - -@testable import ConfigKeyKit - -@Suite("ConfigKey Tests") -internal struct ConfigKeyTests { - @Test("ConfigKey with explicit keys and default") - internal func explicitKeys() { - let key = ConfigKey(cli: "test.key", env: "TEST_KEY", default: "default-value") - - #expect(key.key(for: .commandLine) == "test.key") - #expect(key.key(for: .environment) == "TEST_KEY") - #expect(key.defaultValue == "default-value") - } - - @Test("ConfigKey with base string and default prefix") - internal func baseStringWithDefaultPrefix() { - let key = ConfigKey( - bushelPrefixed: "cloudkit.container_id", default: "iCloud.com.example.App" - ) - - #expect(key.key(for: .commandLine) == "cloudkit.container_id") - #expect(key.key(for: .environment) == "BUSHEL_CLOUDKIT_CONTAINER_ID") - #expect(key.defaultValue == "iCloud.com.example.App") - } - - @Test("ConfigKey with base string and no prefix") - internal func baseStringNoPrefix() { - let key = ConfigKey( - "cloudkit.container_id", envPrefix: nil, default: "iCloud.com.example.App" - ) - - #expect(key.key(for: .commandLine) == "cloudkit.container_id") - #expect(key.key(for: .environment) == "CLOUDKIT_CONTAINER_ID") - #expect(key.defaultValue == "iCloud.com.example.App") - } - - @Test("ConfigKey with default value") - internal func defaultValue() { - let key = ConfigKey(cli: "test.key", env: "TEST_KEY", default: "default-value") - - #expect(key.defaultValue == "default-value") - } - - @Test("Boolean ConfigKey with default") - internal func booleanDefaultValue() { - let key = ConfigKey(bushelPrefixed: "sync.verbose", default: false) - - #expect(key.defaultValue == false) - } -} diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/NamingStyleTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/NamingStyleTests.swift deleted file mode 100644 index e45dca0a..00000000 --- a/Examples/BushelCloud/Tests/ConfigKeyKitTests/NamingStyleTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// NamingStyleTests.swift -// ConfigKeyKit -// -// Tests for naming style transformations -// - -import Testing - -@testable import ConfigKeyKit - -@Suite("NamingStyle Tests") -internal struct NamingStyleTests { - @Test("Dot-separated style") - internal func dotSeparatedStyle() { - let style = StandardNamingStyle.dotSeparated - #expect(style.transform("cloudkit.container_id") == "cloudkit.container_id") - } - - @Test("Screaming snake case with prefix") - internal func screamingSnakeCaseWithPrefix() { - let style = StandardNamingStyle.screamingSnakeCase(prefix: "BUSHEL") - #expect(style.transform("cloudkit.container_id") == "BUSHEL_CLOUDKIT_CONTAINER_ID") - } - - @Test("Screaming snake case without prefix") - internal func screamingSnakeCaseNoPrefix() { - let style = StandardNamingStyle.screamingSnakeCase(prefix: nil) - #expect(style.transform("cloudkit.container_id") == "CLOUDKIT_CONTAINER_ID") - } - - @Test("Screaming snake case with nil prefix") - internal func screamingSnakeCaseNilPrefix() { - let style = StandardNamingStyle.screamingSnakeCase(prefix: nil) - #expect(style.transform("sync.verbose") == "SYNC_VERBOSE") - } -} diff --git a/Examples/BushelCloud/Tests/ConfigKeyKitTests/OptionalConfigKeyTests.swift b/Examples/BushelCloud/Tests/ConfigKeyKitTests/OptionalConfigKeyTests.swift deleted file mode 100644 index 3daac28f..00000000 --- a/Examples/BushelCloud/Tests/ConfigKeyKitTests/OptionalConfigKeyTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// OptionalConfigKeyTests.swift -// ConfigKeyKit -// -// Tests for OptionalConfigKey configuration -// - -import Testing - -@testable import ConfigKeyKit - -@Suite("OptionalConfigKey Tests") -internal struct OptionalConfigKeyTests { - @Test("OptionalConfigKey with explicit keys") - internal func explicitKeys() { - let key = OptionalConfigKey(cli: "test.key", env: "TEST_KEY") - - #expect(key.key(for: .commandLine) == "test.key") - #expect(key.key(for: .environment) == "TEST_KEY") - } - - @Test("OptionalConfigKey with base string and default prefix") - internal func baseStringWithDefaultPrefix() { - let key = OptionalConfigKey(bushelPrefixed: "cloudkit.key_id") - - #expect(key.key(for: .commandLine) == "cloudkit.key_id") - #expect(key.key(for: .environment) == "BUSHEL_CLOUDKIT_KEY_ID") - } - - @Test("OptionalConfigKey with base string and no prefix") - internal func baseStringNoPrefix() { - let key = OptionalConfigKey("cloudkit.key_id", envPrefix: nil) - - #expect(key.key(for: .commandLine) == "cloudkit.key_id") - #expect(key.key(for: .environment) == "CLOUDKIT_KEY_ID") - } - - @Test("OptionalConfigKey and ConfigKey generate identical keys") - internal func keyGenerationParity() { - let optional = OptionalConfigKey(bushelPrefixed: "test.key") - let withDefault = ConfigKey(bushelPrefixed: "test.key", default: "default") - - #expect(optional.key(for: .commandLine) == withDefault.key(for: .commandLine)) - #expect(optional.key(for: .environment) == withDefault.key(for: .environment)) - } - - @Test("OptionalConfigKey for Int type") - internal func intOptionalKey() { - let key = OptionalConfigKey(bushelPrefixed: "sync.min_interval") - - #expect(key.key(for: .commandLine) == "sync.min_interval") - #expect(key.key(for: .environment) == "BUSHEL_SYNC_MIN_INTERVAL") - } - - @Test("OptionalConfigKey for Double type") - internal func doubleOptionalKey() { - let key = OptionalConfigKey(bushelPrefixed: "fetch.interval_global") - - #expect(key.key(for: .commandLine) == "fetch.interval_global") - #expect(key.key(for: .environment) == "BUSHEL_FETCH_INTERVAL_GLOBAL") - } -} diff --git a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml index 023e20de..a878600d 100644 --- a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml +++ b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml @@ -21,7 +21,7 @@ concurrency: env: PACKAGE_NAME: CelestraCloud - MISTKIT_BRANCH: v1.0.0-beta.1 + MISTKIT_BRANCH: v1.0.0-beta.2 jobs: configure: diff --git a/Examples/CelestraCloud/.github/workflows/codeql.yml b/Examples/CelestraCloud/.github/workflows/codeql.yml index 341134d6..df1ac023 100644 --- a/Examples/CelestraCloud/.github/workflows/codeql.yml +++ b/Examples/CelestraCloud/.github/workflows/codeql.yml @@ -58,7 +58,9 @@ jobs: swift package --version - name: Setup MistKit - uses: ./.github/actions/setup-mistkit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: v1.0.0-beta.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/Examples/CelestraCloud/.github/workflows/update-feeds.yml b/Examples/CelestraCloud/.github/workflows/update-feeds.yml index 100a7179..5a883d72 100644 --- a/Examples/CelestraCloud/.github/workflows/update-feeds.yml +++ b/Examples/CelestraCloud/.github/workflows/update-feeds.yml @@ -49,7 +49,7 @@ env: CLOUDKIT_KEY_ID: ${{ secrets.CLOUDKIT_KEY_ID }} CLOUDKIT_ENVIRONMENT: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && 'development' || github.event.inputs.environment || 'production' }} CLOUDKIT_PRIVATE_KEY_PATH: /tmp/cloudkit_key.pem - MISTKIT_BRANCH: v1.0.0-beta.1 + MISTKIT_BRANCH: v1.0.0-beta.2 jobs: # Determine which tier to run based on schedule or manual input @@ -141,12 +141,23 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # Resolve MISTKIT_BRANCH to its current HEAD commit so the cache key below + # changes whenever MistKit is updated. Without this the binary cache keys only + # on CelestraCloud's own sources, so a new MistKit commit on the same branch + # produces a stale cache hit and the fix under test is never rebuilt. + - name: Resolve MistKit branch SHA + id: mistkit-sha + run: | + SHA=$(git ls-remote https://github.com/brightdigit/MistKit.git "$MISTKIT_BRANCH" | head -n1 | cut -f1) + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Resolved MISTKIT_BRANCH=$MISTKIT_BRANCH to ${SHA:-}" + - name: Cache compiled binary id: cache-binary uses: actions/cache@v4 with: path: .build/release/celestra-cloud - key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ github.event.inputs.force_rebuild || 'false' }} + key: celestra-cloud-${{ runner.os }}-${{ hashFiles('Sources/**/*.swift', 'Package.swift') }}-${{ steps.mistkit-sha.outputs.sha }}-${{ github.event.inputs.force_rebuild || 'false' }} - name: Setup MistKit if: steps.cache-binary.outputs.cache-hit != 'true' diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index 76b381ba..8cc20d02 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit - commit = ea897c34cc0cc63c0a4c35bb99bf819535a47c6e - parent = 38f0d77f93f1df4384271be2ff865cae2e2f813d + commit = 4c532d39cd0540b08899c09fe80c24b00a7fd1ce + parent = 503ad2b6566916a9aa3c45b46b66bceb64df548d method = merge cmdver = 0.4.9 diff --git a/Examples/CelestraCloud/CHANGELOG.md b/Examples/CelestraCloud/CHANGELOG.md index 484bbf72..66700b48 100644 --- a/Examples/CelestraCloud/CHANGELOG.md +++ b/Examples/CelestraCloud/CHANGELOG.md @@ -70,7 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Technical Details - **Platform**: macOS 26+ (Swift 6.2) - **Concurrency**: Full Swift 6 concurrency support with strict checking -- **Dependencies**: MistKit 1.0.0-alpha.3, SyndiKit 0.6.1, ArgumentParser, swift-log +- **Dependencies**: MistKit 1.0.0-beta.2, SyndiKit 0.6.1, ArgumentParser, swift-log - **CloudKit**: Public database with Feed and Article record types - **Schema**: Text-based .ckdb schema with cktool deployment diff --git a/Examples/CelestraCloud/Scripts/lint.sh b/Examples/CelestraCloud/Scripts/lint.sh index dc840547..985de1e2 100755 --- a/Examples/CelestraCloud/Scripts/lint.sh +++ b/Examples/CelestraCloud/Scripts/lint.sh @@ -58,7 +58,8 @@ if [ -z "$FORMAT_ONLY" ]; then run_command swift build --build-tests fi -$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "CelestraCloud" +run_command $PACKAGE_DIR/Scripts/header.sh -d "$PACKAGE_DIR/Sources" -c "Leo Dion" -o "BrightDigit" -p "CelestraCloud" +run_command $PACKAGE_DIR/Scripts/header.sh -d "$PACKAGE_DIR/Tests" -c "Leo Dion" -o "BrightDigit" -p "CelestraCloud" # Generated files now automatically include ignore directives via OpenAPI generator configuration diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift index 8d0150ff..1d25ed5f 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Celestra.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation @main internal enum Celestra { diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift index 4680eb7a..b3cb333c 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift @@ -27,15 +27,14 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraCloudKit -import CelestraKit -import Foundation -import MistKit +internal import CelestraCloudKit +internal import CelestraKit +internal import Foundation +internal import MistKit // MARK: - Main Type internal enum AddFeedCommand { - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal static func run(args: [String]) async throws { guard let feedURL = args.first else { print("Error: Missing feed URL") diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift index 21e75c1f..9af2eba4 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift @@ -27,13 +27,12 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraCloudKit -import CelestraKit -import Foundation -import MistKit +internal import CelestraCloudKit +internal import CelestraKit +internal import Foundation +internal import MistKit internal enum ClearCommand { - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal static func run(args: [String]) async throws { // Require confirmation let hasConfirm = args.contains("--confirm") diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift index fef95ff9..64ee2d46 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraCloudKit -import CelestraKit -import Foundation -import MistKit +internal import CelestraCloudKit +internal import CelestraKit +internal import Foundation +internal import MistKit extension UpdateCommand { internal static func createFeedResult( diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift index f768ba79..1336e98e 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift @@ -27,13 +27,12 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraCloudKit -import CelestraKit -import Foundation -import MistKit +internal import CelestraCloudKit +internal import CelestraKit +internal import Foundation +internal import MistKit internal enum UpdateCommand { - @available(macOS 13.0, *) internal static func run() async throws { let startTime = Date() let loader = ConfigurationLoader() @@ -91,7 +90,6 @@ internal enum UpdateCommand { } } - @available(macOS 13.0, *) private static func createProcessor( config: CelestraConfiguration ) throws -> FeedUpdateProcessor { @@ -115,7 +113,6 @@ internal enum UpdateCommand { ) } - @available(macOS 13.0, *) private static func queryFeeds( config: CelestraConfiguration, processor: FeedUpdateProcessor @@ -138,7 +135,6 @@ internal enum UpdateCommand { return feeds } - @available(macOS 13.0, *) private static func processFeeds( _ feeds: [Feed], processor: FeedUpdateProcessor diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift index 5de33763..f7710066 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommandError.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Errors specific to feed update operations internal struct UpdateCommandError: LocalizedError { diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateSummary.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateSummary.swift index 8ade5d61..ff4a7e2f 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateSummary.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateSummary.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraCloudKit +internal import CelestraCloudKit /// Tracks update operation statistics internal struct UpdateSummary { diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor+Fetch.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor+Fetch.swift index 4cd6e369..c1664fe1 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor+Fetch.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor+Fetch.swift @@ -27,12 +27,11 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraCloudKit -import CelestraKit -import Foundation -import MistKit +internal import CelestraCloudKit +internal import CelestraKit +internal import Foundation +internal import MistKit -@available(macOS 13.0, *) extension FeedUpdateProcessor { internal func processSuccessfulFetch( feed: Feed, diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift index d9959e3b..5153e5d7 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateProcessor.swift @@ -27,13 +27,12 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraCloudKit -import CelestraKit -import Foundation -import MistKit +internal import CelestraCloudKit +internal import CelestraKit +internal import Foundation +internal import MistKit /// Processes individual feed updates -@available(macOS 13.0, *) internal struct FeedUpdateProcessor { internal let service: CloudKitService internal let fetcher: RSSFetcherService diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift index a0143868..b08f7e30 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Services/FeedUpdateResult.swift @@ -35,29 +35,4 @@ internal enum FeedUpdateResult: Sendable, Equatable { case notModified case skipped(reason: String) case error(message: String) - - // MARK: - Subtypes - - internal enum SimpleStatus { - case success - case notModified - case skipped - case error - } - - // MARK: - Properties - - /// Simple status for backward compatibility - internal var simpleStatus: SimpleStatus { - switch self { - case .success: - return .success - case .notModified: - return .notModified - case .skipped: - return .skipped - case .error: - return .error - } - } } diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift index 648f902e..7522d099 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift @@ -35,7 +35,6 @@ public import MistKit /// Shared configuration helper for creating CloudKit service public enum CelestraConfig { /// Create CloudKit service from validated configuration - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public static func createCloudKitService(from config: ValidatedCloudKitConfiguration) throws -> CloudKitService { @@ -57,7 +56,6 @@ public enum CelestraConfig { } /// Create CloudKit service from environment variables - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) @available( *, deprecated, message: "Use ConfigurationLoader with createCloudKitService(from:) instead" ) diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift index 460de999..b4449c9c 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift @@ -32,7 +32,6 @@ internal import Foundation internal import MistKit /// Loads and merges configuration from multiple sources -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public actor ConfigurationLoader { private let configReader: ConfigReader diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift index 653c38f1..083a907b 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Protocols/CloudKitRecordOperating.swift @@ -76,20 +76,31 @@ public protocol CloudKitRecordOperating: Sendable { // MARK: - CloudKitService Conformance -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService: CloudKitRecordOperating { - /// Satisfy CloudKitRecordOperating protocol by forwarding to modifyRecords(_:atomic:) + /// Satisfy CloudKitRecordOperating protocol by forwarding to modifyRecords(_:atomic:). + /// + /// MistKit now returns `[RecordResult]`; this protocol keeps the `[RecordInfo]` contract, + /// so a per-record `.failure` is rethrown as `CloudKitError.recordOperationFailed`. That + /// matches Celestra's conservative all-or-nothing batch handling (a failure surfaces as a + /// thrown error, which the caller treats as a failed batch). public func modifyRecords(_ operations: [RecordOperation]) async throws(CloudKitError) -> [RecordInfo] { - try await modifyRecords( + let results = try await modifyRecords( operations, atomic: false, database: .public(.prefers(.serverToServer)) ) + var records: [RecordInfo] = [] + records.reserveCapacity(results.count) + for result in results { + records.append(try result.get()) + } + return records } - /// Satisfy CloudKitRecordOperating's `queryRecords` (no database param) by forwarding to the public-database overload. + /// Satisfy CloudKitRecordOperating's `queryRecords` (no database param) + /// by forwarding to the public-database overload. public func queryRecords( recordType: String, filters: [QueryFilter]?, @@ -109,7 +120,8 @@ extension CloudKitService: CloudKitRecordOperating { return result.records } - /// Satisfy CloudKitRecordOperating's `queryAllRecords` (no database param) by forwarding to the public-database overload. + /// Satisfy CloudKitRecordOperating's `queryAllRecords` (no database param) + /// by forwarding to the public-database overload. public func queryAllRecords( recordType: String, filters: [QueryFilter]?, diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift index c92b5eb8..3d9a8801 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCategorizer.swift @@ -31,7 +31,6 @@ public import CelestraKit internal import Foundation /// Pure function type for categorizing feed items into new vs modified articles -@available(macOS 13.0, *) public struct ArticleCategorizer: Sendable { /// Result of article categorization public struct Result: Sendable, Equatable { diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift index dff70fdd..afc2ed56 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleCloudKitService.swift @@ -29,7 +29,7 @@ public import CelestraKit public import Foundation -import Logging +internal import Logging public import MistKit // swiftlint:disable file_length @@ -55,7 +55,6 @@ private let guidQueryBatchSize = 150 private let articleMutationBatchSize = 10 /// Service for Article-related CloudKit operations with dependency injection support -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct ArticleCloudKitService: Sendable { private enum BatchOperation { case create diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleOperationBuilder.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleOperationBuilder.swift index 672665ab..fcc13c07 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleOperationBuilder.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleOperationBuilder.swift @@ -34,7 +34,6 @@ public import MistKit /// Pure function type for building CloudKit record operations from articles. /// Follows the pattern of ArticleCategorizer and FeedMetadataBuilder for testable, /// dependency-free operation building. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct ArticleOperationBuilder: Sendable { /// Initialize article operation builder public init() {} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift index 3030e513..dd146428 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/ArticleSyncService.swift @@ -31,7 +31,6 @@ public import CelestraKit public import MistKit /// Service for synchronizing articles: query existing, categorize, create/update -@available(macOS 13.0, *) public struct ArticleSyncService: Sendable { private let articleService: ArticleCloudKitService private let categorizer: ArticleCategorizer diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift index b71ae8a6..edbcd542 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CelestraError.swift @@ -62,67 +62,77 @@ public enum CelestraError: LocalizedError { /// Invalid record name case invalidRecordName(String) + // MARK: - Lookup Tables + + // The tables below are keyed by `caseID` (see the discriminator at the bottom + // of this file) so that cases with associated values — which can't be written + // as case-literal keys — participate alongside the payload-free cases. + + /// Cases that are retriable on their own. `cloudKitError` is decided + /// separately, delegating to the wrapped `CloudKitError`. + private static let retriableCaseIDs: Set = [ + .rssFetchFailed, + .networkUnavailable, + ] + + /// Error descriptions for cases whose text doesn't depend on associated values. + private static let staticDescriptions: [CaseID: String] = [ + .quotaExceeded: "CloudKit quota exceeded. Please try again later.", + .networkUnavailable: "Network unavailable. Check your connection.", + .permissionDenied: "Permission denied for CloudKit operation.", + ] + + /// Recovery suggestions. Cases absent here have no suggestion (`nil`). + private static let recoverySuggestions: [CaseID: String] = [ + .rssFetchFailed: "Verify the feed URL is accessible and try again.", + .invalidFeedData: "Verify the feed URL returns valid RSS/Atom data.", + .quotaExceeded: "Wait a few minutes for CloudKit quota to reset, then try again.", + .networkUnavailable: "Check your internet connection and try again.", + .permissionDenied: "Check your CloudKit permissions and API token configuration.", + ] + // MARK: - Retriability /// Determines if this error can be retried public var isRetriable: Bool { - switch self { - case .cloudKitError(let ckError): + if case .cloudKitError(let ckError) = self { return isCloudKitErrorRetriable(ckError) - case .rssFetchFailed, .networkUnavailable: - return true - case .quotaExceeded, .invalidFeedData, .batchOperationFailed, - .permissionDenied, .recordNotFound, .cloudKitOperationFailed, .invalidRecordName: - return false } + return Self.retriableCaseIDs.contains(caseID) } // MARK: - LocalizedError Conformance /// Localized error description public var errorDescription: String? { - switch self { - case .cloudKitError(let error): - return "CloudKit operation failed: \(error.localizedDescription)" - case .rssFetchFailed(let url, let error): - return "Failed to fetch RSS feed from \(url.absoluteString): \(error.localizedDescription)" - case .invalidFeedData(let reason): - return "Invalid feed data: \(reason)" - case .batchOperationFailed(let errors): - return "Batch operation failed with \(errors.count) error(s)" - case .quotaExceeded: - return "CloudKit quota exceeded. Please try again later." - case .networkUnavailable: - return "Network unavailable. Check your connection." - case .permissionDenied: - return "Permission denied for CloudKit operation." - case .recordNotFound(let recordName): - return "Record not found: \(recordName)" - case .cloudKitOperationFailed(let message): - return "CloudKit operation failed: \(message)" - case .invalidRecordName(let message): - return "Invalid record name: \(message)" + // Cases without associated values get their text from the lookup table; + // the rest interpolate their payloads. + guard let staticDescription = Self.staticDescriptions[caseID] else { + switch self { + case .cloudKitError(let error): + return "CloudKit operation failed: \(error.localizedDescription)" + case .rssFetchFailed(let url, let error): + return "Failed to fetch RSS feed from \(url.absoluteString): \(error.localizedDescription)" + case .invalidFeedData(let reason): + return "Invalid feed data: \(reason)" + case .batchOperationFailed(let errors): + return "Batch operation failed with \(errors.count) error(s)" + case .recordNotFound(let recordName): + return "Record not found: \(recordName)" + case .cloudKitOperationFailed(let message): + return "CloudKit operation failed: \(message)" + case .invalidRecordName(let message): + return "Invalid record name: \(message)" + default: + assertionFailure("Missing `errorDescription` for case: \(self).") + return nil + } } + return staticDescription } /// Suggested recovery action for the error - public var recoverySuggestion: String? { - switch self { - case .quotaExceeded: - return "Wait a few minutes for CloudKit quota to reset, then try again." - case .networkUnavailable: - return "Check your internet connection and try again." - case .rssFetchFailed: - return "Verify the feed URL is accessible and try again." - case .permissionDenied: - return "Check your CloudKit permissions and API token configuration." - case .invalidFeedData: - return "Verify the feed URL returns valid RSS/Atom data." - case .cloudKitError, .batchOperationFailed, .recordNotFound, - .cloudKitOperationFailed, .invalidRecordName: - return nil - } - } + public var recoverySuggestion: String? { Self.recoverySuggestions[caseID] } // MARK: - CloudKit Error Classification @@ -135,21 +145,42 @@ public enum CelestraError: LocalizedError { // Retry on server errors (5xx) and rate limiting (429) // Don't retry on client errors (4xx) except 429 return statusCode >= 500 || statusCode == 429 - case .invalidResponse, .underlyingError: - // Network-related errors are retriable + // Network-related/transient errors are retriable + case .invalidResponse, .underlyingError, .networkError: return true - case .networkError: - // Network errors are retriable - return true - case .decodingError: - // Decoding errors are not retriable (data format issue) - return false - case .unsupportedOperationType, .paginationLimitExceeded: - // Programmer/configuration issues — not retriable - return false - case .missingCredentials, .invalidPrivateKey: - // Credential/configuration issues — not retriable + + // Everything else (decoding, configuration, credential, malformed-request, + // and quota errors) is not retriable. + default: return false } } } + +// MARK: - Case Identity + +extension CelestraError { + /// Payload-free mirror of `CelestraError`'s cases. Used as the key into the + /// lookup tables above so that cases with associated values can be classified + /// without being written as case literals. + private enum CaseID { + case cloudKitError, rssFetchFailed, invalidFeedData, batchOperationFailed, + quotaExceeded, networkUnavailable, permissionDenied, recordNotFound, + cloudKitOperationFailed, invalidRecordName + } + + private var caseID: CaseID { + switch self { + case .cloudKitError: .cloudKitError + case .rssFetchFailed: .rssFetchFailed + case .invalidFeedData: .invalidFeedData + case .batchOperationFailed: .batchOperationFailed + case .quotaExceeded: .quotaExceeded + case .networkUnavailable: .networkUnavailable + case .permissionDenied: .permissionDenied + case .recordNotFound: .recordNotFound + case .cloudKitOperationFailed: .cloudKitOperationFailed + case .invalidRecordName: .invalidRecordName + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift index 7f5f22a2..8b4cf9a7 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift @@ -33,7 +33,6 @@ internal import Logging public import MistKit /// CloudKit service extensions for Celestra operations -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension CloudKitService { // MARK: - Feed Operations diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedCloudKitService.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedCloudKitService.swift index a4974ef6..7f62b556 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedCloudKitService.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Services/FeedCloudKitService.swift @@ -29,11 +29,10 @@ public import CelestraKit public import Foundation -import Logging +internal import Logging public import MistKit /// Service for Feed-related CloudKit operations with dependency injection support -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct FeedCloudKitService: Sendable { private let recordOperator: any CloudKitRecordOperating diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift index 22e7102d..80d18002 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import Testing +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/UpdateCommandConfigurationTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/UpdateCommandConfigurationTests.swift index d2f09cf8..39a5903c 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/UpdateCommandConfigurationTests.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/UpdateCommandConfigurationTests.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+Description.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+Description.swift index 9407fb20..f73e2b39 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+Description.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+Description.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import Testing +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+RecoverySuggestion.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+RecoverySuggestion.swift index 11d56073..031edc5c 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+RecoverySuggestion.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests+RecoverySuggestion.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import Testing +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests.swift index 7f88fb58..b1a2b7a7 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CelestraErrorTests.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import Testing +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CloudKitConversionErrorTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CloudKitConversionErrorTests.swift index 4515cbe4..7baa72e0 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CloudKitConversionErrorTests.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Errors/CloudKitConversionErrorTests.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +internal import Foundation +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift index dc8e3ee1..b628db72 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+FromCloudKit.swift @@ -1,7 +1,36 @@ -import CelestraKit -import Foundation -import MistKit -import Testing +// +// ArticleConversion+FromCloudKit.swift +// CelestraCloud +// +// 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 CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift index ef341bef..1c0d3561 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion+ToCloudKit.swift @@ -1,7 +1,36 @@ -import CelestraKit -import Foundation -import MistKit -import Testing +// +// ArticleConversion+ToCloudKit.swift +// CelestraCloud +// +// 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 CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion.swift index 08e4a2d2..e1c49436 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/ArticleConversion.swift @@ -1,2 +1,31 @@ +// +// ArticleConversion.swift +// CelestraCloud +// +// 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. +// + /// Namespace for Article conversion tests internal enum ArticleConversion {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift index e5abb96d..6069dcea 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+FromCloudKit.swift @@ -1,7 +1,36 @@ -import CelestraKit -import Foundation -import MistKit -import Testing +// +// FeedConversion+FromCloudKit.swift +// CelestraCloud +// +// 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 CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+RoundTrip.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+RoundTrip.swift index 018cb130..0a0e92b5 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+RoundTrip.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+RoundTrip.swift @@ -1,7 +1,36 @@ -import CelestraKit -import Foundation -import MistKit -import Testing +// +// FeedConversion+RoundTrip.swift +// CelestraCloud +// +// 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 CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift index 61ff2a15..1ba5245a 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion+ToCloudKit.swift @@ -1,7 +1,36 @@ -import CelestraKit -import Foundation -import MistKit -import Testing +// +// FeedConversion+ToCloudKit.swift +// CelestraCloud +// +// 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 CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion.swift index e2e69be8..54f7d2c2 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Extensions/FeedConversion.swift @@ -1,2 +1,31 @@ +// +// FeedConversion.swift +// CelestraCloud +// +// 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. +// + /// Namespace for Feed conversion tests internal enum FeedConversion {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Mocks/MockCloudKitRecordOperator.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Mocks/MockCloudKitRecordOperator.swift index 09aac148..636f2e73 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Mocks/MockCloudKitRecordOperator.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Mocks/MockCloudKitRecordOperator.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit -import Synchronization +internal import Foundation +internal import MistKit +internal import Synchronization @testable import CelestraCloudKit @@ -45,9 +45,7 @@ internal final class MockCloudKitRecordOperator: CloudKitRecordOperating, Sendab internal struct QueryCall: Sendable { internal let recordType: String internal let filters: [QueryFilter]? - internal let sortBy: [QuerySort]? internal let limit: Int? - internal let desiredKeys: [String]? } internal struct ModifyCall: Sendable { @@ -97,9 +95,7 @@ internal final class MockCloudKitRecordOperator: CloudKitRecordOperating, Sendab QueryCall( recordType: recordType, filters: filters, - sortBy: sortBy, - limit: limit, - desiredKeys: desiredKeys + limit: limit ) ) return state.queryRecordsResult @@ -130,9 +126,7 @@ internal final class MockCloudKitRecordOperator: CloudKitRecordOperating, Sendab QueryCall( recordType: recordType, filters: filters, - sortBy: sortBy, - limit: pageSize, - desiredKeys: desiredKeys + limit: pageSize ) ) return state.queryRecordsResult diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Models/BatchOperationResultTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Models/BatchOperationResultTests.swift index 95b7daaf..e8176827 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Models/BatchOperationResultTests.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Models/BatchOperationResultTests.swift @@ -1,7 +1,36 @@ -import CelestraKit -import Foundation -import MistKit -import Testing +// +// BatchOperationResultTests.swift +// CelestraCloud +// +// 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 CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Advanced.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Advanced.swift index 84b2a16b..a16e5261 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Advanced.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Advanced.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import Testing +internal import CelestraKit +internal import Foundation +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Basic.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Basic.swift index 58ac2d58..4d8d764e 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Basic.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer+Basic.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import Testing +internal import CelestraKit +internal import Foundation +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer.swift index f02ff0ee..3a5d9433 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCategorizer.swift @@ -1,2 +1,31 @@ +// +// ArticleCategorizer.swift +// CelestraCloud +// +// 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. +// + /// Namespace for ArticleCategorizer tests internal enum ArticleCategorizer {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Mutations.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Mutations.swift index 9db8260a..f364a9dd 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Mutations.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Mutations.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import MistKit -import Testing +internal import CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit @@ -66,18 +66,6 @@ extension ArticleCloudKitService { ) } - private func createArticleRecordFields(guid: String = "test-guid") -> [String: FieldValue] { - [ - "feedRecordName": .string("feed-123"), - "guid": .string(guid), - "title": .string("Test Article"), - "url": .string("https://example.com/article"), - "fetchedTimestamp": .date(Date(timeIntervalSince1970: 1_000_000)), - "expiresTimestamp": .date(Date(timeIntervalSince1970: 1_000_000 + 30 * 24 * 60 * 60)), - "contentHash": .string("abc123"), - ] - } - // MARK: - createArticles Tests @Test("createArticles returns empty result for empty input") diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift index 4ec2091c..130a1c16 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService+Query.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import MistKit -import Testing +internal import CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit @@ -51,21 +51,6 @@ extension ArticleCloudKitService { ) } - private func createTestArticle( - recordName: String? = nil, - guid: String = "test-guid" - ) -> Article { - Article( - recordName: recordName, - feedRecordName: "feed-123", - guid: guid, - title: "Test Article", - url: "https://example.com/article", - fetchedAt: Date(timeIntervalSince1970: 1_000_000), - ttlDays: 30 - ) - } - private func createArticleRecordFields(guid: String = "test-guid") -> [String: FieldValue] { [ "feedRecordName": .string("feed-123"), diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService.swift index 32714368..22bc144b 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/ArticleCloudKitService.swift @@ -1,2 +1,31 @@ +// +// ArticleCloudKitService.swift +// CelestraCloud +// +// 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. +// + /// Namespace for ArticleCloudKitService tests internal enum ArticleCloudKitService {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+CRUD.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+CRUD.swift index 629b25e0..081f90d7 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+CRUD.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+CRUD.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import MistKit -import Testing +internal import CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+Query.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+Query.swift index ab4e8366..99741511 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+Query.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService+Query.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,10 +27,10 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import MistKit -import Testing +internal import CelestraKit +internal import Foundation +internal import MistKit +internal import Testing @testable import CelestraCloudKit @@ -51,26 +51,6 @@ extension FeedCloudKitService { ) } - private func createTestFeed() -> Feed { - Feed( - recordName: nil, - feedURL: "https://example.com/feed.xml", - title: "Test Feed", - description: "A test feed", - isFeatured: false, - isVerified: true, - subscriberCount: 100, - totalAttempts: 5, - successfulAttempts: 4, - lastAttempted: Date(timeIntervalSince1970: 1_000_000), - isActive: true, - etag: "etag-123", - lastModified: "Mon, 01 Jan 2024 00:00:00 GMT", - failureCount: 1, - minUpdateInterval: 3_600 - ) - } - // MARK: - queryFeeds Tests @Test("queryFeeds returns feeds from query results") diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService.swift index 5afd86bc..15fd25a3 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedCloudKitService.swift @@ -1,2 +1,31 @@ +// +// FeedCloudKitService.swift +// CelestraCloud +// +// 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. +// + /// Namespace for FeedCloudKitService tests internal enum FeedCloudKitService {} diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Error.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Error.swift index 5d952872..b169e088 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Error.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Error.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import Testing +internal import CelestraKit +internal import Foundation +internal import Testing @testable import CelestraCloudKit @@ -62,32 +62,6 @@ extension FeedMetadataBuilder { ) } - private func createFeedData( - title: String = "New Feed Title", - description: String? = "New Feed Description", - minUpdateInterval: TimeInterval? = 7_200 - ) -> FeedData { - FeedData( - title: title, - description: description, - items: [], // Not used in metadata building - minUpdateInterval: minUpdateInterval - ) - } - - private func createFetchResponse( - feedData: FeedData? = nil, - etag: String? = "new-etag", - lastModified: String? = "Tue, 02 Jan 2024 00:00:00 GMT" - ) -> FetchResponse { - FetchResponse( - feedData: feedData, - lastModified: lastModified, - etag: etag, - wasModified: feedData != nil - ) - } - // MARK: - Error Metadata Tests @Test("Error metadata preserves all feed data") diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+NotModified.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+NotModified.swift index fdadd0d4..3c49fba8 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+NotModified.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+NotModified.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import Testing +internal import CelestraKit +internal import Foundation +internal import Testing @testable import CelestraCloudKit @@ -62,19 +62,6 @@ extension FeedMetadataBuilder { ) } - private func createFeedData( - title: String = "New Feed Title", - description: String? = "New Feed Description", - minUpdateInterval: TimeInterval? = 7_200 - ) -> FeedData { - FeedData( - title: title, - description: description, - items: [], // Not used in metadata building - minUpdateInterval: minUpdateInterval - ) - } - private func createFetchResponse( feedData: FeedData? = nil, etag: String? = "new-etag", diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Success.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Success.swift index 749e1ca8..f075b3b4 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Success.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder+Success.swift @@ -3,7 +3,7 @@ // CelestraCloud // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import CelestraKit -import Foundation -import Testing +internal import CelestraKit +internal import Foundation +internal import Testing @testable import CelestraCloudKit diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder.swift index b0bbf0d9..b8e661b0 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Services/FeedMetadataBuilder.swift @@ -1,2 +1,31 @@ +// +// FeedMetadataBuilder.swift +// CelestraCloud +// +// 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. +// + /// Namespace for FeedMetadataBuilder tests internal enum FeedMetadataBuilder {} diff --git a/Examples/MistDemo/.swiftlint.yml b/Examples/MistDemo/.swiftlint.yml index c33a1a85..3ebd9518 100644 --- a/Examples/MistDemo/.swiftlint.yml +++ b/Examples/MistDemo/.swiftlint.yml @@ -11,7 +11,6 @@ opt_in_rules: - contains_over_range_nil_comparison - convenience_type - discouraged_object_literal - - discouraged_optional_boolean - empty_collection_literal - empty_count - empty_string @@ -126,6 +125,10 @@ indentation_width: file_name: severity: error excluded: + # Holds only the per-platform `#if`-selected typealiases; named neutrally + # so no alias is misread as a `main_type` by `file_types_order` (see the + # file's header comment). The resulting `file_name` mismatch is expected. + - PlatformAliases.swift - Package.swift - AsyncHelpers.swift - UserInfoTestExtension.swift diff --git a/Examples/MistDemo/App/MistDemoApp.swift b/Examples/MistDemo/App/MistDemoApp.swift index ea52376a..740c7ce7 100644 --- a/Examples/MistDemo/App/MistDemoApp.swift +++ b/Examples/MistDemo/App/MistDemoApp.swift @@ -27,9 +27,14 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import MistDemoApp -import SwiftUI +internal import MistDemoApp +internal import SwiftUI @main internal struct MistDemoAppMain: AppMain { + // SwiftUI owns the AppDelegate's lifecycle via this adaptor; the + // delegate's static-weak `receiver` is wired from `CloudKitStore.init` + // so the OS-delivered APNs token lands back in the observable store. + @PlatformApplicationDelegateAdaptor(PushNotificationDelegate.self) + private var pushDelegate } diff --git a/Examples/MistDemo/MistDemoApp.entitlements b/Examples/MistDemo/MistDemoApp.entitlements index 66b80d9b..4911fde2 100644 --- a/Examples/MistDemo/MistDemoApp.entitlements +++ b/Examples/MistDemo/MistDemoApp.entitlements @@ -14,5 +14,9 @@ com.apple.security.network.client + aps-environment + development + com.apple.developer.aps-environment + development diff --git a/Examples/MistDemo/Package.resolved b/Examples/MistDemo/Package.resolved index 2fa36330..7cd27b35 100644 --- a/Examples/MistDemo/Package.resolved +++ b/Examples/MistDemo/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7284c3deec21f39c02edfa30e7214ff910bbb668d02643c0e02f07ab3341122d", + "originHash" : "6f8d22d7a01321d7f20aacae50cf0745b96d0dcf85ca136a97f120a653651735", "pins" : [ { "identity" : "async-http-client", @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-openapi-runtime", "state" : { - "revision" : "f039fa6d6338aab5164f3d1be16281524c9a8f89", - "version" : "1.11.0" + "revision" : "3d3a8457661daf7fb260ceeb9f0e24e5204ba5fb", + "version" : "1.12.0" } }, { diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index ab997e79..04c9daa1 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -106,6 +106,7 @@ let package = Package( ], dependencies: [ .package(name: "MistKit", path: "../.."), + .package(name: "ConfigKeyKit", path: "../../Packages/ConfigKeyKit"), .package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0" @@ -125,11 +126,6 @@ let package = Package( ), ], targets: [ - .target( - name: "ConfigKeyKit", - dependencies: [], - swiftSettings: swiftSettings - ), .target( name: "MistDemoApp", dependencies: ["MistDemoKit"], @@ -138,7 +134,7 @@ let package = Package( .target( name: "MistDemoKit", dependencies: [ - "ConfigKeyKit", + .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), .product( name: "Hummingbird", @@ -160,6 +156,8 @@ let package = Package( ], resources: [ .copy("Resources/index.html"), + .copy("Resources/styles.css"), + .copy("Resources/js"), ], swiftSettings: swiftSettings ), @@ -167,7 +165,7 @@ let package = Package( name: "MistDemo", dependencies: [ "MistDemoKit", - "ConfigKeyKit", + .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), ], swiftSettings: swiftSettings @@ -176,9 +174,8 @@ let package = Package( name: "MistDemoTests", dependencies: [ "MistDemoKit", - "ConfigKeyKit", + .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), - .product(name: "MistKitOpenAPI", package: "MistKit"), .product( name: "Hummingbird", package: "hummingbird", diff --git a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift index 2aa28bd9..571186d9 100644 --- a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift +++ b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import MistDemoKit +internal import MistDemoKit @main internal enum MistDemo { diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/CKRecord+TypedField.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/CKRecord+TypedField.swift index 6a099e18..eb8a1232 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/CKRecord+TypedField.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/CKRecord+TypedField.swift @@ -28,8 +28,8 @@ // #if canImport(CloudKit) - import CloudKit - import Foundation + internal import CloudKit + internal import Foundation extension CKRecord { /// Reads `field` from the record and casts it to `T`. diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift index 1961a64a..fab146b6 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/Note.swift @@ -28,9 +28,9 @@ // #if canImport(CloudKit) - import CloudKit - import Foundation - import MistDemoKit + internal import CloudKit + internal import Foundation + internal import MistDemoKit /// Note record, mirroring the `Note` type defined in `schema.ckdb`: /// diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/RecordZoneChangesSnapshot.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/RecordZoneChangesSnapshot.swift new file mode 100644 index 00000000..31e1a8cd --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/RecordZoneChangesSnapshot.swift @@ -0,0 +1,42 @@ +// +// RecordZoneChangesSnapshot.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import CloudKit + internal import Foundation + + /// Snapshot from `CKFetchRecordZoneChangesOperation` so the UI can show + /// what changed since the last sync token. + internal struct RecordZoneChangesSnapshot: Sendable { + internal let changedRecordNames: [String] + internal let deletedRecordNames: [String] + internal let serverChangeToken: CKServerChangeToken? + internal let moreComing: Bool + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/ResolveResult.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/ResolveResult.swift new file mode 100644 index 00000000..8c580220 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/ResolveResult.swift @@ -0,0 +1,47 @@ +// +// ResolveResult.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import Foundation + + /// Result of a composed `records/resolve`. The REST endpoint takes + /// either a record name or a share URL; the native CloudKit surface + /// branches on the input shape, so we record which branch ran. + internal struct ResolveResult: Sendable { + internal enum Source: String, Sendable { + case recordName + case shareURL + } + + internal let source: Source + internal let recordName: String? + internal let recordType: String? + internal let shareTitle: String? + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Models/ZoneRow.swift b/Examples/MistDemo/Sources/MistDemoApp/Models/ZoneRow.swift index ac711fc5..f9648aa9 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Models/ZoneRow.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Models/ZoneRow.swift @@ -28,9 +28,9 @@ // #if canImport(CloudKit) - import CloudKit - import Foundation - import MistDemoKit + internal import CloudKit + internal import Foundation + internal import MistDemoKit /// Display-friendly snapshot of a CKRecordZone for the SwiftUI list. extension ZoneRow { diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase+WebAuthToken.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase+WebAuthToken.swift index d34a79ec..632cc3aa 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase+WebAuthToken.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase+WebAuthToken.swift @@ -28,7 +28,7 @@ // #if canImport(CloudKit) - import CloudKit + internal import CloudKit extension CKDatabase { /// Capture a web-auth token via `CKFetchWebAuthTokenOperation` for the diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase.Scope+Demo.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase.Scope+Demo.swift index 0be3b9b1..29d43615 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase.Scope+Demo.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CKDatabase.Scope+Demo.swift @@ -28,7 +28,7 @@ // #if canImport(CloudKit) - import CloudKit + internal import CloudKit extension CKDatabase.Scope { /// Scopes exposed in the MistDemoApp picker. `.shared` is intentionally diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Assets.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Assets.swift new file mode 100644 index 00000000..927cb4a0 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Assets.swift @@ -0,0 +1,90 @@ +// +// CloudKitStore+Assets.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import CloudKit + internal import Foundation + internal import MistDemoKit + + /// Result of a composed `assets/rereference`. The native CloudKit surface + /// can't move asset metadata between records in one call — we fetch the + /// source record, reuse its `CKAsset`, then save it onto the target + /// record. The result records both the source and target. + internal struct RereferenceResult: Sendable { + internal let sourceRecordName: String + internal let assetField: String + internal let targetRecordName: String + internal let targetAssetField: String + } + + extension CloudKitStore { + /// Upload `fileURL` as the `image` asset on a new Note record. Maps to + /// `assets/upload` in the REST surface — native CloudKit does the + /// upload inline as part of `database.save(_:)`. + internal func uploadAssetNote( + title: String, + index: Int64, + fileURL: URL + ) async throws -> Note { + try await createNote(title: title, index: index, imageURL: fileURL) + } + + /// Re-reference an asset from one record onto another. Composed call: + /// fetch the source record, pull its `CKAsset`, save the target with + /// that same asset. Native CloudKit doesn't expose a single-call + /// equivalent of the REST `assets/rereference` endpoint, hence the + /// composition. + internal func rereferenceAsset( + sourceRecordName: String, + assetField: String, + targetRecordName: String, + targetAssetField: String? = nil + ) async throws -> RereferenceResult { + let resolvedTargetField = targetAssetField ?? assetField + + let sourceID = CKRecord.ID(recordName: sourceRecordName) + let sourceRecord = try await database.record(for: sourceID) + guard let asset = sourceRecord[assetField] as? CKAsset else { + throw CloudKitStoreError.unexpectedSaveResult + } + + let targetID = CKRecord.ID(recordName: targetRecordName) + let targetRecord = try await database.record(for: targetID) + targetRecord[resolvedTargetField] = asset + _ = try await database.save(targetRecord) + + return RereferenceResult( + sourceRecordName: sourceRecordName, + assetField: assetField, + targetRecordName: targetRecordName, + targetAssetField: resolvedTargetField + ) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+PushTokens.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+PushTokens.swift new file mode 100644 index 00000000..8ccc73d1 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+PushTokens.swift @@ -0,0 +1,94 @@ +// +// CloudKitStore+PushTokens.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import CloudKit + public import Foundation + internal import MistDemoKit + + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + internal import AppKit + #elseif canImport(UIKit) && !os(watchOS) + internal import UIKit + #endif + + extension CloudKitStore { + /// Trigger APNs registration. Returns immediately after asking the OS; + /// the actual token arrives via the platform app delegate hook, which + /// the demo app forwards back into the store via `recordDeviceToken`. + /// On platforms where APNs isn't available we report `.failed`. + internal func requestPushNotificationRegistration() { + PlatformApplication.registerSharedForRemoteNotifications() + pushTokenStatus = .requesting + // #else + // pushTokenStatus = .failed( + // message: "APNs registration is unavailable on this platform." + // ) + // #endif + } + + /// Forward the APNs device token captured by the platform app delegate. + internal func recordDeviceToken(_ data: Data) { + let hex = data.map { String(format: "%02x", $0) }.joined() + pushTokenStatus = .registered(hexToken: hex) + } + + /// Forward the APNs registration error captured by the platform app delegate. + /// Surfaces the underlying NSError domain + code alongside the localized + /// message — the default `localizedDescription` for sandboxed-app / + /// signing failures collapses to "the operation couldn't be completed. + /// (OSStatus error N)" which by itself doesn't say what failed. + internal func recordDeviceTokenError(_ error: any Error) { + let nsError = error as NSError + let summary = + "\(error.localizedDescription)\n" + + "[\(nsError.domain) code \(nsError.code)]" + pushTokenStatus = .failed(message: summary) + } + } + + extension CloudKitStore: PushTokenReceiver { + /// Records the APNs device token forwarded by the platform app delegate. + public func didRegisterForRemoteNotifications(deviceToken: Data) { + recordDeviceToken(deviceToken) + } + + /// Records the APNs registration failure forwarded by the platform app + /// delegate. + public func didFailToRegisterForRemoteNotifications(error: any Error) { + recordDeviceTokenError(error) + } + + /// Stores a description of the most recent remote notification payload. + public func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + lastReceivedNotification = String(describing: userInfo) + } + } + +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Records.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Records.swift new file mode 100644 index 00000000..15a01eeb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Records.swift @@ -0,0 +1,130 @@ +// +// CloudKitStore+Records.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import CloudKit + internal import Foundation + internal import MistDemoKit + + extension CloudKitStore { + /// Look up records by name. Maps to `records/lookup` in the REST + /// surface; uses `database.record(for:)` per ID. + internal func lookupRecords(names: [String]) async throws -> [Note] { + var notes: [Note] = [] + for name in names { + let recordID = CKRecord.ID(recordName: name) + let record = try await database.record(for: recordID) + if let note = Note(record) { + notes.append(note) + } + } + return notes + } + + /// Fetch record-level deltas for the given zone since `previousToken`. + /// Returns the changed and deleted record names plus the new sync + /// token. Pass that token back on the next call for an incremental + /// fetch. Maps to `records/changes` in the REST surface. + internal func fetchRecordZoneChanges( + zoneID: CKRecordZone.ID, + since previousToken: CKServerChangeToken? = nil + ) async throws -> RecordZoneChangesSnapshot { + try await withCheckedThrowingContinuation { continuation in + let configuration = + CKFetchRecordZoneChangesOperation + .ZoneConfiguration(previousServerChangeToken: previousToken) + let operation = CKFetchRecordZoneChangesOperation( + recordZoneIDs: [zoneID], + configurationsByRecordZoneID: [zoneID: configuration] + ) + + var changed: [String] = [] + var deleted: [String] = [] + var resolvedToken: CKServerChangeToken? + var moreComing = false + + operation.recordWasChangedBlock = { recordID, _ in + changed.append(recordID.recordName) + } + operation.recordWithIDWasDeletedBlock = { recordID, _ in + deleted.append(recordID.recordName) + } + operation.recordZoneFetchResultBlock = { _, result in + if case .success(let payload) = result { + resolvedToken = payload.serverChangeToken + moreComing = payload.moreComing + } + } + operation.fetchRecordZoneChangesResultBlock = { result in + switch result { + case .failure(let error): + continuation.resume(throwing: error) + case .success: + continuation.resume( + returning: RecordZoneChangesSnapshot( + changedRecordNames: changed, + deletedRecordNames: deleted, + serverChangeToken: resolvedToken, + moreComing: moreComing + ) + ) + } + } + + database.add(operation) + } + } + + /// Resolve a record reference. Accepts either a CloudKit record name + /// (routed through `database.record(for:)`) or a share URL (routed + /// through `CKContainer.share(metadataFor:)`). Maps to `records/resolve` + /// in the REST surface as a composed call. + internal func resolveReference(input: String) async throws -> ResolveResult { + if let url = URL(string: input), url.scheme?.hasPrefix("http") == true { + let container = CKContainer(identifier: containerIdentifier) + let metadata = try await container.shareMetadata(for: url) + return ResolveResult( + source: .shareURL, + recordName: metadata.rootRecordID.recordName, + recordType: metadata.rootRecord?.recordType, + shareTitle: metadata.share[CKShare.SystemFieldKey.title] as? String + ) + } + let record = try await database.record( + for: CKRecord.ID(recordName: input) + ) + return ResolveResult( + source: .recordName, + recordName: record.recordID.recordName, + recordType: record.recordType, + shareTitle: nil + ) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Subscriptions.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Subscriptions.swift new file mode 100644 index 00000000..6c74dcd3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Subscriptions.swift @@ -0,0 +1,115 @@ +// +// CloudKitStore+Subscriptions.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import CloudKit + internal import Foundation + internal import MistDemoKit + + /// Display-friendly snapshot of a CKSubscription. + internal struct SubscriptionRow: Identifiable, Hashable, Sendable { + internal let id: String + internal let kind: String + internal let recordType: String? + + internal init(_ subscription: CKSubscription) { + self.id = subscription.subscriptionID + switch subscription { + case let query as CKQuerySubscription: + self.kind = "Query" + self.recordType = query.recordType + case let zone as CKRecordZoneSubscription: + self.kind = "Zone (\(zone.zoneID.zoneName))" + self.recordType = nil + case is CKDatabaseSubscription: + self.kind = "Database" + self.recordType = nil + default: + self.kind = "Other" + self.recordType = nil + } + } + } + + extension CloudKitStore { + /// List every CloudKit subscription registered on the selected + /// database. Maps to `subscriptions/list` in the REST surface. + internal func loadSubscriptions() async throws -> [SubscriptionRow] { + let subscriptions = try await database.allSubscriptions() + return subscriptions.map(SubscriptionRow.init).sorted { $0.id < $1.id } + } + + /// Look up specific subscriptions by ID. Maps to `subscriptions/lookup`. + internal func lookupSubscriptions( + ids: [String] + ) async throws -> [SubscriptionRow] { + try await withCheckedThrowingContinuation { continuation in + let operation = CKFetchSubscriptionsOperation(subscriptionIDs: ids) + var rows: [SubscriptionRow] = [] + operation.perSubscriptionResultBlock = { _, result in + if case .success(let subscription) = result { + rows.append(SubscriptionRow(subscription)) + } + } + operation.fetchSubscriptionsResultBlock = { result in + switch result { + case .success: + continuation.resume(returning: rows.sorted { $0.id < $1.id }) + case .failure(let error): + continuation.resume(throwing: error) + } + } + database.add(operation) + } + } + + /// Create a demo Note-query subscription so the subscriptions list has + /// something visible. Uses a fixed `subscriptionID` so repeated taps are + /// idempotent — CloudKit returns a conflict if it already exists, which + /// the UI surfaces via the standard error path. + internal func createDemoSubscription() async throws -> SubscriptionRow { + let subscription = CKQuerySubscription( + recordType: Note.recordType, + predicate: NSPredicate(value: true), + subscriptionID: "MistDemo.noteCreated", + options: [.firesOnRecordCreation] + ) + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + subscription.notificationInfo = info + let saved = try await database.save(subscription) + return SubscriptionRow(saved) + } + + /// Delete a subscription by ID. + internal func deleteSubscription(id: String) async throws { + _ = try await database.deleteSubscription(withID: id) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Users.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Users.swift new file mode 100644 index 00000000..7f3145f1 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Users.swift @@ -0,0 +1,123 @@ +// +// CloudKitStore+Users.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import CloudKit + internal import Foundation + internal import MistDemoKit + + /// Display-friendly snapshot of a CKUserIdentity. + internal struct UserIdentityRow: Identifiable, Hashable, Sendable { + internal let id: String + internal let displayName: String? + internal let recordName: String? + internal let lookupHint: String? + + internal init(_ identity: CKUserIdentity, lookupHint: String? = nil) { + let recordName = identity.userRecordID?.recordName + self.id = + recordName + ?? identity.lookupInfo?.emailAddress + ?? identity.lookupInfo?.phoneNumber + ?? lookupHint + ?? UUID().uuidString + self.recordName = recordName + self.displayName = Self.formatName(identity.nameComponents) + self.lookupHint = lookupHint + } + + private static func formatName( + _ components: PersonNameComponents? + ) -> String? { + guard let components else { + return nil + } + let formatter = PersonNameComponentsFormatter() + let formatted = formatter.string(from: components) + return formatted.isEmpty ? nil : formatted + } + } + + extension CloudKitStore { + /// Look up a user identity by iCloud email address. Maps to + /// `users/lookup/email` in the REST surface; uses CloudKit's + /// `discoverUserIdentity(withEmailAddress:)`. + /// + /// `discoverUserIdentity(withEmailAddress:)` is deprecated as of macOS + /// 14 / iOS 17 but still ships on the supported platforms — the + /// CloudKit framework hasn't published an async replacement, so the + /// completion-handler form is wrapped via `withCheckedThrowingContinuation`. + internal func lookupUser(byEmail email: String) async throws -> UserIdentityRow? { + let container = CKContainer(identifier: containerIdentifier) + let identity: CKUserIdentity? = try await withCheckedThrowingContinuation { + continuation in + container.discoverUserIdentity(withEmailAddress: email) { + identity, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: identity) + } + } + } + return identity.map { UserIdentityRow($0, lookupHint: email) } + } + + /// Look up a user identity by record name. Maps to `users/lookup/id`. + internal func lookupUser(byRecordName recordName: String) async throws -> UserIdentityRow? { + let container = CKContainer(identifier: containerIdentifier) + let recordID = CKRecord.ID(recordName: recordName) + let identity: CKUserIdentity? = try await withCheckedThrowingContinuation { + continuation in + container.discoverUserIdentity(withUserRecordID: recordID) { + identity, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: identity) + } + } + } + return identity.map { UserIdentityRow($0, lookupHint: recordName) } + } + + /// Discover identities for a batch of email addresses, looping the + /// per-call API since the framework doesn't expose a batch entry point. + /// Maps to `users/discover` (POST) in the REST surface. + internal func discoverUsers(byEmails emails: [String]) async throws -> [UserIdentityRow] { + var rows: [UserIdentityRow] = [] + for email in emails { + if let row = try await lookupUser(byEmail: email) { + rows.append(row) + } + } + return rows + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Zones.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Zones.swift new file mode 100644 index 00000000..31a485ec --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore+Zones.swift @@ -0,0 +1,99 @@ +// +// CloudKitStore+Zones.swift +// MistDemo +// +// 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. +// + +#if canImport(CloudKit) + internal import CloudKit + internal import Foundation + internal import MistDemoKit + + /// Snapshot returned by `CKFetchDatabaseChangesOperation` so the UI can + /// show what changed since the last sync token. + internal struct DatabaseChangesSnapshot: Sendable { + internal let changedZoneIDs: [CKRecordZone.ID] + internal let deletedZoneIDs: [CKRecordZone.ID] + internal let serverChangeToken: CKServerChangeToken? + internal let moreComing: Bool + } + + extension CloudKitStore { + /// Create a new custom record zone in the selected database. Public + /// databases reject this — `CKModifyRecordZonesOperation` returns an + /// error which we surface to the UI. + internal func createZone(named name: String) async throws -> ZoneRow { + let zone = CKRecordZone(zoneName: name) + let saved = try await database.save(zone) + return ZoneRow(saved) + } + + /// Delete a custom record zone by name in the selected database. + internal func deleteZone(named name: String) async throws { + _ = try await database.deleteRecordZone( + withID: CKRecordZone.ID( + zoneName: name, ownerName: CKCurrentUserDefaultName + ) + ) + } + + /// Fetch database-scope changes since `previousToken`. Returns the + /// changed/deleted zone IDs plus the new sync token. Pass the returned + /// token on the next call to receive a delta. + internal func fetchDatabaseChanges( + since previousToken: CKServerChangeToken? = nil + ) async throws -> DatabaseChangesSnapshot { + try await withCheckedThrowingContinuation { continuation in + let operation = CKFetchDatabaseChangesOperation( + previousServerChangeToken: previousToken + ) + + var changed: [CKRecordZone.ID] = [] + var deleted: [CKRecordZone.ID] = [] + + operation.recordZoneWithIDWasDeletedBlock = { deleted.append($0) } + operation.recordZoneWithIDChangedBlock = { changed.append($0) } + operation.fetchDatabaseChangesResultBlock = { result in + switch result { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let payload): + continuation.resume( + returning: DatabaseChangesSnapshot( + changedZoneIDs: changed, + deletedZoneIDs: deleted, + serverChangeToken: payload.serverChangeToken, + moreComing: payload.moreComing + ) + ) + } + } + + database.add(operation) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift index 81c8e926..0f450563 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStore.swift @@ -28,9 +28,9 @@ // #if canImport(CloudKit) - import CloudKit - import Foundation - import MistDemoKit + internal import CloudKit + internal import Foundation + internal import MistDemoKit public import Observation /// Observable source of truth for the MistDemo app's CloudKit state. @@ -48,6 +48,15 @@ internal var lastError: String? internal var databaseScope: CKDatabase.Scope = .private + /// Latest APNs registration state. Driven by + /// `CloudKitStore+PushTokens.swift`; the SwiftUI Push Tokens view + /// observes this for live status updates. + internal var pushTokenStatus: PushTokenStatus = .idle + + /// Pretty-printed payload of the most recent remote notification + /// delivered while the app was running. Cleared on launch. + internal var lastReceivedNotification: String? + /// The signed-in iCloud user's record name. Mirrors `currentUserRecordName` /// in the web demo and is used to flag the "You" badge on notes the /// current user created. @@ -64,6 +73,10 @@ public init(containerIdentifier: String) { self.containerIdentifier = containerIdentifier self.container = CKContainer(identifier: containerIdentifier) + + // The platform AppDelegate is owned by SwiftUI; weak-link this store + // as the sink for its APNs callbacks. + PushNotificationDelegate.receiver = self } /// Apply the editable fields onto a CKRecord. CloudKit's system metadata diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift index 4826fb52..373bada2 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/CloudKitStoreError.swift @@ -28,8 +28,8 @@ // #if canImport(CloudKit) - import Foundation - import MistDemoKit + internal import Foundation + internal import MistDemoKit /// Errors specific to `CloudKitStore` operations. internal enum CloudKitStoreError: Error, LocalizedError { diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformAliases.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformAliases.swift new file mode 100644 index 00000000..7f6de62a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformAliases.swift @@ -0,0 +1,77 @@ +// +// PlatformAliases.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) + public import SwiftUI + + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + public import AppKit + + /// The platform application type (`NSApplication` on AppKit, + /// `UIApplication` on UIKit, `WKApplication` on watchOS). Lets the + /// push-notification delegate and registration call sites name one type + /// instead of branching. + public typealias PlatformApplication = NSApplication + + /// The platform application delegate protocol matching + /// ``PlatformApplication``. + public typealias ApplicationDelegate = NSApplicationDelegate + + /// SwiftUI's delegate-adaptor property wrapper matching + /// ``ApplicationDelegate``. Lets `@main` declare the adaptor once + /// rather than under parallel `#if` branches. + public typealias PlatformApplicationDelegateAdaptor = NSApplicationDelegateAdaptor + #elseif canImport(WatchKit) + public import WatchKit + + /// The platform application type. See the AppKit branch for full notes. + public typealias PlatformApplication = WKApplication + + /// The platform application delegate protocol matching + /// ``PlatformApplication``. + public typealias ApplicationDelegate = WKApplicationDelegate + + /// SwiftUI's delegate-adaptor property wrapper matching + /// ``ApplicationDelegate``. + public typealias PlatformApplicationDelegateAdaptor = WKApplicationDelegateAdaptor + #elseif canImport(UIKit) + public import UIKit + + /// The platform application type. See the AppKit branch for full notes. + public typealias PlatformApplication = UIApplication + + /// The platform application delegate protocol matching + /// ``PlatformApplication``. + public typealias ApplicationDelegate = UIApplicationDelegate + + /// SwiftUI's delegate-adaptor property wrapper matching + /// ``ApplicationDelegate``. + public typealias PlatformApplicationDelegateAdaptor = UIApplicationDelegateAdaptor + #endif +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+NSApplicationDelegate.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+NSApplicationDelegate.swift new file mode 100644 index 00000000..be53dd09 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+NSApplicationDelegate.swift @@ -0,0 +1,48 @@ +// +// PlatformApplicationDelegate+NSApplicationDelegate.swift +// MistDemo +// +// 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. +// + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + public import AppKit + internal import Foundation + + extension PlatformApplicationDelegate where Self: NSApplicationDelegate { + /// A remote notification arrived (AppKit variant). + /// + /// Cannot be `@objc` because Swift forbids `@objc` on protocol extension + /// members; AppKit's optional `application(_:didReceiveRemoteNotification:)` + /// selector is bridged by a concrete `@objc` shim on + /// ``PushNotificationDelegate`` that delegates here. + public func application( + _: NSApplication, + didReceiveRemoteNotification userInfo: [String: Any] + ) { + Self.receiver?.didReceiveRemoteNotification(userInfo: userInfo) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+RemoteNotifications.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+RemoteNotifications.swift new file mode 100644 index 00000000..6dad0c6a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+RemoteNotifications.swift @@ -0,0 +1,56 @@ +// +// PlatformApplicationDelegate+RemoteNotifications.swift +// MistDemo +// +// 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. +// + +#if (canImport(AppKit) && !targetEnvironment(macCatalyst)) || (canImport(UIKit) && !os(watchOS)) + public import Foundation + + extension PlatformApplicationDelegate { + /// APNs delivered a device token — forward it to the registered receiver. + /// + /// Cannot be `@objc` (Swift forbids `@objc` on protocol extension + /// members); the optional system-delegate selector is bridged by a + /// concrete `@objc` shim on ``PushNotificationDelegate`` that + /// delegates here. + public func application( + _: PlatformApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Self.receiver?.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + /// APNs refused registration — forward the error to the receiver so it + /// can surface in the UI. + public func application( + _: PlatformApplication, + didFailToRegisterForRemoteNotificationsWithError error: any Error + ) { + Self.receiver?.didFailToRegisterForRemoteNotifications(error: error) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+UIApplicationDelegate.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+UIApplicationDelegate.swift new file mode 100644 index 00000000..d1fe3a69 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+UIApplicationDelegate.swift @@ -0,0 +1,45 @@ +// +// PlatformApplicationDelegate+UIApplicationDelegate.swift +// MistDemo +// +// 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. +// + +#if canImport(UIKit) && !os(watchOS) + internal import Foundation + public import UIKit + + extension PlatformApplicationDelegate where Self: UIApplicationDelegate { + /// A remote notification arrived (UIKit variant). + public func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + Self.receiver?.didReceiveRemoteNotification(userInfo: userInfo) + completionHandler(.newData) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+WKApplicationDelegate.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+WKApplicationDelegate.swift new file mode 100644 index 00000000..10fd5c30 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate+WKApplicationDelegate.swift @@ -0,0 +1,54 @@ +// +// PlatformApplicationDelegate+WKApplicationDelegate.swift +// MistDemo +// +// 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. +// + +#if canImport(WatchKit) + internal import Foundation + public import WatchKit + + extension PlatformApplicationDelegate where Self: WKApplicationDelegate { + /// APNs delivered a device token — forward it to the registered receiver. + public func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { + Self.receiver?.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + /// APNs refused registration — forward the error to the receiver. + public func didFailToRegisterForRemoteNotificationsWithError(_ error: any Error) { + Self.receiver?.didFailToRegisterForRemoteNotifications(error: error) + } + + /// A remote notification arrived (watchOS variant). + public func didReceiveRemoteNotification( + _ userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (WKBackgroundFetchResult) -> Void + ) { + Self.receiver?.didReceiveRemoteNotification(userInfo: userInfo) + completionHandler(.newData) + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate.swift new file mode 100644 index 00000000..eea08a35 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PlatformApplicationDelegate.swift @@ -0,0 +1,45 @@ +// +// PlatformApplicationDelegate.swift +// MistDemo +// +// 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. +// + +#if canImport(ObjectiveC) + // `@objc` requires the Objective-C runtime, surfaced here via Foundation. + internal import Foundation + + /// App-level delegate behavior that funnels APNs callbacks to a + /// ``PushTokenReceiver``. Deliberately independent of the system delegate + /// protocol (``ApplicationDelegate``) and of the concrete + /// ``PushNotificationDelegate`` class — it only knows about the receiver. The + /// shared callbacks are supplied by the extensions below (and the + /// per-platform extension files), keyed off the static `receiver`. + @MainActor + @objc + public protocol PlatformApplicationDelegate: AnyObject { + static var receiver: (any PushTokenReceiver)? { get } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PushNotificationDelegate.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PushNotificationDelegate.swift new file mode 100644 index 00000000..6ddc4ca6 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PushNotificationDelegate.swift @@ -0,0 +1,155 @@ +// +// PushNotificationDelegate.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) + public import Foundation + + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + public import AppKit + #elseif canImport(WatchKit) + public import WatchKit + #elseif canImport(UIKit) && !os(watchOS) + public import UIKit + #endif + + /// Universal AppKit/UIKit/watchOS delegate that catches APNs callbacks + /// SwiftUI can't observe directly. SwiftUI's `@NSApplicationDelegateAdaptor` + /// / `@UIApplicationDelegateAdaptor` / `@WKApplicationDelegateAdaptor` + /// instantiates this with the parameterless `NSObject` init, so the receiver + /// is wired via a `static weak` set by `CloudKitStore.init` rather than + /// passed in. + /// + /// The APNs callbacks themselves are supplied by + /// ``PlatformApplicationDelegate`` and its per-platform extensions; + /// conforming to both that protocol and the system ``ApplicationDelegate`` + /// (required by the SwiftUI adaptor) is all this class needs to declare. + @MainActor + public final class PushNotificationDelegate: + NSObject, ApplicationDelegate, PlatformApplicationDelegate + { + /// The object the platform delegate forwards APNs callbacks to. Set by + /// `CloudKitStore.init`; held weakly so the store's lifetime governs it. + public static weak var receiver: (any PushTokenReceiver)? + + /// Required by SwiftUI's delegate adaptor, which constructs the + /// delegate with no arguments at app launch. + override public init() { + super.init() + } + + // MARK: - Obj-C bridge for system-delegate optional selectors + // + // Swift forbids `@objc` on protocol extension members, so the + // ``PlatformApplicationDelegate`` defaults can't directly satisfy the + // optional `@objc` requirements on the system delegate protocols. These + // concrete shims expose the selectors to the Obj-C runtime so AppKit / + // UIKit / WatchKit dispatch lands on this class, and forward to the + // same `static weak receiver` the protocol extensions use. + + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + /// APNs delivered a device token (AppKit variant). + @objc + public func application( + _ application: NSApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Self.receiver?.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + /// APNs refused registration (AppKit variant). + @objc + public func application( + _ application: NSApplication, + didFailToRegisterForRemoteNotificationsWithError error: any Error + ) { + Self.receiver?.didFailToRegisterForRemoteNotifications(error: error) + } + + /// A remote notification arrived (AppKit variant). + @objc + public func application( + _ application: NSApplication, + didReceiveRemoteNotification userInfo: [String: Any] + ) { + Self.receiver?.didReceiveRemoteNotification(userInfo: userInfo) + } + #elseif canImport(UIKit) && !os(watchOS) + /// APNs delivered a device token (UIKit variant). + @objc + public func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Self.receiver?.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + /// APNs refused registration (UIKit variant). + @objc + public func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: any Error + ) { + Self.receiver?.didFailToRegisterForRemoteNotifications(error: error) + } + + /// A remote notification arrived (UIKit variant). + @objc + public func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + Self.receiver?.didReceiveRemoteNotification(userInfo: userInfo) + completionHandler(.newData) + } + #elseif canImport(WatchKit) + /// APNs delivered a device token (watchOS variant). + @objc + public func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) { + Self.receiver?.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + /// APNs refused registration (watchOS variant). + @objc + public func didFailToRegisterForRemoteNotificationsWithError(_ error: any Error) { + Self.receiver?.didFailToRegisterForRemoteNotifications(error: error) + } + + /// A remote notification arrived (watchOS variant). + @objc + public func didReceiveRemoteNotification( + _ userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (WKBackgroundFetchResult) -> Void + ) { + Self.receiver?.didReceiveRemoteNotification(userInfo: userInfo) + completionHandler(.newData) + } + #endif + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/PushTokenReceiver.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/PushTokenReceiver.swift new file mode 100644 index 00000000..6cfbc403 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/PushTokenReceiver.swift @@ -0,0 +1,44 @@ +// +// PushTokenReceiver.swift +// MistDemo +// +// 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. +// + +#if canImport(ObjectiveC) + public import Foundation + + /// Receiver side of the platform push-notification bridge. The + /// `PushNotificationDelegate` (AppKit / UIKit) forwards OS callbacks + /// to whatever object is currently registered as + /// `PushNotificationDelegate.receiver`. + @MainActor + @objc + public protocol PushTokenReceiver: AnyObject { + func didRegisterForRemoteNotifications(deviceToken: Data) + func didFailToRegisterForRemoteNotifications(error: any Error) + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Services/RemoteNotificationRegistering.swift b/Examples/MistDemo/Sources/MistDemoApp/Services/RemoteNotificationRegistering.swift new file mode 100644 index 00000000..d3ee04fc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Services/RemoteNotificationRegistering.swift @@ -0,0 +1,65 @@ +// +// RemoteNotificationRegistering.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) + public import SwiftUI + + /// Unifies the per-platform "register for remote notifications" entry point + /// so push code can call it through ``PlatformApplication`` without + /// branching. The accessor is named `sharedApplication` rather than `shared` + /// because `WKApplication.shared()` is a method, not a property — reusing the + /// `shared` name would collide with it. + @MainActor + internal protocol RemoteNotificationRegistering { + static var sharedApplication: PlatformApplication { get } + func registerForRemoteNotifications() + } + + extension RemoteNotificationRegistering { + /// Registers the shared platform application for remote notifications — + /// the single cross-platform entry point push code calls. + internal static func registerSharedForRemoteNotifications() { + sharedApplication.registerForRemoteNotifications() + } + } + + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + extension NSApplication: RemoteNotificationRegistering { + internal static var sharedApplication: NSApplication { shared } + } + #elseif canImport(WatchKit) + extension WKApplication: RemoteNotificationRegistering { + internal static var sharedApplication: WKApplication { shared() } + } + #elseif canImport(UIKit) + extension UIApplication: RemoteNotificationRegistering { + internal static var sharedApplication: UIApplication { shared } + } + #endif +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift index b228da76..f8c1040f 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView+Actions.swift @@ -28,13 +28,13 @@ // #if canImport(SwiftUI) && canImport(CloudKit) - import CloudKit - import SwiftUI + internal import CloudKit + internal import SwiftUI #if canImport(AppKit) - import AppKit + internal import AppKit #elseif canImport(UIKit) - import UIKit + internal import UIKit #endif extension AccountView { diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift index 8892fa41..5eff98c4 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AccountView.swift @@ -28,13 +28,13 @@ // #if canImport(SwiftUI) && canImport(CloudKit) - import CloudKit - import SwiftUI + internal import CloudKit + internal import SwiftUI #if canImport(AppKit) - import AppKit + internal import AppKit #elseif canImport(UIKit) - import UIKit + internal import UIKit #endif /// View showing the iCloud account status, the public/private database diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/AssetsView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/AssetsView.swift new file mode 100644 index 00000000..85b45d40 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/AssetsView.swift @@ -0,0 +1,158 @@ +// +// AssetsView.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + internal import MistDemoKit + internal import SwiftUI + + /// View driving `assets/upload` and the composed `assets/rereference` + /// against native CloudKit. Upload is a one-step `database.save(_:)` + /// with a `CKAsset` payload; rereference fetches the source record, + /// reuses its asset descriptor, and saves the target — surfaced via + /// `CompositionDisclosure`. + internal struct AssetsView: View { + @Environment(CloudKitStore.self) private var service + @State private var uploadTitle: String = "Asset demo" + @State private var uploadIndex: Int64 = 0 + @State private var uploadFilePath: String = "" + @State private var uploadResult: Note? + @State private var uploadError: String? + @State private var sourceRecord: String = "" + @State private var assetField: String = "image" + @State private var targetRecord: String = "" + @State private var targetAssetField: String = "" + @State private var rereferenceResult: RereferenceResult? + @State private var rereferenceError: String? + + internal var body: some View { + Form { + uploadSection + rereferenceSection + } + .formStyle(.grouped) + .navigationTitle("Assets") + } + + private var uploadSection: some View { + Section { + TextField("Note title", text: $uploadTitle) + TextField( + "Sort index", value: $uploadIndex, format: .number + ) + TextField("Local file path", text: $uploadFilePath) + .font(.body.monospaced()) + Button("Upload") { Task { await runUpload() } } + .disabled(uploadTitle.isEmpty || uploadFilePath.isEmpty) + if let uploadError { + Text(uploadError).font(.callout).foregroundStyle(.red) + } + if let uploadResult { + LabeledContent("Created", value: uploadResult.id) + } + } header: { + Text("Upload — assets/upload") + } footer: { + Text( + "Creates a Note with the file at the given path as its " + + "`image` asset. Native CKAsset upload runs inline as part of " + + "database.save(_:)." + ) + .font(.caption) + } + } + + private var rereferenceSection: some View { + Section { + TextField("Source record name", text: $sourceRecord) + .font(.body.monospaced()) + TextField("Asset field name", text: $assetField) + TextField("Target record name", text: $targetRecord) + .font(.body.monospaced()) + TextField( + "Target asset field (optional)", + text: $targetAssetField + ) + Button("Rereference") { Task { await runRereference() } } + .disabled( + sourceRecord.isEmpty || targetRecord.isEmpty + || assetField.isEmpty + ) + if let rereferenceError { + Text(rereferenceError).font(.callout).foregroundStyle(.red) + } + if let result = rereferenceResult { + LabeledContent("Source", value: result.sourceRecordName) + LabeledContent("Target", value: result.targetRecordName) + LabeledContent("Field", value: result.targetAssetField) + } + CompositionDisclosure( + restEndpoint: "assets/rereference", + steps: [ + "database.record(for: source) — fetch source record", + "Read source[assetField] as CKAsset", + "database.record(for: target) — fetch target record", + "target[targetAssetField] = asset; database.save(target)", + ] + ) + } header: { + Text("Rereference — assets/rereference (composed)") + } + } + + private func runUpload() async { + uploadError = nil + uploadResult = nil + let url = URL(fileURLWithPath: uploadFilePath) + do { + uploadResult = try await service.uploadAssetNote( + title: uploadTitle, + index: uploadIndex, + fileURL: url + ) + } catch { + uploadError = error.localizedDescription + } + } + + private func runRereference() async { + rereferenceError = nil + rereferenceResult = nil + do { + rereferenceResult = try await service.rereferenceAsset( + sourceRecordName: sourceRecord, + assetField: assetField, + targetRecordName: targetRecord, + targetAssetField: targetAssetField.isEmpty ? nil : targetAssetField + ) + } catch { + rereferenceError = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/CompositionDisclosure.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/CompositionDisclosure.swift new file mode 100644 index 00000000..c052e5c3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/CompositionDisclosure.swift @@ -0,0 +1,80 @@ +// +// CompositionDisclosure.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) + internal import SwiftUI + + /// Disclosure group documenting the underlying CloudKit operations that + /// compose a single REST endpoint. Used by `RecordsView` (`records/resolve`) + /// and `AssetsView` (`assets/rereference`) so users learning the SDK can + /// see where native CloudKit's API shape diverges from the REST surface. + internal struct CompositionDisclosure: View { + internal let restEndpoint: String + internal let steps: [String] + + internal var body: some View { + // `DisclosureGroup` is unavailable on watchOS and tvOS, so there the + // composition detail is shown inline (non-collapsible) under a plain + // header. + #if os(watchOS) || os(tvOS) + VStack(alignment: .leading, spacing: 6) { + Text("📎 Composition").font(.subheadline.bold()) + compositionDetail + } + #else + DisclosureGroup("📎 Composition") { + compositionDetail + } + .font(.subheadline) + #endif + } + + private var compositionDetail: some View { + VStack(alignment: .leading, spacing: 6) { + Text("REST endpoint: ").font(.caption.bold()) + + Text(restEndpoint).font(.caption.monospaced()) + Text("Underlying CloudKit operations:") + .font(.caption.bold()) + ForEach(Array(steps.enumerated()), id: \.offset) { index, step in + HStack(alignment: .top, spacing: 6) { + Text("\(index + 1).").font(.caption) + Text(step).font(.caption.monospaced()) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + + internal init(restEndpoint: String, steps: [String]) { + self.restEndpoint = restEndpoint + self.steps = steps + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/DetailColumnRoot.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/DetailColumnRoot.swift index dbea999a..448fe89b 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/DetailColumnRoot.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/DetailColumnRoot.swift @@ -28,7 +28,7 @@ // #if canImport(SwiftUI) - import SwiftUI + internal import SwiftUI /// Routes the sidebar selection to the appropriate detail view. internal struct DetailColumnRoot: View { @@ -42,11 +42,21 @@ ZoneListView() case .query: QueryView() + case .records: + RecordsView() + case .subscriptions: + SubscriptionsView() + case .pushTokens: + PushTokensView() + case .assets: + AssetsView() + case .users: + UsersView() case nil: ContentUnavailableView( "Pick a section from the sidebar", systemImage: "sidebar.left", - description: Text("Account, Zones, or Query Records") + description: Text("Account, Zones, Records, Subscriptions, …") ) } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift index 19e74e1b..43d059df 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/NoteEditView.swift @@ -28,9 +28,9 @@ // #if canImport(SwiftUI) && canImport(CloudKit) - import MistDemoKit - import SwiftUI - import UniformTypeIdentifiers + internal import MistDemoKit + internal import SwiftUI + internal import UniformTypeIdentifiers /// Sheet form for creating or editing a Note. The same view backs both flows; /// the `mode` value drives the title and which service method is called on save. diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/PushTokensView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/PushTokensView.swift new file mode 100644 index 00000000..0fe57f3f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/PushTokensView.swift @@ -0,0 +1,109 @@ +// +// PushTokensView.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + internal import MistDemoKit + internal import SwiftUI + + /// Surfaces the device's APNs token for cross-reference with the REST + /// `tokens/create` / `tokens/register` workflows. The native demo uses + /// the CloudKit framework, so the token here is informational only: + /// iCloud routes pushes to the signed-in user's devices without anyone + /// passing the device token to CloudKit. See the Subscriptions view for + /// the actual server-side notification setup. + internal struct PushTokensView: View { + @Environment(CloudKitStore.self) private var service + + internal var body: some View { + Form { + Section { + tokenStatus + Button("Register for Remote Notifications") { + service.requestPushNotificationRegistration() + } + } header: { + Text("APNs device token") + } footer: { + Text( + "Real push delivery requires a signed build with the push " + + "entitlement and a paid developer account. Simulators and " + + "unentitled builds surface the registration error path " + + "below instead of a token. The native CloudKit framework " + + "binds this token to the signed-in iCloud account " + + "automatically — to actually receive a push, create a " + + "CKSubscription from the Subscriptions tab. The MistKit " + + "REST tokens/create + tokens/register endpoints exist for " + + "server-side scenarios where there's no iCloud user " + + "context to route through." + ) + .font(.caption) + } + if let payload = service.lastReceivedNotification { + Section { + Text(payload) + .font(.callout.monospaced()) + #if !os(tvOS) && !os(watchOS) + .textSelection(.enabled) + #endif + } header: { + Text("Last received remote notification") + } + } + } + .formStyle(.grouped) + .navigationTitle("Push Tokens") + } + + @ViewBuilder + private var tokenStatus: some View { + switch service.pushTokenStatus { + case .idle: + LabeledContent("APNs Token", value: "Not requested yet") + case .requesting: + HStack { + ProgressView().controlSize(.small) + Text("Requesting…") + } + case .registered(let hex): + LabeledContent("APNs Token") { + Text(hex) + .font(.callout.monospaced()) + .lineLimit(3) + .truncationMode(.middle) + #if !os(tvOS) && !os(watchOS) + .textSelection(.enabled) + #endif + } + case .failed(let message): + LabeledContent("APNs Token", value: "Failed") + Text(message).font(.caption).foregroundStyle(.red) + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift index e5bdca8a..aafa886e 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/QueryView.swift @@ -28,8 +28,8 @@ // #if canImport(SwiftUI) && canImport(CloudKit) - import MistDemoKit - import SwiftUI + internal import MistDemoKit + internal import SwiftUI /// View for querying Note records from CloudKit. internal struct QueryView: View { diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift index e725b50e..856ab594 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordDetailView.swift @@ -28,8 +28,8 @@ // #if canImport(SwiftUI) && canImport(CloudKit) - import MistDemoKit - import SwiftUI + internal import MistDemoKit + internal import SwiftUI /// Detail view showing all fields and metadata for a single Note record. internal struct RecordDetailView: View { diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/RecordsView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordsView.swift new file mode 100644 index 00000000..d55bd707 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/RecordsView.swift @@ -0,0 +1,189 @@ +// +// RecordsView.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + internal import CloudKit + internal import MistDemoKit + internal import SwiftUI + + /// View driving `records/lookup`, `records/changes`, and the composed + /// `records/resolve` against native CloudKit. Lookup and resolve accept + /// the same input shape (a record name) but resolve also accepts a + /// share URL; the underlying composition is documented inline via + /// `CompositionDisclosure`. + internal struct RecordsView: View { + @Environment(CloudKitStore.self) private var service + @State private var lookupInput: String = "" + @State private var lookupResults: [Note] = [] + @State private var lookupError: String? + @State private var resolveInput: String = "" + @State private var resolveResult: ResolveResult? + @State private var resolveError: String? + @State private var changesSnapshot: RecordZoneChangesSnapshot? + @State private var changesError: String? + @State private var changesToken: CKServerChangeToken? + @State private var loading = false + + internal var body: some View { + Form { + lookupSection + changesSection + resolveSection + } + .formStyle(.grouped) + .navigationTitle("Records") + } + + private var lookupSection: some View { + Section { + TextField("Record name", text: $lookupInput) + .font(.body.monospaced()) + Button("Lookup") { Task { await runLookup() } } + .disabled(lookupInput.isEmpty || loading) + if let lookupError { + Text(lookupError).font(.callout).foregroundStyle(.red) + } + ForEach(lookupResults) { note in + VStack(alignment: .leading, spacing: 2) { + Text(note.title ?? "(untitled)").font(.headline) + Text(note.id).font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } header: { + Text("Lookup — records/lookup") + } footer: { + Text("CKFetchRecordsOperation / database.record(for:)") + .font(.caption) + } + } + + private var changesSection: some View { + Section { + Button("Fetch changes (_defaultZone)") { + Task { await runChanges() } + } + .disabled(loading) + if let changesError { + Text(changesError).font(.callout).foregroundStyle(.red) + } + if let snapshot = changesSnapshot { + LabeledContent("Changed", value: "\(snapshot.changedRecordNames.count)") + LabeledContent("Deleted", value: "\(snapshot.deletedRecordNames.count)") + LabeledContent("More coming", value: snapshot.moreComing ? "Yes" : "No") + } + } header: { + Text("Changes — records/changes") + } footer: { + Text("CKFetchRecordZoneChangesOperation. Repeat calls return deltas.") + .font(.caption) + } + } + + private var resolveSection: some View { + Section { + TextField( + "Record name or share URL", + text: $resolveInput + ) + .font(.body.monospaced()) + Button("Resolve") { Task { await runResolve() } } + .disabled(resolveInput.isEmpty || loading) + if let resolveError { + Text(resolveError).font(.callout).foregroundStyle(.red) + } + if let result = resolveResult { + LabeledContent("Branch", value: result.source.rawValue) + if let name = result.recordName { + LabeledContent("Record", value: name) + } + if let type = result.recordType { + LabeledContent("Type", value: type) + } + if let title = result.shareTitle { + LabeledContent("Share title", value: title) + } + } + CompositionDisclosure( + restEndpoint: "records/resolve", + steps: [ + "Record name → database.record(for: CKRecord.ID(recordName:))", + "Share URL → container.shareMetadata(for: url)", + ] + ) + } header: { + Text("Resolve — records/resolve (composed)") + } + } + + private func runLookup() async { + loading = true + lookupError = nil + defer { loading = false } + do { + let names = lookupInput.split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + lookupResults = try await service.lookupRecords(names: names) + } catch { + lookupResults = [] + lookupError = error.localizedDescription + } + } + + private func runChanges() async { + loading = true + changesError = nil + defer { loading = false } + do { + let snapshot = try await service.fetchRecordZoneChanges( + zoneID: CKRecordZone.ID(zoneName: CKRecordZone.ID.defaultZoneName), + since: changesToken + ) + changesSnapshot = snapshot + changesToken = snapshot.serverChangeToken + } catch { + changesSnapshot = nil + changesError = error.localizedDescription + } + } + + private func runResolve() async { + loading = true + resolveError = nil + defer { loading = false } + do { + resolveResult = try await service.resolveReference(input: resolveInput) + } catch { + resolveResult = nil + resolveError = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarItem.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarItem.swift index 2d3a12ee..f69ccb1e 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarItem.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarItem.swift @@ -32,12 +32,22 @@ internal enum SidebarItem: Hashable, CaseIterable { case account case zones case query + case records + case subscriptions + case pushTokens + case assets + case users internal var label: String { switch self { case .account: return "iCloud Account" case .zones: return "Zones" case .query: return "Query Records" + case .records: return "Records" + case .subscriptions: return "Subscriptions" + case .pushTokens: return "Push Tokens" + case .assets: return "Assets" + case .users: return "Users" } } @@ -46,6 +56,11 @@ internal enum SidebarItem: Hashable, CaseIterable { case .account: return "person.crop.circle" case .zones: return "tray.full" case .query: return "magnifyingglass" + case .records: return "doc.text" + case .subscriptions: return "bell" + case .pushTokens: return "key.radiowaves.forward" + case .assets: return "photo" + case .users: return "person.2" } } } diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarView.swift index 4c4906ab..b2f4cd7f 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/SidebarView.swift @@ -28,7 +28,7 @@ // #if canImport(SwiftUI) - import SwiftUI + internal import SwiftUI /// Sidebar list of navigation items. internal struct SidebarView: View { diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/SubscriptionsView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/SubscriptionsView.swift new file mode 100644 index 00000000..08a3afba --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/SubscriptionsView.swift @@ -0,0 +1,156 @@ +// +// SubscriptionsView.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + internal import MistDemoKit + internal import SwiftUI + + /// View driving `subscriptions/list` and `subscriptions/lookup` against + /// native CloudKit. Includes a "Create demo subscription" button so the + /// list has something to render against a fresh container. + internal struct SubscriptionsView: View { + @Environment(CloudKitStore.self) private var service + @State private var rows: [SubscriptionRow] = [] + @State private var loading = false + @State private var loadError: String? + @State private var lookupInput: String = "" + + internal var body: some View { + Group { + if loading { + ProgressView("Loading subscriptions…") + } else if let loadError { + ContentUnavailableView( + "Couldn't load subscriptions", + systemImage: "exclamationmark.triangle", + description: Text(loadError) + ) + } else if rows.isEmpty { + ContentUnavailableView( + "No subscriptions yet", + systemImage: "bell.slash", + description: Text( + "Tap Create Demo to register a Note-created subscription." + ) + ) + } else { + List(rows) { row in + VStack(alignment: .leading, spacing: 2) { + Text(row.id).font(.body.monospaced()) + Text(row.kind) + .font(.caption) + .foregroundStyle(.secondary) + if let recordType = row.recordType { + Text("Record type: \(recordType)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .deleteSwipeAction { + Task { await deleteSubscription(id: row.id) } + } + } + } + } + .safeAreaInset(edge: .bottom) { lookupBar } + .navigationTitle("Subscriptions") + .toolbar { + ToolbarItem { + Button("Create Demo") { Task { await createDemo() } } + } + ToolbarItem { + Button("Refresh") { Task { await refresh() } } + } + } + .task { await refresh() } + } + + private var lookupBar: some View { + HStack { + TextField( + "Lookup IDs (comma-separated)", + text: $lookupInput + ) + .font(.body.monospaced()) + Button("Lookup") { Task { await runLookup() } } + .disabled(lookupInput.isEmpty) + } + .padding(8) + .background(.thinMaterial) + } + + private func refresh() async { + loading = true + loadError = nil + defer { loading = false } + do { + rows = try await service.loadSubscriptions() + } catch { + loadError = error.localizedDescription + } + } + + private func createDemo() async { + do { + _ = try await service.createDemoSubscription() + await refresh() + } catch { + loadError = error.localizedDescription + } + } + + private func runLookup() async { + let ids = + lookupInput + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard !ids.isEmpty else { + return + } + loading = true + loadError = nil + defer { loading = false } + do { + rows = try await service.lookupSubscriptions(ids: ids) + } catch { + loadError = error.localizedDescription + } + } + + private func deleteSubscription(id: String) async { + do { + try await service.deleteSubscription(id: id) + await refresh() + } catch { + loadError = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/UsersView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/UsersView.swift new file mode 100644 index 00000000..8aed459e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/UsersView.swift @@ -0,0 +1,172 @@ +// +// UsersView.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) && canImport(CloudKit) + internal import MistDemoKit + internal import SwiftUI + + /// View driving `users/discover`, `users/lookup/email`, and + /// `users/lookup/id` against native CloudKit. CloudKit only returns + /// identities the caller is permitted to discover, so empty results + /// here usually mean the lookup target hasn't opted in to discovery. + internal struct UsersView: View { + @Environment(CloudKitStore.self) private var service + @State private var emailInput: String = "" + @State private var recordNameInput: String = "" + @State private var discoverInput: String = "" + @State private var results: [UserIdentityRow] = [] + @State private var error: String? + @State private var loading = false + + internal var body: some View { + Form { + emailSection + recordNameSection + discoverSection + if let error { + Section { + Text(error).font(.callout).foregroundStyle(.red) + } + } + if !results.isEmpty { + resultsSection + } + } + .formStyle(.grouped) + .navigationTitle("Users") + } + + private var emailSection: some View { + Section { + TextField("Email address", text: $emailInput) + Button("Lookup by Email") { + Task { await lookupByEmail() } + } + .disabled(emailInput.isEmpty || loading) + } header: { + Text("users/lookup/email") + } + } + + private var recordNameSection: some View { + Section { + TextField("User record name", text: $recordNameInput) + .font(.body.monospaced()) + Button("Lookup by Record Name") { + Task { await lookupByRecordName() } + } + .disabled(recordNameInput.isEmpty || loading) + } header: { + Text("users/lookup/id") + } + } + + private var discoverSection: some View { + Section { + TextField("Comma-separated emails", text: $discoverInput) + Button("Discover") { + Task { await discover() } + } + .disabled(discoverInput.isEmpty || loading) + } header: { + Text("users/discover (POST)") + } footer: { + Text( + "CloudKit JS exposes a per-email primitive only; the batch " + + "POST surface is composed by looping the per-call API." + ) + .font(.caption) + } + } + + private var resultsSection: some View { + Section("Results") { + ForEach(results) { row in + VStack(alignment: .leading, spacing: 2) { + Text(row.displayName ?? "(no display name)") + .font(.headline) + if let recordName = row.recordName { + Text(recordName).font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + if let hint = row.lookupHint { + Text("Looked up via: \(hint)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + + private func lookupByEmail() async { + await runLookup { + if let row = try await service.lookupUser(byEmail: emailInput) { + return [row] + } + return [] + } + } + + private func lookupByRecordName() async { + await runLookup { + if let row = try await service.lookupUser( + byRecordName: recordNameInput + ) { + return [row] + } + return [] + } + } + + private func discover() async { + let emails = + discoverInput + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + await runLookup { + try await service.discoverUsers(byEmails: emails) + } + } + + private func runLookup( + _ operation: @Sendable () async throws -> [UserIdentityRow] + ) async { + loading = true + error = nil + defer { loading = false } + do { + results = try await operation() + } catch { + results = [] + self.error = error.localizedDescription + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/View+DeleteSwipeAction.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/View+DeleteSwipeAction.swift new file mode 100644 index 00000000..b40a761a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/View+DeleteSwipeAction.swift @@ -0,0 +1,52 @@ +// +// View+DeleteSwipeAction.swift +// MistDemo +// +// 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. +// + +#if canImport(SwiftUI) + internal import SwiftUI + + extension View { + /// Attaches a trailing destructive "Delete" swipe action. + /// + /// `swipeActions(...)` is unavailable on tvOS, so this is a no-op there; + /// every list row that offers swipe-to-delete routes through this helper + /// so the platform guard lives in exactly one place. + @ViewBuilder + internal func deleteSwipeAction( + perform action: @escaping () -> Void + ) -> some View { + #if os(tvOS) + self + #else + self.swipeActions { + Button("Delete", role: .destructive, action: action) + } + #endif + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift index cb81d152..5990cbb7 100644 --- a/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift +++ b/Examples/MistDemo/Sources/MistDemoApp/Views/ZoneListView.swift @@ -28,15 +28,20 @@ // #if canImport(SwiftUI) && canImport(CloudKit) - import MistDemoKit - import SwiftUI + internal import MistDemoKit + internal import SwiftUI - /// View listing all CloudKit record zones. + /// View listing all CloudKit record zones, with modify (create/delete) + /// and changes (database-scope sync token) actions. Covers `zones/list`, + /// `zones/lookup`, `zones/modify`, and `zones/changes` in one place. internal struct ZoneListView: View { @Environment(CloudKitStore.self) private var service @State private var zones: [ZoneRow] = [] @State private var loading = false @State private var loadError: String? + @State private var newZoneName: String = "" + @State private var changesSnapshot: DatabaseChangesSnapshot? + @State private var changesError: String? internal var body: some View { Group { @@ -65,14 +70,21 @@ .foregroundStyle(.secondary) } .padding(.vertical, 2) + .deleteSwipeAction { + Task { await deleteZone(named: zone.zoneName) } + } } } } + .safeAreaInset(edge: .bottom) { actionBar } .navigationTitle(service.databaseScope.label.map { "Zones — \($0)" } ?? "Zones") .toolbar { ToolbarItem { Button("Refresh") { Task { await refresh() } } } + ToolbarItem { + Button("Fetch Changes") { Task { await fetchChanges() } } + } } .task { await refresh() } .onChange(of: service.databaseScope) { _, _ in @@ -81,6 +93,39 @@ } } + private var actionBar: some View { + VStack(spacing: 4) { + HStack { + TextField( + "New zone name", text: $newZoneName + ) + .font(.body.monospaced()) + Button("Create") { + Task { await createZone() } + } + .disabled(newZoneName.isEmpty) + } + if let snapshot = changesSnapshot { + HStack { + Text( + "Changed \(snapshot.changedZoneIDs.count), " + + "deleted \(snapshot.deletedZoneIDs.count)" + ) + .font(.caption) + Spacer() + Text(snapshot.moreComing ? "more coming" : "in-sync") + .font(.caption) + .foregroundStyle(.secondary) + } + } + if let changesError { + Text(changesError).font(.caption).foregroundStyle(.red) + } + } + .padding(8) + .background(.thinMaterial) + } + private func refresh() async { loading = true loadError = nil @@ -91,5 +136,35 @@ loadError = error.localizedDescription } } + + private func createZone() async { + do { + _ = try await service.createZone(named: newZoneName) + newZoneName = "" + await refresh() + } catch { + loadError = error.localizedDescription + } + } + + private func deleteZone(named name: String) async { + do { + try await service.deleteZone(named: name) + await refresh() + } catch { + loadError = error.localizedDescription + } + } + + private func fetchChanges() async { + changesError = nil + do { + changesSnapshot = try await service.fetchDatabaseChanges( + since: changesSnapshot?.serverChangeToken + ) + } catch { + changesError = error.localizedDescription + } + } } #endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AtomicBool.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AtomicBool.swift new file mode 100644 index 00000000..fbf2dadd --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AtomicBool.swift @@ -0,0 +1,48 @@ +// +// AtomicBool.swift +// MistDemo +// +// 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 + +/// Minimal atomic Bool — avoids pulling in Atomics for one flag. +/// +/// `Sendable` conformance is sound because all mutation goes through the +/// `NSLock`; the `value` field is never read or written outside `exchange`. +internal final class AtomicBool: Sendable { + private let lock = NSLock() + nonisolated(unsafe) private var value = false + + /// Set to `newValue` and return the prior value. + internal func exchange(_ newValue: Bool) -> Bool { + lock.lock() + defer { lock.unlock() } + let old = value + value = newValue + return old + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift index cd31f8fd..425ebbff 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to create a new record in CloudKit public struct CreateCommand: MistDemoCommand, OutputFormatting { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift new file mode 100644 index 00000000..3d3cff77 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateTokenCommand.swift @@ -0,0 +1,95 @@ +// +// CreateTokenCommand.swift +// MistDemo +// +// 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 MistKit + +// `helpText` below is a multi-line string whose option column doesn't align +// with Swift's indent steps; the rule isn't useful inside literal help text. +// swiftlint:disable indentation_width + +/// Command for `tokens/create`. Mints a CloudKit-managed APNs token that +/// non-device callers use as the destination for subscription-triggered pushes. +public struct CreateTokenCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = CreateTokenConfig + /// The command name. + public static let commandName = "create-token" + /// The command abstract. + public static let abstract = "Create an APNs token for CloudKit subscriptions" + /// The command help text. + public static let helpText = """ + CREATE-TOKEN - Create an APNs token for CloudKit subscriptions + + USAGE: + mistdemo create-token [--apns-environment ] [--client-id ] + + OPTIONS: + --apns-environment APNs environment, default development + --client-id Logical CloudKit client identifier + (default: fresh UUID per call) + --database Database to target + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo create-token --apns-environment development + mistdemo create-token --client-id 1A2B3C4D-... # stable identity + """ + + private let config: CreateTokenConfig + + /// Creates a new instance. + public init(config: CreateTokenConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + let environment = try resolveEnvironment() + let service = try MistKitClientFactory.create(for: config.base) + let result = try await service.createAPNsToken( + environment: environment, + clientId: config.clientId, + database: config.base.database + ) + try await outputResult(result, format: config.output) + } + + private func resolveEnvironment() throws -> APNsEnvironment { + guard let raw = config.apnsEnvironment else { + return .development + } + guard let environment = APNsEnvironment(rawValue: raw) else { + throw TokenCommandError.invalidEnvironment(raw) + } + return environment + } +} + +// swiftlint:enable indentation_width diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateZoneCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateZoneCommand.swift new file mode 100644 index 00000000..101692f9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CreateZoneCommand.swift @@ -0,0 +1,105 @@ +// +// CreateZoneCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command to create a single CloudKit zone. +public struct CreateZoneCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = CreateZoneConfig + /// The command name. + public static let commandName = "create-zone" + /// The command abstract. + public static let abstract = "Create a single CloudKit zone" + /// The command help text. + public static let helpText = """ + CREATE-ZONE - Create a single CloudKit zone + + USAGE: + mistdemo create-zone --zone-name [options] + + OPTIONS: + --zone-name Zone name to create (required) + --zone-owner Optional owner record name + --database Database to target (private or shared) + --output-format Output format + + EXAMPLES: + mistdemo create-zone --zone-name Articles + mistdemo create-zone --zone-name SharedZone --database shared + + NOTES: + - The .public database does not support custom zones + - Auth method follows --database + - Zone names are case-sensitive + """ + + private let config: CreateZoneConfig + + /// Creates a new instance. + public init(config: CreateZoneConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("🆕 Create CloudKit Zone") + print(String(repeating: "=", count: 60)) + + let service = try MistKitClientFactory.create(for: config.base) + + print("\n📋 Creating zone:") + print(" - Name: \(config.zoneName)") + if let owner = config.ownerRecordName { + print(" - Owner: \(owner)") + } + print(" - Database: \(config.base.database)") + + let zone = try await service.createZone( + zoneName: config.zoneName, + ownerRecordName: config.ownerRecordName, + database: config.base.database + ) + + print("\n✅ Created zone:") + print(" - \(zone.zoneName)") + if let owner = zone.ownerRecordName { + print(" Owner: \(owner)") + } + if !zone.capabilities.isEmpty { + print(" Capabilities: \(zone.capabilities.joined(separator: ", "))") + } + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Zone creation completed!") + print(String(repeating: "=", count: 60)) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift index 6f02a7a4..145c5d15 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/CurrentUserCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to get information about the authenticated user public struct CurrentUserCommand: MistDemoCommand, OutputFormatting { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift index 94bf05f3..4bcbc8b8 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to delete an existing record from CloudKit public struct DeleteCommand: MistDemoCommand, OutputFormatting { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteResult.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteResult.swift index 6869050b..0191f401 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteResult.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteResult.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Result of a successful delete, formatted as command output. public struct DeleteResult: Encodable, Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteZoneCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteZoneCommand.swift new file mode 100644 index 00000000..a6be8d05 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DeleteZoneCommand.swift @@ -0,0 +1,99 @@ +// +// DeleteZoneCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command to delete a single CloudKit zone. +public struct DeleteZoneCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = DeleteZoneConfig + /// The command name. + public static let commandName = "delete-zone" + /// The command abstract. + public static let abstract = "Delete a single CloudKit zone" + /// The command help text. + public static let helpText = """ + DELETE-ZONE - Delete a single CloudKit zone + + USAGE: + mistdemo delete-zone --zone-name [options] + + OPTIONS: + --zone-name Zone name to delete (required) + --zone-owner Optional owner record name + --database Database to target (private or shared) + --output-format Output format + + EXAMPLES: + mistdemo delete-zone --zone-name Articles + mistdemo delete-zone --zone-name SharedZone --database shared + + NOTES: + - The .public database does not support custom zones + - Deleting a zone removes ALL records inside it + - Auth method follows --database + - Zone names are case-sensitive + """ + + private let config: DeleteZoneConfig + + /// Creates a new instance. + public init(config: DeleteZoneConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + print("\n" + String(repeating: "=", count: 60)) + print("🗑️ Delete CloudKit Zone") + print(String(repeating: "=", count: 60)) + + let service = try MistKitClientFactory.create(for: config.base) + + print("\n📋 Deleting zone:") + print(" - Name: \(config.zoneName)") + if let owner = config.ownerRecordName { + print(" - Owner: \(owner)") + } + print(" - Database: \(config.base.database)") + + try await service.deleteZone( + zoneName: config.zoneName, + ownerRecordName: config.ownerRecordName, + database: config.base.database + ) + + print("\n✅ Deleted zone '\(config.zoneName)'") + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Zone deletion completed!") + print(String(repeating: "=", count: 60)) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsCommand.swift index 76cf3d35..8ab93bd7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Walks the audience through CloudKit's typed errors for the talk's /// "CloudKit as Your Backend" / Act 3, Step 4 — Error handling segment. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift index 17fb2b2b..003c0348 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner+Output.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit extension DemoErrorsRunner { internal func printRunnerHeader() { @@ -56,10 +56,23 @@ extension DemoErrorsRunner { let status = error.httpStatusCode.map(String.init) ?? "n/a" let prefix = error.httpStatusCode == expectedStatus ? "✅" : "❌" print("\(prefix) Caught CloudKitError — status: \(status)") - if case .httpErrorWithDetails(_, let serverErrorCode, let reason) = error { + switch error { + case .httpErrorWithDetails(_, let serverErrorCode, let reason): print(" serverErrorCode: \(serverErrorCode ?? "")") print(" reason: \(reason ?? "")") - } else { + 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)") } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift index ce259489..ae9b8219 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoErrorsRunner.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Runs the talk's CloudKit error scenarios (401, 404, 409) and prints typed /// `CloudKitError` details. Mirrors the section/prefix style of diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift index 7c3a32e6..295fbea5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DemoInFilterCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Demonstrates the IN/NOT_IN QueryFilter fix (issue #192) end-to-end. /// @@ -115,7 +115,6 @@ public struct DemoInFilterCommand: MistDemoCommand { return createdNames } - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) private func verifyAndQueryRecords( client: CloudKitService, recordType: String, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DiscoverAllUserIdentitiesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DiscoverAllUserIdentitiesCommand.swift new file mode 100644 index 00000000..5347b3c1 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DiscoverAllUserIdentitiesCommand.swift @@ -0,0 +1,102 @@ +// +// DiscoverAllUserIdentitiesCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command that discovers user identities for a set of email addresses using +/// the auto-chunking `discoverAllUserIdentities(lookupInfos:)` convenience +/// (issue #307). +public struct DiscoverAllUserIdentitiesCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = DiscoverConfig + /// The command name. + public static let commandName = "discover-all" + /// The command abstract. + public static let abstract = + "Discover user identities, auto-chunking large inputs (discoverAllUserIdentities)" + /// The command help text. + public static let helpText = """ + DISCOVER-ALL - Discover user identities, auto-chunking past CloudKit's 200/request cap + + USAGE: + mistdemo discover-all --discover-emails [options] + + INPUT (choose one): + --discover-emails Comma-separated email addresses + --stdin Read one email per line from stdin + + OPTIONS: + --batch-size Items per request (default 200, clamped 1...200). + Set small (e.g. 1) to force multiple requests. + --output-format Output format (json, table, csv, yaml) + + NOTES: + - Requires API + web-auth credentials; the endpoint is pinned to the + public database, so the --database flag does not apply. + - Each email is sent as a lookup info; CloudKit only returns identities + for accounts discoverable to the caller. + """ + + private let config: DiscoverConfig + + /// Creates a new instance. + public init(config: DiscoverConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + guard !config.emails.isEmpty else { + throw DiscoverError.emailsRequired + } + guard config.base.hasUserContextCredentials else { + throw DiscoverError.webAuthRequired + } + + let service = try MistKitClientFactory.create(for: config.base) + + let effectiveBatchSize = min( + max(config.batchSize, 1), + CloudKitService.maxRecordsPerRequest + ) + let batches = (config.emails.count + effectiveBatchSize - 1) / effectiveBatchSize + let note = + "discover-all: \(config.emails.count) lookup(s), batchSize \(config.batchSize) " + + "→ \(batches) request(s)\n" + FileHandle.standardError.write(Data(note.utf8)) + + let lookupInfos = config.emails.map { UserIdentityLookupInfo(emailAddress: $0) } + let identities = try await service.discoverAllUserIdentities( + lookupInfos: lookupInfos, + batchSize: config.batchSize + ) + try await outputResults(identities, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/DiscoverCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/DiscoverCommand.swift new file mode 100644 index 00000000..5a251d3e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/DiscoverCommand.swift @@ -0,0 +1,84 @@ +// +// DiscoverCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command that discovers user identities by email address. +public struct DiscoverCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = DiscoverConfig + /// The command name. + public static let commandName = "discover" + /// The command abstract. + public static let abstract = "Discover user identities by email" + /// The command help text. + public static let helpText = """ + DISCOVER - Discover user identities by email + + USAGE: + mistdemo discover --discover-emails + cat emails.txt | mistdemo discover --stdin + + INPUT (choose one): + --discover-emails Comma-separated email addresses + --stdin Read one email per line from stdin + + OPTIONS: + --output-format Output format (json, table, csv, yaml) + + NOTES: + - Requires API + web-auth credentials. The underlying CloudKit + endpoint is pinned to the public database; the database flag + does not apply. + - CloudKit only returns identities for accounts discoverable to + the caller. + """ + + private let config: DiscoverConfig + + /// Creates a new instance. + public init(config: DiscoverConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + guard !config.emails.isEmpty else { + throw DiscoverError.emailsRequired + } + guard config.base.hasUserContextCredentials else { + throw DiscoverError.webAuthRequired + } + + let service = try MistKitClientFactory.create(for: config.base) + let identities = try await service.lookupUsersByEmail(config.emails) + try await outputResults(identities, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift index 5f50cdeb..9a76e037 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/FetchChangesCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to fetch record changes with incremental sync public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { @@ -145,7 +145,7 @@ public struct FetchChangesCommand: MistDemoCommand, OutputFormatting { private func displayRecords(_ records: [RecordInfo], limit: Int) { let displayed = records.prefix(limit) for record in displayed { - print(" 📝 \(record.recordType) - \(record.recordName)") + print(" 📝 \(record.recordType ?? "(deleted)") - \(record.recordName)") if !record.fields.isEmpty { print(" Fields: \(record.fields.keys.joined(separator: ", "))") } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ListSubscriptionsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ListSubscriptionsCommand.swift new file mode 100644 index 00000000..68360116 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ListSubscriptionsCommand.swift @@ -0,0 +1,70 @@ +// +// ListSubscriptionsCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command for `subscriptions/list`. Lists every CloudKit subscription +/// registered against the selected database. +public struct ListSubscriptionsCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = ListSubscriptionsConfig + /// The command name. + public static let commandName = "list-subscriptions" + /// The command abstract. + public static let abstract = "List CloudKit subscriptions" + /// The command help text. + public static let helpText = """ + LIST-SUBSCRIPTIONS - List CloudKit subscriptions + + USAGE: + mistdemo list-subscriptions [options] + + OPTIONS: + --database Database to target (private, shared, public) + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo list-subscriptions --database private + """ + + private let config: ListSubscriptionsConfig + + /// Creates a new instance. + public init(config: ListSubscriptionsConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + let service = try MistKitClientFactory.create(for: config.base) + let subscriptions = try await service.listSubscriptions(database: config.base.database) + try await outputResults(subscriptions, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ListZonesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ListZonesCommand.swift new file mode 100644 index 00000000..8543c2df --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ListZonesCommand.swift @@ -0,0 +1,86 @@ +// +// ListZonesCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command that lists all zones in the configured database. +public struct ListZonesCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = ListZonesConfig + /// The command name. + public static let commandName = "list-zones" + /// The command abstract. + public static let abstract = "List all zones in the database" + /// The command help text. + public static let helpText = """ + LIST-ZONES - List all zones in the database + + USAGE: + mistdemo list-zones [options] + + OPTIONS: + --database Database to target (private, shared) + --zones-include-default Include `_defaultZone` in the output + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo list-zones --database private + mistdemo list-zones --database shared --zones-include-default + + NOTES: + - Only `private` and `shared` databases support zone listing. + - By default the default zone (`_defaultZone`) is filtered out so + only custom zones are shown. + """ + + private let config: ListZonesConfig + + /// Creates a new instance. + public init(config: ListZonesConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + if case .public = config.base.database { + throw ListZonesError.databaseNotSupported + } + + let service = try MistKitClientFactory.create(for: config.base) + let zones = try await service.listZones(database: config.base.database) + + let filtered = + config.includeDefault + ? zones + : zones.filter { $0.zoneName != ZoneID.defaultZone.zoneName } + + try await outputResults(filtered, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupAllRecordsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupAllRecordsCommand.swift new file mode 100644 index 00000000..8b45bb9a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupAllRecordsCommand.swift @@ -0,0 +1,99 @@ +// +// LookupAllRecordsCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command to look up records by name using the auto-chunking +/// `lookupAllRecords` convenience (issue #307). +public struct LookupAllRecordsCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = LookupConfig + /// The command name. + public static let commandName = "lookup-all" + /// The command abstract. + public static let abstract = + "Look up records by name, auto-chunking large inputs (lookupAllRecords)" + /// The command help text. + public static let helpText = """ + LOOKUP-ALL - Fetch records by name, auto-chunking past CloudKit's 200/request cap + + USAGE: + mistdemo lookup-all --record-names [options] + + REQUIRED: + --api-token CloudKit API token + --web-auth-token Web authentication token + --record-names Comma-separated record names + + OPTIONS: + --fields Restrict returned fields + --batch-size Items per request (default 200, clamped 1...200). + Set small (e.g. 1) to force multiple requests. + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo lookup-all --record-names note-1,note-2,note-3 --batch-size 1 + """ + + private let config: LookupConfig + + /// Creates a new instance. + public init(config: LookupConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + let client = try MistKitClientFactory.create(for: config.base) + + let effectiveBatchSize = min( + max(config.batchSize, 1), + CloudKitService.maxRecordsPerRequest + ) + let batches = + (config.recordNames.count + effectiveBatchSize - 1) / effectiveBatchSize + let note = + "lookup-all: \(config.recordNames.count) name(s), batchSize \(config.batchSize) " + + "→ \(batches) request(s)\n" + FileHandle.standardError.write(Data(note.utf8)) + + let results = try await client.lookupAllRecords( + recordNames: config.recordNames, + desiredKeys: config.fields, + database: config.base.database, + batchSize: config.batchSize + ) + + let records = results.compactMap { result in + if case .success(let record) = result { record } else { nil } + } + try await outputResults(records, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift index 6871ebf5..b291301b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to look up records by name in CloudKit public struct LookupCommand: MistDemoCommand, OutputFormatting { @@ -76,14 +76,19 @@ public struct LookupCommand: MistDemoCommand, OutputFormatting { do { let client = try MistKitClientFactory.create(for: config.base) - let records = try await client.lookupRecords( + let results = try await client.lookupRecords( recordNames: config.recordNames, desiredKeys: config.fields, database: config.base.database ) + // A per-record lookup failure (e.g. NOT_FOUND) comes back as `.failure`. + let records = results.compactMap { result in + if case .success(let record) = result { record } else { nil } + } + // Report missing names to stderr so a JSON/CSV/etc. stdout stream stays parseable - let foundNames = Set(records.compactMap { $0.recordName }) + let foundNames = Set(records.map(\.recordName)) let missing = config.recordNames.filter { !foundNames.contains($0) } if !missing.isEmpty { let line = diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupSubscriptionCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupSubscriptionCommand.swift new file mode 100644 index 00000000..31d7cf00 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupSubscriptionCommand.swift @@ -0,0 +1,77 @@ +// +// LookupSubscriptionCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command for `subscriptions/lookup`. Looks up one or more CloudKit +/// subscriptions by ID. +public struct LookupSubscriptionCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = LookupSubscriptionConfig + /// The command name. + public static let commandName = "lookup-subscription" + /// The command abstract. + public static let abstract = "Look up a CloudKit subscription by ID" + /// The command help text. + public static let helpText = """ + LOOKUP-SUBSCRIPTION - Look up a CloudKit subscription by ID + + USAGE: + mistdemo lookup-subscription --subscription-ids [options] + + OPTIONS: + --subscription-ids Comma-separated subscription IDs + --database Database to target + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo lookup-subscription --subscription-ids sub-1,sub-2 --database private + """ + + private let config: LookupSubscriptionConfig + + /// Creates a new instance. + public init(config: LookupSubscriptionConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + guard !config.subscriptionIDs.isEmpty else { + throw SubscriptionCommandError.missingSubscriptionIDs + } + let service = try MistKitClientFactory.create(for: config.base) + let subscriptions = try await service.lookupSubscriptions( + ids: config.subscriptionIDs, + database: config.base.database + ) + try await outputResults(subscriptions, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift index 3d38519e..9a9b566a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/LookupZonesCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to look up specific CloudKit zones by name public struct LookupZonesCommand: MistDemoCommand, OutputFormatting { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoCommand.swift index 98e40c8b..effa5155 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoCommand.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation /// Typealias for MistDemo commands - now uses generic Command protocol public typealias MistDemoCommand = Command diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift new file mode 100644 index 00000000..2d8af359 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/MistDemoLoggingBootstrap.swift @@ -0,0 +1,58 @@ +// +// MistDemoLoggingBootstrap.swift +// MistDemo +// +// 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 Logging + +/// Wires swift-log so MistKit's `LoggingMiddleware` actually emits the +/// HTTP request trace on `--verbose` runs. +/// +/// MistKit emits at `.debug` on `com.brightdigit.MistKit.middleware`; nothing +/// is visible until `LoggingSystem.bootstrap` is called. The CLI never bootstraps +/// otherwise (the demo doesn't want generic info-level noise from every +/// subsystem), so we opt in only when the integration test commands ask for it. +internal enum MistDemoLoggingBootstrap { + private static let hasBootstrapped = AtomicBool() + + /// Bootstrap exactly once per process. `LoggingSystem.bootstrap` is + /// itself a once-only call, so guard with an atomic flag — repeated + /// `--verbose` invocations from the same process (e.g. tests) become + /// no-ops instead of crashes. + internal static func bootstrapOnce() { + guard hasBootstrapped.exchange(true) == false else { + return + } + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = + label == "com.brightdigit.MistKit.middleware" ? .debug : .info + return handler + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift index 8d11c206..3d95f8f5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to perform batch create/update/delete operations. public struct ModifyCommand: MistDemoCommand, OutputFormatting { @@ -67,61 +67,59 @@ public struct ModifyCommand: MistDemoCommand, OutputFormatting { self.config = config } + private static func makeRows(from records: [RecordInfo]) -> [ModifyResultRow] { + records.map { record in + ModifyResultRow( + operation: "applied", + recordType: record.recordType, + recordName: record.recordName, + recordChangeTag: record.recordChangeTag + ) + } + } + + private static func reportFailures(_ failures: [RecordOperationFailure]) { + guard !failures.isEmpty else { + return + } + for failure in failures { + let identifier = failure.identifier + let code = failure.serverErrorCode.rawValue + let reasonFragment = failure.reason.map { ": \($0)" } ?? "" + let line = "Warning: operation on '\(identifier)' failed (\(code))\(reasonFragment)\n" + FileHandle.standardError.write(Data(line.utf8)) + } + } + /// Executes the command. public func execute() async throws { do { - let client = try MistKitClientFactory.create( - for: config.base - ) - + let client = try MistKitClientFactory.create(for: config.base) let operations = try config.operations.enumerated() - .map { index, input in - try input.toRecordOperation(index: index) - } - + .map { index, input in try input.toRecordOperation(index: index) } let results = try await client.modifyRecords( operations, atomic: config.atomic, database: config.base.database ) - - let rows = results.map { record in - ModifyResultRow( - operation: "applied", - recordType: record.recordType, - recordName: record.recordName, - recordChangeTag: record.recordChangeTag - ) + let succeeded = results.compactMap { result in + if case .success(let record) = result { record } else { nil } } - - let recordReturningOpsCount = - config.operations - .filter { $0.operation != .delete }.count - let partialFailure = - !config.atomic - && results.count < recordReturningOpsCount - - if partialFailure { - let missing = recordReturningOpsCount - results.count - let line = - "Warning: \(missing) of \(recordReturningOpsCount)" - + " create/update op(s) did not return a record.\n" - FileHandle.standardError.write(Data(line.utf8)) + let failures = results.compactMap { result in + if case .failure(let error) = result { error } else { nil } } - + Self.reportFailures(failures) let envelope = ModifyOutput( - results: rows, + results: Self.makeRows(from: succeeded), attempted: config.operations.count, - succeeded: results.count, - partialFailure: partialFailure + succeeded: succeeded.count, + partialFailure: !config.atomic && !failures.isEmpty ) try await outputResult(envelope, format: config.output) } catch let error as ModifyError { throw error } catch { - throw ModifyError.operationFailed( - error.localizedDescription - ) + throw ModifyError.operationFailed(error.localizedDescription) } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyOutput.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyOutput.swift index 60285350..8948628d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyOutput.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyOutput.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// JSON envelope for modify output. public struct ModifyOutput: Encodable, Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyResultRow.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyResultRow.swift index d4ea90b7..54abb908 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyResultRow.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyResultRow.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// One row in the modify command's output. public struct ModifyResultRow: Encodable, Sendable { @@ -40,8 +40,8 @@ public struct ModifyResultRow: Encodable, Sendable { /// The operation type applied. public let operation: String - /// The record type. - public let recordType: String + /// The record type, or `nil` for a deleted (typeless) record. + public let recordType: String? /// The record name. public let recordName: String? /// The record change tag. @@ -50,7 +50,7 @@ public struct ModifyResultRow: Encodable, Sendable { /// Creates a new instance. public init( operation: String, - recordType: String, + recordType: String?, recordName: String?, recordChangeTag: String? ) { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift new file mode 100644 index 00000000..7a8ec535 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifySubscriptionsCommand.swift @@ -0,0 +1,122 @@ +// +// ModifySubscriptionsCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command for `subscriptions/modify`. Creates or deletes a CloudKit +/// subscription. +public struct ModifySubscriptionsCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = ModifySubscriptionsConfig + /// The command name. + public static let commandName = "modify-subscriptions" + /// The command abstract. + public static let abstract = "Create or delete a CloudKit subscription" + /// The command help text. + public static let helpText = """ + MODIFY-SUBSCRIPTIONS - Create or delete a CloudKit subscription + + USAGE: + mistdemo modify-subscriptions --operation create \\ + --subscription-id --record-type [--fires-on ] + mistdemo modify-subscriptions --operation delete --subscription-id + + OPTIONS: + --operation create or delete (default: create) + --subscription-id Subscription identifier (required) + --record-type Record type for a create query subscription + --fires-on Comma-separated: create,update,delete + --database Database to target + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo modify-subscriptions --operation create \\ + --subscription-id arts --record-type Article --database private + mistdemo modify-subscriptions --operation delete \\ + --subscription-id arts --database private + """ + + private let config: ModifySubscriptionsConfig + + /// Creates a new instance. + public init(config: ModifySubscriptionsConfig) { + self.config = config + } + + private static func parseFiresOn(_ raws: [String]) -> SubscriptionFireEvents { + var firesOn: SubscriptionFireEvents = [] + for raw in raws { + switch raw { + case "create": firesOn.insert(.create) + case "update": firesOn.insert(.update) + case "delete": firesOn.insert(.delete) + default: break + } + } + return firesOn + } + + /// Executes the command. + public func execute() async throws { + guard let subscriptionID = config.subscriptionID, !subscriptionID.isEmpty else { + throw SubscriptionCommandError.missingSubscriptionID + } + let service = try MistKitClientFactory.create(for: config.base) + + switch config.operation { + case "create": + try await runCreate(subscriptionID: subscriptionID, service: service) + case "delete": + try await service.deleteSubscription(id: subscriptionID, database: config.base.database) + print("✅ Deleted subscription '\(subscriptionID)'.") + default: + throw SubscriptionCommandError.invalidOperation(config.operation) + } + } + + private func runCreate( + subscriptionID: String, + service: CloudKitService + ) async throws { + guard let recordType = config.recordType, !recordType.isEmpty else { + throw SubscriptionCommandError.missingRecordType + } + let firesOn = Self.parseFiresOn(config.firesOn) + let created = try await service.createSubscription( + .query( + subscriptionID: subscriptionID, + recordType: recordType, + firesOn: firesOn + ), + database: config.base.database + ) + try await outputResult(created, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyZonesCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyZonesCommand.swift new file mode 100644 index 00000000..04dfb05e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ModifyZonesCommand.swift @@ -0,0 +1,101 @@ +// +// ModifyZonesCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command that creates or deletes zones. +public struct ModifyZonesCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = ModifyZonesConfig + /// The command name. + public static let commandName = "modify-zones" + /// The command abstract. + public static let abstract = "Create or delete CloudKit zones" + /// The command help text. + public static let helpText = """ + MODIFY-ZONES - Create or delete CloudKit zones + + USAGE: + mistdemo modify-zones --operations-file [options] + cat zones.json | mistdemo modify-zones --stdin [options] + + INPUT (choose one): + --operations-file Path to JSON envelope + --stdin Read JSON envelope from stdin + + OPTIONS: + --database Database to target (private, shared) + --output-format Output format (json, table, csv, yaml) + + INPUT FORMAT: + { + "operations": [ + { "type": "create", "zoneName": "Articles" }, + { "type": "delete", "zoneName": "Archive" } + ] + } + + NOTES: + - Only `private` and `shared` databases support zone modification. + - Each delete is announced on stderr before the request is sent. + """ + + private let config: ModifyZonesConfig + + /// Creates a new instance. + public init(config: ModifyZonesConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + if case .public = config.base.database { + throw ModifyZonesError.databaseNotSupported + } + + let service = try MistKitClientFactory.create(for: config.base) + + let operations = try config.operations.map { input -> ZoneOperation in + let operation = try input.toZoneOperation() + if case .delete(let zoneID) = operation { + let warning = "⚠️ Deleting zone '\(zoneID.zoneName)'\n" + FileHandle.standardError.write(Data(warning.utf8)) + } + return operation + } + + let results = try await service.modifyZones( + operations, + database: config.base.database + ) + + try await outputResults(results, format: config.output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand+Experiment.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand+Experiment.swift new file mode 100644 index 00000000..6348055d --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand+Experiment.swift @@ -0,0 +1,121 @@ +// +// ProbeDuplicateSubscriptionCommand+Experiment.swift +// MistDemo +// +// 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 MistKit + +extension ProbeDuplicateSubscriptionCommand { + internal func runExperiment( + _ experiment: ProbeExperiment, + service: CloudKitService, + database: Database + ) async -> String { + let seedSub = experiment.seedSubscription() + let probeSub = experiment.probeSubscription() + print("") + print("▶︎ #\(experiment.index): \(experiment.label)") + print( + " seed: id=\(seedSub.subscriptionID) " + + describeSubscription(seedSub) + ) + print( + " probe: id=\(probeSub.subscriptionID) " + + describeSubscription(probeSub) + ) + + let seedResult: SubscriptionResult? + do { + let seedResults = try await service.modifySubscriptions( + [.create(seedSub)], + database: database + ) + seedResult = seedResults.first + print(" seed result: \(formatResult(seedResults.first))") + } catch { + print(" seed result: THREW \(error)") + return "seed threw — cannot probe" + } + + var probeOutcome = "no probe result" + do { + let probeResults = try await service.modifySubscriptions( + [.create(probeSub)], + database: database + ) + print(" probe result: \(formatResult(probeResults.first))") + probeOutcome = summarize(probeResults.first) + } catch { + print(" probe result: THREW \(error)") + probeOutcome = "threw: \(error)" + } + + // Best-effort cleanup, both IDs in case seed succeeded. + var idsToDelete: [String] = [seedSub.subscriptionID] + if probeSub.subscriptionID != seedSub.subscriptionID { + idsToDelete.append(probeSub.subscriptionID) + } + for id in idsToDelete { + do { + try await service.deleteSubscription(id: id, database: database) + } catch { + print(" cleanup warning for '\(id)': \(error)") + } + } + _ = seedResult + return probeOutcome + } + + internal func describeSubscription(_ sub: SubscriptionInfo) -> String { + let recordType = sub.query?.recordType ?? "" + var names: [String] = [] + if sub.firesOn.contains(.create) { + names.append("create") + } + if sub.firesOn.contains(.update) { + names.append("update") + } + if sub.firesOn.contains(.delete) { + names.append("delete") + } + return "recordType=\(recordType) firesOn=[\(names.joined(separator: ","))]" + } + + internal func summarize(_ result: SubscriptionResult?) -> String { + switch result { + case .none: + return "no result" + case .success: + return "SUCCESS" + case .failure(let failure): + return + "FAIL code=\(failure.serverErrorCode.rawValue) " + + "isLikelyDuplicate=\(failure.isLikelyDuplicate)" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift new file mode 100644 index 00000000..98f30536 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeDuplicateSubscriptionCommand.swift @@ -0,0 +1,213 @@ +// +// ProbeDuplicateSubscriptionCommand.swift +// MistDemo +// +// 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 MistKit + +// `helpText` below is a multi-line string whose option column doesn't +// align with Swift's indent steps; the rule isn't useful inside literal +// help text. +// swiftlint:disable indentation_width + +/// One-off diagnostic command: probes CloudKit's +/// `subscriptions/modify` to pin down what triggers the +/// `INTERNAL_ERROR` / "could not find subscription we just created" +/// failure. Not part of the integration test suite — run manually. +public struct ProbeDuplicateSubscriptionCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = ProbeDuplicateSubscriptionConfig + + /// One row of the end-of-run summary printed by ``execute()``. + internal struct ExperimentOutcome { + internal let index: Int + internal let label: String + internal let result: String + } + + /// The command name. + public static let commandName = "probe-duplicate-subscription" + /// The command abstract. + public static let abstract = + "Probe CloudKit subscription uniqueness (diagnostic)" + /// The command help text. + public static let helpText = """ + PROBE-DUPLICATE-SUBSCRIPTION - Diagnostic: which axes trigger duplicate? + + Creates a seed subscription, then tries variations to identify which + properties (subscriptionID vs query+firesOn vs recordType) CloudKit + treats as uniqueness keys. Prints raw serverErrorCode / reason for + every probe so we can confirm or refute MistKit's "isLikelyDuplicate" + detection. Cleans up all subscriptions it creates. + + USAGE: + mistdemo probe-duplicate-subscription [options] + + OPTIONS: + --database public | private | shared + --record-type Record type to probe (default: Note) + --alternate-record-type Record type for negative control + (default: Article) + --verbose Print full SubscriptionResult per probe + + EXAMPLES: + mistdemo probe-duplicate-subscription --database public --verbose + mistdemo probe-duplicate-subscription --database private + """ + + private let config: ProbeDuplicateSubscriptionConfig + + /// Creates a new instance. + public init(config: ProbeDuplicateSubscriptionConfig) { + self.config = config + } + + internal static func makeExperiments( + run: Substring, + recordType: String, + alternateRecordType: String + ) -> [ProbeExperiment] { + [ + .same( + index: 1, + label: "different ID, same recordType, same firesOn", + run: run, + recordType: recordType, + seedFiresOn: [.create], + probeFiresOn: [.create], + sameID: false + ), + .same( + index: 2, + label: "same ID, same recordType, same firesOn", + run: run, + recordType: recordType, + seedFiresOn: [.create], + probeFiresOn: [.create], + sameID: true + ), + .same( + index: 3, + label: "different ID, same recordType, different firesOn", + run: run, + recordType: recordType, + seedFiresOn: [.create], + probeFiresOn: [.update], + sameID: false + ), + .same( + index: 4, + label: "different ID, same recordType, superset firesOn", + run: run, + recordType: recordType, + seedFiresOn: [.create], + probeFiresOn: [.create, .update], + sameID: false + ), + .differentRecordType( + index: 5, + label: "different ID, different recordType, same firesOn", + run: run, + seedRecordType: recordType, + probeRecordType: alternateRecordType, + firesOn: [.create] + ), + ] + } + + /// Executes the command. + public func execute() async throws { + let service = try MistKitClientFactory.create(for: config.base) + let database = config.base.database + let probeRun = UUID().uuidString.lowercased().prefix(8) + + print("🧪 probe-duplicate-subscription (run=\(probeRun))") + print(" database=\(database.pathSegment) recordType=\(config.recordType)") + print( + " Each experiment: seed one subscription, probe with a variation, " + + "report seed/probe outcomes, cleanup." + ) + + let experiments = Self.makeExperiments( + run: probeRun, + recordType: config.recordType, + alternateRecordType: config.alternateRecordType + ) + + var summary: [ExperimentOutcome] = [] + for experiment in experiments { + let outcome = await runExperiment( + experiment, + service: service, + database: database + ) + summary.append( + ExperimentOutcome( + index: experiment.index, + label: experiment.label, + result: outcome + ) + ) + } + + print("") + print("📋 Summary") + for row in summary { + print(" #\(row.index) \(row.label)") + print(" → \(row.result)") + } + } + + internal func formatResult(_ result: SubscriptionResult?) -> String { + guard let result else { + return "nil" + } + switch result { + case .success(let info): + return "SUCCESS id=\(info.subscriptionID)" + case .failure(let failure): + var parts: [String] = [ + "FAILURE id=\(failure.identifier)", + "code=\(failure.serverErrorCode.rawValue)", + "reason=\"\(failure.reason ?? "")\"", + "isLikelyDuplicate=\(failure.isLikelyDuplicate)", + ] + if config.verbose, let uuid = failure.uuid { + parts.append("uuid=\(uuid)") + } + return parts.joined(separator: " ") + } + } +} + +// swiftlint:enable indentation_width + +// The per-experiment runner lives in +// `ProbeDuplicateSubscriptionCommand+Experiment.swift`; +// `ProbeExperiment` lives in `ProbeExperiment.swift` and +// `ProbeSubscriptionTemplate` in its own file. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift new file mode 100644 index 00000000..8affc9fa --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeExperiment.swift @@ -0,0 +1,100 @@ +// +// ProbeExperiment.swift +// MistDemo +// +// 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 MistKit + +/// One probe experiment — a seed subscription + a probe subscription +/// + a label. Both are pure value types; we materialize them via +/// `seedSubscription()` / `probeSubscription()` so identifiers stay +/// stable across the seed/probe/cleanup phases of one experiment. +internal struct ProbeExperiment { + internal let index: Int + internal let label: String + internal let seed: ProbeSubscriptionTemplate + internal let probe: ProbeSubscriptionTemplate + + internal static func same( + index: Int, + label: String, + run: Substring, + recordType: String, + seedFiresOn: SubscriptionFireEvents, + probeFiresOn: SubscriptionFireEvents, + sameID: Bool + ) -> ProbeExperiment { + let seedID = "probe-\(run)-e\(index)-seed" + let probeID = sameID ? seedID : "probe-\(run)-e\(index)-probe" + return ProbeExperiment( + index: index, + label: label, + seed: ProbeSubscriptionTemplate( + id: seedID, + recordType: recordType, + firesOn: seedFiresOn + ), + probe: ProbeSubscriptionTemplate( + id: probeID, + recordType: recordType, + firesOn: probeFiresOn + ) + ) + } + + internal static func differentRecordType( + index: Int, + label: String, + run: Substring, + seedRecordType: String, + probeRecordType: String, + firesOn: SubscriptionFireEvents + ) -> ProbeExperiment { + ProbeExperiment( + index: index, + label: label, + seed: ProbeSubscriptionTemplate( + id: "probe-\(run)-e\(index)-seed", + recordType: seedRecordType, + firesOn: firesOn + ), + probe: ProbeSubscriptionTemplate( + id: "probe-\(run)-e\(index)-probe", + recordType: probeRecordType, + firesOn: firesOn + ) + ) + } + + internal func seedSubscription() -> SubscriptionInfo { + seed.materialize() + } + + internal func probeSubscription() -> SubscriptionInfo { + probe.materialize() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeSubscriptionTemplate.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeSubscriptionTemplate.swift new file mode 100644 index 00000000..0b1d59df --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ProbeSubscriptionTemplate.swift @@ -0,0 +1,47 @@ +// +// ProbeSubscriptionTemplate.swift +// MistDemo +// +// 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 MistKit + +/// A single `SubscriptionInfo` blueprint used by ``ProbeExperiment`` — +/// captured separately so the experiment can materialize identical +/// subscriptions across seed/probe/cleanup phases. +internal struct ProbeSubscriptionTemplate { + internal let id: String + internal let recordType: String + internal let firesOn: SubscriptionFireEvents + + internal func materialize() -> SubscriptionInfo { + .query( + subscriptionID: id, + recordType: recordType, + firesOn: firesOn + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift index fc38ab06..8c26294c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand+FilterParsing.swift @@ -27,12 +27,33 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit extension QueryCommand { + /// Comparison operators keyed by every spelling we accept, each mapped to + /// the matching ``QueryFilter`` case. A lookup table replaces a `switch` + /// that tripped `cyclomatic_complexity`. + private static let comparisonFilterBuilders: + [String: @Sendable (String, FieldValue) -> QueryFilter] = [ + "eq": QueryFilter.equals, + "equals": QueryFilter.equals, + "==": QueryFilter.equals, + "=": QueryFilter.equals, + "ne": QueryFilter.notEquals, + "not_equals": QueryFilter.notEquals, + "!=": QueryFilter.notEquals, + "gt": QueryFilter.greaterThan, + ">": QueryFilter.greaterThan, + "gte": QueryFilter.greaterThanOrEquals, + ">=": QueryFilter.greaterThanOrEquals, + "lt": QueryFilter.lessThan, + "<": QueryFilter.lessThan, + "lte": QueryFilter.lessThanOrEquals, + "<=": QueryFilter.lessThanOrEquals, + ] + /// Parse a single filter expression "field:operator:value" into a QueryFilter - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal static func parseFilter(_ filterString: String) throws -> QueryFilter { let components = filterString.split( separator: ":", maxSplits: 2, omittingEmptySubsequences: false @@ -54,7 +75,6 @@ extension QueryCommand { } /// Build a QueryFilter from parsed components. - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal static func buildFilter( field: String, operatorString: String, @@ -71,37 +91,19 @@ extension QueryCommand { } /// Build comparison-based filters (equals, not equals, greater/less than). - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - // swiftlint:disable:next cyclomatic_complexity internal static func buildComparisonFilter( field: String, operatorString: String, value: String ) -> QueryFilter? { - switch operatorString.lowercased() { - case "eq", "equals", "==", "=": - return .equals(field, inferFieldValue(value)) - case "ne", "not_equals", "!=": - return .notEquals(field, inferFieldValue(value)) - case "gt", ">": - return .greaterThan(field, inferFieldValue(value)) - case "gte", ">=": - return .greaterThanOrEquals( - field, inferFieldValue(value) - ) - case "lt", "<": - return .lessThan(field, inferFieldValue(value)) - case "lte", "<=": - return .lessThanOrEquals( - field, inferFieldValue(value) - ) - default: + guard let makeFilter = comparisonFilterBuilders[operatorString.lowercased()] + else { return nil } + return makeFilter(field, inferFieldValue(value)) } /// Build string and list-based filters. - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) internal static func buildSpecialFilter( field: String, operatorString: String, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index 4d0a9ea2..a0e7ebc7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to query Note records from CloudKit with filtering and sorting public struct QueryCommand: MistDemoCommand, OutputFormatting { @@ -71,28 +71,17 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { // Build filters // NOTE: Zone, offset, and continuation marker support require // enhancements to CloudKitService.queryRecords method (GitHub issues #145, #146) - let recordInfos: [RecordInfo] - if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { - let filters: [QueryFilter]? = - config.filters.isEmpty - ? nil - : try config.filters.map { try Self.parseFilter($0) } - recordInfos = try await client.queryRecords( - recordType: config.recordType, - filters: filters, - sortBy: nil, - limit: config.limit, - database: config.base.database - ) - } else { - recordInfos = try await client.queryRecords( - recordType: config.recordType, - filters: nil, - sortBy: nil, - limit: config.limit, - database: config.base.database - ) - } + let filters: [QueryFilter]? = + config.filters.isEmpty + ? nil + : try config.filters.map { try Self.parseFilter($0) } + let recordInfos = try await client.queryRecords( + recordType: config.recordType, + filters: filters, + sortBy: nil, + limit: config.limit, + database: config.base.database + ) // Format and output results try await outputResults(recordInfos, format: config.output) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift new file mode 100644 index 00000000..23951864 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/RegisterTokenCommand.swift @@ -0,0 +1,103 @@ +// +// RegisterTokenCommand.swift +// MistDemo +// +// 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 MistKit + +// `helpText` below is a multi-line string whose option column doesn't align +// with Swift's indent steps; the rule isn't useful inside literal help text. +// swiftlint:disable indentation_width + +/// Command for `tokens/register`. Registers a device's APNs token so CloudKit +/// delivers subscription-triggered pushes to it. Per Apple's `RegisterTokens.html` +/// REST reference, the request requires both the hex token and the APNs +/// environment it targets. +public struct RegisterTokenCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = RegisterTokenConfig + /// The command name. + public static let commandName = "register-token" + /// The command abstract. + public static let abstract = "Register a device APNs token with CloudKit" + /// The command help text. + public static let helpText = """ + REGISTER-TOKEN - Register a device APNs token with CloudKit + + USAGE: + mistdemo register-token --apns-token [--apns-environment ] \ + [--client-id ] + + OPTIONS: + --apns-token APNs device token (hex string) from a device + --apns-environment APNs environment, default development + --client-id Logical CloudKit client identifier — reuse + the value passed to create-token to tie the + two halves to the same logical client + --database Database to target + + EXAMPLES: + mistdemo register-token --apns-token 0a1b2c3d... \ + --apns-environment development --database private + """ + + private let config: RegisterTokenConfig + + /// Creates a new instance. + public init(config: RegisterTokenConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + guard let apnsToken = config.apnsToken, !apnsToken.isEmpty else { + throw TokenCommandError.missingAPNsToken + } + let environment = try resolveEnvironment() + let service = try MistKitClientFactory.create(for: config.base) + try await service.registerAPNsToken( + apnsToken, + environment: environment, + clientId: config.clientId, + database: config.base.database + ) + print("✅ Registered APNs token with CloudKit.") + } + + private func resolveEnvironment() throws -> APNsEnvironment { + guard let raw = config.apnsEnvironment else { + return .development + } + guard let environment = APNsEnvironment(rawValue: raw) else { + throw TokenCommandError.invalidEnvironment(raw) + } + return environment + } +} + +// swiftlint:enable indentation_width diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/RereferenceAssetCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/RereferenceAssetCommand.swift new file mode 100644 index 00000000..28655822 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/RereferenceAssetCommand.swift @@ -0,0 +1,109 @@ +// +// RereferenceAssetCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command for `assets/rereference`. Reuses an existing CloudKit asset +/// descriptor from one record on another, avoiding a second upload of the +/// bytes. +public struct RereferenceAssetCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = RereferenceAssetConfig + /// The command name. + public static let commandName = "rereference-asset" + /// The command abstract. + public static let abstract = "Re-reference an asset across records" + /// The command help text. + public static let helpText = """ + REREFERENCE-ASSET - Re-reference an existing asset across records + + USAGE: + mistdemo rereference-asset \\ + --source-record --asset-field \\ + --target-record [--target-asset-field ] + + OPTIONS: + --source-record Record name whose asset to reuse + --asset-field Field on the source record holding the asset + --target-record Record name receiving the asset reference + --target-asset-field Field on the target record (defaults to --asset-field) + --database Database to target + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo rereference-asset \\ + --source-record note-a --asset-field image \\ + --target-record note-b + """ + + private let config: RereferenceAssetConfig + + /// Creates a new instance. + public init(config: RereferenceAssetConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + guard let sourceRecord = config.sourceRecord else { + throw RereferenceAssetError.sourceRecordRequired + } + guard let assetField = config.assetField else { + throw RereferenceAssetError.assetFieldRequired + } + guard let targetRecord = config.targetRecord else { + throw RereferenceAssetError.targetRecordRequired + } + + print("\n" + String(repeating: "=", count: 60)) + print("📎 Re-reference Asset") + print(String(repeating: "=", count: 60)) + print(" Source: \(sourceRecord).\(assetField)") + print(" Target: \(targetRecord).\(config.targetAssetField ?? assetField)") + + do { + let service = try MistKitClientFactory.create(for: config.base) + let record = try await service.rereferenceAsset( + fromRecord: sourceRecord, + field: assetField, + toRecord: targetRecord, + field: config.targetAssetField, + database: config.base.database + ) + try await outputResult(record, format: config.output) + } catch let error as CloudKitError { + throw RereferenceAssetError.operationFailed(error.localizedDescription) + } + + print("\n" + String(repeating: "=", count: 60)) + print("✅ Re-reference completed!") + print(String(repeating: "=", count: 60)) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/RereferenceAssetError.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/RereferenceAssetError.swift new file mode 100644 index 00000000..9e6cdb22 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/RereferenceAssetError.swift @@ -0,0 +1,66 @@ +// +// RereferenceAssetError.swift +// MistDemo +// +// 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. +// + +public import Foundation + +/// CLI-layer errors for MistDemo's `rereference-asset` command. +/// +/// This type lives in MistDemo, not MistKit, on purpose. MistKit's +/// `CloudKitService.rereferenceAsset(...)` already `throws(CloudKitError)` for +/// every domain/transport failure; this enum does not duplicate or replace it. +/// Instead it covers concerns specific to the command-line front end: +/// - `sourceRecordRequired` / `assetFieldRequired` / `targetRecordRequired` +/// validate command-line arguments *before* MistKit is invoked. +/// - `operationFailed` wraps a `CloudKitError`'s `localizedDescription` caught +/// from `rereferenceAsset(...)` (see `RereferenceAssetCommand`), surfacing it +/// as a flat user-facing message. +/// +/// This mirrors the established demo pattern shared by `DeleteError`, +/// `UploadAssetError`, etc. — "input-validation cases + `operationFailed(String)` +/// wrapper" — which keeps CLI presentation concerns out of the library. +public enum RereferenceAssetError: Error, LocalizedError { + case sourceRecordRequired + case assetFieldRequired + case targetRecordRequired + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .sourceRecordRequired: + return "Source record is required. Specify with --source-record " + case .assetFieldRequired: + return "Asset field is required. Specify with --asset-field " + case .targetRecordRequired: + return "Target record is required. Specify with --target-record " + case .operationFailed(let message): + return "Re-reference operation failed: \(message)" + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift new file mode 100644 index 00000000..60fc4bea --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift @@ -0,0 +1,74 @@ +// +// ResolveCommand.swift +// MistDemo +// +// 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 + +/// Stub command for `records/resolve`. Resolves a share URL or record +/// reference to a CloudKit record. The MistKit Swift wrapper is tracked +/// in #41; until that lands, this command prints the standard pending +/// banner and exits 0 so the `--help` shape is discoverable today. +public struct ResolveCommand: MistDemoCommand { + /// The configuration type. + public typealias Config = ResolveConfig + /// The command name. + public static let commandName = "resolve" + /// The command abstract. + public static let abstract = "Resolve a share URL or record reference (pending #41)" + /// The command help text. + public static let helpText = """ + RESOLVE - Resolve a share URL or record reference + + USAGE: + mistdemo resolve --share-url [options] + mistdemo resolve --record-name [options] + + INPUT (choose one): + --share-url Share URL to resolve + --record-name Record name to resolve + + OPTIONS: + --database Database to target + --output-format Output format (json, table, csv, yaml) + + STATUS: + Not yet implemented — pending MistKit support, tracked in #41. + """ + + private let config: ResolveConfig + + /// Creates a new instance. + public init(config: ResolveConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + PendingStub.printPending(endpoint: "records/resolve", trackingIssue: 41) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index b3b701c9..a86ad306 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -27,8 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import Logging +internal import MistKit /// Command to run comprehensive integration tests against the private database, /// covering all CloudKit API methods including user-identity endpoints. @@ -80,6 +81,9 @@ public struct TestPrivateCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { + if config.verbose { + MistDemoLoggingBootstrap.bootstrapOnce() + } let service = try MistKitClientFactory.create(for: config.base) // Private-database flows always carry web-auth credentials, so the same // service can also serve user-identity routes when this command needs diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift index 408114b8..0aa801e7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift @@ -27,8 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import Logging +internal import MistKit /// Command to run comprehensive integration tests for all CloudKit operations public struct TestPublicCommand: MistDemoCommand { @@ -79,6 +80,9 @@ public struct TestPublicCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { + if config.verbose { + MistDemoLoggingBootstrap.bootstrapOnce() + } let service = try MistKitClientFactory.create(for: config.base) // A single service handles every phase: server-to-server signing on // `.public` for record ops, plus web-auth for user-identity routes when diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift index eabce926..9fd3c493 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UpdateCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to update an existing record in CloudKit public struct UpdateCommand: MistDemoCommand, OutputFormatting { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift index 84c0b683..8d87a9aa 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/UploadAssetCommand.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Command to upload binary assets to CloudKit public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { @@ -202,7 +202,9 @@ public struct UploadAssetCommand: MistDemoCommand, OutputFormatting { recordNames: [recordName], database: config.base.database ) - guard let existingRecord = existingRecords.first else { + guard let firstResult = existingRecords.first, + case .success(let existingRecord) = firstResult + else { throw UploadAssetError.operationFailed( "Record '\(recordName)' not found" ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ValidateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ValidateCommand.swift new file mode 100644 index 00000000..0a3e7e8f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ValidateCommand.swift @@ -0,0 +1,161 @@ +// +// ValidateCommand.swift +// MistDemo +// +// 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 MistKit + +/// Command that validates the local CloudKit credential configuration and +/// optionally exercises a live API round-trip. +public struct ValidateCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = ValidateConfig + /// The command name. + public static let commandName = "validate" + /// The command abstract. + public static let abstract = "Validate CloudKit credentials and reachability" + /// The command help text. + public static let helpText = """ + VALIDATE - Validate CloudKit credentials and reachability + + USAGE: + mistdemo validate [options] + + OPTIONS: + --validate-skip-network Only parse credentials; skip network call. + --validate-test-query Also run a minimal listZones query. + --output-format Output format (json, table, csv, yaml). + + EXIT CODES: + 0 Validation succeeded. + 1 One or more checks failed; see structured `errors` in output. + + NOTES: + - `fetchCaller` (the network check) requires API + web-auth + credentials. With server-to-server only, the network check is + skipped automatically. + - With --validate.skip-network, only the parse-time check runs. + """ + + private let config: ValidateConfig + + /// Creates a new instance. + public init(config: ValidateConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + var errors: [String] = [] + let service = makeService(into: &errors) + let userInfo = await fetchCallerIfPossible( + service: service, + errors: &errors + ) + let zonesFound = await runTestQueryIfRequested( + service: service, + errors: &errors + ) + + let result = ValidationResult( + credentialsValid: service != nil, + webAuthConfigured: config.base.hasUserContextCredentials, + serverToServerConfigured: config.base.hasServerToServerCredentials, + userInfo: userInfo, + zonesFound: zonesFound, + errors: errors + ) + try await emit(result) + + if !errors.isEmpty { + throw ValidateError(reason: errors.joined(separator: "; ")) + } + } + + /// Emit the validation result. JSON output is written through + /// `FileHandle.standardOutput` directly so callers piping the output + /// still see structured JSON even when this command throws afterwards + /// (Swift's `print()` is fully buffered when stdout is not a TTY, and + /// the fatal-error path that handles the throw doesn't flush the buffer). + private func emit(_ result: ValidationResult) async throws { + guard config.output == .json else { + try await outputResult(result, format: config.output) + return + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data("\n".utf8)) + } + + private func makeService(into errors: inout [String]) -> CloudKitService? { + do { + return try MistKitClientFactory.create(for: config.base) + } catch { + errors.append(error.localizedDescription) + return nil + } + } + + private func fetchCallerIfPossible( + service: CloudKitService?, + errors: inout [String] + ) async -> UserInfo? { + guard let service, + !config.skipNetwork, + config.base.hasUserContextCredentials + else { + return nil + } + do { + return try await service.fetchCaller() + } catch { + errors.append("fetchCaller failed: \(error.localizedDescription)") + return nil + } + } + + private func runTestQueryIfRequested( + service: CloudKitService?, + errors: inout [String] + ) async -> Int? { + guard let service, config.testQuery else { + return nil + } + do { + let zones = try await service.listZones(database: config.base.database) + return zones.count + } catch { + errors.append( + "Test query (listZones) failed: \(error.localizedDescription)" + ) + return nil + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift index 856b03b7..632b20a9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation public import MistKit /// Configuration for auth-token command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift index 5c39f1d2..9a1bdb3c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Resolves the "should we open the browser on startup?" decision from /// the two mutually-exclusive CLI flags into a single boolean. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift index b9eb0e66..d1a7377e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ConfigurationError.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Configuration errors. internal enum ConfigurationError: LocalizedError { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift index c7bb3880..32a8e27e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation public import MistKit /// Configuration for create command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift new file mode 100644 index 00000000..076f2d78 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift @@ -0,0 +1,98 @@ +// +// CreateTokenConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `create-token` command. +public struct CreateTokenConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// APNs device token (hex string). + public let apnsToken: String? + /// APNs environment (development, production). + public let apnsEnvironment: String? + /// Optional logical CloudKit client identifier. Reuse the same value when + /// calling `register-token` later to tie the two halves to a single + /// logical client. + public let clientId: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + apnsToken: String? = nil, + apnsEnvironment: String? = nil, + clientId: String? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.apnsToken = apnsToken + self.apnsEnvironment = apnsEnvironment + self.clientId = clientId + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "json" + ) ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + apnsToken: configuration.string(forKey: "apns-token"), + apnsEnvironment: configuration.string(forKey: "apns-environment"), + clientId: configuration.string(forKey: "client-id"), + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift new file mode 100644 index 00000000..d00010eb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift @@ -0,0 +1,101 @@ +// +// CreateZoneConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for create-zone command. +public struct CreateZoneConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The zone name to create. + public let zoneName: String + /// Optional owner record name (typically nil for the caller's own zones). + public let ownerRecordName: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zoneName: String, + ownerRecordName: String? = nil, + output: OutputFormat = .table + ) { + self.base = base + self.zoneName = zoneName + self.ownerRecordName = ownerRecordName + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + guard + let zoneName = configuration.string(forKey: "zone.name"), + !zoneName.isEmpty + else { + throw ConfigurationError.missingRequired( + "zone.name", + suggestion: "Pass --zone-name ." + ) + } + + let ownerRecordName = configuration.string(forKey: "zone.owner") + + let outputString = + configuration.string(forKey: "output.format", default: "table") + ?? "table" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init( + base: baseConfig, + zoneName: zoneName, + ownerRecordName: ownerRecordName, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift index 17f23d98..bc806007 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation public import MistKit /// Configuration for current-user command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift index 80adf6f4..2a6ed4bf 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation /// Configuration for delete command. public struct DeleteConfig: Sendable, ConfigurationParseable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift new file mode 100644 index 00000000..d98988eb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift @@ -0,0 +1,101 @@ +// +// DeleteZoneConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for delete-zone command. +public struct DeleteZoneConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The zone name to delete. + public let zoneName: String + /// Optional owner record name (typically nil for the caller's own zones). + public let ownerRecordName: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + zoneName: String, + ownerRecordName: String? = nil, + output: OutputFormat = .table + ) { + self.base = base + self.zoneName = zoneName + self.ownerRecordName = ownerRecordName + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + guard + let zoneName = configuration.string(forKey: "zone.name"), + !zoneName.isEmpty + else { + throw ConfigurationError.missingRequired( + "zone.name", + suggestion: "Pass --zone-name ." + ) + } + + let ownerRecordName = configuration.string(forKey: "zone.owner") + + let outputString = + configuration.string(forKey: "output.format", default: "table") + ?? "table" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init( + base: baseConfig, + zoneName: zoneName, + ownerRecordName: ownerRecordName, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift index f2187d4c..8084bb00 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation /// Configuration for demo-errors command. public struct DemoErrorsConfig: Sendable, ConfigurationParseable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsError.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsError.swift index 10568921..2a9f5808 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsError.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Errors specific to the demo-errors command's configuration parsing. internal enum DemoErrorsError: LocalizedError { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift new file mode 100644 index 00000000..f48fb494 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift @@ -0,0 +1,134 @@ +// +// DiscoverConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation +public import MistKit + +/// Configuration for the `discover` command (email lookup). +public struct DiscoverConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The email addresses to look up. + public let emails: [String] + /// Maximum items per request for the auto-chunking `discover-all` command + /// (the plain `discover` command ignores it). + public let batchSize: Int + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + emails: [String], + batchSize: Int = CloudKitService.maxRecordsPerRequest, + output: OutputFormat = .json + ) { + self.base = base + self.emails = emails + self.batchSize = batchSize + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let emails = Self.parseEmails(from: configuration) + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + let batchSize = + configuration.int( + forKey: MistDemoConstants.ConfigKeys.batchSize, + default: CloudKitService.maxRecordsPerRequest + ) ?? CloudKitService.maxRecordsPerRequest + + self.init( + base: baseConfig, + emails: emails, + batchSize: batchSize, + output: output + ) + } + + /// Parse emails from the `discover.emails` key (comma-separated) or stdin + /// (one address per line) when `--stdin` is set. + internal static func parseEmails( + from configuration: MistDemoConfiguration + ) -> [String] { + if let raw = configuration.string(forKey: "discover.emails"), + !raw.isEmpty + { + return + raw + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + if configuration.bool( + forKey: MistDemoConstants.ConfigKeys.stdin, + default: false + ) { + let stdinData = FileHandle.standardInput.readDataToEndOfFile() + guard let raw = String(data: stdinData, encoding: .utf8) else { + return [] + } + return + raw + .split(whereSeparator: { $0.isNewline }) + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + return [] + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Field.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Field.swift index b94b8ec9..1df3bd22 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Field.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Field.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Field definition for create operations. public struct Field: Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldType.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldType.swift index 2eafc824..213934cf 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldType.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FieldType.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Supported field types for CloudKit records. public enum FieldType: String, CaseIterable, Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift new file mode 100644 index 00000000..f5bc571c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift @@ -0,0 +1,78 @@ +// +// ListSubscriptionsConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `list-subscriptions` command. +public struct ListSubscriptionsConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + output: OutputFormat = .table + ) { + self.base = base + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "table" + ) ?? "table" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init(base: baseConfig, output: output) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift new file mode 100644 index 00000000..d4206f9e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift @@ -0,0 +1,92 @@ +// +// ListZonesConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `list-zones` command. +public struct ListZonesConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// If true, include `_defaultZone` in the listing; otherwise it's filtered + /// out so only custom zones are shown. + public let includeDefault: Bool + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + includeDefault: Bool = false, + output: OutputFormat = .table + ) { + self.base = base + self.includeDefault = includeDefault + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let includeDefault = configuration.bool( + forKey: "zones.include-default", + default: false + ) + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "table" + ) ?? "table" + let output = OutputFormat(rawValue: outputString) ?? .table + + self.init( + base: baseConfig, + includeDefault: includeDefault, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift index 9c51fbbc..edc14289 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift @@ -28,7 +28,8 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation +public import MistKit /// Configuration for lookup command. public struct LookupConfig: Sendable, ConfigurationParseable { @@ -43,6 +44,9 @@ public struct LookupConfig: Sendable, ConfigurationParseable { public let recordNames: [String] /// The optional field names to include in the response. public let fields: [String]? + /// Maximum items per request for the auto-chunking `lookup-all` command + /// (the plain `lookup` command ignores it). + public let batchSize: Int /// The output format. public let output: OutputFormat @@ -51,11 +55,13 @@ public struct LookupConfig: Sendable, ConfigurationParseable { base: MistDemoConfig, recordNames: [String], fields: [String]? = nil, + batchSize: Int = CloudKitService.maxRecordsPerRequest, output: OutputFormat = .json ) { self.base = base self.recordNames = recordNames self.fields = fields + self.batchSize = batchSize self.output = output } @@ -111,10 +117,17 @@ public struct LookupConfig: Sendable, ConfigurationParseable { ) ?? MistDemoConstants.Defaults.outputFormat let output = OutputFormat(rawValue: outputString) ?? .json + let batchSize = + configReader.int( + forKey: MistDemoConstants.ConfigKeys.batchSize, + default: CloudKitService.maxRecordsPerRequest + ) ?? CloudKitService.maxRecordsPerRequest + self.init( base: baseConfig, recordNames: recordNames, fields: fields, + batchSize: batchSize, output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift new file mode 100644 index 00000000..ac5e23b2 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift @@ -0,0 +1,93 @@ +// +// LookupSubscriptionConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `lookup-subscription` command. +public struct LookupSubscriptionConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The subscription IDs to look up. + public let subscriptionIDs: [String] + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + subscriptionIDs: [String] = [], + output: OutputFormat = .json + ) { + self.base = base + self.subscriptionIDs = subscriptionIDs + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let idsString = configuration.string(forKey: "subscription-ids") ?? "" + let subscriptionIDs = + idsString + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "json" + ) ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + subscriptionIDs: subscriptionIDs, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift index c1542073..f85215ba 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation /// Configuration for lookup-zones command. public struct LookupZonesConfig: Sendable, ConfigurationParseable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift index 243e5f58..36d4f673 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+DatabaseConfiguration.swift @@ -40,6 +40,13 @@ extension MistDemoConfig { (try? resolveAPICredentials()) != nil } + /// Indicates whether server-to-server signing material (key ID + private + /// key) is present in the configuration. Required to sign `.public` + /// database requests. + internal var hasServerToServerCredentials: Bool { + (try? resolveServerToServerCredentials()) != nil + } + /// Build `Credentials` for the primary `CloudKitService` targeting /// `self.database`. /// diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift index d88d4af5..a793b4c6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import MistKit +internal import MistKit extension MistDemoConfig { internal struct CoreConfig { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift index 64fe379a..9b307edb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -28,8 +28,8 @@ // public import ConfigKeyKit -import Configuration -import Foundation +internal import Configuration +internal import Foundation public import MistKit /// Centralized configuration for MistDemo. @@ -39,6 +39,9 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { /// The base configuration type. public typealias BaseConfig = Never + /// CloudKit record type for integration tests. + internal static let recordType = "Note" + // MARK: - CloudKit Core Configuration /// CloudKit container identifier. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift index 536402fe..cccb8c15 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift @@ -27,9 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Configuration -import Foundation -import SystemPackage +internal import Configuration +internal import Foundation +internal import SystemPackage /// Swift Configuration-based setup for MistDemo. public struct MistDemoConfiguration: Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift new file mode 100644 index 00000000..fc5c04d8 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift @@ -0,0 +1,108 @@ +// +// ModifySubscriptionsConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `modify-subscriptions` command. +public struct ModifySubscriptionsConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The operation to apply (`create` or `delete`). Defaults to `create`. + public let operation: String + /// The subscription ID to create or delete. + public let subscriptionID: String? + /// The record type for a `create` query subscription. + public let recordType: String? + /// Comma-separated fire events (`create`, `update`, `delete`). + public let firesOn: [String] + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + operation: String = "create", + subscriptionID: String? = nil, + recordType: String? = nil, + firesOn: [String] = ["create", "update", "delete"], + output: OutputFormat = .json + ) { + self.base = base + self.operation = operation + self.subscriptionID = subscriptionID + self.recordType = recordType + self.firesOn = firesOn + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let firesOnString = configuration.string(forKey: "fires-on") ?? "create,update,delete" + let firesOn = + firesOnString + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "json" + ) ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + operation: configuration.string(forKey: "operation", default: "create") ?? "create", + subscriptionID: configuration.string(forKey: "subscription-id"), + recordType: configuration.string(forKey: "record-type"), + firesOn: firesOn, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift new file mode 100644 index 00000000..b9a9d8f7 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift @@ -0,0 +1,140 @@ +// +// ModifyZonesConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +public import Foundation + +/// Configuration for the `modify-zones` command. +public struct ModifyZonesConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The list of zone operations to perform. + public let operations: [ZoneOperationInput] + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + operations: [ZoneOperationInput], + output: OutputFormat = .json + ) { + self.base = base + self.operations = operations + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let operations = try Self.parseOperationsFromSources(configuration) + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + operations: operations, + output: output + ) + } + + /// Parse a zone-operations JSON envelope from data. + /// + /// Accepts the envelope form `{ "operations": [...] }`. + public static func parseOperations( + from data: Data + ) throws -> [ZoneOperationInput] { + do { + let envelope = try JSONDecoder().decode( + ZoneOperationsEnvelope.self, + from: data + ) + return envelope.operations + } catch let error as ModifyZonesError { + throw error + } catch { + throw ModifyZonesError.parsingFailed(error.localizedDescription) + } + } + + private static func parseOperationsFromSources( + _ configReader: MistDemoConfiguration + ) throws -> [ZoneOperationInput] { + if let path = configReader.string( + forKey: MistDemoConstants.ConfigKeys.operationsFile + ) { + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return try parseOperations(from: data) + } catch let error as ModifyZonesError { + throw error + } catch { + throw ModifyZonesError.operationsFileError( + path, + error.localizedDescription + ) + } + } + + if configReader.bool( + forKey: MistDemoConstants.ConfigKeys.stdin, + default: false + ) { + let stdinData = FileHandle.standardInput.readDataToEndOfFile() + guard !stdinData.isEmpty else { + throw ModifyZonesError.emptyStdin + } + return try parseOperations(from: stdinData) + } + + throw ModifyZonesError.operationsRequired + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift new file mode 100644 index 00000000..e0f2bbde --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift @@ -0,0 +1,90 @@ +// +// ProbeDuplicateSubscriptionConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit + +/// Configuration for the `probe-duplicate-subscription` command. +public struct ProbeDuplicateSubscriptionConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The record type to use for the probe's query subscriptions. + public let recordType: String + /// An optional secondary record type used by the "different recordType" + /// negative-control experiment. Defaults to `"Article"`. + public let alternateRecordType: String + /// Verbose output (prints full raw `SubscriptionResult` for every probe). + public let verbose: Bool + + /// Creates a new instance. + public init( + base: MistDemoConfig, + recordType: String = "Note", + alternateRecordType: String = "Article", + verbose: Bool = false + ) { + self.base = base + self.recordType = recordType + self.alternateRecordType = alternateRecordType + self.verbose = verbose + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let recordType = + configuration.string(forKey: "record-type", default: "Note") ?? "Note" + let alternateRecordType = + configuration.string(forKey: "alternate-record-type", default: "Article") ?? "Article" + let verbose = configuration.bool(forKey: "verbose", default: false) + + self.init( + base: baseConfig, + recordType: recordType, + alternateRecordType: alternateRecordType, + verbose: verbose + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift index 1e49f23b..6d51e3c9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation extension QueryConfig { internal struct ParsedPagination { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift index 813dd081..f5873971 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation public import MistKit /// Configuration for query command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift new file mode 100644 index 00000000..3ddf574c --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift @@ -0,0 +1,100 @@ +// +// RegisterTokenConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `register-token` command. +public struct RegisterTokenConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// APNs device token (hex string) captured from a real device. + public let apnsToken: String? + /// APNs environment (development, production). Required by CloudKit per + /// Apple's `RegisterTokens.html` REST reference — defaults to `development` + /// when unset. + public let apnsEnvironment: String? + /// Optional logical CloudKit client identifier. When set, ties this + /// registration to a previously-minted `tokens/create` call sharing the + /// same `clientId`. + public let clientId: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + apnsToken: String? = nil, + apnsEnvironment: String? = nil, + clientId: String? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.apnsToken = apnsToken + self.apnsEnvironment = apnsEnvironment + self.clientId = clientId + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "json" + ) ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + apnsToken: configuration.string(forKey: "apns-token"), + apnsEnvironment: configuration.string(forKey: "apns-environment"), + clientId: configuration.string(forKey: "client-id"), + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift new file mode 100644 index 00000000..6a1d8e95 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift @@ -0,0 +1,101 @@ +// +// RereferenceAssetConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `rereference-asset` command. +public struct RereferenceAssetConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// Source record name whose asset is being reused. + public let sourceRecord: String? + /// Field on the source record holding the asset. + public let assetField: String? + /// Target record name receiving the asset reference. + public let targetRecord: String? + /// Field on the target record (defaults to `assetField`). + public let targetAssetField: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + sourceRecord: String? = nil, + assetField: String? = nil, + targetRecord: String? = nil, + targetAssetField: String? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.sourceRecord = sourceRecord + self.assetField = assetField + self.targetRecord = targetRecord + self.targetAssetField = targetAssetField + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "json" + ) ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + sourceRecord: configuration.string(forKey: "source-record"), + assetField: configuration.string(forKey: "asset-field"), + targetRecord: configuration.string(forKey: "target-record"), + targetAssetField: configuration.string(forKey: "target-asset-field"), + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift new file mode 100644 index 00000000..3bacfe31 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift @@ -0,0 +1,94 @@ +// +// ResolveConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `resolve` command. Parses the future argument shape +/// even though `ResolveCommand.execute()` is currently a `PendingStub` +/// — the `--help` text and argument names stabilize here so callers can +/// integrate against the real surface when #41 lands. +public struct ResolveConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// Optional share URL to resolve. + public let shareURL: String? + /// Optional record name to resolve. + public let recordName: String? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + shareURL: String? = nil, + recordName: String? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.shareURL = shareURL + self.recordName = recordName + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "json" + ) ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + shareURL: configuration.string(forKey: "share-url"), + recordName: configuration.string(forKey: "record-name"), + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift index d4a70ba6..9b03c3c2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import MistKit +internal import MistKit /// Configuration for test-private command (private database). public struct TestPrivateConfig: Sendable, ConfigurationParseable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift index 6597bdcf..214576e3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation public import MistKit /// Configuration for update command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift index 4b38d8c5..71f6366a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation public import MistKit /// Configuration for upload-asset command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift new file mode 100644 index 00000000..09a361c1 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift @@ -0,0 +1,102 @@ +// +// ValidateConfig.swift +// MistDemo +// +// 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. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `validate` command. +public struct ValidateConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// If true, only check that credentials parse — skip the live `fetchCaller` + /// round-trip. + public let skipNetwork: Bool + /// If true, also run a minimal `listZones` query against the configured + /// database to verify end-to-end reachability. + public let testQuery: Bool + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + skipNetwork: Bool = false, + testQuery: Bool = false, + output: OutputFormat = .json + ) { + self.base = base + self.skipNetwork = skipNetwork + self.testQuery = testQuery + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let skipNetwork = configuration.bool( + forKey: "validate.skip-network", + default: false + ) + let testQuery = configuration.bool( + forKey: "validate.test-query", + default: false + ) + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: MistDemoConstants.Defaults.outputFormat + ) ?? MistDemoConstants.Defaults.outputFormat + let output = OutputFormat(rawValue: outputString) ?? .json + + self.init( + base: baseConfig, + skipNetwork: skipNetwork, + testQuery: testQuery, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift index 8103853e..ebfd8557 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation public import MistKit /// Configuration for the long-running `web` demo command. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ZoneOperationInput.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ZoneOperationInput.swift new file mode 100644 index 00000000..3e68dbee --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ZoneOperationInput.swift @@ -0,0 +1,66 @@ +// +// ZoneOperationInput.swift +// MistDemo +// +// 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. +// + +public import Foundation +public import MistKit + +/// One zone operation parsed from the modify-zones JSON payload. +public struct ZoneOperationInput: Codable, Sendable, Equatable { + /// The operation kind ("create" or "delete"). + public let type: String + /// The CloudKit zone name. + public let zoneName: String + + /// Creates a new instance. + public init(type: String, zoneName: String) { + self.type = type + self.zoneName = zoneName + } + + /// Convert this operation input into a MistKit `ZoneOperation`. + /// + /// - Throws: `ModifyZonesError.invalidOperationType` for unknown `type` + /// values, or `.invalidZoneName` for empty / whitespace-only names. + public func toZoneOperation() throws -> ZoneOperation { + let trimmed = zoneName.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + throw ModifyZonesError.invalidZoneName(zoneName) + } + let zoneID = ZoneID(zoneName: trimmed, ownerName: nil) + + switch type.lowercased() { + case "create": + return .create(zoneID) + case "delete": + return .delete(zoneID) + default: + throw ModifyZonesError.invalidOperationType(type) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ZoneOperationsEnvelope.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ZoneOperationsEnvelope.swift new file mode 100644 index 00000000..d6c53588 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ZoneOperationsEnvelope.swift @@ -0,0 +1,42 @@ +// +// ZoneOperationsEnvelope.swift +// MistDemo +// +// 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 + +/// JSON envelope for the `modify-zones` payload: +/// `{ "operations": [ZoneOperationInput] }`. +public struct ZoneOperationsEnvelope: Codable, Sendable { + /// The list of zone operations. + public let operations: [ZoneOperationInput] + + /// Creates a new instance. + public init(operations: [ZoneOperationInput]) { + self.operations = operations + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift index 27e100cc..afdb1044 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation extension MistDemoConstants { /// Default values for configuration parameters. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Messages.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Messages.swift index 19eb5331..ee986f40 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Messages.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Messages.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation extension MistDemoConstants { /// User-facing messages. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift index d502dc34..a5c2d40d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Central constants for MistDemo application. public enum MistDemoConstants { @@ -81,6 +81,8 @@ public enum MistDemoConstants { public static let operationsFile = "operations.file" /// Atomic configuration key. public static let atomic = "atomic" + /// Batch size configuration key for the auto-chunking `*-all` commands. + public static let batchSize = "batch.size" } // MARK: - Field Names diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/DiscoverError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/DiscoverError.swift new file mode 100644 index 00000000..b78e7f9f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/DiscoverError.swift @@ -0,0 +1,65 @@ +// +// DiscoverError.swift +// MistDemo +// +// 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. +// + +public import Foundation + +/// Errors that can occur during discover command execution. +public enum DiscoverError: Error, LocalizedError { + case emailsRequired + case webAuthRequired + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .emailsRequired: + return + "No emails provided. Use --discover-emails or pipe " + + "one address per line to stdin." + case .webAuthRequired: + return + "discover requires API + web-auth credentials. Set " + + "CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN, or run " + + "`mistdemo auth-token` first." + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .emailsRequired: + return + "Pass --discover-emails alice@example.com,bob@example.com, " + + "or pipe addresses to stdin with --stdin." + case .webAuthRequired: + return + "User-identity routes (lookupUsersByEmail) are pinned to " + + "CloudKit's public DB and require web-auth." + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput+Convenience.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput+Convenience.swift index acadabe8..c24aa758 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput+Convenience.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput+Convenience.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation // MARK: - JSON Encoding diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput.swift index f40e0be5..bd2d5832 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ErrorOutput.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// JSON-formatted error output for consistent error reporting. public struct ErrorOutput: Sendable, Codable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/FieldConversionError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/FieldConversionError.swift index b9e26adc..2fd7c3bb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Errors/FieldConversionError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/FieldConversionError.swift @@ -28,7 +28,7 @@ // public import Foundation -import MistKit +internal import MistKit /// Errors that can occur during field conversion public enum FieldConversionError: Error, LocalizedError { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ListZonesError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ListZonesError.swift new file mode 100644 index 00000000..17c1f1ad --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ListZonesError.swift @@ -0,0 +1,53 @@ +// +// ListZonesError.swift +// MistDemo +// +// 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. +// + +public import Foundation + +/// Errors that can occur during list-zones command execution. +public enum ListZonesError: Error, LocalizedError { + case databaseNotSupported + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .databaseNotSupported: + return + "Zone listing requires --database private or --database shared. " + + "CloudKit's public database has no enumerable zones." + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .databaseNotSupported: + return "Rerun with --database private or --database shared." + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/MistDemoError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/MistDemoError.swift index 268d77fa..a31b47ba 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Errors/MistDemoError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/MistDemoError.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Comprehensive error type for MistDemo operations. internal enum MistDemoError: LocalizedError, Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ModifyZonesError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ModifyZonesError.swift new file mode 100644 index 00000000..4f79f3c3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ModifyZonesError.swift @@ -0,0 +1,92 @@ +// +// ModifyZonesError.swift +// MistDemo +// +// 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. +// + +public import Foundation + +/// Errors that can occur during modify-zones command execution. +public enum ModifyZonesError: Error, LocalizedError { + case databaseNotSupported + case operationsRequired + case operationsFileError(String, String) + case emptyStdin + case parsingFailed(String) + case invalidOperationType(String) + case invalidZoneName(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .databaseNotSupported: + return + "Zone modification requires --database private or --database shared. " + + "CloudKit's public database has no user-modifiable zones." + case .operationsRequired: + return + "No operations provided. " + + "Use --operations-file or pipe JSON to stdin." + case .operationsFileError(let path, let reason): + return "Failed to read operations file '\(path)': \(reason)" + case .emptyStdin: + return "Empty stdin. Provide a JSON object with `operations`." + case .parsingFailed(let reason): + return "Failed to parse zone operations: \(reason)" + case .invalidOperationType(let opType): + return + "Unknown zone operation type '\(opType)'. " + + "Use one of: create, delete." + case .invalidZoneName(let name): + return "Invalid zone name '\(name)'." + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .databaseNotSupported: + return "Rerun with --database private or --database shared." + case .operationsRequired: + return + "Provide JSON: --operations-file ops.json or " + + "echo '{\"operations\":[...]}' | mistdemo modify-zones --stdin" + case .operationsFileError: + return "Ensure the file exists and contains valid JSON." + case .emptyStdin: + return + "Pipe JSON: echo " + + "'{\"operations\":[{\"type\":\"create\",\"zoneName\":\"X\"}]}' " + + "| mistdemo modify-zones --stdin" + case .parsingFailed: + return "Check the JSON syntax and the schema of each operation." + case .invalidOperationType: + return "Set 'type' to 'create' or 'delete'." + case .invalidZoneName: + return "Provide a non-empty zone name without leading/trailing whitespace." + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/SubscriptionCommandError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/SubscriptionCommandError.swift new file mode 100644 index 00000000..ce767a0f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/SubscriptionCommandError.swift @@ -0,0 +1,56 @@ +// +// SubscriptionCommandError.swift +// MistDemo +// +// 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. +// + +public import Foundation + +/// Errors raised by the subscription commands before a CloudKit call is made. +public enum SubscriptionCommandError: Error, LocalizedError { + /// No subscription IDs were supplied to a lookup command. + case missingSubscriptionIDs + /// No subscription ID was supplied to a modify command. + case missingSubscriptionID + /// A `create` operation was requested without a `--record-type`. + case missingRecordType + /// An unrecognized `--operation` value was supplied. + case invalidOperation(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .missingSubscriptionIDs: + return "No subscription IDs supplied. Pass --subscription-ids ." + case .missingSubscriptionID: + return "No subscription ID supplied. Pass --subscription-id ." + case .missingRecordType: + return "Creating a query subscription requires --record-type ." + case .invalidOperation(let value): + return "Unknown operation '\(value)'. Use 'create' or 'delete'." + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/TokenCommandError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/TokenCommandError.swift new file mode 100644 index 00000000..81e220eb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/TokenCommandError.swift @@ -0,0 +1,48 @@ +// +// TokenCommandError.swift +// MistDemo +// +// 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. +// + +public import Foundation + +/// Errors raised by the APNs token commands before a CloudKit call is made. +public enum TokenCommandError: Error, LocalizedError { + /// An unrecognized `--apns-environment` value was supplied. + case invalidEnvironment(String) + /// No APNs token was supplied to the register command. + case missingAPNsToken + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .invalidEnvironment(let value): + return "Unknown APNs environment '\(value)'. Use 'development' or 'production'." + case .missingAPNsToken: + return "No APNs token supplied. Pass --apns-token ." + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ValidateError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ValidateError.swift new file mode 100644 index 00000000..467601e2 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ValidateError.swift @@ -0,0 +1,49 @@ +// +// ValidateError.swift +// MistDemo +// +// 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. +// + +public import Foundation + +/// Signals that the `validate` command's full pipeline did not pass. The +/// per-check failure messages are joined into `reason` and are also present +/// in the structured `ValidationResult.errors` already emitted to stdout — +/// this error exists purely to drive a non-zero exit code. +public struct ValidateError: Error, LocalizedError { + /// The joined reason(s) from the individual validation checks. + public let reason: String + + /// A localized description of the error. + public var errorDescription: String? { + "Validation failed: \(reason)" + } + + /// Creates a new instance. + public init(reason: String) { + self.reason = reason + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/Array+Field.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Array+Field.swift index d82556fd..a80f5993 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Extensions/Array+Field.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Array+Field.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation public import MistKit extension Array where Element == Field { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/Command+AnyCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Command+AnyCommand.swift index e422d33a..b2407e87 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Extensions/Command+AnyCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/Command+AnyCommand.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation /// Default implementation of createInstance for all MistDemo commands. extension Command where Config.ConfigReader == MistDemoConfiguration { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/ConfigKey+MistDemo.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/ConfigKey+MistDemo.swift index a6a9ee7d..5f93d250 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Extensions/ConfigKey+MistDemo.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/ConfigKey+MistDemo.swift @@ -28,7 +28,7 @@ // public import ConfigKeyKit -import Foundation +internal import Foundation // MARK: - MistDemo-Specific Config Key Helpers diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift index 6bf6a3ce..ccb5188e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation public import MistKit extension FieldValue { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/String+Padding.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/String+Padding.swift index 9da66b3f..1d4b85ef 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Extensions/String+Padding.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/String+Padding.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation extension String { /// Pad the string on the left to the given width. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/AssetUploadReceipt+PhaseState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/AssetUploadReceipt+PhaseState.swift index a994e9c8..94d569ce 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/AssetUploadReceipt+PhaseState.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/AssetUploadReceipt+PhaseState.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit extension AssetUploadReceipt: PhaseStateDecodable, PhaseStateEncodable { internal init(from state: PhaseState) throws { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/CleanupPhaseMarker.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/CleanupPhaseMarker.swift index 285e6ef1..2b2f260f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/CleanupPhaseMarker.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/CleanupPhaseMarker.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Marker protocol identifying the cleanup phase so the runner can skip it /// when `--skip-cleanup` is set and re-run it on failure. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/CreatedRecordNames.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/CreatedRecordNames.swift index f7508863..1bc7e979 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/CreatedRecordNames.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/CreatedRecordNames.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Wraps the `createdRecordNames` slot of `PhaseState`. internal struct CreatedRecordNames: PhaseStateDecodable, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IncrementalSyncInput.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IncrementalSyncInput.swift index 468b3841..c47b2a27 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IncrementalSyncInput.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IncrementalSyncInput.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Composite input read by `IncrementalSyncPhase`. internal struct IncrementalSyncInput: PhaseStateDecodable, Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationPhase.swift index 9194b8b6..cde5eb72 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// A single step in an integration test. /// diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTest.swift index 40e8012c..d0472fe2 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTest.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// An integration test scenario -- typically one per CloudKit database. internal protocol IntegrationTest { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestData.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestData.swift deleted file mode 100644 index d5abeb00..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestData.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// IntegrationTestData.swift -// MistDemo -// -// 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. -// - -import Foundation - -/// Test data generation utilities for integration tests. -internal enum IntegrationTestData { - /// CloudKit record type for integration tests. - internal static let recordType = "MistKitIntegrationTest" - - /// Generate minimal PNG-like binary data for upload testing. - /// - /// Produces data with a valid PNG signature, IHDR, IDAT, and IEND structure, - /// but padding chunks use zeroed CRC32 values (invalid). Not standards-compliant - /// and will be rejected by PNG decoders; suitable only as raw binary test payloads. - /// - Parameter sizeKB: Desired size in kilobytes (default: 10) - /// - Returns: PNG-like binary data - internal static func generateTestImage(sizeKB: Int = 10) -> Data { - // Minimal valid 1x1 pixel PNG - // PNG signature - var data = Data([ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, - ]) - - // IHDR chunk (image header) for 1x1 pixel RGBA image - let ihdrData: [UInt8] = [ - 0x00, 0x00, 0x00, 0x0D, // Chunk length: 13 bytes - 0x49, 0x48, 0x44, 0x52, // Chunk type: "IHDR" - 0x00, 0x00, 0x00, 0x01, // Width: 1 - 0x00, 0x00, 0x00, 0x01, // Height: 1 - 0x08, // Bit depth: 8 - 0x06, // Color type: RGBA - 0x00, // Compression: deflate - 0x00, // Filter: adaptive - 0x00, // Interlace: none - 0x1F, 0x15, 0xC4, 0x89, // CRC32 checksum - ] - data.append(contentsOf: ihdrData) - - // IDAT chunk (image data) - minimal compressed pixel data - let idatData: [UInt8] = [ - 0x00, 0x00, 0x00, 0x0C, // Chunk length: 12 bytes - 0x49, 0x44, 0x41, 0x54, // Chunk type: "IDAT" - 0x08, 0x1D, 0x01, 0x02, 0x00, 0xFD, 0xFF, // Compressed data - 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, - 0xE2, 0x21, 0xBC, 0x33, // CRC32 checksum - ] - data.append(contentsOf: idatData) - - // IEND chunk (image trailer) - let iendData: [UInt8] = [ - 0x00, 0x00, 0x00, 0x00, // Chunk length: 0 - 0x49, 0x45, 0x4E, 0x44, // Chunk type: "IEND" - 0xAE, 0x42, 0x60, 0x82, // CRC32 checksum - ] - data.append(contentsOf: iendData) - - // Pad to requested size with additional IDAT chunks if needed - let targetSize = sizeKB * 1_024 - while data.count < targetSize { - // Add padding IDAT chunks - let remainingBytes = targetSize - data.count - let chunkSize = min(8_192, remainingBytes - 12) // Leave room for chunk overhead - - if chunkSize <= 0 { - break - } - - // Chunk length (4 bytes) - let lengthBytes: [UInt8] = [ - UInt8((chunkSize >> 24) & 0xFF), - UInt8((chunkSize >> 16) & 0xFF), - UInt8((chunkSize >> 8) & 0xFF), - UInt8(chunkSize & 0xFF), - ] - data.append(contentsOf: lengthBytes) - - // Chunk type: "IDAT" - data.append(contentsOf: [0x49, 0x44, 0x41, 0x54]) - - // Padding data - data.append(contentsOf: Array(repeating: UInt8(0x00), count: chunkSize)) - - // Simple CRC32 (not accurate, but sufficient for test data) - data.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) - } - - return data - } -} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift index 75b13aaa..527baeff 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Errors that can occur during integration testing. internal enum IntegrationTestError: LocalizedError, Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift index 0a23d6d6..f0a60bbc 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Thin façade that builds a `PhaseContext` from CLI configuration and /// dispatches to the appropriate `PhasedIntegrationTest` implementation. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/NoState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/NoState.swift index 84ee5a98..20071d5f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/NoState.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/NoState.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Sentinel used as `Input` or `Output` when a phase consumes or produces /// no `PhaseState`. Stands in for `Void`, which cannot conform to protocols. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PNGData.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PNGData.swift new file mode 100644 index 00000000..ab312686 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PNGData.swift @@ -0,0 +1,182 @@ +// +// PNGData.swift +// MistDemo +// +// 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 + +/// Minimal, dependency-free PNG encoder for solid-color images. +/// +/// Built entirely from `Data` — correct per-chunk CRC-32 and a valid zlib +/// stream using uncompressed ("stored") DEFLATE blocks plus an Adler-32 +/// checksum — so the output renders in the CloudKit Dashboard and any standard +/// PNG decoder. No CoreGraphics/ImageIO dependency, so it stays cross-platform +/// (Linux/WASI). +internal enum PNGData { + /// Solid fill color (RGB) for the generated test image. + private static let fillRed: UInt8 = 0x34 + private static let fillGreen: UInt8 = 0x9F + private static let fillBlue: UInt8 = 0xE6 + + /// Encode a solid-color RGB image as a valid PNG. + /// + /// - Parameters: + /// - width: Image width in pixels. + /// - height: Image height in pixels. + /// - red: Red channel for every pixel. + /// - green: Green channel for every pixel. + /// - blue: Blue channel for every pixel. + /// - Returns: Valid PNG image data. + private static func encode( + width: Int, + height: Int, + red: UInt8, + green: UInt8, + blue: UInt8 + ) -> Data { + // Raw image data: each scanline is a filter-type byte (0 = None) followed + // by `width` RGB pixels. + var raw = [UInt8]() + raw.reserveCapacity(height * (1 + width * 3)) + for _ in 0.. Data { + let typeBytes = Array(type.utf8) + var data = Data() + data.append(contentsOf: bigEndianBytes(UInt32(payload.count))) + data.append(contentsOf: typeBytes) + data.append(contentsOf: payload) + data.append(contentsOf: bigEndianBytes(crc32(typeBytes + payload))) + return data + } + + /// Wrap raw bytes in a zlib stream using uncompressed DEFLATE blocks. + private static func zlibStored(_ raw: [UInt8]) -> [UInt8] { + var out: [UInt8] = [0x78, 0x01] // zlib header (CM=deflate, no preset dict) + + let maxBlock = 0xFFFF + var offset = 0 + if raw.isEmpty { + // A single empty, final stored block. + out.append(contentsOf: [0x01, 0x00, 0x00, 0xFF, 0xFF]) + } else { + while offset < raw.count { + let len = min(maxBlock, raw.count - offset) + let isFinal = offset + len >= raw.count + out.append(isFinal ? 0x01 : 0x00) // BFINAL + BTYPE=00 (stored) + let len16 = UInt16(len) + out.append(UInt8(len16 & 0xFF)) // LEN, little-endian + out.append(UInt8(len16 >> 8)) + out.append(UInt8(~len16 & 0xFF)) // NLEN = ~LEN + out.append(UInt8(~len16 >> 8)) + out.append(contentsOf: raw[offset..<(offset + len)]) + offset += len + } + } + + out.append(contentsOf: bigEndianBytes(adler32(raw))) // zlib trailer + return out + } + + /// Big-endian 4-byte representation of a `UInt32`. + private static func bigEndianBytes(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8(value & 0xFF), + ] + } + + /// PNG CRC-32 (polynomial 0xEDB88320) over the given bytes. + private static func crc32(_ bytes: [UInt8]) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in bytes { + crc ^= UInt32(byte) + for _ in 0..<8 { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : (crc >> 1) + } + } + return crc ^ 0xFFFF_FFFF + } + + /// Adler-32 checksum (the zlib stream trailer) over the given bytes. + private static func adler32(_ bytes: [UInt8]) -> UInt32 { + let modulus: UInt32 = 65_521 + var lowSum: UInt32 = 1 + var highSum: UInt32 = 0 + for byte in bytes { + lowSum = (lowSum + UInt32(byte)) % modulus + highSum = (highSum + lowSum) % modulus + } + return (highSum << 16) | lowSum + } + + /// Generate a real, decodable solid-color PNG for upload testing. + /// + /// Delegates the byte-level encoding to ``PNGEncoder``, which produces a + /// cross-platform (Linux/WASI), Dashboard-renderable PNG with no + /// CoreGraphics/ImageIO dependency. + /// + /// `sizeKB` is a size hint: the image is a square whose pixel dimensions are + /// scaled so the encoded PNG approximates the requested size. + /// + /// - Parameter sizeKB: Desired approximate size in kilobytes (default: 10) + /// - Returns: Valid PNG image data + internal static func generate(withSizeInKB sizeKB: Int = 10) -> Data { + let targetBytes = max(1, sizeKB) * 1_024 + // Raw image bytes ≈ height * (1 + width * 3) ≈ 3 * side² for a square. + let side = max(1, Int((Double(targetBytes) / 3.0).squareRoot().rounded())) + return encode( + width: side, height: side, red: fillRed, green: fillGreen, blue: fillBlue + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift index c5a8148c..7bf738f3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Shared dependencies and configuration available to every phase. internal struct PhaseContext: Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift index 506c1594..e674fa62 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseState.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Mutable state that flows between phases as the test progresses. /// diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateDecodable.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateDecodable.swift index cba96cbe..5ecd0297 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateDecodable.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateDecodable.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// A type that can be initialized from `PhaseState`. /// diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateEncodable.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateEncodable.swift index eb97a2e4..d5d41642 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateEncodable.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseStateEncodable.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// A type that can write itself into `PhaseState`. /// diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift index 3e483c75..a35f93b0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// An integration test composed of an ordered list of phases. /// @@ -122,7 +122,7 @@ extension PhasedIntegrationTest { print( " 3. Navigate to \(dbName) Database \u{2192} Records" ) - let recType = IntegrationTestData.recordType + let recType = MistDemoConfig.recordType print(" 4. Search for record type: \(recType)") } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift index 6016b9cc..915fef5d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CleanupPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct CleanupPhase: IntegrationPhase, CleanupPhaseMarker { internal typealias Input = CreatedRecordNames @@ -53,7 +53,7 @@ internal struct CleanupPhase: IntegrationPhase, CleanupPhaseMarker { let deleteOps = input.names.map { recordName in RecordOperation( operationType: .forceDelete, - recordType: IntegrationTestData.recordType, + recordType: MistDemoConfig.recordType, recordName: recordName ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift index ef527616..e4f3e35c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CreateRecordsPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct CreateRecordsPhase: IntegrationPhase { internal typealias Input = AssetUploadReceipt @@ -53,7 +53,7 @@ internal struct CreateRecordsPhase: IntegrationPhase { for recordIndex in 1...context.recordCount { let recordName = "mistkit-test-\(UUID().uuidString.lowercased())" let record = try await context.service.createRecord( - recordType: IntegrationTestData.recordType, + recordType: MistDemoConfig.recordType, recordName: recordName, fields: [ "title": .string("Test Record \(recordIndex)"), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift index f6fb5b4e..cd7ca11c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DiscoverUserIdentitiesPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Calls POST `/users/discover` to look up specific user identities. /// @@ -57,7 +57,7 @@ internal struct DiscoverUserIdentitiesPhase: IntegrationPhase { if context.verbose { for identity in identities { - if let name = identity.userRecordName { print(" - \(name)") } + if case .recordName(let name) = identity.userRecordName { print(" - \(name)") } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllZoneChangesPhase.swift new file mode 100644 index 00000000..67bd7581 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchAllZoneChangesPhase.swift @@ -0,0 +1,67 @@ +// +// FetchAllZoneChangesPhase.swift +// MistDemo +// +// 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 MistKit + +/// Exercises ``CloudKitService/fetchAllZoneChanges(syncToken:maxPages:database:)`` +/// against a live container. Failures are non-fatal (matching +/// ``FetchZoneChangesPhase``) so test pipelines with empty zone change feeds +/// don't fail the whole suite. +internal struct FetchAllZoneChangesPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Fetch all zone changes" + internal static let emoji = "🔁" + internal static let apiName = "fetchAllZoneChanges" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + do { + let (zones, token) = try await context.service.fetchAllZoneChanges( + database: context.database + ) + print("✅ Fetched \(zones.count) zone(s) across all pages") + if context.verbose { + for zone in zones { + print(" - \(zone.zoneName)") + } + if let token { + print(" Sync token: \(token.prefix(30))...") + } + } + } catch { + print("⚠️ fetchAllZoneChanges failed (non-fatal): \(error)") + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift index 327984e6..393e8829 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchCallerPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Calls `users/caller`, the user-context endpoint that replaced the deprecated /// `users/current`. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift index f0c96345..bd536a02 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FetchZoneChangesPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct FetchZoneChangesPhase: IntegrationPhase { internal typealias Input = NoState diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift index 639f9a93..d97afe2b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/FinalVerificationPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct FinalVerificationPhase: IntegrationPhase { internal typealias Input = NoState diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift index 4fc3ae3b..a7c3fda4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/IncrementalSyncPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct IncrementalSyncPhase: IntegrationPhase { internal typealias Input = IncrementalSyncInput diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift index 3a8ef274..86753e92 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/InitialSyncPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct InitialSyncPhase: IntegrationPhase { internal typealias Input = CreatedRecordNames diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift index 4f6881a8..1ca143cb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ListZonesPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct ListZonesPhase: IntegrationPhase { internal typealias Input = NoState diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift index 6f91ac79..23c0e6e4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct LookupRecordsPhase: IntegrationPhase { internal typealias Input = CreatedRecordNames @@ -48,10 +48,13 @@ internal struct LookupRecordsPhase: IntegrationPhase { print(" Looking up \(lookupNames.count) of \(input.names.count) record(s) by name") } - let records = try await context.service.lookupRecords( + let results = try await context.service.lookupRecords( recordNames: lookupNames, database: context.database ) + let records = results.compactMap { result in + if case .success(let record) = result { record } else { nil } + } print("✅ Looked up \(records.count) record(s)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift index 3dcca32e..858eafb0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByEmailPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Calls POST `/users/lookup/email`. /// @@ -78,7 +78,7 @@ internal struct LookupUsersByEmailPhase: IntegrationPhase { if context.verbose { for identity in identities { - if let name = identity.userRecordName { print(" - \(name)") } + if case .recordName(let name) = identity.userRecordName { print(" - \(name)") } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift index 3d3465c5..6c5b58c6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupUsersByRecordNamePhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Calls POST `/users/lookup/id` with the caller's own user record name to /// exercise the endpoint via a self-lookup. @@ -55,7 +55,7 @@ internal struct LookupUsersByRecordNamePhase: IntegrationPhase { if context.verbose { for identity in identities { - if let name = identity.userRecordName { print(" - \(name)") } + if case .recordName(let name) = identity.userRecordName { print(" - \(name)") } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift index 6ebbd1b9..30321bf0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupZonePhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct LookupZonePhase: IntegrationPhase { internal typealias Input = NoState diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift index a2b19d1e..5cc8277f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyRecordsPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct ModifyRecordsPhase: IntegrationPhase { internal typealias Input = CreatedRecordNames @@ -48,7 +48,7 @@ internal struct ModifyRecordsPhase: IntegrationPhase { let operations = recordsToUpdate.enumerated().map { offset, recordName in RecordOperation( operationType: .forceReplace, - recordType: IntegrationTestData.recordType, + recordType: MistDemoConfig.recordType, recordName: recordName, fields: [ "title": .string("Updated Record \(offset + 1)") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift new file mode 100644 index 00000000..c3626fc5 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ModifyZonesPhase.swift @@ -0,0 +1,103 @@ +// +// ModifyZonesPhase.swift +// MistDemo +// +// 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 MistKit + +/// Exercises `modifyZones` end-to-end: create a uniquely-named test zone, +/// verify it via `lookupZones`, then delete it. Cleanup runs even on +/// verification failure so we don't leave stray zones behind. +internal struct ModifyZonesPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Modify zones (create + verify + delete)" + internal static let emoji = "🧱" + internal static let apiName = "modifyZones" + + internal func run( + input: NoState, + context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let suffix = UUID().uuidString.prefix(8) + let zoneName = "MistDemoIntegrationZone-\(suffix)" + let zoneID = ZoneID(zoneName: zoneName, ownerName: nil) + + do { + _ = try await context.service.modifyZones( + [.create(zoneID)], + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(zoneName)") + } + + let lookedUp = try await context.service.lookupZones( + zoneIDs: [zoneID], + database: context.database + ) + guard lookedUp.contains(where: { $0.zoneName == zoneName }) else { + try await cleanup(zoneID: zoneID, context: context) + throw IntegrationTestError.verificationFailed( + "created zone '\(zoneName)' not returned by lookupZones" + ) + } + if context.verbose { + print(" ✅ Verified zone via lookupZones") + } + + try await cleanup(zoneID: zoneID, context: context) + if context.verbose { + print(" ✅ Deleted zone: \(zoneName)") + } + } catch let error as IntegrationTestError { + throw error + } catch { + try? await cleanup(zoneID: zoneID, context: context) + throw IntegrationTestError.verificationFailed( + "modifyZones round-trip failed: \(error.localizedDescription)" + ) + } + + print("✅ Round-tripped zone create/verify/delete") + return NoState() + } + + private func cleanup( + zoneID: ZoneID, + context: PhaseContext + ) async throws { + _ = try await context.service.modifyZones( + [.delete(zoneID)], + database: context.database + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift new file mode 100644 index 00000000..ff832883 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/NotificationRoundtripPhase.swift @@ -0,0 +1,218 @@ +// +// NotificationRoundtripPhase.swift +// MistDemo +// +// 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 MistKit + +/// End-to-end notification probe: stand up a subscription, mint a web-courier +/// token, mutate a matching record, and long-poll the courier URL for the +/// resulting push — the only fully headless way to observe a CloudKit +/// notification round trip (no device, no APNs entitlement, no signing). +/// +/// - Note: This is a **probe**, not a hard assertion (issue #379). The +/// web-courier wire format isn't documented in Apple's REST reference, and +/// push delivery is asynchronous/eventual, so a non-arrival within the wait +/// window is reported as a soft warning rather than failing the suite. The +/// captured frame is logged verbatim so the wire format can be pinned down; +/// once it is, the poller can graduate into the MistKit library and this +/// phase can tighten into a real assertion. +internal struct NotificationRoundtripPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Trigger a subscription and await the web-courier push (probe)" + internal static let emoji = "📨" + internal static let apiName = "modifySubscriptions+createToken+webcourier" + + /// Bounded wait for delivery. Push is eventual; keep this generous but finite. + private static let deliveryTimeout: Double = 45 + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + #if os(WASI) + print(" ⏭️ Skipped: web-courier long-poll requires URLSession (unavailable on WASI).") + return NoState() + #else + let suffix = UUID().uuidString.lowercased() + let subscriptionID = "mistkit-notif-\(suffix)" + + // 1. Subscription that fires on *any* change (create/update/delete) to + // the shared test record type. + _ = try await context.service.createSubscription( + .query( + subscriptionID: subscriptionID, + recordType: MistDemoConfig.recordType, + firesOn: [.create, .update, .delete] + ), + database: context.database + ) + if context.verbose { + print(" ✅ Created subscription: \(subscriptionID)") + } + + var createdRecordName: String? + do { + // 2. Mint + register a courier token, then trigger a matching change. + let courierURL = try await mintCourierToken(context: context) + let record = try await trigger(suffix: suffix, context: context) + createdRecordName = record.recordName + + // 3. Await our subscription's push (bounded, soft — see helper). + await awaitPush( + courierURL: courierURL, + subscriptionID: subscriptionID, + expectedRecordName: record.recordName + ) + } catch { + await cleanup( + subscriptionID: subscriptionID, + recordName: createdRecordName, + context: context + ) + throw error + } + + await cleanup( + subscriptionID: subscriptionID, recordName: createdRecordName, context: context + ) + print("✅ Notification probe completed for subscription '\(subscriptionID)'") + return NoState() + #endif + } + + #if !os(WASI) + /// Mint a web-courier token and register it, so CloudKit delivers this + /// container's subscription pushes to it. (CloudKit JS rolls both into + /// `registerForNotifications()`.) Returns the long-poll courier URL. + private func mintCourierToken(context: PhaseContext) async throws -> URL { + let clientId = UUID().uuidString + let token = try await context.service.createAPNsToken( + environment: .development, + clientId: clientId, + database: context.database + ) + try await context.service.registerAPNsToken( + token.apnsToken, + environment: token.environment, + clientId: clientId, + database: context.database + ) + if context.verbose { + print( + " ✅ Minted + registered courier token; polling \(token.webcourierURL.absoluteString)" + ) + } + return token.webcourierURL + } + + /// Create a record that matches the subscription — the change that should + /// fire the push. The record name is deliberately distinct from the + /// subscription ID (`mistkit-notif-`): CloudKit provisions an + /// internal `_sub_trigger_sub_` backing record named after the + /// subscription ID, so reusing that name here collides with it + /// (`invalid attempt to update record from type '_sub_trigger_sub_…'`). + private func trigger(suffix: String, context: PhaseContext) async throws -> RecordInfo { + let record = try await context.service.createRecord( + recordType: MistDemoConfig.recordType, + recordName: "mistkit-notif-rec-\(suffix)", + fields: ["title": .string("notification probe \(suffix)")], + database: context.database + ) + if context.verbose { + print(" ✅ Triggered with record: \(record.recordName)") + } + return record + } + + /// Await *our* subscription's push within a bounded window. Other + /// subscriptions on this record type fire too, so filter by sid. The poller + /// is rebuilt inside the stream, so no non-Sendable state crosses the + /// timeout boundary. Any failure here is soft — delivery is eventual and + /// this is a probe, not a hard assertion (#379). + private func awaitPush( + courierURL: URL, + subscriptionID: String, + expectedRecordName: String + ) async { + do { + let notification: CourierNotification? = try await withTimeout( + seconds: Self.deliveryTimeout + ) { + for try await note in WebCourierPoller(courierURL: courierURL).notifications() + where note.subscriptionID == subscriptionID { + return note + } + return nil + } + guard let notification else { + print(" ⚠️ Courier stream ended before delivering '\(subscriptionID)' (#379).") + return + } + let reason = notification.reason.map(String.init(describing:)) ?? "?" + print( + " ✅ Received push for '\(subscriptionID)' — " + + "record \(notification.recordName ?? "?"), reason \(reason)" + ) + if notification.recordName != expectedRecordName { + print( + " ⚠️ Notification record '\(notification.recordName ?? "nil")' " + + "≠ created '\(expectedRecordName)'." + ) + } + } catch { + print( + " ⚠️ No courier push within \(formatTimeout(Self.deliveryTimeout)) " + + "(\(error.localizedDescription)). Probe inconclusive — delivery is eventual (#379)." + ) + } + } + #endif + + /// Best-effort teardown of the trigger record and subscription. Runs on both + /// the success and failure paths; swallows its own errors so it never masks + /// the original outcome. + private func cleanup( + subscriptionID: String, + recordName: String?, + context: PhaseContext + ) async { + if let recordName { + try? await context.service.deleteRecord( + recordType: MistDemoConfig.recordType, + recordName: recordName, + database: context.database + ) + } + try? await context.service.deleteSubscription( + id: subscriptionID, + database: context.database + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift index 0a91557f..034e853d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/QueryRecordsPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct QueryRecordsPhase: IntegrationPhase { internal typealias Input = CreatedRecordNames @@ -45,9 +45,9 @@ internal struct QueryRecordsPhase: IntegrationPhase { do { let records = try await context.service.queryRecords( - recordType: IntegrationTestData.recordType + recordType: MistDemoConfig.recordType ) - print("✅ Queried \(records.count) record(s) of type '\(IntegrationTestData.recordType)'") + print("✅ Queried \(records.count) record(s) of type '\(MistDemoConfig.recordType)'") if context.verbose { let ours = records.filter { input.names.contains($0.recordName) } print(" Found \(ours.count) of our \(input.names.count) test records") diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetInput.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetInput.swift new file mode 100644 index 00000000..4e8d8465 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetInput.swift @@ -0,0 +1,44 @@ +// +// RereferenceAssetInput.swift +// MistDemo +// +// 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 MistKit + +/// Phase input for ``RereferenceAssetPhase``: the uploaded asset receipt +/// (expected checksum) plus the records created with that asset (the +/// re-reference source). +internal struct RereferenceAssetInput: PhaseStateDecodable, Sendable { + internal let receipt: AssetUploadReceipt + internal let recordNames: [String] + + internal init(from state: PhaseState) throws { + self.receipt = try AssetUploadReceipt(from: state) + self.recordNames = try CreatedRecordNames(from: state).names + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetPhase.swift new file mode 100644 index 00000000..258dd0cb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/RereferenceAssetPhase.swift @@ -0,0 +1,124 @@ +// +// RereferenceAssetPhase.swift +// MistDemo +// +// 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 MistKit + +/// Re-references an existing asset from one record onto a freshly-created +/// target record without re-uploading the bytes, then verifies the target +/// resolves to the same asset (`assets/rereference`). +internal struct RereferenceAssetPhase: IntegrationPhase { + internal typealias Input = RereferenceAssetInput + internal typealias Output = CreatedRecordNames + + internal static let title = "Rereference asset" + internal static let emoji = "📎" + internal static let apiName = "rereferenceAssets" + + /// Confirm the target record now references the same asset bytes. + private static func verify( + _ record: RecordInfo, expected: Asset, context: PhaseContext + ) throws { + guard case .asset(let targetAsset) = record.fields["image"] else { + throw IntegrationTestError.verificationFailed( + "Target record '\(record.recordName)' has no 'image' asset after re-reference" + ) + } + + // Verify against the file checksum when available — it's the stable signal. + // Signed CDN download URLs carry an expiry and aren't stable across calls, + // so they're only a fallback when no checksum was echoed back. + if let expectedChecksum = expected.fileChecksum { + guard targetAsset.fileChecksum == expectedChecksum else { + throw IntegrationTestError.verificationFailed( + "Re-referenced asset on '\(record.recordName)' does not match the source " + + "(fileChecksum: \(targetAsset.fileChecksum ?? "nil") vs \(expectedChecksum))" + ) + } + } else if let expectedURL = expected.downloadURL { + guard targetAsset.downloadURL == expectedURL else { + throw IntegrationTestError.verificationFailed( + "Re-referenced asset on '\(record.recordName)' does not match the source " + + "(downloadURL: \(targetAsset.downloadURL ?? "nil") vs \(expectedURL))" + ) + } + } else { + throw IntegrationTestError.verificationFailed( + "Expected asset for '\(record.recordName)' has no identifying field to verify against" + ) + } + + if context.verbose { + print(" Verified fileChecksum: \(targetAsset.fileChecksum ?? "nil")") + } + } + + internal func run( + input: RereferenceAssetInput, context: PhaseContext + ) async throws -> CreatedRecordNames { + print("\n\(Self.emoji) \(Self.title)") + + guard let sourceRecordName = input.recordNames.first else { + throw IntegrationTestError.missingPhaseState("createdRecordNames") + } + + // A fresh target record with no image of its own, so the re-reference is + // what attaches the asset. + let targetRecordName = "mistkit-test-\(UUID().uuidString.lowercased())" + _ = try await context.service.createRecord( + recordType: MistDemoConfig.recordType, + recordName: targetRecordName, + fields: [ + "title": .string("Rereference Target"), + "index": .int64(0), + ], + database: context.database + ) + + if context.verbose { + print(" Source: \(sourceRecordName)") + print(" Target: \(targetRecordName)") + } + + let updated = try await context.service.rereferenceAsset( + fromRecord: sourceRecordName, + field: "image", + toRecord: targetRecordName, + field: "image", + database: context.database + ) + + try Self.verify(updated, expected: input.receipt.asset, context: context) + + print("✅ Re-referenced asset onto \(targetRecordName)") + + // Track the target so cleanup removes it alongside the other records. + return CreatedRecordNames(input.recordNames + [targetRecordName]) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift new file mode 100644 index 00000000..7f66dc21 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift @@ -0,0 +1,48 @@ +// +// ResolveRecordsPhase.swift +// MistDemo +// +// 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 + +/// Stub phase for `records/resolve`. The pipeline does not wire this phase +/// into `PublicDatabaseTest` / `PrivateDatabaseTest` yet — it stays available +/// for `#41` to flip into a real run when the MistKit Swift wrapper lands. +internal struct ResolveRecordsPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Resolve records (pending #41)" + internal static let emoji = "🔗" + internal static let apiName = "resolveRecords" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + PendingStub.printPending(endpoint: "records/resolve", trackingIssue: 41) + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SubscriptionRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SubscriptionRoundtripPhase.swift new file mode 100644 index 00000000..84416e7d --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SubscriptionRoundtripPhase.swift @@ -0,0 +1,139 @@ +// +// SubscriptionRoundtripPhase.swift +// MistDemo +// +// 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 MistKit + +/// Create a query subscription, confirm it appears via `subscriptions/list` +/// and `subscriptions/lookup`, then delete it — exercising all three +/// subscription endpoints in a single self-cleaning phase (mirroring +/// ``ZoneRoundtripPhase``). +internal struct SubscriptionRoundtripPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Create, list, lookup, and delete a subscription" + internal static let emoji = "🔔" + internal static let apiName = "modifySubscriptions+listSubscriptions+lookupSubscriptions" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + // CloudKit dedupes query subscriptions by their query + firesOn signature, + // not by subscriptionID, so a leftover subscription from an interrupted or + // failed prior run (same recordType + firesOn) makes `createSubscription` + // fail with `subscriptionLikelyDuplicate`. Sweep any stale test + // subscriptions first so this phase is self-healing across runs. + try await sweepStaleSubscriptions(context: context) + + let subscriptionID = "mistkit-itest-\(UUID().uuidString.lowercased())" + + let created = try await context.service.createSubscription( + .query( + subscriptionID: subscriptionID, + recordType: MistDemoConfig.recordType, + firesOn: [.create, .update, .delete] + ), + database: context.database + ) + if context.verbose { + print(" ✅ Created subscription: \(created.subscriptionID)") + } + + do { + try await verify(subscriptionID: subscriptionID, context: context) + } catch { + // Best-effort cleanup before surfacing the failure. + try? await context.service.deleteSubscription( + id: subscriptionID, database: context.database + ) + throw error + } + + try await context.service.deleteSubscription( + id: subscriptionID, + database: context.database + ) + if context.verbose { + print(" ✅ Deleted subscription: \(subscriptionID)") + } + + print("✅ Roundtrip succeeded for subscription '\(subscriptionID)'") + + return NoState() + } + + /// Delete leftover test subscriptions so a fresh create won't collide with + /// CloudKit's query-signature dedup. Matches both the `mistkit-itest-` ID + /// convention and the test's query signature (`recordType`), so the blocking + /// subscription is cleared even if its ID differs from the current prefix. + /// Best-effort: a failed delete shouldn't fail the phase. + private func sweepStaleSubscriptions(context: PhaseContext) async throws { + let existing = try await context.service.listSubscriptions(database: context.database) + let stale = existing.filter { + $0.subscriptionID.hasPrefix("mistkit-itest-") + || $0.query?.recordType == MistDemoConfig.recordType + } + for subscription in stale { + try? await context.service.deleteSubscription( + id: subscription.subscriptionID, + database: context.database + ) + if context.verbose { + print(" 🧹 Swept stale subscription: \(subscription.subscriptionID)") + } + } + } + + /// Confirm the created subscription is visible via both `list` and `lookup`. + private func verify(subscriptionID: String, context: PhaseContext) async throws { + let all = try await context.service.listSubscriptions(database: context.database) + guard all.contains(where: { $0.subscriptionID == subscriptionID }) else { + throw IntegrationTestError.verificationFailed( + "Created subscription '\(subscriptionID)' missing from listSubscriptions" + ) + } + if context.verbose { + print(" ✅ Listed \(all.count) subscription(s); found ours") + } + + let looked = try await context.service.lookupSubscriptions( + ids: [subscriptionID], + database: context.database + ) + guard looked.contains(where: { $0.subscriptionID == subscriptionID }) else { + throw IntegrationTestError.verificationFailed( + "lookupSubscriptions did not return '\(subscriptionID)'" + ) + } + if context.verbose { + print(" ✅ Looked up subscription by ID") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift new file mode 100644 index 00000000..9511b21a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/TokenRoundtripPhase.swift @@ -0,0 +1,70 @@ +// +// TokenRoundtripPhase.swift +// MistDemo +// +// 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 MistKit + +/// Mint an APNs token via `tokens/create`, then register it via +/// `tokens/register` — exercising both token endpoints in one phase (per #53, +/// register is seeded with the token returned by create). +internal struct TokenRoundtripPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Create and register an APNs token" + internal static let emoji = "🎟️" + internal static let apiName = "createToken+registerToken" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + // Reuse one clientId across both halves so the round-trip exercises the + // CloudKit JS-style "single logical client" attribution path. + let clientId = UUID().uuidString + + let token = try await context.service.createAPNsToken( + environment: .development, + clientId: clientId, + database: context.database + ) + if context.verbose { + print(" ✅ Created APNs token (\(token.apnsToken.prefix(8))…)") + } + + try await context.service.registerAPNsToken( + token.apnsToken, + environment: token.environment, + clientId: clientId, + database: context.database + ) + print("✅ Created and registered an APNs token") + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift index 999c9045..ea710a8d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/UploadAssetPhase.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct UploadAssetPhase: IntegrationPhase { internal typealias Input = NoState @@ -43,7 +43,7 @@ internal struct UploadAssetPhase: IntegrationPhase { ) async throws -> AssetUploadReceipt { print("\n\(Self.emoji) \(Self.title)") - let testData = IntegrationTestData.generateTestImage(sizeKB: context.assetSizeKB) + let testData = PNGData.generate(withSizeInKB: context.assetSizeKB) let sizeInMB = Double(testData.count) / 1_024 / 1_024 if context.verbose { @@ -52,7 +52,7 @@ internal struct UploadAssetPhase: IntegrationPhase { let receipt = try await context.service.uploadAssets( data: testData, - recordType: IntegrationTestData.recordType, + recordType: MistDemoConfig.recordType, fieldName: "image", database: context.database ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZoneRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZoneRoundtripPhase.swift new file mode 100644 index 00000000..b068714d --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZoneRoundtripPhase.swift @@ -0,0 +1,71 @@ +// +// ZoneRoundtripPhase.swift +// MistDemo +// +// 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 MistKit + +/// Create and immediately delete a uniquely-named zone, exercising both +/// ``CloudKitService/createZone(zoneName:ownerRecordName:database:)`` and +/// ``CloudKitService/deleteZone(zoneName:ownerRecordName:database:)`` in +/// a single self-cleaning phase. CloudKit only allows custom zones on the +/// private and shared databases. +internal struct ZoneRoundtripPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Create and delete a zone" + internal static let emoji = "🌀" + internal static let apiName = "createZone+deleteZone" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let zoneName = "mistkit-itest-\(UUID().uuidString.lowercased())" + + let created = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(created.zoneName)") + } + + try await context.service.deleteZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Deleted zone: \(zoneName)") + } + + print("✅ Roundtrip succeeded for zone '\(zoneName)'") + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/SyncTokenSlot.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/SyncTokenSlot.swift index 49d4f745..4ab9a90a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/SyncTokenSlot.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/SyncTokenSlot.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Wraps the `syncToken` slot of `PhaseState`. internal struct SyncTokenSlot: PhaseStateDecodable, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 3fbaac55..92c82743 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct PrivateDatabaseTest: PhasedIntegrationTest { internal let name = "Private Database" @@ -41,16 +41,23 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { // pipeline; the service resolves web-auth credentials per call when needed. internal let phases: [any IntegrationPhase] = [ ListZonesPhase(), + ModifyZonesPhase(), LookupZonePhase(), + ZoneRoundtripPhase(), FetchZoneChangesPhase(), + FetchAllZoneChangesPhase(), UploadAssetPhase(), CreateRecordsPhase(), + RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), InitialSyncPhase(), ModifyRecordsPhase(), IncrementalSyncPhase(), FinalVerificationPhase(), + SubscriptionRoundtripPhase(), + TokenRoundtripPhase(), + NotificationRoundtripPhase(), CleanupPhase(), ] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index e8cdceca..3e4d0b59 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit internal struct PublicDatabaseTest: PhasedIntegrationTest { internal let name = "Public Database" @@ -56,6 +56,7 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { LookupZonePhase(), UploadAssetPhase(), CreateRecordsPhase(), + RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), ModifyRecordsPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/UserInfo+PhaseState.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/UserInfo+PhaseState.swift index 3661f435..8e82b2d4 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/UserInfo+PhaseState.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/UserInfo+PhaseState.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit extension UserInfo: PhaseStateDecodable, PhaseStateEncodable { internal init(from state: PhaseState) throws { diff --git a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift index 170f5b3c..2817dc15 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import ConfigKeyKit -import Foundation +internal import ConfigKeyKit +internal import Foundation /// Top-level driver for the `mistdemo` CLI. Registers all available commands, /// parses arguments, and dispatches to the matching command — the executable @@ -54,11 +54,30 @@ public enum MistDemoRunner { await registry.register(UploadAssetCommand.self) await registry.register(DemoInFilterCommand.self) await registry.register(LookupZonesCommand.self) + await registry.register(CreateZoneCommand.self) + await registry.register(ListZonesCommand.self) + await registry.register(ModifyZonesCommand.self) + await registry.register(DiscoverCommand.self) + await registry.register(LookupAllRecordsCommand.self) + await registry.register(DiscoverAllUserIdentitiesCommand.self) + await registry.register(ValidateCommand.self) + await registry.register(DeleteZoneCommand.self) await registry.register(FetchChangesCommand.self) await registry.register(TestPublicCommand.self) await registry.register(TestPrivateCommand.self) await registry.register(DemoErrorsCommand.self) + // Pending MistKit wrappers — print "pending #N" and exit 0. Each + // command flips to a real implementation when its tracking issue lands. + await registry.register(ResolveCommand.self) + await registry.register(RereferenceAssetCommand.self) + await registry.register(ListSubscriptionsCommand.self) + await registry.register(LookupSubscriptionCommand.self) + await registry.register(ModifySubscriptionsCommand.self) + await registry.register(ProbeDuplicateSubscriptionCommand.self) + await registry.register(CreateTokenCommand.self) + await registry.register(RegisterTokenCommand.self) + // Parse command line arguments let parser = CommandLineParser() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/AuthRequest.swift b/Examples/MistDemo/Sources/MistDemoKit/Models/AuthRequest.swift index 507d9b18..3037e093 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Models/AuthRequest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Models/AuthRequest.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Request model for authentication callback from CloudKit Web Services. /// diff --git a/Examples/MistDemo/Sources/MistDemoKit/Models/ValidationResult.swift b/Examples/MistDemo/Sources/MistDemoKit/Models/ValidationResult.swift new file mode 100644 index 00000000..2651c61b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Models/ValidationResult.swift @@ -0,0 +1,71 @@ +// +// ValidationResult.swift +// MistDemo +// +// 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 +public import MistKit + +/// Structured outcome of the `validate` command. +public struct ValidationResult: Encodable, Sendable { + /// Whether MistDemo successfully parsed the configured credentials into a + /// `CloudKitService`. False indicates a configuration error (missing or + /// malformed env vars / config file). + public let credentialsValid: Bool + /// Whether the configuration carries API + web-auth tokens. User-identity + /// routes (`fetchCaller`, `lookupUsers*`) require this. + public let webAuthConfigured: Bool + /// Whether the configuration carries a key ID + private key material. + /// Required to sign `.public` database requests. + public let serverToServerConfigured: Bool + /// Caller info returned by `users/caller`. Nil when the network check was + /// skipped or when web-auth wasn't configured. + public let userInfo: UserInfo? + /// Zones returned by the optional `--test-query`. Nil when the flag wasn't + /// passed or the call failed (in which case `errors` carries the reason). + public let zonesFound: Int? + /// Human-readable error messages collected during validation. Empty on + /// full success. + public let errors: [String] + + /// Creates a new instance. + public init( + credentialsValid: Bool, + webAuthConfigured: Bool, + serverToServerConfigured: Bool, + userInfo: UserInfo? = nil, + zonesFound: Int? = nil, + errors: [String] = [] + ) { + self.credentialsValid = credentialsValid + self.webAuthConfigured = webAuthConfigured + self.serverToServerConfigured = serverToServerConfigured + self.userInfo = userInfo + self.zonesFound = zonesFound + self.errors = errors + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/CSVEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/CSVEscaper.swift index 5ce1de49..1a908fe6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/CSVEscaper.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/CSVEscaper.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// CSV escaper conforming to RFC 4180 public struct CSVEscaper: OutputEscaper { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/JSONEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/JSONEscaper.swift index f84338ec..72e379b6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/JSONEscaper.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/JSONEscaper.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// JSON escaper (usually handled by JSONEncoder, but useful for manual JSON building) public struct JSONEscaper: OutputEscaper { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/OutputEscaperFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/OutputEscaperFactory.swift index 4c114ec9..9622e827 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/OutputEscaperFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/OutputEscaperFactory.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Factory for creating output escapers based on output format public enum OutputEscaperFactory { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/TableEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/TableEscaper.swift index 2e354f66..f6dc026b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/TableEscaper.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/TableEscaper.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Table escaper for plain text table output public struct TableEscaper: OutputEscaper { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/YAMLEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/YAMLEscaper.swift index 73420ae8..1f5355a0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/YAMLEscaper.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Escapers/YAMLEscaper.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// YAML escaper for proper string formatting public struct YAMLEscaper: OutputEscaper { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/CSVFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/CSVFormatter.swift index 519b900f..3dd997c0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/CSVFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/CSVFormatter.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Formatter for CSV output public struct CSVFormatter: OutputFormatter { @@ -65,7 +65,7 @@ public struct CSVFormatter: OutputFormatter { // Basic fields output += "recordName,\(escaper.escape(record.recordName))\n" - output += "recordType,\(escaper.escape(record.recordType))\n" + output += "recordType,\(escaper.escape(record.recordType ?? ""))\n" // Custom fields for (fieldName, fieldValue) in record.fields.sorted(by: { $0.key < $1.key }) { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/OutputFormatterFactory.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/OutputFormatterFactory.swift index 185b3d3a..0124588d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/OutputFormatterFactory.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/OutputFormatterFactory.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Factory for creating output formatters based on output format public enum OutputFormatterFactory { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/TableFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/TableFormatter.swift index 19a4efe4..1d842c44 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/TableFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/TableFormatter.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Formatter for table output public struct TableFormatter: OutputFormatter { @@ -61,7 +61,7 @@ public struct TableFormatter: OutputFormatter { var output = "" output += "Record Name: \(escaper.escape(record.recordName))\n" - output += "Record Type: \(escaper.escape(record.recordType))\n" + output += "Record Type: \(escaper.escape(record.recordType ?? ""))\n" if !record.fields.isEmpty { output += "Fields:\n" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/YAMLFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/YAMLFormatter.swift index b7669305..01924cea 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/YAMLFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Formatters/YAMLFormatter.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Formatter for YAML output public struct YAMLFormatter: OutputFormatter { @@ -61,7 +61,7 @@ public struct YAMLFormatter: OutputFormatter { var output = "" output += "recordName: \(escaper.escape(record.recordName))\n" - output += "recordType: \(escaper.escape(record.recordType))\n" + output += "recordType: \(escaper.escape(record.recordType ?? ""))\n" if !record.fields.isEmpty { output += "fields:\n" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/JSONFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/JSONFormatter.swift index 34df776c..df9d83bd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/JSONFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/JSONFormatter.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Formatter for JSON output public struct JSONFormatter: OutputFormatter { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormat.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormat.swift index d719c329..8a6518cb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormat.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormat.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Supported output formats public enum OutputFormat: String, Sendable, CaseIterable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormatter.swift index 985051ee..4647038a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/OutputFormatter.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Protocol for formatting output in different formats public protocol OutputFormatter: Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Output/Protocols/OutputEscaper.swift b/Examples/MistDemo/Sources/MistDemoKit/Output/Protocols/OutputEscaper.swift index d70cfcb2..5e897db3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Output/Protocols/OutputEscaper.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Output/Protocols/OutputEscaper.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +internal import Foundation /// Protocol for escaping strings for specific output formats public protocol OutputEscaper: Sendable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Implementations.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Implementations.swift index 36a7c4af..074c2031 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Implementations.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Implementations.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit // MARK: - Format-specific implementations diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Records.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Records.swift index 555cfd26..d2bf470f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Records.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Records.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit // MARK: - RecordInfo Output Formatting @@ -46,7 +46,7 @@ extension OutputFormatting { let record = records[0] print(MistDemoConstants.Messages.recordCreated) print("├─ Name: \(record.recordName)") - print("├─ Type: \(record.recordType)") + print("├─ Type: \(record.recordType ?? "")") if let changeTag = record.recordChangeTag { print("├─ Change Tag: \(changeTag)") } @@ -64,7 +64,7 @@ extension OutputFormatting { for (index, record) in records.enumerated() { print("\n[\(index + 1)] Record: \(record.recordName)") - print(" Type: \(record.recordType)") + print(" Type: \(record.recordType ?? "")") if let changeTag = record.recordChangeTag { print(" Change Tag: \(changeTag)") } @@ -110,7 +110,7 @@ extension OutputFormatting { case MistDemoConstants.FieldNames.recordName: values.append(csvEscaper.escape(record.recordName)) case MistDemoConstants.FieldNames.recordType: - values.append(csvEscaper.escape(record.recordType)) + values.append(csvEscaper.escape(record.recordType ?? "")) case MistDemoConstants.FieldNames.recordChangeTag: values.append(csvEscaper.escape(record.recordChangeTag ?? "")) default: @@ -139,7 +139,7 @@ extension OutputFormatting { print("record:") let name = yamlEscaper.escape(record.recordName) print(" \(recordNameKey): \(name)") - let rtype = yamlEscaper.escape(record.recordType) + let rtype = yamlEscaper.escape(record.recordType ?? "") print(" \(recordTypeKey): \(rtype)") if let changeTag = record.recordChangeTag { let tag = yamlEscaper.escape(changeTag) @@ -157,7 +157,7 @@ extension OutputFormatting { for record in records { let name = yamlEscaper.escape(record.recordName) print(" - \(recordNameKey): \(name)") - let rtype = yamlEscaper.escape(record.recordType) + let rtype = yamlEscaper.escape(record.recordType ?? "") print(" \(recordTypeKey): \(rtype)") if let changeTag = record.recordChangeTag { let tag = yamlEscaper.escape(changeTag) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Users.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Users.swift index 3cbe5ea7..031bdc7c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Users.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting+Users.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit // MARK: - UserInfo Output Formatting diff --git a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting.swift b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting.swift index 0640a32b..b5f62a97 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Protocols/OutputFormatting.swift @@ -27,8 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import MistKit +internal import Foundation +internal import MistKit /// Protocol for formatting command output in different formats public protocol OutputFormatting { diff --git a/Examples/MistDemo/Sources/MistDemoKit/PushTokenStatus.swift b/Examples/MistDemo/Sources/MistDemoKit/PushTokenStatus.swift new file mode 100644 index 00000000..d8da4e0e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/PushTokenStatus.swift @@ -0,0 +1,38 @@ +// +// PushTokenStatus.swift +// MistDemo +// +// 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. +// + +/// The push-token registration state surfaced to the UI. APNs requires a +/// signed app + push entitlement; on simulators or unentitled builds the +/// OS reports an error which the UI renders inline. +public enum PushTokenStatus: Sendable { + case idle + case requesting + case registered(hexToken: String) + case failed(message: String) +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 2bf13fb0..38e87299 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -4,220 +4,7 @@ MistKit Web Demo - + @@ -225,9 +12,9 @@

MistKit Web Demo

- Authenticate with your Apple ID, then exercise the same CloudKit - operations through MistKit (server) or CloudKit JS (browser) and - compare the wire-level behavior. + Authenticate with your Apple ID, then exercise the v1.0.0-beta.2 CloudKit + surface through MistKit (server) or CloudKit JS (browser) and compare the + wire-level behavior side-by-side.

Backend

@@ -250,8 +37,7 @@

Database

Private uses the captured Apple ID web-auth token; Public uses server-to-server signing on the MistKit side and the API token on - the CloudKit JS side. Browsers can't perform S2S signing, so - "MistKit + Public" is unique to the server path. + the CloudKit JS side.

Auth

@@ -262,8 +48,11 @@

Auth

+
-

Notes MistKit Private

+

Notes MistKit Private + records/query · records/modify +

@@ -302,754 +91,304 @@

Notes MistKit

+
+
+

+ New note + +

+ + + + + +
+ + + No image attached. +
+ +
+ + + +
+
+
+ Last raw response +
(none yet)
+
+
+ +
+

Rereference asset assets/rereference

+

Reuse another note's image on a target note — no re-upload. Click a row to prefill the source.

+
+ + + +
+
+
+ Last raw response +
(none yet)
+
+
+ 📎 CloudKit JS composition +
    +
  1. database.fetchRecords([sourceRecordName]) — pull source record
  2. +
  3. Read CloudKit.Asset descriptor from source's image field
  4. +
  5. database.saveRecords([target]) — save target with reused asset
  6. +
+
+
+
+
+
+ + +
+

Records records/lookup · records/changes · records/resolve

+
+
+

Lookup records/lookup

+
+ + +
+
+
(none yet)
+
-

- New note - -

- - - - -
- - - +

Changes records/changes

+
+ + + +
+
+
(none yet)
+
+
+

Resolve records/resolve (MistKit pending #41 — CloudKit JS composed)

+
+ + +
+
+
(none yet)
+
+ 📎 CloudKit JS composition +
    +
  1. Share URL → container.fetchShareMetadataWithURL(url)
  2. +
  3. Record name → database.fetchRecords([{ recordName }])
  4. +
+
+
+
+
+ + +
+

Zones zones/list · zones/lookup · zones/modify · zones/changes

+
+
+

List zones/list

+
+ +
+
+
+ + + + + + + +
Zone NameOwnerAtomic
No zones loaded — click Fetch Zones.
-
Last raw response -
(none yet)
+
(none yet)
+
+

Lookup zones/lookup

+
+ + +
+
+
(none yet)
+
+
+

Modify zones/modify

+
+ + + + +
+
+
(none yet)
+
+
+

Changes zones/changes

+
+ + +
+
+
(none yet)
+
- - - + + + + + + + + + + diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js new file mode 100644 index 00000000..83f575fc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js @@ -0,0 +1,690 @@ +// Shared globals + helpers for the MistDemo web page. +// +// Each operation module (records.js, zones.js, etc.) reads `currentMode` / +// `currentDatabase` set here, and calls these helpers to render output to +// per-panel `
` and `
` elements. This module also
+// owns the existing Notes CRUD panel — it's the originating shape every
+// new panel mirrors.
+
+let container = null;
+let webAuthToken = null;
+let authenticationInProgress = false;
+let currentMode = 'mistkit';            // 'mistkit' | 'cloudkitjs'
+let currentDatabase = 'private';        // 'private' | 'public'
+let publicDatabaseAvailable = false;
+let notes = [];
+let selectedRecordName = null;
+let authComplete = false;
+let queryInFlight = false;
+let currentUserRecordName = null;
+let currentSort = { field: '___createTime', ascending: false };
+const REFRESH_DELAY_MS = 1200;
+
+const authStatusDiv = document.getElementById('auth-status');
+const signinButton = document.getElementById('signin-button');
+const signoutButton = document.getElementById('signout-button');
+const notesCard = document.getElementById('notes-card');
+const modeBadge = document.getElementById('mode-badge');
+const dbBadge = document.getElementById('db-badge');
+const dbPrivateBtn = document.getElementById('db-private');
+const dbPublicBtn = document.getElementById('db-public');
+const dbHint = document.getElementById('db-hint');
+const tbody = document.getElementById('notes-tbody');
+const tableStatusEl = document.getElementById('table-status');
+const formStatusEl = document.getElementById('form-status');
+const formHeading = document.getElementById('form-heading');
+const formRecordName = document.getElementById('form-record-name');
+const titleInput = document.getElementById('form-title');
+const indexInput = document.getElementById('form-index');
+const saveBtn = document.getElementById('save-btn');
+const clearBtn = document.getElementById('clear-btn');
+const deleteBtn = document.getElementById('delete-btn');
+const refreshBtn = document.getElementById('refresh-btn');
+const recordTypeInput = document.getElementById('record-type');
+const queryLimitInput = document.getElementById('query-limit');
+const rawResponseEl = document.getElementById('raw-response');
+const formImageGenerateBtn = document.getElementById('form-image-generate');
+const formImageClearBtn = document.getElementById('form-image-clear');
+const formImageStatusEl = document.getElementById('form-image-status');
+const formImagePreviewImg = document.getElementById('form-image-preview');
+const assetsSourceInput = document.getElementById('assets-source');
+
+// Generated image staged for the next save, or null.
+//   { dataURL, base64, blob, byteLength }
+let pendingImage = null;
+
+// ---- shared helpers ----
+
+function setStatus(el, message, kind) {
+    if (!el) return;
+    el.className = `status ${kind || ''}`;
+    el.textContent = message;
+    if (kind) el.style.display = 'block';
+}
+
+function clearStatus(el) {
+    if (!el) return;
+    el.className = 'status';
+    el.textContent = '';
+    el.style.display = 'none';
+}
+
+// JSON.stringify replacer that renders Dates as ISO strings and drops
+// circular references. Some CloudKit JS results (e.g. the value resolved
+// by `registerForNotifications`) hold cyclic structures that would
+// otherwise throw "JSON.stringify cannot serialize cyclic structures".
+function safeReplacer() {
+    const seen = new WeakSet();
+    return (_key, value) => {
+        if (value instanceof Date) return value.toISOString();
+        if (value && typeof value === 'object') {
+            if (seen.has(value)) return '[Circular]';
+            seen.add(value);
+        }
+        return value;
+    };
+}
+
+function showRaw(value) {
+    rawResponseEl.textContent = value == null ? '(none)' : JSON.stringify(value, safeReplacer(), 2);
+}
+
+// Render an arbitrary payload to a specific 
 element (used by all
+// new operation panels). Mirrors `showRaw` but takes the target element
+// explicitly.
+function renderRaw(el, value) {
+    if (!el) return;
+    el.textContent = value == null
+        ? '(none)'
+        : JSON.stringify(value, safeReplacer(), 2);
+}
+
+// Render a simple read-only table of `items` into `tbody`. `getters` is an
+// array of `item => cellValue` functions, one per column — its length must
+// match the table's column count. Used by the Zones and Subscriptions list
+// panels to present results as a table instead of raw JSON.
+function renderListTable(tbody, getters, items, emptyMessage) {
+    if (!tbody) return;
+    tbody.innerHTML = '';
+    if (!items || items.length === 0) {
+        const tr = document.createElement('tr');
+        const td = document.createElement('td');
+        td.colSpan = getters.length;
+        td.className = 'empty-state';
+        td.textContent = emptyMessage;
+        tr.appendChild(td);
+        tbody.appendChild(tr);
+        return;
+    }
+    for (const item of items) {
+        const tr = document.createElement('tr');
+        for (const get of getters) {
+            const td = document.createElement('td');
+            const value = get(item);
+            td.textContent = (value == null || value === '') ? '—' : String(value);
+            tr.appendChild(td);
+        }
+        tbody.appendChild(tr);
+    }
+}
+
+// Run an operation and pipe its progress through the panel's status div
+// + raw `
`. Common shape across every new panel:
+//   - "loading…" while in-flight
+//   - success body rendered as JSON in the pre
+//   - errors rendered as red banner + payload (or message) in the pre
+async function runPanelOperation({ statusEl, rawEl, label, fn }) {
+    setStatus(statusEl, `${label}…`, 'loading');
+    try {
+        const result = await fn();
+        renderRaw(rawEl, result);
+        setStatus(statusEl, `${label} succeeded.`, 'success');
+        return result;
+    } catch (error) {
+        const payload = error && error.payload ? error.payload : { message: error.message };
+        renderRaw(rawEl, payload);
+        const msg = (error && error.message) || 'Unknown error';
+        setStatus(statusEl, `${label} failed: ${msg}`, 'error');
+        return null;
+    }
+}
+
+async function postJSON(path, body) {
+    const init = { headers: { 'Content-Type': 'application/json' } };
+    if (body !== undefined) {
+        init.method = 'POST';
+        init.body = JSON.stringify(body);
+    }
+    const response = await fetch(path, init);
+    const text = await response.text();
+    let payload;
+    try { payload = text ? JSON.parse(text) : null; } catch { payload = { message: text }; }
+    if (!response.ok) {
+        const msg = (payload && (payload.message || payload.error)) || `HTTP ${response.status}`;
+        const err = new Error(msg);
+        err.payload = payload;
+        err.status = response.status;
+        throw err;
+    }
+    return payload;
+}
+
+async function fetchJSON(path) {
+    const response = await fetch(path);
+    const text = await response.text();
+    let payload;
+    try { payload = text ? JSON.parse(text) : null; } catch { payload = { message: text }; }
+    if (!response.ok) {
+        const msg = (payload && (payload.message || payload.error)) || `HTTP ${response.status}`;
+        const err = new Error(msg);
+        err.payload = payload;
+        err.status = response.status;
+        throw err;
+    }
+    return payload;
+}
+
+function ckJsDatabase() {
+    return currentDatabase === 'public'
+        ? container.publicCloudDatabase
+        : container.privateCloudDatabase;
+}
+
+function ckJsContainer() {
+    return container;
+}
+
+function csv(value) {
+    return (value || '')
+        .split(',')
+        .map(s => s.trim())
+        .filter(s => s.length > 0);
+}
+
+// ---- image generation (Note.image asset) ----
+
+// Generates a 96×96 PNG with a deterministic-per-call random background and
+// the title's first character — enough variety to verify uploads/rereferences
+// distinguish between notes, without needing the user to pick a file.
+function generateNoteImage(title) {
+    const size = 96;
+    const canvas = document.createElement('canvas');
+    canvas.width = size;
+    canvas.height = size;
+    const ctx = canvas.getContext('2d');
+    const hue = Math.floor(Math.random() * 360);
+    ctx.fillStyle = `hsl(${hue}, 70%, 55%)`;
+    ctx.fillRect(0, 0, size, size);
+    ctx.fillStyle = 'white';
+    ctx.font = 'bold 56px -apple-system, BlinkMacSystemFont, sans-serif';
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'middle';
+    const initial = ((title || '').trim()[0] || '?').toUpperCase();
+    ctx.fillText(initial, size / 2, size / 2 + 2);
+    const base64 = canvas.toDataURL('image/png').split(',', 2)[1];
+    const bin = atob(base64);
+    const bytes = new Uint8Array(bin.length);
+    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+    const blob = new Blob([bytes], { type: 'image/png' });
+    return { base64, blob, byteLength: bytes.length };
+}
+
+// Returns the flat Asset descriptor from an image field, or null when the
+// field is missing/empty. MistKit returns `image` as a bare Asset shape;
+// CloudKit JS wraps it in `{ value: ... }`. Both formats are handled.
+function existingImageDescriptor(note) {
+    const field = note && note.raw && note.raw.fields && note.raw.fields.image;
+    if (!field) return null;
+    const value = (typeof field === 'object' && 'value' in field) ? field.value : field;
+    return (value && typeof value === 'object') ? value : null;
+}
+
+function refreshImageState() {
+    if (pendingImage) {
+        formImagePreviewImg.src = 'data:image/png;base64,' + pendingImage.base64;
+        formImagePreviewImg.style.display = 'block';
+        formImageStatusEl.textContent =
+            `Generated (${pendingImage.byteLength} bytes) — save to upload.`;
+        formImageClearBtn.disabled = false;
+        return;
+    }
+    const existing = existingImageDescriptor(selectedNote());
+    if (existing) {
+        if (existing.downloadURL) {
+            formImagePreviewImg.src = existing.downloadURL;
+            formImagePreviewImg.style.display = 'block';
+        } else {
+            formImagePreviewImg.src = '';
+            formImagePreviewImg.style.display = 'none';
+        }
+        const sizeLabel = existing.size != null ? ` (${existing.size} bytes)` : '';
+        formImageStatusEl.textContent =
+            `Existing image attached${sizeLabel} — Generate to replace.`;
+        formImageClearBtn.disabled = true;
+        return;
+    }
+    formImagePreviewImg.style.display = 'none';
+    formImagePreviewImg.src = '';
+    formImageStatusEl.textContent = 'No image attached.';
+    formImageClearBtn.disabled = true;
+}
+
+formImageGenerateBtn.addEventListener('click', () => {
+    pendingImage = generateNoteImage(titleInput.value);
+    refreshImageState();
+});
+formImageClearBtn.addEventListener('click', () => {
+    pendingImage = null;
+    refreshImageState();
+});
+
+// ---- form state for Notes CRUD ----
+
+function selectedNote() {
+    return notes.find(n => n.recordName === selectedRecordName) || null;
+}
+
+function refreshFormState() {
+    const note = selectedNote();
+    if (note) {
+        formHeading.textContent = 'Edit note';
+        formRecordName.textContent = `· ${note.recordName}`;
+        saveBtn.textContent = 'Save';
+        deleteBtn.disabled = false;
+    } else {
+        formHeading.textContent = 'New note';
+        formRecordName.textContent = '';
+        saveBtn.textContent = 'Create';
+        deleteBtn.disabled = true;
+    }
+}
+
+function clearForm() {
+    selectedRecordName = null;
+    titleInput.value = '';
+    indexInput.value = '';
+    pendingImage = null;
+    clearStatus(formStatusEl);
+    refreshFormState();
+    refreshImageState();
+    renderRows();
+}
+
+function loadNoteIntoForm(note) {
+    selectedRecordName = note.recordName;
+    titleInput.value = note.title ?? '';
+    indexInput.value = note.index != null ? String(note.index) : '';
+    pendingImage = null;
+    if (assetsSourceInput) assetsSourceInput.value = note.recordName;
+    clearStatus(formStatusEl);
+    refreshFormState();
+    refreshImageState();
+    renderRows();
+}
+
+// ---- render Notes table ----
+
+function renderRows() {
+    tbody.innerHTML = '';
+    if (notes.length === 0) {
+        const tr = document.createElement('tr');
+        const td = document.createElement('td');
+        td.colSpan = 5;
+        td.className = 'empty-state';
+        td.textContent = 'No notes — Refresh or Create one.';
+        tr.appendChild(td);
+        tbody.appendChild(tr);
+        return;
+    }
+    for (const note of notes) {
+        const tr = document.createElement('tr');
+        tr.title = note.recordName;
+        if (note.recordName === selectedRecordName) tr.classList.add('selected');
+        tr.addEventListener('click', (e) => {
+            if (e.target.closest('button')) return;
+            loadNoteIntoForm(note);
+        });
+
+        const titleTd = document.createElement('td');
+        titleTd.textContent = note.title ?? '';
+        if (note.createdBy && note.createdBy === currentUserRecordName) {
+            const youBadge = document.createElement('span');
+            youBadge.className = 'badge badge-you';
+            youBadge.textContent = 'You';
+            youBadge.title = `Created by ${note.createdBy}`;
+            titleTd.appendChild(youBadge);
+        }
+        tr.appendChild(titleTd);
+
+        const indexTd = document.createElement('td');
+        indexTd.textContent = note.index != null ? String(note.index) : '';
+        tr.appendChild(indexTd);
+
+        const createdTd = document.createElement('td');
+        createdTd.className = 'timestamp';
+        createdTd.textContent = formatTimestamp(note.created);
+        if (note.created) createdTd.title = note.created.toISOString();
+        tr.appendChild(createdTd);
+
+        const modifiedTd = document.createElement('td');
+        modifiedTd.className = 'timestamp';
+        modifiedTd.textContent = formatTimestamp(note.modified);
+        if (note.modified) modifiedTd.title = note.modified.toISOString();
+        tr.appendChild(modifiedTd);
+
+        const actionsTd = document.createElement('td');
+        actionsTd.className = 'actions';
+        const delBtn = document.createElement('button');
+        delBtn.className = 'danger';
+        delBtn.type = 'button';
+        delBtn.textContent = 'Delete';
+        delBtn.addEventListener('click', () => deleteNote(note));
+        actionsTd.appendChild(delBtn);
+        tr.appendChild(actionsTd);
+
+        tbody.appendChild(tr);
+    }
+}
+
+function refreshSortIndicators() {
+    document.querySelectorAll('th.sortable').forEach(th => {
+        const field = th.dataset.sortField;
+        const isActive = currentSort && currentSort.field === field;
+        th.classList.toggle('active', isActive);
+        const indicator = th.querySelector('.sort-indicator');
+        if (isActive) {
+            indicator.textContent = currentSort.ascending ? '↑' : '↓';
+        } else {
+            indicator.textContent = '';
+        }
+    });
+}
+
+document.querySelectorAll('th.sortable').forEach(th => {
+    th.addEventListener('click', () => {
+        const field = th.dataset.sortField;
+        if (currentSort && currentSort.field === field) {
+            currentSort = currentSort.ascending
+                ? { field, ascending: false }
+                : null;
+        } else {
+            currentSort = { field, ascending: true };
+        }
+        refreshSortIndicators();
+        if (authComplete) queryNotes();
+    });
+});
+
+// ---- payload normalization ----
+
+function normalizeRecords(payload) {
+    const list = (payload && payload.records) || [];
+    return list.map(record => {
+        const fields = record.fields || {};
+        const titleField = fields.title;
+        const indexField = fields.index;
+        return {
+            recordName: record.recordName,
+            recordType: record.recordType,
+            recordChangeTag: record.recordChangeTag,
+            title: titleField && (titleField.value ?? titleField),
+            index: indexField && Number(indexField.value ?? indexField),
+            created: toDate(record.created),
+            modified: toDate(record.modified),
+            createdBy: extractUserRecordName(record.created),
+            raw: record,
+        };
+    });
+}
+
+function extractUserRecordName(value) {
+    if (value == null || typeof value !== 'object' || value instanceof Date) {
+        return null;
+    }
+    return value.userRecordName ?? null;
+}
+
+function toDate(value) {
+    if (value == null) return null;
+    if (value instanceof Date) return value;
+    if (typeof value === 'number') return new Date(value);
+    if (typeof value === 'object') {
+        const inner = value.timestamp;
+        if (inner == null) return null;
+        if (inner instanceof Date) return inner;
+        if (typeof inner === 'number') return new Date(inner);
+        if (typeof inner === 'string') {
+            const parsed = Date.parse(inner);
+            return isNaN(parsed) ? null : new Date(parsed);
+        }
+    }
+    return null;
+}
+
+function formatTimestamp(date) {
+    if (!date) return '—';
+    return date.toLocaleString(undefined, {
+        dateStyle: 'short', timeStyle: 'short',
+    });
+}
+
+function buildFields() {
+    const out = {};
+    const title = titleInput.value.trim();
+    if (title.length > 0) out.title = title;
+    const indexRaw = indexInput.value.trim();
+    if (indexRaw.length > 0) {
+        const parsed = Number(indexRaw);
+        if (!isFinite(parsed)) {
+            throw new Error('Index must be a number.');
+        }
+        out.index = parsed;
+    }
+    return out;
+}
+
+function ckJsFields(fields) {
+    const wrapped = {};
+    for (const [k, v] of Object.entries(fields)) {
+        wrapped[k] = { value: v };
+    }
+    return wrapped;
+}
+
+// ---- Notes CRUD operations ----
+
+async function queryNotes() {
+    if (queryInFlight) return;
+    const recordType = recordTypeInput.value.trim();
+    const limit = parseInt(queryLimitInput.value, 10);
+    queryInFlight = true;
+    setQueryControlsDisabled(true);
+    const dbLabel = currentDatabase === 'public' ? 'public' : 'private';
+    const modeLabel = currentMode === 'mistkit' ? 'MistKit' : 'CloudKit JS';
+    setStatus(tableStatusEl, `Loading ${dbLabel} via ${modeLabel}`, 'loading');
+    try {
+        let payload;
+        if (currentMode === 'mistkit') {
+            payload = await postJSON('/api/records/query', {
+                recordType,
+                database: currentDatabase,
+                limit: isFinite(limit) ? limit : undefined,
+                sortBy: currentSort
+                    ? [{ field: currentSort.field, ascending: currentSort.ascending }]
+                    : undefined,
+            });
+        } else {
+            const query = { recordType };
+            if (currentSort) {
+                query.sortBy = [{
+                    fieldName: currentSort.field,
+                    ascending: currentSort.ascending,
+                }];
+            }
+            payload = await ckJsDatabase().performQuery(query, {
+                resultsLimit: isFinite(limit) ? limit : undefined,
+            });
+            if (payload && payload.hasErrors && payload.errors.length) {
+                throw new Error(payload.errors[0].reason || 'CloudKit JS query failed');
+            }
+        }
+        notes = normalizeRecords(payload);
+        if (selectedRecordName && !notes.some(n => n.recordName === selectedRecordName)) {
+            clearForm();
+        } else {
+            refreshFormState();
+            renderRows();
+        }
+        showRaw(payload);
+        setStatus(tableStatusEl, `Loaded ${notes.length} record${notes.length === 1 ? '' : 's'}.`, 'success');
+    } catch (error) {
+        setStatus(tableStatusEl, `Query failed: ${error.message}`, 'error');
+        showRaw(error.payload || { message: error.message });
+    } finally {
+        queryInFlight = false;
+        setQueryControlsDisabled(false);
+        refreshDatabasePicker();
+    }
+}
+
+function setQueryControlsDisabled(disabled) {
+    const ids = [
+        'refresh-btn', 'db-private', 'db-public',
+        'mode-mistkit', 'mode-cloudkitjs',
+        'save-btn', 'delete-btn',
+    ];
+    for (const id of ids) {
+        const el = document.getElementById(id);
+        if (el) el.disabled = disabled;
+    }
+}
+
+async function saveNote() {
+    let fields;
+    try {
+        fields = buildFields();
+    } catch (error) {
+        setStatus(formStatusEl, error.message, 'error');
+        return;
+    }
+    const recordType = recordTypeInput.value.trim();
+    const note = selectedNote();
+    const isUpdate = note != null;
+    const hasPendingImage = pendingImage != null;
+    if (Object.keys(fields).length === 0 && !hasPendingImage) {
+        setStatus(formStatusEl, 'Provide a title, index, or image.', 'error');
+        return;
+    }
+    const label = isUpdate ? 'Update' : 'Create';
+    clearStatus(formStatusEl);
+    try {
+        let payload;
+        // MistKit asset uploads are a two-step flow: POST bytes to
+        // /api/assets/upload, then create/update with the returned descriptor.
+        // CloudKit JS handles upload inline through saveRecords by passing a
+        // Blob in the field value.
+        let uploadedRecordName = null;
+        if (hasPendingImage && currentMode === 'mistkit') {
+            setStatus(formStatusEl, 'Uploading image…', 'loading');
+            const receipt = await postJSON('/api/assets/upload', {
+                recordType,
+                fieldName: 'image',
+                recordName: isUpdate ? note.recordName : undefined,
+                database: currentDatabase,
+                data: pendingImage.base64,
+            });
+            uploadedRecordName = receipt.recordName;
+            // FieldValue's Asset case decodes the bare Asset shape directly.
+            fields.image = receipt.asset;
+        }
+        if (currentMode === 'mistkit') {
+            if (isUpdate) {
+                payload = await postJSON('/api/records/update', {
+                    recordType,
+                    database: currentDatabase,
+                    recordName: note.recordName,
+                    fields,
+                    recordChangeTag: note.recordChangeTag,
+                });
+            } else {
+                payload = await postJSON('/api/records/create', {
+                    recordType,
+                    database: currentDatabase,
+                    recordName: uploadedRecordName || undefined,
+                    fields,
+                });
+            }
+        } else {
+            const ckFields = ckJsFields(fields);
+            if (hasPendingImage) {
+                // CloudKit JS treats Blob/File values as asset uploads and
+                // attaches them inline during saveRecords.
+                ckFields.image = { value: pendingImage.blob };
+            }
+            const record = { recordType, fields: ckFields };
+            if (isUpdate) {
+                record.recordName = note.recordName;
+                record.recordChangeTag = note.recordChangeTag;
+            }
+            payload = await ckJsDatabase().saveRecords([record]);
+            if (payload && payload.hasErrors && payload.errors.length) {
+                throw new Error(payload.errors[0].reason || 'CloudKit JS save failed');
+            }
+        }
+        pendingImage = null;
+        showRaw(payload);
+        setStatus(formStatusEl, `${label} succeeded.`, 'success');
+        if (!isUpdate) clearForm();
+        if (!isUpdate) {
+            setStatus(
+                formStatusEl,
+                `Created — waiting ${REFRESH_DELAY_MS}ms for CloudKit to settle`,
+                'loading'
+            );
+            await new Promise(r => setTimeout(r, REFRESH_DELAY_MS));
+        }
+        await queryNotes();
+    } catch (error) {
+        setStatus(formStatusEl, `${label} failed: ${error.message}`, 'error');
+        showRaw(error.payload || { message: error.message });
+    }
+}
+
+async function deleteNote(note, statusEl = tableStatusEl) {
+    clearStatus(statusEl);
+    try {
+        let payload;
+        if (currentMode === 'mistkit') {
+            payload = await postJSON('/api/records/delete', {
+                recordType: note.recordType,
+                database: currentDatabase,
+                recordName: note.recordName,
+                recordChangeTag: note.recordChangeTag,
+            });
+        } else {
+            payload = await ckJsDatabase().deleteRecords([{ recordName: note.recordName }]);
+            if (payload && payload.hasErrors && payload.errors.length) {
+                throw new Error(payload.errors[0].reason || 'CloudKit JS delete failed');
+            }
+        }
+        showRaw(payload);
+        setStatus(statusEl, `Deleted ${note.recordName}.`, 'success');
+        if (note.recordName === selectedRecordName) clearForm();
+        await queryNotes();
+    } catch (error) {
+        setStatus(statusEl, `Delete failed: ${error.message}`, 'error');
+        showRaw(error.payload || { message: error.message });
+    }
+}
+
+saveBtn.addEventListener('click', saveNote);
+clearBtn.addEventListener('click', clearForm);
+deleteBtn.addEventListener('click', () => {
+    const note = selectedNote();
+    if (note) deleteNote(note, formStatusEl);
+});
+refreshBtn.addEventListener('click', queryNotes);
+
+refreshFormState();
+refreshSortIndicators();
+refreshImageState();
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/assets.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/assets.js
new file mode 100644
index 00000000..8e60d38d
--- /dev/null
+++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/assets.js
@@ -0,0 +1,72 @@
+// assets/rereference handler embedded in the Notes panel. The asset field
+// is fixed to `image` since the Notes schema has a single ASSET field, so
+// the UI only asks for source + target record names. MistKit POSTs to
+// /api/assets/rereference (server composes assets/rereference +
+// records/modify); CloudKit JS composes the same flow client-side: fetch
+// source → reuse CloudKit.Asset descriptor → save target.
+
+const assetsTargetInput = document.getElementById('assets-target');
+const assetsStatus = document.getElementById('assets-status');
+const assetsRaw = document.getElementById('assets-raw');
+const ASSET_FIELD = 'image';
+
+document.getElementById('assets-rereference-btn').addEventListener('click', async () => {
+    const source = document.getElementById('assets-source')?.value.trim() ?? '';
+    const target = assetsTargetInput.value.trim();
+    if (!source || !target) {
+        setStatus(assetsStatus, 'Provide both a source and a target record name.', 'error');
+        return;
+    }
+    if (source === target) {
+        setStatus(assetsStatus, 'Source and target must be different records.', 'error');
+        return;
+    }
+    if (currentMode === 'mistkit') {
+        setStatus(assetsStatus, 'Rereferencing…', 'loading');
+        try {
+            const payload = await postJSON('/api/assets/rereference', {
+                sourceRecordName: source,
+                assetField: ASSET_FIELD,
+                targetRecordName: target,
+                targetAssetField: ASSET_FIELD,
+                database: currentDatabase,
+            });
+            renderRaw(assetsRaw, payload);
+            setStatus(assetsStatus, `Rereferenced onto ${target}.`, 'success');
+            await queryNotes();
+        } catch (error) {
+            const payload = error.payload || { message: error.message };
+            renderRaw(assetsRaw, payload);
+            setStatus(assetsStatus, `Failed: ${error.message}`, 'error');
+        }
+        return;
+    }
+    setStatus(assetsStatus, 'Fetching source record…', 'loading');
+    try {
+        const fetchPayload = await ckJsDatabase().fetchRecords([source]);
+        if (fetchPayload.hasErrors && fetchPayload.errors.length) {
+            throw new Error(fetchPayload.errors[0].reason || 'Fetch source failed');
+        }
+        const sourceRecord = (fetchPayload.records || [])[0];
+        if (!sourceRecord) throw new Error(`Source record ${source} not found.`);
+        const assetDescriptor = sourceRecord.fields && sourceRecord.fields[ASSET_FIELD];
+        if (!assetDescriptor) {
+            throw new Error(`Field ${ASSET_FIELD} not present on source record.`);
+        }
+        setStatus(assetsStatus, 'Saving target with reused asset…', 'loading');
+        const savePayload = await ckJsDatabase().saveRecords([{
+            recordName: target,
+            recordType: sourceRecord.recordType,
+            fields: { [ASSET_FIELD]: assetDescriptor },
+        }]);
+        if (savePayload.hasErrors && savePayload.errors.length) {
+            throw new Error(savePayload.errors[0].reason || 'Save target failed');
+        }
+        renderRaw(assetsRaw, { fetchSource: fetchPayload, saveTarget: savePayload });
+        setStatus(assetsStatus, `Rereferenced onto ${target}.`, 'success');
+        await queryNotes();
+    } catch (error) {
+        renderRaw(assetsRaw, { message: error.message });
+        setStatus(assetsStatus, `Failed: ${error.message}`, 'error');
+    }
+});
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/auth.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/auth.js
new file mode 100644
index 00000000..ef8ab098
--- /dev/null
+++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/auth.js
@@ -0,0 +1,148 @@
+// CloudKit JS bootstrap + Apple ID auth flow. Polls the internal
+// `_ckSession` token from the CloudKit JS container once the user signs
+// in, then forwards it to `/api/authenticate` so the server can use it
+// for MistKit-mode requests.
+
+function setAuthed(authed) {
+    authComplete = authed;
+    document.querySelectorAll('.pre-auth').forEach(card => {
+        card.classList.toggle('pre-auth', !authed);
+    });
+    signoutButton.style.display = authed ? 'inline-block' : 'none';
+}
+
+async function loadServerConfig() {
+    const response = await fetch('/api/config');
+    if (!response.ok) throw new Error('Failed to load server config: ' + response.status);
+    return response.json();
+}
+
+async function pollWebAuthToken() {
+    const pollIntervalMs = 250;
+    const pollDeadlineMs = 10_000;
+    const pollStart = Date.now();
+    return new Promise((resolve, reject) => {
+        const handle = setInterval(() => {
+            const token = container?._auth?._ckSession;
+            if (token) {
+                clearInterval(handle);
+                resolve(token);
+                return;
+            }
+            if (Date.now() - pollStart >= pollDeadlineMs) {
+                clearInterval(handle);
+                reject(new Error('Timeout waiting for web auth token'));
+            }
+        }, pollIntervalMs);
+    });
+}
+
+async function postAuthenticate(userIdentity, token) {
+    const response = await fetch('/api/authenticate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+            sessionToken: token,
+            userRecordName: userIdentity.userRecordName,
+        }),
+    });
+    if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`);
+    }
+    return response.status;
+}
+
+function renderTokenDisplay(token) {
+    notesCard.style.display = 'none';
+    document.getElementById('signin-area').style.display = 'none';
+    const card = document.createElement('div');
+    card.className = 'card';
+    card.innerHTML = `
+        

Web Auth Token captured

+

Use this token for command-line CloudKit API access:

+
${token}
+

The server has shut down — you can close this window.

+ `; + document.querySelector('.layout').appendChild(card); +} + +async function handleAuthentication(userIdentity) { + if (authenticationInProgress) return; + authenticationInProgress = true; + currentUserRecordName = userIdentity?.userRecordName ?? null; + setStatus(authStatusDiv, 'Capturing web auth token...', 'success'); + try { + const token = await pollWebAuthToken(); + webAuthToken = token; + const status = await postAuthenticate(userIdentity, token); + if (status === 205) { + setStatus(authStatusDiv, 'Authentication complete.', 'success'); + renderTokenDisplay(token); + } else { + setStatus(authStatusDiv, `Authenticated as ${userIdentity.userRecordName}.`, 'success'); + setAuthed(true); + queryNotes(); + } + } catch (error) { + setStatus(authStatusDiv, `Authentication failed: ${error.message}`, 'error'); + } finally { + authenticationInProgress = false; + } +} + +signoutButton.addEventListener('click', async () => { + try { + await container.signOut(); + webAuthToken = null; + currentUserRecordName = null; + setAuthed(false); + notes = []; + clearForm(); + setStatus(authStatusDiv, 'Signed out.', 'success'); + } catch (error) { + setStatus(authStatusDiv, 'Sign out failed: ' + error.message, 'error'); + } +}); + +async function initializeCloudKit() { + try { + if (typeof CloudKit === 'undefined') { + throw new Error('CloudKit.js failed to load'); + } + const serverConfig = await loadServerConfig(); + publicDatabaseAvailable = !!serverConfig.publicDatabaseAvailable; + refreshDatabasePicker(); + CloudKit.configure({ + containers: [{ + containerIdentifier: serverConfig.containerIdentifier, + apiTokenAuth: { + apiToken: serverConfig.apiToken, + persist: true, + signInButton: { id: 'signin-button', theme: 'black' }, + }, + environment: serverConfig.environment || 'development', + }], + }); + container = CloudKit.getDefaultContainer(); + const userIdentity = await container.setUpAuth(); + if (userIdentity) { + setStatus(authStatusDiv, 'Already signed in. Capturing token...', 'success'); + await handleAuthentication(userIdentity); + } else { + setStatus(authStatusDiv, 'Click "Sign In with Apple ID" to authenticate.', 'success'); + } + container.whenUserSignsIn().then((identity) => handleAuthentication(identity)); + container.whenUserSignsOut().then(() => { + webAuthToken = null; + currentUserRecordName = null; + setAuthed(false); + notes = []; + clearForm(); + setStatus(authStatusDiv, 'Signed out.', 'success'); + }); + } catch (error) { + setStatus(authStatusDiv, 'CloudKit setup failed: ' + error.message, 'error'); + } +} + +initializeCloudKit(); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/mode.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/mode.js new file mode 100644 index 00000000..5bff094b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/mode.js @@ -0,0 +1,62 @@ +// Mode + database toggles. Reads `currentMode` / `currentDatabase` / +// `publicDatabaseAvailable` set in app.js and updates the picker +// disabled-state + banner copy. + +document.getElementById('mode-mistkit').addEventListener('click', () => setMode('mistkit')); +document.getElementById('mode-cloudkitjs').addEventListener('click', () => setMode('cloudkitjs')); + +function setMode(mode) { + if (mode === currentMode) return; + currentMode = mode; + document.getElementById('mode-mistkit').classList.toggle('active', mode === 'mistkit'); + document.getElementById('mode-cloudkitjs').classList.toggle('active', mode === 'cloudkitjs'); + modeBadge.textContent = mode === 'mistkit' ? 'MistKit' : 'CloudKit JS'; + refreshDatabasePicker(); + if (authComplete) queryNotes(); +} + +dbPrivateBtn.addEventListener('click', () => setDatabase('private')); +dbPublicBtn.addEventListener('click', () => setDatabase('public')); + +function setDatabase(database) { + if (database === currentDatabase) return; + if (database === 'public' && !isPublicAllowedForCurrentMode()) { + return; + } + currentDatabase = database; + refreshDatabasePicker(); + if (authComplete) queryNotes(); +} + +function isPublicAllowedForCurrentMode() { + return currentMode === 'cloudkitjs' || publicDatabaseAvailable; +} + +function refreshDatabasePicker() { + const publicAllowed = isPublicAllowedForCurrentMode(); + dbPublicBtn.disabled = !publicAllowed; + if (!publicAllowed && currentDatabase === 'public') { + currentDatabase = 'private'; + } + dbPrivateBtn.classList.toggle('active', currentDatabase === 'private'); + dbPublicBtn.classList.toggle('active', currentDatabase === 'public'); + dbBadge.textContent = currentDatabase === 'public' ? 'Public' : 'Private'; + if (!publicDatabaseAvailable && currentMode === 'mistkit') { + dbHint.textContent = + 'MistKit + Public requires server-to-server credentials ' + + '(CLOUDKIT_KEY_ID + CLOUDKIT_PRIVATE_KEY[_PATH]). ' + + 'Restart the server with those set to enable Public on this side.'; + } else if (currentMode === 'mistkit' && currentDatabase === 'public') { + dbHint.textContent = + 'Heads-up: on MistKit + Public, records you write are owned ' + + 'by the server-to-server key, not your iCloud user — so they ' + + 'won’t carry a "You" badge.'; + } else { + dbHint.textContent = + 'Private uses the captured Apple ID web-auth token; Public ' + + 'uses server-to-server signing on the MistKit side and the ' + + 'API token on the CloudKit JS side.'; + } +} + +refreshDatabasePicker(); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/pending.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/pending.js new file mode 100644 index 00000000..eaf534bd --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/pending.js @@ -0,0 +1,28 @@ +// Render the standard "pending #N" banner. Used by panel modules that +// hit endpoints whose MistKit Swift wrapper hasn't landed yet — the +// server returns 501 with a JSON body containing `endpoint` + `tracking`, +// and this module formats that into a yellow advisory above the raw +// response area. + +function renderPendingBanner(statusEl, payload) { + if (!statusEl) return; + const endpoint = (payload && payload.endpoint) || 'unknown'; + const tracking = (payload && payload.tracking) || '#?'; + const msg = + `MistKit support pending for ${endpoint} — tracked in ${tracking}. ` + + `Switch to CloudKit JS mode to exercise this endpoint today.`; + statusEl.className = 'status error'; + statusEl.textContent = msg; + statusEl.style.display = 'block'; +} + +// Treat the structured 501 body as a "pending" response rather than a +// hard error so the panel surfaces the asymmetry visibly. Returns true +// if the payload is a pending-stub body. +function isPendingPayload(payload) { + return payload + && typeof payload === 'object' + && payload.error === 'not_implemented' + && typeof payload.endpoint === 'string' + && typeof payload.tracking === 'string'; +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/records.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/records.js new file mode 100644 index 00000000..1accc599 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/records.js @@ -0,0 +1,120 @@ +// records/lookup · records/changes · records/resolve panel handlers. +// records/query and records/modify are wired in app.js (Notes CRUD). + +const recordsLookupInput = document.getElementById('records-lookup-input'); +const recordsLookupStatus = document.getElementById('records-lookup-status'); +const recordsLookupRaw = document.getElementById('records-lookup-raw'); + +document.getElementById('records-lookup-btn').addEventListener('click', async () => { + const names = csv(recordsLookupInput.value); + if (names.length === 0) { + setStatus(recordsLookupStatus, 'Provide at least one record name.', 'error'); + return; + } + await runPanelOperation({ + statusEl: recordsLookupStatus, + rawEl: recordsLookupRaw, + label: 'Lookup', + fn: async () => { + if (currentMode === 'mistkit') { + // records/lookup MistKit wrapper is implemented but isn't + // exposed on the demo server yet — surface that asymmetry + // by returning a pending banner inline. + return await postJSON('/api/records/lookup', { + database: currentDatabase, + recordNames: names, + }); + } + const payload = await ckJsDatabase().fetchRecords(names); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS lookup failed'); + } + return payload; + }, + }); +}); + +const recordsChangesZone = document.getElementById('records-changes-zone'); +const recordsChangesToken = document.getElementById('records-changes-token'); +const recordsChangesStatus = document.getElementById('records-changes-status'); +const recordsChangesRaw = document.getElementById('records-changes-raw'); + +document.getElementById('records-changes-btn').addEventListener('click', async () => { + const zoneName = recordsChangesZone.value.trim() || '_defaultZone'; + const syncToken = recordsChangesToken.value.trim() || undefined; + await runPanelOperation({ + statusEl: recordsChangesStatus, + rawEl: recordsChangesRaw, + label: 'Fetch changes', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/records/changes', { + database: currentDatabase, + zoneName, + syncToken, + }); + } + const payload = await ckJsDatabase().fetchRecordZoneChanges({ + zoneID: { zoneName }, + syncToken, + }); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS changes failed'); + } + return payload; + }, + }); +}); + +const recordsResolveInput = document.getElementById('records-resolve-input'); +const recordsResolveStatus = document.getElementById('records-resolve-status'); +const recordsResolveRaw = document.getElementById('records-resolve-raw'); + +document.getElementById('records-resolve-btn').addEventListener('click', async () => { + const value = recordsResolveInput.value.trim(); + if (value.length === 0) { + setStatus(recordsResolveStatus, 'Provide a record name or share URL.', 'error'); + return; + } + const isURL = /^https?:\/\//i.test(value); + if (currentMode === 'mistkit') { + // records/resolve MistKit wrapper isn't landed yet (#41). Hit the + // 501 stub to render the pending banner. + setStatus(recordsResolveStatus, 'Resolve…', 'loading'); + try { + const payload = await postJSON('/api/records/resolve', { input: value }); + renderRaw(recordsResolveRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(recordsResolveStatus, payload); + } else { + setStatus(recordsResolveStatus, 'Resolve succeeded.', 'success'); + } + } catch (error) { + const payload = error.payload || { message: error.message }; + renderRaw(recordsResolveRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(recordsResolveStatus, payload); + } else { + setStatus(recordsResolveStatus, `Resolve failed: ${error.message}`, 'error'); + } + } + return; + } + // CloudKit JS composed call — branch on input shape. + await runPanelOperation({ + statusEl: recordsResolveStatus, + rawEl: recordsResolveRaw, + label: isURL ? 'Resolve share URL' : 'Resolve record name', + fn: async () => { + if (isURL) { + const payload = await ckJsContainer().fetchShareMetadataWithURL({ shareURL: value }); + return { branch: 'shareURL', payload }; + } + const payload = await ckJsDatabase().fetchRecords([value]); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS resolve failed'); + } + return { branch: 'recordName', payload }; + }, + }); +}); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/subscriptions.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/subscriptions.js new file mode 100644 index 00000000..1f704db3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/subscriptions.js @@ -0,0 +1,228 @@ +// subscriptions/list · subscriptions/lookup · subscriptions/modify panel +// handlers. MistKit side hits the real /api/subscriptions* routes +// (#49/#50/#51); CloudKit JS side hits the browser SDK primitives. The +// pending-banner branches remain as a defensive fallback. + +const subsListStatus = document.getElementById('subs-list-status'); +const subsListRaw = document.getElementById('subs-list-raw'); +const subsListTbody = document.getElementById('subs-list-tbody'); +const subsLookupStatus = document.getElementById('subs-lookup-status'); +const subsLookupRaw = document.getElementById('subs-lookup-raw'); + +// CloudKit JS `fetchAllSubscriptions` resolves to `{ subscriptions: [...] }`; +// the MistKit route (pending #49) will mirror that shape. Each subscription +// carries `subscriptionID`, `subscriptionType`, and an optional `query` +// (record-type subscriptions) and `zoneID` (zone subscriptions). +function renderSubscriptionsTable(payload) { + const subs = (payload && payload.subscriptions) || []; + renderListTable(subsListTbody, [ + s => s.subscriptionID ?? s.subscriptionId, + s => s.subscriptionType, + s => s.query && s.query.recordType, + s => s.zoneID && s.zoneID.zoneName, + ], Array.isArray(subs) ? subs : [], 'No subscriptions found.'); +} + +document.getElementById('subs-list-btn').addEventListener('click', async () => { + setStatus(subsListStatus, 'Fetching…', 'loading'); + try { + if (currentMode === 'mistkit') { + try { + const payload = await fetchJSON('/api/subscriptions'); + renderRaw(subsListRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsListStatus, payload); + } else { + renderSubscriptionsTable(payload); + setStatus(subsListStatus, 'Loaded.', 'success'); + } + } catch (error) { + const payload = error.payload || { message: error.message }; + renderRaw(subsListRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsListStatus, payload); + } else { + setStatus(subsListStatus, `Failed: ${error.message}`, 'error'); + } + } + return; + } + const payload = await ckJsDatabase().fetchAllSubscriptions(); + renderRaw(subsListRaw, payload); + renderSubscriptionsTable(payload); + setStatus(subsListStatus, 'Loaded.', 'success'); + } catch (error) { + renderRaw(subsListRaw, { message: error.message }); + setStatus(subsListStatus, `Failed: ${error.message}`, 'error'); + } +}); + +document.getElementById('subs-lookup-btn').addEventListener('click', async () => { + const ids = csv(document.getElementById('subs-lookup-input').value); + if (ids.length === 0) { + setStatus(subsLookupStatus, 'Provide at least one subscription ID.', 'error'); + return; + } + setStatus(subsLookupStatus, 'Looking up…', 'loading'); + try { + if (currentMode === 'mistkit') { + try { + const payload = await fetchJSON(`/api/subscriptions/${encodeURIComponent(ids[0])}`); + renderRaw(subsLookupRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsLookupStatus, payload); + } else { + setStatus(subsLookupStatus, 'Loaded.', 'success'); + } + } catch (error) { + const payload = error.payload || { message: error.message }; + renderRaw(subsLookupRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsLookupStatus, payload); + } else { + setStatus(subsLookupStatus, `Failed: ${error.message}`, 'error'); + } + } + return; + } + const payload = await ckJsDatabase().fetchSubscriptions(ids); + renderRaw(subsLookupRaw, payload); + setStatus(subsLookupStatus, 'Loaded.', 'success'); + } catch (error) { + renderRaw(subsLookupRaw, { message: error.message }); + setStatus(subsLookupStatus, `Failed: ${error.message}`, 'error'); + } +}); + +// ---- Create (subscriptions/modify) ---- + +const subsCreateType = document.getElementById('subs-create-type'); +const subsCreateQueryFields = document.getElementById('subs-create-query-fields'); +const subsCreateZoneFields = document.getElementById('subs-create-zone-fields'); +const subsCreateStatus = document.getElementById('subs-create-status'); +const subsCreateRaw = document.getElementById('subs-create-raw'); + +// Toggle the type-specific input row to match the selected subscription type. +function refreshSubsCreateFields() { + const isZone = subsCreateType.value === 'zone'; + subsCreateQueryFields.style.display = isZone ? 'none' : ''; + subsCreateZoneFields.style.display = isZone ? '' : 'none'; +} +subsCreateType.addEventListener('change', refreshSubsCreateFields); +refreshSubsCreateFields(); + +// Build a CloudKit.Subscription dictionary from the form. Throws with a +// user-facing message when a required field for the chosen type is missing. +function buildSubscription() { + const type = subsCreateType.value; + const id = document.getElementById('subs-create-id').value.trim(); + const subscription = { subscriptionType: type }; + if (id) subscription.subscriptionID = id; + if (type === 'zone') { + const zoneName = document.getElementById('subs-create-zone').value.trim(); + if (!zoneName) throw new Error('Provide a zone name.'); + subscription.zoneID = { zoneName }; + } else { + const recordType = document.getElementById('subs-create-record-type').value; + const firesOn = Array.from( + document.querySelectorAll('.subs-fires-on:checked') + ).map(cb => cb.value); + if (!firesOn.length) throw new Error('Select at least one "Fires on" operation.'); + subscription.firesOn = firesOn; + subscription.query = { recordType }; + } + return subscription; +} + +document.getElementById('subs-create-btn').addEventListener('click', async () => { + let subscription; + try { + subscription = buildSubscription(); + } catch (error) { + setStatus(subsCreateStatus, error.message, 'error'); + return; + } + setStatus(subsCreateStatus, 'Creating…', 'loading'); + try { + if (currentMode === 'mistkit') { + // subscriptions/modify MistKit wrapper isn't landed yet (#51) — + // hit the 501 stub and render the pending banner inline. + try { + const payload = await postJSON('/api/subscriptions/modify', subscription); + renderRaw(subsCreateRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsCreateStatus, payload); + } else { + setStatus(subsCreateStatus, 'Created.', 'success'); + } + } catch (error) { + const payload = error.payload || { message: error.message }; + renderRaw(subsCreateRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsCreateStatus, payload); + } else { + setStatus(subsCreateStatus, `Failed: ${error.message}`, 'error'); + } + } + return; + } + const payload = await ckJsDatabase().saveSubscriptions(subscription); + renderRaw(subsCreateRaw, payload); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS save subscription failed'); + } + setStatus(subsCreateStatus, 'Created.', 'success'); + } catch (error) { + renderRaw(subsCreateRaw, error.payload || { message: error.message }); + setStatus(subsCreateStatus, `Failed: ${error.message}`, 'error'); + } +}); + +// ---- Delete (subscriptions/modify) ---- + +const subsDeleteStatus = document.getElementById('subs-delete-status'); +const subsDeleteRaw = document.getElementById('subs-delete-raw'); + +document.getElementById('subs-delete-btn').addEventListener('click', async () => { + const ids = csv(document.getElementById('subs-delete-input').value); + if (ids.length === 0) { + setStatus(subsDeleteStatus, 'Provide at least one subscription ID.', 'error'); + return; + } + setStatus(subsDeleteStatus, 'Deleting…', 'loading'); + try { + if (currentMode === 'mistkit') { + // CloudKit Web Services models delete as part of subscriptions/modify, + // whose MistKit wrapper isn't landed yet (#51) — hit the 501 stub. + try { + const payload = await postJSON('/api/subscriptions/modify', { + delete: ids.map(id => ({ subscriptionID: id })), + }); + renderRaw(subsDeleteRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsDeleteStatus, payload); + } else { + setStatus(subsDeleteStatus, 'Deleted.', 'success'); + } + } catch (error) { + const payload = error.payload || { message: error.message }; + renderRaw(subsDeleteRaw, payload); + if (isPendingPayload(payload)) { + renderPendingBanner(subsDeleteStatus, payload); + } else { + setStatus(subsDeleteStatus, `Failed: ${error.message}`, 'error'); + } + } + return; + } + const payload = await ckJsDatabase().deleteSubscriptions(ids); + renderRaw(subsDeleteRaw, payload); + if (payload && payload.hasErrors && payload.errors.length) { + throw new Error(payload.errors[0].reason || 'CloudKit JS delete subscription failed'); + } + setStatus(subsDeleteStatus, 'Deleted.', 'success'); + } catch (error) { + renderRaw(subsDeleteRaw, error.payload || { message: error.message }); + setStatus(subsDeleteStatus, `Failed: ${error.message}`, 'error'); + } +}); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js new file mode 100644 index 00000000..de697b6a --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/tokens.js @@ -0,0 +1,133 @@ +// tokens/create + tokens/register panel handler. The CloudKit JS SDK +// combines token creation and registration into a single +// `container.registerForNotifications()` call (and surfaces incoming +// notifications via `addNotificationListener`). MistKit-side hits the real +// /api/tokens (#52) and /api/tokens/register (#53) routes in sequence, +// feeding the minted token into the register step. + +const tokensStatus = document.getElementById('tokens-status'); +const tokensRaw = document.getElementById('tokens-raw'); + +// MistKit mode has no SDK listener, so we mirror CloudKit JS's +// `addNotificationListener` by hand: long-poll the `webcourierURL` returned by +// /api/tokens. The courier is consume-on-delivery (one notification per +// response, then it closes), so we re-fetch in a loop; an empty body is a +// keepalive/timeout — just poll again. (Verified wire format: see #379 and +// WEB_COURIER_SPIKE.md.) +// +// CAVEAT: this fetches cross-origin against Apple's courier host. CloudKit JS +// polls the same host from the browser, but if CORS blocks a hand-rolled fetch, +// route the poll through a server proxy instead (the server already holds the +// webcourierURL). The catch below surfaces that failure with a hint. +let courierListener = null; + +function stopCourierListener() { + if (courierListener) { + courierListener.abort(); + courierListener = null; + } +} + +// Map the courier wire payload ({ aps, ck }) onto the documented +// CloudKit.Notification fields — the JS twin of Swift's CourierNotification. +function decodeCourierNotification(payload) { + const ck = (payload && payload.ck) || {}; + const qry = ck.qry || {}; + const reasons = { 1: 'recordCreated', 2: 'recordUpdated', 3: 'recordDeleted' }; + return { + notificationID: ck.nid, + containerIdentifier: ck.cid, + subscriptionID: qry.sid, + recordName: qry.rid, + zoneID: qry.zid, + reason: reasons[qry.fo] || qry.fo, + alertBody: payload && payload.aps && payload.aps.alert, + }; +} + +async function listenOnCourier(webcourierURL) { + stopCourierListener(); + const controller = new AbortController(); + courierListener = controller; + while (!controller.signal.aborted) { + let response; + try { + response = await fetch(webcourierURL, { signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) { return; } + renderRaw(tokensRaw, { + courierError: error.message, + hint: 'If this is a CORS failure, proxy the courier poll through the server.', + }); + return; + } + const body = (await response.text()).trim(); + if (!body) { continue; } // keepalive/timeout — re-poll + try { + renderRaw(tokensRaw, { lastNotification: decodeCourierNotification(JSON.parse(body)) }); + } catch (_parseError) { + renderRaw(tokensRaw, { lastNotificationRaw: body }); + } + } +} + +document.getElementById('tokens-register-btn').addEventListener('click', async () => { + if (currentMode === 'mistkit') { + // Both create + register are pending. Hit both 501s sequentially and + // render the combined response so the asymmetry vs CloudKit JS is + // visible on a single panel. + setStatus(tokensStatus, 'Registering…', 'loading'); + const result = { create: null, register: null }; + try { + result.create = await postJSON('/api/tokens', {}); + } catch (error) { result.create = error.payload || { message: error.message }; } + // tokens/register takes the apnsToken minted by tokens/create — feed + // the created token forward so the two-step REST flow is exercised + // end-to-end (CloudKit JS rolls both into registerForNotifications()). + const createdToken = result.create && result.create.apnsToken; + const createdEnvironment = + (result.create && result.create.apnsEnvironment) || 'development'; + try { + result.register = await postJSON('/api/tokens/register', + createdToken + ? { apnsToken: createdToken, apnsEnvironment: createdEnvironment } + : {}); + } catch (error) { result.register = error.payload || { message: error.message }; } + renderRaw(tokensRaw, result); + if (isPendingPayload(result.create) || isPendingPayload(result.register)) { + renderPendingBanner(tokensStatus, result.create || result.register); + return; + } + // Mirror registerForNotifications(): once the token is minted, start + // listening on its courier URL so incoming pushes render live. + const webcourierURL = result.create && result.create.webcourierURL; + if (webcourierURL) { + setStatus(tokensStatus, 'Registered — listening for notifications…', 'success'); + listenOnCourier(webcourierURL); + } else { + setStatus(tokensStatus, 'Registered.', 'success'); + } + return; + } + + setStatus(tokensStatus, 'Registering for notifications…', 'loading'); + try { + // Switching to the SDK listener — stop any hand-rolled MistKit poll. + stopCourierListener(); + const result = await ckJsContainer().registerForNotifications(); + renderRaw(tokensRaw, result); + setStatus(tokensStatus, 'Registered.', 'success'); + try { + ckJsContainer().addNotificationListener((notification) => { + renderRaw(tokensRaw, { lastNotification: notification }); + }); + } catch (_listenerError) { + // addNotificationListener is best-effort; older CloudKit JS + // versions don't expose it. Don't fail the panel if the + // listener wire-up fails. + } + } catch (error) { + renderRaw(tokensRaw, { message: error.message }); + setStatus(tokensStatus, `Failed: ${error.message}`, 'error'); + } +}); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/users.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/users.js new file mode 100644 index 00000000..47e518cc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/users.js @@ -0,0 +1,62 @@ +// users/caller · users/discover panel handlers. The deprecated +// users/lookup/email and users/lookup/id primitives are not exposed — +// users/discover is Apple's supported replacement and handles both email +// and record-name lookups (phone-number support tracked in #398). + +const usersCallerStatus = document.getElementById('users-caller-status'); +const usersCallerRaw = document.getElementById('users-caller-raw'); +const usersDiscoverStatus = document.getElementById('users-discover-status'); +const usersDiscoverRaw = document.getElementById('users-discover-raw'); + +document.getElementById('users-caller-btn').addEventListener('click', async () => { + await runPanelOperation({ + statusEl: usersCallerStatus, + rawEl: usersCallerRaw, + label: 'Fetch caller', + fn: async () => { + if (currentMode === 'mistkit') { + return await fetchJSON('/api/users/caller'); + } + return await ckJsContainer().fetchCurrentUserIdentity(); + }, + }); +}); + +document.getElementById('users-discover-btn').addEventListener('click', async () => { + const emails = csv(document.getElementById('users-discover-emails').value); + const userRecordNames = csv(document.getElementById('users-discover-record-names').value); + if (emails.length === 0 && userRecordNames.length === 0) { + setStatus(usersDiscoverStatus, 'Provide at least one email or record name.', 'error'); + return; + } + await runPanelOperation({ + statusEl: usersDiscoverStatus, + rawEl: usersDiscoverRaw, + label: 'Discover users', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/users/discover', { emails, userRecordNames }); + } + // CloudKit JS exposes per-item primitives — loop and aggregate + // to match the REST endpoint's batch shape. + const results = []; + for (const email of emails) { + try { + const identity = await ckJsContainer().discoverUserIdentityWithEmailAddress(email); + results.push({ email, identity }); + } catch (error) { + results.push({ email, error: error.message }); + } + } + for (const recordName of userRecordNames) { + try { + const identity = await ckJsContainer().discoverUserIdentityWithUserRecordName(recordName); + results.push({ userRecordName: recordName, identity }); + } catch (error) { + results.push({ userRecordName: recordName, error: error.message }); + } + } + return { discovered: results }; + }, + }); +}); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/zones.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/zones.js new file mode 100644 index 00000000..8c8bb555 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/zones.js @@ -0,0 +1,135 @@ +// zones/list · zones/lookup · zones/modify · zones/changes panel handlers. +// zones/modify is wired on the demo server (POST /api/zones/modify), so the +// MistKit-mode Create/Delete buttons hit the real CloudKitService. The other +// three (list/lookup/changes) have landed MistKit wrappers (#215, #45, #48, +// #367) but aren't yet exposed on the server, so MistKit-mode calls to them +// still 404. CloudKit JS calls are fully exercisable today. + +const zonesListStatus = document.getElementById('zones-list-status'); +const zonesListRaw = document.getElementById('zones-list-raw'); +const zonesListTbody = document.getElementById('zones-list-tbody'); +const zonesLookupStatus = document.getElementById('zones-lookup-status'); +const zonesLookupRaw = document.getElementById('zones-lookup-raw'); +const zonesModifyStatus = document.getElementById('zones-modify-status'); +const zonesModifyRaw = document.getElementById('zones-modify-raw'); +const zonesChangesStatus = document.getElementById('zones-changes-status'); +const zonesChangesRaw = document.getElementById('zones-changes-raw'); + +// Both the MistKit (`/api/zones/list`) and CloudKit JS +// (`fetchAllRecordZones`) responses wrap the zone array under `zones`; +// CloudKit JS nests the name under `zoneID.zoneName`, MistKit returns a +// flat `zoneName`, so read both. +function renderZonesTable(payload) { + const zones = (payload && payload.zones) || []; + renderListTable(zonesListTbody, [ + z => (z.zoneID && z.zoneID.zoneName) ?? z.zoneName, + z => z.zoneID && z.zoneID.ownerRecordName, + z => (z.atomic != null ? String(z.atomic) : ''), + ], Array.isArray(zones) ? zones : [], 'No zones found.'); +} + +document.getElementById('zones-list-btn').addEventListener('click', async () => { + const payload = await runPanelOperation({ + statusEl: zonesListStatus, + rawEl: zonesListRaw, + label: 'List zones', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/zones/list', { database: currentDatabase }); + } + return await ckJsDatabase().fetchAllRecordZones(); + }, + }); + if (payload) renderZonesTable(payload); +}); + +document.getElementById('zones-lookup-btn').addEventListener('click', async () => { + const zoneNames = csv(document.getElementById('zones-lookup-input').value); + if (zoneNames.length === 0) { + setStatus(zonesLookupStatus, 'Provide at least one zone name.', 'error'); + return; + } + await runPanelOperation({ + statusEl: zonesLookupStatus, + rawEl: zonesLookupRaw, + label: 'Lookup zones', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/zones/lookup', { + database: currentDatabase, + zoneNames, + }); + } + const zoneIDs = zoneNames.map(name => ({ zoneName: name })); + return await ckJsDatabase().fetchRecordZones(zoneIDs); + }, + }); +}); + +document.getElementById('zones-modify-create-btn').addEventListener('click', async () => { + const zoneName = document.getElementById('zones-modify-create').value.trim(); + if (zoneName.length === 0) { + setStatus(zonesModifyStatus, 'Provide a zone name to create.', 'error'); + return; + } + await runPanelOperation({ + statusEl: zonesModifyStatus, + rawEl: zonesModifyRaw, + label: 'Create zone', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/zones/modify', { + database: currentDatabase, + create: [{ zoneName }], + }); + } + return await ckJsDatabase().saveRecordZones([{ zoneID: { zoneName } }]); + }, + }); +}); + +document.getElementById('zones-modify-delete-btn').addEventListener('click', async () => { + const zoneName = document.getElementById('zones-modify-delete').value.trim(); + if (zoneName.length === 0) { + setStatus(zonesModifyStatus, 'Provide a zone name to delete.', 'error'); + return; + } + await runPanelOperation({ + statusEl: zonesModifyStatus, + rawEl: zonesModifyRaw, + label: 'Delete zone', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/zones/modify', { + database: currentDatabase, + delete: [{ zoneName }], + }); + } + return await ckJsDatabase().deleteRecordZones([{ zoneName }]); + }, + }); +}); + +document.getElementById('zones-changes-btn').addEventListener('click', async () => { + const token = document.getElementById('zones-changes-token').value.trim() || undefined; + await runPanelOperation({ + statusEl: zonesChangesStatus, + rawEl: zonesChangesRaw, + label: 'Zone changes', + fn: async () => { + if (currentMode === 'mistkit') { + return await postJSON('/api/zones/changes', { + database: currentDatabase, + syncToken: token, + }); + } + // CloudKit JS doesn't expose a direct zones/changes — the + // equivalent is composed per-zone via fetchRecordChanges, so + // surface that pedagogical asymmetry inline. + // CloudKit JS does expose a database-level changes primitive + // (`fetchDatabaseChanges`, returning changed record zones), which + // is the closest analog to the REST zones/changes endpoint. + return await ckJsDatabase().fetchDatabaseChanges({ syncToken: token }); + }, + }); +}); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/styles.css b/Examples/MistDemo/Sources/MistDemoKit/Resources/styles.css new file mode 100644 index 00000000..147d7301 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/styles.css @@ -0,0 +1,308 @@ +:root { + --bg: #f5f5f7; + --card: #ffffff; + --ink: #1d1d1f; + --muted: #6e6e73; + --accent: #0369a1; + --accent-dark: #0c4a6e; + --danger: #c00; + --danger-bg: #fdd; + --success-bg: #d1f5d3; + --success-fg: #1d5e20; + --border: #d0d7de; + --row-hover: #f0f4f8; + --row-selected: #dbeafe; +} +* { box-sizing: border-box; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + margin: 0; + padding: 24px; + background-color: var(--bg); + color: var(--ink); +} +.layout { + max-width: 1100px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; +} +.card { + background: var(--card); + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} +h1 { font-size: 24px; margin: 0 0 4px; } +h2 { font-size: 18px; margin: 0 0 12px; } +h3 { font-size: 15px; margin: 0 0 8px; } +p { color: var(--muted); margin: 0 0 16px; line-height: 1.5; } +label { display: block; font-size: 13px; font-weight: 600; margin: 12px 0 4px; } +input, textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} +textarea { font-family: 'SF Mono', Menlo, monospace; font-size: 13px; resize: vertical; min-height: 60px; } +select { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + font-family: inherit; + background: #fff; +} +/* Checkboxes/radios must not inherit the full-width `input` rule above. */ +input[type="checkbox"], input[type="radio"] { width: auto; } +.field-label { display: inline-flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } +.fires-on { + display: flex; + gap: 14px; + align-items: center; + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px; + margin: 0; +} +.fires-on legend { font-size: 12px; color: var(--muted); padding: 0 4px; } +.fires-on label { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--ink); } +button { + background: var(--accent); + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 600; +} +button:hover:not(:disabled) { background: var(--accent-dark); } +button:disabled { opacity: 0.45; cursor: not-allowed; } +button.secondary { + background: transparent; + color: var(--ink); + border: 1px solid var(--border); + font-weight: 500; +} +button.secondary:hover:not(:disabled) { background: var(--row-hover); } +button.danger { background: var(--danger); } +button.danger:hover:not(:disabled) { background: #900; } +.status { + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + font-size: 13px; + display: none; +} +.status.success { background: var(--success-bg); color: var(--success-fg); display: block; } +.status.error { background: var(--danger-bg); color: var(--danger); display: block; } +.status.loading { background: var(--row-hover); color: var(--muted); display: block; } +.status.loading::after { + content: '…'; + display: inline-block; + margin-left: 4px; + animation: status-loading-dots 1.2s steps(4, end) infinite; + width: 1ch; + overflow: hidden; + vertical-align: bottom; +} +@keyframes status-loading-dots { + 0% { width: 0; } + 100% { width: 1ch; } +} +pre { + background: #0d1117; + color: #c9d1d9; + padding: 12px; + border-radius: 6px; + font-size: 12px; + overflow: auto; + margin: 8px 0 0; + max-height: 240px; + max-width: 100%; + /* Wrap long unbreakable strings (e.g. single-line JSON error payloads) + so the block never pushes past its card. */ + white-space: pre-wrap; + word-break: break-word; +} +.mode-toggle { + display: flex; + gap: 8px; + margin: 12px 0 4px; +} +.mode-toggle button { + background: transparent; + color: var(--ink); + border: 1px solid var(--border); + font-weight: 500; +} +.mode-toggle button.active { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.mode-hint { font-size: 12px; color: var(--muted); margin-top: 4px; } +.notes-grid { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + gap: 24px; + align-items: start; +} +@media (max-width: 820px) { + .notes-grid { grid-template-columns: 1fr; } +} +.notes-form-stack { + display: flex; + flex-direction: column; + gap: 24px; + min-width: 0; +} +.notes-form-stack > section + section { + border-top: 1px solid var(--border); + padding-top: 16px; +} +.image-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin: 4px 0 8px; +} +.image-state { font-size: 12px; color: var(--muted); } +.image-preview { + max-width: 96px; + max-height: 96px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 8px; +} +.table-toolbar { + display: flex; + align-items: end; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.table-toolbar label { margin: 0 0 4px; } +.table-toolbar .limit-field { width: 100px; } +.table-wrap { + border: 1px solid var(--border); + border-radius: 8px; + overflow: auto; + max-height: 480px; +} +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border); +} +th { + position: sticky; + top: 0; + background: var(--row-hover); + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} +th.sortable { cursor: pointer; user-select: none; white-space: nowrap; } +th.sortable:hover { color: var(--accent); } +th.sortable.active { color: var(--accent); } +.sort-indicator { display: inline-block; width: 12px; margin-left: 4px; } +td.timestamp { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} +tbody tr { cursor: pointer; } +tbody tr:hover { background: var(--row-hover); } +tbody tr.selected { background: var(--row-selected); } +td.record-name { + font-family: 'SF Mono', Menlo, monospace; + font-size: 12px; + color: var(--muted); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +td.actions { width: 1%; white-space: nowrap; } +td.actions button { padding: 4px 10px; font-size: 12px; } +.empty-state { + padding: 24px; + text-align: center; + color: var(--muted); + font-size: 13px; +} +.form-actions { + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; +} +.form-mode-label { + font-size: 12px; + color: var(--muted); + font-weight: 500; +} +.pre-auth { opacity: 0.5; pointer-events: none; } +#signin-area { margin-top: 8px; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; background: #eef; color: var(--accent); } +.badge-you { background: var(--success-bg); color: var(--success-fg); margin-left: 8px; } +.raw-response summary { font-size: 12px; color: var(--muted); cursor: pointer; margin-top: 12px; } +.raw-response[open] summary { margin-bottom: 4px; } + +/* New panel styles for v1.0.0-beta.2 surface */ +.panel-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} +/* Allow grid tracks to shrink below their content's intrinsic width so a + long line inside a child (e.g. a
) can't widen the whole card. */
+.panel-grid > section { min-width: 0; }
+.panel-row {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  align-items: end;
+}
+.panel-row > input,
+.panel-row > textarea { flex: 1; min-width: 180px; }
+.composition {
+  border-left: 3px solid var(--accent);
+  padding: 8px 12px;
+  margin-top: 12px;
+  background: var(--row-hover);
+  border-radius: 4px;
+  font-size: 12px;
+  color: var(--muted);
+}
+.composition summary {
+  cursor: pointer;
+  font-weight: 600;
+  color: var(--ink);
+}
+.composition ol { margin: 6px 0 0 18px; padding: 0; }
+.composition li { margin-bottom: 2px; font-family: 'SF Mono', Menlo, monospace; }
+.endpoint-label {
+  font-family: 'SF Mono', Menlo, monospace;
+  font-size: 11px;
+  color: var(--muted);
+  margin-left: 6px;
+}
+.pending-banner {
+  background: #fffbe6;
+  border: 1px solid #ffe58f;
+  color: #ad8b00;
+  padding: 8px 12px;
+  border-radius: 6px;
+  font-size: 12px;
+  margin-top: 8px;
+}
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Reads.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Reads.swift
new file mode 100644
index 00000000..4c12fc8a
--- /dev/null
+++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Reads.swift
@@ -0,0 +1,89 @@
+//
+//  CloudKitService+WebBackend+Reads.swift
+//  MistDemo
+//
+//  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 MistKit
+
+// Read-side `WebBackend` conformance for records and zones: lookup, changes,
+// and zone listing. The primary conformance declaration lives in
+// `CloudKitService+WebBackend.swift`.
+extension CloudKitService {
+  internal func webLookupRecords(
+    recordNames: [String],
+    database: MistKit.Database
+  ) async throws -> [RecordInfo] {
+    let results = try await lookupRecords(
+      recordNames: recordNames,
+      desiredKeys: nil,
+      database: database
+    )
+    // All-or-nothing: `lookupRecords` returns a per-record `[RecordResult]`,
+    // but the demo collapses it — any single failure (e.g. CloudKit's
+    // NOT_FOUND) throws, so the web panel shows the error rather than
+    // silently returning fewer rows than were asked for. Surfacing partial
+    // results (found records alongside per-record failures) is a possible
+    // future enhancement.
+    return try results.map { try $0.get() }
+  }
+
+  internal func webRecordChanges(
+    zoneName: String?,
+    syncToken: String?,
+    database: MistKit.Database
+  ) async throws -> RecordChangesResult {
+    try await fetchRecordChanges(
+      zoneID: zoneName.map { ZoneID(zoneName: $0) },
+      syncToken: syncToken,
+      database: database
+    )
+  }
+
+  internal func webListZones(
+    database: MistKit.Database
+  ) async throws -> [ZoneInfo] {
+    try await listZones(database: database)
+  }
+
+  internal func webLookupZones(
+    zoneNames: [String],
+    database: MistKit.Database
+  ) async throws -> [ZoneInfo] {
+    try await lookupZones(
+      zoneIDs: zoneNames.map { ZoneID(zoneName: $0) },
+      database: database
+    )
+  }
+
+  internal func webZoneChanges(
+    syncToken: String?,
+    database: MistKit.Database
+  ) async throws -> ZoneChangesResult {
+    try await fetchZoneChanges(syncToken: syncToken, database: database)
+  }
+}
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Users.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Users.swift
new file mode 100644
index 00000000..9704fd2b
--- /dev/null
+++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Users.swift
@@ -0,0 +1,51 @@
+//
+//  CloudKitService+WebBackend+Users.swift
+//  MistDemo
+//
+//  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 MistKit
+
+// User-identity `WebBackend` conformance. These operate on the public
+// database with web-auth credentials, so none take a `database` argument.
+// The primary conformance declaration lives in
+// `CloudKitService+WebBackend.swift`.
+extension CloudKitService {
+  internal func webFetchCaller() async throws -> UserInfo {
+    try await fetchCaller()
+  }
+
+  internal func webDiscoverUsers(
+    emails: [String],
+    userRecordNames: [String]
+  ) async throws -> [UserIdentity] {
+    let lookupInfos =
+      emails.map { UserIdentityLookupInfo(emailAddress: $0) }
+      + userRecordNames.map { UserIdentityLookupInfo(userRecordName: $0) }
+    return try await discoverUserIdentities(lookupInfos: lookupInfos)
+  }
+}
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift
new file mode 100644
index 00000000..1faa9b33
--- /dev/null
+++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift
@@ -0,0 +1,191 @@
+//
+//  CloudKitService+WebBackend.swift
+//  MistDemo
+//
+//  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 MistKit
+
+extension CloudKitService: WebBackend {
+  internal func webQuery(
+    recordType: String,
+    limit: Int?,
+    sortBy: [WebRequests.QuerySortField]?,
+    database: MistKit.Database
+  ) async throws -> [RecordInfo] {
+    let querySorts = sortBy?.map { sort in
+      QuerySort.sort(sort.field, ascending: sort.ascending)
+    }
+    let result = try await queryRecords(
+      recordType: recordType,
+      filters: nil,
+      sortBy: querySorts,
+      limit: limit,
+      desiredKeys: nil,
+      continuationMarker: nil,
+      database: database
+    )
+    return result.records
+  }
+
+  internal func webCreate(
+    recordType: String,
+    recordName: String?,
+    fields: [String: FieldValue],
+    database: MistKit.Database
+  ) async throws -> RecordInfo {
+    try await createRecord(
+      recordType: recordType,
+      recordName: recordName,
+      fields: fields,
+      database: database
+    )
+  }
+
+  internal func webUpdate(
+    recordType: String,
+    recordName: String,
+    fields: [String: FieldValue],
+    recordChangeTag: String?,
+    database: MistKit.Database
+  ) async throws -> RecordInfo {
+    try await updateRecord(
+      recordType: recordType,
+      recordName: recordName,
+      fields: fields,
+      recordChangeTag: recordChangeTag,
+      database: database
+    )
+  }
+
+  internal func webDelete(
+    recordType: String,
+    recordName: String,
+    recordChangeTag: String?,
+    database: MistKit.Database
+  ) async throws {
+    try await deleteRecord(
+      recordType: recordType,
+      recordName: recordName,
+      recordChangeTag: recordChangeTag,
+      database: database
+    )
+  }
+
+  internal func webModifyZones(
+    create: [String],
+    delete: [String],
+    database: MistKit.Database
+  ) async throws -> [ZoneInfo] {
+    let operations =
+      create.map { ZoneOperation.create(ZoneID(zoneName: $0)) }
+      + delete.map { ZoneOperation.delete(ZoneID(zoneName: $0)) }
+    return try await modifyZones(operations, database: database)
+  }
+
+  internal func webListSubscriptions(
+    database: MistKit.Database
+  ) async throws -> [SubscriptionInfo] {
+    try await listSubscriptions(database: database)
+  }
+
+  internal func webLookupSubscriptions(
+    ids: [String],
+    database: MistKit.Database
+  ) async throws -> [SubscriptionInfo] {
+    try await lookupSubscriptions(ids: ids, database: database)
+  }
+
+  internal func webModifySubscriptions(
+    operations: [SubscriptionOperation],
+    database: MistKit.Database
+  ) async throws -> [SubscriptionInfo] {
+    let results = try await modifySubscriptions(operations, database: database)
+    // Surface any per-subscription failure (e.g. CloudKit's INTERNAL_ERROR on
+    // create) as a thrown error so the web panel shows it — matching what
+    // CloudKit JS reports — rather than silently returning fewer rows.
+    return try results.map { try $0.get() }
+  }
+
+  internal func webCreateToken(
+    environment: APNsEnvironment,
+    clientId: String?,
+    database: MistKit.Database
+  ) async throws -> APNsTokenResult {
+    try await createAPNsToken(
+      environment: environment,
+      clientId: clientId,
+      database: database
+    )
+  }
+
+  internal func webRegisterToken(
+    apnsToken: String,
+    environment: APNsEnvironment,
+    clientId: String?,
+    database: MistKit.Database
+  ) async throws {
+    try await registerAPNsToken(
+      apnsToken,
+      environment: environment,
+      clientId: clientId,
+      database: database
+    )
+  }
+
+  internal func webRereferenceAsset(
+    sourceRecordName: String,
+    assetField: String,
+    targetRecordName: String,
+    targetAssetField: String?,
+    database: MistKit.Database
+  ) async throws -> RecordInfo {
+    try await rereferenceAsset(
+      fromRecord: sourceRecordName,
+      field: assetField,
+      toRecord: targetRecordName,
+      field: targetAssetField,
+      database: database
+    )
+  }
+
+  internal func webUploadAsset(
+    data: Data,
+    recordType: String,
+    fieldName: String,
+    recordName: String?,
+    database: MistKit.Database
+  ) async throws -> AssetUploadReceipt {
+    try await uploadAssets(
+      data: data,
+      recordType: recordType,
+      fieldName: fieldName,
+      recordName: recordName,
+      database: database
+    )
+  }
+}
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift
index ff8039fd..2669072b 100644
--- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift
+++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift
@@ -48,6 +48,7 @@ internal protocol WebBackend: Sendable {
 
   func webCreate(
     recordType: String,
+    recordName: String?,
     fields: [String: FieldValue],
     database: MistKit.Database
   ) async throws -> RecordInfo
@@ -66,70 +67,88 @@ internal protocol WebBackend: Sendable {
     recordChangeTag: String?,
     database: MistKit.Database
   ) async throws
-}
 
-@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *)
-extension CloudKitService: WebBackend {
-  internal func webQuery(
-    recordType: String,
-    limit: Int?,
-    sortBy: [WebRequests.QuerySortField]?,
+  func webLookupRecords(
+    recordNames: [String],
     database: MistKit.Database
-  ) async throws -> [RecordInfo] {
-    let querySorts = sortBy?.map { sort in
-      QuerySort.sort(sort.field, ascending: sort.ascending)
-    }
-    let result = try await queryRecords(
-      recordType: recordType,
-      filters: nil,
-      sortBy: querySorts,
-      limit: limit,
-      desiredKeys: nil,
-      continuationMarker: nil,
-      database: database
-    )
-    return result.records
-  }
-
-  internal func webCreate(
-    recordType: String,
-    fields: [String: FieldValue],
+  ) async throws -> [RecordInfo]
+
+  func webRecordChanges(
+    zoneName: String?,
+    syncToken: String?,
     database: MistKit.Database
-  ) async throws -> RecordInfo {
-    try await createRecord(
-      recordType: recordType,
-      fields: fields,
-      database: database
-    )
-  }
+  ) async throws -> RecordChangesResult
 
-  internal func webUpdate(
-    recordType: String,
-    recordName: String,
-    fields: [String: FieldValue],
-    recordChangeTag: String?,
+  func webModifyZones(
+    create: [String],
+    delete: [String],
+    database: MistKit.Database
+  ) async throws -> [ZoneInfo]
+
+  func webListZones(
+    database: MistKit.Database
+  ) async throws -> [ZoneInfo]
+
+  func webLookupZones(
+    zoneNames: [String],
+    database: MistKit.Database
+  ) async throws -> [ZoneInfo]
+
+  func webZoneChanges(
+    syncToken: String?,
+    database: MistKit.Database
+  ) async throws -> ZoneChangesResult
+
+  func webFetchCaller() async throws -> UserInfo
+
+  func webDiscoverUsers(
+    emails: [String],
+    userRecordNames: [String]
+  ) async throws -> [UserIdentity]
+
+  func webListSubscriptions(
+    database: MistKit.Database
+  ) async throws -> [SubscriptionInfo]
+
+  func webLookupSubscriptions(
+    ids: [String],
     database: MistKit.Database
-  ) async throws -> RecordInfo {
-    try await updateRecord(
-      recordType: recordType,
-      recordName: recordName,
-      fields: fields,
-      recordChangeTag: recordChangeTag,
-      database: database
-    )
-  }
-
-  internal func webDelete(
+  ) async throws -> [SubscriptionInfo]
+
+  func webModifySubscriptions(
+    operations: [SubscriptionOperation],
+    database: MistKit.Database
+  ) async throws -> [SubscriptionInfo]
+
+  func webCreateToken(
+    environment: APNsEnvironment,
+    clientId: String?,
+    database: MistKit.Database
+  ) async throws -> APNsTokenResult
+
+  func webRegisterToken(
+    apnsToken: String,
+    environment: APNsEnvironment,
+    clientId: String?,
+    database: MistKit.Database
+  ) async throws
+
+  func webRereferenceAsset(
+    sourceRecordName: String,
+    assetField: String,
+    targetRecordName: String,
+    targetAssetField: String?,
+    database: MistKit.Database
+  ) async throws -> RecordInfo
+
+  func webUploadAsset(
+    data: Data,
     recordType: String,
-    recordName: String,
-    recordChangeTag: String?,
+    fieldName: String,
+    recordName: String?,
     database: MistKit.Database
-  ) async throws {
-    try await deleteRecord(
-      recordType: recordType,
-      recordName: recordName,
-      recordChangeTag: recordChangeTag,
-      database: database
-    )
-  }
+  ) async throws -> AssetUploadReceipt
 }
+
+// The `CloudKitService: WebBackend` conformance lives in
+// `CloudKitService+WebBackend.swift`.
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift
index 080456a3..bc62a9a0 100644
--- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift
+++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebIndexHTML.swift
@@ -32,26 +32,63 @@
 
   /// Loader for the web command's interactive page served by `WebServer`.
   ///
-  /// The HTML+JS lives in `Resources/index.html` and is read from
-  /// `Bundle.module` on first access. The mode toggle in this page lets
-  /// users compare MistKit (server-side) and CloudKit JS (browser-side)
-  /// against the same CloudKit container; the CloudKit JS side is wired
-  /// in by #329.
+  /// The HTML, CSS, and JS modules live under `Resources/` and are read
+  /// from `Bundle.module` on first access, then cached in memory so each
+  /// request serves the same `ByteBuffer`. The mode toggle in `index.html`
+  /// lets users compare MistKit (server-side) and CloudKit JS (browser-
+  /// side) against the same CloudKit container.
   internal enum WebIndexHTML {
     internal static let content: String = loadContent()
+    /// Cached extracted CSS file body served at `GET /styles.css`.
+    internal static let stylesheet: String = loadResource(
+      name: "styles", extension: "css"
+    )
+
+    /// Cached JS module bodies, keyed by the path the browser requests
+    /// (e.g. `"app.js"`). Populated once from the bundled `js/`
+    /// subdirectory; missing files surface as a preconditionFailure on
+    /// boot so a typo'd `