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
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,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 |
Expand All @@ -210,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<T>` 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/):**
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <list> [options]

INPUT (choose one):
--discover-emails <list> Comma-separated email addresses
--stdin Read one email per line from stdin

OPTIONS:
--batch-size <n> Items per request (default 200, clamped 1...200).
Set small (e.g. 1) to force multiple requests.
--output-format <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)
}
}
Original file line number Diff line number Diff line change
@@ -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 <names> [options]

REQUIRED:
--api-token <token> CloudKit API token
--web-auth-token <token> Web authentication token
--record-names <names> Comma-separated record names

OPTIONS:
--fields <field1,field2,...> Restrict returned fields
--batch-size <n> Items per request (default 200, clamped 1...200).
Set small (e.g. 1) to force multiple requests.
--output-format <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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

public import ConfigKeyKit
internal import Foundation
public import MistKit

/// Configuration for the `discover` command (email lookup).
public struct DiscoverConfig: Sendable, ConfigurationParseable {
Expand All @@ -41,17 +42,22 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable {
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
}

Expand Down Expand Up @@ -79,9 +85,16 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable {
) ?? 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
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

public import ConfigKeyKit
internal import Foundation
public import MistKit

/// Configuration for lookup command.
public struct LookupConfig: Sendable, ConfigurationParseable {
Expand All @@ -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

Expand All @@ -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
}

Expand Down Expand Up @@ -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
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public enum MistDemoRunner {
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)
Expand Down
Loading
Loading