From 80b3391b1517c8795286167ece9919da2aba25d2 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 14:22:28 -0400 Subject: [PATCH 1/5] Add custom/shared zone support to the query path (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `queryRecords` hard-coded `zoneID: .init(zoneName: "_defaultZone")` into the `records/query` request body, which made custom and shared zones unqueryable. Thread an optional `zoneID: ZoneID? = nil` through the query path: - `queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:…)` — the `Query` primitive that builds the request body - both deprecated `queryRecords(recordType:…)` overloads - `queryAllRecords(…)` — forwarded on every page it fetches - `fetchExistingRecordNames(…)` — so a pre-fetch can target the same zone as the `modifyRecords(_:zoneID:)` that follows it Use `ZoneID` rather than the bare `zoneName: String` the issue sketched, so shared zones can carry `ownerName`. This also matches the existing `modifyRecords(_:atomic:zoneID:…)` parameter. `nil` omits the `zoneID` key entirely and lets CloudKit resolve the database's default zone. That is not a silent policy default in the sense of `.claude/memory/feedback_no_silent_policy_defaults.md` — no credential or attribution semantics ride on it — and it keeps the change source-compatible. `database:` still has no default. `RecordManaging`'s query methods stay zone-unaware: that protocol is a deprecated, database-agnostic abstraction. No OpenAPI change was needed — `Components.Schemas.ZoneID` already models `zoneName` + `ownerName`, and `records/query` already references it. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 6 + .../CloudKitService+Classification.swift | 6 + .../CloudKitService+Operations.swift | 9 +- .../CloudKitService+QueryPagination.swift | 5 + .../AbstractionLayerArchitecture.md | 2 +- .../CloudKitLimitsAndPerformance.md | 6 +- .../GeneratedCodeAnalysis.md | 2 +- .../Documentation.docc/HandlingErrors.md | 2 +- .../Documentation.docc/WorkingWithRecords.md | 36 +++- .../CloudKitServiceTests.Query+ZoneID.swift | 179 ++++++++++++++++++ 10 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift diff --git a/AGENTS.md b/AGENTS.md index 1fbf5c1a..b1364db6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,12 @@ MistKit/ | `CloudKitService+Classification.swift` | operation classification (batch sync result tracking) | | `CloudKitService+ErrorHandling.swift` | error mapping helpers | +**Zone selection on queries (issue #146):** `queryRecords` previously hard-coded `zoneID: .init(zoneName: "_defaultZone")` into the `records/query` body, making custom and shared zones unqueryable. The query path now takes an optional `zoneID: ZoneID? = nil`, threaded through `queryRecords` (both the `Query` primitive and the deprecated `recordType:` overloads), `queryAllRecords` (forwarded on every page), and `fetchExistingRecordNames`. This mirrors the existing `modifyRecords(_:atomic:zoneID:…)` parameter, and uses `ZoneID` rather than a bare `zoneName` string so shared zones can carry `ownerName`. + +`nil` means **omit the `zoneID` key entirely** and let CloudKit resolve the database's default zone — it is not a silent policy default in the sense of `.claude/memory/feedback_no_silent_policy_defaults.md` (no credential or attribution semantics ride on it), and it matches the `zoneID`/`desiredKeys`/`numbersAsStrings` request-option convention already used by `modifyRecords`. The `database:` parameter deliberately still has **no** default. + +`RecordManaging`'s `queryRecords(recordType:)` / `queryAllRecords(recordType:)` are intentionally **not** zone-aware — that protocol is a deprecated, database-agnostic abstraction; callers needing a specific zone should call `CloudKitService` directly. + **Sync/Change Operations:** - `fetchRecordChanges(recordType:syncToken:)` → `/records/changes` — returns `RecordChangesResult` with `records`, `syncToken`, `moreComing` - `fetchAllRecordChanges(recordType:syncToken:)` — convenience wrapper that auto-paginates using `moreComing` diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift index edc06754..3ddda516 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Classification.swift @@ -59,17 +59,23 @@ extension CloudKitService { /// - recordType: The CloudKit record type to scan. /// - limit: Optional maximum number of records to fetch (1-200). Defaults /// to CloudKit's per-request maximum. + /// - zoneID: Optional zone to scan. When `nil` (the default) CloudKit + /// resolves the database's default zone. Pass the same ``ZoneID`` you + /// intend to hand `modifyRecords(_:zoneID:)` so the pre-fetch and the + /// modify target the same zone. /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`). /// - Returns: Set of existing record names. /// - Throws: `CloudKitError` if the underlying query fails. public func fetchExistingRecordNames( recordType: String, limit: Int? = nil, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> Set { let result: QueryResult = try await queryRecords( Query(recordType: recordType), limit: limit ?? Self.maxRecordsPerRequest, + zoneID: zoneID, database: database ) return Set(result.records.map(\.recordName)) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift index 54e98943..884fac2b 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+Operations.swift @@ -40,7 +40,7 @@ internal import OpenAPIRuntime #endif extension CloudKitService { - /// Query records from the default zone with pagination support. + /// Query records from a CloudKit zone with pagination support. /// /// The unified ``Query`` value carries the `recordType` plus any /// ``QueryFilter`` predicates and ``QuerySort`` descriptors. The same @@ -53,6 +53,10 @@ extension CloudKitService { /// - desiredKeys: Optional list of field names to fetch. /// - continuationMarker: Marker from a previous ``QueryResult`` to /// fetch the next page. + /// - zoneID: Optional zone to query. When `nil` (the default) the `zoneID` + /// key is omitted from the request and CloudKit resolves the database's + /// default zone (`_defaultZone`). Pass a ``ZoneID`` to target a custom + /// zone, including a shared zone via ``ZoneID/ownerName``. /// - zoneWide: When true, query across all zones rather than a single zone. /// - numbersAsStrings: When true, numeric field values are returned as strings (avoids /// JavaScript precision loss for `INT64`). @@ -65,6 +69,7 @@ extension CloudKitService { limit: Int? = nil, desiredKeys: [String]? = nil, continuationMarker: String? = nil, + zoneID: ZoneID? = nil, zoneWide: Bool? = nil, numbersAsStrings: Bool? = nil, database: Database @@ -82,7 +87,7 @@ extension CloudKitService { ), body: .json( .init( - zoneID: .init(zoneName: "_defaultZone"), + zoneID: zoneID.map { Components.Schemas.ZoneID(from: $0) }, resultsLimit: effectiveLimit, query: query.schema, desiredKeys: desiredKeys, diff --git a/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift index 551bdcdf..96d9a8f6 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+QueryPagination.swift @@ -45,6 +45,9 @@ extension CloudKitService { /// - desiredKeys: Optional array of field names to fetch /// - maxPages: Maximum number of pages to fetch before throwing /// `CloudKitError.paginationLimitExceeded` (defaults to 1,000) + /// - zoneID: Optional zone to query. When `nil` (the default) CloudKit + /// resolves the database's default zone (`_defaultZone`). Pass a + /// ``ZoneID`` to target a custom or shared zone. /// - database: The CloudKit database scope to query (`.public`, `.private`, `.shared`) /// - Returns: Array of all matching records across all pages /// - Throws: `CloudKitError`. When `maxPages` is exceeded, throws @@ -62,6 +65,7 @@ extension CloudKitService { pageSize: Int? = nil, desiredKeys: [String]? = nil, maxPages: Int = 1_000, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> [RecordInfo] { var allRecords: [RecordInfo] = [] @@ -87,6 +91,7 @@ extension CloudKitService { limit: pageSize, desiredKeys: desiredKeys, continuationMarker: currentMarker, + zoneID: zoneID, database: database ) diff --git a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md index 04b429fd..0d756cb5 100644 --- a/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md +++ b/Sources/MistKit/Documentation.docc/AbstractionLayerArchitecture.md @@ -222,7 +222,7 @@ public struct QueryResult: Codable, Sendable { Two iteration helpers cover the common cases: -- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` — single page. +- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` — single page. - `queryAllRecords(...)` — auto-pagination with an enforced maximum, surfacing ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` with the already-fetched records when the cap is reached. Sync endpoints follow the same shape: ``RecordChangesResult`` and ``ZoneChangesResult`` carry `syncToken` and `moreComing`. `fetchAllRecordChanges(recordType:syncToken:)` walks the cursor automatically. diff --git a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md index 09fa33e6..aa96e372 100644 --- a/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md +++ b/Sources/MistKit/Documentation.docc/CloudKitLimitsAndPerformance.md @@ -9,14 +9,14 @@ CloudKit Web Services is a remote API with per-request size limits and per-accou | Concern | Enforced where | Notes | | --- | --- | --- | | Records per query response | CloudKit | Max 200; the `limit` parameter is validated 1–200. | -| Pages per auto-paginated query | MistKit | `maxPages: 1_000` on ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)``. | +| Pages per auto-paginated query | MistKit | `maxPages: 1_000` on ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)``. | | Records per modify batch | CloudKit | Practical cap around 200; chunk larger batches client-side. | | Asset upload size / connection pool | MistKit (transport separation) | `URLSession.shared` used for CDN uploads to avoid HTTP/2 reuse with the API host. | | Requests per second | CloudKit | Server-side rate limit; surfaces as 503/429. | ## Pagination guard -``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks CloudKit's `continuationMarker` for you. Two safeguards prevent runaway iteration: +``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` walks CloudKit's `continuationMarker` for you. Two safeguards prevent runaway iteration: 1. **`maxPages` cap (default `1_000`)** — if the auto-paginator hits the cap, it throws ``CloudKitError/paginationLimitExceeded(maxPages:records:)``. The records collected so far are attached to the error so the caller can resume from a narrowed query or accept a partial result. 2. **Stuck-marker detection** — if CloudKit returns an empty page with the same continuation marker it just gave you, the paginator stops cleanly rather than spinning. This guards against a server-side bug pattern where the cursor never advances. @@ -113,7 +113,7 @@ For custom transports, prefer one transport per `CloudKitService` and reuse the ### Limits - ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` -- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` +- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` - ``CloudKitService/modifyRecords(_:atomic:database:)`` ### Asset uploads diff --git a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md index 338c7a3f..62a00cec 100644 --- a/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md +++ b/Sources/MistKit/Documentation.docc/GeneratedCodeAnalysis.md @@ -365,7 +365,7 @@ case .undocumented(let code, _): } ``` -That's correct but tedious for every call site. ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` collapses it to one async call returning ``QueryResult``. The generated layer still does the type-safe HTTP work; the wrapper handles the call-site ergonomics, error mapping, and conversion between generated and domain types. +That's correct but tedious for every call site. ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` collapses it to one async call returning ``QueryResult``. The generated layer still does the type-safe HTTP work; the wrapper handles the call-site ergonomics, error mapping, and conversion between generated and domain types. ## Integration with the wrapper diff --git a/Sources/MistKit/Documentation.docc/HandlingErrors.md b/Sources/MistKit/Documentation.docc/HandlingErrors.md index 85d83ace..d0396665 100644 --- a/Sources/MistKit/Documentation.docc/HandlingErrors.md +++ b/Sources/MistKit/Documentation.docc/HandlingErrors.md @@ -164,7 +164,7 @@ logging; use the cases themselves for control flow. ### `paginationLimitExceeded` carries partial results -``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks the continuation marker for you and stops at `maxPages` (default `1_000`) as a runaway guard. When it trips, the records collected so far are attached to the error so the caller can decide: +``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` walks the continuation marker for you and stops at `maxPages` (default `1_000`) as a runaway guard. When it trips, the records collected so far are attached to the error so the caller can decide: ```swift do { diff --git a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md index 603806d5..c6211dd5 100644 --- a/Sources/MistKit/Documentation.docc/WorkingWithRecords.md +++ b/Sources/MistKit/Documentation.docc/WorkingWithRecords.md @@ -8,7 +8,7 @@ CRUD, batch, and lookup against CloudKit records — the operations you'll reach ## Querying -Use ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` for a single page of results. Filters are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: +Use ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` for a single page of results. Filters are built with ``QueryFilter`` factories, sorts with ``QuerySort/ascending(_:)`` / ``QuerySort/descending(_:)``: ```swift let result = try await service.queryRecords( @@ -28,7 +28,7 @@ for record in result.records { } ``` -For unbounded iteration, ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` walks the continuation marker for you with a safety guard at `maxPages` (default `1_000`): +For unbounded iteration, ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` walks the continuation marker for you with a safety guard at `maxPages` (default `1_000`): ```swift let allArticles = try await service.queryAllRecords( @@ -40,6 +40,34 @@ let allArticles = try await service.queryAllRecords( > Warning: If `queryAllRecords` hits its page cap, it throws ``CloudKitError/paginationLimitExceeded(maxPages:records:)`` with the records collected so far. See for the recovery pattern. +### Querying a custom or shared zone + +Both query methods accept an optional `zoneID`. When you omit it, the `zoneID` key is left out of the request entirely and CloudKit resolves the database's default zone (`_defaultZone`) — which is the only zone the public database has. + +To read from a custom zone in the private database, pass a ``ZoneID``: + +```swift +let notes = try await service.queryAllRecords( + recordType: "Note", + zoneID: ZoneID(zoneName: "NotesZone"), + database: .private +) +``` + +A shared zone additionally needs the owner's record name, because the zone lives in *their* database: + +```swift +let shared = try await service.queryRecords( + Query(recordType: "Note"), + zoneID: ZoneID(zoneName: "NotesZone", ownerName: "_abc123…"), + database: .shared +) +``` + +Use ``CloudKitService/listZones(database:)`` to discover which zones a database has, and ``ZoneID/defaultZone`` when you want to name the default zone explicitly. + +> Note: `zoneID` and `zoneWide` pull in opposite directions — `zoneWide: true` queries across *every* zone in the database, which makes `zoneID` moot. `zoneWide` is only valid against the private and shared databases. + ## Creating Use ``CloudKitService/createRecord(recordType:recordName:fields:database:)`` for a single create. Fields are a `[String: FieldValue]` dictionary — every CloudKit scalar plus references, locations, assets, and lists are modeled in ``FieldValue``: @@ -175,8 +203,8 @@ The inline DocC on these methods carries fuller examples for initial-vs-incremen ### Read operations -- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneWide:numbersAsStrings:database:)`` -- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:database:)`` +- ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` +- ``CloudKitService/queryAllRecords(recordType:filters:sortBy:pageSize:desiredKeys:maxPages:zoneID:database:)`` - ``CloudKitService/lookupRecords(recordNames:desiredKeys:database:)`` ### Write operations diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift new file mode 100644 index 00000000..bc24f40a --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift @@ -0,0 +1,179 @@ +// +// CloudKitServiceTests.Query+ZoneID.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Query { + /// Coverage for issue #146: `queryRecords` hard-coded `_defaultZone`, making + /// custom and shared zones unqueryable. The `zoneID` parameter must serialize + /// into the `records/query` request body — including a shared zone's + /// `ownerName` — and must be omitted entirely when the caller passes `nil`. + @Suite("Zone Selection") + internal struct ZoneIDSelection { + private static let database: Database = .public(.prefers(.serverToServer)) + + /// Builds a service whose transport records request bodies for inspection. + private static func makeService( + _ provider: ResponseProvider + ) throws -> CloudKitService { + try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: MockTransport(responseProvider: provider) + ) + } + + /// The `index`-th request body sent for `operationID`, decoded as a JSON object. + private static func sentBody( + for operationID: String, + from provider: ResponseProvider, + at index: Int = 0 + ) async throws -> [String: Any] { + let bodies = await provider.bodies(for: operationID) + let data = try #require(bodies.compactMap { $0 }.dropFirst(index).first) + return try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + @Test("queryRecords() omits zoneID when none is supplied (default-zone behavior)") + internal func queryOmitsZoneIDByDefault() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + // No `zoneID` key at all — CloudKit resolves `_defaultZone` server-side. + #expect(body["zoneID"] == nil) + } + + @Test("queryRecords() forwards a custom zone name into the request body") + internal func queryForwardsCustomZoneName() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: ZoneID(zoneName: "CustomZone"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "CustomZone") + #expect(zoneID["ownerName"] == nil) + } + + @Test("queryRecords() forwards a shared zone's ownerName") + internal func queryForwardsSharedZoneOwnerName() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: ZoneID(zoneName: "SharedZone", ownerName: "_owner-record-name"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "SharedZone") + #expect(zoneID["ownerName"] as? String == "_owner-record-name") + } + + @Test("queryRecords() forwards ZoneID.defaultZone explicitly when asked") + internal func queryForwardsExplicitDefaultZone() async throws { + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: .defaultZone, + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "_defaultZone") + } + + @Test("queryAllRecords() forwards zoneID on every page it fetches") + internal func queryAllRecordsForwardsZoneIDPerPage() async throws { + // Page 1 carries a continuation marker so the paginator issues a second + // request; the queue then falls through to the marker-less default. + let provider = ResponseProvider( + defaultResponse: try .successfulQueryResponse(recordCount: 1) + ) + await provider.enqueue( + try .successfulQueryResponse(recordCount: 1, continuationMarker: "page-2"), + for: "queryRecords" + ) + let service = try Self.makeService(provider) + + _ = try await service.queryAllRecords( + recordType: "TestRecord", + zoneID: ZoneID(zoneName: "CustomZone", ownerName: "_owner-record-name"), + database: Self.database + ) + + let callCount = await provider.callCount(for: "queryRecords") + #expect(callCount == 2) + + for index in 0.. Date: Thu, 20 Aug 2026 18:43:49 -0400 Subject: [PATCH 2/5] Add MistDemo coverage for custom/shared zone queries Wire zoneID and optional zone owner through QueryCommand, the query integration phase, and the web query path so #146 can be exercised live. Co-authored-by: Cursor --- .../MistDemoKit/Commands/QueryCommand.swift | 7 +++++-- .../Configuration/QueryConfig+Parsing.swift | 5 +++++ .../Configuration/QueryConfig.swift | 5 +++++ .../Constants/MistDemoConstants.swift | 2 ++ .../Server/CloudKitService+WebBackend.swift | 6 ++++++ .../MistDemoKit/Server/WebBackend.swift | 2 ++ .../MistDemoKit/Server/WebRequests.swift | 12 +++++++++++ .../MistDemoKit/Server/WebServer+CRUD.swift | 2 ++ .../QueryCommandTests+ZoneConfiguration.swift | 21 +++++++++++++++++++ ...QueryConfigTests+BasicInitialization.swift | 14 +++++++++++++ .../Server/MockBackend+Calls.swift | 2 ++ .../Server/MockBackend+RecordOperations.swift | 4 ++++ 12 files changed, 80 insertions(+), 2 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift index a27a5553..8309dcc5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/QueryCommand.swift @@ -48,6 +48,8 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { OPTIONS: --record-type Record type (default: Note) + --zone Zone to query (default: _defaultZone) + --zone-owner Owner record name for shared zones --filter Filter: field:operator:value --sort Sort (asc/desc) --limit Max records (1-200) @@ -71,8 +73,6 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { let client = try MistKitClientFactory.create(for: config.base) // Build filters - // NOTE: Zone, offset, and continuation marker support require - // enhancements to CloudKitService.queryRecords method (GitHub issues #145, #146) let filters: [QueryFilter] = config.filters.isEmpty ? [] @@ -82,10 +82,13 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting { filters: filters, sortBy: [] ) + let zoneID = ZoneID(zoneName: config.zone, ownerName: config.zoneOwner) let result = try await client.queryRecords( query, limit: config.limit, desiredKeys: config.fields, + continuationMarker: config.continuationMarker, + zoneID: zoneID, zoneWide: config.zoneWide, numbersAsStrings: config.numbersAsStrings, database: config.base.database diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift index 6d51e3c9..3a0fc3f9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift @@ -40,6 +40,7 @@ extension QueryConfig { internal struct ParsedOptions { internal let zone: String + internal let zoneOwner: String? internal let recordType: String internal let filters: [String] internal let sort: (field: String, order: SortOrder)? @@ -54,6 +55,9 @@ extension QueryConfig { forKey: MistDemoConstants.ConfigKeys.zone, default: MistDemoConstants.Defaults.zone ) ?? MistDemoConstants.Defaults.zone + let zoneOwner = configReader.string( + forKey: MistDemoConstants.ConfigKeys.zoneOwner + ) let recordType = configReader.string( forKey: MistDemoConstants.ConfigKeys.recordType, @@ -73,6 +77,7 @@ extension QueryConfig { return ParsedOptions( zone: zone, + zoneOwner: zoneOwner, recordType: recordType, filters: filters, sort: sort, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift index 02080c9a..6dc91beb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift @@ -42,6 +42,8 @@ public struct QueryConfig: Sendable, ConfigurationParseable { public let base: MistDemoConfig /// The CloudKit zone name. public let zone: String + /// The optional zone owner (ownerName for shared zones). + public let zoneOwner: String? /// The CloudKit record type. public let recordType: String /// The filter expressions. @@ -67,6 +69,7 @@ public struct QueryConfig: Sendable, ConfigurationParseable { public init( base: MistDemoConfig, zone: String = "_defaultZone", + zoneOwner: String? = nil, recordType: String = "Note", filters: [String] = [], sort: (field: String, order: SortOrder)? = nil, @@ -80,6 +83,7 @@ public struct QueryConfig: Sendable, ConfigurationParseable { ) { self.base = base self.zone = zone + self.zoneOwner = zoneOwner self.recordType = recordType self.filters = filters self.sort = sort @@ -113,6 +117,7 @@ public struct QueryConfig: Sendable, ConfigurationParseable { self.init( base: baseConfig, zone: parsed.zone, + zoneOwner: parsed.zoneOwner, recordType: parsed.recordType, filters: parsed.filters, sort: parsed.sort, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift index 40905b16..2b8d01bb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift @@ -51,6 +51,8 @@ public enum MistDemoConstants { public static let recordName = "record.name" /// Zone configuration key. public static let zone = "zone" + /// Zone owner configuration key (ownerName for shared zones). + public static let zoneOwner = "zone.owner" /// Limit configuration key. public static let limit = "limit" /// Fields configuration key. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift index 27adf675..ac7a1f7b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -35,16 +35,22 @@ extension CloudKitService: WebBackend { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, + zoneName: String?, + zoneOwner: String?, database: MistKit.Database ) async throws -> [RecordInfo] { let querySorts = sortBy?.map { sort in QuerySort.sort(sort.field, ascending: sort.ascending) } + let zoneID = zoneName.map { + ZoneID(zoneName: $0, ownerName: zoneOwner) + } let result = try await queryRecords( Query(recordType: recordType, sortBy: querySorts ?? []), limit: limit, desiredKeys: nil, continuationMarker: nil, + zoneID: zoneID, database: database ) return result.records diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index b9a2edb9..6105540e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -43,6 +43,8 @@ internal protocol WebBackend: Sendable { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, + zoneName: String?, + zoneOwner: String?, database: MistKit.Database ) async throws -> [RecordInfo] diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 79c3706e..11e01e80 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -58,12 +58,18 @@ internal enum WebRequests { case limit case sortBy case database + case zoneName + case zoneOwner } internal let recordType: String internal let limit: Int? internal let sortBy: [QuerySortField]? internal let database: MistKit.Database + /// Optional zone name for custom/shared zone queries. + internal let zoneName: String? + /// Optional zone owner (ownerName) for shared zones. + internal let zoneOwner: String? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -75,6 +81,12 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zoneName = try container.decodeIfPresent( + String.self, forKey: .zoneName + ) + self.zoneOwner = try container.decodeIfPresent( + String.self, forKey: .zoneOwner + ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift index 1591b9e9..18208e81 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -51,6 +51,8 @@ recordType: body.recordType, limit: body.limit, sortBy: body.sortBy, + zoneName: body.zoneName, + zoneOwner: body.zoneOwner, database: body.database ) return try WebJSON.encoder().encode( diff --git a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift index 8d7518c9..b6b1ddd5 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Commands/QueryCommand/QueryCommandTests+ZoneConfiguration.swift @@ -51,5 +51,26 @@ extension QueryCommandTests { #expect(config.zone == "customZone") } + + @Test("Default zoneOwner is nil") + internal func defaultZoneOwnerIsNil() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig(base: baseConfig) + + #expect(config.zoneOwner == nil) + } + + @Test("Custom zoneOwner is preserved") + internal func customZoneOwnerIsPreserved() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zone: "SharedZone", + zoneOwner: "_abc123" + ) + + #expect(config.zone == "SharedZone") + #expect(config.zoneOwner == "_abc123") + } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift index 8b5c1ee5..1a07d6e2 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/QueryConfig/QueryConfigTests+BasicInitialization.swift @@ -42,6 +42,7 @@ extension QueryConfigTests { let config = QueryConfig(base: baseConfig) #expect(config.zone == "_defaultZone") + #expect(config.zoneOwner == nil) #expect(config.recordType == "Note") #expect(config.filters.isEmpty) #expect(config.sort == nil) @@ -64,6 +65,19 @@ extension QueryConfigTests { #expect(config.recordType == "Note") } + @Test("QueryConfig initializes with custom zone owner") + internal func initializeWithCustomZoneOwner() async throws { + let baseConfig = try await MistDemoConfig() + let config = QueryConfig( + base: baseConfig, + zone: "SharedZone", + zoneOwner: "_ownerRecordName" + ) + + #expect(config.zone == "SharedZone") + #expect(config.zoneOwner == "_ownerRecordName") + } + @Test("QueryConfig initializes with custom record type") internal func initializeWithCustomRecordType() async throws { let baseConfig = try await MistDemoConfig() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index f7b01fdc..3baba4b0 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -39,6 +39,8 @@ internal let recordType: String internal let limit: Int? internal let sortBy: [WebRequests.QuerySortField]? + internal let zoneName: String? + internal let zoneOwner: String? internal let database: MistKit.Database } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift index aa2c3a4c..62b908c1 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift @@ -37,12 +37,16 @@ recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, + zoneName: String?, + zoneOwner: String?, database: MistKit.Database ) async throws -> [RecordInfo] { lastQuery = QueryCall( recordType: recordType, limit: limit, sortBy: sortBy, + zoneName: zoneName, + zoneOwner: zoneOwner, database: database ) try consumePendingError() From 04f2f35bb26efc1ba648544c236101397447f508 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 27 Aug 2026 20:01:11 -0400 Subject: [PATCH 3/5] Address #426 review and add custom-zone query integration phase Rebase follow-up: reject zoneOwner without zoneName at the web request boundary, update AGENTS.md and DocC links post-#424, and add CustomZoneQueryPhase to test-private for end-to-end zoneID coverage. Co-authored-by: Cursor --- AGENTS.md | 4 +- .../Phases/CustomZoneQueryPhase.swift | 166 ++++++++++++++++++ .../Tests/PrivateDatabaseTest.swift | 1 + .../MistDemoKit/Server/WebRequests.swift | 7 + .../Server/WebServerTests+QueryZone.swift | 82 +++++++++ Sources/MistKit/Models/Queries/Query.swift | 2 +- 6 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift diff --git a/AGENTS.md b/AGENTS.md index b1364db6..c3df38ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,11 +204,11 @@ MistKit/ | `CloudKitService+Classification.swift` | operation classification (batch sync result tracking) | | `CloudKitService+ErrorHandling.swift` | error mapping helpers | -**Zone selection on queries (issue #146):** `queryRecords` previously hard-coded `zoneID: .init(zoneName: "_defaultZone")` into the `records/query` body, making custom and shared zones unqueryable. The query path now takes an optional `zoneID: ZoneID? = nil`, threaded through `queryRecords` (both the `Query` primitive and the deprecated `recordType:` overloads), `queryAllRecords` (forwarded on every page), and `fetchExistingRecordNames`. This mirrors the existing `modifyRecords(_:atomic:zoneID:…)` parameter, and uses `ZoneID` rather than a bare `zoneName` string so shared zones can carry `ownerName`. +**Zone selection on queries (issue #146):** `queryRecords` previously hard-coded `zoneID: .init(zoneName: "_defaultZone")` into the `records/query` body, making custom and shared zones unqueryable. The query path now takes an optional `zoneID: ZoneID? = nil`, threaded through the `Query` primitive, `queryAllRecords` (forwarded on every page), and `fetchExistingRecordNames`. This mirrors the existing `modifyRecords(_:atomic:zoneID:…)` parameter, and uses `ZoneID` rather than a bare `zoneName` string so shared zones can carry `ownerName`. `nil` means **omit the `zoneID` key entirely** and let CloudKit resolve the database's default zone — it is not a silent policy default in the sense of `.claude/memory/feedback_no_silent_policy_defaults.md` (no credential or attribution semantics ride on it), and it matches the `zoneID`/`desiredKeys`/`numbersAsStrings` request-option convention already used by `modifyRecords`. The `database:` parameter deliberately still has **no** default. -`RecordManaging`'s `queryRecords(recordType:)` / `queryAllRecords(recordType:)` are intentionally **not** zone-aware — that protocol is a deprecated, database-agnostic abstraction; callers needing a specific zone should call `CloudKitService` directly. +`RecordManaging` declares only `queryAllRecords(recordType:)` (`Sources/MistKit/RecordManagement/RecordManaging.swift`) and stays zone-unaware — callers needing a specific zone should call `CloudKitService` directly. **Sync/Change Operations:** - `fetchRecordChanges(recordType:syncToken:)` → `/records/changes` — returns `RecordChangesResult` with `records`, `syncToken`, `moreComing` diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift new file mode 100644 index 00000000..478cd2e3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift @@ -0,0 +1,166 @@ +// +// CustomZoneQueryPhase.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 `queryRecords` with an explicit `zoneID` against a live custom +/// zone: create the zone, write records into it, query them back, and verify +/// the default-zone path does not see them. Owns zone teardown because +/// ``CleanupPhase`` issues deletes without a `zoneID`. +internal struct CustomZoneQueryPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Query records in a custom zone" + internal static let emoji = "🗂️" + internal static let apiName = "records/query (zoneID)" + + internal func run( + input: NoState, + context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let zoneName = "MistDemoZoneQuery-\(UUID().uuidString.prefix(8))" + let zoneID = ZoneID(zoneName: zoneName, ownerName: nil) + + _ = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(zoneName)") + } + + let recordName1 = "mistkit-zone-query-\(UUID().uuidString.lowercased())" + let recordName2 = "mistkit-zone-query-\(UUID().uuidString.lowercased())" + let expectedNames = Set([recordName1, recordName2]) + + do { + _ = try await context.service.modifyRecords( + [ + .create( + recordType: MistDemoConfig.recordType, + recordName: recordName1, + fields: [ + "title": .string("Zone query 1"), + "index": .int64(1), + ] + ), + .create( + recordType: MistDemoConfig.recordType, + recordName: recordName2, + fields: [ + "title": .string("Zone query 2"), + "index": .int64(2), + ] + ), + ], + zoneID: zoneID, + database: context.database + ) + + let query = Query(recordType: MistDemoConfig.recordType) + + do { + let result = try await context.service.queryRecords( + query, + zoneID: zoneID, + database: context.database + ) + let foundNames = Set(result.records.map(\.recordName)) + guard expectedNames.isSubset(of: foundNames) else { + try await cleanup(zoneName: zoneName, context: context) + throw IntegrationTestError.verificationFailed( + "zone query did not return both created records" + ) + } + if context.verbose { + print(" ✅ Custom-zone query returned both records") + } + + let defaultResult = try await context.service.queryRecords( + query, + database: context.database + ) + let defaultNames = Set(defaultResult.records.map(\.recordName)) + let leaked = expectedNames.intersection(defaultNames) + guard leaked.isEmpty else { + try await cleanup(zoneName: zoneName, context: context) + throw IntegrationTestError.verificationFailed( + "default-zone query returned custom-zone records: " + + leaked.sorted().joined(separator: ", ") + ) + } + if context.verbose { + print(" ✅ Default-zone query excluded custom-zone records") + } + } catch { + guard case CloudKitError.notFound = error else { + try? await cleanup(zoneName: zoneName, context: context) + throw error + } + print( + "⚠️ queryRecords returned NOT_FOUND — schema may not be indexed yet (non-fatal)" + ) + } + + try await cleanup(zoneName: zoneName, context: context) + print("✅ Queried records in a custom zone") + } catch let error as IntegrationTestError { + try? await cleanup(zoneName: zoneName, context: context) + throw error + } catch { + try? await cleanup(zoneName: zoneName, context: context) + throw IntegrationTestError.verificationFailed( + "custom zone query failed: \(error.localizedDescription)" + ) + } + + return NoState() + } + + private func cleanup( + zoneName: String, + context: PhaseContext + ) async throws { + if context.skipCleanup { + print(" ⏭️ Skipping zone cleanup — inspect zone '\(zoneName)'") + return + } + try await context.service.deleteZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Deleted zone: \(zoneName)") + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index abac15e6..ffc21aca 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -56,6 +56,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { ModifyRecordsPhase(), IncrementalSyncPhase(), QueryRequestOptionsPhase(), + CustomZoneQueryPhase(), ModifyRequestOptionsPhase(), ChangesRequestOptionsPhase(), FinalVerificationPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 11e01e80..9f26597a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -87,6 +87,13 @@ internal enum WebRequests { self.zoneOwner = try container.decodeIfPresent( String.self, forKey: .zoneOwner ) + if self.zoneOwner != nil, self.zoneName == nil { + throw DecodingError.dataCorruptedError( + forKey: .zoneOwner, + in: container, + debugDescription: "zoneOwner requires zoneName" + ) + } } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift new file mode 100644 index 00000000..4e64c6ee --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift @@ -0,0 +1,82 @@ +// +// WebServerTests+QueryZone.swift +// MistDemoTests +// +// 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(Hummingbird) + internal import Foundation + internal import HTTPTypes + internal import Hummingbird + internal import HummingbirdTesting + internal import MistKit + internal import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("POST /api/records/query rejects zoneOwner without zoneName") + internal func queryRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: #"{"recordType":"Note","zoneOwner":"_abc"}"#) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/query forwards zoneName and zoneOwner to the backend") + internal func queryForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/query", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastQuery + #expect(captured?.zoneName == "Articles") + #expect(captured?.zoneOwner == "_abc123") + } + } +#endif diff --git a/Sources/MistKit/Models/Queries/Query.swift b/Sources/MistKit/Models/Queries/Query.swift index 312b3003..bb2064c0 100644 --- a/Sources/MistKit/Models/Queries/Query.swift +++ b/Sources/MistKit/Models/Queries/Query.swift @@ -33,7 +33,7 @@ internal import MistKitOpenAPI /// predicates and ``QuerySort`` descriptors. /// /// The same value can be passed to -/// ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:database:)`` +/// ``CloudKitService/queryRecords(_:limit:desiredKeys:continuationMarker:zoneID:zoneWide:numbersAsStrings:database:)`` /// for a one-off query and embedded in /// ``SubscriptionInfo/Kind/query(_:)`` to describe a query /// subscription's predicate — they share this single representation. From b345f709df6b14cac3fe7cc5e714db6601e5296e Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 27 Aug 2026 20:32:52 -0400 Subject: [PATCH 4/5] Fix MistDemo integration test failures and improve error output. Query the default zone explicitly in CustomZoneQueryPhase isolation checks, skip timestamp verification for records that omit the field, and print copy-friendly failure messages instead of trapping on exit. Co-authored-by: Cursor --- .../MistDemo/Sources/MistDemo/MistDemo.swift | 11 ++++++++-- .../Integration/PhasedIntegrationTest.swift | 22 ++++++++++++++++--- .../Phases/CustomZoneQueryPhase.swift | 3 ++- .../Phases/LookupRecordsPhase.swift | 2 +- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift index 571186d9..0b742524 100644 --- a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift +++ b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift @@ -27,12 +27,19 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import Darwin internal import MistDemoKit @main internal enum MistDemo { @MainActor - internal static func main() async throws { - try await MistDemoRunner.run() + internal static func main() async { + do { + try await MistDemoRunner.run() + } catch { + // PhasedIntegrationTest prints a copy-friendly message before rethrowing; + // exit cleanly instead of trapping on an uncaught top-level throw. + exit(1) + } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift index a35f93b0..8feca476 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhasedIntegrationTest.swift @@ -57,7 +57,8 @@ extension PhasedIntegrationTest { completed.append(index) } } catch { - print("\n\u{274C} Error: \(error)") + let message = Self.failureMessage(for: error) + print("\n\u{274C} Error: \(message)") let cleanupAlreadyRan = phases.enumerated().contains { index, phase in phase is any CleanupPhaseMarker && completed.contains(index) } @@ -74,7 +75,8 @@ extension PhasedIntegrationTest { ) } printSummary( - completed: completed, skipped: skipped, errored: true + completed: completed, skipped: skipped, errored: true, + failureMessage: message ) throw error } @@ -127,7 +129,8 @@ extension PhasedIntegrationTest { } private func printSummary( - completed: [Int], skipped: [Int], errored: Bool + completed: [Int], skipped: [Int], errored: Bool, + failureMessage: String? = nil ) { print("\n" + String(repeating: "=", count: 80)) let header = @@ -164,5 +167,18 @@ extension PhasedIntegrationTest { print(" \u{2022} Run with --verbose for detailed output") let tip = " \u{2022} Use --skip-cleanup to inspect records" print("\(tip) in CloudKit Console") + if let failureMessage { + print("\nFailure message (copy):") + print(failureMessage) + } + } + + private static func failureMessage(for error: any Error) -> String { + if let localized = error as? any LocalizedError, + let description = localized.errorDescription + { + return description + } + return String(describing: error) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift index 478cd2e3..c6f5ca64 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/CustomZoneQueryPhase.swift @@ -32,7 +32,7 @@ internal import MistKit /// Exercises `queryRecords` with an explicit `zoneID` against a live custom /// zone: create the zone, write records into it, query them back, and verify -/// the default-zone path does not see them. Owns zone teardown because +/// ``ZoneID/defaultZone`` does not see them. Owns zone teardown because /// ``CleanupPhase`` issues deletes without a `zoneID`. internal struct CustomZoneQueryPhase: IntegrationPhase { internal typealias Input = NoState @@ -108,6 +108,7 @@ internal struct CustomZoneQueryPhase: IntegrationPhase { let defaultResult = try await context.service.queryRecords( query, + zoneID: .defaultZone, database: context.database ) let defaultNames = Set(defaultResult.records.map(\.recordName)) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift index 281bb9e0..135bc531 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/LookupRecordsPhase.swift @@ -47,7 +47,7 @@ internal struct LookupRecordsPhase: IntegrationPhase { /// tag is honored. Failing loud here catches a regression in that recovery. private static func verifyTimestampRoundTrip(in records: [RecordInfo]) throws { let expected = CreateRecordsPhase.verificationTimestamp.timeIntervalSince1970 - for record in records { + for record in records where record.fields["timestamp"] != nil { guard case .date(let value)? = record.fields["timestamp"] else { throw IntegrationTestError.verificationFailed( "Record \(record.recordName) timestamp did not round-trip as a date" From 1ccb3b1c23941e3fcd14544263513b1434c81d16 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 27 Aug 2026 20:52:01 -0400 Subject: [PATCH 5/5] Fix MistDemo build on non-Apple platforms MistDemo.swift imported Darwin to source exit(1), which broke the executable target on Linux, Windows, Android and WASI with "no such module 'Darwin'". Import Foundation instead, which vends exit on every supported platform and matches the idiom already used by the BushelCloud and CelestraCloud examples. Co-Authored-By: Claude Opus 5 (1M context) --- Examples/MistDemo/Sources/MistDemo/MistDemo.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift index 0b742524..88597b35 100644 --- a/Examples/MistDemo/Sources/MistDemo/MistDemo.swift +++ b/Examples/MistDemo/Sources/MistDemo/MistDemo.swift @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -internal import Darwin +internal import Foundation internal import MistDemoKit @main