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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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` 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`
- `fetchAllRecordChanges(recordType:syncToken:)` — convenience wrapper that auto-paginates using `moreComing`
Expand Down
11 changes: 9 additions & 2 deletions Examples/MistDemo/Sources/MistDemo/MistDemo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,19 @@
// OTHER DEALINGS IN THE SOFTWARE.
//

internal import Foundation
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public struct QueryCommand: MistDemoCommand, OutputFormatting {

OPTIONS:
--record-type <type> Record type (default: Note)
--zone <name> Zone to query (default: _defaultZone)
--zone-owner <owner> Owner record name for shared zones
--filter <filter> Filter: field:operator:value
--sort <field:order> Sort (asc/desc)
--limit <count> Max records (1-200)
Expand All @@ -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
? []
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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,
Expand All @@ -73,6 +77,7 @@ extension QueryConfig {

return ParsedOptions(
zone: zone,
zoneOwner: zoneOwner,
recordType: recordType,
filters: filters,
sort: sort,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -74,7 +75,8 @@ extension PhasedIntegrationTest {
)
}
printSummary(
completed: completed, skipped: skipped, errored: true
completed: completed, skipped: skipped, errored: true,
failureMessage: message
)
throw error
}
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//
// 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
/// ``ZoneID/defaultZone`` 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,
zoneID: .defaultZone,
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)")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest {
ModifyRecordsPhase(),
IncrementalSyncPhase(),
QueryRequestOptionsPhase(),
CustomZoneQueryPhase(),
ModifyRequestOptionsPhase(),
ChangesRequestOptionsPhase(),
FinalVerificationPhase(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading